diff --git a/android/app/build.gradle b/android/app/build.gradle index 729249538..72c71687c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -54,6 +54,9 @@ flutter { } dependencies { + implementation 'com.github.bumptech.glide:glide:4.9.0' + annotationProcessor 'androidx.annotation:annotation:1.1.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' implementation 'com.karumi:dexter:5.0.0' testImplementation 'junit:junit:4.12' androidTestImplementation 'com.android.support.test:runner:1.0.2' diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index 5e18d389d..f3b5bbe56 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -1,24 +1,21 @@ package deckers.thibault.aves; import android.Manifest; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; -import android.content.ContentResolver; -import android.content.DialogInterface; import android.content.Intent; -import android.database.Cursor; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; import android.os.AsyncTask; -import android.os.Build; import android.os.Bundle; -import android.provider.MediaStore; import android.provider.Settings; import android.util.Log; -import android.util.Size; -import android.util.SparseArray; +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.karumi.dexter.Dexter; import com.karumi.dexter.PermissionToken; import com.karumi.dexter.listener.PermissionDeniedResponse; @@ -27,47 +24,42 @@ import com.karumi.dexter.listener.PermissionRequest; import com.karumi.dexter.listener.single.PermissionListener; import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.ByteBuffer; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.function.BiConsumer; import java.util.function.Consumer; -import java.util.function.Function; import java.util.stream.Collectors; import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.provider.MediaStoreImageProvider; import deckers.thibault.aves.utils.Utils; import io.flutter.app.FlutterActivity; -import io.flutter.plugins.GeneratedPluginRegistrant; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugins.GeneratedPluginRegistrant; class ThumbnailFetcher { - private ContentResolver contentResolver; - private SparseArray taskMap = new SparseArray<>(); + private Activity activity; + private HashMap taskMap = new HashMap<>(); - ThumbnailFetcher(ContentResolver contentResolver) { - this.contentResolver = contentResolver; + ThumbnailFetcher(Activity activity) { + this.activity = activity; } - void fetch (Integer id, Result result) { - AsyncTask task = new BitmapWorkerTask(contentResolver).execute(new BitmapWorkerTask.MyTaskParams(id, result, this::complete)); - taskMap.append(id, task); + 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(Integer id) { - AsyncTask task = taskMap.get(id, null); + void cancel(String uri) { + AsyncTask task = taskMap.get(uri); if (task != null) task.cancel(true); - taskMap.remove(id); + taskMap.remove(uri); } - void complete(Integer id) { - taskMap.remove(id); + private void complete(String uri) { + taskMap.remove(uri); } } @@ -81,7 +73,7 @@ public class MainActivity extends FlutterActivity { super.onCreate(savedInstanceState); GeneratedPluginRegistrant.registerWith(this); - thumbnailFetcher = new ThumbnailFetcher(getContentResolver()); + thumbnailFetcher = new ThumbnailFetcher(this); new MethodChannel(getFlutterView(), CHANNEL).setMethodCallHandler( (call, result) -> { switch (call.method) { @@ -89,13 +81,16 @@ public class MainActivity extends FlutterActivity { getPermissionResult(result, this); break; case "getThumbnail": { - Integer id = call.argument("id"); - thumbnailFetcher.fetch(id, result); + 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 "cancelGetThumbnail": { - Integer id = call.argument("id"); - thumbnailFetcher.cancel(id); + String uri = call.argument("uri"); + thumbnailFetcher.cancel(uri); result.success(null); break; } @@ -106,21 +101,6 @@ public class MainActivity extends FlutterActivity { }); } -// public void getImageThumbnail(final Result result, String uri, int width, int height) { -// // https://developer.android.com/reference/android/content/ContentResolver.html#loadThumbnail(android.net.Uri,%20android.util.Size,%20android.os.CancellationSignal) -// try { -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { -// Bitmap bmp = getContentResolver().loadThumbnail(Uri.parse(uri), new Size(width, height), null); -// result.success(bmp); -// } else { -// // TODO get by mediastore -// getContentResolver(). -// } -// } catch (IOException e) { -// e.printStackTrace(); -// } -// } - public void getPermissionResult(final Result result, final Activity activity) { Dexter.withActivity(activity) .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -137,47 +117,32 @@ public class MainActivity extends FlutterActivity { 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", new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int 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", new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - } + 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", new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - token.continuePermissionRequest(); - - } + builder.setPositiveButton("OK", (dialog, id) -> { + dialog.cancel(); + token.continuePermissionRequest(); }); - builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - token.cancelPermissionRequest(); - } + builder.setNegativeButton("Cancel", (dialog, id) -> { + dialog.cancel(); + token.cancelPermissionRequest(); }); AlertDialog alert = builder.create(); alert.show(); @@ -196,12 +161,15 @@ class BitmapWorkerTask extends AsyncTask complete; + Consumer complete; - MyTaskParams(Integer id, Result result, Consumer complete) { - this.id = id; + MyTaskParams(ImageEntry entry, int width, int height, Result result, Consumer complete) { + this.entry = entry; + this.width = width; + this.height = height; this.result = result; this.complete = complete; } @@ -217,26 +185,40 @@ class BitmapWorkerTask extends AsyncTask 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 (Exception e) { + e.printStackTrace(); } + Glide.with(activity).clear(target); } else { - Log.d(LOG_TAG, "getThumbnail with id=" + p.id + "(cancelled)"); + Log.d(LOG_TAG, "getThumbnail with uri=" + entry.getUri() + "(cancelled)"); } return new MyTaskResult(p, data); } @@ -244,11 +226,11 @@ class BitmapWorkerTask extends AsyncTask() {{ put("path", entry.path); put("contentId", entry.contentId); - put("uri", entry.uri); put("mimeType", entry.mimeType); put("width", entry.width); put("height", entry.height); @@ -73,6 +88,8 @@ public class ImageEntry { put("sourceDateTakenMillis", entry.sourceDateTakenMillis); put("bucketDisplayName", entry.bucketDisplayName); put("durationMillis", entry.durationMillis); + // + put("uri", entry.getUri().toString()); }}; } @@ -174,4 +191,11 @@ public class ImageEntry { public void setTitle(String title) { this.title = title; } + + // convenience method + + private static long toLong(Object o) { + if (o instanceof Integer) return Long.valueOf((Integer)o); + return (long)o; + } } diff --git a/android/build.gradle b/android/build.gradle index 1b3361691..6b1a639ef 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.4.1' + classpath 'com.android.tools.build:gradle:3.4.2' } } diff --git a/lib/image_fullscreen_page.dart b/lib/image_fullscreen_page.dart index cb959c9a3..effed491d 100644 --- a/lib/image_fullscreen_page.dart +++ b/lib/image_fullscreen_page.dart @@ -2,15 +2,15 @@ import 'package:aves/thumbnail.dart'; import 'package:flutter/material.dart'; class ImageFullscreenPage extends StatelessWidget { - final int id; + final Map entry; - ImageFullscreenPage({this.id}); + ImageFullscreenPage({this.entry}); @override Widget build(BuildContext context) { var width = MediaQuery.of(context).size.width; return Thumbnail( - id: id, + entry: entry, extent: width, ); } diff --git a/lib/main.dart b/lib/main.dart index 71549818f..ba07c9fa4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,8 @@ class MyApp extends StatelessWidget { class ImageFetcher { static const platform = const MethodChannel('deckers.thibault.aves/mediastore'); + static double devicePixelRatio; + static Future getImages() async { try { final result = await platform.invokeMethod('getImages'); @@ -35,10 +37,12 @@ class ImageFetcher { return []; } - static Future getThumbnail(int id) async { + static Future getThumbnail(Map entry, double width, double height) async { try { final result = await platform.invokeMethod('getThumbnail', { - 'id': id, + 'entry': entry, + 'width': (width * devicePixelRatio).round(), + 'height': (height * devicePixelRatio).round(), }); return result as Uint8List; } on PlatformException catch (e) { @@ -47,10 +51,10 @@ class ImageFetcher { return Uint8List(0); } - static cancelGetThumbnail(int id) async { + static cancelGetThumbnail(String uri) async { try { await platform.invokeMethod('cancelGetThumbnail', { - 'id': id, + 'uri': uri, }); } on PlatformException catch (e) { debugPrint('failed with exception=${e.message}'); @@ -76,13 +80,17 @@ class _MyHomePageState extends State { super.initState(); imageCache.maximumSizeBytes = 100 * 1024 * 1024; imageLoader = ImageFetcher.getImages(); + + WidgetsBinding.instance.addPostFrameCallback((duration) { + ImageFetcher.devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + debugPrint('$runtimeType devicePixelRatio=${ImageFetcher.devicePixelRatio}'); + }); } @override Widget build(BuildContext context) { var columnCount = 4; - var spacing = 1.0; - var extent = (MediaQuery.of(context).size.width - spacing * (columnCount - 1)) / columnCount; + var extent = MediaQuery.of(context).size.width / columnCount; debugPrint('MediaQuery.of(context).size=${MediaQuery.of(context).size} extent=$extent'); return Scaffold( appBar: AppBar( @@ -112,16 +120,13 @@ class _MyHomePageState extends State { controller: scrollController, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: extent, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, ), itemBuilder: (gridContext, index) { var imageEntryMap = imageEntryList[index] as Map; - var contentId = imageEntryMap['contentId']; return GestureDetector( onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (context) => ImageFullscreenPage(id: contentId)), + MaterialPageRoute(builder: (context) => ImageFullscreenPage(entry: imageEntryMap)), ), child: Container( decoration: BoxDecoration( @@ -131,7 +136,7 @@ class _MyHomePageState extends State { ), ), child: Thumbnail( - id: contentId, + entry: imageEntryMap, extent: extent, ), ), diff --git a/lib/mime_types.dart b/lib/mime_types.dart new file mode 100644 index 000000000..2e592b492 --- /dev/null +++ b/lib/mime_types.dart @@ -0,0 +1,6 @@ +class MimeTypes { + static const String MIME_VIDEO = "video"; + static const String MIME_JPEG = "image/jpeg"; + static const String MIME_PNG = "image/png"; + static const String MIME_GIF = "image/gif"; +} diff --git a/lib/thumbnail.dart b/lib/thumbnail.dart index 1928d7c0d..b0c420686 100644 --- a/lib/thumbnail.dart +++ b/lib/thumbnail.dart @@ -1,13 +1,14 @@ import 'dart:typed_data'; + import 'package:aves/main.dart'; +import 'package:aves/mime_types.dart'; +import 'package:flutter/material.dart'; import 'package:transparent_image/transparent_image.dart'; -import 'package:flutter/material.dart'; - class Thumbnail extends StatefulWidget { - Thumbnail({Key key, @required this.id, @required this.extent}) : super(key: key); + Thumbnail({Key key, @required this.entry, @required this.extent}) : super(key: key); - final int id; + final Map entry; final double extent; @override @@ -20,29 +21,48 @@ class ThumbnailState extends State { @override void initState() { super.initState(); - loader = ImageFetcher.getThumbnail(widget.id); + loader = ImageFetcher.getThumbnail(widget.entry, widget.extent, widget.extent); } @override void dispose() { - ImageFetcher.cancelGetThumbnail(widget.id); + ImageFetcher.cancelGetThumbnail(widget.entry['uri']); super.dispose(); } @override Widget build(BuildContext context) { + String mimeType = widget.entry['mimeType']; + var isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO); + var isGif = mimeType == MimeTypes.MIME_GIF; + var iconSize = widget.extent / 4; return FutureBuilder( future: loader, builder: (futureContext, AsyncSnapshot snapshot) { var ready = snapshot.connectionState == ConnectionState.done && !snapshot.hasError; Uint8List bytes = ready ? snapshot.data : kTransparentImage; return Hero( - tag: widget.id, - child: Image.memory( - bytes, - width: widget.extent, - height: widget.extent, - fit: BoxFit.cover, + tag: widget.entry['uri'], + child: Stack( + alignment: AlignmentDirectional.bottomStart, + children: [ + Image.memory( + bytes, + width: widget.extent, + height: widget.extent, + fit: BoxFit.cover, + ), + if (isVideo) + Icon( + Icons.play_circle_outline, + size: iconSize, + ), + if (isGif) + Icon( + Icons.gif, + size: iconSize, + ), + ], ), ); }); diff --git a/pubspec.yaml b/pubspec.yaml index 9aabd1408..487590f28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ description: A new Flutter application. version: 1.0.0+1 environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.2.2 <3.0.0" dependencies: flutter: