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