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