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.setOob(fileParams.url);
 79            message.setDeleted(false);
 80            mXmppConnectionService.updateMessage(message);
 81        }
 82        this.message.setTransferable(this);
 83        try {
 84            if (message.hasFileOnRemoteHost()) {
 85                mUrl = AesGcmURL.of(fileParams.url);
 86            } else if (message.isOOb() && fileParams.url != 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        boolean notify = acceptedAutomatically && !message.isRead();
190        if (message.getEncryption() == Message.ENCRYPTION_PGP) {
191            notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
192        }
193        DownloadableFile file;
194        final DownloadableFile tmp = mXmppConnectionService.getFileBackend().getFile(message);
195        final String extension = MimeUtils.extractRelevantExtension(tmp.getName());
196        try {
197            mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(tmp), extension);
198            file = mXmppConnectionService.getFileBackend().getFile(message);
199            tmp.renameTo(file);
200        } catch (final IOException e) {
201            file = tmp;
202        }
203        message.setTransferable(null);
204        mXmppConnectionService.updateMessage(message);
205        mHttpConnectionManager.finishConnection(this);
206        final boolean notifyAfterScan = notify;
207        mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
208            if (notifyAfterScan) {
209                mXmppConnectionService.getNotificationService().push(message);
210            }
211        });
212    }
213
214    private void decryptIfNeeded() throws IOException {
215        if (file.getKey() != null && file.getIv() != null) {
216            decryptFile();
217        }
218    }
219
220    private void changeStatus(int status) {
221        this.mStatus = status;
222        mHttpConnectionManager.updateConversationUi(true);
223    }
224
225    private void showToastForException(final Exception e) {
226        final Call call = mostRecentCall;
227        final boolean cancelled = call != null && call.isCanceled();
228        if (e == null || cancelled) {
229            return;
230        }
231        if (e instanceof java.net.UnknownHostException) {
232            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
233        } else if (e instanceof java.net.ConnectException) {
234            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
235        } else if (e instanceof FileWriterException) {
236            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
237        } else if (e instanceof InvalidFileException) {
238            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_invalid_file);
239        } else {
240            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
241        }
242    }
243
244    private void updateProgress(long i) {
245        this.mProgress = (int) i;
246        mHttpConnectionManager.updateConversationUi(false);
247    }
248
249    @Override
250    public int getStatus() {
251        return this.mStatus;
252    }
253
254    @Override
255    public Long getFileSize() {
256        if (this.file != null) {
257            return this.file.getExpectedSize();
258        } else {
259            return null;
260        }
261    }
262
263    @Override
264    public int getProgress() {
265        return this.mProgress;
266    }
267
268    public Message getMessage() {
269        return message;
270    }
271
272    private class FileSizeChecker implements Runnable {
273
274        private final boolean interactive;
275
276        FileSizeChecker(boolean interactive) {
277            this.interactive = interactive;
278        }
279
280
281        @Override
282        public void run() {
283            check();
284        }
285
286        private void retrieveFailed(@Nullable final Exception e) {
287            changeStatus(STATUS_OFFER_CHECK_FILESIZE);
288            if (interactive) {
289                showToastForException(e);
290            } else {
291                HttpDownloadConnection.this.acceptedAutomatically = false;
292                HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
293            }
294            cancel();
295        }
296
297        private void check() {
298            long size;
299            try {
300                size = retrieveFileSize();
301            } catch (final Exception e) {
302                Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
303                retrieveFailed(e);
304                return;
305            }
306            final Message.FileParams fileParams = message.getFileParams();
307            FileBackend.updateFileParams(message, fileParams.url, size);
308            message.setOob(fileParams.url);
309            mXmppConnectionService.databaseBackend.updateMessage(message, true);
310            file.setExpectedSize(size);
311            message.resetFileParams();
312            if (mHttpConnectionManager.hasStoragePermission()
313                    && size <= mHttpConnectionManager.getAutoAcceptFileSize()
314                    && mXmppConnectionService.isDataSaverDisabled()) {
315                HttpDownloadConnection.this.acceptedAutomatically = true;
316                download(interactive);
317            } else {
318                changeStatus(STATUS_OFFER);
319                HttpDownloadConnection.this.acceptedAutomatically = false;
320                HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
321            }
322        }
323
324        private long retrieveFileSize() throws IOException {
325            Log.d(Config.LOGTAG, "retrieve file size. interactive:" + interactive);
326            changeStatus(STATUS_CHECKING);
327            final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
328                    mUrl,
329                    message.getConversation().getAccount(),
330                    interactive
331            );
332            final Request request = new Request.Builder()
333                    .url(URL.stripFragment(mUrl))
334                    .addHeader("Accept-Encoding", "identity")
335                    .head()
336                    .build();
337            mostRecentCall = client.newCall(request);
338            try {
339                final Response response = mostRecentCall.execute();
340                throwOnInvalidCode(response);
341                final String contentLength = response.header("Content-Length");
342                final String contentType = response.header("Content-Type");
343                final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
344                if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
345                    final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
346                    if (fileExtension != null) {
347                        mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), fileExtension), contentType);
348                        Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
349                        setupFile();
350                    }
351                }
352                if (Strings.isNullOrEmpty(contentLength)) {
353                    throw new IOException("no content-length found in HEAD response");
354                }
355                final long size = Long.parseLong(contentLength, 10);
356                if (size < 0) {
357                    throw new IOException("Server reported negative file size");
358                }
359                return size;
360            } catch (final IOException e) {
361                Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
362                throw e;
363            } catch (final NumberFormatException e) {
364                throw new IOException(e);
365            }
366        }
367
368    }
369
370    private class FileDownloader implements Runnable {
371
372        private final boolean interactive;
373
374        public FileDownloader(boolean interactive) {
375            this.interactive = interactive;
376        }
377
378        @Override
379        public void run() {
380            try {
381                changeStatus(STATUS_DOWNLOADING);
382                download();
383                decryptIfNeeded();
384                finish();
385                updateImageBounds();
386            } catch (final SSLHandshakeException e) {
387                changeStatus(STATUS_OFFER);
388            } catch (final Exception e) {
389                Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": unable to download file", e);
390                if (interactive) {
391                    showToastForException(e);
392                } else {
393                    HttpDownloadConnection.this.acceptedAutomatically = false;
394                    HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
395                }
396                cancel();
397            }
398        }
399
400        private void download() throws Exception {
401            final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
402                    mUrl,
403                    message.getConversation().getAccount(),
404                    interactive
405            );
406
407            final Request.Builder requestBuilder = new Request.Builder().url(URL.stripFragment(mUrl));
408
409            final long expected = file.getExpectedSize();
410            final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
411            final long resumeSize;
412            if (tryResume) {
413                resumeSize = file.getSize();
414                Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
415                requestBuilder.addHeader("Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
416            } else {
417                resumeSize = 0;
418            }
419            final Request request = requestBuilder.build();
420            mostRecentCall = client.newCall(request);
421            final Response response = mostRecentCall.execute();
422            throwOnInvalidCode(response);
423            final String contentRange = response.header("Content-Range");
424            final boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
425            final InputStream inputStream = response.body().byteStream();
426            final OutputStream outputStream;
427            long transmitted = 0;
428            if (tryResume && serverResumed) {
429                Log.d(Config.LOGTAG, "server resumed");
430                transmitted = file.getSize();
431                updateProgress(Math.round(((double) transmitted / expected) * 100));
432                outputStream = AbstractConnectionManager.createOutputStream(file, true, false);
433            } else {
434                final String contentLength = response.header("Content-Length");
435                final long size = Strings.isNullOrEmpty(contentLength) ? 0 : Longs.tryParse(contentLength);
436                if (expected != size) {
437                    Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
438                }
439                file.getParentFile().mkdirs();
440                Log.d(Config.LOGTAG,"creating file: "+file.getAbsolutePath());
441                if (!file.exists() && !file.createNewFile()) {
442                    throw new FileWriterException(file);
443                }
444                outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
445            }
446            int count;
447            final byte[] buffer = new byte[4096];
448            while ((count = inputStream.read(buffer)) != -1) {
449                transmitted += count;
450                try {
451                    outputStream.write(buffer, 0, count);
452                } catch (final IOException e) {
453                    throw new FileWriterException(file);
454                }
455                if (transmitted > expected) {
456                    throw new InvalidFileException(String.format("File exceeds expected size of %d", expected));
457                }
458                updateProgress(Math.round(((double) transmitted / expected) * 100));
459            }
460            outputStream.flush();
461        }
462
463        private void updateImageBounds() {
464            final boolean privateMessage = message.isPrivateMessage();
465            message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
466            final String url;
467            final String ref = mUrl.fragment();
468            if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
469                url = AesGcmURL.toAesGcmUrl(mUrl);
470            } else {
471                url = mUrl.toString();
472            }
473            mXmppConnectionService.getFileBackend().updateFileParams(message, url);
474            mXmppConnectionService.updateMessage(message);
475        }
476
477    }
478
479    private static void throwOnInvalidCode(final Response response) throws IOException {
480        final int code = response.code();
481        if (code < 200 || code >= 300) {
482            throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
483        }
484    }
485
486    private static class InvalidFileException extends IOException {
487
488        private InvalidFileException(final String message) {
489            super(message);
490        }
491
492    }
493}