1package eu.siacs.conversations.http;
2
3import android.util.Log;
4
5import androidx.annotation.Nullable;
6
7import com.google.common.base.Strings;
8import com.google.common.io.ByteStreams;
9import com.google.common.primitives.Longs;
10
11import java.io.FileInputStream;
12import java.io.IOException;
13import java.io.InputStream;
14import java.io.OutputStream;
15import java.util.Locale;
16
17import javax.net.ssl.SSLHandshakeException;
18
19import eu.siacs.conversations.Config;
20import eu.siacs.conversations.R;
21import eu.siacs.conversations.entities.DownloadableFile;
22import eu.siacs.conversations.entities.Message;
23import eu.siacs.conversations.entities.Transferable;
24import eu.siacs.conversations.persistance.FileBackend;
25import eu.siacs.conversations.services.AbstractConnectionManager;
26import eu.siacs.conversations.services.XmppConnectionService;
27import eu.siacs.conversations.utils.CryptoHelper;
28import eu.siacs.conversations.utils.FileWriterException;
29import eu.siacs.conversations.utils.MimeUtils;
30import okhttp3.Call;
31import okhttp3.HttpUrl;
32import okhttp3.OkHttpClient;
33import okhttp3.Request;
34import okhttp3.Response;
35
36import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
37
38public class HttpDownloadConnection implements Transferable {
39
40 private final Message message;
41 private final HttpConnectionManager mHttpConnectionManager;
42 private final XmppConnectionService mXmppConnectionService;
43 private HttpUrl mUrl;
44 private DownloadableFile file;
45 private int mStatus = Transferable.STATUS_UNKNOWN;
46 private boolean acceptedAutomatically = false;
47 private int mProgress = 0;
48 private Call mostRecentCall;
49
50 HttpDownloadConnection(Message message, HttpConnectionManager manager) {
51 this.message = message;
52 this.mHttpConnectionManager = manager;
53 this.mXmppConnectionService = manager.getXmppConnectionService();
54 }
55
56 @Override
57 public boolean start() {
58 if (mXmppConnectionService.hasInternetConnection()) {
59 if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
60 checkFileSize(true);
61 } else {
62 download(true);
63 }
64 return true;
65 } else {
66 return false;
67 }
68 }
69
70 public void init(boolean interactive) {
71 if (message.isDeleted()) {
72 if (message.getType() == Message.TYPE_PRIVATE_FILE) {
73 message.setType(Message.TYPE_PRIVATE);
74 } else if (message.isFileOrImage()) {
75 message.setType(Message.TYPE_TEXT);
76 }
77 message.setOob(true);
78 message.setDeleted(false);
79 mXmppConnectionService.updateMessage(message);
80 }
81 this.message.setTransferable(this);
82 try {
83 final Message.FileParams fileParams = message.getFileParams();
84 if (message.hasFileOnRemoteHost()) {
85 mUrl = AesGcmURL.of(fileParams.url);
86 } else if (message.isOOb() && fileParams.url != null && fileParams.size != null) {
87 mUrl = AesGcmURL.of(fileParams.url);
88 } else {
89 mUrl = AesGcmURL.of(message.getBody().split("\n")[0]);
90 }
91 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
92 if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
93 this.message.setEncryption(Message.ENCRYPTION_PGP);
94 } else if (message.getEncryption() != Message.ENCRYPTION_OTR
95 && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
96 this.message.setEncryption(Message.ENCRYPTION_NONE);
97 }
98 final String ext = extension.getExtension();
99 final String filename = Strings.isNullOrEmpty(ext) ? message.getUuid() : String.format("%s.%s", message.getUuid(), ext);
100 mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
101 setupFile();
102 if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
103 this.message.setEncryption(Message.ENCRYPTION_NONE);
104 }
105 final Long knownFileSize;
106 if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
107 knownFileSize = null;
108 } else {
109 knownFileSize = message.getFileParams().size;
110 }
111 Log.d(Config.LOGTAG,"knownFileSize: "+knownFileSize+", body="+message.getBody());
112 if (knownFileSize != null && interactive) {
113 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL
114 && this.file.getKey() != null) {
115 this.file.setExpectedSize(knownFileSize + 16);
116 } else {
117 this.file.setExpectedSize(knownFileSize);
118 }
119 download(true);
120 } else {
121 checkFileSize(interactive);
122 }
123 } catch (final IllegalArgumentException e) {
124 this.cancel();
125 }
126 }
127
128 private void setupFile() {
129 final String reference = mUrl.fragment();
130 if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
131 this.file = new DownloadableFile(mXmppConnectionService.getCacheDir(), message.getUuid());
132 this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
133 Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
134 } else {
135 this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
136 }
137 }
138
139 private void download(final boolean interactive) {
140 EXECUTOR.execute(new FileDownloader(interactive));
141 }
142
143 private void checkFileSize(final boolean interactive) {
144 EXECUTOR.execute(new FileSizeChecker(interactive));
145 }
146
147 @Override
148 public void cancel() {
149 final Call call = this.mostRecentCall;
150 if (call != null && !call.isCanceled()) {
151 call.cancel();
152 }
153 mHttpConnectionManager.finishConnection(this);
154 message.setTransferable(null);
155 if (message.isFileOrImage()) {
156 message.setDeleted(true);
157 }
158 mHttpConnectionManager.updateConversationUi(true);
159 }
160
161 private void decryptFile() throws IOException {
162 final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
163
164 if (outputFile.getParentFile().mkdirs()) {
165 Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
166 }
167
168 if (!outputFile.createNewFile()) {
169 Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
170 }
171
172 final InputStream is = new FileInputStream(this.file);
173
174 outputFile.setKey(this.file.getKey());
175 outputFile.setIv(this.file.getIv());
176 final OutputStream os = AbstractConnectionManager.createOutputStream(outputFile, false, true);
177
178 ByteStreams.copy(is, os);
179
180 FileBackend.close(is);
181 FileBackend.close(os);
182
183 if (!file.delete()) {
184 Log.w(Config.LOGTAG, "unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath());
185 }
186 }
187
188 private void finish() {
189 message.setTransferable(null);
190 mHttpConnectionManager.finishConnection(this);
191 boolean notify = acceptedAutomatically && !message.isRead();
192 if (message.getEncryption() == Message.ENCRYPTION_PGP) {
193 notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
194 }
195 mHttpConnectionManager.updateConversationUi(true);
196 final boolean notifyAfterScan = notify;
197 final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
198 mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
199 if (notifyAfterScan) {
200 mXmppConnectionService.getNotificationService().push(message);
201 }
202 });
203 }
204
205 private void decryptIfNeeded() throws IOException {
206 if (file.getKey() != null && file.getIv() != null) {
207 decryptFile();
208 }
209 }
210
211 private void changeStatus(int status) {
212 this.mStatus = status;
213 mHttpConnectionManager.updateConversationUi(true);
214 }
215
216 private void showToastForException(final Exception e) {
217 final Call call = mostRecentCall;
218 final boolean cancelled = call != null && call.isCanceled();
219 if (e == null || cancelled) {
220 return;
221 }
222 if (e instanceof java.net.UnknownHostException) {
223 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
224 } else if (e instanceof java.net.ConnectException) {
225 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
226 } else if (e instanceof FileWriterException) {
227 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
228 } else if (e instanceof InvalidFileException) {
229 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_invalid_file);
230 } else {
231 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
232 }
233 }
234
235 private void updateProgress(long i) {
236 this.mProgress = (int) i;
237 mHttpConnectionManager.updateConversationUi(false);
238 }
239
240 @Override
241 public int getStatus() {
242 return this.mStatus;
243 }
244
245 @Override
246 public Long getFileSize() {
247 if (this.file != null) {
248 return this.file.getExpectedSize();
249 } else {
250 return null;
251 }
252 }
253
254 @Override
255 public int getProgress() {
256 return this.mProgress;
257 }
258
259 public Message getMessage() {
260 return message;
261 }
262
263 private class FileSizeChecker implements Runnable {
264
265 private final boolean interactive;
266
267 FileSizeChecker(boolean interactive) {
268 this.interactive = interactive;
269 }
270
271
272 @Override
273 public void run() {
274 check();
275 }
276
277 private void retrieveFailed(@Nullable final Exception e) {
278 changeStatus(STATUS_OFFER_CHECK_FILESIZE);
279 if (interactive) {
280 showToastForException(e);
281 } else {
282 HttpDownloadConnection.this.acceptedAutomatically = false;
283 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
284 }
285 cancel();
286 }
287
288 private void check() {
289 long size;
290 try {
291 size = retrieveFileSize();
292 } catch (final Exception e) {
293 Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
294 retrieveFailed(e);
295 return;
296 }
297 final Message.FileParams fileParams = message.getFileParams();
298 FileBackend.updateFileParams(message, fileParams.url, size);
299 message.setOob(true);
300 mXmppConnectionService.databaseBackend.updateMessage(message, true);
301 file.setExpectedSize(size);
302 message.resetFileParams();
303 if (mHttpConnectionManager.hasStoragePermission()
304 && size <= mHttpConnectionManager.getAutoAcceptFileSize()
305 && mXmppConnectionService.isDataSaverDisabled()) {
306 HttpDownloadConnection.this.acceptedAutomatically = true;
307 download(interactive);
308 } else {
309 changeStatus(STATUS_OFFER);
310 HttpDownloadConnection.this.acceptedAutomatically = false;
311 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
312 }
313 }
314
315 private long retrieveFileSize() throws IOException {
316 Log.d(Config.LOGTAG, "retrieve file size. interactive:" + interactive);
317 changeStatus(STATUS_CHECKING);
318 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
319 mUrl,
320 message.getConversation().getAccount(),
321 interactive
322 );
323 final Request request = new Request.Builder()
324 .url(URL.stripFragment(mUrl))
325 .addHeader("Accept-Encoding", "identity")
326 .head()
327 .build();
328 mostRecentCall = client.newCall(request);
329 try {
330 final Response response = mostRecentCall.execute();
331 throwOnInvalidCode(response);
332 final String contentLength = response.header("Content-Length");
333 final String contentType = response.header("Content-Type");
334 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
335 if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
336 final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
337 if (fileExtension != null) {
338 mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), fileExtension), contentType);
339 Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
340 setupFile();
341 }
342 }
343 if (Strings.isNullOrEmpty(contentLength)) {
344 throw new IOException("no content-length found in HEAD response");
345 }
346 final long size = Long.parseLong(contentLength, 10);
347 if (size < 0) {
348 throw new IOException("Server reported negative file size");
349 }
350 return size;
351 } catch (final IOException e) {
352 Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
353 throw e;
354 } catch (final NumberFormatException e) {
355 throw new IOException(e);
356 }
357 }
358
359 }
360
361 private class FileDownloader implements Runnable {
362
363 private final boolean interactive;
364
365 public FileDownloader(boolean interactive) {
366 this.interactive = interactive;
367 }
368
369 @Override
370 public void run() {
371 try {
372 changeStatus(STATUS_DOWNLOADING);
373 download();
374 decryptIfNeeded();
375 updateImageBounds();
376 finish();
377 } catch (final SSLHandshakeException e) {
378 changeStatus(STATUS_OFFER);
379 } catch (final Exception e) {
380 Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": unable to download file", e);
381 if (interactive) {
382 showToastForException(e);
383 } else {
384 HttpDownloadConnection.this.acceptedAutomatically = false;
385 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
386 }
387 cancel();
388 }
389 }
390
391 private void download() throws Exception {
392 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
393 mUrl,
394 message.getConversation().getAccount(),
395 interactive
396 );
397
398 final Request.Builder requestBuilder = new Request.Builder().url(URL.stripFragment(mUrl));
399
400 final long expected = file.getExpectedSize();
401 final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
402 final long resumeSize;
403 if (tryResume) {
404 resumeSize = file.getSize();
405 Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
406 requestBuilder.addHeader("Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
407 } else {
408 resumeSize = 0;
409 }
410 final Request request = requestBuilder.build();
411 mostRecentCall = client.newCall(request);
412 final Response response = mostRecentCall.execute();
413 throwOnInvalidCode(response);
414 final String contentRange = response.header("Content-Range");
415 final boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
416 final InputStream inputStream = response.body().byteStream();
417 final OutputStream outputStream;
418 long transmitted = 0;
419 if (tryResume && serverResumed) {
420 Log.d(Config.LOGTAG, "server resumed");
421 transmitted = file.getSize();
422 updateProgress(Math.round(((double) transmitted / expected) * 100));
423 outputStream = AbstractConnectionManager.createOutputStream(file, true, false);
424 } else {
425 final String contentLength = response.header("Content-Length");
426 final long size = Strings.isNullOrEmpty(contentLength) ? 0 : Longs.tryParse(contentLength);
427 if (expected != size) {
428 Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
429 }
430 file.getParentFile().mkdirs();
431 Log.d(Config.LOGTAG,"creating file: "+file.getAbsolutePath());
432 if (!file.exists() && !file.createNewFile()) {
433 throw new FileWriterException(file);
434 }
435 outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
436 }
437 int count;
438 final byte[] buffer = new byte[4096];
439 while ((count = inputStream.read(buffer)) != -1) {
440 transmitted += count;
441 try {
442 outputStream.write(buffer, 0, count);
443 } catch (final IOException e) {
444 throw new FileWriterException(file);
445 }
446 if (transmitted > expected) {
447 throw new InvalidFileException(String.format("File exceeds expected size of %d", expected));
448 }
449 updateProgress(Math.round(((double) transmitted / expected) * 100));
450 }
451 outputStream.flush();
452 }
453
454 private void updateImageBounds() {
455 final boolean privateMessage = message.isPrivateMessage();
456 message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
457 final String url;
458 final String ref = mUrl.fragment();
459 if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
460 url = AesGcmURL.toAesGcmUrl(mUrl);
461 } else {
462 url = mUrl.toString();
463 }
464 mXmppConnectionService.getFileBackend().updateFileParams(message, url);
465 mXmppConnectionService.updateMessage(message);
466 }
467
468 }
469
470 private static void throwOnInvalidCode(final Response response) throws IOException {
471 final int code = response.code();
472 if (code < 200 || code >= 300) {
473 throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
474 }
475 }
476
477 private static class InvalidFileException extends IOException {
478
479 private InvalidFileException(final String message) {
480 super(message);
481 }
482
483 }
484}