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