1package eu.siacs.conversations.persistance;
2
3import android.annotation.TargetApi;
4import android.content.ContentResolver;
5import android.content.Context;
6import android.database.Cursor;
7import android.graphics.Bitmap;
8import android.graphics.BitmapFactory;
9import android.graphics.Canvas;
10import android.graphics.Color;
11import android.graphics.Matrix;
12import android.graphics.Paint;
13import android.graphics.RectF;
14import android.graphics.pdf.PdfRenderer;
15import android.media.MediaMetadataRetriever;
16import android.media.MediaScannerConnection;
17import android.net.Uri;
18import android.os.Build;
19import android.os.Environment;
20import android.os.ParcelFileDescriptor;
21import android.provider.MediaStore;
22import android.provider.OpenableColumns;
23import android.system.Os;
24import android.system.StructStat;
25import android.util.Base64;
26import android.util.Base64OutputStream;
27import android.util.DisplayMetrics;
28import android.util.Log;
29import android.util.LruCache;
30
31import androidx.annotation.RequiresApi;
32import androidx.annotation.StringRes;
33import androidx.core.content.FileProvider;
34import androidx.exifinterface.media.ExifInterface;
35
36import com.google.common.base.Strings;
37import com.google.common.io.ByteStreams;
38
39import java.io.ByteArrayOutputStream;
40import java.io.Closeable;
41import java.io.File;
42import java.io.FileDescriptor;
43import java.io.FileInputStream;
44import java.io.FileNotFoundException;
45import java.io.FileOutputStream;
46import java.io.IOException;
47import java.io.InputStream;
48import java.io.OutputStream;
49import java.net.ServerSocket;
50import java.net.Socket;
51import java.security.DigestOutputStream;
52import java.security.MessageDigest;
53import java.security.NoSuchAlgorithmException;
54import java.text.SimpleDateFormat;
55import java.util.ArrayList;
56import java.util.Date;
57import java.util.List;
58import java.util.Locale;
59import java.util.UUID;
60
61import eu.siacs.conversations.Config;
62import eu.siacs.conversations.R;
63import eu.siacs.conversations.entities.DownloadableFile;
64import eu.siacs.conversations.entities.Message;
65import eu.siacs.conversations.services.AttachFileToConversationRunnable;
66import eu.siacs.conversations.services.XmppConnectionService;
67import eu.siacs.conversations.ui.util.Attachment;
68import eu.siacs.conversations.utils.Compatibility;
69import eu.siacs.conversations.utils.CryptoHelper;
70import eu.siacs.conversations.utils.FileUtils;
71import eu.siacs.conversations.utils.FileWriterException;
72import eu.siacs.conversations.utils.MimeUtils;
73import eu.siacs.conversations.xmpp.pep.Avatar;
74
75public class FileBackend {
76
77 private static final Object THUMBNAIL_LOCK = new Object();
78
79 private static final SimpleDateFormat IMAGE_DATE_FORMAT =
80 new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
81
82 private static final String FILE_PROVIDER = ".files";
83 private static final float IGNORE_PADDING = 0.15f;
84 private final XmppConnectionService mXmppConnectionService;
85
86 public FileBackend(XmppConnectionService service) {
87 this.mXmppConnectionService = service;
88 }
89
90 public static long getFileSize(Context context, Uri uri) {
91 try {
92 final Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
93 if (cursor != null && cursor.moveToFirst()) {
94 long size = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE));
95 cursor.close();
96 return size;
97 } else {
98 return -1;
99 }
100 } catch (Exception e) {
101 return -1;
102 }
103 }
104
105 public static boolean allFilesUnderSize(
106 Context context, List<Attachment> attachments, long max) {
107 final boolean compressVideo =
108 !AttachFileToConversationRunnable.getVideoCompression(context)
109 .equals("uncompressed");
110 if (max <= 0) {
111 Log.d(Config.LOGTAG, "server did not report max file size for http upload");
112 return true; // exception to be compatible with HTTP Upload < v0.2
113 }
114 for (Attachment attachment : attachments) {
115 if (attachment.getType() != Attachment.Type.FILE) {
116 continue;
117 }
118 String mime = attachment.getMime();
119 if (mime != null && mime.startsWith("video/") && compressVideo) {
120 try {
121 Dimensions dimensions =
122 FileBackend.getVideoDimensions(context, attachment.getUri());
123 if (dimensions.getMin() > 720) {
124 Log.d(
125 Config.LOGTAG,
126 "do not consider video file with min width larger than 720 for size check");
127 continue;
128 }
129 } catch (NotAVideoFile notAVideoFile) {
130 // ignore and fall through
131 }
132 }
133 if (FileBackend.getFileSize(context, attachment.getUri()) > max) {
134 Log.d(
135 Config.LOGTAG,
136 "not all files are under "
137 + max
138 + " bytes. suggesting falling back to jingle");
139 return false;
140 }
141 }
142 return true;
143 }
144
145 public static File getBackupDirectory(final Context context) {
146 final File conversationsDownloadDirectory =
147 new File(
148 Environment.getExternalStoragePublicDirectory(
149 Environment.DIRECTORY_DOWNLOADS),
150 context.getString(R.string.app_name));
151 return new File(conversationsDownloadDirectory, "Backup");
152 }
153
154 public static File getLegacyBackupDirectory(final String app) {
155 final File appDirectory = new File(Environment.getExternalStorageDirectory(), app);
156 return new File(appDirectory, "Backup");
157 }
158
159 private static Bitmap rotate(final Bitmap bitmap, final int degree) {
160 if (degree == 0) {
161 return bitmap;
162 }
163 final int w = bitmap.getWidth();
164 final int h = bitmap.getHeight();
165 final Matrix matrix = new Matrix();
166 matrix.postRotate(degree);
167 final Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true);
168 if (!bitmap.isRecycled()) {
169 bitmap.recycle();
170 }
171 return result;
172 }
173
174 public static boolean isPathBlacklisted(String path) {
175 final String androidDataPath =
176 Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/data/";
177 return path.startsWith(androidDataPath);
178 }
179
180 private static Paint createAntiAliasingPaint() {
181 Paint paint = new Paint();
182 paint.setAntiAlias(true);
183 paint.setFilterBitmap(true);
184 paint.setDither(true);
185 return paint;
186 }
187
188 private static String getTakePhotoPath() {
189 return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
190 + "/Camera/";
191 }
192
193 public static Uri getUriForUri(Context context, Uri uri) {
194 if ("file".equals(uri.getScheme())) {
195 return getUriForFile(context, new File(uri.getPath()));
196 } else {
197 return uri;
198 }
199 }
200
201 public static Uri getUriForFile(Context context, File file) {
202 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N || Config.ONLY_INTERNAL_STORAGE) {
203 try {
204 return FileProvider.getUriForFile(context, getAuthority(context), file);
205 } catch (IllegalArgumentException e) {
206 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
207 throw new SecurityException(e);
208 } else {
209 return Uri.fromFile(file);
210 }
211 }
212 } else {
213 return Uri.fromFile(file);
214 }
215 }
216
217 public static String getAuthority(Context context) {
218 return context.getPackageName() + FILE_PROVIDER;
219 }
220
221 private static boolean hasAlpha(final Bitmap bitmap) {
222 final int w = bitmap.getWidth();
223 final int h = bitmap.getHeight();
224 final int yStep = Math.max(1, w / 100);
225 final int xStep = Math.max(1, h / 100);
226 for (int x = 0; x < w; x += xStep) {
227 for (int y = 0; y < h; y += yStep) {
228 if (Color.alpha(bitmap.getPixel(x, y)) < 255) {
229 return true;
230 }
231 }
232 }
233 return false;
234 }
235
236 private static int calcSampleSize(File image, int size) {
237 BitmapFactory.Options options = new BitmapFactory.Options();
238 options.inJustDecodeBounds = true;
239 BitmapFactory.decodeFile(image.getAbsolutePath(), options);
240 return calcSampleSize(options, size);
241 }
242
243 private static int calcSampleSize(BitmapFactory.Options options, int size) {
244 int height = options.outHeight;
245 int width = options.outWidth;
246 int inSampleSize = 1;
247
248 if (height > size || width > size) {
249 int halfHeight = height / 2;
250 int halfWidth = width / 2;
251
252 while ((halfHeight / inSampleSize) > size && (halfWidth / inSampleSize) > size) {
253 inSampleSize *= 2;
254 }
255 }
256 return inSampleSize;
257 }
258
259 private static Dimensions getVideoDimensions(Context context, Uri uri) throws NotAVideoFile {
260 MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
261 try {
262 mediaMetadataRetriever.setDataSource(context, uri);
263 } catch (RuntimeException e) {
264 throw new NotAVideoFile(e);
265 }
266 return getVideoDimensions(mediaMetadataRetriever);
267 }
268
269 private static Dimensions getVideoDimensionsOfFrame(
270 MediaMetadataRetriever mediaMetadataRetriever) {
271 Bitmap bitmap = null;
272 try {
273 bitmap = mediaMetadataRetriever.getFrameAtTime();
274 return new Dimensions(bitmap.getHeight(), bitmap.getWidth());
275 } catch (Exception e) {
276 return null;
277 } finally {
278 if (bitmap != null) {
279 bitmap.recycle();
280 }
281 }
282 }
283
284 private static Dimensions getVideoDimensions(MediaMetadataRetriever metadataRetriever)
285 throws NotAVideoFile {
286 String hasVideo =
287 metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO);
288 if (hasVideo == null) {
289 throw new NotAVideoFile();
290 }
291 Dimensions dimensions = getVideoDimensionsOfFrame(metadataRetriever);
292 if (dimensions != null) {
293 return dimensions;
294 }
295 final int rotation = extractRotationFromMediaRetriever(metadataRetriever);
296 boolean rotated = rotation == 90 || rotation == 270;
297 int height;
298 try {
299 String h =
300 metadataRetriever.extractMetadata(
301 MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
302 height = Integer.parseInt(h);
303 } catch (Exception e) {
304 height = -1;
305 }
306 int width;
307 try {
308 String w =
309 metadataRetriever.extractMetadata(
310 MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
311 width = Integer.parseInt(w);
312 } catch (Exception e) {
313 width = -1;
314 }
315 metadataRetriever.release();
316 Log.d(Config.LOGTAG, "extracted video dims " + width + "x" + height);
317 return rotated ? new Dimensions(width, height) : new Dimensions(height, width);
318 }
319
320 private static int extractRotationFromMediaRetriever(MediaMetadataRetriever metadataRetriever) {
321 String r =
322 metadataRetriever.extractMetadata(
323 MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
324 try {
325 return Integer.parseInt(r);
326 } catch (Exception e) {
327 return 0;
328 }
329 }
330
331 public static void close(final Closeable stream) {
332 if (stream != null) {
333 try {
334 stream.close();
335 } catch (Exception e) {
336 Log.d(Config.LOGTAG, "unable to close stream", e);
337 }
338 }
339 }
340
341 public static void close(final Socket socket) {
342 if (socket != null) {
343 try {
344 socket.close();
345 } catch (IOException e) {
346 Log.d(Config.LOGTAG, "unable to close socket", e);
347 }
348 }
349 }
350
351 public static void close(final ServerSocket socket) {
352 if (socket != null) {
353 try {
354 socket.close();
355 } catch (IOException e) {
356 Log.d(Config.LOGTAG, "unable to close server socket", e);
357 }
358 }
359 }
360
361 public static boolean weOwnFile(Context context, Uri uri) {
362 if (uri == null || !ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
363 return false;
364 } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
365 return fileIsInFilesDir(context, uri);
366 } else {
367 return weOwnFileLollipop(uri);
368 }
369 }
370
371 /**
372 * This is more than hacky but probably way better than doing nothing Further 'optimizations'
373 * might contain to get the parents of CacheDir and NoBackupDir and check against those as well
374 */
375 private static boolean fileIsInFilesDir(Context context, Uri uri) {
376 try {
377 final String haystack = context.getFilesDir().getParentFile().getCanonicalPath();
378 final String needle = new File(uri.getPath()).getCanonicalPath();
379 return needle.startsWith(haystack);
380 } catch (IOException e) {
381 return false;
382 }
383 }
384
385 @TargetApi(Build.VERSION_CODES.LOLLIPOP)
386 private static boolean weOwnFileLollipop(Uri uri) {
387 try {
388 File file = new File(uri.getPath());
389 FileDescriptor fd =
390 ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
391 .getFileDescriptor();
392 StructStat st = Os.fstat(fd);
393 return st.st_uid == android.os.Process.myUid();
394 } catch (FileNotFoundException e) {
395 return false;
396 } catch (Exception e) {
397 return true;
398 }
399 }
400
401 public static Uri getMediaUri(Context context, File file) {
402 final String filePath = file.getAbsolutePath();
403 final Cursor cursor;
404 try {
405 cursor =
406 context.getContentResolver()
407 .query(
408 MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
409 new String[] {MediaStore.Images.Media._ID},
410 MediaStore.Images.Media.DATA + "=? ",
411 new String[] {filePath},
412 null);
413 } catch (SecurityException e) {
414 return null;
415 }
416 if (cursor != null && cursor.moveToFirst()) {
417 final int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID));
418 cursor.close();
419 return Uri.withAppendedPath(
420 MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
421 } else {
422 return null;
423 }
424 }
425
426 public static void updateFileParams(Message message, String url, long size) {
427 final StringBuilder body = new StringBuilder();
428 body.append(url).append('|').append(size);
429 message.setBody(body.toString());
430 }
431
432 public Bitmap getPreviewForUri(Attachment attachment, int size, boolean cacheOnly) {
433 final String key = "attachment_" + attachment.getUuid().toString() + "_" + size;
434 final LruCache<String, Bitmap> cache = mXmppConnectionService.getBitmapCache();
435 Bitmap bitmap = cache.get(key);
436 if (bitmap != null || cacheOnly) {
437 return bitmap;
438 }
439 final String mime = attachment.getMime();
440 if ("application/pdf".equals(mime) && Compatibility.runsTwentyOne()) {
441 bitmap = cropCenterSquarePdf(attachment.getUri(), size);
442 drawOverlay(
443 bitmap,
444 paintOverlayBlackPdf(bitmap)
445 ? R.drawable.open_pdf_black
446 : R.drawable.open_pdf_white,
447 0.75f);
448 } else if (mime != null && mime.startsWith("video/")) {
449 bitmap = cropCenterSquareVideo(attachment.getUri(), size);
450 drawOverlay(
451 bitmap,
452 paintOverlayBlack(bitmap)
453 ? R.drawable.play_video_black
454 : R.drawable.play_video_white,
455 0.75f);
456 } else {
457 bitmap = cropCenterSquare(attachment.getUri(), size);
458 if (bitmap != null && "image/gif".equals(mime)) {
459 Bitmap withGifOverlay = bitmap.copy(Bitmap.Config.ARGB_8888, true);
460 drawOverlay(
461 withGifOverlay,
462 paintOverlayBlack(withGifOverlay)
463 ? R.drawable.play_gif_black
464 : R.drawable.play_gif_white,
465 1.0f);
466 bitmap.recycle();
467 bitmap = withGifOverlay;
468 }
469 }
470 if (bitmap != null) {
471 cache.put(key, bitmap);
472 }
473 return bitmap;
474 }
475
476 private void createNoMedia(File diretory) {
477 final File noMedia = new File(diretory, ".nomedia");
478 if (!noMedia.exists()) {
479 try {
480 if (!noMedia.createNewFile()) {
481 Log.d(Config.LOGTAG, "created nomedia file " + noMedia.getAbsolutePath());
482 }
483 } catch (Exception e) {
484 Log.d(Config.LOGTAG, "could not create nomedia file");
485 }
486 }
487 }
488
489 public void updateMediaScanner(File file) {
490 updateMediaScanner(file, null);
491 }
492
493 public void updateMediaScanner(File file, final Runnable callback) {
494 MediaScannerConnection.scanFile(
495 mXmppConnectionService,
496 new String[] {file.getAbsolutePath()},
497 null,
498 new MediaScannerConnection.MediaScannerConnectionClient() {
499 @Override
500 public void onMediaScannerConnected() {}
501
502 @Override
503 public void onScanCompleted(String path, Uri uri) {
504 if (callback != null && file.getAbsolutePath().equals(path)) {
505 callback.run();
506 } else {
507 Log.d(Config.LOGTAG, "media scanner scanned wrong file");
508 if (callback != null) {
509 callback.run();
510 }
511 }
512 }
513 });
514 }
515
516 public boolean deleteFile(Message message) {
517 File file = getFile(message);
518 if (file.delete()) {
519 updateMediaScanner(file);
520 return true;
521 } else {
522 return false;
523 }
524 }
525
526 public DownloadableFile getFile(Message message) {
527 return getFile(message, true);
528 }
529
530 public DownloadableFile getFileForPath(String path) {
531 return getFileForPath(
532 path,
533 MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path)));
534 }
535
536 private DownloadableFile getFileForPath(final String path, final String mime) {
537 if (path.startsWith("/")) {
538 return new DownloadableFile(path);
539 } else {
540 return getLegacyFileForFilename(path, mime);
541 }
542 }
543
544 public DownloadableFile getLegacyFileForFilename(final String filename, final String mime) {
545 if (Strings.isNullOrEmpty(mime)) {
546 return new DownloadableFile(getLegacyStorageLocation("Files"), filename);
547 } else if (mime.startsWith("image/")) {
548 return new DownloadableFile(getLegacyStorageLocation("Images"), filename);
549 } else if (mime.startsWith("video/")) {
550 return new DownloadableFile(getLegacyStorageLocation("Videos"), filename);
551 } else {
552 return new DownloadableFile(getLegacyStorageLocation("Files"), filename);
553 }
554 }
555
556 public boolean isInternalFile(final File file) {
557 final File internalFile = getFileForPath(file.getName());
558 return file.getAbsolutePath().equals(internalFile.getAbsolutePath());
559 }
560
561 public DownloadableFile getFile(Message message, boolean decrypted) {
562 final boolean encrypted =
563 !decrypted
564 && (message.getEncryption() == Message.ENCRYPTION_PGP
565 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
566 String path = message.getRelativeFilePath();
567 if (path == null) {
568 path = message.getUuid();
569 }
570 final DownloadableFile file = getFileForPath(path, message.getMimeType());
571 if (encrypted) {
572 return new DownloadableFile(getLegacyStorageLocation("Files"), file.getName() + ".pgp");
573 } else {
574 return file;
575 }
576 }
577
578 public List<Attachment> convertToAttachments(List<DatabaseBackend.FilePath> relativeFilePaths) {
579 final List<Attachment> attachments = new ArrayList<>();
580 for (final DatabaseBackend.FilePath relativeFilePath : relativeFilePaths) {
581 final String mime =
582 MimeUtils.guessMimeTypeFromExtension(
583 MimeUtils.extractRelevantExtension(relativeFilePath.path));
584 final File file = getFileForPath(relativeFilePath.path, mime);
585 attachments.add(Attachment.of(relativeFilePath.uuid, file, mime));
586 }
587 return attachments;
588 }
589
590 private File getLegacyStorageLocation(final String type) {
591 if (Config.ONLY_INTERNAL_STORAGE) {
592 return new File(mXmppConnectionService.getFilesDir(), type);
593 } else {
594 final File appDirectory =
595 new File(
596 Environment.getExternalStorageDirectory(),
597 mXmppConnectionService.getString(R.string.app_name));
598 final File appMediaDirectory = new File(appDirectory, "Media");
599 final String locationName =
600 String.format(
601 "%s %s", mXmppConnectionService.getString(R.string.app_name), type);
602 return new File(appMediaDirectory, locationName);
603 }
604 }
605
606 private Bitmap resize(final Bitmap originalBitmap, int size) throws IOException {
607 int w = originalBitmap.getWidth();
608 int h = originalBitmap.getHeight();
609 if (w <= 0 || h <= 0) {
610 throw new IOException("Decoded bitmap reported bounds smaller 0");
611 } else if (Math.max(w, h) > size) {
612 int scalledW;
613 int scalledH;
614 if (w <= h) {
615 scalledW = Math.max((int) (w / ((double) h / size)), 1);
616 scalledH = size;
617 } else {
618 scalledW = size;
619 scalledH = Math.max((int) (h / ((double) w / size)), 1);
620 }
621 final Bitmap result =
622 Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
623 if (!originalBitmap.isRecycled()) {
624 originalBitmap.recycle();
625 }
626 return result;
627 } else {
628 return originalBitmap;
629 }
630 }
631
632 public boolean useImageAsIs(final Uri uri) {
633 final String path = getOriginalPath(uri);
634 if (path == null || isPathBlacklisted(path)) {
635 return false;
636 }
637 final File file = new File(path);
638 long size = file.length();
639 if (size == 0
640 || size
641 >= mXmppConnectionService
642 .getResources()
643 .getInteger(R.integer.auto_accept_filesize)) {
644 return false;
645 }
646 BitmapFactory.Options options = new BitmapFactory.Options();
647 options.inJustDecodeBounds = true;
648 try {
649 final InputStream inputStream =
650 mXmppConnectionService.getContentResolver().openInputStream(uri);
651 BitmapFactory.decodeStream(inputStream, null, options);
652 close(inputStream);
653 if (options.outMimeType == null || options.outHeight <= 0 || options.outWidth <= 0) {
654 return false;
655 }
656 return (options.outWidth <= Config.IMAGE_SIZE
657 && options.outHeight <= Config.IMAGE_SIZE
658 && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
659 } catch (FileNotFoundException e) {
660 Log.d(Config.LOGTAG, "unable to get image dimensions", e);
661 return false;
662 }
663 }
664
665 public String getOriginalPath(Uri uri) {
666 return FileUtils.getPath(mXmppConnectionService, uri);
667 }
668
669 private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException {
670 Log.d(
671 Config.LOGTAG,
672 "copy file (" + uri.toString() + ") to private storage " + file.getAbsolutePath());
673 file.getParentFile().mkdirs();
674 try {
675 file.createNewFile();
676 } catch (IOException e) {
677 throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
678 }
679 try (final OutputStream os = new FileOutputStream(file);
680 final InputStream is =
681 mXmppConnectionService.getContentResolver().openInputStream(uri)) {
682 if (is == null) {
683 throw new FileCopyException(R.string.error_file_not_found);
684 }
685 try {
686 ByteStreams.copy(is, os);
687 } catch (IOException e) {
688 throw new FileWriterException();
689 }
690 try {
691 os.flush();
692 } catch (IOException e) {
693 throw new FileWriterException();
694 }
695 } catch (final FileNotFoundException e) {
696 cleanup(file);
697 throw new FileCopyException(R.string.error_file_not_found);
698 } catch (final FileWriterException e) {
699 cleanup(file);
700 throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
701 } catch (final SecurityException e) {
702 cleanup(file);
703 throw new FileCopyException(R.string.error_security_exception);
704 } catch (final IOException e) {
705 cleanup(file);
706 throw new FileCopyException(R.string.error_io_exception);
707 }
708 }
709
710 public void copyFileToPrivateStorage(Message message, Uri uri, String type)
711 throws FileCopyException {
712 String mime = MimeUtils.guessMimeTypeFromUriAndMime(mXmppConnectionService, uri, type);
713 Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage (mime=" + mime + ")");
714 String extension = MimeUtils.guessExtensionFromMimeType(mime);
715 if (extension == null) {
716 Log.d(Config.LOGTAG, "extension from mime type was null");
717 extension = getExtensionFromUri(uri);
718 }
719 if ("ogg".equals(extension) && type != null && type.startsWith("audio/")) {
720 extension = "oga";
721 }
722 setupRelativeFilePath(message, String.format("%s.%s", message.getUuid(), extension));
723 copyFileToPrivateStorage(mXmppConnectionService.getFileBackend().getFile(message), uri);
724 }
725
726 private String getExtensionFromUri(Uri uri) {
727 String[] projection = {MediaStore.MediaColumns.DATA};
728 String filename = null;
729 Cursor cursor;
730 try {
731 cursor =
732 mXmppConnectionService
733 .getContentResolver()
734 .query(uri, projection, null, null, null);
735 } catch (IllegalArgumentException e) {
736 cursor = null;
737 }
738 if (cursor != null) {
739 try {
740 if (cursor.moveToFirst()) {
741 filename = cursor.getString(0);
742 }
743 } catch (Exception e) {
744 filename = null;
745 } finally {
746 cursor.close();
747 }
748 }
749 if (filename == null) {
750 final List<String> segments = uri.getPathSegments();
751 if (segments.size() > 0) {
752 filename = segments.get(segments.size() - 1);
753 }
754 }
755 int pos = filename == null ? -1 : filename.lastIndexOf('.');
756 return pos > 0 ? filename.substring(pos + 1) : null;
757 }
758
759 private void copyImageToPrivateStorage(File file, Uri image, int sampleSize)
760 throws FileCopyException, ImageCompressionException {
761 final File parent = file.getParentFile();
762 if (parent != null && parent.mkdirs()) {
763 Log.d(Config.LOGTAG, "created parent directory");
764 }
765 InputStream is = null;
766 OutputStream os = null;
767 try {
768 if (!file.exists() && !file.createNewFile()) {
769 throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
770 }
771 is = mXmppConnectionService.getContentResolver().openInputStream(image);
772 if (is == null) {
773 throw new FileCopyException(R.string.error_not_an_image_file);
774 }
775 final Bitmap originalBitmap;
776 final BitmapFactory.Options options = new BitmapFactory.Options();
777 final int inSampleSize = (int) Math.pow(2, sampleSize);
778 Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize);
779 options.inSampleSize = inSampleSize;
780 originalBitmap = BitmapFactory.decodeStream(is, null, options);
781 is.close();
782 if (originalBitmap == null) {
783 throw new ImageCompressionException("Source file was not an image");
784 }
785 if (!"image/jpeg".equals(options.outMimeType) && hasAlpha(originalBitmap)) {
786 originalBitmap.recycle();
787 throw new ImageCompressionException("Source file had alpha channel");
788 }
789 Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
790 final int rotation = getRotation(image);
791 scaledBitmap = rotate(scaledBitmap, rotation);
792 boolean targetSizeReached = false;
793 int quality = Config.IMAGE_QUALITY;
794 final int imageMaxSize =
795 mXmppConnectionService
796 .getResources()
797 .getInteger(R.integer.auto_accept_filesize);
798 while (!targetSizeReached) {
799 os = new FileOutputStream(file);
800 Log.d(Config.LOGTAG, "compressing image with quality " + quality);
801 boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, quality, os);
802 if (!success) {
803 throw new FileCopyException(R.string.error_compressing_image);
804 }
805 os.flush();
806 final long fileSize = file.length();
807 Log.d(Config.LOGTAG, "achieved file size of " + fileSize);
808 targetSizeReached = fileSize <= imageMaxSize || quality <= 50;
809 quality -= 5;
810 }
811 scaledBitmap.recycle();
812 } catch (final FileNotFoundException e) {
813 cleanup(file);
814 throw new FileCopyException(R.string.error_file_not_found);
815 } catch (final IOException e) {
816 cleanup(file);
817 throw new FileCopyException(R.string.error_io_exception);
818 } catch (SecurityException e) {
819 cleanup(file);
820 throw new FileCopyException(R.string.error_security_exception_during_image_copy);
821 } catch (final OutOfMemoryError e) {
822 ++sampleSize;
823 if (sampleSize <= 3) {
824 copyImageToPrivateStorage(file, image, sampleSize);
825 } else {
826 throw new FileCopyException(R.string.error_out_of_memory);
827 }
828 } finally {
829 close(os);
830 close(is);
831 }
832 }
833
834 private static void cleanup(final File file) {
835 try {
836 file.delete();
837 } catch (Exception e) {
838
839 }
840 }
841
842 public void copyImageToPrivateStorage(File file, Uri image)
843 throws FileCopyException, ImageCompressionException {
844 Log.d(
845 Config.LOGTAG,
846 "copy image ("
847 + image.toString()
848 + ") to private storage "
849 + file.getAbsolutePath());
850 copyImageToPrivateStorage(file, image, 0);
851 }
852
853 public void copyImageToPrivateStorage(Message message, Uri image)
854 throws FileCopyException, ImageCompressionException {
855 final String filename;
856 switch (Config.IMAGE_FORMAT) {
857 case JPEG:
858 filename = String.format("%s.%s", message.getUuid(), "jpg");
859 break;
860 case PNG:
861 filename = String.format("%s.%s", message.getUuid(), "png");
862 break;
863 case WEBP:
864 filename = String.format("%s.%s", message.getUuid(), "webp");
865 break;
866 default:
867 throw new IllegalStateException("Unknown image format");
868 }
869 setupRelativeFilePath(message, filename);
870 copyImageToPrivateStorage(getFile(message), image);
871 updateFileParams(message);
872 }
873
874 public void setupRelativeFilePath(final Message message, final String filename) {
875 final String extension = MimeUtils.extractRelevantExtension(filename);
876 final String mime = MimeUtils.guessMimeTypeFromExtension(extension);
877 setupRelativeFilePath(message, filename, mime);
878 }
879
880 public File getStorageLocation(final String filename, final String mime) {
881 final File parentDirectory;
882 if (Strings.isNullOrEmpty(mime)) {
883 parentDirectory =
884 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
885 } else if (mime.startsWith("image/")) {
886 parentDirectory =
887 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
888 } else if (mime.startsWith("video/")) {
889 parentDirectory =
890 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
891 } else {
892 parentDirectory =
893 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
894 }
895 final File appDirectory =
896 new File(parentDirectory, mXmppConnectionService.getString(R.string.app_name));
897 return new File(appDirectory, filename);
898 }
899
900 public void setupRelativeFilePath(
901 final Message message, final String filename, final String mime) {
902 final File file = getStorageLocation(filename, mime);
903 message.setRelativeFilePath(file.getAbsolutePath());
904 }
905
906 public boolean unusualBounds(final Uri image) {
907 try {
908 final BitmapFactory.Options options = new BitmapFactory.Options();
909 options.inJustDecodeBounds = true;
910 final InputStream inputStream =
911 mXmppConnectionService.getContentResolver().openInputStream(image);
912 BitmapFactory.decodeStream(inputStream, null, options);
913 close(inputStream);
914 float ratio = (float) options.outHeight / options.outWidth;
915 return ratio > (21.0f / 9.0f) || ratio < (9.0f / 21.0f);
916 } catch (final Exception e) {
917 Log.w(Config.LOGTAG, "unable to detect image bounds", e);
918 return false;
919 }
920 }
921
922 private int getRotation(final File file) {
923 try (final InputStream inputStream = new FileInputStream(file)) {
924 return getRotation(inputStream);
925 } catch (Exception e) {
926 return 0;
927 }
928 }
929
930 private int getRotation(final Uri image) {
931 try (final InputStream is =
932 mXmppConnectionService.getContentResolver().openInputStream(image)) {
933 return is == null ? 0 : getRotation(is);
934 } catch (final Exception e) {
935 return 0;
936 }
937 }
938
939 private static int getRotation(final InputStream inputStream) throws IOException {
940 final ExifInterface exif = new ExifInterface(inputStream);
941 final int orientation =
942 exif.getAttributeInt(
943 ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
944 switch (orientation) {
945 case ExifInterface.ORIENTATION_ROTATE_180:
946 return 180;
947 case ExifInterface.ORIENTATION_ROTATE_90:
948 return 90;
949 case ExifInterface.ORIENTATION_ROTATE_270:
950 return 270;
951 default:
952 return 0;
953 }
954 }
955
956 public Bitmap getThumbnail(Message message, int size, boolean cacheOnly) throws IOException {
957 final String uuid = message.getUuid();
958 final LruCache<String, Bitmap> cache = mXmppConnectionService.getBitmapCache();
959 Bitmap thumbnail = cache.get(uuid);
960 if ((thumbnail == null) && (!cacheOnly)) {
961 synchronized (THUMBNAIL_LOCK) {
962 thumbnail = cache.get(uuid);
963 if (thumbnail != null) {
964 return thumbnail;
965 }
966 DownloadableFile file = getFile(message);
967 final String mime = file.getMimeType();
968 if ("application/pdf".equals(mime) && Compatibility.runsTwentyOne()) {
969 thumbnail = getPdfDocumentPreview(file, size);
970 } else if (mime.startsWith("video/")) {
971 thumbnail = getVideoPreview(file, size);
972 } else {
973 final Bitmap fullSize = getFullSizeImagePreview(file, size);
974 if (fullSize == null) {
975 throw new FileNotFoundException();
976 }
977 thumbnail = resize(fullSize, size);
978 thumbnail = rotate(thumbnail, getRotation(file));
979 if (mime.equals("image/gif")) {
980 Bitmap withGifOverlay = thumbnail.copy(Bitmap.Config.ARGB_8888, true);
981 drawOverlay(
982 withGifOverlay,
983 paintOverlayBlack(withGifOverlay)
984 ? R.drawable.play_gif_black
985 : R.drawable.play_gif_white,
986 1.0f);
987 thumbnail.recycle();
988 thumbnail = withGifOverlay;
989 }
990 }
991 cache.put(uuid, thumbnail);
992 }
993 }
994 return thumbnail;
995 }
996
997 private Bitmap getFullSizeImagePreview(File file, int size) {
998 BitmapFactory.Options options = new BitmapFactory.Options();
999 options.inSampleSize = calcSampleSize(file, size);
1000 try {
1001 return BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1002 } catch (OutOfMemoryError e) {
1003 options.inSampleSize *= 2;
1004 return BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1005 }
1006 }
1007
1008 private void drawOverlay(Bitmap bitmap, int resource, float factor) {
1009 Bitmap overlay =
1010 BitmapFactory.decodeResource(mXmppConnectionService.getResources(), resource);
1011 Canvas canvas = new Canvas(bitmap);
1012 float targetSize = Math.min(canvas.getWidth(), canvas.getHeight()) * factor;
1013 Log.d(
1014 Config.LOGTAG,
1015 "target size overlay: "
1016 + targetSize
1017 + " overlay bitmap size was "
1018 + overlay.getHeight());
1019 float left = (canvas.getWidth() - targetSize) / 2.0f;
1020 float top = (canvas.getHeight() - targetSize) / 2.0f;
1021 RectF dst = new RectF(left, top, left + targetSize - 1, top + targetSize - 1);
1022 canvas.drawBitmap(overlay, null, dst, createAntiAliasingPaint());
1023 }
1024
1025 /** https://stackoverflow.com/a/3943023/210897 */
1026 private boolean paintOverlayBlack(final Bitmap bitmap) {
1027 final int h = bitmap.getHeight();
1028 final int w = bitmap.getWidth();
1029 int record = 0;
1030 for (int y = Math.round(h * IGNORE_PADDING); y < h - Math.round(h * IGNORE_PADDING); ++y) {
1031 for (int x = Math.round(w * IGNORE_PADDING);
1032 x < w - Math.round(w * IGNORE_PADDING);
1033 ++x) {
1034 int pixel = bitmap.getPixel(x, y);
1035 if ((Color.red(pixel) * 0.299
1036 + Color.green(pixel) * 0.587
1037 + Color.blue(pixel) * 0.114)
1038 > 186) {
1039 --record;
1040 } else {
1041 ++record;
1042 }
1043 }
1044 }
1045 return record < 0;
1046 }
1047
1048 private boolean paintOverlayBlackPdf(final Bitmap bitmap) {
1049 final int h = bitmap.getHeight();
1050 final int w = bitmap.getWidth();
1051 int white = 0;
1052 for (int y = 0; y < h; ++y) {
1053 for (int x = 0; x < w; ++x) {
1054 int pixel = bitmap.getPixel(x, y);
1055 if ((Color.red(pixel) * 0.299
1056 + Color.green(pixel) * 0.587
1057 + Color.blue(pixel) * 0.114)
1058 > 186) {
1059 white++;
1060 }
1061 }
1062 }
1063 return white > (h * w * 0.4f);
1064 }
1065
1066 private Bitmap cropCenterSquareVideo(Uri uri, int size) {
1067 MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1068 Bitmap frame;
1069 try {
1070 metadataRetriever.setDataSource(mXmppConnectionService, uri);
1071 frame = metadataRetriever.getFrameAtTime(0);
1072 metadataRetriever.release();
1073 return cropCenterSquare(frame, size);
1074 } catch (Exception e) {
1075 frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1076 frame.eraseColor(0xff000000);
1077 return frame;
1078 }
1079 }
1080
1081 private Bitmap getVideoPreview(final File file, final int size) {
1082 final MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1083 Bitmap frame;
1084 try {
1085 metadataRetriever.setDataSource(file.getAbsolutePath());
1086 frame = metadataRetriever.getFrameAtTime(0);
1087 metadataRetriever.release();
1088 frame = resize(frame, size);
1089 } catch (IOException | RuntimeException e) {
1090 frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1091 frame.eraseColor(0xff000000);
1092 }
1093 drawOverlay(
1094 frame,
1095 paintOverlayBlack(frame)
1096 ? R.drawable.play_video_black
1097 : R.drawable.play_video_white,
1098 0.75f);
1099 return frame;
1100 }
1101
1102 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1103 private Bitmap getPdfDocumentPreview(final File file, final int size) {
1104 try {
1105 final ParcelFileDescriptor fileDescriptor =
1106 ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
1107 final Bitmap rendered = renderPdfDocument(fileDescriptor, size, true);
1108 drawOverlay(
1109 rendered,
1110 paintOverlayBlackPdf(rendered)
1111 ? R.drawable.open_pdf_black
1112 : R.drawable.open_pdf_white,
1113 0.75f);
1114 return rendered;
1115 } catch (final IOException | SecurityException e) {
1116 Log.d(Config.LOGTAG, "unable to render PDF document preview", e);
1117 final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1118 placeholder.eraseColor(0xff000000);
1119 return placeholder;
1120 }
1121 }
1122
1123 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1124 private Bitmap cropCenterSquarePdf(final Uri uri, final int size) {
1125 try {
1126 ParcelFileDescriptor fileDescriptor =
1127 mXmppConnectionService.getContentResolver().openFileDescriptor(uri, "r");
1128 final Bitmap bitmap = renderPdfDocument(fileDescriptor, size, false);
1129 return cropCenterSquare(bitmap, size);
1130 } catch (Exception e) {
1131 final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1132 placeholder.eraseColor(0xff000000);
1133 return placeholder;
1134 }
1135 }
1136
1137 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1138 private Bitmap renderPdfDocument(
1139 ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
1140 final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
1141 final PdfRenderer.Page page = pdfRenderer.openPage(0);
1142 final Dimensions dimensions =
1143 scalePdfDimensions(
1144 new Dimensions(page.getHeight(), page.getWidth()), targetSize, fit);
1145 final Bitmap rendered =
1146 Bitmap.createBitmap(dimensions.width, dimensions.height, Bitmap.Config.ARGB_8888);
1147 rendered.eraseColor(0xffffffff);
1148 page.render(rendered, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
1149 page.close();
1150 pdfRenderer.close();
1151 fileDescriptor.close();
1152 return rendered;
1153 }
1154
1155 public Uri getTakePhotoUri() {
1156 final String filename =
1157 String.format("IMG_%s.%s", IMAGE_DATE_FORMAT.format(new Date()), "jpg");
1158 final File directory;
1159 if (Config.ONLY_INTERNAL_STORAGE) {
1160 directory = new File(mXmppConnectionService.getCacheDir(), "Camera");
1161 } else {
1162 directory =
1163 new File(
1164 Environment.getExternalStoragePublicDirectory(
1165 Environment.DIRECTORY_DCIM),
1166 "Camera");
1167 }
1168 final File file = new File(directory, filename);
1169 file.getParentFile().mkdirs();
1170 return getUriForFile(mXmppConnectionService, file);
1171 }
1172
1173 public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
1174
1175 final Avatar uncompressAvatar = getUncompressedAvatar(image);
1176 if (uncompressAvatar != null
1177 && uncompressAvatar.image.length() <= Config.AVATAR_CHAR_LIMIT) {
1178 return uncompressAvatar;
1179 }
1180 if (uncompressAvatar != null) {
1181 Log.d(
1182 Config.LOGTAG,
1183 "uncompressed avatar exceeded char limit by "
1184 + (uncompressAvatar.image.length() - Config.AVATAR_CHAR_LIMIT));
1185 }
1186
1187 Bitmap bm = cropCenterSquare(image, size);
1188 if (bm == null) {
1189 return null;
1190 }
1191 if (hasAlpha(bm)) {
1192 Log.d(Config.LOGTAG, "alpha in avatar detected; uploading as PNG");
1193 bm.recycle();
1194 bm = cropCenterSquare(image, 96);
1195 return getPepAvatar(bm, Bitmap.CompressFormat.PNG, 100);
1196 }
1197 return getPepAvatar(bm, format, 100);
1198 }
1199
1200 private Avatar getUncompressedAvatar(Uri uri) {
1201 Bitmap bitmap = null;
1202 try {
1203 bitmap =
1204 BitmapFactory.decodeStream(
1205 mXmppConnectionService.getContentResolver().openInputStream(uri));
1206 return getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100);
1207 } catch (Exception e) {
1208 return null;
1209 } finally {
1210 if (bitmap != null) {
1211 bitmap.recycle();
1212 }
1213 }
1214 }
1215
1216 private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
1217 try {
1218 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1219 Base64OutputStream mBase64OutputStream =
1220 new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1221 MessageDigest digest = MessageDigest.getInstance("SHA-1");
1222 DigestOutputStream mDigestOutputStream =
1223 new DigestOutputStream(mBase64OutputStream, digest);
1224 if (!bitmap.compress(format, quality, mDigestOutputStream)) {
1225 return null;
1226 }
1227 mDigestOutputStream.flush();
1228 mDigestOutputStream.close();
1229 long chars = mByteArrayOutputStream.size();
1230 if (format != Bitmap.CompressFormat.PNG
1231 && quality >= 50
1232 && chars >= Config.AVATAR_CHAR_LIMIT) {
1233 int q = quality - 2;
1234 Log.d(
1235 Config.LOGTAG,
1236 "avatar char length was " + chars + " reducing quality to " + q);
1237 return getPepAvatar(bitmap, format, q);
1238 }
1239 Log.d(Config.LOGTAG, "settled on char length " + chars + " with quality=" + quality);
1240 final Avatar avatar = new Avatar();
1241 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1242 avatar.image = new String(mByteArrayOutputStream.toByteArray());
1243 if (format.equals(Bitmap.CompressFormat.WEBP)) {
1244 avatar.type = "image/webp";
1245 } else if (format.equals(Bitmap.CompressFormat.JPEG)) {
1246 avatar.type = "image/jpeg";
1247 } else if (format.equals(Bitmap.CompressFormat.PNG)) {
1248 avatar.type = "image/png";
1249 }
1250 avatar.width = bitmap.getWidth();
1251 avatar.height = bitmap.getHeight();
1252 return avatar;
1253 } catch (OutOfMemoryError e) {
1254 Log.d(Config.LOGTAG, "unable to convert avatar to base64 due to low memory");
1255 return null;
1256 } catch (Exception e) {
1257 return null;
1258 }
1259 }
1260
1261 public Avatar getStoredPepAvatar(String hash) {
1262 if (hash == null) {
1263 return null;
1264 }
1265 Avatar avatar = new Avatar();
1266 final File file = getAvatarFile(hash);
1267 FileInputStream is = null;
1268 try {
1269 avatar.size = file.length();
1270 BitmapFactory.Options options = new BitmapFactory.Options();
1271 options.inJustDecodeBounds = true;
1272 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1273 is = new FileInputStream(file);
1274 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1275 Base64OutputStream mBase64OutputStream =
1276 new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1277 MessageDigest digest = MessageDigest.getInstance("SHA-1");
1278 DigestOutputStream os = new DigestOutputStream(mBase64OutputStream, digest);
1279 byte[] buffer = new byte[4096];
1280 int length;
1281 while ((length = is.read(buffer)) > 0) {
1282 os.write(buffer, 0, length);
1283 }
1284 os.flush();
1285 os.close();
1286 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1287 avatar.image = new String(mByteArrayOutputStream.toByteArray());
1288 avatar.height = options.outHeight;
1289 avatar.width = options.outWidth;
1290 avatar.type = options.outMimeType;
1291 return avatar;
1292 } catch (NoSuchAlgorithmException | IOException e) {
1293 return null;
1294 } finally {
1295 close(is);
1296 }
1297 }
1298
1299 public boolean isAvatarCached(Avatar avatar) {
1300 final File file = getAvatarFile(avatar.getFilename());
1301 return file.exists();
1302 }
1303
1304 public boolean save(final Avatar avatar) {
1305 File file;
1306 if (isAvatarCached(avatar)) {
1307 file = getAvatarFile(avatar.getFilename());
1308 avatar.size = file.length();
1309 } else {
1310 file =
1311 new File(
1312 mXmppConnectionService.getCacheDir().getAbsolutePath()
1313 + "/"
1314 + UUID.randomUUID().toString());
1315 if (file.getParentFile().mkdirs()) {
1316 Log.d(Config.LOGTAG, "created cache directory");
1317 }
1318 OutputStream os = null;
1319 try {
1320 if (!file.createNewFile()) {
1321 Log.d(
1322 Config.LOGTAG,
1323 "unable to create temporary file " + file.getAbsolutePath());
1324 }
1325 os = new FileOutputStream(file);
1326 MessageDigest digest = MessageDigest.getInstance("SHA-1");
1327 digest.reset();
1328 DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
1329 final byte[] bytes = avatar.getImageAsBytes();
1330 mDigestOutputStream.write(bytes);
1331 mDigestOutputStream.flush();
1332 mDigestOutputStream.close();
1333 String sha1sum = CryptoHelper.bytesToHex(digest.digest());
1334 if (sha1sum.equals(avatar.sha1sum)) {
1335 final File outputFile = getAvatarFile(avatar.getFilename());
1336 if (outputFile.getParentFile().mkdirs()) {
1337 Log.d(Config.LOGTAG, "created avatar directory");
1338 }
1339 final File avatarFile = getAvatarFile(avatar.getFilename());
1340 if (!file.renameTo(avatarFile)) {
1341 Log.d(
1342 Config.LOGTAG,
1343 "unable to rename " + file.getAbsolutePath() + " to " + outputFile);
1344 return false;
1345 }
1346 } else {
1347 Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
1348 if (!file.delete()) {
1349 Log.d(Config.LOGTAG, "unable to delete temporary file");
1350 }
1351 return false;
1352 }
1353 avatar.size = bytes.length;
1354 } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
1355 return false;
1356 } finally {
1357 close(os);
1358 }
1359 }
1360 return true;
1361 }
1362
1363 public void deleteHistoricAvatarPath() {
1364 delete(getHistoricAvatarPath());
1365 }
1366
1367 private void delete(final File file) {
1368 if (file.isDirectory()) {
1369 final File[] files = file.listFiles();
1370 if (files != null) {
1371 for (final File f : files) {
1372 delete(f);
1373 }
1374 }
1375 }
1376 if (file.delete()) {
1377 Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
1378 }
1379 }
1380
1381 private File getHistoricAvatarPath() {
1382 return new File(mXmppConnectionService.getFilesDir(), "/avatars/");
1383 }
1384
1385 private File getAvatarFile(String avatar) {
1386 return new File(mXmppConnectionService.getCacheDir(), "/avatars/" + avatar);
1387 }
1388
1389 public Uri getAvatarUri(String avatar) {
1390 return Uri.fromFile(getAvatarFile(avatar));
1391 }
1392
1393 public Bitmap cropCenterSquare(Uri image, int size) {
1394 if (image == null) {
1395 return null;
1396 }
1397 InputStream is = null;
1398 try {
1399 BitmapFactory.Options options = new BitmapFactory.Options();
1400 options.inSampleSize = calcSampleSize(image, size);
1401 is = mXmppConnectionService.getContentResolver().openInputStream(image);
1402 if (is == null) {
1403 return null;
1404 }
1405 Bitmap input = BitmapFactory.decodeStream(is, null, options);
1406 if (input == null) {
1407 return null;
1408 } else {
1409 input = rotate(input, getRotation(image));
1410 return cropCenterSquare(input, size);
1411 }
1412 } catch (FileNotFoundException | SecurityException e) {
1413 Log.d(Config.LOGTAG, "unable to open file " + image.toString(), e);
1414 return null;
1415 } finally {
1416 close(is);
1417 }
1418 }
1419
1420 public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
1421 if (image == null) {
1422 return null;
1423 }
1424 InputStream is = null;
1425 try {
1426 BitmapFactory.Options options = new BitmapFactory.Options();
1427 options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
1428 is = mXmppConnectionService.getContentResolver().openInputStream(image);
1429 if (is == null) {
1430 return null;
1431 }
1432 Bitmap source = BitmapFactory.decodeStream(is, null, options);
1433 if (source == null) {
1434 return null;
1435 }
1436 int sourceWidth = source.getWidth();
1437 int sourceHeight = source.getHeight();
1438 float xScale = (float) newWidth / sourceWidth;
1439 float yScale = (float) newHeight / sourceHeight;
1440 float scale = Math.max(xScale, yScale);
1441 float scaledWidth = scale * sourceWidth;
1442 float scaledHeight = scale * sourceHeight;
1443 float left = (newWidth - scaledWidth) / 2;
1444 float top = (newHeight - scaledHeight) / 2;
1445
1446 RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
1447 Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
1448 Canvas canvas = new Canvas(dest);
1449 canvas.drawBitmap(source, null, targetRect, createAntiAliasingPaint());
1450 if (source.isRecycled()) {
1451 source.recycle();
1452 }
1453 return dest;
1454 } catch (SecurityException e) {
1455 return null; // android 6.0 with revoked permissions for example
1456 } catch (FileNotFoundException e) {
1457 return null;
1458 } finally {
1459 close(is);
1460 }
1461 }
1462
1463 public Bitmap cropCenterSquare(Bitmap input, int size) {
1464 int w = input.getWidth();
1465 int h = input.getHeight();
1466
1467 float scale = Math.max((float) size / h, (float) size / w);
1468
1469 float outWidth = scale * w;
1470 float outHeight = scale * h;
1471 float left = (size - outWidth) / 2;
1472 float top = (size - outHeight) / 2;
1473 RectF target = new RectF(left, top, left + outWidth, top + outHeight);
1474
1475 Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1476 Canvas canvas = new Canvas(output);
1477 canvas.drawBitmap(input, null, target, createAntiAliasingPaint());
1478 if (!input.isRecycled()) {
1479 input.recycle();
1480 }
1481 return output;
1482 }
1483
1484 private int calcSampleSize(Uri image, int size)
1485 throws FileNotFoundException, SecurityException {
1486 final BitmapFactory.Options options = new BitmapFactory.Options();
1487 options.inJustDecodeBounds = true;
1488 final InputStream inputStream =
1489 mXmppConnectionService.getContentResolver().openInputStream(image);
1490 BitmapFactory.decodeStream(inputStream, null, options);
1491 close(inputStream);
1492 return calcSampleSize(options, size);
1493 }
1494
1495 public void updateFileParams(Message message) {
1496 updateFileParams(message, null);
1497 }
1498
1499 public void updateFileParams(Message message, String url) {
1500 DownloadableFile file = getFile(message);
1501 final String mime = file.getMimeType();
1502 final boolean privateMessage = message.isPrivateMessage();
1503 final boolean image =
1504 message.getType() == Message.TYPE_IMAGE
1505 || (mime != null && mime.startsWith("image/"));
1506 final boolean video = mime != null && mime.startsWith("video/");
1507 final boolean audio = mime != null && mime.startsWith("audio/");
1508 final boolean pdf = "application/pdf".equals(mime);
1509 final StringBuilder body = new StringBuilder();
1510 if (url != null) {
1511 body.append(url);
1512 }
1513 body.append('|').append(file.getSize());
1514 if (image || video || (pdf && Compatibility.runsTwentyOne())) {
1515 try {
1516 final Dimensions dimensions;
1517 if (video) {
1518 dimensions = getVideoDimensions(file);
1519 } else if (pdf && Compatibility.runsTwentyOne()) {
1520 dimensions = getPdfDocumentDimensions(file);
1521 } else {
1522 dimensions = getImageDimensions(file);
1523 }
1524 if (dimensions.valid()) {
1525 body.append('|').append(dimensions.width).append('|').append(dimensions.height);
1526 }
1527 } catch (NotAVideoFile notAVideoFile) {
1528 Log.d(
1529 Config.LOGTAG,
1530 "file with mime type " + file.getMimeType() + " was not a video file");
1531 // fall threw
1532 }
1533 } else if (audio) {
1534 body.append("|0|0|").append(getMediaRuntime(file));
1535 }
1536 message.setBody(body.toString());
1537 message.setDeleted(false);
1538 message.setType(
1539 privateMessage
1540 ? Message.TYPE_PRIVATE_FILE
1541 : (image ? Message.TYPE_IMAGE : Message.TYPE_FILE));
1542 }
1543
1544 private int getMediaRuntime(File file) {
1545 try {
1546 MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
1547 mediaMetadataRetriever.setDataSource(file.toString());
1548 return Integer.parseInt(
1549 mediaMetadataRetriever.extractMetadata(
1550 MediaMetadataRetriever.METADATA_KEY_DURATION));
1551 } catch (RuntimeException e) {
1552 return 0;
1553 }
1554 }
1555
1556 private Dimensions getImageDimensions(File file) {
1557 BitmapFactory.Options options = new BitmapFactory.Options();
1558 options.inJustDecodeBounds = true;
1559 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1560 int rotation = getRotation(file);
1561 boolean rotated = rotation == 90 || rotation == 270;
1562 int imageHeight = rotated ? options.outWidth : options.outHeight;
1563 int imageWidth = rotated ? options.outHeight : options.outWidth;
1564 return new Dimensions(imageHeight, imageWidth);
1565 }
1566
1567 private Dimensions getVideoDimensions(File file) throws NotAVideoFile {
1568 MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1569 try {
1570 metadataRetriever.setDataSource(file.getAbsolutePath());
1571 } catch (RuntimeException e) {
1572 throw new NotAVideoFile(e);
1573 }
1574 return getVideoDimensions(metadataRetriever);
1575 }
1576
1577 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1578 private Dimensions getPdfDocumentDimensions(final File file) {
1579 final ParcelFileDescriptor fileDescriptor;
1580 try {
1581 fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
1582 if (fileDescriptor == null) {
1583 return new Dimensions(0, 0);
1584 }
1585 } catch (FileNotFoundException e) {
1586 return new Dimensions(0, 0);
1587 }
1588 try {
1589 final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
1590 final PdfRenderer.Page page = pdfRenderer.openPage(0);
1591 final int height = page.getHeight();
1592 final int width = page.getWidth();
1593 page.close();
1594 pdfRenderer.close();
1595 return scalePdfDimensions(new Dimensions(height, width));
1596 } catch (IOException | SecurityException e) {
1597 Log.d(Config.LOGTAG, "unable to get dimensions for pdf document", e);
1598 return new Dimensions(0, 0);
1599 }
1600 }
1601
1602 private Dimensions scalePdfDimensions(Dimensions in) {
1603 final DisplayMetrics displayMetrics =
1604 mXmppConnectionService.getResources().getDisplayMetrics();
1605 final int target = (int) (displayMetrics.density * 288);
1606 return scalePdfDimensions(in, target, true);
1607 }
1608
1609 private static Dimensions scalePdfDimensions(
1610 final Dimensions in, final int target, final boolean fit) {
1611 final int w, h;
1612 if (fit == (in.width <= in.height)) {
1613 w = Math.max((int) (in.width / ((double) in.height / target)), 1);
1614 h = target;
1615 } else {
1616 w = target;
1617 h = Math.max((int) (in.height / ((double) in.width / target)), 1);
1618 }
1619 return new Dimensions(h, w);
1620 }
1621
1622 public Bitmap getAvatar(String avatar, int size) {
1623 if (avatar == null) {
1624 return null;
1625 }
1626 Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
1627 return bm;
1628 }
1629
1630 private static class Dimensions {
1631 public final int width;
1632 public final int height;
1633
1634 Dimensions(int height, int width) {
1635 this.width = width;
1636 this.height = height;
1637 }
1638
1639 public int getMin() {
1640 return Math.min(width, height);
1641 }
1642
1643 public boolean valid() {
1644 return width > 0 && height > 0;
1645 }
1646 }
1647
1648 private static class NotAVideoFile extends Exception {
1649 public NotAVideoFile(Throwable t) {
1650 super(t);
1651 }
1652
1653 public NotAVideoFile() {
1654 super();
1655 }
1656 }
1657
1658 public static class ImageCompressionException extends Exception {
1659
1660 ImageCompressionException(String message) {
1661 super(message);
1662 }
1663 }
1664
1665 public static class FileCopyException extends Exception {
1666 private final int resId;
1667
1668 private FileCopyException(@StringRes int resId) {
1669 this.resId = resId;
1670 }
1671
1672 public @StringRes int getResId() {
1673 return resId;
1674 }
1675 }
1676}