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}