1package eu.siacs.conversations.persistance;
2
3import android.graphics.Bitmap;
4import android.graphics.BitmapFactory;
5import android.graphics.Canvas;
6import android.graphics.Matrix;
7import android.graphics.RectF;
8import android.net.Uri;
9import android.os.Environment;
10import android.util.Base64;
11import android.util.Base64OutputStream;
12import android.util.Log;
13import android.webkit.MimeTypeMap;
14
15import java.io.ByteArrayOutputStream;
16import java.io.Closeable;
17import java.io.File;
18import java.io.FileNotFoundException;
19import java.io.FileOutputStream;
20import java.io.IOException;
21import java.io.InputStream;
22import java.io.OutputStream;
23import java.net.Socket;
24import java.net.URL;
25import java.security.DigestOutputStream;
26import java.security.MessageDigest;
27import java.security.NoSuchAlgorithmException;
28import java.text.SimpleDateFormat;
29import java.util.Arrays;
30import java.util.Date;
31import java.util.Locale;
32
33import eu.siacs.conversations.Config;
34import eu.siacs.conversations.R;
35import eu.siacs.conversations.entities.DownloadableFile;
36import eu.siacs.conversations.entities.Message;
37import eu.siacs.conversations.entities.Transferable;
38import eu.siacs.conversations.services.XmppConnectionService;
39import eu.siacs.conversations.utils.CryptoHelper;
40import eu.siacs.conversations.utils.ExifHelper;
41import eu.siacs.conversations.utils.FileUtils;
42import eu.siacs.conversations.xmpp.pep.Avatar;
43
44public class FileBackend {
45 private final SimpleDateFormat imageDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
46
47 private XmppConnectionService mXmppConnectionService;
48
49 public FileBackend(XmppConnectionService service) {
50 this.mXmppConnectionService = service;
51 }
52
53 public DownloadableFile getFile(Message message) {
54 return getFile(message, true);
55 }
56
57 public DownloadableFile getFile(Message message, boolean decrypted) {
58 final boolean encrypted = !decrypted
59 && (message.getEncryption() == Message.ENCRYPTION_PGP
60 || message.getEncryption() == Message.ENCRYPTION_DECRYPTED);
61 final DownloadableFile file;
62 String path = message.getRelativeFilePath();
63 if (path == null) {
64 path = message.getUuid();
65 }
66 if (path.startsWith("/")) {
67 file = new DownloadableFile(path);
68 } else {
69 String mime = message.getMimeType();
70 if (mime != null && mime.startsWith("image")) {
71 file = new DownloadableFile(getConversationsImageDirectory() + path);
72 } else {
73 file = new DownloadableFile(getConversationsFileDirectory() + path);
74 }
75 }
76 if (encrypted) {
77 return new DownloadableFile(getConversationsFileDirectory() + file.getName() + ".pgp");
78 } else {
79 return file;
80 }
81 }
82
83 public static String getConversationsFileDirectory() {
84 return Environment.getExternalStorageDirectory().getAbsolutePath()+"/Conversations/";
85 }
86
87 public static String getConversationsImageDirectory() {
88 return Environment.getExternalStoragePublicDirectory(
89 Environment.DIRECTORY_PICTURES).getAbsolutePath()
90 + "/Conversations/";
91 }
92
93 public Bitmap resize(Bitmap originalBitmap, int size) {
94 int w = originalBitmap.getWidth();
95 int h = originalBitmap.getHeight();
96 if (Math.max(w, h) > size) {
97 int scalledW;
98 int scalledH;
99 if (w <= h) {
100 scalledW = (int) (w / ((double) h / size));
101 scalledH = size;
102 } else {
103 scalledW = size;
104 scalledH = (int) (h / ((double) w / size));
105 }
106 Bitmap result = Bitmap.createScaledBitmap(originalBitmap, scalledW, scalledH, true);
107 if (originalBitmap != null && !originalBitmap.isRecycled()) {
108 originalBitmap.recycle();
109 }
110 return result;
111 } else {
112 return originalBitmap;
113 }
114 }
115
116 public static Bitmap rotate(Bitmap bitmap, int degree) {
117 if (degree == 0) {
118 return bitmap;
119 }
120 int w = bitmap.getWidth();
121 int h = bitmap.getHeight();
122 Matrix mtx = new Matrix();
123 mtx.postRotate(degree);
124 Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true);
125 if (bitmap != null && !bitmap.isRecycled()) {
126 bitmap.recycle();
127 }
128 return result;
129 }
130
131 public boolean useImageAsIs(Uri uri) {
132 String path = getOriginalPath(uri);
133 if (path == null) {
134 return false;
135 }
136 File file = new File(path);
137 long size = file.length();
138 if (size == 0 || size >= 524288 ) {
139 return false;
140 }
141 BitmapFactory.Options options = new BitmapFactory.Options();
142 options.inJustDecodeBounds = true;
143 try {
144 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(uri), null, options);
145 if (options == null || options.outMimeType == null || options.outHeight <= 0 || options.outWidth <= 0) {
146 return false;
147 }
148 return (options.outWidth <= Config.IMAGE_SIZE && options.outHeight <= Config.IMAGE_SIZE && options.outMimeType.contains(Config.IMAGE_FORMAT.name().toLowerCase()));
149 } catch (FileNotFoundException e) {
150 return false;
151 }
152 }
153
154 public String getOriginalPath(Uri uri) {
155 Log.d(Config.LOGTAG,"get original path for uri: "+uri.toString());
156 return FileUtils.getPath(mXmppConnectionService,uri);
157 }
158
159 public DownloadableFile copyFileToPrivateStorage(Message message, Uri uri) throws FileCopyException {
160 Log.d(Config.LOGTAG, "copy " + uri.toString() + " to private storage");
161 String mime = mXmppConnectionService.getContentResolver().getType(uri);
162 String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime);
163 message.setRelativeFilePath(message.getUuid() + "." + extension);
164 DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message);
165 file.getParentFile().mkdirs();
166 OutputStream os = null;
167 InputStream is = null;
168 try {
169 file.createNewFile();
170 os = new FileOutputStream(file);
171 is = mXmppConnectionService.getContentResolver().openInputStream(uri);
172 byte[] buffer = new byte[1024];
173 int length;
174 while ((length = is.read(buffer)) > 0) {
175 os.write(buffer, 0, length);
176 }
177 os.flush();
178 } catch(FileNotFoundException e) {
179 throw new FileCopyException(R.string.error_file_not_found);
180 } catch (IOException e) {
181 e.printStackTrace();
182 throw new FileCopyException(R.string.error_io_exception);
183 } finally {
184 close(os);
185 close(is);
186 }
187 Log.d(Config.LOGTAG, "output file name " + mXmppConnectionService.getFileBackend().getFile(message));
188 return file;
189 }
190
191 public DownloadableFile copyImageToPrivateStorage(Message message, Uri image)
192 throws FileCopyException {
193 return this.copyImageToPrivateStorage(message, image, 0);
194 }
195
196 private DownloadableFile copyImageToPrivateStorage(Message message,Uri image, int sampleSize) throws FileCopyException {
197 switch(Config.IMAGE_FORMAT) {
198 case JPEG:
199 message.setRelativeFilePath(message.getUuid()+".jpg");
200 break;
201 case PNG:
202 message.setRelativeFilePath(message.getUuid()+".png");
203 break;
204 case WEBP:
205 message.setRelativeFilePath(message.getUuid()+".webp");
206 break;
207 }
208 DownloadableFile file = getFile(message);
209 file.getParentFile().mkdirs();
210 InputStream is = null;
211 OutputStream os = null;
212 try {
213 file.createNewFile();
214 is = mXmppConnectionService.getContentResolver().openInputStream(image);
215 os = new FileOutputStream(file);
216
217 Bitmap originalBitmap;
218 BitmapFactory.Options options = new BitmapFactory.Options();
219 int inSampleSize = (int) Math.pow(2, sampleSize);
220 Log.d(Config.LOGTAG, "reading bitmap with sample size " + inSampleSize);
221 options.inSampleSize = inSampleSize;
222 originalBitmap = BitmapFactory.decodeStream(is, null, options);
223 is.close();
224 if (originalBitmap == null) {
225 throw new FileCopyException(R.string.error_not_an_image_file);
226 }
227 Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
228 int rotation = getRotation(image);
229 scaledBitmap = rotate(scaledBitmap, rotation);
230 boolean success = scaledBitmap.compress(Config.IMAGE_FORMAT, Config.IMAGE_QUALITY, os);
231 if (!success) {
232 throw new FileCopyException(R.string.error_compressing_image);
233 }
234 os.flush();
235 long size = file.getSize();
236 int width = scaledBitmap.getWidth();
237 int height = scaledBitmap.getHeight();
238 message.setBody(Long.toString(size) + '|' + width + '|' + height);
239 return file;
240 } catch (FileNotFoundException e) {
241 throw new FileCopyException(R.string.error_file_not_found);
242 } catch (IOException e) {
243 e.printStackTrace();
244 throw new FileCopyException(R.string.error_io_exception);
245 } catch (SecurityException e) {
246 throw new FileCopyException(R.string.error_security_exception_during_image_copy);
247 } catch (OutOfMemoryError e) {
248 ++sampleSize;
249 if (sampleSize <= 3) {
250 return copyImageToPrivateStorage(message, image, sampleSize);
251 } else {
252 throw new FileCopyException(R.string.error_out_of_memory);
253 }
254 } catch (NullPointerException e) {
255 throw new FileCopyException(R.string.error_io_exception);
256 } finally {
257 close(os);
258 close(is);
259 }
260 }
261
262 private int getRotation(File file) {
263 return getRotation(Uri.parse("file://"+file.getAbsolutePath()));
264 }
265
266 private int getRotation(Uri image) {
267 InputStream is = null;
268 try {
269 is = mXmppConnectionService.getContentResolver().openInputStream(image);
270 return ExifHelper.getOrientation(is);
271 } catch (FileNotFoundException e) {
272 return 0;
273 } finally {
274 close(is);
275 }
276 }
277
278 public Bitmap getThumbnail(Message message, int size, boolean cacheOnly)
279 throws FileNotFoundException {
280 Bitmap thumbnail = mXmppConnectionService.getBitmapCache().get(message.getUuid());
281 if ((thumbnail == null) && (!cacheOnly)) {
282 File file = getFile(message);
283 BitmapFactory.Options options = new BitmapFactory.Options();
284 options.inSampleSize = calcSampleSize(file, size);
285 Bitmap fullsize = BitmapFactory.decodeFile(file.getAbsolutePath(),options);
286 if (fullsize == null) {
287 throw new FileNotFoundException();
288 }
289 thumbnail = resize(fullsize, size);
290 thumbnail = rotate(thumbnail, getRotation(file));
291 this.mXmppConnectionService.getBitmapCache().put(message.getUuid(),thumbnail);
292 }
293 return thumbnail;
294 }
295
296 public Uri getTakePhotoUri() {
297 StringBuilder pathBuilder = new StringBuilder();
298 pathBuilder.append(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM));
299 pathBuilder.append('/');
300 pathBuilder.append("Camera");
301 pathBuilder.append('/');
302 pathBuilder.append("IMG_" + this.imageDateFormat.format(new Date()) + ".jpg");
303 Uri uri = Uri.parse("file://" + pathBuilder.toString());
304 File file = new File(uri.toString());
305 file.getParentFile().mkdirs();
306 return uri;
307 }
308
309 public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
310 try {
311 Avatar avatar = new Avatar();
312 Bitmap bm = cropCenterSquare(image, size);
313 if (bm == null) {
314 return null;
315 }
316 ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
317 Base64OutputStream mBase64OutputSttream = new Base64OutputStream(
318 mByteArrayOutputStream, Base64.DEFAULT);
319 MessageDigest digest = MessageDigest.getInstance("SHA-1");
320 DigestOutputStream mDigestOutputStream = new DigestOutputStream(
321 mBase64OutputSttream, digest);
322 if (!bm.compress(format, 75, mDigestOutputStream)) {
323 return null;
324 }
325 mDigestOutputStream.flush();
326 mDigestOutputStream.close();
327 avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
328 avatar.image = new String(mByteArrayOutputStream.toByteArray());
329 return avatar;
330 } catch (NoSuchAlgorithmException e) {
331 return null;
332 } catch (IOException e) {
333 return null;
334 }
335 }
336
337 public boolean isAvatarCached(Avatar avatar) {
338 File file = new File(getAvatarPath(avatar.getFilename()));
339 return file.exists();
340 }
341
342 public boolean save(Avatar avatar) {
343 File file;
344 if (isAvatarCached(avatar)) {
345 file = new File(getAvatarPath(avatar.getFilename()));
346 } else {
347 String filename = getAvatarPath(avatar.getFilename());
348 file = new File(filename + ".tmp");
349 file.getParentFile().mkdirs();
350 OutputStream os = null;
351 try {
352 file.createNewFile();
353 os = new FileOutputStream(file);
354 MessageDigest digest = MessageDigest.getInstance("SHA-1");
355 digest.reset();
356 DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
357 mDigestOutputStream.write(avatar.getImageAsBytes());
358 mDigestOutputStream.flush();
359 mDigestOutputStream.close();
360 String sha1sum = CryptoHelper.bytesToHex(digest.digest());
361 if (sha1sum.equals(avatar.sha1sum)) {
362 file.renameTo(new File(filename));
363 } else {
364 Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
365 file.delete();
366 return false;
367 }
368 } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
369 return false;
370 } finally {
371 close(os);
372 }
373 }
374 avatar.size = file.length();
375 return true;
376 }
377
378 public String getAvatarPath(String avatar) {
379 return mXmppConnectionService.getFilesDir().getAbsolutePath()+ "/avatars/" + avatar;
380 }
381
382 public Uri getAvatarUri(String avatar) {
383 return Uri.parse("file:" + getAvatarPath(avatar));
384 }
385
386 public Bitmap cropCenterSquare(Uri image, int size) {
387 if (image == null) {
388 return null;
389 }
390 InputStream is = null;
391 try {
392 BitmapFactory.Options options = new BitmapFactory.Options();
393 options.inSampleSize = calcSampleSize(image, size);
394 is = mXmppConnectionService.getContentResolver().openInputStream(image);
395 if (is == null) {
396 return null;
397 }
398 Bitmap input = BitmapFactory.decodeStream(is, null, options);
399 if (input == null) {
400 return null;
401 } else {
402 input = rotate(input, getRotation(image));
403 return cropCenterSquare(input, size);
404 }
405 } catch (SecurityException e) {
406 return null; // happens for example on Android 6.0 if contacts permissions get revoked
407 } catch (FileNotFoundException e) {
408 return null;
409 } finally {
410 close(is);
411 }
412 }
413
414 public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
415 if (image == null) {
416 return null;
417 }
418 InputStream is = null;
419 try {
420 BitmapFactory.Options options = new BitmapFactory.Options();
421 options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
422 is = mXmppConnectionService.getContentResolver().openInputStream(image);
423 if (is == null) {
424 return null;
425 }
426 Bitmap source = BitmapFactory.decodeStream(is, null, options);
427 if (source == null) {
428 return null;
429 }
430 int sourceWidth = source.getWidth();
431 int sourceHeight = source.getHeight();
432 float xScale = (float) newWidth / sourceWidth;
433 float yScale = (float) newHeight / sourceHeight;
434 float scale = Math.max(xScale, yScale);
435 float scaledWidth = scale * sourceWidth;
436 float scaledHeight = scale * sourceHeight;
437 float left = (newWidth - scaledWidth) / 2;
438 float top = (newHeight - scaledHeight) / 2;
439
440 RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight);
441 Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
442 Canvas canvas = new Canvas(dest);
443 canvas.drawBitmap(source, null, targetRect, null);
444 if (source != null && !source.isRecycled()) {
445 source.recycle();
446 }
447 return dest;
448 } catch (SecurityException e) {
449 return null; //android 6.0 with revoked permissions for example
450 } catch (FileNotFoundException e) {
451 return null;
452 } finally {
453 close(is);
454 }
455 }
456
457 public Bitmap cropCenterSquare(Bitmap input, int size) {
458 int w = input.getWidth();
459 int h = input.getHeight();
460
461 float scale = Math.max((float) size / h, (float) size / w);
462
463 float outWidth = scale * w;
464 float outHeight = scale * h;
465 float left = (size - outWidth) / 2;
466 float top = (size - outHeight) / 2;
467 RectF target = new RectF(left, top, left + outWidth, top + outHeight);
468
469 Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
470 Canvas canvas = new Canvas(output);
471 canvas.drawBitmap(input, null, target, null);
472 if (input != null && !input.isRecycled()) {
473 input.recycle();
474 }
475 return output;
476 }
477
478 private int calcSampleSize(Uri image, int size) throws FileNotFoundException, SecurityException {
479 BitmapFactory.Options options = new BitmapFactory.Options();
480 options.inJustDecodeBounds = true;
481 BitmapFactory.decodeStream(mXmppConnectionService.getContentResolver().openInputStream(image), null, options);
482 return calcSampleSize(options, size);
483 }
484
485 private static int calcSampleSize(File image, int size) {
486 BitmapFactory.Options options = new BitmapFactory.Options();
487 options.inJustDecodeBounds = true;
488 BitmapFactory.decodeFile(image.getAbsolutePath(), options);
489 return calcSampleSize(options, size);
490 }
491
492 public static int calcSampleSize(BitmapFactory.Options options, int size) {
493 int height = options.outHeight;
494 int width = options.outWidth;
495 int inSampleSize = 1;
496
497 if (height > size || width > size) {
498 int halfHeight = height / 2;
499 int halfWidth = width / 2;
500
501 while ((halfHeight / inSampleSize) > size
502 && (halfWidth / inSampleSize) > size) {
503 inSampleSize *= 2;
504 }
505 }
506 return inSampleSize;
507 }
508
509 public Uri getJingleFileUri(Message message) {
510 File file = getFile(message);
511 return Uri.parse("file://" + file.getAbsolutePath());
512 }
513
514 public void updateFileParams(Message message) {
515 updateFileParams(message,null);
516 }
517
518 public void updateFileParams(Message message, URL url) {
519 DownloadableFile file = getFile(message);
520 if (message.getType() == Message.TYPE_IMAGE || file.getMimeType().startsWith("image/")) {
521 BitmapFactory.Options options = new BitmapFactory.Options();
522 options.inJustDecodeBounds = true;
523 BitmapFactory.decodeFile(file.getAbsolutePath(), options);
524 int rotation = getRotation(file);
525 boolean rotated = rotation == 90 || rotation == 270;
526 int imageHeight = rotated ? options.outWidth : options.outHeight;
527 int imageWidth = rotated ? options.outHeight : options.outWidth;
528 if (url == null) {
529 message.setBody(Long.toString(file.getSize()) + '|' + imageWidth + '|' + imageHeight);
530 } else {
531 message.setBody(url.toString()+"|"+Long.toString(file.getSize()) + '|' + imageWidth + '|' + imageHeight);
532 }
533 } else {
534 if (url != null) {
535 message.setBody(url.toString()+"|"+Long.toString(file.getSize()));
536 } else {
537 message.setBody(Long.toString(file.getSize()));
538 }
539 }
540
541 }
542
543 public class FileCopyException extends Exception {
544 private static final long serialVersionUID = -1010013599132881427L;
545 private int resId;
546
547 public FileCopyException(int resId) {
548 this.resId = resId;
549 }
550
551 public int getResId() {
552 return resId;
553 }
554 }
555
556 public Bitmap getAvatar(String avatar, int size) {
557 if (avatar == null) {
558 return null;
559 }
560 Bitmap bm = cropCenter(getAvatarUri(avatar), size, size);
561 if (bm == null) {
562 return null;
563 }
564 return bm;
565 }
566
567 public boolean isFileAvailable(Message message) {
568 return getFile(message).exists();
569 }
570
571 public static void close(Closeable stream) {
572 if (stream != null) {
573 try {
574 stream.close();
575 } catch (IOException e) {
576 }
577 }
578 }
579
580 public static void close(Socket socket) {
581 if (socket != null) {
582 try {
583 socket.close();
584 } catch (IOException e) {
585 }
586 }
587 }
588}