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, boolean cacheOnly) throws IOException {
950 final String uuid = message.getUuid();
951 final LruCache<String, Bitmap> cache = mXmppConnectionService.getBitmapCache();
952 Bitmap thumbnail = cache.get(uuid);
953 if ((thumbnail == null) && (!cacheOnly)) {
954 final Drawable drawable = getThumbnail(message, res, size, cacheOnly);
955 if (drawable != null) {
956 thumbnail = drawDrawable(drawable);
957 cache.put(uuid, thumbnail);
958 }
959 }
960 return thumbnail;
961 }
962
963 private Drawable getImagePreview(File file, Resources res, int size, final String mime) throws IOException {
964 if (android.os.Build.VERSION.SDK_INT >= 28) {
965 ImageDecoder.Source source = ImageDecoder.createSource(file);
966 return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
967 int w = info.getSize().getWidth();
968 int h = info.getSize().getHeight();
969 int scalledW;
970 int scalledH;
971 if (w <= h) {
972 scalledW = Math.max((int) (w / ((double) h / size)), 1);
973 scalledH = size;
974 } else {
975 scalledW = size;
976 scalledH = Math.max((int) (h / ((double) w / size)), 1);
977 }
978 decoder.setTargetSize(scalledW, scalledH);
979 });
980 } else {
981 BitmapFactory.Options options = new BitmapFactory.Options();
982 options.inSampleSize = calcSampleSize(file, size);
983 Bitmap bitmap = null;
984 try {
985 bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
986 } catch (OutOfMemoryError e) {
987 options.inSampleSize *= 2;
988 bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
989 }
990 bitmap = resize(bitmap, size);
991 bitmap = rotate(bitmap, getRotation(file));
992 if (mime.equals("image/gif")) {
993 Bitmap withGifOverlay = bitmap.copy(Bitmap.Config.ARGB_8888, true);
994 drawOverlay(withGifOverlay, paintOverlayBlack(withGifOverlay) ? R.drawable.play_gif_black : R.drawable.play_gif_white, 1.0f);
995 bitmap.recycle();
996 bitmap = withGifOverlay;
997 }
998 return new BitmapDrawable(res, bitmap);
999 }
1000 }
1001
1002 protected Bitmap drawDrawable(Drawable drawable) {
1003 Bitmap bitmap = null;
1004
1005 if (drawable instanceof BitmapDrawable) {
1006 bitmap = ((BitmapDrawable) drawable).getBitmap();
1007 if (bitmap != null) return bitmap;
1008 }
1009
1010 bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
1011 Canvas canvas = new Canvas(bitmap);
1012 drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
1013 drawable.draw(canvas);
1014 return bitmap;
1015 }
1016
1017 private void drawOverlay(Bitmap bitmap, int resource, float factor) {
1018 Bitmap overlay =
1019 BitmapFactory.decodeResource(mXmppConnectionService.getResources(), resource);
1020 Canvas canvas = new Canvas(bitmap);
1021 float targetSize = Math.min(canvas.getWidth(), canvas.getHeight()) * factor;
1022 Log.d(
1023 Config.LOGTAG,
1024 "target size overlay: "
1025 + targetSize
1026 + " overlay bitmap size was "
1027 + overlay.getHeight());
1028 float left = (canvas.getWidth() - targetSize) / 2.0f;
1029 float top = (canvas.getHeight() - targetSize) / 2.0f;
1030 RectF dst = new RectF(left, top, left + targetSize - 1, top + targetSize - 1);
1031 canvas.drawBitmap(overlay, null, dst, createAntiAliasingPaint());
1032 }
1033
1034 /** https://stackoverflow.com/a/3943023/210897 */
1035 private boolean paintOverlayBlack(final Bitmap bitmap) {
1036 final int h = bitmap.getHeight();
1037 final int w = bitmap.getWidth();
1038 int record = 0;
1039 for (int y = Math.round(h * IGNORE_PADDING); y < h - Math.round(h * IGNORE_PADDING); ++y) {
1040 for (int x = Math.round(w * IGNORE_PADDING);
1041 x < w - Math.round(w * IGNORE_PADDING);
1042 ++x) {
1043 int pixel = bitmap.getPixel(x, y);
1044 if ((Color.red(pixel) * 0.299
1045 + Color.green(pixel) * 0.587
1046 + Color.blue(pixel) * 0.114)
1047 > 186) {
1048 --record;
1049 } else {
1050 ++record;
1051 }
1052 }
1053 }
1054 return record < 0;
1055 }
1056
1057 private boolean paintOverlayBlackPdf(final Bitmap bitmap) {
1058 final int h = bitmap.getHeight();
1059 final int w = bitmap.getWidth();
1060 int white = 0;
1061 for (int y = 0; y < h; ++y) {
1062 for (int x = 0; x < w; ++x) {
1063 int pixel = bitmap.getPixel(x, y);
1064 if ((Color.red(pixel) * 0.299
1065 + Color.green(pixel) * 0.587
1066 + Color.blue(pixel) * 0.114)
1067 > 186) {
1068 white++;
1069 }
1070 }
1071 }
1072 return white > (h * w * 0.4f);
1073 }
1074
1075 private Bitmap cropCenterSquareVideo(Uri uri, int size) {
1076 MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1077 Bitmap frame;
1078 try {
1079 metadataRetriever.setDataSource(mXmppConnectionService, uri);
1080 frame = metadataRetriever.getFrameAtTime(0);
1081 metadataRetriever.release();
1082 return cropCenterSquare(frame, size);
1083 } catch (Exception e) {
1084 frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1085 frame.eraseColor(0xff000000);
1086 return frame;
1087 }
1088 }
1089
1090 private Bitmap getVideoPreview(final File file, final int size) {
1091 final MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1092 Bitmap frame;
1093 try {
1094 metadataRetriever.setDataSource(file.getAbsolutePath());
1095 frame = metadataRetriever.getFrameAtTime(0);
1096 metadataRetriever.release();
1097 frame = resize(frame, size);
1098 } catch (IOException | RuntimeException e) {
1099 frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1100 frame.eraseColor(0xff000000);
1101 }
1102 drawOverlay(
1103 frame,
1104 paintOverlayBlack(frame)
1105 ? R.drawable.play_video_black
1106 : R.drawable.play_video_white,
1107 0.75f);
1108 return frame;
1109 }
1110
1111 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1112 private Bitmap getPdfDocumentPreview(final File file, final int size) {
1113 try {
1114 final ParcelFileDescriptor fileDescriptor =
1115 ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
1116 final Bitmap rendered = renderPdfDocument(fileDescriptor, size, true);
1117 drawOverlay(
1118 rendered,
1119 paintOverlayBlackPdf(rendered)
1120 ? R.drawable.open_pdf_black
1121 : R.drawable.open_pdf_white,
1122 0.75f);
1123 return rendered;
1124 } catch (final IOException | SecurityException e) {
1125 Log.d(Config.LOGTAG, "unable to render PDF document preview", e);
1126 final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1127 placeholder.eraseColor(0xff000000);
1128 return placeholder;
1129 }
1130 }
1131
1132 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1133 private Bitmap cropCenterSquarePdf(final Uri uri, final int size) {
1134 try {
1135 ParcelFileDescriptor fileDescriptor =
1136 mXmppConnectionService.getContentResolver().openFileDescriptor(uri, "r");
1137 final Bitmap bitmap = renderPdfDocument(fileDescriptor, size, false);
1138 return cropCenterSquare(bitmap, size);
1139 } catch (Exception e) {
1140 final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1141 placeholder.eraseColor(0xff000000);
1142 return placeholder;
1143 }
1144 }
1145
1146 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1147 private Bitmap renderPdfDocument(
1148 ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
1149 final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
1150 final PdfRenderer.Page page = pdfRenderer.openPage(0);
1151 final Dimensions dimensions =
1152 scalePdfDimensions(
1153 new Dimensions(page.getHeight(), page.getWidth()), targetSize, fit);
1154 final Bitmap rendered =
1155 Bitmap.createBitmap(dimensions.width, dimensions.height, Bitmap.Config.ARGB_8888);
1156 rendered.eraseColor(0xffffffff);
1157 page.render(rendered, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
1158 page.close();
1159 pdfRenderer.close();
1160 fileDescriptor.close();
1161 return rendered;
1162 }
1163
1164 public Uri getTakePhotoUri() {
1165 final String filename =
1166 String.format("IMG_%s.%s", IMAGE_DATE_FORMAT.format(new Date()), "jpg");
1167 final File directory;
1168 if (Config.ONLY_INTERNAL_STORAGE) {
1169 directory = new File(mXmppConnectionService.getCacheDir(), "Camera");
1170 } else {
1171 directory =
1172 new File(
1173 Environment.getExternalStoragePublicDirectory(
1174 Environment.DIRECTORY_DCIM),
1175 "Camera");
1176 }
1177 final File file = new File(directory, filename);
1178 file.getParentFile().mkdirs();
1179 return getUriForFile(mXmppConnectionService, file);
1180 }
1181
1182 public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
1183
1184 final Avatar uncompressAvatar = getUncompressedAvatar(image);
1185 if (uncompressAvatar != null
1186 && uncompressAvatar.image.length() <= Config.AVATAR_CHAR_LIMIT) {
1187 return uncompressAvatar;
1188 }
1189 if (uncompressAvatar != null) {
1190 Log.d(
1191 Config.LOGTAG,
1192 "uncompressed avatar exceeded char limit by "
1193 + (uncompressAvatar.image.length() - Config.AVATAR_CHAR_LIMIT));
1194 }
1195
1196 Bitmap bm = cropCenterSquare(image, size);
1197 if (bm == null) {
1198 return null;
1199 }
1200 if (hasAlpha(bm)) {
1201 Log.d(Config.LOGTAG, "alpha in avatar detected; uploading as PNG");
1202 bm.recycle();
1203 bm = cropCenterSquare(image, 96);
1204 return getPepAvatar(bm, Bitmap.CompressFormat.PNG, 100);
1205 }
1206 return getPepAvatar(bm, format, 100);
1207 }
1208
1209 private Avatar getUncompressedAvatar(Uri uri) {
1210 Bitmap bitmap = null;
1211 try {
1212 bitmap =
1213 BitmapFactory.decodeStream(
1214 mXmppConnectionService.getContentResolver().openInputStream(uri));
1215 return getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100);
1216 } catch (Exception e) {
1217 return null;
1218 } finally {
1219 if (bitmap != null) {
1220 bitmap.recycle();
1221 }
1222 }
1223 }
1224
1225 private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
1226 try {
1227 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1228 Base64OutputStream mBase64OutputStream =
1229 new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1230 MessageDigest digest = MessageDigest.getInstance("SHA-1");
1231 DigestOutputStream mDigestOutputStream =
1232 new DigestOutputStream(mBase64OutputStream, digest);
1233 if (!bitmap.compress(format, quality, mDigestOutputStream)) {
1234 return null;
1235 }
1236 mDigestOutputStream.flush();
1237 mDigestOutputStream.close();
1238 long chars = mByteArrayOutputStream.size();
1239 if (format != Bitmap.CompressFormat.PNG
1240 && quality >= 50
1241 && chars >= Config.AVATAR_CHAR_LIMIT) {
1242 int q = quality - 2;
1243 Log.d(
1244 Config.LOGTAG,
1245 "avatar char length was " + chars + " reducing quality to " + q);
1246 return getPepAvatar(bitmap, format, q);
1247 }
1248 Log.d(Config.LOGTAG, "settled on char length " + chars + " with quality=" + quality);
1249 final Avatar avatar = new Avatar();
1250 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1251 avatar.image = new String(mByteArrayOutputStream.toByteArray());
1252 if (format.equals(Bitmap.CompressFormat.WEBP)) {
1253 avatar.type = "image/webp";
1254 } else if (format.equals(Bitmap.CompressFormat.JPEG)) {
1255 avatar.type = "image/jpeg";
1256 } else if (format.equals(Bitmap.CompressFormat.PNG)) {
1257 avatar.type = "image/png";
1258 }
1259 avatar.width = bitmap.getWidth();
1260 avatar.height = bitmap.getHeight();
1261 return avatar;
1262 } catch (OutOfMemoryError e) {
1263 Log.d(Config.LOGTAG, "unable to convert avatar to base64 due to low memory");
1264 return null;
1265 } catch (Exception e) {
1266 return null;
1267 }
1268 }
1269
1270 public Avatar getStoredPepAvatar(String hash) {
1271 if (hash == null) {
1272 return null;
1273 }
1274 Avatar avatar = new Avatar();
1275 final File file = getAvatarFile(hash);
1276 FileInputStream is = null;
1277 try {
1278 avatar.size = file.length();
1279 BitmapFactory.Options options = new BitmapFactory.Options();
1280 options.inJustDecodeBounds = true;
1281 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1282 is = new FileInputStream(file);
1283 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
1284 Base64OutputStream mBase64OutputStream =
1285 new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
1286 MessageDigest digest = MessageDigest.getInstance("SHA-1");
1287 DigestOutputStream os = new DigestOutputStream(mBase64OutputStream, digest);
1288 byte[] buffer = new byte[4096];
1289 int length;
1290 while ((length = is.read(buffer)) > 0) {
1291 os.write(buffer, 0, length);
1292 }
1293 os.flush();
1294 os.close();
1295 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
1296 avatar.image = new String(mByteArrayOutputStream.toByteArray());
1297 avatar.height = options.outHeight;
1298 avatar.width = options.outWidth;
1299 avatar.type = options.outMimeType;
1300 return avatar;
1301 } catch (NoSuchAlgorithmException | IOException e) {
1302 return null;
1303 } finally {
1304 close(is);
1305 }
1306 }
1307
1308 public boolean isAvatarCached(Avatar avatar) {
1309 final File file = getAvatarFile(avatar.getFilename());
1310 return file.exists();
1311 }
1312
1313 public boolean save(final Avatar avatar) {
1314 File file;
1315 if (isAvatarCached(avatar)) {
1316 file = getAvatarFile(avatar.getFilename());
1317 avatar.size = file.length();
1318 } else {
1319 file =
1320 new File(
1321 mXmppConnectionService.getCacheDir().getAbsolutePath()
1322 + "/"
1323 + UUID.randomUUID().toString());
1324 if (file.getParentFile().mkdirs()) {
1325 Log.d(Config.LOGTAG, "created cache directory");
1326 }
1327 OutputStream os = null;
1328 try {
1329 if (!file.createNewFile()) {
1330 Log.d(
1331 Config.LOGTAG,
1332 "unable to create temporary file " + file.getAbsolutePath());
1333 }
1334 os = new FileOutputStream(file);
1335 MessageDigest digest = MessageDigest.getInstance("SHA-1");
1336 digest.reset();
1337 DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
1338 final byte[] bytes = avatar.getImageAsBytes();
1339 mDigestOutputStream.write(bytes);
1340 mDigestOutputStream.flush();
1341 mDigestOutputStream.close();
1342 String sha1sum = CryptoHelper.bytesToHex(digest.digest());
1343 if (sha1sum.equals(avatar.sha1sum)) {
1344 final File outputFile = getAvatarFile(avatar.getFilename());
1345 if (outputFile.getParentFile().mkdirs()) {
1346 Log.d(Config.LOGTAG, "created avatar directory");
1347 }
1348 final File avatarFile = getAvatarFile(avatar.getFilename());
1349 if (!file.renameTo(avatarFile)) {
1350 Log.d(
1351 Config.LOGTAG,
1352 "unable to rename " + file.getAbsolutePath() + " to " + outputFile);
1353 return false;
1354 }
1355 } else {
1356 Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
1357 if (!file.delete()) {
1358 Log.d(Config.LOGTAG, "unable to delete temporary file");
1359 }
1360 return false;
1361 }
1362 avatar.size = bytes.length;
1363 } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
1364 return false;
1365 } finally {
1366 close(os);
1367 }
1368 }
1369 return true;
1370 }
1371
1372 public void deleteHistoricAvatarPath() {
1373 delete(getHistoricAvatarPath());
1374 }
1375
1376 private void delete(final File file) {
1377 if (file.isDirectory()) {
1378 final File[] files = file.listFiles();
1379 if (files != null) {
1380 for (final File f : files) {
1381 delete(f);
1382 }
1383 }
1384 }
1385 if (file.delete()) {
1386 Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
1387 }
1388 }
1389
1390 private File getHistoricAvatarPath() {
1391 return new File(mXmppConnectionService.getFilesDir(), "/avatars/");
1392 }
1393
1394 private File getAvatarFile(String avatar) {
1395 return new File(mXmppConnectionService.getCacheDir(), "/avatars/" + avatar);
1396 }
1397
1398 public Uri getAvatarUri(String avatar) {
1399 return Uri.fromFile(getAvatarFile(avatar));
1400 }
1401
1402 public Bitmap cropCenterSquare(Uri image, int size) {
1403 if (image == null) {
1404 return null;
1405 }
1406 InputStream is = null;
1407 try {
1408 BitmapFactory.Options options = new BitmapFactory.Options();
1409 options.inSampleSize = calcSampleSize(image, size);
1410 is = mXmppConnectionService.getContentResolver().openInputStream(image);
1411 if (is == null) {
1412 return null;
1413 }
1414 Bitmap input = BitmapFactory.decodeStream(is, null, options);
1415 if (input == null) {
1416 return null;
1417 } else {
1418 input = rotate(input, getRotation(image));
1419 return cropCenterSquare(input, size);
1420 }
1421 } catch (FileNotFoundException | SecurityException e) {
1422 Log.d(Config.LOGTAG, "unable to open file " + image.toString(), e);
1423 return null;
1424 } finally {
1425 close(is);
1426 }
1427 }
1428
1429 public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
1430 if (image == null) {
1431 return null;
1432 }
1433 InputStream is = null;
1434 try {
1435 BitmapFactory.Options options = new BitmapFactory.Options();
1436 options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
1437 is = mXmppConnectionService.getContentResolver().openInputStream(image);
1438 if (is == null) {
1439 return null;
1440 }
1441 Bitmap source = BitmapFactory.decodeStream(is, null, options);
1442 if (source == null) {
1443 return null;
1444 }
1445 int sourceWidth = source.getWidth();
1446 int sourceHeight = source.getHeight();
1447 float xScale = (float) newWidth / sourceWidth;
1448 float yScale = (float) newHeight / sourceHeight;
1449 float scale = Math.max(xScale, yScale);
1450 float scaledWidth = scale * sourceWidth;
1451 float scaledHeight = scale * sourceHeight;
1452 float left = (newWidth - scaledWidth) / 2;
1453 float top = (newHeight - scaledHeight) / 2;
1454
1455 RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
1456 Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
1457 Canvas canvas = new Canvas(dest);
1458 canvas.drawBitmap(source, null, targetRect, createAntiAliasingPaint());
1459 if (source.isRecycled()) {
1460 source.recycle();
1461 }
1462 return dest;
1463 } catch (SecurityException e) {
1464 return null; // android 6.0 with revoked permissions for example
1465 } catch (FileNotFoundException e) {
1466 return null;
1467 } finally {
1468 close(is);
1469 }
1470 }
1471
1472 public Bitmap cropCenterSquare(Bitmap input, int size) {
1473 int w = input.getWidth();
1474 int h = input.getHeight();
1475
1476 float scale = Math.max((float) size / h, (float) size / w);
1477
1478 float outWidth = scale * w;
1479 float outHeight = scale * h;
1480 float left = (size - outWidth) / 2;
1481 float top = (size - outHeight) / 2;
1482 RectF target = new RectF(left, top, left + outWidth, top + outHeight);
1483
1484 Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
1485 Canvas canvas = new Canvas(output);
1486 canvas.drawBitmap(input, null, target, createAntiAliasingPaint());
1487 if (!input.isRecycled()) {
1488 input.recycle();
1489 }
1490 return output;
1491 }
1492
1493 private int calcSampleSize(Uri image, int size)
1494 throws FileNotFoundException, SecurityException {
1495 final BitmapFactory.Options options = new BitmapFactory.Options();
1496 options.inJustDecodeBounds = true;
1497 final InputStream inputStream =
1498 mXmppConnectionService.getContentResolver().openInputStream(image);
1499 BitmapFactory.decodeStream(inputStream, null, options);
1500 close(inputStream);
1501 return calcSampleSize(options, size);
1502 }
1503
1504 public void updateFileParams(Message message) {
1505 updateFileParams(message, null);
1506 }
1507
1508 public void updateFileParams(Message message, String url) {
1509 DownloadableFile file = getFile(message);
1510 final String mime = file.getMimeType();
1511 final boolean privateMessage = message.isPrivateMessage();
1512 final boolean image =
1513 message.getType() == Message.TYPE_IMAGE
1514 || (mime != null && mime.startsWith("image/"));
1515 final boolean video = mime != null && mime.startsWith("video/");
1516 final boolean audio = mime != null && mime.startsWith("audio/");
1517 final boolean pdf = "application/pdf".equals(mime);
1518 Message.FileParams fileParams = new Message.FileParams();
1519 if (url != null) {
1520 fileParams.url = url;
1521 }
1522 fileParams.size = file.getSize();
1523 if (image || video || (pdf && Compatibility.runsTwentyOne())) {
1524 try {
1525 final Dimensions dimensions;
1526 if (video) {
1527 dimensions = getVideoDimensions(file);
1528 } else if (pdf && Compatibility.runsTwentyOne()) {
1529 dimensions = getPdfDocumentDimensions(file);
1530 } else {
1531 dimensions = getImageDimensions(file);
1532 }
1533 if (dimensions.valid()) {
1534 fileParams.width = dimensions.width;
1535 fileParams.height = dimensions.height;
1536 }
1537 } catch (NotAVideoFile notAVideoFile) {
1538 Log.d(
1539 Config.LOGTAG,
1540 "file with mime type " + file.getMimeType() + " was not a video file");
1541 // fall threw
1542 }
1543 } else if (audio) {
1544 fileParams.runtime = getMediaRuntime(file);
1545 }
1546 message.setFileParams(fileParams);
1547 message.setDeleted(false);
1548 message.setType(
1549 privateMessage
1550 ? Message.TYPE_PRIVATE_FILE
1551 : (image ? Message.TYPE_IMAGE : Message.TYPE_FILE));
1552 }
1553
1554 private int getMediaRuntime(File file) {
1555 try {
1556 MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
1557 mediaMetadataRetriever.setDataSource(file.toString());
1558 return Integer.parseInt(
1559 mediaMetadataRetriever.extractMetadata(
1560 MediaMetadataRetriever.METADATA_KEY_DURATION));
1561 } catch (RuntimeException e) {
1562 return 0;
1563 }
1564 }
1565
1566 private Dimensions getImageDimensions(File file) {
1567 BitmapFactory.Options options = new BitmapFactory.Options();
1568 options.inJustDecodeBounds = true;
1569 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
1570 int rotation = getRotation(file);
1571 boolean rotated = rotation == 90 || rotation == 270;
1572 int imageHeight = rotated ? options.outWidth : options.outHeight;
1573 int imageWidth = rotated ? options.outHeight : options.outWidth;
1574 return new Dimensions(imageHeight, imageWidth);
1575 }
1576
1577 private Dimensions getVideoDimensions(File file) throws NotAVideoFile {
1578 MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
1579 try {
1580 metadataRetriever.setDataSource(file.getAbsolutePath());
1581 } catch (RuntimeException e) {
1582 throw new NotAVideoFile(e);
1583 }
1584 return getVideoDimensions(metadataRetriever);
1585 }
1586
1587 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
1588 private Dimensions getPdfDocumentDimensions(final File file) {
1589 final ParcelFileDescriptor fileDescriptor;
1590 try {
1591 fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
1592 if (fileDescriptor == null) {
1593 return new Dimensions(0, 0);
1594 }
1595 } catch (FileNotFoundException e) {
1596 return new Dimensions(0, 0);
1597 }
1598 try {
1599 final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
1600 final PdfRenderer.Page page = pdfRenderer.openPage(0);
1601 final int height = page.getHeight();
1602 final int width = page.getWidth();
1603 page.close();
1604 pdfRenderer.close();
1605 return scalePdfDimensions(new Dimensions(height, width));
1606 } catch (IOException | SecurityException e) {
1607 Log.d(Config.LOGTAG, "unable to get dimensions for pdf document", e);
1608 return new Dimensions(0, 0);
1609 }
1610 }
1611
1612 private Dimensions scalePdfDimensions(Dimensions in) {
1613 final DisplayMetrics displayMetrics =
1614 mXmppConnectionService.getResources().getDisplayMetrics();
1615 final int target = (int) (displayMetrics.density * 288);
1616 return scalePdfDimensions(in, target, true);
1617 }
1618
1619 private static Dimensions scalePdfDimensions(
1620 final Dimensions in, final int target, final boolean fit) {
1621 final int w, h;
1622 if (fit == (in.width <= in.height)) {
1623 w = Math.max((int) (in.width / ((double) in.height / target)), 1);
1624 h = target;
1625 } else {
1626 w = target;
1627 h = Math.max((int) (in.height / ((double) in.width / target)), 1);
1628 }
1629 return new Dimensions(h, w);
1630 }
1631
1632 public Bitmap getAvatar(String avatar, int size) {
1633 if (avatar == null) {
1634 return null;
1635 }
1636 Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
1637 return bm;
1638 }
1639
1640 private static class Dimensions {
1641 public final int width;
1642 public final int height;
1643
1644 Dimensions(int height, int width) {
1645 this.width = width;
1646 this.height = height;
1647 }
1648
1649 public int getMin() {
1650 return Math.min(width, height);
1651 }
1652
1653 public boolean valid() {
1654 return width > 0 && height > 0;
1655 }
1656 }
1657
1658 private static class NotAVideoFile extends Exception {
1659 public NotAVideoFile(Throwable t) {
1660 super(t);
1661 }
1662
1663 public NotAVideoFile() {
1664 super();
1665 }
1666 }
1667
1668 public static class ImageCompressionException extends Exception {
1669
1670 ImageCompressionException(String message) {
1671 super(message);
1672 }
1673 }
1674
1675 public static class FileCopyException extends Exception {
1676 private final int resId;
1677
1678 private FileCopyException(@StringRes int resId) {
1679 this.resId = resId;
1680 }
1681
1682 public @StringRes int getResId() {
1683 return resId;
1684 }
1685 }
1686}