From 10be8b1f2e2b1a93a03a12607b4e606a670ff2a1 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 21 Jul 2019 12:22:01 +0900 Subject: [PATCH] fullscreen: work with uri/path & flutter image widget --- .../deckers/thibault/aves/MainActivity.java | 7 +- .../thibault/aves/model/ImageEntry.java | 34 +-- .../provider/MediaStoreImageProvider.java | 20 +- .../thibault/aves/utils/FileUtils.java | 211 ++++++++++++++++++ lib/image_fullscreen_page.dart | 79 ++++--- 5 files changed, 282 insertions(+), 69 deletions(-) create mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java 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 63d2a866f..d04465937 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -93,7 +93,8 @@ public class MainActivity extends FlutterActivity { case "cancelGetImageBytes": { String uri = call.argument("uri"); thumbnailFetcher.cancel(uri); - result.success(null); + // do not send `null`, as it closes the channel + result.success(""); break; } default: @@ -214,12 +215,14 @@ class BitmapWorkerTask extends AsyncTask() {{ + put("uri", entry.uri.toString()); put("path", entry.path); put("contentId", entry.contentId); put("mimeType", entry.mimeType); @@ -88,8 +84,6 @@ public class ImageEntry { put("sourceDateTakenMillis", entry.sourceDateTakenMillis); put("bucketDisplayName", entry.bucketDisplayName); put("durationMillis", entry.durationMillis); - // - put("uri", entry.getUri().toString()); }}; } @@ -97,6 +91,10 @@ public class ImageEntry { isVideo = mimeType.startsWith(Constants.MIME_VIDEO); } + public Uri getUri() { + return uri; + } + @Nullable public String getPath() { return path; @@ -106,18 +104,6 @@ public class ImageEntry { return path == null ? null : new File(path).getName(); } - public InputStream getInputStream(Context context) throws FileNotFoundException { - // FileInputStream is faster than input stream from ContentResolver - return path != null ? new FileInputStream(path) : context.getContentResolver().openInputStream(getUri()); - } - - public Uri getUri() { - if (uri != null) { - return uri; - } - return ContentUris.withAppendedId(mediaStoreContentUri, contentId); - } - public boolean isVideo() { return isVideo; } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 14511a055..da32f0320 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -1,6 +1,7 @@ package deckers.thibault.aves.model.provider; import android.app.Activity; +import android.content.ContentUris; import android.database.Cursor; import android.net.Uri; import android.provider.MediaStore; @@ -39,22 +40,24 @@ public class MediaStoreImageProvider { public List fetchAll(Activity activity) { - return fetch(activity, FILES_URI, null); + return fetch(activity, FILES_URI); } - public List fetch(final Activity activity, final Uri uri, String mimeType) { + private List fetch(final Activity activity, final Uri queryUri) { ArrayList entries = new ArrayList<>(); // URI should refer to the "files" table, not to the "images" or "videos" one, // as our projection includes a mix of columns from both - Uri filesUri = uri; - if (!FILES_URI.equals(uri)) { - String id = uri.getLastPathSegment(); + Uri filesUri = queryUri; + if (!FILES_URI.equals(queryUri)) { + String id = queryUri.getLastPathSegment(); filesUri = Uri.withAppendedPath(FILES_URI, id); } + String orderBy = MediaStore.Images.ImageColumns.DATE_TAKEN + " DESC"; + try { - Cursor cursor = activity.getContentResolver().query(filesUri, PROJECTION, SELECTION, null, null); + Cursor cursor = activity.getContentResolver().query(filesUri, PROJECTION, SELECTION, null, orderBy); if (cursor != null) { Log.d(LOG_TAG, "fetch query returned " + cursor.getCount() + " entries"); @@ -72,9 +75,12 @@ public class MediaStoreImageProvider { int durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION); while (cursor.moveToNext()) { + long contentId = cursor.getLong(idColumn); + Uri itemUri = ContentUris.withAppendedId(FILES_URI, contentId); ImageEntry imageEntry = new ImageEntry( + itemUri, cursor.getString(pathColumn), - cursor.getLong(idColumn), + contentId, cursor.getString(mimeTypeColumn), cursor.getInt(widthColumn), cursor.getInt(heightColumn), diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java new file mode 100644 index 000000000..f65b525d3 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2007-2008 OpenIntents.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This file was modified by the Flutter authors from the following original file: + * https://raw.githubusercontent.com/iPaulPro/aFileChooser/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java + */ + +// TLAD: copied from https://raw.githubusercontent.com/flutter/plugins/master/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java + +package deckers.thibault.aves.utils; + +import android.annotation.SuppressLint; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class FileUtils { + + public String getPathFromUri(final Context context, final Uri uri) { + String path = getPathFromLocalUri(context, uri); + if (path == null) { + path = getPathFromRemoteUri(context, uri); + } + return path; + } + + @SuppressLint("NewApi") + private String getPathFromLocalUri(final Context context, final Uri uri) { + final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + + if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { + if (isExternalStorageDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + if ("primary".equalsIgnoreCase(type)) { + return Environment.getExternalStorageDirectory() + "/" + split[1]; + } + } else if (isDownloadsDocument(uri)) { + final String id = DocumentsContract.getDocumentId(uri); + + if (!TextUtils.isEmpty(id)) { + try { + final Uri contentUri = + ContentUris.withAppendedId( + Uri.parse(Environment.DIRECTORY_DOWNLOADS), Long.valueOf(id)); + return getDataColumn(context, contentUri, null, null); + } catch (NumberFormatException e) { + return null; + } + } + + } else if (isMediaDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + Uri contentUri = null; + if ("image".equals(type)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if ("video".equals(type)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else if ("audio".equals(type)) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + final String selection = "_id=?"; + final String[] selectionArgs = new String[] {split[1]}; + + return getDataColumn(context, contentUri, selection, selectionArgs); + } + } else if ("content".equalsIgnoreCase(uri.getScheme())) { + + // Return the remote address + if (isGooglePhotosUri(uri)) { + return null; + } + + return getDataColumn(context, uri, null, null); + } else if ("file".equalsIgnoreCase(uri.getScheme())) { + return uri.getPath(); + } + + return null; + } + + private static String getDataColumn( + Context context, Uri uri, String selection, String[] selectionArgs) { + + final String column = "_data"; + final String[] projection = {column}; + try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) { + if (cursor != null && cursor.moveToFirst()) { + final int column_index = cursor.getColumnIndex(column); + + //yandex.disk and dropbox do not have _data column + if (column_index == -1) { + return null; + } + + return cursor.getString(column_index); + } + } + return null; + } + + private static String getPathFromRemoteUri(final Context context, final Uri uri) { + // The code below is why Java now has try-with-resources and the Files utility. + File file = null; + InputStream inputStream = null; + OutputStream outputStream = null; + boolean success = false; + try { + String extension = getImageExtension(context, uri); + inputStream = context.getContentResolver().openInputStream(uri); + file = File.createTempFile("image_picker", extension, context.getCacheDir()); + outputStream = new FileOutputStream(file); + if (inputStream != null) { + copy(inputStream, outputStream); + success = true; + } + } catch (IOException ignored) { + } finally { + try { + if (inputStream != null) inputStream.close(); + } catch (IOException ignored) { + } + try { + if (outputStream != null) outputStream.close(); + } catch (IOException ignored) { + // If closing the output stream fails, we cannot be sure that the + // target file was written in full. Flushing the stream merely moves + // the bytes into the OS, not necessarily to the file. + success = false; + } + } + return success ? file.getPath() : null; + } + + /** @return extension of image with dot, or default .jpg if it none. */ + private static String getImageExtension(Context context, Uri uriImage) { + String extension = null; + + try (Cursor cursor = context + .getContentResolver() + .query(uriImage, new String[]{MediaStore.MediaColumns.MIME_TYPE}, null, null, null)) { + + if (cursor != null && cursor.moveToNext()) { + String mimeType = cursor.getString(0); + + extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + } + } + + if (extension == null) { + //default extension for matches the previous behavior of the plugin + extension = "jpg"; + } + return "." + extension; + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + final byte[] buffer = new byte[4 * 1024]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.flush(); + } + + private static boolean isExternalStorageDocument(Uri uri) { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + private static boolean isDownloadsDocument(Uri uri) { + return "com.android.providers.downloads.documents".equals(uri.getAuthority()); + } + + private static boolean isMediaDocument(Uri uri) { + return "com.android.providers.media.documents".equals(uri.getAuthority()); + } + + private static boolean isGooglePhotosUri(Uri uri) { + return "com.google.android.apps.photos.contentprovider".equals(uri.getAuthority()); + } +} diff --git a/lib/image_fullscreen_page.dart b/lib/image_fullscreen_page.dart index 60b4d064d..4c5f3da4d 100644 --- a/lib/image_fullscreen_page.dart +++ b/lib/image_fullscreen_page.dart @@ -1,8 +1,9 @@ +import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; -import 'package:aves/model/image_fetcher.dart'; import 'package:flutter/material.dart'; +import 'package:transparent_image/transparent_image.dart'; class ImageFullscreenPage extends StatefulWidget { final Map entry; @@ -15,14 +16,16 @@ class ImageFullscreenPage extends StatefulWidget { } class ImageFullscreenPageState extends State { - Future loader; - int get imageWidth => widget.entry['width']; int get imageHeight => widget.entry['height']; String get uri => widget.entry['uri']; + String get path => widget.entry['path']; + + double requestWidth, requestHeight; + @override void initState() { super.initState(); @@ -30,53 +33,57 @@ class ImageFullscreenPageState extends State { @override void dispose() { - ImageFetcher.cancelGetImageBytes(uri); super.dispose(); } @override Widget build(BuildContext context) { - if (loader == null) { + if (requestWidth == null || requestHeight == null) { var mediaQuery = MediaQuery.of(context); var screenSize = mediaQuery.size; var dpr = mediaQuery.devicePixelRatio; - var requestWidth = imageWidth * dpr; - var requestHeight = imageHeight * dpr; + requestWidth = imageWidth * dpr; + requestHeight = imageHeight * dpr; if (imageWidth > screenSize.width || imageHeight > screenSize.height) { var ratio = max(imageWidth / screenSize.width, imageHeight / screenSize.height); requestWidth /= ratio; requestHeight /= ratio; } - loader = ImageFetcher.getImageBytes(widget.entry, requestWidth.round(), requestHeight.round()); } - return FutureBuilder( - future: loader, - builder: (futureContext, AsyncSnapshot snapshot) { - var ready = snapshot.connectionState == ConnectionState.done && !snapshot.hasError; - return Hero( - tag: uri, - child: Stack( - children: [ - Center( - child: widget.thumbnail == null - ? CircularProgressIndicator() - : Image.memory( - widget.thumbnail, - width: imageWidth.toDouble(), - height: imageHeight.toDouble(), - fit: BoxFit.contain, - ), + return MediaQuery.removeViewInsets( + context: context, + // remove bottom view insets to paint underneath the translucent navigation bar + removeBottom: true, + child: Scaffold( + body: Hero( + tag: uri, + child: Stack( + children: [ + Center( + child: widget.thumbnail == null + ? CircularProgressIndicator() + : Image.memory( + widget.thumbnail, + width: requestWidth, + height: requestHeight, + fit: BoxFit.contain, + ), + ), + Center( + child: FadeInImage( + placeholder: MemoryImage(kTransparentImage), + image: FileImage(File(path)), + fadeOutDuration: Duration(milliseconds: 1), + fadeInDuration: Duration(milliseconds: 200), + width: requestWidth, + height: requestHeight, + fit: BoxFit.contain, ), - if (ready) - Image.memory( - snapshot.data, - width: imageWidth.toDouble(), - height: imageHeight.toDouble(), - fit: BoxFit.contain, - ), - ], - ), - ); - }); + ), + ], + ), + ), + ), + ); } }