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