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