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