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