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