HttpDownloadConnection.java

  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		this.message.setTransferable(this);
 76		try {
 77			if (message.hasFileOnRemoteHost()) {
 78				mUrl = CryptoHelper.toHttpsUrl(message.getFileParams().url);
 79			} else {
 80				mUrl = CryptoHelper.toHttpsUrl(new URL(message.getBody().split("\n")[0]));
 81			}
 82			final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath());
 83			if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 84				this.message.setEncryption(Message.ENCRYPTION_PGP);
 85			} else if (message.getEncryption() != Message.ENCRYPTION_OTR
 86					&& message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
 87				this.message.setEncryption(Message.ENCRYPTION_NONE);
 88			}
 89			final String ext;
 90			if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 91				ext = extension.secondary;
 92			} else {
 93				ext = extension.main;
 94			}
 95			message.setRelativeFilePath(message.getUuid() + (ext != null ? ("." + ext) : ""));
 96			if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 97				this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
 98				Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
 99			} else {
100				this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
101			}
102			final String reference = mUrl.getRef();
103			if (reference != null && AesGcmURLStreamHandler.IV_KEY.matcher(reference).matches()) {
104				this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
105			}
106
107			if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
108				this.message.setEncryption(Message.ENCRYPTION_NONE);
109			}
110			method = mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME) ? Method.P1_S3 : Method.HTTP_UPLOAD;
111			long knownFileSize = message.getFileParams().size;
112			if (knownFileSize > 0 && interactive && method != Method.P1_S3) {
113				this.file.setExpectedSize(knownFileSize);
114				download(true);
115			} else {
116				checkFileSize(interactive);
117			}
118		} catch (MalformedURLException e) {
119			this.cancel();
120		}
121	}
122
123	private void download(boolean interactive) {
124		new Thread(new FileDownloader(interactive)).start();
125	}
126
127	private void checkFileSize(boolean interactive) {
128		new Thread(new FileSizeChecker(interactive)).start();
129	}
130
131	@Override
132	public void cancel() {
133		this.canceled = true;
134		mHttpConnectionManager.finishConnection(this);
135		message.setTransferable(null);
136		if (message.isFileOrImage()) {
137			message.setDeleted(true);
138		}
139		mHttpConnectionManager.updateConversationUi(true);
140	}
141
142	private void decryptOmemoFile() throws Exception {
143		final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
144
145		if (outputFile.getParentFile().mkdirs()) {
146			Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
147		}
148
149		try {
150			outputFile.createNewFile();
151			final InputStream is = new FileInputStream(this.file);
152
153			outputFile.setKey(this.file.getKey());
154			outputFile.setIv(this.file.getIv());
155			final OutputStream os = AbstractConnectionManager.createOutputStream(outputFile, false, true);
156
157			ByteStreams.copy(is, os);
158
159			FileBackend.close(is);
160			FileBackend.close(os);
161
162			if (!file.delete()) {
163				Log.w(Config.LOGTAG,"unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath());
164			}
165
166			message.setRelativeFilePath(outputFile.getPath());
167		} catch (IOException e) {
168			message.setEncryption(Message.ENCRYPTION_DECRYPTION_FAILED);
169			mXmppConnectionService.updateMessage(message);
170		}
171	}
172
173	private void finish() throws Exception {
174		if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL)	{
175			decryptOmemoFile();
176		}
177		message.setTransferable(null);
178		mHttpConnectionManager.finishConnection(this);
179		boolean notify = acceptedAutomatically && !message.isRead();
180		if (message.getEncryption() == Message.ENCRYPTION_PGP) {
181			notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
182		}
183		mHttpConnectionManager.updateConversationUi(true);
184		final boolean notifyAfterScan = notify;
185		mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
186			if (notifyAfterScan) {
187				mXmppConnectionService.getNotificationService().push(message);
188			}
189		});
190	}
191
192	private void changeStatus(int status) {
193		this.mStatus = status;
194		mHttpConnectionManager.updateConversationUi(true);
195	}
196
197	private void showToastForException(Exception e) {
198		if (e instanceof java.net.UnknownHostException) {
199			mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
200		} else if (e instanceof java.net.ConnectException) {
201			mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
202		} else if (e instanceof FileWriterException) {
203			mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
204		} else if (!(e instanceof CancellationException)) {
205			mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
206		}
207	}
208
209	private void updateProgress(long i) {
210		this.mProgress = (int) i;
211		mHttpConnectionManager.updateConversationUi(false);
212	}
213
214	@Override
215	public int getStatus() {
216		return this.mStatus;
217	}
218
219	@Override
220	public long getFileSize() {
221		if (this.file != null) {
222			return this.file.getExpectedSize();
223		} else {
224			return 0;
225		}
226	}
227
228	@Override
229	public int getProgress() {
230		return this.mProgress;
231	}
232
233	public Message getMessage() {
234		return message;
235	}
236
237	private class FileSizeChecker implements Runnable {
238
239		private final boolean interactive;
240
241		FileSizeChecker(boolean interactive) {
242			this.interactive = interactive;
243		}
244
245
246		@Override
247		public void run() {
248			if (mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME)) {
249				retrieveUrl();
250			} else {
251				check();
252			}
253		}
254
255		private void retrieveUrl() {
256			changeStatus(STATUS_CHECKING);
257			final Account account = message.getConversation().getAccount();
258			IqPacket request = mXmppConnectionService.getIqGenerator().requestP1S3Url(Jid.of(account.getJid().getDomain()), mUrl.getHost());
259			mXmppConnectionService.sendIqPacket(message.getConversation().getAccount(), request, (a, packet) -> {
260				if (packet.getType() == IqPacket.TYPE.RESULT) {
261					String download = packet.query().getAttribute("download");
262					if (download != null) {
263						try {
264							mUrl = new URL(download);
265							check();
266							return;
267						} catch (MalformedURLException e) {
268							//fallthrough
269						}
270					}
271				}
272				Log.d(Config.LOGTAG,"unable to retrieve actual download url");
273				retrieveFailed(null);
274			});
275		}
276
277		private void retrieveFailed(@Nullable Exception e) {
278			changeStatus(STATUS_OFFER_CHECK_FILESIZE);
279			if (interactive) {
280				if (e != null) {
281					showToastForException(e);
282				}
283			} else {
284				HttpDownloadConnection.this.acceptedAutomatically = false;
285				HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
286			}
287			cancel();
288		}
289
290		private void check() {
291			long size;
292			try {
293				size = retrieveFileSize();
294			} catch (Exception e) {
295				Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
296				retrieveFailed(e);
297				return;
298			}
299			//TODO at this stage we probably also want to persist the file size in the body of the
300			// message via a similar mechansim as updateFileParams() - essentially body needs to read
301			// "url|filesize"
302			// afterwards a file that failed to download mid way will not display 'check file size' anymore
303			file.setExpectedSize(size);
304			message.resetFileParams();
305			if (mHttpConnectionManager.hasStoragePermission()
306					&& size <= mHttpConnectionManager.getAutoAcceptFileSize()
307					&& mXmppConnectionService.isDataSaverDisabled()) {
308				HttpDownloadConnection.this.acceptedAutomatically = true;
309				download(interactive);
310			} else {
311				changeStatus(STATUS_OFFER);
312				HttpDownloadConnection.this.acceptedAutomatically = false;
313				HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
314			}
315		}
316
317		private long retrieveFileSize() throws IOException {
318			try {
319				Log.d(Config.LOGTAG, "retrieve file size. interactive:" + String.valueOf(interactive));
320				changeStatus(STATUS_CHECKING);
321				HttpURLConnection connection;
322				final String hostname = mUrl.getHost();
323				final boolean onion = hostname != null && hostname.endsWith(".onion");
324				if (mUseTor || message.getConversation().getAccount().isOnion() || onion) {
325					connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
326				} else {
327					connection = (HttpURLConnection) mUrl.openConnection();
328				}
329				if (method == Method.P1_S3) {
330					connection.setRequestMethod("GET");
331					connection.addRequestProperty("Range","bytes=0-0");
332				} else {
333					connection.setRequestMethod("HEAD");
334				}
335				connection.setUseCaches(false);
336				Log.d(Config.LOGTAG, "url: " + connection.getURL().toString());
337				connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
338				if (connection instanceof HttpsURLConnection) {
339					mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
340				}
341				connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
342				connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
343				connection.connect();
344				String contentLength;
345				if (method == Method.P1_S3) {
346					String contentRange = connection.getHeaderField("Content-Range");
347					String[] contentRangeParts = contentRange == null ? new String[0] : contentRange.split("/");
348					if (contentRangeParts.length != 2) {
349						contentLength = null;
350					} else {
351						contentLength = contentRangeParts[1];
352					}
353				} else {
354					contentLength = connection.getHeaderField("Content-Length");
355				}
356				connection.disconnect();
357				if (contentLength == null) {
358					throw new IOException("no content-length found in HEAD response");
359				}
360				return Long.parseLong(contentLength, 10);
361			} catch (IOException e) {
362				Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
363				throw e;
364			} catch (NumberFormatException e) {
365				throw new IOException();
366			}
367		}
368
369	}
370
371	private class FileDownloader implements Runnable {
372
373		private final boolean interactive;
374
375		private OutputStream os;
376
377		public FileDownloader(boolean interactive) {
378			this.interactive = interactive;
379		}
380
381		@Override
382		public void run() {
383			try {
384				changeStatus(STATUS_DOWNLOADING);
385				download();
386				finish();
387				updateImageBounds();
388			} catch (SSLHandshakeException e) {
389				changeStatus(STATUS_OFFER);
390			} catch (Exception e) {
391				if (interactive) {
392					showToastForException(e);
393				} else {
394					HttpDownloadConnection.this.acceptedAutomatically = false;
395					HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
396				}
397				cancel();
398			}
399		}
400
401		private void download() throws Exception {
402			InputStream is = null;
403			HttpURLConnection connection = null;
404			PowerManager.WakeLock wakeLock = mHttpConnectionManager.createWakeLock("http_download_" + message.getUuid());
405			try {
406				wakeLock.acquire();
407				if (mUseTor || message.getConversation().getAccount().isOnion()) {
408					connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
409				} else {
410					connection = (HttpURLConnection) mUrl.openConnection();
411				}
412				if (connection instanceof HttpsURLConnection) {
413					mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
414				}
415				connection.setUseCaches(false);
416				connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
417				final long expected = file.getExpectedSize();
418				final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
419				long resumeSize = 0;
420
421				if (tryResume) {
422					resumeSize = file.getSize();
423					Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
424					connection.setRequestProperty("Range", "bytes=" + resumeSize + "-");
425				}
426				connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
427				connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
428				connection.connect();
429				is = new BufferedInputStream(connection.getInputStream());
430				final String contentRange = connection.getHeaderField("Content-Range");
431				boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
432				long transmitted = 0;
433				if (tryResume && serverResumed) {
434					Log.d(Config.LOGTAG, "server resumed");
435					transmitted = file.getSize();
436					updateProgress(Math.round(((double) transmitted / expected) * 100));
437					os = AbstractConnectionManager.createOutputStream(file, true, false);
438					if (os == null) {
439						throw new FileWriterException();
440					}
441				} else {
442					long reportedContentLengthOnGet;
443					try {
444						reportedContentLengthOnGet = Long.parseLong(connection.getHeaderField("Content-Length"));
445					} catch (NumberFormatException | NullPointerException e) {
446						reportedContentLengthOnGet = 0;
447					}
448					if (expected != reportedContentLengthOnGet) {
449						Log.d(Config.LOGTAG, "content-length reported on GET (" + reportedContentLengthOnGet + ") did not match Content-Length reported on HEAD (" + expected + ")");
450					}
451					file.getParentFile().mkdirs();
452					if (!file.exists() && !file.createNewFile()) {
453						throw new FileWriterException();
454					}
455					os = AbstractConnectionManager.createOutputStream(file, false, false);
456				}
457				int count;
458				byte[] buffer = new byte[4096];
459				while ((count = is.read(buffer)) != -1) {
460					transmitted += count;
461					try {
462						os.write(buffer, 0, count);
463					} catch (IOException e) {
464						throw new FileWriterException();
465					}
466					updateProgress(Math.round(((double) transmitted / expected) * 100));
467					if (canceled) {
468						throw new CancellationException();
469					}
470				}
471				try {
472					os.flush();
473				} catch (IOException e) {
474					throw new FileWriterException();
475				}
476			} catch (CancellationException | IOException e) {
477				Log.d(Config.LOGTAG, "http download failed " + e.getMessage());
478				throw e;
479			} finally {
480				FileBackend.close(os);
481				FileBackend.close(is);
482				if (connection != null) {
483					connection.disconnect();
484				}
485				WakeLockHelper.release(wakeLock);
486			}
487		}
488
489		private void updateImageBounds() {
490			final boolean privateMessage = message.isPrivateMessage();
491			message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
492			final URL url;
493			final String ref = mUrl.getRef();
494			if (method == Method.P1_S3) {
495				url = message.getFileParams().url;
496			} else if (ref != null && AesGcmURLStreamHandler.IV_KEY.matcher(ref).matches()) {
497				url = CryptoHelper.toAesGcmUrl(mUrl);
498			} else {
499				url = mUrl;
500			}
501			mXmppConnectionService.getFileBackend().updateFileParams(message, url);
502			mXmppConnectionService.updateMessage(message);
503		}
504
505	}
506}