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