refactored method/event channels, use ImageEntry instead of Map

This commit is contained in:
Thibault Deckers 2019-07-28 12:45:21 +09:00
parent b9cc2c076c
commit d63e560e7d
16 changed files with 536 additions and 361 deletions

View file

@ -1,284 +1,28 @@
package deckers.thibault.aves; 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.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import com.bumptech.glide.Glide; import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
import com.bumptech.glide.load.Key; import deckers.thibault.aves.channelhandlers.ImageDecodeHandler;
import com.bumptech.glide.request.FutureTarget; import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
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 io.flutter.app.FlutterActivity; import io.flutter.app.FlutterActivity;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugins.GeneratedPluginRegistrant; import io.flutter.plugins.GeneratedPluginRegistrant;
import io.flutter.view.FlutterView;
class ThumbnailFetcher {
private Activity activity;
private HashMap<String, AsyncTask> 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);
}
}
public class MainActivity extends FlutterActivity { 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 @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this); GeneratedPluginRegistrant.registerWith(this);
thumbnailFetcher = new ThumbnailFetcher(this); MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
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;
}
});
}
public void getPermissionResult(final Result result, final Activity activity) { FlutterView messenger = getFlutterView();
Dexter.withActivity(activity) new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) new MethodChannel(messenger, ImageDecodeHandler.CHANNEL).setMethodCallHandler(new ImageDecodeHandler(this, mediaStoreStreamHandler));
.withListener(new PermissionListener() { new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
@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<Map> 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<String, String> 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);
}
} }
} }
class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, BitmapWorkerTask.MyTaskResult> {
private static final String LOG_TAG = Utils.createLogTag(BitmapWorkerTask.class);
static class MyTaskParams {
ImageEntry entry;
int width, height;
Result result;
Consumer<String> complete;
MyTaskParams(ImageEntry entry, int width, int height, Result result, Consumer<String> 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<Bitmap> 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);
}
}
}

View file

@ -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));
}
}

View file

@ -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<String, String> 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);
}
}
}

View file

@ -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<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> {
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
static class Params {
ImageEntry entry;
int width, height;
MethodChannel.Result result;
Consumer<String> complete;
Params(ImageEntry entry, int width, int height, MethodChannel.Result result, Consumer<String> 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<Bitmap> 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);
}
}
}

View file

@ -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<String, AsyncTask> 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);
}
}

View file

@ -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<ImageEntry> stream = new MediaStoreImageProvider().fetchAll(activity);
stream.map(ImageEntry::toMap)
.forEach(entry -> eventSink.success(entry));
eventSink.endOfStream();
}
}

View file

@ -9,6 +9,7 @@ import android.util.Log;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Utils; 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; + " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=" + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO;
public List<ImageEntry> fetchAll(Activity activity) { public Stream<ImageEntry> fetchAll(Activity activity) {
return fetch(activity, FILES_URI); return fetch(activity, FILES_URI);
} }
private List<ImageEntry> fetch(final Activity activity, final Uri queryUri) { private Stream<ImageEntry> fetch(final Activity activity, final Uri queryUri) {
ArrayList<ImageEntry> entries = new ArrayList<>(); ArrayList<ImageEntry> entries = new ArrayList<>();
// URI should refer to the "files" table, not to the "images" or "videos" one, // URI should refer to the "files" table, not to the "images" or "videos" one,
@ -109,6 +110,6 @@ public class MediaStoreImageProvider {
} catch (Exception e) { } catch (Exception e) {
Log.d(LOG_TAG, "failed to get entries", e); Log.d(LOG_TAG, "failed to get entries", e);
} }
return entries; return entries.stream();
} }
} }

View file

@ -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));
}
}

View file

