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() {
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 final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
195 mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
196 if (notifyAfterScan) {
197 mXmppConnectionService.getNotificationService().push(message);
198 }
199 });
200 }
201
202 private void decryptIfNeeded() {
203 if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
204 decryptOmemoFile();
205 }
206 }
207
208 private void changeStatus(int status) {
209 this.mStatus = status;
210 mHttpConnectionManager.updateConversationUi(true);
211 }
212
213 private void showToastForException(Exception e) {
214 if (e instanceof java.net.UnknownHostException) {
215 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
216 } else if (e instanceof java.net.ConnectException) {
217 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
218 } else if (e instanceof FileWriterException) {
219 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
220 } else if (!(e instanceof CancellationException)) {
221 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
222 }
223 }
224
225 private void updateProgress(long i) {
226 this.mProgress = (int) i;
227 mHttpConnectionManager.updateConversationUi(false);
228 }
229
230 @Override
231 public int getStatus() {
232 return this.mStatus;
233 }
234
235 @Override
236 public long getFileSize() {
237 if (this.file != null) {
238 return this.file.getExpectedSize();
239 } else {
240 return 0;
241 }
242 }
243
244 @Override
245 public int getProgress() {
246 return this.mProgress;
247 }
248
249 public Message getMessage() {
250 return message;
251 }
252
253 private class FileSizeChecker implements Runnable {
254
255 private final boolean interactive;
256
257 FileSizeChecker(boolean interactive) {
258 this.interactive = interactive;
259 }
260
261
262 @Override
263 public void run() {
264 if (mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME)) {
265 retrieveUrl();
266 } else {
267 check();
268 }
269 }
270
271 private void retrieveUrl() {
272 changeStatus(STATUS_CHECKING);
273 final Account account = message.getConversation().getAccount();
274 IqPacket request = mXmppConnectionService.getIqGenerator().requestP1S3Url(Jid.of(account.getJid().getDomain()), mUrl.getHost());
275 mXmppConnectionService.sendIqPacket(message.getConversation().getAccount(), request, (a, packet) -> {
276 if (packet.getType() == IqPacket.TYPE.RESULT) {
277 String download = packet.query().getAttribute("download");
278 if (download != null) {
279 try {
280 mUrl = new URL(download);
281 check();
282 return;
283 } catch (MalformedURLException e) {
284 //fallthrough
285 }
286 }
287 }
288 Log.d(Config.LOGTAG,"unable to retrieve actual download url");
289 retrieveFailed(null);
290 });
291 }
292
293 private void retrieveFailed(@Nullable Exception e) {
294 changeStatus(STATUS_OFFER_CHECK_FILESIZE);
295 if (interactive) {
296 if (e != null) {
297 showToastForException(e);
298 }
299 } else {
300 HttpDownloadConnection.this.acceptedAutomatically = false;
301 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
302 }
303 cancel();
304 }
305
306 private void check() {
307 long size;
308 try {
309 size = retrieveFileSize();
310 } catch (Exception e) {
311 Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
312 retrieveFailed(e);
313 return;
314 }
315 final Message.FileParams fileParams = message.getFileParams();
316 FileBackend.updateFileParams(message, fileParams.url, size);
317 message.setOob(true);
318 mXmppConnectionService.databaseBackend.updateMessage(message, true);
319 file.setExpectedSize(size);
320 message.resetFileParams();
321 if (mHttpConnectionManager.hasStoragePermission()
322 && size <= mHttpConnectionManager.getAutoAcceptFileSize()
323 && mXmppConnectionService.isDataSaverDisabled()) {
324 HttpDownloadConnection.this.acceptedAutomatically = true;
325 download(interactive);
326 } else {
327 changeStatus(STATUS_OFFER);
328 HttpDownloadConnection.this.acceptedAutomatically = false;
329 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
330 }
331 }
332
333 private long retrieveFileSize() throws IOException {
334 try {
335 Log.d(Config.LOGTAG, "retrieve file size. interactive:" + String.valueOf(interactive));
336 changeStatus(STATUS_CHECKING);
337 HttpURLConnection connection;
338 final String hostname = mUrl.getHost();
339 final boolean onion = hostname != null && hostname.endsWith(".onion");
340 if (mUseTor || message.getConversation().getAccount().isOnion() || onion) {
341 connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
342 } else {
343 connection = (HttpURLConnection) mUrl.openConnection();
344 }
345 if (method == Method.P1_S3) {
346 connection.setRequestMethod("GET");
347 connection.addRequestProperty("Range","bytes=0-0");
348 } else {
349 connection.setRequestMethod("HEAD");
350 }
351 connection.setUseCaches(false);
352 Log.d(Config.LOGTAG, "url: " + connection.getURL().toString());
353 connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
354 if (connection instanceof HttpsURLConnection) {
355 mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
356 }
357 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
358 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
359 connection.connect();
360 String contentLength;
361 if (method == Method.P1_S3) {
362 String contentRange = connection.getHeaderField("Content-Range");
363 String[] contentRangeParts = contentRange == null ? new String[0] : contentRange.split("/");
364 if (contentRangeParts.length != 2) {
365 contentLength = null;
366 } else {
367 contentLength = contentRangeParts[1];
368 }
369 } else {
370 contentLength = connection.getHeaderField("Content-Length");
371 }
372 connection.disconnect();
373 if (contentLength == null) {
374 throw new IOException("no content-length found in HEAD response");
375 }
376 return Long.parseLong(contentLength, 10);
377 } catch (IOException e) {
378 Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
379 throw e;
380 } catch (NumberFormatException e) {
381 throw new IOException();
382 }
383 }
384
385 }
386
387 private class FileDownloader implements Runnable {
388
389 private final boolean interactive;
390
391 private OutputStream os;
392
393 public FileDownloader(boolean interactive) {
394 this.interactive = interactive;
395 }
396
397 @Override
398 public void run() {
399 try {
400 changeStatus(STATUS_DOWNLOADING);
401 download();
402 decryptIfNeeded();
403 updateImageBounds();
404 finish();
405 } catch (SSLHandshakeException e) {
406 changeStatus(STATUS_OFFER);
407 } catch (Exception e) {
408 if (interactive) {
409 showToastForException(e);
410 } else {
411 HttpDownloadConnection.this.acceptedAutomatically = false;
412 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
413 }
414 cancel();
415 }
416 }
417
418 private void download() throws Exception {
419 InputStream is = null;
420 HttpURLConnection connection = null;
421 PowerManager.WakeLock wakeLock = mHttpConnectionManager.createWakeLock("http_download_" + message.getUuid());
422 try {
423 wakeLock.acquire();
424 if (mUseTor || message.getConversation().getAccount().isOnion()) {
425 connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
426 } else {
427 connection = (HttpURLConnection) mUrl.openConnection();
428 }
429 if (connection instanceof HttpsURLConnection) {
430 mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
431 }
432 connection.setUseCaches(false);
433 connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
434 final long expected = file.getExpectedSize();
435 final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
436 long resumeSize = 0;
437
438 if (tryResume) {
439 resumeSize = file.getSize();
440 Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
441 connection.setRequestProperty("Range", "bytes=" + resumeSize + "-");
442 }
443 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
444 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
445 connection.connect();
446 is = new BufferedInputStream(connection.getInputStream());
447 final String contentRange = connection.getHeaderField("Content-Range");
448 boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
449 long transmitted = 0;
450 if (tryResume && serverResumed) {
451 Log.d(Config.LOGTAG, "server resumed");
452 transmitted = file.getSize();
453 updateProgress(Math.round(((double) transmitted / expected) * 100));
454 os = AbstractConnectionManager.createOutputStream(file, true, false);
455 if (os == null) {
456 throw new FileWriterException();
457 }
458 } else {
459 long reportedContentLengthOnGet;
460 try {
461 reportedContentLengthOnGet = Long.parseLong(connection.getHeaderField("Content-Length"));
462 } catch (NumberFormatException | NullPointerException e) {
463 reportedContentLengthOnGet = 0;
464 }
465 if (expected != reportedContentLengthOnGet) {
466 Log.d(Config.LOGTAG, "content-length reported on GET (" + reportedContentLengthOnGet + ") did not match Content-Length reported on HEAD (" + expected + ")");
467 }
468 file.getParentFile().mkdirs();
469 if (!file.exists() && !file.createNewFile()) {
470 throw new FileWriterException();
471 }
472 os = AbstractConnectionManager.createOutputStream(file, false, false);
473 }
474 int count;
475 byte[] buffer = new byte[4096];
476 while ((count = is.read(buffer)) != -1) {
477 transmitted += count;
478 try {
479 os.write(buffer, 0, count);
480 } catch (IOException e) {
481 throw new FileWriterException();
482 }
483 updateProgress(Math.round(((double) transmitted / expected) * 100));
484 if (canceled) {
485 throw new CancellationException();
486 }
487 }
488 try {
489 os.flush();
490 } catch (IOException e) {
491 throw new FileWriterException();
492 }
493 } catch (CancellationException | IOException e) {
494 Log.d(Config.LOGTAG, "http download failed " + e.getMessage());
495 throw e;
496 } finally {
497 FileBackend.close(os);
498 FileBackend.close(is);
499 if (connection != null) {
500 connection.disconnect();
501 }
502 WakeLockHelper.release(wakeLock);
503 }
504 }
505
506 private void updateImageBounds() {
507 final boolean privateMessage = message.isPrivateMessage();
508 message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
509 final URL url;
510 final String ref = mUrl.getRef();
511 if (method == Method.P1_S3) {
512 url = message.getFileParams().url;
513 } else if (ref != null && AesGcmURLStreamHandler.IV_KEY.matcher(ref).matches()) {
514 url = CryptoHelper.toAesGcmUrl(mUrl);
515 } else {
516 url = mUrl;
517 }
518 mXmppConnectionService.getFileBackend().updateFileParams(message, url);
519 mXmppConnectionService.updateMessage(message);
520 }
521
522 }
523}