1package eu.siacs.conversations.persistance;
2
3import android.annotation.TargetApi;
4import android.content.ContentResolver;
5import android.content.Context;
6import android.content.Intent;
7import android.database.Cursor;
8import android.graphics.Bitmap;
9import android.graphics.BitmapFactory;
10import android.graphics.Canvas;
11import android.graphics.Color;
12import android.graphics.Matrix;
13import android.graphics.Paint;
14import android.graphics.RectF;
15import android.media.MediaMetadataRetriever;
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.support.v4.content.FileProvider;
23import android.system.Os;
24import android.system.StructStat;
25import android.util.Base64;
26import android.util.Base64OutputStream;
27import android.util.Log;
28import android.util.LruCache;
29import android.webkit.MimeTypeMap;
30
31import java.io.ByteArrayOutputStream;
32import java.io.Closeable;
33import java.io.File;
34import java.io.FileDescriptor;
35import java.io.FileInputStream;
36import java.io.FileNotFoundException;
37import java.io.FileOutputStream;
38import java.io.IOException;
39import java.io.InputStream;
40import java.io.OutputStream;
41import java.net.Socket;
42import java.net.URL;
43import java.security.DigestOutputStream;
44import java.security.MessageDigest;
45import java.security.NoSuchAlgorithmException;
46import java.text.SimpleDateFormat;
47import java.util.Date;
48import java.util.List;
49import java.util.Locale;
50
51import eu.siacs.conversations.Config;
52import eu.siacs.conversations.R;
53import eu.siacs.conversations.entities.DownloadableFile;
54import eu.siacs.conversations.entities.Message;
55import eu.siacs.conversations.services.XmppConnectionService;
56import eu.siacs.conversations.utils.CryptoHelper;
57import eu.siacs.conversations.utils.ExifHelper;
58import eu.siacs.conversations.utils.FileUtils;
59import eu.siacs.conversations.utils.FileWriterException;
60import eu.siacs.conversations.utils.MimeUtils;
61import eu.siacs.conversations.xmpp.pep.Avatar;
62
63public class FileBackend {
64
65 private static final Object THUMBNAIL_LOCK = new Object();
66
67 private static final SimpleDateFormat IMAGE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
68
69 public static final String FILE_PROVIDER = ".files";
70
71 private XmppConnectionService mXmppConnectionService;
72
73 public FileBackend(XmppConnectionService service) {
74 this.mXmppConnectionService = service;
75 }
76
77 private void createNoMedia() {
78 final File nomedia = new File(getConversationsDirectory("Files") + ".nomedia");
79 if (!nomedia.exists()) {
80 try {
81 nomedia.createNewFile();
82 } catch (Exception e) {
83 Log.d(Config.LOGTAG, "could not create nomedia file");
84 }
85 }
86 }
87
88 public void updateMediaScanner(File file) {
89 String path = file.getAbsolutePath();
90 if (!path.startsWith(getConversationsDirectory("Files"))) {
91 Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
92 intent.setData(Uri.fromFile(file));
93 mXmppConnectionService.sendBroadcast(intent);
94 } else {
95 createNoMedia();
96 }
97 }
98
99 public boolean deleteFile(Message message) {
100 File file = getFile(message);
101 if (file.delete()) {
102 updateMediaScanner(file);
103 return true;
104 } else {
105 return false;
106 }
107 }
108
109 public DownloadableFile getFile(Message message) {
110 return getFile(message, true);
111 }
112
113 public DownloadableFile getFile(Message message, boolean decrypted) {
114 final boolean encrypted = !decrypted
115 && (message.getEncryption() == Message.ENCRYPTION_PGP
116 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
117 final DownloadableFile file;
118 String path = message.getRelativeFilePath();
119 if (path == null) {
120 path = message.getUuid();
121 }
122 if (path.startsWith("/")) {
123 file = new DownloadableFile(path);
124 } else {
125 String mime = message.getMimeType();
126 if (mime != null && mime.startsWith("image/")) {
127 file = new DownloadableFile(getConversationsDirectory("Images") + path);
128 } else if (mime != null && mime.startsWith("video/")) {
129 file = new DownloadableFile(getConversationsDirectory("Videos") + path);
130 } else {
131 file = new DownloadableFile(getConversationsDirectory("Files") + path);
132 }
133 }
134 if (encrypted) {
135 return new DownloadableFile(getConversationsDirectory("Files") + file.getName() + ".pgp");
136 } else {
137 return file;
138 }
139 }
140
141 public static long getFileSize(Context context, Uri uri) {
142 try {
143 final Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
144 if (cursor != null && cursor.moveToFirst()) {
145 long size = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE));
146 cursor.close();
147 return size;
148 } else {
149 return -1;
150 }
151 } catch (Exception e) {
152 return -1;
153 }
154 }
155
156 public static boolean allFilesUnderSize(Context context, List<Uri> uris, long max) {
157 if (max <= 0) {
158 Log.d(Config.LOGTAG, "server did not report max file size for http upload");
159 return true; //exception to be compatible with HTTP Upload < v0.2
160 }
161 for (Uri uri : uris) {
162 String mime = context.getContentResolver().getType(uri);
163 if (mime != null && mime.startsWith("video/")) {
164 try {
165 Dimensions dimensions = FileBackend.getVideoDimensions(context, uri);
166 if (dimensions.getMin() > 720) {
167 Log.d(Config.LOGTAG, "do not consider video file with min width larger than 720 for size check");
168 continue;
169 }
170 } catch (NotAVideoFile notAVideoFile) {
171 //ignore and fall through
172 }
173 }
174 if (FileBackend.getFileSize(context, uri) > max) {
175 Log.d(Config.LOGTAG, "not all files are under " + max + " bytes. suggesting falling back to jingle");
176 return false;
177 }
178 }
179 return true;
180 }
181
182 public String getConversationsDirectory(final String type) {
183 if (Config.ONLY_INTERNAL_STORAGE) {
184 return mXmppConnectionService.getFilesDir().getAbsolutePath() + "/" + type + "/";
185 } else {
186 return Environment.getExternalStorageDirectory() + "/Conversations/Media/Conversations " + type + "/";
187 }
188 }
189
190 public static String getConversationsLogsDirectory() {
191 return Environment.getExternalStorageDirectory().getAbsolutePath() + "/Conversations/";
192 }
193
194 public Bitmap resize(Bitmap originalBitmap, int size) {
195 int w = originalBitmap.getWidth();
196 int h = originalBitmap.getHeight();
197 if (Math.max(w, h) > size) {
198 int scalledW;
199 int scalledH;
200 if (w <= h) {
201 scalledW = (int) (w / ((double) h / size));
202 scalledH = size;
203 } else {
204 scalledW = size;
205 scalledH = (int) (h / ((double) w / size));
206 }
207 Bitmap result = Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
208 if (originalBitmap != null && !originalBitmap.isRecycled()) {
209 originalBitmap.recycle();
210 }
211 return result;
212 } else {
213 return originalBitmap;
214 }
215 }
216
217 public static Bitmap rotate(Bitmap bitmap, int degree) {
218 if (degree == 0) {
219 return bitmap;
220 }
221 int w = bitmap.getWidth();
222 int h = bitmap.getHeight();
223 Matrix mtx = new Matrix();
224 mtx.postRotate(degree);
225 Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true);
226 if (bitmap != null && !bitmap.isRecycled()) {
227 bitmap.recycle();
228 }
229 return result;
230 }
231
232 public boolean useImageAsIs(Uri uri) {
233 String path = getOriginalPath(uri);
234 if (path == null) {
235 return false;
236 }
237 File file = new File(path);
238 long size = file.length();
239 if (size == 0 || size >= mXmppConnectionService.getResources().getInteger(R.integer.auto_accept_filesize)) {
240 return false;
241 }
242 BitmapFactory.Options options = new BitmapFactory.Options();
243 options.inJustDecodeBounds = true;
244 try {
245 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(uri), null, options);
246 if (options.outMimeType == null || options.outHeight <= 0 || options.outWidth <= 0) {
247 return false;
248 }
249 return (options.outWidth <= Config.IMAGE_SIZE && options.outHeight <= Config.IMAGE_SIZE && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
250 } catch (FileNotFoundException e) {
251 return false;
252 }
253 }
254
255 public String getOriginalPath(Uri uri) {
256 return FileUtils.getPath(mXmppConnectionService, uri);
257 }
258
259 public void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException {
260 Log.d(Config.LOGTAG, "copy file (" + uri.toString() + ") to private storage " + file.getAbsolutePath());
261 file.getParentFile().mkdirs();
262 OutputStream os = null;
263 InputStream is = null;
264 try {
265 file.createNewFile();
266 os = new FileOutputStream(file);
267 is = mXmppConnectionService.getContentResolver().openInputStream(uri);
268 byte[] buffer = new byte[1024];
269 int length;
270 while ((length = is.read(buffer)) > 0) {
271 try {
272 os.write(buffer, 0, length);
273 } catch (IOException e) {
274 throw new FileWriterException();
275 }
276 }
277 try {
278 os.flush();
279 } catch (IOException e) {
280 throw new FileWriterException();
281 }
282 } catch (FileNotFoundException e) {
283 throw new FileCopyException(R.string.error_file_not_found);
284 } catch (FileWriterException e) {
285 throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
286 } catch (IOException e) {
287 e.printStackTrace();
288 throw new FileCopyException(R.string.error_io_exception);
289 } finally {
290 close(os);
291 close(is);
292 }
293 }
294
295 public void copyFileToPrivateStorage(Message message, Uri uri) throws FileCopyException {
296 String mime = MimeUtils.guessMimeTypeFromUri(mXmppConnectionService, uri);
297 Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage (mime=" + mime + ")");
298 String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime);
299 if (extension == null) {
300 extension = getExtensionFromUri(uri);
301 }
302 message.setRelativeFilePath(message.getUuid() + "." + extension);
303 copyFileToPrivateStorage(mXmppConnectionService.getFileBackend().getFile(message), uri);
304 }
305
306 private String getExtensionFromUri(Uri uri) {
307 String[] projection = {MediaStore.MediaColumns.DATA};
308 String filename = null;
309 Cursor cursor = mXmppConnectionService.getContentResolver().query(uri, projection, null, null, null);
310 if (cursor != null) {
311 try {
312 if (cursor.moveToFirst()) {
313 filename = cursor.getString(0);
314 }
315 } catch (Exception e) {
316 filename = null;
317 } finally {
318 cursor.close();
319 }
320 }
321 int pos = filename == null ? -1 : filename.lastIndexOf('.');
322 return pos > 0 ? filename.substring(pos + 1) : null;
323 }
324
325 private void copyImageToPrivateStorage(File file, Uri image, int sampleSize) throws FileCopyException {
326 file.getParentFile().mkdirs();
327 InputStream is = null;
328 OutputStream os = null;
329 try {
330 if (!file.exists() && !file.createNewFile()) {
331 throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
332 }
333 is = mXmppConnectionService.getContentResolver().openInputStream(image);
334 if (is == null) {
335 throw new FileCopyException(R.string.error_not_an_image_file);
336 }
337 Bitmap originalBitmap;
338 BitmapFactory.Options options = new BitmapFactory.Options();
339 int inSampleSize = (int) Math.pow(2, sampleSize);
340 Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize);
341 options.inSampleSize = inSampleSize;
342 originalBitmap = BitmapFactory.decodeStream(is, null, options);
343 is.close();
344 if (originalBitmap == null) {
345 throw new FileCopyException(R.string.error_not_an_image_file);
346 }
347 Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
348 int rotation = getRotation(image);
349 scaledBitmap = rotate(scaledBitmap, rotation);
350 boolean targetSizeReached = false;
351 int quality = Config.IMAGE_QUALITY;
352 final int imageMaxSize = mXmppConnectionService.getResources().getInteger(R.integer.auto_accept_filesize);
353 while (!targetSizeReached) {
354 os = new FileOutputStream(file);
355 boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, quality, os);
356 if (!success) {
357 throw new FileCopyException(R.string.error_compressing_image);
358 }
359 os.flush();
360 targetSizeReached = file.length() <= imageMaxSize || quality <= 50;
361 quality -= 5;
362 }
363 scaledBitmap.recycle();
364 } catch (FileNotFoundException e) {
365 throw new FileCopyException(R.string.error_file_not_found);
366 } catch (IOException e) {
367 e.printStackTrace();
368 throw new FileCopyException(R.string.error_io_exception);
369 } catch (SecurityException e) {
370 throw new FileCopyException(R.string.error_security_exception_during_image_copy);
371 } catch (OutOfMemoryError e) {
372 ++sampleSize;
373 if (sampleSize <= 3) {
374 copyImageToPrivateStorage(file, image, sampleSize);
375 } else {
376 throw new FileCopyException(R.string.error_out_of_memory);
377 }
378 } finally {
379 close(os);
380 close(is);
381 }
382 }
383
384 public void copyImageToPrivateStorage(File file, Uri image) throws FileCopyException {
385 Log.d(Config.LOGTAG, "copy image (" + image.toString() + ") to private storage " + file.getAbsolutePath());
386 copyImageToPrivateStorage(file, image, 0);
387 }
388
389 public void copyImageToPrivateStorage(Message message, Uri image) throws FileCopyException {
390 switch (Config.IMAGE_FORMAT) {
391 case JPEG:
392 message.setRelativeFilePath(message.getUuid() + ".jpg");
393 break;
394 case PNG:
395 message.setRelativeFilePath(message.getUuid() + ".png");
396 break;
397 case WEBP:
398 message.setRelativeFilePath(message.getUuid() + ".webp");
399 break;
400 }
401 copyImageToPrivateStorage(getFile(message), image);
402 updateFileParams(message);
403 }
404
405 private int getRotation(File file) {
406 return getRotation(Uri.parse("file://" + file.getAbsolutePath()));
407 }
408
409 private int getRotation(Uri image) {
410 InputStream is = null;
411 try {
412 is = mXmppConnectionService.getContentResolver().openInputStream(image);
413 return ExifHelper.getOrientation(is);
414 } catch (FileNotFoundException e) {
415 return 0;
416 } finally {
417 close(is);
418 }
419 }
420
421 public Bitmap getThumbnail(Message message, int size, boolean cacheOnly) throws FileNotFoundException {
422 final String uuid = message.getUuid();
423 final LruCache<String, Bitmap> cache = mXmppConnectionService.getBitmapCache();
424 Bitmap thumbnail = cache.get(uuid);
425 if ((thumbnail == null) && (!cacheOnly)) {
426 synchronized (THUMBNAIL_LOCK) {
427 thumbnail = cache.get(uuid);
428 if (thumbnail != null) {
429 return thumbnail;
430 }
431 DownloadableFile file = getFile(message);
432 final String mime = file.getMimeType();
433 if (mime.startsWith("video/")) {
434 thumbnail = getVideoPreview(file, size);
435 } else {
436 Bitmap fullsize = getFullsizeImagePreview(file, size);
437 if (fullsize == null) {
438 throw new FileNotFoundException();
439 }
440 thumbnail = resize(fullsize, size);
441 thumbnail = rotate(thumbnail, getRotation(file));
442 if (mime.equals("image/gif")) {
443 Bitmap withGifOverlay = thumbnail.copy(Bitmap.Config.ARGB_8888, true);
444 drawOverlay(withGifOverlay, R.drawable.play_gif, 1.0f);
445 thumbnail.recycle();
446 thumbnail = withGifOverlay;
447 }
448 }
449 this.mXmppConnectionService.getBitmapCache().put(uuid, thumbnail);
450 }
451 }
452 return thumbnail;
453 }
454
455 private Bitmap getFullsizeImagePreview(File file, int size) {
456 BitmapFactory.Options options = new BitmapFactory.Options();
457 options.inSampleSize = calcSampleSize(file, size);
458 try {
459 return BitmapFactory.decodeFile(file.getAbsolutePath(), options);
460 } catch (OutOfMemoryError e) {
461 options.inSampleSize *= 2;
462 return BitmapFactory.decodeFile(file.getAbsolutePath(), options);
463 }
464 }
465
466 private void drawOverlay(Bitmap bitmap, int resource, float factor) {
467 Bitmap overlay = BitmapFactory.decodeResource(mXmppConnectionService.getResources(), resource);
468 Canvas canvas = new Canvas(bitmap);
469 float targetSize = Math.min(canvas.getWidth(), canvas.getHeight()) * factor;
470 Log.d(Config.LOGTAG, "target size overlay: " + targetSize + " overlay bitmap size was " + overlay.getHeight());
471 float left = (canvas.getWidth() - targetSize) / 2.0f;
472 float top = (canvas.getHeight() - targetSize) / 2.0f;
473 RectF dst = new RectF(left, top, left + targetSize - 1, top + targetSize - 1);
474 canvas.drawBitmap(overlay, null, dst, createAntiAliasingPaint());
475 }
476
477 private static Paint createAntiAliasingPaint() {
478 Paint paint = new Paint();
479 paint.setAntiAlias(true);
480 paint.setFilterBitmap(true);
481 paint.setDither(true);
482 return paint;
483 }
484
485 private Bitmap getVideoPreview(File file, int size) {
486 MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
487 Bitmap frame;
488 try {
489 metadataRetriever.setDataSource(file.getAbsolutePath());
490 frame = metadataRetriever.getFrameAtTime(0);
491 metadataRetriever.release();
492 frame = resize(frame, size);
493 } catch (RuntimeException e) {
494 frame = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
495 frame.eraseColor(0xff000000);
496 }
497 drawOverlay(frame, R.drawable.play_video, 0.75f);
498 return frame;
499 }
500
501 private static String getTakePhotoPath() {
502 return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) + "/Camera/";
503 }
504
505 public Uri getTakePhotoUri() {
506 File file;
507 if (Config.ONLY_INTERNAL_STORAGE) {
508 file = new File(mXmppConnectionService.getCacheDir().getAbsolutePath(), "Camera/IMG_" + this.IMAGE_DATE_FORMAT.format(new Date()) + ".jpg");
509 } else {
510 file = new File(getTakePhotoPath() + "IMG_" + this.IMAGE_DATE_FORMAT.format(new Date()) + ".jpg");
511 }
512 file.getParentFile().mkdirs();
513 return getUriForFile(mXmppConnectionService, file);
514 }
515
516 public static Uri getUriForFile(Context context, File file) {
517 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N || Config.ONLY_INTERNAL_STORAGE) {
518 try {
519 String packageId = context.getPackageName();
520 return FileProvider.getUriForFile(context, packageId + FILE_PROVIDER, file);
521 } catch (IllegalArgumentException e) {
522 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
523 throw new SecurityException(e);
524 } else {
525 return Uri.fromFile(file);
526 }
527 }
528 } else {
529 return Uri.fromFile(file);
530 }
531 }
532
533 public static Uri getIndexableTakePhotoUri(Uri original) {
534 if (Config.ONLY_INTERNAL_STORAGE || "file".equals(original.getScheme())) {
535 return original;
536 } else {
537 List<String> segments = original.getPathSegments();
538 return Uri.parse("file://" + getTakePhotoPath() + segments.get(segments.size() - 1));
539 }
540 }
541
542 public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
543 Bitmap bm = cropCenterSquare(image, size);
544 if (bm == null) {
545 return null;
546 }
547 if (hasAlpha(bm)) {
548 Log.d(Config.LOGTAG,"alpha in avatar detected; uploading as PNG");
549 bm.recycle();
550 bm = cropCenterSquare(image, 96);
551 return getPepAvatar(bm, Bitmap.CompressFormat.PNG, 100);
552 }
553 return getPepAvatar(bm, format, 100);
554 }
555
556 private static boolean hasAlpha(final Bitmap bitmap) {
557 for(int x = 0; x < bitmap.getWidth(); ++x) {
558 for(int y = 0; y < bitmap.getWidth(); ++y) {
559 if (Color.alpha(bitmap.getPixel(x,y)) < 255) {
560 return true;
561 }
562 }
563 }
564 return false;
565 }
566
567 private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
568 try {
569 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
570 Base64OutputStream mBase64OutputStream = new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
571 MessageDigest digest = MessageDigest.getInstance("SHA-1");
572 DigestOutputStream mDigestOutputStream = new DigestOutputStream(mBase64OutputStream, digest);
573 if (!bitmap.compress(format, quality, mDigestOutputStream)) {
574 return null;
575 }
576 mDigestOutputStream.flush();
577 mDigestOutputStream.close();
578 long chars = mByteArrayOutputStream.size();
579 if (format != Bitmap.CompressFormat.PNG && quality >= 50 && chars >= Config.AVATAR_CHAR_LIMIT) {
580 int q = quality - 2;
581 Log.d(Config.LOGTAG, "avatar char length was " + chars + " reducing quality to " + q);
582 return getPepAvatar(bitmap, format, q);
583 }
584 Log.d(Config.LOGTAG, "settled on char length " + chars + " with quality=" + quality);
585 final Avatar avatar = new Avatar();
586 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
587 avatar.image = new String(mByteArrayOutputStream.toByteArray());
588 if (format.equals(Bitmap.CompressFormat.WEBP)) {
589 avatar.type = "image/webp";
590 } else if (format.equals(Bitmap.CompressFormat.JPEG)) {
591 avatar.type = "image/jpeg";
592 } else if (format.equals(Bitmap.CompressFormat.PNG)) {
593 avatar.type = "image/png";
594 }
595 avatar.width = bitmap.getWidth();
596 avatar.height = bitmap.getHeight();
597 return avatar;
598 } catch (Exception e) {
599 return null;
600 }
601 }
602
603 public Avatar getStoredPepAvatar(String hash) {
604 if (hash == null) {
605 return null;
606 }
607 Avatar avatar = new Avatar();
608 File file = new File(getAvatarPath(hash));
609 FileInputStream is = null;
610 try {
611 avatar.size = file.length();
612 BitmapFactory.Options options = new BitmapFactory.Options();
613 options.inJustDecodeBounds = true;
614 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
615 is = new FileInputStream(file);
616 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
617 Base64OutputStream mBase64OutputStream = new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
618 MessageDigest digest = MessageDigest.getInstance("SHA-1");
619 DigestOutputStream os = new DigestOutputStream(mBase64OutputStream, digest);
620 byte[] buffer = new byte[4096];
621 int length;
622 while ((length = is.read(buffer)) > 0) {
623 os.write(buffer, 0, length);
624 }
625 os.flush();
626 os.close();
627 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
628 avatar.image = new String(mByteArrayOutputStream.toByteArray());
629 avatar.height = options.outHeight;
630 avatar.width = options.outWidth;
631 avatar.type = options.outMimeType;
632 return avatar;
633 } catch (IOException e) {
634 return null;
635 } catch (NoSuchAlgorithmException e) {
636 return null;
637 } finally {
638 close(is);
639 }
640 }
641
642 public boolean isAvatarCached(Avatar avatar) {
643 File file = new File(getAvatarPath(avatar.getFilename()));
644 return file.exists();
645 }
646
647 public boolean save(Avatar avatar) {
648 File file;
649 if (isAvatarCached(avatar)) {
650 file = new File(getAvatarPath(avatar.getFilename()));
651 avatar.size = file.length();
652 } else {
653 String filename = getAvatarPath(avatar.getFilename());
654 file = new File(filename + ".tmp");
655 file.getParentFile().mkdirs();
656 OutputStream os = null;
657 try {
658 file.createNewFile();
659 os = new FileOutputStream(file);
660 MessageDigest digest = MessageDigest.getInstance("SHA-1");
661 digest.reset();
662 DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
663 final byte[] bytes = avatar.getImageAsBytes();
664 mDigestOutputStream.write(bytes);
665 mDigestOutputStream.flush();
666 mDigestOutputStream.close();
667 String sha1sum = CryptoHelper.bytesToHex(digest.digest());
668 if (sha1sum.equals(avatar.sha1sum)) {
669 file.renameTo(new File(filename));
670 } else {
671 Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
672 file.delete();
673 return false;
674 }
675 avatar.size = bytes.length;
676 } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
677 return false;
678 } finally {
679 close(os);
680 }
681 }
682 return true;
683 }
684
685 public String getAvatarPath(String avatar) {
686 return mXmppConnectionService.getFilesDir().getAbsolutePath() + "/avatars/" + avatar;
687 }
688
689 public Uri getAvatarUri(String avatar) {
690 return Uri.parse("file:" + getAvatarPath(avatar));
691 }
692
693 public Bitmap cropCenterSquare(Uri image, int size) {
694 if (image == null) {
695 return null;
696 }
697 InputStream is = null;
698 try {
699 BitmapFactory.Options options = new BitmapFactory.Options();
700 options.inSampleSize = calcSampleSize(image, size);
701 is = mXmppConnectionService.getContentResolver().openInputStream(image);
702 if (is == null) {
703 return null;
704 }
705 Bitmap input = BitmapFactory.decodeStream(is, null, options);
706 if (input == null) {
707 return null;
708 } else {
709 input = rotate(input, getRotation(image));
710 return cropCenterSquare(input, size);
711 }
712 } catch (SecurityException e) {
713 return null; // happens for example on Android 6.0 if contacts permissions get revoked
714 } catch (FileNotFoundException e) {
715 return null;
716 } finally {
717 close(is);
718 }
719 }
720
721 public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
722 if (image == null) {
723 return null;
724 }
725 InputStream is = null;
726 try {
727 BitmapFactory.Options options = new BitmapFactory.Options();
728 options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
729 is = mXmppConnectionService.getContentResolver().openInputStream(image);
730 if (is == null) {
731 return null;
732 }
733 Bitmap source = BitmapFactory.decodeStream(is, null, options);
734 if (source == null) {
735 return null;
736 }
737 int sourceWidth = source.getWidth();
738 int sourceHeight = source.getHeight();
739 float xScale = (float) newWidth / sourceWidth;
740 float yScale = (float) newHeight / sourceHeight;
741 float scale = Math.max(xScale, yScale);
742 float scaledWidth = scale * sourceWidth;
743 float scaledHeight = scale * sourceHeight;
744 float left = (newWidth - scaledWidth) / 2;
745 float top = (newHeight - scaledHeight) / 2;
746
747 RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
748 Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
749 Canvas canvas = new Canvas(dest);
750 canvas.drawBitmap(source, null, targetRect, createAntiAliasingPaint());
751 if (source.isRecycled()) {
752 source.recycle();
753 }
754 return dest;
755 } catch (SecurityException e) {
756 return null; //android 6.0 with revoked permissions for example
757 } catch (FileNotFoundException e) {
758 return null;
759 } finally {
760 close(is);
761 }
762 }
763
764 public Bitmap cropCenterSquare(Bitmap input, int size) {
765 int w = input.getWidth();
766 int h = input.getHeight();
767
768 float scale = Math.max((float) size / h, (float) size / w);
769
770 float outWidth = scale * w;
771 float outHeight = scale * h;
772 float left = (size - outWidth) / 2;
773 float top = (size - outHeight) / 2;
774 RectF target = new RectF(left, top, left + outWidth, top + outHeight);
775
776 Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
777 Canvas canvas = new Canvas(output);
778 canvas.drawBitmap(input, null, target, createAntiAliasingPaint());
779 if (!input.isRecycled()) {
780 input.recycle();
781 }
782 return output;
783 }
784
785 private int calcSampleSize(Uri image, int size) throws FileNotFoundException, SecurityException {
786 BitmapFactory.Options options = new BitmapFactory.Options();
787 options.inJustDecodeBounds = true;
788 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(image), null, options);
789 return calcSampleSize(options, size);
790 }
791
792 private static int calcSampleSize(File image, int size) {
793 BitmapFactory.Options options = new BitmapFactory.Options();
794 options.inJustDecodeBounds = true;
795 BitmapFactory.decodeFile(image.getAbsolutePath(), options);
796 return calcSampleSize(options, size);
797 }
798
799 public static int calcSampleSize(BitmapFactory.Options options, int size) {
800 int height = options.outHeight;
801 int width = options.outWidth;
802 int inSampleSize = 1;
803
804 if (height > size || width > size) {
805 int halfHeight = height / 2;
806 int halfWidth = width / 2;
807
808 while ((halfHeight / inSampleSize) > size
809 && (halfWidth / inSampleSize) > size) {
810 inSampleSize *= 2;
811 }
812 }
813 return inSampleSize;
814 }
815
816 public void updateFileParams(Message message) {
817 updateFileParams(message, null);
818 }
819
820 public void updateFileParams(Message message, URL url) {
821 DownloadableFile file = getFile(message);
822 final String mime = file.getMimeType();
823 boolean image = message.getType() == Message.TYPE_IMAGE || (mime != null && mime.startsWith("image/"));
824 boolean video = mime != null && mime.startsWith("video/");
825 boolean audio = mime != null && mime.startsWith("audio/");
826 final StringBuilder body = new StringBuilder();
827 if (url != null) {
828 body.append(url.toString());
829 }
830 body.append('|').append(file.getSize());
831 if (image || video) {
832 try {
833 Dimensions dimensions = image ? getImageDimensions(file) : getVideoDimensions(file);
834 body.append('|').append(dimensions.width).append('|').append(dimensions.height);
835 } catch (NotAVideoFile notAVideoFile) {
836 Log.d(Config.LOGTAG, "file with mime type " + file.getMimeType() + " was not a video file");
837 //fall threw
838 }
839 } else if (audio) {
840 body.append("|0|0|").append(getMediaRuntime(file));
841 }
842 message.setBody(body.toString());
843 }
844
845 public int getMediaRuntime(Uri uri) {
846 try {
847 MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
848 mediaMetadataRetriever.setDataSource(mXmppConnectionService, uri);
849 return Integer.parseInt(mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
850 } catch (RuntimeException e) {
851 return 0;
852 }
853 }
854
855 private int getMediaRuntime(File file) {
856 try {
857 MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
858 mediaMetadataRetriever.setDataSource(file.toString());
859 return Integer.parseInt(mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
860 } catch (RuntimeException e) {
861 return 0;
862 }
863 }
864
865 private Dimensions getImageDimensions(File file) {
866 BitmapFactory.Options options = new BitmapFactory.Options();
867 options.inJustDecodeBounds = true;
868 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
869 int rotation = getRotation(file);
870 boolean rotated = rotation == 90 || rotation == 270;
871 int imageHeight = rotated ? options.outWidth : options.outHeight;
872 int imageWidth = rotated ? options.outHeight : options.outWidth;
873 return new Dimensions(imageHeight, imageWidth);
874 }
875
876 private Dimensions getVideoDimensions(File file) throws NotAVideoFile {
877 MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever();
878 try {
879 metadataRetriever.setDataSource(file.getAbsolutePath());
880 } catch (RuntimeException e) {
881 throw new NotAVideoFile(e);
882 }
883 return getVideoDimensions(metadataRetriever);
884 }
885
886 private static Dimensions getVideoDimensions(Context context, Uri uri) throws NotAVideoFile {
887 MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
888 try {
889 mediaMetadataRetriever.setDataSource(context, uri);
890 } catch (RuntimeException e) {
891 throw new NotAVideoFile(e);
892 }
893 return getVideoDimensions(mediaMetadataRetriever);
894 }
895
896 private static Dimensions getVideoDimensions(MediaMetadataRetriever metadataRetriever) throws NotAVideoFile {
897 String hasVideo = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO);
898 if (hasVideo == null) {
899 throw new NotAVideoFile();
900 }
901 int rotation = extractRotationFromMediaRetriever(metadataRetriever);
902 boolean rotated = rotation == 90 || rotation == 270;
903 int height;
904 try {
905 String h = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
906 height = Integer.parseInt(h);
907 } catch (Exception e) {
908 height = -1;
909 }
910 int width;
911 try {
912 String w = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
913 width = Integer.parseInt(w);
914 } catch (Exception e) {
915 width = -1;
916 }
917 metadataRetriever.release();
918 Log.d(Config.LOGTAG, "extracted video dims " + width + "x" + height);
919 return rotated ? new Dimensions(width, height) : new Dimensions(height, width);
920 }
921
922 private static int extractRotationFromMediaRetriever(MediaMetadataRetriever metadataRetriever) {
923 int rotation;
924 if (Build.VERSION.SDK_INT >= 17) {
925 String r = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
926 try {
927 rotation = Integer.parseInt(r);
928 } catch (Exception e) {
929 rotation = 0;
930 }
931 } else {
932 rotation = 0;
933 }
934 return rotation;
935 }
936
937 private static class Dimensions {
938 public final int width;
939 public final int height;
940
941 public Dimensions(int height, int width) {
942 this.width = width;
943 this.height = height;
944 }
945
946 public int getMin() {
947 return Math.min(width, height);
948 }
949 }
950
951 private static class NotAVideoFile extends Exception {
952 public NotAVideoFile(Throwable t) {
953 super(t);
954 }
955
956 public NotAVideoFile() {
957 super();
958 }
959 }
960
961 public class FileCopyException extends Exception {
962 private static final long serialVersionUID = -1010013599132881427L;
963 private int resId;
964
965 public FileCopyException(int resId) {
966 this.resId = resId;
967 }
968
969 public int getResId() {
970 return resId;
971 }
972 }
973
974 public Bitmap getAvatar(String avatar, int size) {
975 if (avatar == null) {
976 return null;
977 }
978 Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
979 if (bm == null) {
980 return null;
981 }
982 return bm;
983 }
984
985 public boolean isFileAvailable(Message message) {
986 return getFile(message).exists();
987 }
988
989 public static void close(Closeable stream) {
990 if (stream != null) {
991 try {
992 stream.close();
993 } catch (IOException e) {
994 }
995 }
996 }
997
998 public static void close(Socket socket) {
999 if (socket != null) {
1000 try {
1001 socket.close();
1002 } catch (IOException e) {
1003 }
1004 }
1005 }
1006
1007
1008 public static boolean weOwnFile(Context context, Uri uri) {
1009 if (uri == null || !ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
1010 return false;
1011 } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
1012 return fileIsInFilesDir(context, uri);
1013 } else {
1014 return weOwnFileLollipop(uri);
1015 }
1016 }
1017
1018
1019 /**
1020 * This is more than hacky but probably way better than doing nothing
1021 * Further 'optimizations' might contain to get the parents of CacheDir and NoBackupDir
1022 * and check against those as well
1023 */
1024 private static boolean fileIsInFilesDir(Context context, Uri uri) {
1025 try {
1026 final String haystack = context.getFilesDir().getParentFile().getCanonicalPath();
1027 final String needle = new File(uri.getPath()).getCanonicalPath();
1028 return needle.startsWith(haystack);
1029 } catch (IOException e) {
1030 return false;
1031 }
1032 }
1033
1034 @TargetApi(Build.VERSION_CODES.LOLLIPOP)
1035 private static boolean weOwnFileLollipop(Uri uri) {
1036 try {
1037 File file = new File(uri.getPath());
1038 FileDescriptor fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).getFileDescriptor();
1039 StructStat st = Os.fstat(fd);
1040 return st.st_uid == android.os.Process.myUid();
1041 } catch (FileNotFoundException e) {
1042 return false;
1043 } catch (Exception e) {
1044 return true;
1045 }
1046 }
1047}