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;
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<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);
}
}
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<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);
}
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<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.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<ImageEntry> fetchAll(Activity activity) {
public Stream<ImageEntry> fetchAll(Activity activity) {
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<>();
// 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();
}
}

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: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<Map> entries;
final List<ImageEntry> 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<Map> entries;
final List<ImageEntry> entries;
final int index;
const FullscreenBottomOverlay({Key key, this.entries, this.index}) : super(key: key);
@ -73,7 +74,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
Future<Map> _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<FullscreenBottomOverlay> {
}
initDetailLoader() {
_detailLoader = ImageFetcher.getOverlayMetadata(entry['path']);
_detailLoader = ImageDecodeService.getOverlayMetadata(entry.path);
}
@override
@ -96,7 +97,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
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<FullscreenBottomOverlay> {
SizedBox(
width: screenWidth,
child: Text(
entry['title'],
entry.title,
overflow: TextOverflow.ellipsis,
),
),
@ -133,7 +134,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
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}')),
],
),
),

View file

@ -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<Map> entries;
final List<ImageEntry> entries;
final String initialUri;
const ImageFullscreenPage({
@ -27,12 +28,12 @@ class ImageFullscreenPageState extends State<ImageFullscreenPage> with SingleTic
AnimationController _overlayAnimationController;
Animation<Offset> _topOverlayOffset, _bottomOverlayOffset;
List<Map> get entries => widget.entries;
List<ImageEntry> 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<ImageFullscreenPage> 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,

View file

@ -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<HomePage> {
Future<List<Map>> _entryListLoader;
static const EventChannel eventChannel = EventChannel('deckers.thibault/aves/mediastore');
List<ImageEntry> entries = List();
bool done = false;
@override
void initState() {
super.initState();
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
@ -43,18 +55,9 @@ class _HomePageState extends State<HomePage> {
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<List<Map>> 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,
);

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 '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<List<Map>> getImageEntries() async {
static getImageEntries() async {
try {
final result = await platform.invokeMethod('getImageEntries');
final entries = (result as List).cast<Map>();
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<Uint8List> getImageBytes(Map entry, int width, int height) async {
debugPrint('getImageBytes with uri=${entry['uri']}');
static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
debugPrint('getImageBytes with uri=${entry.uri}');
try {
final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{
'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', <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 {
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<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);
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);
}
}

View file

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

View file

@ -10,17 +10,26 @@ import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:intl/intl.dart';
class ThumbnailCollection extends StatelessWidget {
final List<Map> entries;
final Map<DateTime, List<Map>> sections;
final List<ImageEntry> entries;
final bool done;
final Map<DateTime, List<ImageEntry>> 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<Map> entries;
final Map<DateTime, List<Map>> sections;
final List<ImageEntry> entries;
final Map<DateTime, List<ImageEntry>> 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,
),
),
);