diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java index c852e5adb..055892408 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -1,10 +1,15 @@ package deckers.thibault.aves.channelhandlers; import android.annotation.SuppressLint; +import android.annotation.TargetApi; import android.app.Activity; +import android.content.ContentResolver; import android.graphics.Bitmap; import android.os.AsyncTask; +import android.os.Build; +import android.provider.MediaStore; import android.util.Log; +import android.util.Size; import com.bumptech.glide.Glide; import com.bumptech.glide.load.Key; @@ -59,52 +64,100 @@ public class ImageDecodeTask extends AsyncTask target; - if (entry.isVideo()) { - options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE); - target = Glide.with(activity) - .asBitmap() - .apply(options) - .load(new VideoThumbnail(activity, entry.getUri())) - .signature(signature) - .submit(p.width, p.height); - } else { - target = Glide.with(activity) - .asBitmap() - .apply(options) - .load(entry.getUri()) - .signature(signature) - .submit(p.width, p.height); - } - - try { - Bitmap bmp = target.get(); - if (bmp != null) { - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - bmp.compress(Bitmap.CompressFormat.JPEG, 90, stream); - data = stream.toByteArray(); - } - } catch (InterruptedException e) { - Log.d(LOG_TAG, "getImageBytes with uri=" + entry.getUri() + " interrupted"); - } catch (Exception e) { - e.printStackTrace(); - } - Glide.with(activity).clear(target); +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { +// bitmap = getBytesByResolverThumbnail(p); +// } else { + bitmap = getBytesByMediaStoreThumbnail(p); +// bitmap = getBytesByGlide(p); +// } } else { - Log.d(LOG_TAG, "getImageBytes with uri=" + entry.getUri() + " cancelled"); + Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.getUri() + " cancelled"); + } + byte[] data = null; + if (bitmap != null) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + // we compress the bitmap because Dart Image.memory cannot decode the raw bytes + // Bitmap.CompressFormat.PNG is slower than JPEG + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); + data = stream.toByteArray(); } return new Result(p, data); } + @TargetApi(Build.VERSION_CODES.Q) + private Bitmap getBytesByResolverThumbnail(Params params) { + ImageEntry entry = params.entry; + int width = params.width; + int height = params.height; + + ContentResolver resolver = activity.getContentResolver(); + try { + return resolver.loadThumbnail(entry.getUri(), new Size(width, height), null); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + private Bitmap getBytesByMediaStoreThumbnail(Params params) { + ImageEntry entry = params.entry; + long contentId = entry.getContentId(); + + ContentResolver resolver = activity.getContentResolver(); + try { + if (entry.isVideo()) { + return MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null); + } else { + return MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null); + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + private Bitmap getBytesByGlide(Params params) { + ImageEntry entry = params.entry; + int width = params.width; + int height = params.height; + + // add signature to ignore cache for images which got modified but kept the same URI + Key signature = new ObjectKey("" + entry.getDateModifiedSecs() + entry.getWidth() + entry.getOrientationDegrees()); + RequestOptions options = new RequestOptions() + .signature(signature) + .override(width, height); + + FutureTarget target; + if (entry.isVideo()) { + options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE); + target = Glide.with(activity) + .asBitmap() + .apply(options) + .load(new VideoThumbnail(activity, entry.getUri())) + .signature(signature) + .submit(width, height); + } else { + target = Glide.with(activity) + .asBitmap() + .apply(options) + .load(entry.getUri()) + .signature(signature) + .submit(width, height); + } + + try { + return target.get(); + } catch (InterruptedException e) { + Log.d(LOG_TAG, "getImageBytes with uri=" + entry.getUri() + " interrupted"); + } catch (Exception e) { + e.printStackTrace(); + } + Glide.with(activity).clear(target); + return null; + } + @Override protected void onPostExecute(Result result) { MethodChannel.Result r = result.params.result; diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index e1a3144ce..a697981fe 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -109,9 +109,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } catch (ImageProcessingException e) { getAllVideoMetadataFallback(call, result); } catch (FileNotFoundException e) { - result.error("getAllMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + result.error("getAllMetadata-filenotfound", "failed to get metadata for path=" + path, e.getMessage()); } catch (Exception e) { - result.error("getAllMetadata-exception", "failed to get metadata for path=" + path, e); + result.error("getAllMetadata-exception", "failed to get metadata for path=" + path, e.getMessage()); } } @@ -144,7 +144,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { result.success(metadataMap); } catch (Exception e) { - result.error("getAllVideoMetadataFallback-exception", "failed to get metadata for path=" + path, e); + result.error("getAllVideoMetadataFallback-exception", "failed to get metadata for path=" + path, e.getMessage()); } } @@ -233,16 +233,16 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } } catch (Exception e) { - result.error("getCatalogMetadata-exception", "failed to get video metadata for path=" + path, e); + result.error("getCatalogMetadata-exception", "failed to get video metadata for path=" + path, e.getMessage()); } } result.success(metadataMap); } catch (ImageProcessingException e) { - result.error("getCatalogMetadata-imageprocessing", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + result.error("getCatalogMetadata-imageprocessing", "failed to get metadata for path=" + path, e.getMessage()); } catch (FileNotFoundException e) { - result.error("getCatalogMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + result.error("getCatalogMetadata-filenotfound", "failed to get metadata for path=" + path, e.getMessage()); } catch (Exception e) { - result.error("getCatalogMetadata-exception", "failed to get metadata for path=" + path, e); + result.error("getCatalogMetadata-exception", "failed to get metadata for path=" + path, e.getMessage()); } } @@ -274,11 +274,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } result.success(metadataMap); } catch (ImageProcessingException e) { - result.error("getOverlayMetadata-imageprocessing", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + result.error("getOverlayMetadata-imageprocessing", "failed to get metadata for path=" + path, e.getMessage()); } catch (FileNotFoundException e) { - result.error("getOverlayMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + result.error("getOverlayMetadata-filenotfound", "failed to get metadata for path=" + path, e.getMessage()); } catch (Exception e) { - result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e); + result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e.getMessage()); } } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java index fa39f1fd6..c6a276dd8 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java @@ -87,6 +87,10 @@ public class ImageEntry { return uri; } + public long getContentId() { + return contentId; + } + @Nullable public String getPath() { return path; diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 1f3bb11fd..741740020 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -194,6 +194,11 @@ public abstract class ImageProvider { } Bitmap originalImage = BitmapFactory.decodeFile(path); + if (originalImage == null) { + Log.e(LOG_TAG, "failed to decode image at path=" + path); + callback.onFailure(); + return; + } Matrix matrix = new Matrix(); int originalWidth = originalImage.getWidth(); int originalHeight = originalImage.getHeight(); @@ -207,14 +212,14 @@ public abstract class ImageProvider { ParcelFileDescriptor pfd = activity.getContentResolver().openFileDescriptor(uri, "rw"); if (pfd != null) fd = pfd.getFileDescriptor(); } catch (FileNotFoundException e) { - Log.w(LOG_TAG, "failed to get file descriptor for document at uri=" + path, e); + Log.e(LOG_TAG, "failed to get file descriptor for document at uri=" + path, e); } if (fd != null) { try (FileOutputStream fos = new FileOutputStream(fd)) { rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos); rotated = true; } catch (IOException e) { - Log.w(LOG_TAG, "failed to save rotated image to document at uri=" + path, e); + Log.e(LOG_TAG, "failed to save rotated image to document at uri=" + path, e); } } } else { @@ -222,7 +227,7 @@ public abstract class ImageProvider { rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos); rotated = true; } catch (IOException e) { - Log.w(LOG_TAG, "failed to save rotated image to path=" + path, e); + Log.e(LOG_TAG, "failed to save rotated image to path=" + path, e); } } if (!rotated) { diff --git a/lib/main.dart b/lib/main.dart index 27bb959df..c3d77e7e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -68,6 +68,7 @@ class _HomePageState extends State { Future setup() async { debugPrint('$runtimeType setup start, elapsed=${_stopwatch.elapsed}'); // TODO reduce permission check time + // TODO TLAD ask android.permission.ACCESS_MEDIA_LOCATION (unredacted EXIF with scoped storage) final permissions = await PermissionHandler().requestPermissions([ PermissionGroup.storage ]); // 350ms diff --git a/lib/widgets/common/image_preview.dart b/lib/widgets/common/image_preview.dart index 52fb9f44e..48f9f9020 100644 --- a/lib/widgets/common/image_preview.dart +++ b/lib/widgets/common/image_preview.dart @@ -76,7 +76,14 @@ class ImagePreviewState extends State with AfterInitMixin { future: _byteLoader, builder: (futureContext, AsyncSnapshot snapshot) { final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; - return bytes.isNotEmpty ? widget.builder(bytes) : Icon(Icons.error); + return bytes.isNotEmpty + ? widget.builder(bytes) + : Center( + child: Icon( + Icons.error, + color: Colors.blueGrey, + ), + ); }); } }