HttpDownloadConnection.java

  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}