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