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