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