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(Exception e) {
215 if (e instanceof java.net.UnknownHostException) {
216 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
217 } else if (e instanceof java.net.ConnectException) {
218 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
219 } else if (e instanceof FileWriterException) {
220 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
221 } else if (!(e instanceof CancellationException)) {
222 mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
223 }
224 }
225
226 private void updateProgress(long i) {
227 this.mProgress = (int) i;
228 mHttpConnectionManager.updateConversationUi(false);
229 }
230
231 @Override
232 public int getStatus() {
233 return this.mStatus;
234 }
235
236 @Override
237 public long getFileSize() {
238 if (this.file != null) {
239 return this.file.getExpectedSize();
240 } else {
241 return 0;
242 }
243 }
244
245 @Override
246 public int getProgress() {
247 return this.mProgress;
248 }
249
250 public Message getMessage() {
251 return message;
252 }
253
254 private class FileSizeChecker implements Runnable {
255
256 private final boolean interactive;
257
258 FileSizeChecker(boolean interactive) {
259 this.interactive = interactive;
260 }
261
262
263 @Override
264 public void run() {
265 check();
266 }
267
268 private void retrieveFailed(@Nullable Exception e) {
269 changeStatus(STATUS_OFFER_CHECK_FILESIZE);
270 if (interactive) {
271 if (e != null) {
272 showToastForException(e);
273 }
274 } else {
275 HttpDownloadConnection.this.acceptedAutomatically = false;
276 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
277 }
278 cancel();
279 }
280
281 private void check() {
282 long size;
283 try {
284 size = retrieveFileSize();
285 } catch (Exception e) {
286 Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
287 retrieveFailed(e);
288 return;
289 }
290 final Message.FileParams fileParams = message.getFileParams();
291 FileBackend.updateFileParams(message, fileParams.url, size);
292 message.setOob(true);
293 mXmppConnectionService.databaseBackend.updateMessage(message, true);
294 file.setExpectedSize(size);
295 message.resetFileParams();
296 if (mHttpConnectionManager.hasStoragePermission()
297 && size <= mHttpConnectionManager.getAutoAcceptFileSize()
298 && mXmppConnectionService.isDataSaverDisabled()) {
299 HttpDownloadConnection.this.acceptedAutomatically = true;
300 download(interactive);
301 } else {
302 changeStatus(STATUS_OFFER);
303 HttpDownloadConnection.this.acceptedAutomatically = false;
304 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
305 }
306 }
307
308 private long retrieveFileSize() throws IOException {
309 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
310 mUrl,
311 message.getConversation().getAccount(),
312 interactive
313 );
314 final Request request = new Request.Builder()
315 .url(URL.stripFragment(mUrl))
316 .head()
317 .build();
318 mostRecentCall = client.newCall(request);
319 try {
320 final Response response = mostRecentCall.execute();
321 final String contentLength = response.header("Content-Length");
322 final String contentType = response.header("Content-Type");
323 final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.encodedPath());
324 if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
325 final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
326 if (fileExtension != null) {
327 message.setRelativeFilePath(String.format("%s.%s", message.getUuid(), fileExtension));
328 Log.d(Config.LOGTAG, "rewriting name after not finding extension in url but in content type");
329 setupFile();
330 }
331 }
332 if (Strings.isNullOrEmpty(contentLength)) {
333 throw new IOException("no content-length found in HEAD response");
334 }
335 return Long.parseLong(contentLength, 10);
336 } catch (IOException e) {
337 Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
338 throw e;
339 } catch (NumberFormatException e) {
340 throw new IOException();
341 }
342 }
343
344 }
345
346 private class FileDownloader implements Runnable {
347
348 private final boolean interactive;
349
350 public FileDownloader(boolean interactive) {
351 this.interactive = interactive;
352 }
353
354 @Override
355 public void run() {
356 try {
357 changeStatus(STATUS_DOWNLOADING);
358 download();
359 decryptIfNeeded();
360 updateImageBounds();
361 finish();
362 } catch (final SSLHandshakeException e) {
363 changeStatus(STATUS_OFFER);
364 } catch (final Exception e) {
365 Log.d(Config.LOGTAG,"problem downloading",e);
366 if (interactive) {
367 showToastForException(e);
368 } else {
369 HttpDownloadConnection.this.acceptedAutomatically = false;
370 HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
371 }
372 cancel();
373 }
374 }
375
376 private void download() throws Exception {
377 final OkHttpClient client = mHttpConnectionManager.buildHttpClient(
378 mUrl,
379 message.getConversation().getAccount(),
380 interactive
381 );
382
383 final Request.Builder requestBuilder = new Request.Builder().url(URL.stripFragment(mUrl));
384
385 final long expected = file.getExpectedSize();
386 final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
387 final long resumeSize;
388 if (tryResume) {
389 resumeSize = file.getSize();
390 Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
391 requestBuilder.addHeader("Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
392 } else {
393 resumeSize = 0;
394 }
395 final Request request = requestBuilder.build();
396 mostRecentCall = client.newCall(request);
397 final Response response = mostRecentCall.execute();
398 final int code = response.code();
399 if (code >= 200 && code <= 299) {
400 final String contentRange = response.header("Content-Range");
401 final boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
402 final InputStream inputStream = response.body().byteStream();
403 final OutputStream outputStream;
404 long transmitted = 0;
405 if (tryResume && serverResumed) {
406 Log.d(Config.LOGTAG, "server resumed");
407 transmitted = file.getSize();
408 updateProgress(Math.round(((double) transmitted / expected) * 100));
409 outputStream = AbstractConnectionManager.createOutputStream(file, true, false);
410 } else {
411 final String contentLength = response.header("Content-Length");
412 final long size = Strings.isNullOrEmpty(contentLength) ? 0 : Longs.tryParse(contentLength);
413 if (expected != size) {
414 Log.d(Config.LOGTAG, "content-length reported on GET (" + size + ") did not match Content-Length reported on HEAD (" + expected + ")");
415 }
416 file.getParentFile().mkdirs();
417 if (!file.exists() && !file.createNewFile()) {
418 throw new FileWriterException();
419 }
420 outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
421 }
422 int count;
423 byte[] buffer = new byte[4096];
424 while ((count = inputStream.read(buffer)) != -1) {
425 transmitted += count;
426 try {
427 outputStream.write(buffer, 0, count);
428 } catch (IOException e) {
429 throw new FileWriterException();
430 }
431 updateProgress(Math.round(((double) transmitted / expected) * 100));
432 if (canceled) {
433 throw new CancellationException();
434 }
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}