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 DownloadableFile copyFileToPrivateStorage(Message message, Uri uri) throws FileCopyException {
159 Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage");
160 String mime = mXmppConnectionService.getContentResolver().getType(uri);
161 String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime);
162 message.setRelativeFilePath(message.getUuid() + "." + extension);
163 DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message);
164 file.getParentFile().mkdirs();
165 OutputStream os = null;
166 InputStream is = null;
167 try {
168 file.createNewFile();
169 os = new FileOutputStream(file);
170 is = mXmppConnectionService.getContentResolver().openInputStream(uri);
171 byte[] buffer = new byte[1024];
172 int length;
173 while ((length = is.read(buffer)) > 0) {
174 os.write(buffer, 0, length);
175 }
176 os.flush();
177 } catch(FileNotFoundException e) {
178 throw new FileCopyException(R.string.error_file_not_found);
179 } catch (IOException e) {
180 e.printStackTrace();
181 throw new FileCopyException(R.string.error_io_exception);
182 } finally {
183 close(os);
184 close(is);
185 }
186 Log.d(Config.LOGTAG, "output file name " + mXmppConnectionService.getFileBackend().getFile(message));
187 return file;
188 }
189
190 public DownloadableFile copyImageToPrivateStorage(Message message, Uri image)
191 throws FileCopyException {
192 return this.copyImageToPrivateStorage(message, image, 0);
193 }
194
195 private DownloadableFile copyImageToPrivateStorage(Message message,Uri image, int sampleSize) throws FileCopyException {
196 switch(Config.IMAGE_FORMAT) {
197 case JPEG:
198 message.setRelativeFilePath(message.getUuid()+".jpg");
199 break;
200 case PNG:
201 message.setRelativeFilePath(message.getUuid()+".png");
202 break;
203 case WEBP:
204 message.setRelativeFilePath(message.getUuid()+".webp");
205 break;
206 }
207 DownloadableFile file = getFile(message);
208 file.getParentFile().mkdirs();
209 InputStream is = null;
210 OutputStream os = null;
211 try {
212 file.createNewFile();
213 is = mXmppConnectionService.getContentResolver().openInputStream(image);
214 Bitmap originalBitmap;
215 BitmapFactory.Options options = new BitmapFactory.Options();
216 int inSampleSize = (int) Math.pow(2, sampleSize);
217 Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize);
218 options.inSampleSize = inSampleSize;
219 originalBitmap = BitmapFactory.decodeStream(is, null, options);
220 is.close();
221 if (originalBitmap == null) {
222 throw new FileCopyException(R.string.error_not_an_image_file);
223 }
224 Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
225 int rotation = getRotation(image);
226 scaledBitmap = rotate(scaledBitmap, rotation);
227 boolean targetSizeReached = false;
228 long size = 0;
229 int quality = Config.IMAGE_QUALITY;
230 while(!targetSizeReached) {
231 os = new FileOutputStream(file);
232 boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, quality, os);
233 if (!success) {
234 throw new FileCopyException(R.string.error_compressing_image);
235 }
236 os.flush();
237 size = file.getSize();
238 targetSizeReached = size <= Config.IMAGE_MAX_SIZE || quality <= 50;
239 quality -= 5;
240 }
241 int width = scaledBitmap.getWidth();
242 int height = scaledBitmap.getHeight();
243 message.setBody(Long.toString(size) + '|' + width + '|' + height);
244 return file;
245 } catch (FileNotFoundException e) {
246 throw new FileCopyException(R.string.error_file_not_found);
247 } catch (IOException e) {
248 e.printStackTrace();
249 throw new FileCopyException(R.string.error_io_exception);
250 } catch (SecurityException e) {
251 throw new FileCopyException(R.string.error_security_exception_during_image_copy);
252 } catch (OutOfMemoryError e) {
253 ++sampleSize;
254 if (sampleSize <= 3) {
255 return copyImageToPrivateStorage(message, image, sampleSize);
256 } else {
257 throw new FileCopyException(R.string.error_out_of_memory);
258 }
259 } catch (NullPointerException e) {
260 throw new FileCopyException(R.string.error_io_exception);
261 } finally {
262 close(os);
263 close(is);
264 }
265 }
266
267 private int getRotation(File file) {
268 return getRotation(Uri.parse("file://"+file.getAbsolutePath()));
269 }
270
271 private int getRotation(Uri image) {
272 InputStream is = null;
273 try {
274 is = mXmppConnectionService.getContentResolver().openInputStream(image);
275 return ExifHelper.getOrientation(is);
276 } catch (FileNotFoundException e) {
277 return 0;
278 } finally {
279 close(is);
280 }
281 }
282
283 public Bitmap getThumbnail(Message message, int size, boolean cacheOnly)
284 throws FileNotFoundException {
285 Bitmap thumbnail = mXmppConnectionService.getBitmapCache().get(message.getUuid());
286 if ((thumbnail == null) && (!cacheOnly)) {
287 File file = getFile(message);
288 BitmapFactory.Options options = new BitmapFactory.Options();
289 options.inSampleSize = calcSampleSize(file, size);
290 Bitmap fullsize = BitmapFactory.decodeFile(file.getAbsolutePath(),options);
291 if (fullsize == null) {
292 throw new FileNotFoundException();
293 }
294 thumbnail = resize(fullsize, size);
295 thumbnail = rotate(thumbnail, getRotation(file));
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 input = rotate(input, getRotation(image));
408 return cropCenterSquare(input, size);
409 }
410 } catch (SecurityException e) {
411 return null; // happens for example on Android 6.0 if contacts permissions get revoked
412 } catch (FileNotFoundException e) {
413 return null;
414 } finally {
415 close(is);
416 }
417 }
418
419 public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
420 if (image == null) {
421 return null;
422 }
423 InputStream is = null;
424 try {
425 BitmapFactory.Options options = new BitmapFactory.Options();
426 options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
427 is = mXmppConnectionService.getContentResolver().openInputStream(image);
428 if (is == null) {
429 return null;
430 }
431 Bitmap source = BitmapFactory.decodeStream(is, null, options);
432 if (source == null) {
433 return null;
434 }
435 int sourceWidth = source.getWidth();
436 int sourceHeight = source.getHeight();
437 float xScale = (float) newWidth / sourceWidth;
438 float yScale = (float) newHeight / sourceHeight;
439 float scale = Math.max(xScale, yScale);
440 float scaledWidth = scale * sourceWidth;
441 float scaledHeight = scale * sourceHeight;
442 float left = (newWidth - scaledWidth) / 2;
443 float top = (newHeight - scaledHeight) / 2;
444
445 RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
446 Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
447 Canvas canvas = new Canvas(dest);
448 canvas.drawBitmap(source, null, targetRect, null);
449 if (source != null && !source.isRecycled()) {
450 source.recycle();
451 }
452 return dest;
453 } catch (SecurityException e) {
454 return null; //android 6.0 with revoked permissions for example
455 } catch (FileNotFoundException e) {
456 return null;
457 } finally {
458 close(is);
459 }
460 }
461
462 public Bitmap cropCenterSquare(Bitmap input, int size) {
463 int w = input.getWidth();
464 int h = input.getHeight();
465
466 float scale = Math.max((float) size / h, (float) size / w);
467
468 float outWidth = scale * w;
469 float outHeight = scale * h;
470 float left = (size - outWidth) / 2;
471 float top = (size - outHeight) / 2;
472 RectF target = new RectF(left, top, left + outWidth, top + outHeight);
473
474 Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
475 Canvas canvas = new Canvas(output);
476 canvas.drawBitmap(input, null, target, null);
477 if (input != null && !input.isRecycled()) {
478 input.recycle();
479 }
480 return output;
481 }
482
483 private int calcSampleSize(Uri image, int size) throws FileNotFoundException, SecurityException {
484 BitmapFactory.Options options = new BitmapFactory.Options();
485 options.inJustDecodeBounds = true;
486 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(image), null, options);
487 return calcSampleSize(options, size);
488 }
489
490 private static int calcSampleSize(File image, int size) {
491 BitmapFactory.Options options = new BitmapFactory.Options();
492 options.inJustDecodeBounds = true;
493 BitmapFactory.decodeFile(image.getAbsolutePath(), options);
494 return calcSampleSize(options, size);
495 }
496
497 public static int calcSampleSize(BitmapFactory.Options options, int size) {
498 int height = options.outHeight;
499 int width = options.outWidth;
500 int inSampleSize = 1;
501
502 if (height > size || width > size) {
503 int halfHeight = height / 2;
504 int halfWidth = width / 2;
505
506 while ((halfHeight / inSampleSize) > size
507 && (halfWidth / inSampleSize) > size) {
508 inSampleSize *= 2;
509 }
510 }
511 return inSampleSize;
512 }
513
514 public Uri getJingleFileUri(Message message) {
515 File file = getFile(message);
516 return Uri.parse("file://" + file.getAbsolutePath());
517 }
518
519 public void updateFileParams(Message message) {
520 updateFileParams(message,null);
521 }
522
523 public void updateFileParams(Message message, URL url) {
524 DownloadableFile file = getFile(message);
525 if (message.getType() == Message.TYPE_IMAGE || file.getMimeType().startsWith("image/")) {
526 BitmapFactory.Options options = new BitmapFactory.Options();
527 options.inJustDecodeBounds = true;
528 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
529 int rotation = getRotation(file);
530 boolean rotated = rotation == 90 || rotation == 270;
531 int imageHeight = rotated ? options.outWidth : options.outHeight;
532 int imageWidth = rotated ? options.outHeight : options.outWidth;
533 if (url == null) {
534 message.setBody(Long.toString(file.getSize()) + '|' + imageWidth + '|' + imageHeight);
535 } else {
536 message.setBody(url.toString()+"|"+Long.toString(file.getSize()) + '|' + imageWidth + '|' + imageHeight);
537 }
538 } else {
539 if (url != null) {
540 message.setBody(url.toString()+"|"+Long.toString(file.getSize()));
541 } else {
542 message.setBody(Long.toString(file.getSize()));
543 }
544 }
545
546 }
547
548 public class FileCopyException extends Exception {
549 private static final long serialVersionUID = -1010013599132881427L;
550 private int resId;
551
552 public FileCopyException(int resId) {
553 this.resId = resId;
554 }
555
556 public int getResId() {
557 return resId;
558 }
559 }
560
561 public Bitmap getAvatar(String avatar, int size) {
562 if (avatar == null) {
563 return null;
564 }
565 Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
566 if (bm == null) {
567 return null;
568 }
569 return bm;
570 }
571
572 public boolean isFileAvailable(Message message) {
573 return getFile(message).exists();
574 }
575
576 public static void close(Closeable stream) {
577 if (stream != null) {
578 try {
579 stream.close();
580 } catch (IOException e) {
581 }
582 }
583 }
584
585 public static void close(Socket socket) {
586 if (socket != null) {
587 try {
588 socket.close();
589 } catch (IOException e) {
590 }
591 }
592 }
593}