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