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            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}