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