refactored method/event channels, use ImageEntry instead of Map
This commit is contained in:
parent
b9cc2c076c
commit
d63e560e7d
16 changed files with 536 additions and 361 deletions
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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}')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
18
lib/model/android_app_service.dart
Normal file
18
lib/model/android_app_service.dart
Normal 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}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue