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