1package eu.siacs.conversations.http;
2
3import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
4
5import android.util.Log;
6import androidx.annotation.Nullable;
7import androidx.core.util.Consumer;
8
9import com.google.common.base.Strings;
10import com.google.common.io.ByteStreams;
11import com.google.common.primitives.Longs;
12import eu.siacs.conversations.Config;
13import eu.siacs.conversations.R;
14import eu.siacs.conversations.entities.DownloadableFile;
15import eu.siacs.conversations.entities.Message;
16import eu.siacs.conversations.entities.Transferable;
17import eu.siacs.conversations.persistance.FileBackend;
18import eu.siacs.conversations.services.AbstractConnectionManager;
19import eu.siacs.conversations.services.XmppConnectionService;
20import eu.siacs.conversations.utils.CryptoHelper;
21import eu.siacs.conversations.utils.FileWriterException;
22import eu.siacs.conversations.utils.MimeUtils;
23import java.io.FileInputStream;
24import java.io.FileOutputStream;
25import java.io.IOException;
26import java.io.InputStream;
27import java.io.OutputStream;
28import java.util.Locale;
29import javax.net.ssl.SSLHandshakeException;
30import okhttp3.Call;
31import okhttp3.HttpUrl;
32import okhttp3.OkHttpClient;
33import okhttp3.Request;
34import okhttp3.Response;
35import org.bouncycastle.crypto.engines.AESEngine;
36import org.bouncycastle.crypto.io.CipherOutputStream;
37import org.bouncycastle.crypto.modes.GCMBlockCipher;
38import org.bouncycastle.crypto.params.AEADParameters;
39import org.bouncycastle.crypto.params.KeyParameter;
40
41public class HttpDownloadConnection implements Transferable {
42
43 private final Message message;
44 private final HttpConnectionManager mHttpConnectionManager;
45 private final XmppConnectionService mXmppConnectionService;
46 private HttpUrl mUrl;
47 private DownloadableFile file;
48 private int mStatus = Transferable.STATUS_UNKNOWN;
49 private boolean acceptedAutomatically = false;
50 private int mProgress = 0;
51 private Call mostRecentCall;
52 final private Consumer<DownloadableFile> cb;
53
54 HttpDownloadConnection(Message message, HttpConnectionManager manager, Consumer<DownloadableFile> cb) {
55 this.message = message;
56 this.mHttpConnectionManager = manager;
57 this.mXmppConnectionService = manager.getXmppConnectionService();
58 this.cb = cb;
59 }
60
61 @Override
62 public boolean start() {
63 if (mXmppConnectionService.hasInternetConnection()) {
64 if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
65 checkFileSize(true);
66 } else {
67 download(true);
68 }
69 return true;
70 } else {
71 return false;
72 }
73 }
74
75 public void init(boolean interactive) {
76 final Message.FileParams fileParams = message.getFileParams();
77 if (message.isDeleted()) {
78 if (message.getType() == Message.TYPE_PRIVATE_FILE) {
79 message.setType(Message.TYPE_PRIVATE);
80 } else if (message.isFileOrImage()) {
81 message.setType(Message.TYPE_TEXT);
82 }
83 message.setDeleted(false);
84 mXmppConnectionService.updateMessage(message);
85 }
86 this.message.setTransferable(this);
87 try {
88 if (message.hasFileOnRemoteHost()) {
89 mUrl = AesGcmURL.of(fileParams.url);
90 } else if (message.isOOb() && fileParams.url != null) {
91 mUrl = AesGcmURL.of(fileParams.url);
92 } else {
93 mUrl = AesGcmURL.of(message.getRawBody().split("\n")[0]);
94 }
95 final AbstractConnectionManager.Extension extension =
96 AbstractConnectionManager.Extension.of(mUrl.encodedPath());
97 if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
98 this.message.setEncryption(Message.ENCRYPTION_PGP);
99 } else if (message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
100 this.message.setEncryption(Message.ENCRYPTION_NONE);
101 }
102 String ext = extension.getExtension();
103 if (ext == null && fileParams.getMediaType() != null) {
104 ext = MimeUtils.guessExtensionFromMimeType(fileParams.getMediaType());
105 }
106 final String filename =
107 Strings.isNullOrEmpty(ext)
108 ? message.getUuid()
109 : String.format("%s.%s", message.getUuid(), ext);
110 mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
111 setupFile();
112 if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL
113 && this.file.getKey() == null) {
114 this.message.setEncryption(Message.ENCRYPTION_NONE);
115 }
116 final Long knownFileSize;
117 if (message.getEncryption() == Message.ENCRYPTION_PGP
118 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
119 knownFileSize = null;
120 } else {
121 knownFileSize = message.getFileParams().size;
122 }
123 if (knownFileSize != null && interactive) {
124 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL
125 && this.file.getKey() != null) {
126 this.file.setExpectedSize(knownFileSize + GCM_AUTHENTICATION_TAG_LENGTH);
127 } else {
128 this.file.setExpectedSize(knownFileSize);
129 }
130 download(true);
131 } else {
132 checkFileSize(interactive);
133 }
134 } catch (final IllegalArgumentException e) {
135 this.cancel();
136 }
137 }
138
139 private void setupFile() {
140 final String reference = mUrl.fragment();
141 if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
142 this.file =
143 new DownloadableFile(mXmppConnectionService.getCacheDir(), message.getUuid());
144 this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
145 Log.d(
146 Config.LOGTAG,
147 "create temporary OMEMO encrypted file: "
148 + this.file.getAbsolutePath()
149 + "("
150 + message.getMimeType()
151 + ")");
152 } else {
153 this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
154 }
155 }
156
157 private void download(final boolean interactive) {
158 EXECUTOR.execute(new FileDownloader(interactive));
159 }
160
161 private void checkFileSize(final boolean interactive) {
162 EXECUTOR.execute(new FileSizeChecker(interactive));
163 }
164
165 @Override
166 public void cancel() {
167 final Call call = this.mostRecentCall;
168 if (call != null && !call.isCanceled()) {
169 call.cancel();
170 }
171 mHttpConnectionManager.finishConnection(this);
172 message.setTransferable(null);
173 if (message.isFileOrImage()) {
174 message.setDeleted(true);
175 }
176 mHttpConnectionManager.updateConversationUi(true);
177 }
178
179 private void decryptFile() throws IOException {
180 final DownloadableFile outputFile =
181 mXmppConnectionService.getFileBackend().getFile(message, true);
182
183 final var directory = outputFile.getParentFile();
184 if (directory != null && directory.mkdirs()) {
185 Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
186 }
187
188 if (!outputFile.createNewFile()) {
189 Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
190 }
191 final var cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
192 cipher.init(
193 false, new AEADParameters(new KeyParameter(this.file.getKey()), 128, file.getIv()));
194 try (final InputStream is = new FileInputStream(this.file);
195 final CipherOutputStream outputStream =
196 new CipherOutputStream(new FileOutputStream(outputFile), cipher)) {
197 ByteStreams.copy(is, outputStream);
198 }
199
200 if (file.delete()) {
201 Log.w(
202 Config.LOGTAG,
203 "deleted temporary OMEMO encrypted file " + file.getAbsolutePath());
204 }
205
206 file = outputFile;
207 }
208
209 private void finish() {
210 boolean notify = acceptedAutomatically && !message.isRead() && cb == null;
211 if (message.getEncryption() == Message.ENCRYPTION_PGP) {
212 notify =
213 message.getConversation()
214 .getAccount()
215 .getPgpDecryptionService()
216 .decrypt(message, notify);
217 }
218 final DownloadableFile tmp = file;
219 final String extension = MimeUtils.extractRelevantExtension(tmp.getName());
220 try {
221 mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(tmp), extension);
222 file = mXmppConnectionService.getFileBackend().getFile(message);
223 boolean didRename = tmp.renameTo(file);
224 if (!didRename) throw new IOException("rename failed");
225 } catch (final IOException e) {
226 Log.w(Config.LOGTAG, "Failed to rename downloaded file: " + e);
227 file = tmp;
228 message.setRelativeFilePath(file.getAbsolutePath());
229 } catch (final XmppConnectionService.BlockedMediaException e) {
230 file = tmp;
231 tmp.delete();
232 message.setDeleted(true);
233 }
234 message.setTransferable(null);
235 mXmppConnectionService.updateMessage(message);
236 mHttpConnectionManager.finishConnection(this);
237 final boolean notifyAfterScan = notify;
238 final DownloadableFile file =
239 mXmppConnectionService.getFileBackend().getFile(message, true);
240 mXmppConnectionService
241 .getFileBackend()
242 .updateMediaScanner(
243 file,
244 () -> {
245 if (notifyAfterScan) {
246 mXmppConnectionService.getNotificationService().push(message);
247 }
248 });
249 }
250
251 private void decryptIfNeeded() throws IOException {
252 if (file.getKey() != null && file.getIv() != null) {
253 decryptFile();
254 }
255 }
256
257 private void changeStatus(int status) {
258 this.mStatus = status;
259 mHttpConnectionManager.updateConversationUi(true);
260 }
261
262 private void showToastForException(final Exception e) {
263 final Call call = mostRecentCall;
264 final boolean cancelled = call != null && call.isCanceled();
265 if (e == null || cancelled) {
266 return;
267 }
268 if (e instanceof java.net.UnknownHostException) {
269 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
270 } else if (e instanceof java.net.ConnectException) {
271 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
272 } else if (e instanceof FileWriterException) {
273 mXmppConnectionService.showErrorToastInUi(
274 R.string.download_failed_could_not_write_file);
275 } else if (e instanceof InvalidFileException) {
276 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_invalid_file);
277 } else {
278 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
279 }
280 }
281
282 private void updateProgress(long i) {
283 this.mProgress = (int) i;
284 mHttpConnectionManager.updateConversationUi(false);
285 }
286
287 @Override
288 public int getStatus() {
289 return this.mStatus;
290 }
291
292 @Override
293 public Long getFileSize() {
294 if (this.file != null) {
295 return this.file.getExpectedSize();
296 } else {
297 return null;
298 }
299 }
300
301 @Override
302 public int getProgress() {
303 return this.mProgress;
304 }
305
306 public Message getMessage() {
307 return message;
308 }
309
310 private class FileSizeChecker implements Runnable {
311
312 private final boolean interactive;
313
314 FileSizeChecker(boolean interactive) {
315 this.interactive = interactive;
316 }
317
318 @Override
319 public void run() {
320 check();
321 }
322
323 private void retrieveFailed(@Nullable final Exception e) {
324 changeStatus(STATUS_OFFER_CHECK_FILESIZE);
325 if (interactive) {
326 showToastForException(e);
327 } else {
328 HttpDownloadConnection.this.acceptedAutomatically = false;
329 HttpDownloadConnection.this
330 .mXmppConnectionService
331 .getNotificationService()
332 .push(message);
333 }
334 cancel();
335 }
336
337 private void check() {
338 long size;
339 try {
340 size = retrieveFileSize();
341 } catch (final Exception e) {
342 Log.d(Config.LOGTAG, "could not retrieve file size", e);
343 retrieveFailed(e);
344 return;
345 }
346 final Message.FileParams fileParams = message.getFileParams();
347 FileBackend.updateFileParams(message, fileParams.url, size);
348 mXmppConnectionService.databaseBackend.updateMessage(message, true);
349 file.setExpectedSize(size);
350 message.setFileParams(null);
351 if (mHttpConnectionManager.hasStoragePermission()
352 && size <= mHttpConnectionManager.getAutoAcceptFileSize()
353 && mXmppConnectionService.isDataSaverDisabled()) {
354 HttpDownloadConnection.this.acceptedAutomatically = true;
355 download(interactive);
356 } else {
357 changeStatus(STATUS_OFFER);
358 HttpDownloadConnection.this.acceptedAutomatically = false;
359 if (cb == null) {
360 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
361 } else {
362 cb.accept(null);
363 }
364 }
365 }
366
367 private long retrieveFileSize() throws IOException {
368 Log.d(Config.LOGTAG, "retrieve file size. interactive:" + interactive);
369 changeStatus(STATUS_CHECKING);
370 final OkHttpClient client =
371 mHttpConnectionManager.buildHttpClient(
372 mUrl, message.getConversation().getAccount(), interactive);
373 final Request request =
374 new Request.Builder()
375 .url(URL.stripFragment(mUrl))
376 .addHeader("Accept-Encoding", "identity")
377 .head()
378 .build();
379 mostRecentCall = client.newCall(request);
380 try (final Response response = mostRecentCall.execute()) {
381 throwOnInvalidCode(response);
382 final String contentLength = response.header("Content-Length");
383 final String contentType = response.header("Content-Type");
384 final AbstractConnectionManager.Extension extension =
385 AbstractConnectionManager.Extension.of(mUrl.encodedPath());
386 if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
387 final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
388 if (fileExtension != null) {
389 mXmppConnectionService
390 .getFileBackend()
391 .setupRelativeFilePath(
392 message,
393 String.format("%s.%s", message.getUuid(), fileExtension),
394 contentType);
395 Log.d(
396 Config.LOGTAG,
397 "rewriting name after not finding extension in url but in content"
398 + " type");
399 setupFile();
400 }
401 }
402 final Long size = Longs.tryParse(Strings.nullToEmpty(contentLength));
403 if (size == null || size < 0) {
404 throw new IOException("no content-length found in HEAD response");
405 }
406 return size;
407 }
408 }
409 }
410
411 private void persistFileSize(final long size) {
412 final Message.FileParams fileParams = message.getFileParams();
413 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && file.getKey() != null) {
414 // store the file size of the clear text file. If we resume the download we will add the
415 // auth tag size again
416 // this is equivalent to use updating file params *after* download (which would take the
417 // clear text size as well)
418 FileBackend.updateFileParams(
419 message, fileParams.url, size - GCM_AUTHENTICATION_TAG_LENGTH);
420 } else {
421 FileBackend.updateFileParams(message, fileParams.url, size);
422 }
423 }
424
425 private class FileDownloader implements Runnable {
426
427 private final boolean interactive;
428
429 public FileDownloader(boolean interactive) {
430 this.interactive = interactive;
431 }
432
433 @Override
434 public void run() {
435 try {
436 changeStatus(STATUS_DOWNLOADING);
437 download();
438 decryptIfNeeded();
439 finish();
440 updateImageBounds();
441 } catch (final SSLHandshakeException e) {
442 changeStatus(STATUS_OFFER);
443 } catch (final Exception e) {
444 Log.d(
445 Config.LOGTAG,
446 message.getConversation().getAccount().getJid().asBareJid()
447 + ": unable to download file",
448 e);
449 if (interactive) {
450 showToastForException(e);
451 } else {
452 HttpDownloadConnection.this.acceptedAutomatically = false;
453 HttpDownloadConnection.this
454 .mXmppConnectionService
455 .getNotificationService()
456 .push(message);
457 }
458 cancel();
459 } finally {
460 if (cb != null) cb.accept(file);
461 }
462 }
463
464 private void download() throws Exception {
465 final long expected = file.getExpectedSize();
466 final var fileExists = file.exists();
467 final var existingFileSize = fileExists ? file.length() : -1L;
468
469 if (fileExists) {
470 if (expected > 0 && existingFileSize == expected) {
471 Log.d(Config.LOGTAG, "file already exits (presumably decryption failure)");
472 return;
473 }
474 }
475 final OkHttpClient client =
476 mHttpConnectionManager.buildHttpClient(
477 mUrl, message.getConversation().getAccount(), interactive);
478
479 final Request.Builder requestBuilder =
480 new Request.Builder().url(URL.stripFragment(mUrl));
481
482 final boolean tryResume =
483 fileExists && existingFileSize > 0 && existingFileSize < expected;
484 final long resumeSize;
485 if (tryResume) {
486 resumeSize = existingFileSize;
487 Log.d(
488 Config.LOGTAG,
489 "http download trying resume after " + resumeSize + " of " + expected);
490 requestBuilder.addHeader(
491 "Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
492 } else {
493 resumeSize = 0;
494 }
495 final Request request = requestBuilder.build();
496 mostRecentCall = client.newCall(request);
497 try (final Response response = mostRecentCall.execute()) {
498 throwOnInvalidCode(response);
499 final String contentRange = response.header("Content-Range");
500 final boolean serverResumed =
501 tryResume
502 && contentRange != null
503 && contentRange.startsWith("bytes " + resumeSize + "-");
504 final var body = response.body();
505 if (body == null) {
506 throw new IOException("response body was null");
507 }
508 final InputStream inputStream = body.byteStream();
509 if (tryResume && serverResumed) {
510 Log.d(Config.LOGTAG, "server resumed");
511 final var offset = file.getSize();
512 try (final OutputStream os = new FileOutputStream(file, true)) {
513 copy(inputStream, os, offset, expected);
514 }
515 } else {
516 final String contentLength = response.header("Content-Length");
517 final Long size = Longs.tryParse(Strings.nullToEmpty(contentLength));
518 if (size == null) {
519 Log.d(Config.LOGTAG, "no content-length in GET response (probably gzip)");
520 } else {
521 if (expected != size) {
522 if (expected == 0) {
523 // this means we got 0 (unknown) on HEAD. We won't download the file
524 // but we update the file size so the user can try it again now that
525 // the actual file size is known
526 persistFileSize(size);
527 }
528 throw new IOException(
529 "Content-Length in GET response did not match expected size");
530 }
531 }
532 final var directory = file.getParentFile();
533 if (directory != null && directory.mkdirs()) {
534 Log.d(Config.LOGTAG, "create directory " + directory.getAbsolutePath());
535 }
536 Log.d(Config.LOGTAG, "creating file: " + file.getAbsolutePath());
537 if (!file.exists() && !file.createNewFile()) {
538 throw new FileWriterException(file);
539 }
540 try (final OutputStream os = new FileOutputStream(file)) {
541 copy(inputStream, os, 0, expected);
542 }
543 }
544 }
545 }
546
547 private void copy(
548 final InputStream inputStream,
549 final OutputStream outputStream,
550 final long offset,
551 final long expected)
552 throws IOException, FileWriterException {
553 long transmitted = offset;
554 int count;
555 final byte[] buffer = new byte[4096];
556 updateProgress(Math.round(((double) transmitted / expected) * 100));
557 while ((count = inputStream.read(buffer)) != -1) {
558 transmitted += count;
559 try {
560 outputStream.write(buffer, 0, count);
561 } catch (final IOException e) {
562 throw new FileWriterException(file);
563 }
564 if (transmitted > expected) {
565 throw new InvalidFileException(
566 String.format("File exceeds expected size of %d", expected));
567 }
568 updateProgress(Math.round(((double) transmitted / expected) * 100));
569 }
570 }
571
572 private void updateImageBounds() {
573 final boolean privateMessage = message.isPrivateMessage();
574 message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
575 final String url;
576 final String ref = mUrl.fragment();
577 if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
578 url = AesGcmURL.toAesGcmUrl(mUrl);
579 } else {
580 url = mUrl.toString();
581 }
582 mXmppConnectionService.getFileBackend().updateFileParams(message, url);
583 mXmppConnectionService.updateMessage(message);
584 }
585 }
586
587 private static void throwOnInvalidCode(final Response response) throws IOException {
588 final int code = response.code();
589 if (code < 200 || code >= 300) {
590 throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
591 }
592 }
593
594 private static class InvalidFileException extends IOException {
595
596 private InvalidFileException(final String message) {
597 super(message);
598 }
599 }
600}