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