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