HttpDownloadConnection.java

  1package eu.siacs.conversations.http;
  2
  3import android.os.PowerManager;
  4import androidx.annotation.Nullable;
  5import android.util.Log;
  6
  7import com.google.common.base.Strings;
  8import com.google.common.io.ByteStreams;
  9
 10import java.io.BufferedInputStream;
 11import java.io.FileInputStream;
 12import java.io.IOException;
 13import java.io.InputStream;
 14import java.io.OutputStream;
 15import java.net.HttpURLConnection;
 16import java.net.MalformedURLException;
 17import java.net.URL;
 18import java.util.concurrent.CancellationException;
 19
 20import javax.net.ssl.HttpsURLConnection;
 21import javax.net.ssl.SSLHandshakeException;
 22
 23import eu.siacs.conversations.Config;
 24import eu.siacs.conversations.R;
 25import eu.siacs.conversations.entities.Account;
 26import eu.siacs.conversations.entities.DownloadableFile;
 27import eu.siacs.conversations.entities.Message;
 28import eu.siacs.conversations.entities.Transferable;
 29import eu.siacs.conversations.persistance.FileBackend;
 30import eu.siacs.conversations.services.AbstractConnectionManager;
 31import eu.siacs.conversations.services.XmppConnectionService;
 32import eu.siacs.conversations.utils.CryptoHelper;
 33import eu.siacs.conversations.utils.FileWriterException;
 34import eu.siacs.conversations.utils.MimeUtils;
 35import eu.siacs.conversations.utils.WakeLockHelper;
 36import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 37
 38public class HttpDownloadConnection implements Transferable {
 39
 40    private final Message message;
 41    private final boolean mUseTor;
 42    private final HttpConnectionManager mHttpConnectionManager;
 43    private final XmppConnectionService mXmppConnectionService;
 44    private URL mUrl;
 45    private DownloadableFile file;
 46    private int mStatus = Transferable.STATUS_UNKNOWN;
 47    private boolean acceptedAutomatically = false;
 48    private int mProgress = 0;
 49    private boolean canceled = false;
 50    private Method method = Method.HTTP_UPLOAD;
 51
 52    HttpDownloadConnection(Message message, HttpConnectionManager manager) {
 53        this.message = message;
 54        this.mHttpConnectionManager = manager;
 55        this.mXmppConnectionService = manager.getXmppConnectionService();
 56        this.mUseTor = mXmppConnectionService.useTorToConnect();
 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        if (message.isDeleted()) {
 75            if (message.getType() == Message.TYPE_PRIVATE_FILE) {
 76                message.setType(Message.TYPE_PRIVATE);
 77            } else if (message.isFileOrImage()) {
 78                message.setType(Message.TYPE_TEXT);
 79            }
 80            message.setOob(true);
 81            message.setDeleted(false);
 82            mXmppConnectionService.updateMessage(message);
 83        }
 84        this.message.setTransferable(this);
 85        try {
 86            final Message.FileParams fileParams = message.getFileParams();
 87            if (message.hasFileOnRemoteHost()) {
 88                mUrl = CryptoHelper.toHttpsUrl(fileParams.url);
 89            } else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) {
 90                mUrl = fileParams.url;
 91            } else {
 92                mUrl = CryptoHelper.toHttpsUrl(new URL(message.getBody().split("\n")[0]));
 93            }
 94            final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath());
 95            if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 96                this.message.setEncryption(Message.ENCRYPTION_PGP);
 97            } else if (message.getEncryption() != Message.ENCRYPTION_OTR
 98                    && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
 99                this.message.setEncryption(Message.ENCRYPTION_NONE);
100            }
101            final String ext = extension.getExtension();
102            if (ext != null) {
103                message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), ext));
104            } else if (Strings.isNullOrEmpty(message.getRelativeFilePath())) {
105                message.setRelativeFilePath(message.getUuid());
106            }
107            setupFile();
108            if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
109                this.message.setEncryption(Message.ENCRYPTION_NONE);
110            }
111            method = mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME) ? Method.P1_S3 : Method.HTTP_UPLOAD;
112            long knownFileSize = message.getFileParams().size;
113            if (knownFileSize > 0 && interactive && method != Method.P1_S3) {
114                this.file.setExpectedSize(knownFileSize);
115                download(true);
116            } else {
117                checkFileSize(interactive);
118            }
119        } catch (MalformedURLException e) {
120            this.cancel();
121        }
122    }
123
124    private void setupFile() {
125        final String reference = mUrl.getRef();
126        if (reference != null && AesGcmURLStreamHandler.IV_KEY.matcher(reference).matches()) {
127            this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
128            this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
129            Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
130        } else {
131            this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
132        }
133    }
134
135    private void download(boolean interactive) {
136        new Thread(new FileDownloader(interactive)).start();
137    }
138
139    private void checkFileSize(boolean interactive) {
140        new Thread(new FileSizeChecker(interactive)).start();
141    }
142
143    @Override
144    public void cancel() {
145        this.canceled = true;
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(Exception e) {
210        if (e instanceof java.net.UnknownHostException) {
211            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
212        } else if (e instanceof java.net.ConnectException) {
213            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
214        } else if (e instanceof FileWriterException) {
215            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
216        } else if (!(e instanceof CancellationException)) {
217            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
218        }
219    }
220
221    private void updateProgress(long i) {
222        this.mProgress = (int) i;
223        mHttpConnectionManager.updateConversationUi(false);
224    }
225
226    @Override
227    public int getStatus() {
228        return this.mStatus;
229    }
230
231    @Override
232    public long getFileSize() {
233        if (this.file != null) {
234            return this.file.getExpectedSize();
235        } else {
236            return 0;
237        }
238    }
239
240    @Override
241    public int getProgress() {
242        return this.mProgress;
243    }
244
245    public Message getMessage() {
246        return message;
247    }
248
249    private class FileSizeChecker implements Runnable {
250
251        private final boolean interactive;
252
253        FileSizeChecker(boolean interactive) {
254            this.interactive = interactive;
255        }
256
257
258        @Override
259        public void run() {
260            if (mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME)) {
261                retrieveUrl();
262            } else {
263                check();
264            }
265        }
266
267        private void retrieveUrl() {
268            changeStatus(STATUS_CHECKING);
269            final Account account = message.getConversation().getAccount();
270            IqPacket request = mXmppConnectionService.getIqGenerator().requestP1S3Url(account.getDomain(), mUrl.getHost());
271            mXmppConnectionService.sendIqPacket(message.getConversation().getAccount(), request, (a, packet) -> {
272                if (packet.getType() == IqPacket.TYPE.RESULT) {
273                    String download = packet.query().getAttribute("download");
274                    if (download != null) {
275                        try {
276                            mUrl = new URL(download);
277                            check();
278                            return;
279                        } catch (MalformedURLException e) {
280                            //fallthrough
281                        }
282                    }
283                }
284                Log.d(Config.LOGTAG, "unable to retrieve actual download url");
285                retrieveFailed(null);
286            });
287        }
288
289        private void retrieveFailed(@Nullable Exception e) {
290            changeStatus(STATUS_OFFER_CHECK_FILESIZE);
291            if (interactive) {
292                if (e != null) {
293                    showToastForException(e);
294                }
295            } else {
296                HttpDownloadConnection.this.acceptedAutomatically = false;
297                HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
298            }
299            cancel();
300        }
301
302        private void check() {
303            long size;
304            try {
305                size = retrieveFileSize();
306            } catch (Exception e) {
307                Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
308                retrieveFailed(e);
309                return;
310            }
311            final Message.FileParams fileParams = message.getFileParams();
312            FileBackend.updateFileParams(message, fileParams.url, size);
313            message.setOob(true);
314            mXmppConnectionService.databaseBackend.updateMessage(message, true);
315            file.setExpectedSize(size);
316            message.resetFileParams();
317            if (mHttpConnectionManager.hasStoragePermission()
318                    && size <= mHttpConnectionManager.getAutoAcceptFileSize()
319                    && mXmppConnectionService.isDataSaverDisabled()) {
320                HttpDownloadConnection.this.acceptedAutomatically = true;
321                download(interactive);
322            } else {
323                changeStatus(STATUS_OFFER);
324                HttpDownloadConnection.this.acceptedAutomatically = false;
325                HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
326            }
327        }
328
329        private long retrieveFileSize() throws IOException {
330            try {
331                Log.d(Config.LOGTAG, "retrieve file size. interactive:" + String.valueOf(interactive));
332                changeStatus(STATUS_CHECKING);
333                HttpURLConnection connection;
334                final String hostname = mUrl.getHost();
335                final boolean onion = hostname != null && hostname.endsWith(".onion");
336                if (mUseTor || message.getConversation().getAccount().isOnion() || onion) {
337                    connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
338                } else {
339                    connection = (HttpURLConnection) mUrl.openConnection();
340                }
341                if (method == Method.P1_S3) {
342                    connection.setRequestMethod("GET");
343                    connection.addRequestProperty("Range", "bytes=0-0");
344                } else {
345                    connection.setRequestMethod("HEAD");
346                }
347                connection.setUseCaches(false);
348                Log.d(Config.LOGTAG, "url: " + connection.getURL().toString());
349                connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
350                if (connection instanceof HttpsURLConnection) {
351                    mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
352                }
353                connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
354                connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
355                connection.connect();
356                String contentLength;
357                if (method == Method.P1_S3) {
358                    String contentRange = connection.getHeaderField("Content-Range");
359                    String[] contentRangeParts = contentRange == null ? new String[0] : contentRange.split("/");
360                    if (contentRangeParts.length != 2) {
361                        contentLength = null;
362                    } else {
363                        contentLength = contentRangeParts[1];
364                    }
365                } else {
366                    contentLength = connection.getHeaderField("Content-Length");
367                }
368                final String contentType = connection.getContentType();
369                final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath());
370                if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
371                    final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
372                    if (fileExtension != null) {
373                        message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), fileExtension));
374                        Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
375                        setupFile();
376                    }
377                }
378                connection.disconnect();
379                if (contentLength == null) {
380                    throw new IOException("no content-length found in HEAD response");
381                }
382                return Long.parseLong(contentLength, 10);
383            } catch (IOException e) {
384                Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
385                throw e;
386            } catch (NumberFormatException e) {
387                throw new IOException();
388            }
389        }
390
391    }
392
393    private class FileDownloader implements Runnable {
394
395        private final boolean interactive;
396
397        private OutputStream os;
398
399        public FileDownloader(boolean interactive) {
400            this.interactive = interactive;
401        }
402
403        @Override
404        public void run() {
405            try {
406                changeStatus(STATUS_DOWNLOADING);
407                download();
408                decryptIfNeeded();
409                updateImageBounds();
410                finish();
411            } catch (SSLHandshakeException e) {
412                changeStatus(STATUS_OFFER);
413            } catch (Exception e) {
414                if (interactive) {
415                    showToastForException(e);
416                } else {
417                    HttpDownloadConnection.this.acceptedAutomatically = false;
418                    HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
419                }
420                cancel();
421            }
422        }
423
424        private void download() throws Exception {
425            InputStream is = null;
426            HttpURLConnection connection = null;
427            PowerManager.WakeLock wakeLock = mHttpConnectionManager.createWakeLock("http_download_" + message.getUuid());
428            try {
429                wakeLock.acquire();
430                if (mUseTor || message.getConversation().getAccount().isOnion()) {
431                    connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
432                } else {
433                    connection = (HttpURLConnection) mUrl.openConnection();
434                }
435                if (connection instanceof HttpsURLConnection) {
436                    mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
437                }
438                connection.setUseCaches(false);
439                connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
440                final long expected = file.getExpectedSize();
441                final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
442                long resumeSize = 0;
443
444                if (tryResume) {
445                    resumeSize = file.getSize();
446                    Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
447                    connection.setRequestProperty("Range", "bytes=" + resumeSize + "-");
448                }
449                connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
450                connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
451                connection.connect();
452                is = new BufferedInputStream(connection.getInputStream());
453                final String contentRange = connection.getHeaderField("Content-Range");
454                boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
455                long transmitted = 0;
456                if (tryResume && serverResumed) {
457                    Log.d(Config.LOGTAG, "server resumed");
458                    transmitted = file.getSize();
459                    updateProgress(Math.round(((double) transmitted / expected) * 100));
460                    os = AbstractConnectionManager.createOutputStream(file, true, false);
461                    if (os == null) {
462                        throw new FileWriterException();
463                    }
464                } else {
465                    long reportedContentLengthOnGet;
466                    try {
467                        reportedContentLengthOnGet = Long.parseLong(connection.getHeaderField("Content-Length"));
468                    } catch (NumberFormatException | NullPointerException e) {
469                        reportedContentLengthOnGet = 0;
470                    }
471                    if (expected != reportedContentLengthOnGet) {
472                        Log.d(Config.LOGTAG, "content-length reported on GET (" + reportedContentLengthOnGet + ") did not match Content-Length reported on HEAD (" + expected + ")");
473                    }
474                    file.getParentFile().mkdirs();
475                    if (!file.exists() && !file.createNewFile()) {
476                        throw new FileWriterException();
477                    }
478                    os = AbstractConnectionManager.createOutputStream(file, false, false);
479                }
480                int count;
481                byte[] buffer = new byte[4096];
482                while ((count = is.read(buffer)) != -1) {
483                    transmitted += count;
484                    try {
485                        os.write(buffer, 0, count);
486                    } catch (IOException e) {
487                        throw new FileWriterException();
488                    }
489                    updateProgress(Math.round(((double) transmitted / expected) * 100));
490                    if (canceled) {
491                        throw new CancellationException();
492                    }
493                }
494                try {
495                    os.flush();
496                } catch (IOException e) {
497                    throw new FileWriterException();
498                }
499            } catch (CancellationException | IOException e) {
500                Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": http download failed", e);
501                throw e;
502            } finally {
503                FileBackend.close(os);
504                FileBackend.close(is);
505                if (connection != null) {
506                    connection.disconnect();
507                }
508                WakeLockHelper.release(wakeLock);
509            }
510        }
511
512        private void updateImageBounds() {
513            final boolean privateMessage = message.isPrivateMessage();
514            message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
515            final URL url;
516            final String ref = mUrl.getRef();
517            if (method == Method.P1_S3) {
518                url = message.getFileParams().url;
519            } else if (ref != null && AesGcmURLStreamHandler.IV_KEY.matcher(ref).matches()) {
520                url = CryptoHelper.toAesGcmUrl(mUrl);
521            } else {
522                url = mUrl;
523            }
524            mXmppConnectionService.getFileBackend().updateFileParams(message, url);
525            mXmppConnectionService.updateMessage(message);
526        }
527
528    }
529}