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