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;
16
17import javax.net.ssl.SSLHandshakeException;
18
19import eu.siacs.conversations.Config;
20import eu.siacs.conversations.R;
21import eu.siacs.conversations.entities.DownloadableFile;
22import eu.siacs.conversations.entities.Message;
23import eu.siacs.conversations.entities.Transferable;
24import eu.siacs.conversations.persistance.FileBackend;
25import eu.siacs.conversations.services.AbstractConnectionManager;
26import eu.siacs.conversations.services.XmppConnectionService;
27import eu.siacs.conversations.utils.CryptoHelper;
28import eu.siacs.conversations.utils.FileWriterException;
29import eu.siacs.conversations.utils.MimeUtils;
30import okhttp3.Call;
31import okhttp3.HttpUrl;
32import okhttp3.OkHttpClient;
33import okhttp3.Request;
34import okhttp3.Response;
35
36import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
37
38public class HttpDownloadConnection implements Transferable {
39
40 private final Message message;
41 private final HttpConnectionManager mHttpConnectionManager;
42 private final XmppConnectionService mXmppConnectionService;
43 private HttpUrl mUrl;
44 private DownloadableFile file;
45 private int mStatus = Transferable.STATUS_UNKNOWN;
46 private boolean acceptedAutomatically = false;
47 private int mProgress = 0;
48 private Call mostRecentCall;
49
50 HttpDownloadConnection(Message message, HttpConnectionManager manager) {
51 this.message = message;
52 this.mHttpConnectionManager = manager;
53 this.mXmppConnectionService = manager.getXmppConnectionService();
54 }
55
56 @Override
57 public boolean start() {
58 if (mXmppConnectionService.hasInternetConnection()) {
59 if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
60 checkFileSize(true);
61 } else {
62 download(true);
63 }
64 return true;
65 } else {
66 return false;
67 }
68 }
69
70 public void init(boolean interactive) {
71 final Message.FileParams fileParams = message.getFileParams();
72 if (message.isDeleted()) {
73 if (message.getType() == Message.TYPE_PRIVATE_FILE) {
74 message.setType(Message.TYPE_PRIVATE);
75 } else if (message.isFileOrImage()) {
76 message.setType(Message.TYPE_TEXT);
77 }
78 message.setOob(fileParams.url);
79 message.setDeleted(false);
80 mXmppConnectionService.updateMessage(message);
81 }
82 this.message.setTransferable(this);
83 try {
84 if (message.hasFileOnRemoteHost()) {
85 mUrl = AesGcmURL.of(fileParams.url);
86 } else if (message.isOOb() && fileParams.url != null) {
87 mUrl = AesGcmURL.of(fileParams.url);
88 } else {
89 mUrl = AesGcmURL.of(message.getBody().split("\n")[0]);
90 }
91 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
92 if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
93 this.message.setEncryption(Message.ENCRYPTION_PGP);
94 } else if (message.getEncryption() != Message.ENCRYPTION_OTR
95 && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
96 this.message.setEncryption(Message.ENCRYPTION_NONE);
97 }
98 final String ext = extension.getExtension();
99 final String filename = Strings.isNullOrEmpty(ext) ? message.getUuid() : String.format("%s.%s", message.getUuid(), ext);
100 mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
101 setupFile();
102 if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
103 this.message.setEncryption(Message.ENCRYPTION_NONE);
104 }
105 //TODO add auth tag size to knownFileSize
106 final Long knownFileSize = message.getFileParams().size;
107 Log.d(Config.LOGTAG,"knownFileSize: "+knownFileSize+", body="+message.getBody());
108 if (knownFileSize != null && interactive) {
109 this.file.setExpectedSize(knownFileSize);
110 download(true);
111 } else {
112 checkFileSize(interactive);
113 }
114 } catch (final IllegalArgumentException e) {
115 this.cancel();
116 }
117 }
118
119 private void setupFile() {
120 final String reference = mUrl.fragment();
121 if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
122 this.file = new DownloadableFile(mXmppConnectionService.getCacheDir(), message.getUuid());
123 this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
124 Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
125 } else {
126 this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
127 }
128 }
129
130 private void download(final boolean interactive) {
131 EXECUTOR.execute(new FileDownloader(interactive));
132 }
133
134 private void checkFileSize(final boolean interactive) {
135 EXECUTOR.execute(new FileSizeChecker(interactive));
136 }
137
138 @Override
139 public void cancel() {
140 final Call call = this.mostRecentCall;
141 if (call != null && !call.isCanceled()) {
142 call.cancel();
143 }
144 mHttpConnectionManager.finishConnection(this);
145 message.setTransferable(null);
146 if (message.isFileOrImage()) {
147 message.setDeleted(true);
148 }
149 mHttpConnectionManager.updateConversationUi(true);
150 }
151
152 private void decryptFile() throws IOException {
153 final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
154
155 if (outputFile.getParentFile().mkdirs()) {
156 Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
157 }
158
159 if (!outputFile.createNewFile()) {
160 Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
161 }
162
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
179 private void finish() {
180 message.setTransferable(null);
181 mHttpConnectionManager.finishConnection(this);
182 boolean notify = acceptedAutomatically && !message.isRead();
183 if (message.getEncryption() == Message.ENCRYPTION_PGP) {
184 notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
185 }
186 mHttpConnectionManager.updateConversationUi(true);
187 final boolean notifyAfterScan = notify;
188 final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
189 mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
190 if (notifyAfterScan) {
191 mXmppConnectionService.getNotificationService().push(message);
192 }
193 });
194 }
195
196 private void decryptIfNeeded() throws IOException {
197 if (file.getKey() != null && file.getIv() != null) {
198 decryptFile();
199 }
200 }
201
202 private void changeStatus(int status) {
203 this.mStatus = status;
204 mHttpConnectionManager.updateConversationUi(true);
205 }
206
207 private void showToastForException(final Exception e) {
208 final Call call = mostRecentCall;
209 final boolean cancelled = call != null && call.isCanceled();
210 if (e == null || cancelled) {
211 return;
212 }
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 {
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 null;
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 check();
264 }
265
266 private void retrieveFailed(@Nullable final Exception e) {
267 changeStatus(STATUS_OFFER_CHECK_FILESIZE);
268 if (interactive) {
269 showToastForException(e);
270 } else {
271 HttpDownloadConnection.this.acceptedAutomatically = false;
272 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
273 }
274 cancel();
275 }
276
277 private void check() {
278 long size;
279 try {
280 size = retrieveFileSize();
281 } catch (final Exception e) {
282 Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
283 retrieveFailed(e);
284 return;
285 }
286 final Message.FileParams fileParams = message.getFileParams();
287 FileBackend.updateFileParams(message, fileParams.url, size);
288 message.setOob(fileParams.url);
289 mXmppConnectionService.databaseBackend.updateMessage(message, true);
290 file.setExpectedSize(size);
291 message.resetFileParams();
292 if (mHttpConnectionManager.hasStoragePermission()
293 && size <= mHttpConnectionManager.getAutoAcceptFileSize()
294 && mXmppConnectionService.isDataSaverDisabled()) {
295 HttpDownloadConnection.this.acceptedAutomatically = true;
296 download(interactive);
297 } else {
298 changeStatus(STATUS_OFFER);
299 HttpDownloadConnection.this.acceptedAutomatically = false;
300 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
301 }
302 }
303
304 private long retrieveFileSize() throws IOException {
305 Log.d(Config.LOGTAG, "retrieve file size. interactive:" + interactive);
306 changeStatus(STATUS_CHECKING);
307 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
308 mUrl,
309 message.getConversation().getAccount(),
310 interactive
311 );
312 final Request request = new Request.Builder()
313 .url(URL.stripFragment(mUrl))
314 .head()
315 .build();
316 mostRecentCall = client.newCall(request);
317 try {
318 final Response response = mostRecentCall.execute();
319 throwOnInvalidCode(response);
320 final String contentLength = response.header("Content-Length");
321 final String contentType = response.header("Content-Type");
322 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
323 if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
324 final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
325 if (fileExtension != null) {
326 mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), fileExtension), contentType);
327 Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
328 setupFile();
329 }
330 }
331 if (Strings.isNullOrEmpty(contentLength)) {
332 throw new IOException("no content-length found in HEAD response");
333 }
334 final long size = Long.parseLong(contentLength, 10);
335 if (size < 0) {
336 throw new IOException("Server reported negative file size");
337 }
338 return size;
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 throwOnInvalidCode(response);
402 final String contentRange = response.header("Content-Range");
403 final boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
404 final InputStream inputStream = response.body().byteStream();
405 final OutputStream outputStream;
406 long transmitted = 0;
407 if (tryResume && serverResumed) {
408 Log.d(Config.LOGTAG, "server resumed");
409 transmitted = file.getSize();
410 updateProgress(Math.round(((double) transmitted / expected) * 100));
411 outputStream = AbstractConnectionManager.createOutputStream(file, true, false);
412 } else {
413 final String contentLength = response.header("Content-Length");
414 final long size = Strings.isNullOrEmpty(contentLength) ? 0 : Longs.tryParse(contentLength);
415 if (expected != size) {
416 Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
417 }
418 file.getParentFile().mkdirs();
419 Log.d(Config.LOGTAG,"creating file: "+file.getAbsolutePath());
420 if (!file.exists() && !file.createNewFile()) {
421 throw new FileWriterException(file);
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(file);
433 }
434 updateProgress(Math.round(((double) transmitted / expected) * 100));
435 }
436 outputStream.flush();
437 }
438
439 private void updateImageBounds() {
440 final boolean privateMessage = message.isPrivateMessage();
441 message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
442 final String url;
443 final String ref = mUrl.fragment();
444 if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
445 url = AesGcmURL.toAesGcmUrl(mUrl);
446 } else {
447 url = mUrl.toString();
448 }
449 mXmppConnectionService.getFileBackend().updateFileParams(message, url);
450 mXmppConnectionService.updateMessage(message);
451 }
452
453 }
454
455 private static void throwOnInvalidCode(final Response response) throws IOException {
456 final int code = response.code();
457 if (code < 200 || code >= 300) {
458 throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
459 }
460 }
461}