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