1package eu.siacs.conversations.persistance;
2
3import android.graphics.Bitmap;
4import android.graphics.BitmapFactory;
5import android.graphics.Canvas;
6import android.graphics.Matrix;
7import android.graphics.RectF;
8import android.net.Uri;
9import android.os.Environment;
10import android.util.Base64;
11import android.util.Base64OutputStream;
12import android.util.Log;
13import android.webkit.MimeTypeMap;
14
15import java.io.ByteArrayOutputStream;
16import java.io.Closeable;
17import java.io.File;
18import java.io.FileNotFoundException;
19import java.io.FileOutputStream;
20import java.io.IOException;
21import java.io.InputStream;
22import java.io.OutputStream;
23import java.net.Socket;
24import java.net.URL;
25import java.security.DigestOutputStream;
26import java.security.MessageDigest;
27import java.security.NoSuchAlgorithmException;
28import java.text.SimpleDateFormat;
29import java.util.Arrays;
30import java.util.Date;
31import java.util.Locale;
32
33import eu.siacs.conversations.Config;
34import eu.siacs.conversations.R;
35import eu.siacs.conversations.entities.DownloadableFile;
36import eu.siacs.conversations.entities.Message;
37import eu.siacs.conversations.entities.Transferable;
38import eu.siacs.conversations.services.XmppConnectionService;
39import eu.siacs.conversations.utils.CryptoHelper;
40import eu.siacs.conversations.utils.ExifHelper;
41import eu.siacs.conversations.utils.FileUtils;
42import eu.siacs.conversations.xmpp.pep.Avatar;
43
44public class FileBackend {
45
46 private static int IMAGE_SIZE = 1920;
47
48 private final SimpleDateFormat imageDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
49
50 private XmppConnectionService mXmppConnectionService;
51
52 public FileBackend(XmppConnectionService service) {
53 this.mXmppConnectionService = service;
54 }
55
56 public DownloadableFile getFile(Message message) {
57 return getFile(message, true);
58 }
59
60 public DownloadableFile getFile(Message message, boolean decrypted) {
61 final boolean encrypted = !decrypted
62 && (message.getEncryption() == Message.ENCRYPTION_PGP
63 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
64 final DownloadableFile file;
65 String path = message.getRelativeFilePath();
66 if (path == null) {
67 path = message.getUuid();
68 }
69 if (path.startsWith("/")) {
70 file = new DownloadableFile(path);
71 } else {
72 String mime = message.getMimeType();
73 if (mime != null && mime.startsWith("image")) {
74 file = new DownloadableFile(getConversationsImageDirectory() + path);
75 } else {
76 file = new DownloadableFile(getConversationsFileDirectory() + path);
77 }
78 }
79 if (encrypted) {
80 return new DownloadableFile(getConversationsFileDirectory() + file.getName() + ".pgp");
81 } else {
82 return file;
83 }
84 }
85
86 public static String getConversationsFileDirectory() {
87 return Environment.getExternalStorageDirectory().getAbsolutePath()+"/Conversations/";
88 }
89
90 public static String getConversationsImageDirectory() {
91 return Environment.getExternalStoragePublicDirectory(
92 Environment.DIRECTORY_PICTURES).getAbsolutePath()
93 + "/Conversations/";
94 }
95
96 public Bitmap resize(Bitmap originalBitmap, int size) {
97 int w = originalBitmap.getWidth();
98 int h = originalBitmap.getHeight();
99 if (Math.max(w, h) > size) {
100 int scalledW;
101 int scalledH;
102 if (w <= h) {
103 scalledW = (int) (w / ((double) h / size));
104 scalledH = size;
105 } else {
106 scalledW = size;
107 scalledH = (int) (h / ((double) w / size));
108 }
109 Bitmap result = Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
110 if (originalBitmap != null && !originalBitmap.isRecycled()) {
111 originalBitmap.recycle();
112 }
113 return result;
114 } else {
115 return originalBitmap;
116 }
117 }
118
119 public Bitmap rotate(Bitmap bitmap, int degree) {
120 int w = bitmap.getWidth();
121 int h = bitmap.getHeight();
122 Matrix mtx = new Matrix();
123 mtx.postRotate(degree);
124 Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true);
125 if (bitmap != null && !bitmap.isRecycled()) {
126 bitmap.recycle();
127 }
128 return result;
129 }
130
131 public boolean useImageAsIs(Uri uri) {
132 String path = getOriginalPath(uri);
133 if (path == null) {
134 return false;
135 }
136 File file = new File(path);
137 long size = file.length();
138 if (size == 0 || size >= 524288 ) {
139 return false;
140 }
141 BitmapFactory.Options options = new BitmapFactory.Options();
142 options.inJustDecodeBounds = true;
143 try {
144 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(uri), null, options);
145 if (options == null || options.outMimeType == null || options.outHeight <= 0 || options.outWidth <= 0) {
146 return false;
147 }
148 return (options.outWidth <= Config.IMAGE_SIZE && options.outHeight <= Config.IMAGE_SIZE && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
149 } catch (FileNotFoundException e) {
150 return false;
151 }
152 }
153
154 public String getOriginalPath(Uri uri) {
155 Log.d(Config.LOGTAG,"get original path for uri: "+uri.toString());
156 return FileUtils.getPath(mXmppConnectionService,uri);
157 }
158
159 public DownloadableFile copyFileToPrivateStorage(Message message, Uri uri) throws FileCopyException {
160 Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage");
161 String mime = mXmppConnectionService.getContentResolver().getType(uri);
162 String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime);
163 message.setRelativeFilePath(message.getUuid() + "." + extension);
164 DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message);
165 file.getParentFile().mkdirs();
166 OutputStream os = null;
167 InputStream is = null;
168 try {
169 file.createNewFile();
170 os = new FileOutputStream(file);
171 is = mXmppConnectionService.getContentResolver().openInputStream(uri);
172 byte[] buffer = new byte[1024];
173 int length;
174 while ((length = is.read(buffer)) > 0) {
175 os.write(buffer, 0, length);
176 }
177 os.flush();
178 } catch(FileNotFoundException e) {
179 throw new FileCopyException(R.string.error_file_not_found);
180 } catch (IOException e) {
181 e.printStackTrace();
182 throw new FileCopyException(R.string.error_io_exception);
183 } finally {
184 close(os);
185 close(is);
186 }
187 Log.d(Config.LOGTAG, "output file name " + mXmppConnectionService.getFileBackend().getFile(message));
188 return file;
189 }
190
191 public DownloadableFile copyImageToPrivateStorage(Message message, Uri image)
192 throws FileCopyException {
193 return this.copyImageToPrivateStorage(message, image, 0);
194 }
195
196 private DownloadableFile copyImageToPrivateStorage(Message message,Uri image, int sampleSize) throws FileCopyException {
197 switch(Config.IMAGE_FORMAT) {
198 case JPEG:
199 message.setRelativeFilePath(message.getUuid()+".jpg");
200 break;
201 case PNG:
202 message.setRelativeFilePath(message.getUuid()+".png");
203 break;
204 case WEBP:
205 message.setRelativeFilePath(message.getUuid()+".webp");
206 break;
207 }
208 DownloadableFile file = getFile(message);
209 file.getParentFile().mkdirs();
210 InputStream is = null;
211 OutputStream os = null;
212 try {
213 file.createNewFile();
214 is = mXmppConnectionService.getContentResolver().openInputStream(image);
215 os = new FileOutputStream(file);
216
217 Bitmap originalBitmap;
218 BitmapFactory.Options options = new BitmapFactory.Options();
219 int inSampleSize = (int) Math.pow(2, sampleSize);
220 Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize);
221 options.inSampleSize = inSampleSize;
222 originalBitmap = BitmapFactory.decodeStream(is, null, options);
223 is.close();
224 if (originalBitmap == null) {
225 throw new FileCopyException(R.string.error_not_an_image_file);
226 }
227 Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
228 int rotation = getRotation(image);
229 if (rotation > 0) {
230 scaledBitmap = rotate(scaledBitmap, rotation);
231 }
232 boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, Config.IMAGE_QUALITY, os);
233 if (!success) {
234 throw new FileCopyException(R.string.error_compressing_image);
235 }
236 os.flush();
237 long size = file.getSize();
238 int width = scaledBitmap.getWidth();
239 int height = scaledBitmap.getHeight();
240 message.setBody(Long.toString(size) + '|' + width + '|' + height);
241 return file;
242 } catch (FileNotFoundException e) {
243 throw new FileCopyException(R.string.error_file_not_found);
244 } catch (IOException e) {
245 e.printStackTrace();
246 throw new FileCopyException(R.string.error_io_exception);
247 } catch (SecurityException e) {
248 throw new FileCopyException(R.string.error_security_exception_during_image_copy);
249 } catch (OutOfMemoryError e) {
250 ++sampleSize;
251 if (sampleSize <= 3) {
252 return copyImageToPrivateStorage(message, image, sampleSize);
253 } else {
254 throw new FileCopyException(R.string.error_out_of_memory);
255 }
256 } catch (NullPointerException e) {
257 throw new FileCopyException(R.string.error_io_exception);
258 } finally {
259 close(os);
260 close(is);
261 }
262 }
263
264 private int getRotation(File file) {
265 return getRotation(Uri.parse("file://"+file.getAbsolutePath()));
266 }
267
268 private int getRotation(Uri image) {
269 InputStream is = null;
270 try {
271 is = mXmppConnectionService.getContentResolver().openInputStream(image);
272 return ExifHelper.getOrientation(is);
273 } catch (FileNotFoundException e) {
274 return 0;
275 } finally {
276 close(is);
277 }
278 }
279
280 public Bitmap getThumbnail(Message message, int size, boolean cacheOnly)
281 throws FileNotFoundException {
282 Bitmap thumbnail = mXmppConnectionService.getBitmapCache().get(message.getUuid());
283 if ((thumbnail == null) && (!cacheOnly)) {
284 File file = getFile(message);
285 BitmapFactory.Options options = new BitmapFactory.Options();
286 options.inSampleSize = calcSampleSize(file, size);
287 Bitmap fullsize = BitmapFactory.decodeFile(file.getAbsolutePath(),options);
288 if (fullsize == null) {
289 throw new FileNotFoundException();
290 }
291 thumbnail = resize(fullsize, size);
292 int rotation = getRotation(file);
293 if (rotation > 0) {
294 thumbnail = rotate(thumbnail, rotation);
295 }
296 this.mXmppConnectionService.getBitmapCache().put(message.getUuid(),thumbnail);
297 }
298 return thumbnail;
299 }
300
301 public Uri getTakePhotoUri() {
302 StringBuilder pathBuilder = new StringBuilder();
303 pathBuilder.append(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM));
304 pathBuilder.append('/');
305 pathBuilder.append("Camera");
306 pathBuilder.append('/');
307 pathBuilder.append("IMG_" + this.imageDateFormat.format(new Date()) + ".jpg");
308 Uri uri = Uri.parse("file://" + pathBuilder.toString());
309 File file = new File(uri.toString());
310 file.getParentFile().mkdirs();
311 return uri;
312 }
313
314 public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
315 try {
316 Avatar avatar = new Avatar();
317 Bitmap bm = cropCenterSquare(image, size);
318 if (bm == null) {
319 return null;
320 }
321 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
322 Base64OutputStream mBase64OutputSttream = new Base64OutputStream(
323 mByteArrayOutputStream, Base64.DEFAULT);
324 MessageDigest digest = MessageDigest.getInstance("SHA-1");
325 DigestOutputStream mDigestOutputStream = new DigestOutputStream(
326 mBase64OutputSttream, digest);
327 if (!bm.compress(format, 75, mDigestOutputStream)) {
328 return null;
329 }
330 mDigestOutputStream.flush();
331 mDigestOutputStream.close();
332 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
333 avatar.image = new String(mByteArrayOutputStream.toByteArray());
334 return avatar;
335 } catch (NoSuchAlgorithmException e) {
336 return null;
337 } catch (IOException e) {
338 return null;
339 }
340 }
341
342 public boolean isAvatarCached(Avatar avatar) {
343 File file = new File(getAvatarPath(avatar.getFilename()));
344 return file.exists();
345 }
346
347 public boolean save(Avatar avatar) {
348 File file;
349 if (isAvatarCached(avatar)) {
350 file = new File(getAvatarPath(avatar.getFilename()));
351 } else {
352 String filename = getAvatarPath(avatar.getFilename());
353 file = new File(filename + ".tmp");
354 file.getParentFile().mkdirs();
355 OutputStream os = null;
356 try {
357 file.createNewFile();
358 os = new FileOutputStream(file);
359 MessageDigest digest = MessageDigest.getInstance("SHA-1");
360 digest.reset();
361 DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
362 mDigestOutputStream.write(avatar.getImageAsBytes());
363 mDigestOutputStream.flush();
364 mDigestOutputStream.close();
365 String sha1sum = CryptoHelper.bytesToHex(digest.digest());
366 if (sha1sum.equals(avatar.sha1sum)) {
367 file.renameTo(new File(filename));
368 } else {
369 Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
370 file.delete();
371 return false;
372 }
373 } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
374 return false;
375 } finally {
376 close(os);
377 }
378 }
379 avatar.size = file.length();
380 return true;
381 }
382
383 public String getAvatarPath(String avatar) {
384 return mXmppConnectionService.getFilesDir().getAbsolutePath()+ "/avatars/" + avatar;
385 }
386
387 public Uri getAvatarUri(String avatar) {
388 return Uri.parse("file:" + getAvatarPath(avatar));
389 }
390
391 public Bitmap cropCenterSquare(Uri image, int size) {
392 if (image == null) {
393 return null;
394 }
395 InputStream is = null;
396 try {
397 BitmapFactory.Options options = new BitmapFactory.Options();
398 options.inSampleSize = calcSampleSize(image, size);
399 is = mXmppConnectionService.getContentResolver().openInputStream(image);
400 if (is == null) {
401 return null;
402 }
403 Bitmap input = BitmapFactory.decodeStream(is, null, options);
404 if (input == null) {
405 return null;
406 } else {
407 int rotation = getRotation(image);
408 if (rotation > 0) {
409 input = rotate(input, rotation);
410 }
411 return cropCenterSquare(input, size);
412 }
413 } catch (SecurityException e) {
414 return null; // happens for example on Android 6.0 if contacts permissions get revoked
415 } catch (FileNotFoundException e) {
416 return null;
417 } finally {
418 close(is);
419 }
420 }
421
422 public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
423 if (image == null) {
424 return null;
425 }
426 InputStream is = null;
427 try {
428 BitmapFactory.Options options = new BitmapFactory.Options();
429 options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
430 is = mXmppConnectionService.getContentResolver().openInputStream(image);
431 if (is == null) {
432 return null;
433 }
434 Bitmap source = BitmapFactory.decodeStream(is, null, options);
435 if (source == null) {
436 return null;
437 }
438 int sourceWidth = source.getWidth();
439 int sourceHeight = source.getHeight();
440 float xScale = (float) newWidth / sourceWidth;
441 float yScale = (float) newHeight / sourceHeight;
442 float scale = Math.max(xScale, yScale);
443 float scaledWidth = scale * sourceWidth;
444 float scaledHeight = scale * sourceHeight;
445 float left = (newWidth - scaledWidth) / 2;
446 float top = (newHeight - scaledHeight) / 2;
447
448 RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
449 Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
450 Canvas canvas = new Canvas(dest);
451 canvas.drawBitmap(source, null, targetRect, null);
452 if (source != null && !source.isRecycled()) {
453 source.recycle();
454 }
455 return dest;
456 } catch (SecurityException e) {
457 return null; //android 6.0 with revoked permissions for example
458 } catch (FileNotFoundException e) {
459 return null;
460 } finally {
461 close(is);
462 }
463 }
464
465 public Bitmap cropCenterSquare(Bitmap input, int size) {
466 int w = input.getWidth();
467 int h = input.getHeight();
468
469 float scale = Math.max((float) size / h, (float) size / w);
470
471 float outWidth = scale * w;
472 float outHeight = scale * h;
473 float left = (size - outWidth) / 2;
474 float top = (size - outHeight) / 2;
475 RectF target = new RectF(left, top, left + outWidth, top + outHeight);
476
477 Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
478 Canvas canvas = new Canvas(output);
479 canvas.drawBitmap(input, null, target, null);
480 if (input != null && !input.isRecycled()) {
481 input.recycle();
482 }
483 return output;
484 }
485
486 private int calcSampleSize(Uri image, int size) throws FileNotFoundException, SecurityException {
487 BitmapFactory.Options options = new BitmapFactory.Options();
488 options.inJustDecodeBounds = true;
489 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(image), null, options);
490 return calcSampleSize(options, size);
491 }
492
493 private static int calcSampleSize(File image, int size) {
494 BitmapFactory.Options options = new BitmapFactory.Options();
495 options.inJustDecodeBounds = true;
496 BitmapFactory.decodeFile(image.getAbsolutePath(), options);
497 return calcSampleSize(options, size);
498 }
499
500 public static int calcSampleSize(BitmapFactory.Options options, int size) {
501 int height = options.outHeight;
502 int width = options.outWidth;
503 int inSampleSize = 1;
504
505 if (height > size || width > size) {
506 int halfHeight = height / 2;
507 int halfWidth = width / 2;
508
509 while ((halfHeight / inSampleSize) > size
510 && (halfWidth / inSampleSize) > size) {
511 inSampleSize *= 2;
512 }
513 }
514 return inSampleSize;
515 }
516
517 public Uri getJingleFileUri(Message message) {
518 File file = getFile(message);
519 return Uri.parse("file://" + file.getAbsolutePath());
520 }
521
522 public void updateFileParams(Message message) {
523 updateFileParams(message,null);
524 }
525
526 public void updateFileParams(Message message, URL url) {
527 DownloadableFile file = getFile(message);
528 if (message.getType() == Message.TYPE_IMAGE || file.getMimeType().startsWith("image/")) {
529 BitmapFactory.Options options = new BitmapFactory.Options();
530 options.inJustDecodeBounds = true;
531 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
532 int rotation = getRotation(file);
533 boolean rotated = rotation == 90 || rotation == 270;
534 int imageHeight = rotated ? options.outWidth : options.outHeight;
535 int imageWidth = rotated ? options.outHeight : options.outWidth;
536 if (url == null) {
537 message.setBody(Long.toString(file.getSize()) + '|' + imageWidth + '|' + imageHeight);
538 } else {
539 message.setBody(url.toString()+"|"+Long.toString(file.getSize()) + '|' + imageWidth + '|' + imageHeight);
540 }
541 } else {
542 if (url != null) {
543 message.setBody(url.toString()+"|"+Long.toString(file.getSize()));
544 } else {
545 message.setBody(Long.toString(file.getSize()));
546 }
547 }
548
549 }
550
551 public class FileCopyException extends Exception {
552 private static final long serialVersionUID = -1010013599132881427L;
553 private int resId;
554
555 public FileCopyException(int resId) {
556 this.resId = resId;
557 }
558
559 public int getResId() {
560 return resId;
561 }
562 }
563
564 public Bitmap getAvatar(String avatar, int size) {
565 if (avatar == null) {
566 return null;
567 }
568 Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
569 if (bm == null) {
570 return null;
571 }
572 return bm;
573 }
574
575 public boolean isFileAvailable(Message message) {
576 return getFile(message).exists();
577 }
578
579 public static void close(Closeable stream) {
580 if (stream != null) {
581 try {
582 stream.close();
583 } catch (IOException e) {
584 }
585 }
586 }
587
588 public static void close(Socket socket) {
589 if (socket != null) {
590 try {
591 socket.close();
592 } catch (IOException e) {
593 }
594 }
595 }
596}