1package eu.siacs.conversations.http;
2
3import android.util.Log;
4
5import androidx.annotation.Nullable;
6
7import com.google.common.base.Strings;
8import com.google.common.io.ByteStreams;
9import com.google.common.primitives.Longs;
10
11import java.io.FileInputStream;
12import java.io.IOException;
13import java.io.InputStream;
14import java.io.OutputStream;
15import java.util.Locale;
16import java.util.concurrent.CancellationException;
17
18import javax.net.ssl.SSLHandshakeException;
19
20import eu.siacs.conversations.Config;
21import eu.siacs.conversations.R;
22import eu.siacs.conversations.entities.DownloadableFile;
23import eu.siacs.conversations.entities.Message;
24import eu.siacs.conversations.entities.Transferable;
25import eu.siacs.conversations.persistance.FileBackend;
26import eu.siacs.conversations.services.AbstractConnectionManager;
27import eu.siacs.conversations.services.XmppConnectionService;
28import eu.siacs.conversations.utils.CryptoHelper;
29import eu.siacs.conversations.utils.FileWriterException;
30import eu.siacs.conversations.utils.MimeUtils;
31import okhttp3.Call;
32import okhttp3.HttpUrl;
33import okhttp3.OkHttpClient;
34import okhttp3.Request;
35import okhttp3.Response;
36
37import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
38
39public class HttpDownloadConnection implements Transferable {
40
41 private final Message message;
42 private final boolean mUseTor;
43 private final HttpConnectionManager mHttpConnectionManager;
44 private final XmppConnectionService mXmppConnectionService;
45 private HttpUrl mUrl;
46 private DownloadableFile file;
47 private int mStatus = Transferable.STATUS_UNKNOWN;
48 private boolean acceptedAutomatically = false;
49 private int mProgress = 0;
50 private boolean canceled = false;
51 private Call mostRecentCall;
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.setOob(true);
82 message.setDeleted(false);
83 mXmppConnectionService.updateMessage(message);
84 }
85 this.message.setTransferable(this);
86 try {
87 final Message.FileParams fileParams = message.getFileParams();
88 if (message.hasFileOnRemoteHost()) {
89 mUrl = AesGcmURL.of(fileParams.url);
90 } else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) {
91 mUrl = AesGcmURL.of(fileParams.url);
92 } else {
93 mUrl = AesGcmURL.of(message.getBody().split("\n")[0]);
94 }
95 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
96 if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
97 this.message.setEncryption(Message.ENCRYPTION_PGP);
98 } else if (message.getEncryption() != Message.ENCRYPTION_OTR
99 && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
100 this.message.setEncryption(Message.ENCRYPTION_NONE);
101 }
102 final String ext = extension.getExtension();
103 if (ext != null) {
104 message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), ext));
105 } else if (Strings.isNullOrEmpty(message.getRelativeFilePath())) {
106 message.setRelativeFilePath(message.getUuid());
107 }
108 setupFile();
109 if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
110 this.message.setEncryption(Message.ENCRYPTION_NONE);
111 }
112 //TODO add auth tag size to knownFileSize
113 final long knownFileSize = message.getFileParams().size;
114 if (knownFileSize > 0 && interactive) {
115 this.file.setExpectedSize(knownFileSize);
116 download(true);
117 } else {
118 checkFileSize(interactive);
119 }
120 } catch (final IllegalArgumentException e) {
121 this.cancel();
122 }
123 }
124
125 private void setupFile() {
126 final String reference = mUrl.fragment();
127 if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
128 this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
129 this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
130 Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
131 } else {
132 this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
133 }
134 }
135
136 private void download(final boolean interactive) {
137 EXECUTOR.execute(new FileDownloader(interactive));
138 }
139
140 private void checkFileSize(final boolean interactive) {
141 EXECUTOR.execute(new FileSizeChecker(interactive));
142 }
143
144 @Override
145 public void cancel() {
146 this.canceled = true;
147 final Call call = this.mostRecentCall;
148 if (call != null && !call.isCanceled()) {
149 call.cancel();
150 }
151 mHttpConnectionManager.finishConnection(this);
152 message.setTransferable(null);
153 if (message.isFileOrImage()) {
154 message.setDeleted(true);
155 }
156 mHttpConnectionManager.updateConversationUi(true);
157 }
158
159 private void decryptFile() throws IOException {
160 final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
161
162 if (outputFile.getParentFile().mkdirs()) {
163 Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
164 }
165
166 if (!outputFile.createNewFile()) {
167 Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
168 }
169
170 final InputStream is = new FileInputStream(this.file);
171
172 outputFile.setKey(this.file.getKey());
173 outputFile.setIv(this.file.getIv());
174 final OutputStream os = AbstractConnectionManager.createOutputStream(outputFile, false, true);
175
176 ByteStreams.copy(is, os);
177
178 FileBackend.close(is);
179 FileBackend.close(os);
180
181 if (!file.delete()) {
182 Log.w(Config.LOGTAG, "unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath());
183 }
184 }
185
186 private void finish() {
187 message.setTransferable(null);
188 mHttpConnectionManager.finishConnection(this);
189 boolean notify = acceptedAutomatically && !message.isRead();
190 if (message.getEncryption() == Message.ENCRYPTION_PGP) {
191 notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
192 }
193 mHttpConnectionManager.updateConversationUi(true);
194 final boolean notifyAfterScan = notify;
195 final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
196 mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
197 if (notifyAfterScan) {
198 mXmppConnectionService.getNotificationService().push(message);
199 }
200 });
201 }
202
203 private void decryptIfNeeded() throws IOException {
204 if (file.getKey() != null && file.getIv() != null) {
205 decryptFile();
206 }
207 }
208
209 private void changeStatus(int status) {
210 this.mStatus = status;
211 mHttpConnectionManager.updateConversationUi(true);
212 }
213
214 private void showToastForException(final Exception e) {
215 final Call call = mostRecentCall;
216 final boolean cancelled = call != null && call.isCanceled();
217 if (e == null || cancelled) {
218 return;
219 }
220 if (e instanceof java.net.UnknownHostException) {
221 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
222 } else if (e instanceof java.net.ConnectException) {
223 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
224 } else if (e instanceof FileWriterException) {
225 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
226 } else {
227 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
228 }
229 }
230
231 private void updateProgress(long i) {
232 this.mProgress = (int) i;
233 mHttpConnectionManager.updateConversationUi(false);
234 }
235
236 @Override
237 public int getStatus() {
238 return this.mStatus;
239 }
240
241 @Override
242 public long getFileSize() {
243 if (this.file != null) {
244 return this.file.getExpectedSize();
245 } else {
246 return 0;
247 }
248 }
249
250 @Override
251 public int getProgress() {
252 return this.mProgress;
253 }
254
255 public Message getMessage() {
256 return message;
257 }
258
259 private class FileSizeChecker implements Runnable {
260
261 private final boolean interactive;
262
263 FileSizeChecker(boolean interactive) {
264 this.interactive = interactive;
265 }
266
267
268 @Override
269 public void run() {
270 check();
271 }
272
273 private void retrieveFailed(@Nullable Exception e) {
274 changeStatus(STATUS_OFFER_CHECK_FILESIZE);
275 if (interactive) {
276 showToastForException(e);
277 } else {
278 HttpDownloadConnection.this.acceptedAutomatically = false;
279 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
280 }
281 cancel();
282 }
283
284 private void check() {
285 long size;
286 try {
287 size = retrieveFileSize();
288 } catch (Exception e) {
289 Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
290 retrieveFailed(e);
291 return;
292 }
293 final Message.FileParams fileParams = message.getFileParams();
294 FileBackend.updateFileParams(message, fileParams.url, size);
295 message.setOob(true);
296 mXmppConnectionService.databaseBackend.updateMessage(message, true);
297 file.setExpectedSize(size);
298 message.resetFileParams();
299 if (mHttpConnectionManager.hasStoragePermission()
300 && size <= mHttpConnectionManager.getAutoAcceptFileSize()
301 && mXmppConnectionService.isDataSaverDisabled()) {
302 HttpDownloadConnection.this.acceptedAutomatically = true;
303 download(interactive);
304 } else {
305 changeStatus(STATUS_OFFER);
306 HttpDownloadConnection.this.acceptedAutomatically = false;
307 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
308 }
309 }
310
311 private long retrieveFileSize() throws IOException {
312 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
313 mUrl,
314 message.getConversation().getAccount(),
315 interactive
316 );
317 final Request request = new Request.Builder()
318 .url(URL.stripFragment(mUrl))
319 .head()
320 .build();
321 mostRecentCall = client.newCall(request);
322 try {
323 final Response response = mostRecentCall.execute();
324 final String contentLength = response.header("Content-Length");
325 final String contentType = response.header("Content-Type");
326 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
327 if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
328 final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
329 if (fileExtension != null) {
330 message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), fileExtension));
331 Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
332 setupFile();
333 }
334 }
335 if (Strings.isNullOrEmpty(contentLength)) {
336 throw new IOException("no content-length found in HEAD response");
337 }
338 return Long.parseLong(contentLength, 10);
339 } catch (IOException e) {
340 Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
341 throw e;
342 } catch (NumberFormatException e) {
343 throw new IOException();
344 }
345 }
346
347 }
348
349 private class FileDownloader implements Runnable {
350
351 private final boolean interactive;
352
353 public FileDownloader(boolean interactive) {
354 this.interactive = interactive;
355 }
356
357 @Override
358 public void run() {
359 try {
360 changeStatus(STATUS_DOWNLOADING);
361 download();
362 decryptIfNeeded();
363 updateImageBounds();
364 finish();
365 } catch (final SSLHandshakeException e) {
366 changeStatus(STATUS_OFFER);
367 } catch (final Exception e) {
368 Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": unable to download file", e);
369 if (interactive) {
370 showToastForException(e);
371 } else {
372 HttpDownloadConnection.this.acceptedAutomatically = false;
373 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
374 }
375 cancel();
376 }
377 }
378
379 private void download() throws Exception {
380 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
381 mUrl,
382 message.getConversation().getAccount(),
383 interactive
384 );
385
386 final Request.Builder requestBuilder = new Request.Builder().url(URL.stripFragment(mUrl));
387
388 final long expected = file.getExpectedSize();
389 final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
390 final long resumeSize;
391 if (tryResume) {
392 resumeSize = file.getSize();
393 Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
394 requestBuilder.addHeader("Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
395 } else {
396 resumeSize = 0;
397 }
398 final Request request = requestBuilder.build();
399 mostRecentCall = client.newCall(request);
400 final Response response = mostRecentCall.execute();
401 final int code = response.code();
402 if (code >= 200 && code <= 299) {
403 final String contentRange = response.header("Content-Range");
404 final boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
405 final InputStream inputStream = response.body().byteStream();
406 final OutputStream outputStream;
407 long transmitted = 0;
408 if (tryResume && serverResumed) {
409 Log.d(Config.LOGTAG, "server resumed");
410 transmitted = file.getSize();
411 updateProgress(Math.round(((double) transmitted / expected) * 100));
412 outputStream = AbstractConnectionManager.createOutputStream(file, true, false);
413 } else {
414 final String contentLength = response.header("Content-Length");
415 final long size = Strings.isNullOrEmpty(contentLength) ? 0 : Longs.tryParse(contentLength);
416 if (expected != size) {
417 Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
418 }
419 file.getParentFile().mkdirs();
420 if (!file.exists() && !file.createNewFile()) {
421 throw new FileWriterException();
422 }
423 outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
424 }
425 int count;
426 final byte[] buffer = new byte[4096];
427 while ((count = inputStream.read(buffer)) != -1) {
428 transmitted += count;
429 try {
430 outputStream.write(buffer, 0, count);
431 } catch (IOException e) {
432 throw new FileWriterException();
433 }
434 updateProgress(Math.round(((double) transmitted / expected) * 100));
435 }
436 outputStream.flush();
437 } else {
438 throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
439 }
440 }
441
442 private void updateImageBounds() {
443 final boolean privateMessage = message.isPrivateMessage();
444 message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
445 final String url;
446 final String ref = mUrl.fragment();
447 if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
448 url = AesGcmURL.toAesGcmUrl(mUrl);
449 } else {
450 url = mUrl.toString();
451 }
452 mXmppConnectionService.getFileBackend().updateFileParams(message, url);
453 mXmppConnectionService.updateMessage(message);
454 }
455
456 }
457}