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