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