@ -1,8 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui'; 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_entry.dart';
import 'package:aves/model/image_fetcher.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -25,10 +26,10 @@ class Blurred extends StatelessWidget {
} }
class FullscreenTopOverlay extends StatelessWidget { class FullscreenTopOverlay extends StatelessWidget {
final List<Map> entries; final List<ImageEntry> entries;
final int index; final int index;
Map get entry => entries[index]; ImageEntry get entry => entries[index];
const FullscreenTopOverlay({Key key, this.entries, this.index}) : super(key: key); const FullscreenTopOverlay({Key key, this.entries, this.index}) : super(key: key);
@ -55,12 +56,12 @@ class FullscreenTopOverlay extends StatelessWidget {
delete() {} delete() {}
share() { share() {
ImageFetcher.share(entry['uri'], entry['mimeType']); AndroidAppService.share(entry.uri, entry.mimeType);
} }
} }
class FullscreenBottomOverlay extends StatefulWidget { class FullscreenBottomOverlay extends StatefulWidget {
final List<Map> entries; final List<ImageEntry> entries;
final int index; final int index;
const FullscreenBottomOverlay({Key key, this.entries, this.index}) : super(key: key); const FullscreenBottomOverlay({Key key, this.entries, this.index}) : super(key: key);
@ -73,7 +74,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
Future<Map> _detailLoader; Future<Map> _detailLoader;
Map _lastDetails; Map _lastDetails;
Map get entry => widget.entries[widget.index]; ImageEntry get entry => widget.entries[widget.index];
@override @override
void initState() { void initState() {
@ -88,7 +89,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
} }
initDetailLoader() { initDetailLoader() {
_detailLoader = ImageFetcher.getOverlayMetadata(entry['path']); _detailLoader = ImageDecodeService.getOverlayMetadata(entry.path);
} }
@override @override
@ -96,7 +97,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
var mediaQuery = MediaQuery.of(context); var mediaQuery = MediaQuery.of(context);
final screenWidth = mediaQuery.size.width; final screenWidth = mediaQuery.size.width;
final viewInsets = mediaQuery.viewInsets; final viewInsets = mediaQuery.viewInsets;
final date = ImageEntry.getBestDate(entry); final date = entry.getBestDate();
final subRowWidth = min(400.0, screenWidth); final subRowWidth = min(400.0, screenWidth);
return Blurred( return Blurred(
child: IgnorePointer( child: IgnorePointer(
@ -121,7 +122,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
SizedBox( SizedBox(
width: screenWidth, width: screenWidth,
child: Text( child: Text(
entry['title'], entry.title,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@ -133,7 +134,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
Icon(Icons.calendar_today, size: 16), Icon(Icons.calendar_today, size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Expanded(child: Text('${DateFormat.yMMMd().format(date)} ${DateFormat.Hm().format(date)}')), 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}')),
], ],
), ),
), ),

View file

@ -2,12 +2,13 @@ import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:aves/image_fullscreen_overlay.dart'; import 'package:aves/image_fullscreen_overlay.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
class ImageFullscreenPage extends StatefulWidget { class ImageFullscreenPage extends StatefulWidget {
final List<Map> entries; final List<ImageEntry> entries;
final String initialUri; final String initialUri;
const ImageFullscreenPage({ const ImageFullscreenPage({
@ -27,12 +28,12 @@ class ImageFullscreenPageState extends State<ImageFullscreenPage> with SingleTic
AnimationController _overlayAnimationController; AnimationController _overlayAnimationController;
Animation<Offset> _topOverlayOffset, _bottomOverlayOffset; Animation<Offset> _topOverlayOffset, _bottomOverlayOffset;
List<Map> get entries => widget.entries; List<ImageEntry> get entries => widget.entries;
@override @override
void initState() { void initState() {
super.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); _currentPage = max(0, index);
_pageController = PageController(initialPage: _currentPage); _pageController = PageController(initialPage: _currentPage);
_overlayAnimationController = AnimationController( _overlayAnimationController = AnimationController(
@ -61,8 +62,8 @@ class ImageFullscreenPageState extends State<ImageFullscreenPage> with SingleTic
builder: (context, index) { builder: (context, index) {
final entry = entries[index]; final entry = entries[index];
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: FileImage(File(entry['path'])), imageProvider: FileImage(File(entry.path)),
heroTag: entry['uri'], heroTag: entry.uri,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => _overlayVisible.value = !_overlayVisible.value, onTapUp: (tapContext, details, value) => _overlayVisible.value = !_overlayVisible.value,

View file

@ -1,5 +1,6 @@
import 'package:aves/common/fake_app_bar.dart'; 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:aves/thumbnail_collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -29,13 +30,24 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
Future<List<Map>> _entryListLoader; static const EventChannel eventChannel = EventChannel('deckers.thibault/aves/mediastore');
List<ImageEntry> entries = List();
bool done = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
imageCache.maximumSizeBytes = 100 * 1024 * 1024; imageCache.maximumSizeBytes = 100 * 1024 * 1024;
_entryListLoader = ImageFetcher.getImageEntries(); eventChannel.receiveBroadcastStream().cast<Map>().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 @override
@ -43,18 +55,9 @@ class _HomePageState extends State<HomePage> {
return Scaffold( return Scaffold(
// fake app bar so that content is safe from status bar, even though we use a SliverAppBar // fake app bar so that content is safe from status bar, even though we use a SliverAppBar
appBar: FakeAppBar(), appBar: FakeAppBar(),
body: Container( body: ThumbnailCollection(
child: FutureBuilder( entries: entries,
future: _entryListLoader, done: done,
builder: (futureContext, AsyncSnapshot<List<Map>> snapshot) {
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
return ThumbnailCollection(entries: snapshot.data);
}
return Center(
child: CircularProgressIndicator(),
);
},
),
), ),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
); );

View file

@ -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', <String, dynamic>{
'title': 'Share via:',
'uri': uri,
'mimeType': mimeType,
});
} on PlatformException catch (e) {
debugPrint('share failed with exception=${e.message}');
}
}
}

View file

@ -1,28 +1,25 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class ImageFetcher { class ImageDecodeService {
static const platform = const MethodChannel('deckers.thibault.aves/mediastore'); static const platform = const MethodChannel('deckers.thibault/aves/image');
static Future<List<Map>> getImageEntries() async { static getImageEntries() async {
try { try {
final result = await platform.invokeMethod('getImageEntries'); await platform.invokeMethod('getImageEntries');
final entries = (result as List).cast<Map>();
debugPrint('getImageEntries found ${entries.length} entries');
return entries;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getImageEntries failed with exception=${e.message}'); debugPrint('getImageEntries failed with exception=${e.message}');
} }
return [];
} }
static Future<Uint8List> getImageBytes(Map entry, int width, int height) async { static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
debugPrint('getImageBytes with uri=${entry['uri']}'); debugPrint('getImageBytes with uri=${entry.uri}');
try { try {
final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{ final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{
'entry': entry, 'entry': entry.toMap(),
'width': width, 'width': width,
'height': height, 'height': height,
}); });
@ -55,16 +52,4 @@ class ImageFetcher {
} }
return Map(); return Map();
} }
static share(String uri, String mimeType) async {
try {
await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:',
'uri': uri,
'mimeType': mimeType,
});
} on PlatformException catch (e) {
debugPrint('share failed with exception=${e.message}');
}
}
} }

View file

@ -1,16 +1,78 @@
class ImageEntry { class ImageEntry {
static DateTime getBestDate(Map entry) { String uri;
final dateTakenMillis = entry['sourceDateTakenMillis'] as int; String path;
if (dateTakenMillis != null && dateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(dateTakenMillis); 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<String, dynamic> 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); if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000);
return null; return null;
} }
static DateTime getDayTaken(Map entry) { DateTime getDayTaken() {
final d = getBestDate(entry); final d = getBestDate();
return d == null ? null : DateTime(d.year, d.month, d.day); return d == null ? null : DateTime(d.year, d.month, d.day);
} }
} }

View file

@ -1,13 +1,14 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; 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:aves/model/mime_types.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:transparent_image/transparent_image.dart'; import 'package:transparent_image/transparent_image.dart';
class Thumbnail extends StatefulWidget { class Thumbnail extends StatefulWidget {
final Map entry; final ImageEntry entry;
final double extent; final double extent;
final double devicePixelRatio; final double devicePixelRatio;
@ -25,34 +26,31 @@ class Thumbnail extends StatefulWidget {
class ThumbnailState extends State<Thumbnail> { class ThumbnailState extends State<Thumbnail> {
Future<Uint8List> _byteLoader; Future<Uint8List> _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 @override
void initState() { void initState() {
super.initState(); super.initState();
// debugPrint('initState with uri=$uri entry=${widget.entry['path']}');
initByteLoader(); initByteLoader();
} }
@override @override
void didUpdateWidget(Thumbnail oldWidget) { void didUpdateWidget(Thumbnail oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (uri == oldWidget.entry['uri'] && widget.extent == oldWidget.extent) return; if (uri == oldWidget.entry.uri && widget.extent == oldWidget.extent) return;
// debugPrint('didUpdateWidget FROM uri=${oldWidget.entry['uri']} TO uri=$uri entry=${widget.entry['path']}');
initByteLoader(); initByteLoader();
} }
initByteLoader() { initByteLoader() {
final dim = (widget.extent * widget.devicePixelRatio).round(); final dim = (widget.extent * widget.devicePixelRatio).round();
_byteLoader = ImageFetcher.getImageBytes(widget.entry, dim, dim); _byteLoader = ImageDecodeService.getImageBytes(widget.entry, dim, dim);
} }
@override @override
void dispose() { void dispose() {
// debugPrint('dispose with uri=$uri entry=${widget.entry['path']}'); ImageDecodeService.cancelGetImageBytes(uri);
ImageFetcher.cancelGetImageBytes(uri);
super.dispose(); super.dispose();
} }

View file

@ -10,17 +10,26 @@ import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class ThumbnailCollection extends StatelessWidget { class ThumbnailCollection extends StatelessWidget {
final List<Map> entries; final List<ImageEntry> entries;
final Map<DateTime, List<Map>> sections; final bool done;
final Map<DateTime, List<ImageEntry>> sections;
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
ThumbnailCollection({Key key, this.entries}) ThumbnailCollection({Key key, this.entries, this.done})
: sections = groupBy(entries, ImageEntry.getDayTaken), : sections = groupBy(entries, (entry) => entry.getDayTaken()),
super(key: key); super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// debugPrint('$runtimeType build with sections=${sections.length}'); // debugPrint('$runtimeType build with sections=${sections.length}');
if (!done) {
return Center(
child: Text(
'streamed ${entries.length} items',
style: TextStyle(fontSize: 16),
),
);
}
return DraggableScrollbar.arrows( return DraggableScrollbar.arrows(
labelTextBuilder: (double offset) => Text( labelTextBuilder: (double offset) => Text(
"${offset ~/ 1}", "${offset ~/ 1}",
@ -46,8 +55,8 @@ class ThumbnailCollection extends StatelessWidget {
} }
class SectionSliver extends StatelessWidget { class SectionSliver extends StatelessWidget {
final List<Map> entries; final List<ImageEntry> entries;
final Map<DateTime, List<Map>> sections; final Map<DateTime, List<ImageEntry>> sections;
final DateTime sectionKey; final DateTime sectionKey;
const SectionSliver({ 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( return Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ImageFullscreenPage( builder: (context) => ImageFullscreenPage(
entries: entries, entries: entries,
initialUri: entry['uri'], initialUri: entry.uri,
), ),
), ),
); );