From d63e560e7da57c155762d16eb648adad85328521 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 28 Jul 2019 12:45:21 +0900 Subject: [PATCH] refactored method/event channels, use ImageEntry instead of Map --- .../deckers/thibault/aves/MainActivity.java | 276 +----------------- .../channelhandlers/AppAdapterHandler.java | 42 +++ .../channelhandlers/ImageDecodeHandler.java | 154 ++++++++++ .../aves/channelhandlers/ImageDecodeTask.java | 99 +++++++ .../ImageDecodeTaskManager.java | 34 +++ .../MediaStoreStreamHandler.java | 38 +++ .../provider/MediaStoreImageProvider.java | 7 +- .../thibault/aves/utils/ShareUtils.java | 14 - lib/image_fullscreen_overlay.dart | 21 +- lib/image_fullscreen_page.dart | 11 +- lib/main.dart | 33 ++- lib/model/android_app_service.dart | 18 ++ ...fetcher.dart => image_decode_service.dart} | 31 +- lib/model/image_entry.dart | 76 ++++- lib/thumbnail.dart | 18 +- lib/thumbnail_collection.dart | 25 +- 16 files changed, 536 insertions(+), 361 deletions(-) create mode 100644 android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTaskManager.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java delete mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/ShareUtils.java create mode 100644 lib/model/android_app_service.dart rename lib/model/{image_fetcher.dart => image_decode_service.dart} (59%) diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index 9c22073d2..3cd19980b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -1,284 +1,28 @@ package deckers.thibault.aves; -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Intent; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.AsyncTask; import android.os.Bundle; -import android.provider.Settings; -import android.util.Log; -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.Key; -import com.bumptech.glide.request.FutureTarget; -import com.bumptech.glide.signature.ObjectKey; -import com.drew.imaging.ImageMetadataReader; -import com.drew.metadata.Metadata; -import com.drew.metadata.exif.ExifSubIFDDirectory; -import com.karumi.dexter.Dexter; -import com.karumi.dexter.PermissionToken; -import com.karumi.dexter.listener.PermissionDeniedResponse; -import com.karumi.dexter.listener.PermissionGrantedResponse; -import com.karumi.dexter.listener.PermissionRequest; -import com.karumi.dexter.listener.single.PermissionListener; - -import java.io.ByteArrayOutputStream; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -import deckers.thibault.aves.model.ImageEntry; -import deckers.thibault.aves.model.provider.MediaStoreImageProvider; -import deckers.thibault.aves.utils.ShareUtils; -import deckers.thibault.aves.utils.Utils; +import deckers.thibault.aves.channelhandlers.AppAdapterHandler; +import deckers.thibault.aves.channelhandlers.ImageDecodeHandler; +import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler; import io.flutter.app.FlutterActivity; +import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugins.GeneratedPluginRegistrant; - -class ThumbnailFetcher { - private Activity activity; - private HashMap taskMap = new HashMap<>(); - - ThumbnailFetcher(Activity activity) { - this.activity = activity; - } - - void fetch(ImageEntry entry, Integer width, Integer height, Result result) { - BitmapWorkerTask.MyTaskParams params = new BitmapWorkerTask.MyTaskParams(entry, width, height, result, this::complete); - AsyncTask task = new BitmapWorkerTask(activity).execute(params); - taskMap.put(entry.getUri().toString(), task); - } - - void cancel(String uri) { - AsyncTask task = taskMap.get(uri); - if (task != null) task.cancel(true); - taskMap.remove(uri); - } - - private void complete(String uri) { - taskMap.remove(uri); - } -} +import io.flutter.view.FlutterView; public class MainActivity extends FlutterActivity { - private static final String LOG_TAG = Utils.createLogTag(MainActivity.class); - private static final String CHANNEL = "deckers.thibault.aves/mediastore"; - - private ThumbnailFetcher thumbnailFetcher; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); GeneratedPluginRegistrant.registerWith(this); - thumbnailFetcher = new ThumbnailFetcher(this); - new MethodChannel(getFlutterView(), CHANNEL).setMethodCallHandler( - (call, result) -> { - switch (call.method) { - case "getImageEntries": - getPermissionResult(result, this); - break; - case "getOverlayMetadata": - String path = call.argument("path"); - getOverlayMetadata(result, path); - break; - case "getImageBytes": { - Map map = call.argument("entry"); - Integer width = call.argument("width"); - Integer height = call.argument("height"); - ImageEntry entry = new ImageEntry(map); - thumbnailFetcher.fetch(entry, width, height, result); - break; - } - case "cancelGetImageBytes": { - String uri = call.argument("uri"); - thumbnailFetcher.cancel(uri); - result.success(null); - break; - } - case "share": { - String title = call.argument("title"); - Uri uri = Uri.parse(call.argument("uri")); - String mimeType = call.argument("mimeType"); - ShareUtils.share(this, title, uri, mimeType); - result.success(null); - } - default: - result.notImplemented(); - break; - } - }); - } + MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler(); - public void getPermissionResult(final Result result, final Activity activity) { - Dexter.withActivity(activity) - .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .withListener(new PermissionListener() { - @Override - public void onPermissionGranted(PermissionGrantedResponse response) { - result.success(fetchAll(activity)); - } - - @Override - public void onPermissionDenied(PermissionDeniedResponse response) { - - AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); - builder.setTitle("We need this permission"); - builder.setCancelable(false); - builder.setPositiveButton("OK", (dialog, id) -> { - dialog.cancel(); - Intent intent = new Intent(); - intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", activity.getPackageName(), null); - intent.setData(uri); - activity.startActivity(intent); - }); - builder.setNegativeButton("Cancel", (dialog, id) -> dialog.cancel()); - AlertDialog alert = builder.create(); - alert.show(); - } - - @Override - public void onPermissionRationaleShouldBeShown(PermissionRequest permission, final PermissionToken token) { - AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); - builder.setTitle("We need this permission"); - builder.setCancelable(false); - builder.setPositiveButton("OK", (dialog, id) -> { - dialog.cancel(); - token.continuePermissionRequest(); - }); - builder.setNegativeButton("Cancel", (dialog, id) -> { - dialog.cancel(); - token.cancelPermissionRequest(); - }); - AlertDialog alert = builder.create(); - alert.show(); - } - }).check(); - } - - List fetchAll(Activity activity) { - return new MediaStoreImageProvider().fetchAll(activity).stream() - .map(ImageEntry::toMap) - .collect(Collectors.toList()); - } - - void getOverlayMetadata (Result result, String path) { - try (InputStream is = new FileInputStream(path)) { - Metadata metadata = ImageMetadataReader.readMetadata(is); - ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); - Map metadataMap = new HashMap<>(); - if (directory != null) { - if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { - metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { - metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { - metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { - metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); - } - } - result.success(metadataMap); - } catch (FileNotFoundException e) { - result.error("getOverlayMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); - } catch (Exception e) { - result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e); - } + FlutterView messenger = getFlutterView(); + new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this)); + new MethodChannel(messenger, ImageDecodeHandler.CHANNEL).setMethodCallHandler(new ImageDecodeHandler(this, mediaStoreStreamHandler)); + new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); } } -class BitmapWorkerTask extends AsyncTask { - private static final String LOG_TAG = Utils.createLogTag(BitmapWorkerTask.class); - - static class MyTaskParams { - ImageEntry entry; - int width, height; - Result result; - Consumer complete; - - MyTaskParams(ImageEntry entry, int width, int height, Result result, Consumer complete) { - this.entry = entry; - this.width = width; - this.height = height; - this.result = result; - this.complete = complete; - } - } - - static class MyTaskResult { - MyTaskParams params; - byte[] data; - - MyTaskResult(MyTaskParams params, byte[] data) { - this.params = params; - this.data = data; - } - } - - @SuppressLint("StaticFieldLeak") - private Activity activity; - - BitmapWorkerTask(Activity activity) { - this.activity = activity; - } - - @Override - protected MyTaskResult doInBackground(MyTaskParams... params) { - MyTaskParams p = params[0]; - ImageEntry entry = p.entry; - byte[] data = null; - if (!this.isCancelled()) { - // 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()); - FutureTarget target = Glide.with(activity) - .asBitmap() - .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); - } else { - Log.d(LOG_TAG, "getImageBytes with uri=" + entry.getUri() + " cancelled"); - } - return new MyTaskResult(p, data); - } - - @Override - protected void onPostExecute(MyTaskResult result) { - MethodChannel.Result r = result.params.result; - String uri = result.params.entry.getUri().toString(); - result.params.complete.accept(uri); - if (result.data != null) { - r.success(result.data); - } else { - r.error("getImageBytes-null", "failed to get thumbnail for uri=" + uri, null); - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java new file mode 100644 index 000000000..3e9fe33f0 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java @@ -0,0 +1,42 @@ +package deckers.thibault.aves.channelhandlers; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.NonNull; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +public class AppAdapterHandler implements MethodChannel.MethodCallHandler { + public static final String CHANNEL = "deckers.thibault/aves/app"; + + private Context context; + + public AppAdapterHandler(Context context) { + this.context = context; + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "share": { + String title = call.argument("title"); + Uri uri = Uri.parse(call.argument("uri")); + String mimeType = call.argument("mimeType"); + share(context, title, uri, mimeType); + result.success(null); + } + default: + result.notImplemented(); + break; + } + } + + private void share(Context context, String title, Uri uri, String mimeType) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.setType(mimeType); + context.startActivity(Intent.createChooser(intent, title)); + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java new file mode 100644 index 000000000..9a3f159a8 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java @@ -0,0 +1,154 @@ +package deckers.thibault.aves.channelhandlers; + +import android.Manifest; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.net.Uri; +import android.provider.Settings; +import android.support.annotation.NonNull; + +import com.drew.imaging.ImageMetadataReader; +import com.drew.metadata.Metadata; +import com.drew.metadata.exif.ExifSubIFDDirectory; +import com.karumi.dexter.Dexter; +import com.karumi.dexter.PermissionToken; +import com.karumi.dexter.listener.PermissionDeniedResponse; +import com.karumi.dexter.listener.PermissionGrantedResponse; +import com.karumi.dexter.listener.PermissionRequest; +import com.karumi.dexter.listener.single.PermissionListener; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import deckers.thibault.aves.model.ImageEntry; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { + public static final String CHANNEL = "deckers.thibault/aves/image"; + + private Activity activity; + private ImageDecodeTaskManager imageDecodeTaskManager; + private MediaStoreStreamHandler mediaStoreStreamHandler; + + public ImageDecodeHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) { + this.activity = activity; + imageDecodeTaskManager = new ImageDecodeTaskManager(activity); + this.mediaStoreStreamHandler = mediaStoreStreamHandler; + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "getImageEntries": + getPermissionResult(result, activity); + break; + case "getImageBytes": { + Map map = call.argument("entry"); + Integer width = call.argument("width"); + Integer height = call.argument("height"); + if (map == null) { + result.error("getImageBytes-args", "failed to get image bytes because 'entry' is null", null); + return; + } + ImageEntry entry = new ImageEntry(map); + imageDecodeTaskManager.fetch(result, entry, width, height); + break; + } + case "cancelGetImageBytes": { + String uri = call.argument("uri"); + imageDecodeTaskManager.cancel(uri); + result.success(null); + break; + } + case "getOverlayMetadata": + String path = call.argument("path"); + getOverlayMetadata(result, path); + break; + default: + result.notImplemented(); + break; + } + } + + private void getPermissionResult(final MethodChannel.Result result, final Activity activity) { + Dexter.withActivity(activity) + .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .withListener(new PermissionListener() { + @Override + public void onPermissionGranted(PermissionGrantedResponse response) { + mediaStoreStreamHandler.fetchAll(activity); + result.success(null); + } + + @Override + public void onPermissionDenied(PermissionDeniedResponse response) { + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); + builder.setTitle("We need this permission"); + builder.setCancelable(false); + builder.setPositiveButton("OK", (dialog, id) -> { + dialog.cancel(); + Intent intent = new Intent(); + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", activity.getPackageName(), null); + intent.setData(uri); + activity.startActivity(intent); + }); + builder.setNegativeButton("Cancel", (dialog, id) -> dialog.cancel()); + AlertDialog alert = builder.create(); + alert.show(); + } + + @Override + public void onPermissionRationaleShouldBeShown(PermissionRequest permission, final PermissionToken token) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); + builder.setTitle("We need this permission"); + builder.setCancelable(false); + builder.setPositiveButton("OK", (dialog, id) -> { + dialog.cancel(); + token.continuePermissionRequest(); + }); + builder.setNegativeButton("Cancel", (dialog, id) -> { + dialog.cancel(); + token.cancelPermissionRequest(); + }); + AlertDialog alert = builder.create(); + alert.show(); + } + }).check(); + } + + private void getOverlayMetadata(MethodChannel.Result result, String path) { + try (InputStream is = new FileInputStream(path)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); + ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); + Map metadataMap = new HashMap<>(); + if (directory != null) { + if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { + metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { + metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { + metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { + metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); + } + } + result.success(metadataMap); + } catch (FileNotFoundException e) { + result.error("getOverlayMetadata-filenotfound", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); + } catch (Exception e) { + result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e); + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..5623b0958 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -0,0 +1,99 @@ +package deckers.thibault.aves.channelhandlers; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.graphics.Bitmap; +import android.os.AsyncTask; +import android.util.Log; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.Key; +import com.bumptech.glide.request.FutureTarget; +import com.bumptech.glide.signature.ObjectKey; + +import java.io.ByteArrayOutputStream; +import java.util.function.Consumer; + +import deckers.thibault.aves.model.ImageEntry; +import deckers.thibault.aves.utils.Utils; +import io.flutter.plugin.common.MethodChannel; + +public class ImageDecodeTask extends AsyncTask { + private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class); + + static class Params { + ImageEntry entry; + int width, height; + MethodChannel.Result result; + Consumer complete; + + Params(ImageEntry entry, int width, int height, MethodChannel.Result result, Consumer complete) { + this.entry = entry; + this.width = width; + this.height = height; + this.result = result; + this.complete = complete; + } + } + + static class Result { + Params params; + byte[] data; + + Result(Params params, byte[] data) { + this.params = params; + this.data = data; + } + } + + @SuppressLint("StaticFieldLeak") + private Activity activity; + + ImageDecodeTask(Activity activity) { + this.activity = activity; + } + + @Override + protected Result doInBackground(Params... params) { + Params p = params[0]; + ImageEntry entry = p.entry; + byte[] data = null; + if (!this.isCancelled()) { + // 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()); + FutureTarget target = Glide.with(activity) + .asBitmap() + .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); + } else { + Log.d(LOG_TAG, "getImageBytes with uri=" + entry.getUri() + " cancelled"); + } + return new Result(p, data); + } + + @Override + protected void onPostExecute(Result result) { + MethodChannel.Result r = result.params.result; + String uri = result.params.entry.getUri().toString(); + result.params.complete.accept(uri); + if (result.data != null) { + r.success(result.data); + } else { + r.error("getImageBytes-null", "failed to get thumbnail for uri=" + uri, null); + } + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTaskManager.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTaskManager.java new file mode 100644 index 000000000..809044110 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTaskManager.java @@ -0,0 +1,34 @@ +package deckers.thibault.aves.channelhandlers; + +import android.app.Activity; +import android.os.AsyncTask; + +import java.util.HashMap; + +import deckers.thibault.aves.model.ImageEntry; +import io.flutter.plugin.common.MethodChannel; + +public class ImageDecodeTaskManager { + private Activity activity; + private HashMap taskMap = new HashMap<>(); + + ImageDecodeTaskManager(Activity activity) { + this.activity = activity; + } + + void fetch(MethodChannel.Result result, ImageEntry entry, Integer width, Integer height) { + ImageDecodeTask.Params params = new ImageDecodeTask.Params(entry, width, height, result, this::complete); + AsyncTask task = new ImageDecodeTask(activity).execute(params); + taskMap.put(entry.getUri().toString(), task); + } + + void cancel(String uri) { + AsyncTask task = taskMap.get(uri); + if (task != null) task.cancel(true); + taskMap.remove(uri); + } + + private void complete(String uri) { + taskMap.remove(uri); + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java new file mode 100644 index 000000000..8f03c95a7 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java @@ -0,0 +1,38 @@ +package deckers.thibault.aves.channelhandlers; + +import android.app.Activity; +import android.util.Log; + +import java.util.stream.Stream; + +import deckers.thibault.aves.model.ImageEntry; +import deckers.thibault.aves.model.provider.MediaStoreImageProvider; +import deckers.thibault.aves.utils.Utils; +import io.flutter.plugin.common.EventChannel; + +public class MediaStoreStreamHandler implements EventChannel.StreamHandler { + public static final String CHANNEL = "deckers.thibault/aves/mediastore"; + + private static final String LOG_TAG = Utils.createLogTag(MediaStoreStreamHandler.class); + + private EventChannel.EventSink eventSink; + + @Override + public void onListen(Object args, final EventChannel.EventSink events) { + Log.w(LOG_TAG, "onListen with args=" + args); + eventSink = events; + } + + @Override + public void onCancel(Object args) { + Log.w(LOG_TAG, "onCancel with args=" + args); + } + + void fetchAll(Activity activity) { + Log.d(LOG_TAG, "fetchAll start"); + Stream stream = new MediaStoreImageProvider().fetchAll(activity); + stream.map(ImageEntry::toMap) + .forEach(entry -> eventSink.success(entry)); + eventSink.endOfStream(); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index dddd8886f..bf4580e2b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -9,6 +9,7 @@ import android.util.Log; import java.util.ArrayList; import java.util.List; +import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.utils.Utils; @@ -39,11 +40,11 @@ public class MediaStoreImageProvider { + " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=" + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO; - public List fetchAll(Activity activity) { + public Stream fetchAll(Activity activity) { return fetch(activity, FILES_URI); } - private List fetch(final Activity activity, final Uri queryUri) { + private Stream fetch(final Activity activity, final Uri queryUri) { ArrayList entries = new ArrayList<>(); // URI should refer to the "files" table, not to the "images" or "videos" one, @@ -109,6 +110,6 @@ public class MediaStoreImageProvider { } catch (Exception e) { Log.d(LOG_TAG, "failed to get entries", e); } - return entries; + return entries.stream(); } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/ShareUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/ShareUtils.java deleted file mode 100644 index 188abd9d1..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/ShareUtils.java +++ /dev/null @@ -1,14 +0,0 @@ -package deckers.thibault.aves.utils; - -import android.app.Activity; -import android.content.Intent; -import android.net.Uri; - -public class ShareUtils { - public static void share(Activity activity, String title, Uri uri, String mimeType) { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.putExtra(Intent.EXTRA_STREAM, uri); - intent.setType(mimeType); - activity.startActivity(Intent.createChooser(intent, title)); - } -} \ No newline at end of file diff --git a/lib/image_fullscreen_overlay.dart b/lib/image_fullscreen_overlay.dart index f35f1c348..6f40220cc 100644 --- a/lib/image_fullscreen_overlay.dart +++ b/lib/image_fullscreen_overlay.dart @@ -1,8 +1,9 @@ import 'dart:math'; import 'dart:ui'; +import 'package:aves/model/android_app_service.dart'; +import 'package:aves/model/image_decode_service.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_fetcher.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -25,10 +26,10 @@ class Blurred extends StatelessWidget { } class FullscreenTopOverlay extends StatelessWidget { - final List entries; + final List entries; final int index; - Map get entry => entries[index]; + ImageEntry get entry => entries[index]; const FullscreenTopOverlay({Key key, this.entries, this.index}) : super(key: key); @@ -55,12 +56,12 @@ class FullscreenTopOverlay extends StatelessWidget { delete() {} share() { - ImageFetcher.share(entry['uri'], entry['mimeType']); + AndroidAppService.share(entry.uri, entry.mimeType); } } class FullscreenBottomOverlay extends StatefulWidget { - final List entries; + final List entries; final int index; const FullscreenBottomOverlay({Key key, this.entries, this.index}) : super(key: key); @@ -73,7 +74,7 @@ class _FullscreenBottomOverlayState extends State { Future _detailLoader; Map _lastDetails; - Map get entry => widget.entries[widget.index]; + ImageEntry get entry => widget.entries[widget.index]; @override void initState() { @@ -88,7 +89,7 @@ class _FullscreenBottomOverlayState extends State { } initDetailLoader() { - _detailLoader = ImageFetcher.getOverlayMetadata(entry['path']); + _detailLoader = ImageDecodeService.getOverlayMetadata(entry.path); } @override @@ -96,7 +97,7 @@ class _FullscreenBottomOverlayState extends State { var mediaQuery = MediaQuery.of(context); final screenWidth = mediaQuery.size.width; final viewInsets = mediaQuery.viewInsets; - final date = ImageEntry.getBestDate(entry); + final date = entry.getBestDate(); final subRowWidth = min(400.0, screenWidth); return Blurred( child: IgnorePointer( @@ -121,7 +122,7 @@ class _FullscreenBottomOverlayState extends State { SizedBox( width: screenWidth, child: Text( - entry['title'], + entry.title, overflow: TextOverflow.ellipsis, ), ), @@ -133,7 +134,7 @@ class _FullscreenBottomOverlayState extends State { Icon(Icons.calendar_today, size: 16), SizedBox(width: 8), Expanded(child: Text('${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}')), - Expanded(child: Text('${entry['width']} × ${entry['height']}')), + Expanded(child: Text('${entry.width} × ${entry.height}')), ], ), ), diff --git a/lib/image_fullscreen_page.dart b/lib/image_fullscreen_page.dart index 09599a0df..af8ea84e4 100644 --- a/lib/image_fullscreen_page.dart +++ b/lib/image_fullscreen_page.dart @@ -2,12 +2,13 @@ import 'dart:io'; import 'dart:math'; import 'package:aves/image_fullscreen_overlay.dart'; +import 'package:aves/model/image_entry.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; class ImageFullscreenPage extends StatefulWidget { - final List entries; + final List entries; final String initialUri; const ImageFullscreenPage({ @@ -27,12 +28,12 @@ class ImageFullscreenPageState extends State with SingleTic AnimationController _overlayAnimationController; Animation _topOverlayOffset, _bottomOverlayOffset; - List get entries => widget.entries; + List get entries => widget.entries; @override void initState() { super.initState(); - final index = entries.indexWhere((entry) => entry['uri'] == widget.initialUri); + final index = entries.indexWhere((entry) => entry.uri == widget.initialUri); _currentPage = max(0, index); _pageController = PageController(initialPage: _currentPage); _overlayAnimationController = AnimationController( @@ -61,8 +62,8 @@ class ImageFullscreenPageState extends State with SingleTic builder: (context, index) { final entry = entries[index]; return PhotoViewGalleryPageOptions( - imageProvider: FileImage(File(entry['path'])), - heroTag: entry['uri'], + imageProvider: FileImage(File(entry.path)), + heroTag: entry.uri, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, onTapUp: (tapContext, details, value) => _overlayVisible.value = !_overlayVisible.value, diff --git a/lib/main.dart b/lib/main.dart index e5d2e6e84..f1fef01fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:aves/common/fake_app_bar.dart'; -import 'package:aves/model/image_fetcher.dart'; +import 'package:aves/model/image_decode_service.dart'; +import 'package:aves/model/image_entry.dart'; import 'package:aves/thumbnail_collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -29,13 +30,24 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - Future> _entryListLoader; + static const EventChannel eventChannel = EventChannel('deckers.thibault/aves/mediastore'); + + List entries = List(); + bool done = false; @override void initState() { super.initState(); imageCache.maximumSizeBytes = 100 * 1024 * 1024; - _entryListLoader = ImageFetcher.getImageEntries(); + eventChannel.receiveBroadcastStream().cast().listen( + (entryMap) => setState(() => entries.add(ImageEntry.fromMap(entryMap))), + onDone: () { + debugPrint('mediastore stream done'); + setState(() => done = true); + }, + onError: (error) => debugPrint('mediastore stream error=$error'), + ); + ImageDecodeService.getImageEntries(); } @override @@ -43,18 +55,9 @@ class _HomePageState extends State { return Scaffold( // fake app bar so that content is safe from status bar, even though we use a SliverAppBar appBar: FakeAppBar(), - body: Container( - child: FutureBuilder( - future: _entryListLoader, - builder: (futureContext, AsyncSnapshot> snapshot) { - if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { - return ThumbnailCollection(entries: snapshot.data); - } - return Center( - child: CircularProgressIndicator(), - ); - }, - ), + body: ThumbnailCollection( + entries: entries, + done: done, ), resizeToAvoidBottomInset: false, ); diff --git a/lib/model/android_app_service.dart b/lib/model/android_app_service.dart new file mode 100644 index 000000000..1f799001a --- /dev/null +++ b/lib/model/android_app_service.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class AndroidAppService { + static const platform = const MethodChannel('deckers.thibault/aves/app'); + + static share(String uri, String mimeType) async { + try { + await platform.invokeMethod('share', { + 'title': 'Share via:', + 'uri': uri, + 'mimeType': mimeType, + }); + } on PlatformException catch (e) { + debugPrint('share failed with exception=${e.message}'); + } + } +} diff --git a/lib/model/image_fetcher.dart b/lib/model/image_decode_service.dart similarity index 59% rename from lib/model/image_fetcher.dart rename to lib/model/image_decode_service.dart index fd97d73ee..00c864bee 100644 --- a/lib/model/image_fetcher.dart +++ b/lib/model/image_decode_service.dart @@ -1,28 +1,25 @@ import 'dart:typed_data'; +import 'package:aves/model/image_entry.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -class ImageFetcher { - static const platform = const MethodChannel('deckers.thibault.aves/mediastore'); +class ImageDecodeService { + static const platform = const MethodChannel('deckers.thibault/aves/image'); - static Future> getImageEntries() async { + static getImageEntries() async { try { - final result = await platform.invokeMethod('getImageEntries'); - final entries = (result as List).cast(); - debugPrint('getImageEntries found ${entries.length} entries'); - return entries; + await platform.invokeMethod('getImageEntries'); } on PlatformException catch (e) { debugPrint('getImageEntries failed with exception=${e.message}'); } - return []; } - static Future getImageBytes(Map entry, int width, int height) async { - debugPrint('getImageBytes with uri=${entry['uri']}'); + static Future getImageBytes(ImageEntry entry, int width, int height) async { + debugPrint('getImageBytes with uri=${entry.uri}'); try { final result = await platform.invokeMethod('getImageBytes', { - 'entry': entry, + 'entry': entry.toMap(), 'width': width, 'height': height, }); @@ -55,16 +52,4 @@ class ImageFetcher { } return Map(); } - - static share(String uri, String mimeType) async { - try { - await platform.invokeMethod('share', { - 'title': 'Share via:', - 'uri': uri, - 'mimeType': mimeType, - }); - } on PlatformException catch (e) { - debugPrint('share failed with exception=${e.message}'); - } - } } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index de9d13d18..c1b723a67 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -1,16 +1,78 @@ class ImageEntry { - static DateTime getBestDate(Map entry) { - final dateTakenMillis = entry['sourceDateTakenMillis'] as int; - if (dateTakenMillis != null && dateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(dateTakenMillis); + String uri; + String path; + int contentId; + String mimeType; + int width; + int height; + int orientationDegrees; + int sizeBytes; + String title; + int dateModifiedSecs; + int sourceDateTakenMillis; + String bucketDisplayName; + int durationMillis; - final dateModifiedSecs = entry['dateModifiedSecs'] as int; + ImageEntry({ + this.uri, + this.path, + this.contentId, + this.mimeType, + this.width, + this.height, + this.orientationDegrees, + this.sizeBytes, + this.title, + this.dateModifiedSecs, + this.sourceDateTakenMillis, + this.bucketDisplayName, + this.durationMillis, + }); + + factory ImageEntry.fromMap(Map map) { + return ImageEntry( + uri: map['uri'], + path: map['path'], + contentId: map['contentId'], + mimeType: map['mimeType'], + width: map['width'], + height: map['height'], + orientationDegrees: map['orientationDegrees'], + sizeBytes: map['sizeBytes'], + title: map['title'], + dateModifiedSecs: map['dateModifiedSecs'], + sourceDateTakenMillis: map['sourceDateTakenMillis'], + bucketDisplayName: map['bucketDisplayName'], + durationMillis: map['durationMillis'], + ); + } + + Map toMap() { + return { + 'uri': uri, + 'path': path, + 'contentId': contentId, + 'mimeType': mimeType, + 'width': width, + 'height': height, + 'orientationDegrees': orientationDegrees, + 'sizeBytes': sizeBytes, + 'title': title, + 'dateModifiedSecs': dateModifiedSecs, + 'sourceDateTakenMillis': sourceDateTakenMillis, + 'bucketDisplayName': bucketDisplayName, + 'durationMillis': durationMillis, + }; + } + + DateTime getBestDate() { + if (sourceDateTakenMillis != null && sourceDateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis); if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000); - return null; } - static DateTime getDayTaken(Map entry) { - final d = getBestDate(entry); + DateTime getDayTaken() { + final d = getBestDate(); return d == null ? null : DateTime(d.year, d.month, d.day); } } diff --git a/lib/thumbnail.dart b/lib/thumbnail.dart index bc0b5fdc1..8da77bdeb 100644 --- a/lib/thumbnail.dart +++ b/lib/thumbnail.dart @@ -1,13 +1,14 @@ import 'dart:math'; import 'dart:typed_data'; -import 'package:aves/model/image_fetcher.dart'; +import 'package:aves/model/image_decode_service.dart'; +import 'package:aves/model/image_entry.dart'; import 'package:aves/model/mime_types.dart'; import 'package:flutter/material.dart'; import 'package:transparent_image/transparent_image.dart'; class Thumbnail extends StatefulWidget { - final Map entry; + final ImageEntry entry; final double extent; final double devicePixelRatio; @@ -25,34 +26,31 @@ class Thumbnail extends StatefulWidget { class ThumbnailState extends State { Future _byteLoader; - String get mimeType => widget.entry['mimeType']; + String get mimeType => widget.entry.mimeType; - String get uri => widget.entry['uri']; + String get uri => widget.entry.uri; @override void initState() { super.initState(); -// debugPrint('initState with uri=$uri entry=${widget.entry['path']}'); initByteLoader(); } @override void didUpdateWidget(Thumbnail oldWidget) { super.didUpdateWidget(oldWidget); - if (uri == oldWidget.entry['uri'] && widget.extent == oldWidget.extent) return; -// debugPrint('didUpdateWidget FROM uri=${oldWidget.entry['uri']} TO uri=$uri entry=${widget.entry['path']}'); + if (uri == oldWidget.entry.uri && widget.extent == oldWidget.extent) return; initByteLoader(); } initByteLoader() { final dim = (widget.extent * widget.devicePixelRatio).round(); - _byteLoader = ImageFetcher.getImageBytes(widget.entry, dim, dim); + _byteLoader = ImageDecodeService.getImageBytes(widget.entry, dim, dim); } @override void dispose() { -// debugPrint('dispose with uri=$uri entry=${widget.entry['path']}'); - ImageFetcher.cancelGetImageBytes(uri); + ImageDecodeService.cancelGetImageBytes(uri); super.dispose(); } diff --git a/lib/thumbnail_collection.dart b/lib/thumbnail_collection.dart index 090a29814..3d90e4c90 100644 --- a/lib/thumbnail_collection.dart +++ b/lib/thumbnail_collection.dart @@ -10,17 +10,26 @@ import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:intl/intl.dart'; class ThumbnailCollection extends StatelessWidget { - final List entries; - final Map> sections; + final List entries; + final bool done; + final Map> sections; final ScrollController scrollController = ScrollController(); - ThumbnailCollection({Key key, this.entries}) - : sections = groupBy(entries, ImageEntry.getDayTaken), + ThumbnailCollection({Key key, this.entries, this.done}) + : sections = groupBy(entries, (entry) => entry.getDayTaken()), super(key: key); @override Widget build(BuildContext context) { // debugPrint('$runtimeType build with sections=${sections.length}'); + if (!done) { + return Center( + child: Text( + 'streamed ${entries.length} items', + style: TextStyle(fontSize: 16), + ), + ); + } return DraggableScrollbar.arrows( labelTextBuilder: (double offset) => Text( "${offset ~/ 1}", @@ -46,8 +55,8 @@ class ThumbnailCollection extends StatelessWidget { } class SectionSliver extends StatelessWidget { - final List entries; - final Map> sections; + final List entries; + final Map> sections; final DateTime sectionKey; const SectionSliver({ @@ -88,13 +97,13 @@ class SectionSliver extends StatelessWidget { ); } - Future _showFullscreen(BuildContext context, Map entry) { + Future _showFullscreen(BuildContext context, ImageEntry entry) { return Navigator.push( context, MaterialPageRoute( builder: (context) => ImageFullscreenPage( entries: entries, - initialUri: entry['uri'], + initialUri: entry.uri, ), ), );