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;
 16import java.util.concurrent.CancellationException;
 17
 18import javax.net.ssl.SSLHandshakeException;
 19
 20import eu.siacs.conversations.Config;
 21import eu.siacs.conversations.R;
 22import eu.siacs.conversations.entities.DownloadableFile;
 23import eu.siacs.conversations.entities.Message;
 24import eu.siacs.conversations.entities.Transferable;
 25import eu.siacs.conversations.persistance.FileBackend;
 26import eu.siacs.conversations.services.AbstractConnectionManager;
 27import eu.siacs.conversations.services.XmppConnectionService;
 28import eu.siacs.conversations.utils.CryptoHelper;
 29import eu.siacs.conversations.utils.FileWriterException;
 30import eu.siacs.conversations.utils.MimeUtils;
 31import okhttp3.Call;
 32import okhttp3.HttpUrl;
 33import okhttp3.OkHttpClient;
 34import okhttp3.Request;
 35import okhttp3.Response;
 36
 37import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
 38
 39public class HttpDownloadConnection implements Transferable {
 40
 41    private final Message message;
 42    private final boolean mUseTor;
 43    private final HttpConnectionManager mHttpConnectionManager;
 44    private final XmppConnectionService mXmppConnectionService;
 45    private HttpUrl mUrl;
 46    private DownloadableFile file;
 47    private int mStatus = Transferable.STATUS_UNKNOWN;
 48    private boolean acceptedAutomatically = false;
 49    private int mProgress = 0;
 50    private boolean canceled = false;
 51    private Call mostRecentCall;
 52
 53    HttpDownloadConnection(Message message, HttpConnectionManager manager) {
 54        this.message = message;
 55        this.mHttpConnectionManager = manager;
 56        this.mXmppConnectionService = manager.getXmppConnectionService();
 57        this.mUseTor = mXmppConnectionService.useTorToConnect();
 58    }
 59
 60    @Override
 61    public boolean start() {
 62        if (mXmppConnectionService.hasInternetConnection()) {
 63            if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
 64                checkFileSize(true);
 65            } else {
 66                download(true);
 67            }
 68            return true;
 69        } else {
 70            return false;
 71        }
 72    }
 73
 74    public void init(boolean interactive) {
 75        if (message.isDeleted()) {
 76            if (message.getType() == Message.TYPE_PRIVATE_FILE) {
 77                message.setType(Message.TYPE_PRIVATE);
 78            } else if (message.isFileOrImage()) {
 79                message.setType(Message.TYPE_TEXT);
 80            }
 81            message.setOob(true);
 82            message.setDeleted(false);
 83            mXmppConnectionService.updateMessage(message);
 84        }
 85        this.message.setTransferable(this);
 86        try {
 87            final Message.FileParams fileParams = message.getFileParams();
 88            if (message.hasFileOnRemoteHost()) {
 89                mUrl = AesGcmURL.of(fileParams.url);
 90            } else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) {
 91                mUrl = AesGcmURL.of(fileParams.url);
 92            } else {
 93                mUrl = AesGcmURL.of(message.getBody().split("\n")[0]);
 94            }
 95            final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
 96            if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 97                this.message.setEncryption(Message.ENCRYPTION_PGP);
 98            } else if (message.getEncryption() != Message.ENCRYPTION_OTR
 99                    && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
100                this.message.setEncryption(Message.ENCRYPTION_NONE);
101            }
102            final String ext = extension.getExtension();
103            if (ext != null) {
104                message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), ext));
105            } else if (Strings.isNullOrEmpty(message.getRelativeFilePath())) {
106                message.setRelativeFilePath(message.getUuid());
107            }
108            setupFile();
109            if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
110                this.message.setEncryption(Message.ENCRYPTION_NONE);
111            }
112            //TODO add auth tag size to knownFileSize
113            final long knownFileSize = message.getFileParams().size;
114            if (knownFileSize > 0 && interactive) {
115                this.file.setExpectedSize(knownFileSize);
116                download(true);
117            } else {
118                checkFileSize(interactive);
119            }
120        } catch (final IllegalArgumentException e) {
121            this.cancel();
122        }
123    }
124
125    private void setupFile() {
126        final String reference = mUrl.fragment();
127        if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
128            this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
129            this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
130            Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
131        } else {
132            this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
133        }
134    }
135
136    private void download(final boolean interactive) {
137        EXECUTOR.execute(new FileDownloader(interactive));
138    }
139
140    private void checkFileSize(final boolean interactive) {
141        EXECUTOR.execute(new FileSizeChecker(interactive));
142    }
143
144    @Override
145    public void cancel() {
146        this.canceled = true;
147        final Call call = this.mostRecentCall;
148        if (call != null && !call.isCanceled()) {
149            call.cancel();
150        }
151        mHttpConnectionManager.finishConnection(this);
152        message.setTransferable(null);
153        if (message.isFileOrImage()) {
154            message.setDeleted(true);
155        }
156        mHttpConnectionManager.updateConversationUi(true);
157    }
158
159    private void decryptFile() throws IOException {
160        final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
161
162        if (outputFile.getParentFile().mkdirs()) {
163            Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
164        }
165
166        if (!outputFile.createNewFile()) {
167            Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
168        }
169
170        final InputStream is = new FileInputStream(this.file);
171
172        outputFile.setKey(this.file.getKey());
173        outputFile.setIv(this.file.getIv());
174        final OutputStream os = AbstractConnectionManager.createOutputStream(outputFile, false, true);
175
176        ByteStreams.copy(is, os);
177
178        FileBackend.close(is);
179        FileBackend.close(os);
180
181        if (!file.delete()) {
182            Log.w(Config.LOGTAG, "unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath());
183        }
184    }
185
186    private void finish() {
187        message.setTransferable(null);
188        mHttpConnectionManager.finishConnection(this);
189        boolean notify = acceptedAutomatically && !message.isRead();
190        if (message.getEncryption() == Message.ENCRYPTION_PGP) {
191            notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
192        }
193        mHttpConnectionManager.updateConversationUi(true);
194        final boolean notifyAfterScan = notify;
195        final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
196        mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
197            if (notifyAfterScan) {
198                mXmppConnectionService.getNotificationService().push(message);
199            }
200        });
201    }
202
203    private void decryptIfNeeded() throws IOException {
204        if (file.getKey() != null && file.getIv() != null) {
205            decryptFile();
206        }
207    }
208
209    private void changeStatus(int status) {
210        this.mStatus = status;
211        mHttpConnectionManager.updateConversationUi(true);
212    }
213
214    private void showToastForException(final Exception e) {
215        final Call call = mostRecentCall;
216        final boolean cancelled = call != null && call.isCanceled();
217        if (e == null || cancelled) {
218            return;
219        }
220        if (e instanceof java.net.UnknownHostException) {
221            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
222        } else if (e instanceof java.net.ConnectException) {
223            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
224        } else if (e instanceof FileWriterException) {
225            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
226        } else {
227            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
228        }
229    }
230
231    private void updateProgress(long i) {
232        this.mProgress = (int) i;
233        mHttpConnectionManager.updateConversationUi(false);
234    }
235
236    @Override
237    public int getStatus() {
238        return this.mStatus;
239    }
240
241    @Override
242    public long getFileSize() {
243        if (this.file != null) {
244            return this.file.getExpectedSize();
245        } else {
246            return 0;
247        }
248    }
249
250    @Override
251    public int getProgress() {
252        return this.mProgress;
253    }
254
255    public Message getMessage() {
256        return message;
257    }
258
259    private class FileSizeChecker implements Runnable {
260
261        private final boolean interactive;
262
263        FileSizeChecker(boolean interactive) {
264            this.interactive = interactive;
265        }
266
267
268        @Override
269        public void run() {
270            check();
271        }
272
273        private void retrieveFailed(@Nullable Exception e) {
274            changeStatus(STATUS_OFFER_CHECK_FILESIZE);
275            if (interactive) {
276                showToastForException(e);
277            } else {
278                HttpDownloadConnection.this.acceptedAutomatically = false;
279                HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
280            }
281            cancel();
282        }
283
284        private void check() {
285            long size;
286            try {
287                size = retrieveFileSize();
288            } catch (Exception e) {
289                Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
290                retrieveFailed(e);
291                return;
292            }
293            final Message.FileParams fileParams = message.getFileParams();
294            FileBackend.updateFileParams(message, fileParams.url, size);
295            message.setOob(true);
296            mXmppConnectionService.databaseBackend.updateMessage(message, true);
297            file.setExpectedSize(size);
298            message.resetFileParams();
299            if (mHttpConnectionManager.hasStoragePermission()
300                    && size <= mHttpConnectionManager.getAutoAcceptFileSize()
301                    && mXmppConnectionService.isDataSaverDisabled()) {
302                HttpDownloadConnection.this.acceptedAutomatically = true;
303                download(interactive);
304            } else {
305                changeStatus(STATUS_OFFER);
306                HttpDownloadConnection.this.acceptedAutomatically = false;
307                HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
308            }
309        }
310
311        private long retrieveFileSize() throws IOException {
312            final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
313                    mUrl,
314                    message.getConversation().getAccount(),
315                    interactive
316            );
317            final Request request = new Request.Builder()
318                    .url(URL.stripFragment(mUrl))
319                    .head()
320                    .build();
321            mostRecentCall = client.newCall(request);
322            try {
323                final Response response = mostRecentCall.execute();
324                final String contentLength = response.header("Content-Length");
325                final String contentType = response.header("Content-Type");
326                final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
327                if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
328                    final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
329                    if (fileExtension != null) {
330                        message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), fileExtension));
331                        Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
332                        setupFile();
333                    }
334                }
335                if (Strings.isNullOrEmpty(contentLength)) {
336                    throw new IOException("no content-length found in HEAD response");
337                }
338                return Long.parseLong(contentLength, 10);
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            final int code = response.code();
402            if (code >= 200 && code <= 299) {
403                final String contentRange = response.header("Content-Range");
404                final boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
405                final InputStream inputStream = response.body().byteStream();
406                final OutputStream outputStream;
407                long transmitted = 0;
408                if (tryResume && serverResumed) {
409                    Log.d(Config.LOGTAG, "server resumed");
410                    transmitted = file.getSize();
411                    updateProgress(Math.round(((double) transmitted / expected) * 100));
412                    outputStream = AbstractConnectionManager.createOutputStream(file, true, false);
413                } else {
414                    final String contentLength = response.header("Content-Length");
415                    final long size = Strings.isNullOrEmpty(contentLength) ? 0 : Longs.tryParse(contentLength);
416                    if (expected != size) {
417                        Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
418                    }
419                    file.getParentFile().mkdirs();
420                    if (!file.exists() && !file.createNewFile()) {
421                        throw new FileWriterException();
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();
433                    }
434                    updateProgress(Math.round(((double) transmitted / expected) * 100));
435                }
436                outputStream.flush();
437            } else {
438                throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
439            }
440        }
441
442        private void updateImageBounds() {
443            final boolean privateMessage = message.isPrivateMessage();
444            message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
445            final String url;
446            final String ref = mUrl.fragment();
447            if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
448                url = AesGcmURL.toAesGcmUrl(mUrl);
449            } else {
450                url = mUrl.toString();
451            }
452            mXmppConnectionService.getFileBackend().updateFileParams(message, url);
453            mXmppConnectionService.updateMessage(message);
454        }
455
456    }
457}