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			file.setExpectedSize(size);
300			message.resetFileParams();
301			if (mHttpConnectionManager.hasStoragePermission()
302					&& size <= mHttpConnectionManager.getAutoAcceptFileSize()
303					&& mXmppConnectionService.isDataSaverDisabled()) {
304				HttpDownloadConnection.this.acceptedAutomatically = true;
305				download(interactive);
306			} else {
307				changeStatus(STATUS_OFFER);
308				HttpDownloadConnection.this.acceptedAutomatically = false;
309				HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
310			}
311		}
312
313		private long retrieveFileSize() throws IOException {
314			try {
315				Log.d(Config.LOGTAG, "retrieve file size. interactive:" + String.valueOf(interactive));
316				changeStatus(STATUS_CHECKING);
317				HttpURLConnection connection;
318				final String hostname = mUrl.getHost();
319				final boolean onion = hostname != null && hostname.endsWith(".onion");
320				if (mUseTor || message.getConversation().getAccount().isOnion() || onion) {
321					connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
322				} else {
323					connection = (HttpURLConnection) mUrl.openConnection();
324				}
325				if (method == Method.P1_S3) {
326					connection.setRequestMethod("GET");
327					connection.addRequestProperty("Range","bytes=0-0");
328				} else {
329					connection.setRequestMethod("HEAD");
330				}
331				connection.setUseCaches(false);
332				Log.d(Config.LOGTAG, "url: " + connection.getURL().toString());
333				connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
334				if (connection instanceof HttpsURLConnection) {
335					mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
336				}
337				connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
338				connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
339				connection.connect();
340				String contentLength;
341				if (method == Method.P1_S3) {
342					String contentRange = connection.getHeaderField("Content-Range");
343					String[] contentRangeParts = contentRange == null ? new String[0] : contentRange.split("/");
344					if (contentRangeParts.length != 2) {
345						contentLength = null;
346					} else {
347						contentLength = contentRangeParts[1];
348					}
349				} else {
350					contentLength = connection.getHeaderField("Content-Length");
351				}
352				connection.disconnect();
353				if (contentLength == null) {
354					throw new IOException("no content-length found in HEAD response");
355				}
356				return Long.parseLong(contentLength, 10);
357			} catch (IOException e) {
358				Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
359				throw e;
360			} catch (NumberFormatException e) {
361				throw new IOException();
362			}
363		}
364
365	}
366
367	private class FileDownloader implements Runnable {
368
369		private final boolean interactive;
370
371		private OutputStream os;
372
373		public FileDownloader(boolean interactive) {
374			this.interactive = interactive;
375		}
376
377		@Override
378		public void run() {
379			try {
380				changeStatus(STATUS_DOWNLOADING);
381				download();
382				finish();
383				updateImageBounds();
384			} catch (SSLHandshakeException e) {
385				changeStatus(STATUS_OFFER);
386			} catch (Exception e) {
387				if (interactive) {
388					showToastForException(e);
389				} else {
390					HttpDownloadConnection.this.acceptedAutomatically = false;
391					HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
392				}
393				cancel();
394			}
395		}
396
397		private void download() throws Exception {
398			InputStream is = null;
399			HttpURLConnection connection = null;
400			PowerManager.WakeLock wakeLock = mHttpConnectionManager.createWakeLock("http_download_" + message.getUuid());
401			try {
402				wakeLock.acquire();
403				if (mUseTor || message.getConversation().getAccount().isOnion()) {
404					connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
405				} else {
406					connection = (HttpURLConnection) mUrl.openConnection();
407				}
408				if (connection instanceof HttpsURLConnection) {
409					mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
410				}
411				connection.setUseCaches(false);
412				connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
413				final long expected = file.getExpectedSize();
414				final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
415				long resumeSize = 0;
416
417				if (tryResume) {
418					resumeSize = file.getSize();
419					Log.d(Config.LOGTAG, "http download trying resume after" + resumeSize + " of " + expected);
420					connection.setRequestProperty("Range", "bytes=" + resumeSize + "-");
421				}
422				connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
423				connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
424				connection.connect();
425				is = new BufferedInputStream(connection.getInputStream());
426				final String contentRange = connection.getHeaderField("Content-Range");
427				boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
428				long transmitted = 0;
429				if (tryResume && serverResumed) {
430					Log.d(Config.LOGTAG, "server resumed");
431					transmitted = file.getSize();
432					updateProgress(Math.round(((double) transmitted / expected) * 100));
433					os = AbstractConnectionManager.createOutputStream(file, true, false);
434					if (os == null) {
435						throw new FileWriterException();
436					}
437				} else {
438					long reportedContentLengthOnGet;
439					try {
440						reportedContentLengthOnGet = Long.parseLong(connection.getHeaderField("Content-Length"));
441					} catch (NumberFormatException | NullPointerException e) {
442						reportedContentLengthOnGet = 0;
443					}
444					if (expected != reportedContentLengthOnGet) {
445						Log.d(Config.LOGTAG, "content-length reported on GET (" + reportedContentLengthOnGet + ") did not match Content-Length reported on HEAD (" + expected + ")");
446					}
447					file.getParentFile().mkdirs();
448					if (!file.exists() && !file.createNewFile()) {
449						throw new FileWriterException();
450					}
451					os = AbstractConnectionManager.createOutputStream(file, false, false);
452				}
453				int count;
454				byte[] buffer = new byte[4096];
455				while ((count = is.read(buffer)) != -1) {
456					transmitted += count;
457					try {
458						os.write(buffer, 0, count);
459					} catch (IOException e) {
460						throw new FileWriterException();
461					}
462					updateProgress(Math.round(((double) transmitted / expected) * 100));
463					if (canceled) {
464						throw new CancellationException();
465					}
466				}
467				try {
468					os.flush();
469				} catch (IOException e) {
470					throw new FileWriterException();
471				}
472			} catch (CancellationException | IOException e) {
473				Log.d(Config.LOGTAG, "http download failed " + e.getMessage());
474				throw e;
475			} finally {
476				FileBackend.close(os);
477				FileBackend.close(is);
478				if (connection != null) {
479					connection.disconnect();
480				}
481				WakeLockHelper.release(wakeLock);
482			}
483		}
484
485		private void updateImageBounds() {
486			final boolean privateMessage = message.isPrivateMessage();
487			message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
488			final URL url;
489			final String ref = mUrl.getRef();
490			if (method == Method.P1_S3) {
491				url = message.getFileParams().url;
492			} else if (ref != null && AesGcmURLStreamHandler.IV_KEY.matcher(ref).matches()) {
493				url = CryptoHelper.toAesGcmUrl(mUrl);
494			} else {
495				url = mUrl;
496			}
497			mXmppConnectionService.getFileBackend().updateFileParams(message, url);
498			mXmppConnectionService.updateMessage(message);
499		}
500
501	}
502}