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