1package eu.siacs.conversations.http;
  2
  3import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
  4
  5import android.util.Log;
  6import androidx.annotation.Nullable;
  7import com.google.common.base.Strings;
  8import com.google.common.io.ByteStreams;
  9import com.google.common.primitives.Longs;
 10import eu.siacs.conversations.Config;
 11import eu.siacs.conversations.R;
 12import eu.siacs.conversations.entities.DownloadableFile;
 13import eu.siacs.conversations.entities.Message;
 14import eu.siacs.conversations.entities.Transferable;
 15import eu.siacs.conversations.persistance.FileBackend;
 16import eu.siacs.conversations.services.AbstractConnectionManager;
 17import eu.siacs.conversations.services.XmppConnectionService;
 18import eu.siacs.conversations.utils.CryptoHelper;
 19import eu.siacs.conversations.utils.FileWriterException;
 20import eu.siacs.conversations.utils.MimeUtils;
 21import java.io.FileInputStream;
 22import java.io.FileOutputStream;
 23import java.io.IOException;
 24import java.io.InputStream;
 25import java.io.OutputStream;
 26import java.util.Locale;
 27import javax.net.ssl.SSLHandshakeException;
 28import okhttp3.Call;
 29import okhttp3.HttpUrl;
 30import okhttp3.OkHttpClient;
 31import okhttp3.Request;
 32import okhttp3.Response;
 33import org.bouncycastle.crypto.engines.AESEngine;
 34import org.bouncycastle.crypto.io.CipherOutputStream;
 35import org.bouncycastle.crypto.modes.GCMBlockCipher;
 36import org.bouncycastle.crypto.params.AEADParameters;
 37import org.bouncycastle.crypto.params.KeyParameter;
 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
 51    HttpDownloadConnection(Message message, HttpConnectionManager manager) {
 52        this.message = message;
 53        this.mHttpConnectionManager = manager;
 54        this.mXmppConnectionService = manager.getXmppConnectionService();
 55    }
 56
 57    @Override
 58    public boolean start() {
 59        if (mXmppConnectionService.hasInternetConnection()) {
 60            if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
 61                checkFileSize(true);
 62            } else {
 63                download(true);
 64            }
 65            return true;
 66        } else {
 67            return false;
 68        }
 69    }
 70
 71    public void init(boolean interactive) {
 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(true);
 79            message.setDeleted(false);
 80            mXmppConnectionService.updateMessage(message);
 81        }
 82        this.message.setTransferable(this);
 83        try {
 84            final Message.FileParams fileParams = message.getFileParams();
 85            if (message.hasFileOnRemoteHost()) {
 86                mUrl = AesGcmURL.of(fileParams.url);
 87            } else if (message.isOOb() && fileParams.url != null && fileParams.size != null) {
 88                mUrl = AesGcmURL.of(fileParams.url);
 89            } else {
 90                mUrl = AesGcmURL.of(message.getBody().split("\n")[0]);
 91            }
 92            final AbstractConnectionManager.Extension extension =
 93                    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            final String ext = extension.getExtension();
100            final String filename =
101                    Strings.isNullOrEmpty(ext)
102                            ? message.getUuid()
103                            : String.format("%s.%s", message.getUuid(), ext);
104            mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
105            setupFile();
106            if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL
107                    && this.file.getKey() == null) {
108                this.message.setEncryption(Message.ENCRYPTION_NONE);
109            }
110            final Long knownFileSize;
111            if (message.getEncryption() == Message.ENCRYPTION_PGP
112                    || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
113                knownFileSize = null;
114            } else {
115                knownFileSize = message.getFileParams().size;
116            }
117            if (knownFileSize != null && interactive) {
118                if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL
119                        && this.file.getKey() != null) {
120                    this.file.setExpectedSize(knownFileSize + GCM_AUTHENTICATION_TAG_LENGTH);
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 =
137                    new DownloadableFile(mXmppConnectionService.getCacheDir(), message.getUuid());
138            this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
139            Log.d(
140                    Config.LOGTAG,
141                    "create temporary OMEMO encrypted file: "
142                            + this.file.getAbsolutePath()
143                            + "("
144                            + message.getMimeType()
145                            + ")");
146        } else {
147            this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
148        }
149    }
150
151    private void download(final boolean interactive) {
152        EXECUTOR.execute(new FileDownloader(interactive));
153    }
154
155    private void checkFileSize(final boolean interactive) {
156        EXECUTOR.execute(new FileSizeChecker(interactive));
157    }
158
159    @Override
160    public void cancel() {
161        final Call call = this.mostRecentCall;
162        if (call != null && !call.isCanceled()) {
163            call.cancel();
164        }
165        mHttpConnectionManager.finishConnection(this);
166        message.setTransferable(null);
167        if (message.isFileOrImage()) {
168            message.setDeleted(true);
169        }
170        mHttpConnectionManager.updateConversationUi(true);
171    }
172
173    private void decryptFile() throws IOException {
174        final DownloadableFile outputFile =
175                mXmppConnectionService.getFileBackend().getFile(message, true);
176
177        final var directory = outputFile.getParentFile();
178        if (directory != null && directory.mkdirs()) {
179            Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
180        }
181
182        if (!outputFile.createNewFile()) {
183            Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
184        }
185        final var cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
186        cipher.init(
187                false, new AEADParameters(new KeyParameter(this.file.getKey()), 128, file.getIv()));
188        try (final InputStream is = new FileInputStream(this.file);
189                final CipherOutputStream outputStream =
190                        new CipherOutputStream(new FileOutputStream(outputFile), cipher)) {
191            ByteStreams.copy(is, outputStream);
192        }
193
194        if (file.delete()) {
195            Log.w(
196                    Config.LOGTAG,
197                    "deleted temporary OMEMO encrypted file " + file.getAbsolutePath());
198        }
199    }
200
201    private void finish() {
202        message.setTransferable(null);
203        mHttpConnectionManager.finishConnection(this);
204        boolean notify = acceptedAutomatically && !message.isRead();
205        if (message.getEncryption() == Message.ENCRYPTION_PGP) {
206            notify =
207                    message.getConversation()
208                            .getAccount()
209                            .getPgpDecryptionService()
210                            .decrypt(message, notify);
211        }
212        mHttpConnectionManager.updateConversationUi(true);
213        final boolean notifyAfterScan = notify;
214        final DownloadableFile file =
215                mXmppConnectionService.getFileBackend().getFile(message, true);
216        mXmppConnectionService
217                .getFileBackend()
218                .updateMediaScanner(
219                        file,
220                        () -> {
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(
250                    R.string.download_failed_could_not_write_file);
251        } else if (e instanceof InvalidFileException) {
252            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_invalid_file);
253        } else {
254            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
255        }
256    }
257
258    private void updateProgress(long i) {
259        this.mProgress = (int) i;
260        mHttpConnectionManager.updateConversationUi(false);
261    }
262
263    @Override
264    public int getStatus() {
265        return this.mStatus;
266    }
267
268    @Override
269    public Long getFileSize() {
270        if (this.file != null) {
271            return this.file.getExpectedSize();
272        } else {
273            return null;
274        }
275    }
276
277    @Override
278    public int getProgress() {
279        return this.mProgress;
280    }
281
282    public Message getMessage() {
283        return message;
284    }
285
286    private class FileSizeChecker implements Runnable {
287
288        private final boolean interactive;
289
290        FileSizeChecker(boolean interactive) {
291            this.interactive = interactive;
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
306                        .mXmppConnectionService
307                        .getNotificationService()
308                        .push(message);
309            }
310            cancel();
311        }
312
313        private void check() {
314            long size;
315            try {
316                size = retrieveFileSize();
317            } catch (final Exception e) {
318                Log.d(Config.LOGTAG, "could not retrieve file size", e);
319                retrieveFailed(e);
320                return;
321            }
322            persistFileSize(size);
323            message.setOob(true);
324            mXmppConnectionService.databaseBackend.updateMessage(message, true);
325            file.setExpectedSize(size);
326            message.resetFileParams();
327            if (mHttpConnectionManager.hasStoragePermission()
328                    && size <= mHttpConnectionManager.getAutoAcceptFileSize()
329                    && mXmppConnectionService.isDataSaverDisabled()) {
330                HttpDownloadConnection.this.acceptedAutomatically = true;
331                download(interactive);
332            } else {
333                changeStatus(STATUS_OFFER);
334                HttpDownloadConnection.this.acceptedAutomatically = false;
335                HttpDownloadConnection.this
336                        .mXmppConnectionService
337                        .getNotificationService()
338                        .push(message);
339            }
340        }
341
342        private long retrieveFileSize() throws IOException {
343            Log.d(Config.LOGTAG, "retrieve file size. interactive:" + interactive);
344            changeStatus(STATUS_CHECKING);
345            final OkHttpClient client =
346                    mHttpConnectionManager.buildHttpClient(
347                            mUrl, message.getConversation().getAccount(), interactive);
348            final Request request =
349                    new Request.Builder()
350                            .url(URL.stripFragment(mUrl))
351                            .addHeader("Accept-Encoding", "identity")
352                            .head()
353                            .build();
354            mostRecentCall = client.newCall(request);
355            try (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 =
360                        AbstractConnectionManager.Extension.of(mUrl.encodedPath());
361                if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
362                    final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
363                    if (fileExtension != null) {
364                        mXmppConnectionService
365                                .getFileBackend()
366                                .setupRelativeFilePath(
367                                        message,
368                                        String.format("%s.%s", message.getUuid(), fileExtension),
369                                        contentType);
370                        Log.d(
371                                Config.LOGTAG,
372                                "rewriting name after not finding extension in url but in content"
373                                        + " type");
374                        setupFile();
375                    }
376                }
377                final Long size = Longs.tryParse(Strings.nullToEmpty(contentLength));
378                if (size == null || size < 0) {
379                    throw new IOException("no content-length found in HEAD response");
380                }
381                return size;
382            }
383        }
384    }
385
386    private void persistFileSize(final long size) {
387        final Message.FileParams fileParams = message.getFileParams();
388        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && file.getKey() != null) {
389            // store the file size of the clear text file. If we resume the download we will add the
390            // auth tag size again
391            // this is equivalent to use updating file params *after* download (which would take the
392            // clear text size as well)
393            FileBackend.updateFileParams(
394                    message, fileParams.url, size - GCM_AUTHENTICATION_TAG_LENGTH);
395        } else {
396            FileBackend.updateFileParams(message, fileParams.url, size);
397        }
398    }
399
400    private class FileDownloader implements Runnable {
401
402        private final boolean interactive;
403
404        public FileDownloader(boolean interactive) {
405            this.interactive = interactive;
406        }
407
408        @Override
409        public void run() {
410            try {
411                changeStatus(STATUS_DOWNLOADING);
412                download();
413                decryptIfNeeded();
414                updateImageBounds();
415                finish();
416            } catch (final SSLHandshakeException e) {
417                changeStatus(STATUS_OFFER);
418            } catch (final Exception e) {
419                Log.d(
420                        Config.LOGTAG,
421                        message.getConversation().getAccount().getJid().asBareJid()
422                                + ": unable to download file",
423                        e);
424                if (interactive) {
425                    showToastForException(e);
426                } else {
427                    HttpDownloadConnection.this.acceptedAutomatically = false;
428                    HttpDownloadConnection.this
429                            .mXmppConnectionService
430                            .getNotificationService()
431                            .push(message);
432                }
433                cancel();
434            }
435        }
436
437        private void download() throws Exception {
438            final long expected = file.getExpectedSize();
439            final var fileExists = file.exists();
440            final var existingFileSize = fileExists ? file.length() : -1L;
441
442            if (fileExists) {
443                if (expected > 0 && existingFileSize == expected) {
444                    Log.d(Config.LOGTAG, "file already exits (presumably decryption failure)");
445                    return;
446                }
447            }
448            final OkHttpClient client =
449                    mHttpConnectionManager.buildHttpClient(
450                            mUrl, message.getConversation().getAccount(), interactive);
451
452            final Request.Builder requestBuilder =
453                    new Request.Builder().url(URL.stripFragment(mUrl));
454
455            final boolean tryResume =
456                    fileExists && existingFileSize > 0 && existingFileSize < expected;
457            final long resumeSize;
458            if (tryResume) {
459                resumeSize = existingFileSize;
460                Log.d(
461                        Config.LOGTAG,
462                        "http download trying resume after " + resumeSize + " of " + expected);
463                requestBuilder.addHeader(
464                        "Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
465            } else {
466                resumeSize = 0;
467            }
468            final Request request = requestBuilder.build();
469            mostRecentCall = client.newCall(request);
470            try (final Response response = mostRecentCall.execute()) {
471                throwOnInvalidCode(response);
472                final String contentRange = response.header("Content-Range");
473                final boolean serverResumed =
474                        tryResume
475                                && contentRange != null
476                                && contentRange.startsWith("bytes " + resumeSize + "-");
477                final var body = response.body();
478                if (body == null) {
479                    throw new IOException("response body was null");
480                }
481                final InputStream inputStream = body.byteStream();
482                if (tryResume && serverResumed) {
483                    Log.d(Config.LOGTAG, "server resumed");
484                    final var offset = file.getSize();
485                    try (final OutputStream os = new FileOutputStream(file, true)) {
486                        copy(inputStream, os, offset, expected);
487                    }
488                } else {
489                    final String contentLength = response.header("Content-Length");
490                    final Long size = Longs.tryParse(Strings.nullToEmpty(contentLength));
491                    if (size == null) {
492                        Log.d(Config.LOGTAG, "no content-length in GET response (probably gzip)");
493                    } else {
494                        if (expected != size) {
495                            if (expected == 0) {
496                                // this means we got 0 (unknown) on HEAD. We won't download the file
497                                // but we update the file size so the user can try it again now that
498                                // the actual file size is known
499                                persistFileSize(size);
500                            }
501                            throw new IOException(
502                                    "Content-Length in GET response did not match expected size");
503                        }
504                    }
505                    final var directory = file.getParentFile();
506                    if (directory != null && directory.mkdirs()) {
507                        Log.d(Config.LOGTAG, "create directory " + directory.getAbsolutePath());
508                    }
509                    Log.d(Config.LOGTAG, "creating file: " + file.getAbsolutePath());
510                    if (!file.exists() && !file.createNewFile()) {
511                        throw new FileWriterException(file);
512                    }
513                    try (final OutputStream os = new FileOutputStream(file)) {
514                        copy(inputStream, os, 0, expected);
515                    }
516                }
517            }
518        }
519
520        private void copy(
521                final InputStream inputStream,
522                final OutputStream outputStream,
523                final long offset,
524                final long expected)
525                throws IOException, FileWriterException {
526            long transmitted = offset;
527            int count;
528            final byte[] buffer = new byte[4096];
529            updateProgress(Math.round(((double) transmitted / expected) * 100));
530            while ((count = inputStream.read(buffer)) != -1) {
531                transmitted += count;
532                try {
533                    outputStream.write(buffer, 0, count);
534                } catch (final IOException e) {
535                    throw new FileWriterException(file);
536                }
537                if (transmitted > expected) {
538                    throw new InvalidFileException(
539                            String.format("File exceeds expected size of %d", expected));
540                }
541                updateProgress(Math.round(((double) transmitted / expected) * 100));
542            }
543        }
544
545        private void updateImageBounds() {
546            final boolean privateMessage = message.isPrivateMessage();
547            message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
548            final String url;
549            final String ref = mUrl.fragment();
550            if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
551                url = AesGcmURL.toAesGcmUrl(mUrl);
552            } else {
553                url = mUrl.toString();
554            }
555            mXmppConnectionService.getFileBackend().updateFileParams(message, url);
556            mXmppConnectionService.updateMessage(message);
557        }
558    }
559
560    private static void throwOnInvalidCode(final Response response) throws IOException {
561        final int code = response.code();
562        if (code < 200 || code >= 300) {
563            throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
564        }
565    }
566
567    private static class InvalidFileException extends IOException {
568
569        private InvalidFileException(final String message) {
570            super(message);
571        }
572    }
573}