From 0c8318444b72e7e3fc11521ddebd154581eba637 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 16 Aug 2019 01:20:09 +0900 Subject: [PATCH] fullscreen: added rotate action --- .../deckers/thibault/aves/MainActivity.java | 4 +- ...codeHandler.java => ImageFileHandler.java} | 38 +++- .../aves/channelhandlers/MetadataHandler.java | 4 +- .../aves/model/provider/ImageProvider.java | 165 +++++++++++++++++- .../thibault/aves/utils/Constants.java | 4 +- .../java/deckers/thibault/aves/utils/Env.java | 6 +- .../thibault/aves/utils/MetadataHelper.java | 74 ++++++++ .../thibault/aves/utils/StorageUtils.java | 31 ++++ .../deckers/thibault/aves/utils/Utils.java | 74 +++----- lib/model/image_entry.dart | 22 ++- lib/model/image_file_service.dart | 14 ++ lib/widgets/album/thumbnail.dart | 94 ++++++---- lib/widgets/fullscreen/image_page.dart | 89 ++++++---- lib/widgets/fullscreen/overlay_top.dart | 44 ++++- 14 files changed, 515 insertions(+), 148 deletions(-) rename android/app/src/main/java/deckers/thibault/aves/channelhandlers/{ImageDecodeHandler.java => ImageFileHandler.java} (81%) create mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.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 0273b7443..1494c95bd 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -5,7 +5,7 @@ import android.net.Uri; import android.os.Bundle; import deckers.thibault.aves.channelhandlers.AppAdapterHandler; -import deckers.thibault.aves.channelhandlers.ImageDecodeHandler; +import deckers.thibault.aves.channelhandlers.ImageFileHandler; import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler; import deckers.thibault.aves.channelhandlers.MetadataHandler; import deckers.thibault.aves.utils.Constants; @@ -27,7 +27,7 @@ public class MainActivity extends FlutterActivity { FlutterView messenger = getFlutterView(); new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this)); - new MethodChannel(messenger, ImageDecodeHandler.CHANNEL).setMethodCallHandler(new ImageDecodeHandler(this, mediaStoreStreamHandler)); + new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java similarity index 81% rename from android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java rename to android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java index 973e9122f..47a68f0e6 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java @@ -26,14 +26,14 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; -public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { +public class ImageFileHandler 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) { + public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) { this.activity = activity; imageDecodeTaskManager = new ImageDecodeTaskManager(activity); this.mediaStoreStreamHandler = mediaStoreStreamHandler; @@ -54,6 +54,9 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { case "rename": rename(call, result); break; + case "rotate": + rotate(call, result); + break; default: result.notImplemented(); break; @@ -94,7 +97,7 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { result.error("rename-provider", "failed to find provider for uri=" + uri, null); return; } - provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() { + provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() { @Override public void onSuccess(Map newFields) { new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); @@ -107,6 +110,35 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { }); } + private void rotate(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + Map map = call.argument("entry"); + Boolean clockwise = call.argument("clockwise"); + if (map == null || clockwise == null) { + result.error("rotate-args", "failed because of missing arguments", null); + return; + } + Uri uri = Uri.parse((String) map.get("uri")); + String path = (String) map.get("path"); + String mimeType = (String) map.get("mimeType"); + + ImageProvider provider = ImageProviderFactory.getProvider(uri); + if (provider == null) { + result.error("rotate-provider", "failed to find provider for uri=" + uri, null); + return; + } + provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() { + @Override + public void onSuccess(Map newFields) { + new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); + } + + @Override + public void onFailure() { + new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", null)); + } + }); + } + private void getPermissionResult(final MethodChannel.Result result, final Activity activity) { Dexter.withActivity(activity) .withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index 0009b7907..f68c57b37 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -30,7 +30,7 @@ import java.util.Map; import java.util.TimeZone; import deckers.thibault.aves.utils.Constants; -import deckers.thibault.aves.utils.Utils; +import deckers.thibault.aves.utils.MetadataHelper; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -200,7 +200,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { retriever.release(); if (dateString != null) { - long dateMillis = Utils.parseVideoMetadataDate(dateString); + long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString); // some videos have an invalid default date (19040101T000000.000Z) that is before Epoch time if (dateMillis > 0) { metadataMap.put("dateMillis", dateMillis); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 16e7f92bb..946f98a0b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -2,17 +2,29 @@ package deckers.thibault.aves.model.provider; import android.app.Activity; import android.content.ContentUris; +import android.content.ContentValues; import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.media.ExifInterface; import android.media.MediaScannerConnection; import android.net.Uri; +import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.util.Log; import java.io.File; +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; import java.util.HashMap; import java.util.Map; +import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Env; +import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.Utils; @@ -20,7 +32,7 @@ import deckers.thibault.aves.utils.Utils; public abstract class ImageProvider { private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); - public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final RenameCallback callback) { + public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { if (oldPath == null) { Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri); callback.onFailure(); @@ -40,10 +52,7 @@ public abstract class ImageProvider { // From KitKat, we need access permission from the Document Provider, at the file level. // From Lollipop, we can request the permission at the SD card root level. boolean renamed; - if (!Env.isOnSdCard(activity, oldPath)) { - // rename with File - renamed = oldFile.renameTo(newFile); - } else { + if (Env.isOnSdCard(activity, oldPath)) { // rename with DocumentFile Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); if (sdCardTreeUri == null) { @@ -52,6 +61,9 @@ public abstract class ImageProvider { return; } renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename); + } else { + // rename with File + renamed = oldFile.renameTo(newFile); } if (!renamed) { @@ -89,7 +101,148 @@ public abstract class ImageProvider { }); } - public interface RenameCallback { + public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { + switch (mimeType) { + case Constants.MIME_JPEG: + rotateJpeg(activity, path, uri, mimeType, clockwise, callback); + break; + case Constants.MIME_PNG: + rotatePng(activity, path, uri, mimeType, clockwise, callback); + break; + default: + callback.onFailure(); + } + } + + private void rotateJpeg(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) { + String editablePath = path; + boolean onSdCard = Env.isOnSdCard(activity, path); + if (onSdCard) { + if (PermissionManager.getSdCardTreeUri(activity) == null) { + Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback); + PermissionManager.showSdCardAccessDialog(activity, runnable); + return; + } + // copy original file to a temporary file for editing + editablePath = StorageUtils.copyFileToTemp(path); + } + + if (editablePath == null) { + callback.onFailure(); + return; + } + + boolean rotated = false; + int newOrientationCode = 0; + try { + ExifInterface exif = new ExifInterface(editablePath); + switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + case ExifInterface.ORIENTATION_ROTATE_90: + newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_180 : ExifInterface.ORIENTATION_NORMAL; + break; + case ExifInterface.ORIENTATION_ROTATE_180: + newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_270 : ExifInterface.ORIENTATION_ROTATE_90; + break; + case ExifInterface.ORIENTATION_ROTATE_270: + newOrientationCode = clockwise ? ExifInterface.ORIENTATION_NORMAL : ExifInterface.ORIENTATION_ROTATE_180; + break; + default: + newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_90 : ExifInterface.ORIENTATION_ROTATE_270; + break; + } + exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(newOrientationCode)); + exif.saveAttributes(); + + // if the image is on the SD card, copy the edited temporary file to the original DocumentFile + rotated = !onSdCard || StorageUtils.writeToDocumentFile(activity, editablePath, uri); + } catch (IOException e) { + Log.w(LOG_TAG, "failed to edit EXIF to rotate image at path=" + path, e); + } + if (!rotated) { + callback.onFailure(); + return; + } + + // update fields in media store + ContentValues values = new ContentValues(); + int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode); + values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees); + if (activity.getContentResolver().update(uri, values, null, null) > 0) { + MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> { + Map newFields = new HashMap<>(); + newFields.put("orientationDegrees", orientationDegrees); + callback.onSuccess(newFields); + }); + } + } + + private void rotatePng(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) { + if (path == null) { + callback.onFailure(); + return; + } + + boolean onSdCard = Env.isOnSdCard(activity, path); + if (onSdCard && PermissionManager.getSdCardTreeUri(activity) == null) { + Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback); + PermissionManager.showSdCardAccessDialog(activity, runnable); + return; + } + + Bitmap originalImage = BitmapFactory.decodeFile(path); + Matrix matrix = new Matrix(); + int originalWidth = originalImage.getWidth(); + int originalHeight = originalImage.getHeight(); + matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1); + Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true); + + boolean rotated = false; + if (onSdCard) { + FileDescriptor fd = null; + try { + ParcelFileDescriptor pfd = activity.getContentResolver().openFileDescriptor(uri, "rw"); + if (pfd != null) fd = pfd.getFileDescriptor(); + } catch (FileNotFoundException e) { + Log.w(LOG_TAG, "failed to get file descriptor for document at uri=" + path, e); + } + if (fd != null) { + try (FileOutputStream fos = new FileOutputStream(fd)) { + rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos); + rotated = true; + } catch (IOException e) { + Log.w(LOG_TAG, "failed to save rotated image to document at uri=" + path, e); + } + } + } else { + try (FileOutputStream fos = new FileOutputStream(path)) { + rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos); + rotated = true; + } catch (IOException e) { + Log.w(LOG_TAG, "failed to save rotated image to path=" + path, e); + } + } + if (!rotated) { + callback.onFailure(); + return; + } + + // update fields in media store + @SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight; + @SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth; + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth); + values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight); + if (activity.getContentResolver().update(uri, values, null, null) > 0) { + MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> { + Map newFields = new HashMap<>(); + newFields.put("width", rotatedWidth); + newFields.put("height", rotatedHeight); + callback.onSuccess(newFields); + }); + } + } + + public interface ImageOpCallback { void onSuccess(Map newFields); void onFailure(); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java index 94aaea67d..6d2bdc816 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java @@ -10,9 +10,11 @@ public class Constants { // mime types - public static final String MIME_VIDEO = "video"; public static final String MIME_GIF = "image/gif"; + public static final String MIME_JPEG = "image/jpeg"; + public static final String MIME_PNG = "image/png"; public static final String MIME_MP2TS = "video/mp2ts"; + public static final String MIME_VIDEO = "video"; // video metadata keys, from android.media.MediaMetadataRetriever diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Env.java b/android/app/src/main/java/deckers/thibault/aves/utils/Env.java index 5c26a1266..f44f075d2 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Env.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Env.java @@ -5,8 +5,6 @@ import android.content.Context; import android.content.SharedPreferences; import android.os.Environment; -import androidx.annotation.NonNull; - public class Env { private static String[] mStorageVolumes; private static String mExternalStorage; @@ -45,7 +43,7 @@ public class Env { return mExternalStorage; } - public static boolean isOnSdCard(final Activity activity, @NonNull String path) { - return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage()); + public static boolean isOnSdCard(final Activity activity, String path) { + return path != null && !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage()); } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java b/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java new file mode 100644 index 000000000..1ef3a36c6 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java @@ -0,0 +1,74 @@ +package deckers.thibault.aves.utils; + +import android.media.ExifInterface; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MetadataHelper { + + // interpret EXIF code to angle (0, 90, 180 or 270 degrees) + public static int getOrientationDegreesForExifCode(int exifOrientation) { + switch (exifOrientation) { + case ExifInterface.ORIENTATION_ROTATE_180: // bottom, right side + return 180; + case ExifInterface.ORIENTATION_ROTATE_90: // right side, top + return 90; + case ExifInterface.ORIENTATION_ROTATE_270: // left side, bottom + return 270; + } + // all other orientations (regular, flipped...) default to an angle of 0 degree + return 0; + } + + // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? + public static long parseVideoMetadataDate(String dateString) { + // optional sub-second + String subSecond = null; + Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString); + if (subSecondMatcher.find()) { + subSecond = subSecondMatcher.group(2).substring(1); + dateString = subSecondMatcher.replaceAll("$1"); + } + + // optional time zone + TimeZone timeZone = null; + Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString); + if (timeZoneMatcher.find()) { + timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", "")); + dateString = timeZoneMatcher.replaceAll(""); + } + + Date date = null; + try { + DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US); + parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT")); + date = parser.parse(dateString); + } catch (ParseException ex) { + // ignore + } + + if (date == null) { + return 0; + } + + long dateMillis = date.getTime(); + if (subSecond != null) { + try { + int millis = (int) (Double.parseDouble("." + subSecond) * 1000); + if (millis >= 0 && millis < 1000) { + dateMillis += millis; + } + } catch (NumberFormatException e) { + // ignore + } + } + return dateMillis; + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index 2a80fa782..c650a194d 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -5,8 +5,10 @@ import android.content.Context; import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.ParcelFileDescriptor; import android.text.TextUtils; import android.util.Log; +import android.webkit.MimeTypeMap; import androidx.documentfile.provider.DocumentFile; @@ -14,6 +16,7 @@ import com.google.common.base.Splitter; import com.google.common.collect.Lists; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -164,6 +167,34 @@ public class StorageUtils { return found && documentFile != null ? Optional.of(documentFile) : Optional.empty(); } + public static String copyFileToTemp(String path) { + try { + String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString()); + File temp = File.createTempFile("aves", '.' + extension); + Utils.copyFile(new File(path), temp); + temp.deleteOnExit(); + return temp.getPath(); + } catch (IOException e) { + Log.w(LOG_TAG, "failed to copy file at path=" + path); + } + return null; + } + + public static boolean writeToDocumentFile(Context context, String from, Uri documentUri) { + try { + ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(documentUri, "rw"); + if (pfd == null) { + Log.w(LOG_TAG, "failed to get file descriptor for documentUri=" + documentUri); + return false; + } + Utils.copyFile(new File(from), pfd.getFileDescriptor()); + return true; + } catch (IOException e) { + Log.w(LOG_TAG, "failed to write to DocumentFile at documentUri=" + documentUri); + } + return false; + } + /** * Delete the specified file on SD card * Note that it does not update related content providers such as the Media Store. diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java b/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java index 27eae8ebd..9dd458567 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java @@ -1,12 +1,11 @@ package deckers.thibault.aves.utils; -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.TimeZone; -import java.util.regex.Matcher; +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; import java.util.regex.Pattern; public class Utils { @@ -29,48 +28,27 @@ public class Utils { return logTag; } - // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? - public static long parseVideoMetadataDate(String dateString) { - // optional sub-second - String subSecond = null; - Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString); - if (subSecondMatcher.find()) { - subSecond = subSecondMatcher.group(2).substring(1); - dateString = subSecondMatcher.replaceAll("$1"); - } - - // optional time zone - TimeZone timeZone = null; - Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString); - if (timeZoneMatcher.find()) { - timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", "")); - dateString = timeZoneMatcher.replaceAll(""); - } - - Date date = null; - try { - DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US); - parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT")); - date = parser.parse(dateString); - } catch (ParseException ex) { - // ignore - } - - if (date == null) { - return 0; - } - - long dateMillis = date.getTime(); - if (subSecond != null) { - try { - int millis = (int) (Double.parseDouble("." + subSecond) * 1000); - if (millis >= 0 && millis < 1000) { - dateMillis += millis; - } - } catch (NumberFormatException e) { - // ignore + public static void copyFile(final File source, final FileDescriptor descriptor) throws IOException { + try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(descriptor)) { + final FileChannel inChannel = inStream.getChannel(); + final FileChannel outChannel = outStream.getChannel(); + final long size = inChannel.size(); + long position = 0; + while (position < size) { + position += inChannel.transferTo(position, 1024L * 1024L, outChannel); + } + } + } + + public static void copyFile(final File source, final File destination) throws IOException { + try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(destination)) { + final FileChannel inChannel = inStream.getChannel(); + final FileChannel outChannel = outStream.getChannel(); + final long size = inChannel.size(); + long position = 0; + while (position < size) { + position += inChannel.transferTo(position, 1024L * 1024L, outChannel); } } - return dateMillis; } } \ No newline at end of file diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 2a1cfd1be..eca6e4a73 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -15,9 +15,9 @@ class ImageEntry with ChangeNotifier { String path; int contentId; final String mimeType; - final int width; - final int height; - final int orientationDegrees; + int width; + int height; + int orientationDegrees; final int sizeBytes; String title; final int dateModifiedSecs; @@ -205,4 +205,20 @@ class ImageEntry with ChangeNotifier { notifyListeners(); return true; } + + bool get canRotate => mimeType == MimeTypes.MIME_JPEG || mimeType == MimeTypes.MIME_PNG; + + Future rotate({@required bool clockwise}) async { + final newFields = await ImageFileService.rotate(this, clockwise: clockwise); + if (newFields.isEmpty) return false; + + final width = newFields['width']; + if (width != null) this.width = width; + final height = newFields['height']; + if (height != null) this.height = height; + final orientationDegrees = newFields['orientationDegrees']; + if (orientationDegrees != null) this.orientationDegrees = orientationDegrees; + notifyListeners(); + return true; + } } diff --git a/lib/model/image_file_service.dart b/lib/model/image_file_service.dart index f66c10577..713788b5f 100644 --- a/lib/model/image_file_service.dart +++ b/lib/model/image_file_service.dart @@ -55,4 +55,18 @@ class ImageFileService { } return Map(); } + + static Future rotate(ImageEntry entry, {@required bool clockwise}) async { + try { + // return map with: 'width' 'height' 'orientationDegrees' (all optional) + final result = await platform.invokeMethod('rotate', { + 'entry': entry.toMap(), + 'clockwise': clockwise, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('rotate failed with exception=${e.message}'); + } + return Map(); + } } diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index a0fc41d02..f6b1c1a27 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -33,13 +33,14 @@ class ThumbnailState extends State { @override void initState() { super.initState(); + entry.addListener(onEntryChange); initByteLoader(); } @override void didUpdateWidget(Thumbnail oldWidget) { super.didUpdateWidget(oldWidget); - if (uri == oldWidget.entry.uri && widget.extent == oldWidget.extent) return; + if (widget.extent == oldWidget.extent && uri == oldWidget.entry.uri && widget.entry.width == oldWidget.entry.width && widget.entry.height == oldWidget.entry.height && widget.entry.orientationDegrees == oldWidget.entry.orientationDegrees) return; initByteLoader(); } @@ -48,8 +49,11 @@ class ThumbnailState extends State { _byteLoader = ImageFileService.getImageBytes(widget.entry, dim, dim); } + onEntryChange() => setState(() => initByteLoader()); + @override void dispose() { + entry.removeListener(onEntryChange); ImageFileService.cancelGetImageBytes(uri); super.dispose(); } @@ -74,43 +78,61 @@ class ThumbnailState extends State { future: _byteLoader, builder: (futureContext, AsyncSnapshot snapshot) { final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; - return Stack( - alignment: AlignmentDirectional.bottomStart, - children: [ - Hero( - tag: uri, - child: LayoutBuilder(builder: (context, constraints) { - // during hero animation back from a fullscreen image, - // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) - // so we wrap the image to apply better constraints - final dim = min(constraints.maxWidth, constraints.maxHeight); - return Container( - alignment: Alignment.center, - constraints: BoxConstraints.tight(Size(dim, dim)), - child: bytes.length > 0 - ? Image.memory( - bytes, - width: dim, - height: dim, - fit: BoxFit.cover, - ) - : Icon(Icons.error), - ); - }), - ), - if (entry.isVideo) - VideoTag( - entry: entry, - iconSize: iconSize, - ) - else if (entry.isGif) - GifTag(iconSize: iconSize) - else if (entry.hasGps) - GpsTag(iconSize: iconSize) - ], - ); + return ThumbnailImage(entry: entry, bytes: bytes, iconSize: iconSize); }), ), ); } } + +class ThumbnailImage extends StatelessWidget { + final Uint8List bytes; + final ImageEntry entry; + final double iconSize; + + const ThumbnailImage({ + Key key, + @required this.bytes, + @required this.entry, + @required this.iconSize, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: AlignmentDirectional.bottomStart, + children: [ + Hero( + tag: entry.uri, + child: LayoutBuilder(builder: (context, constraints) { + // during hero animation back from a fullscreen image, + // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) + // so we wrap the image to apply better constraints + final dim = min(constraints.maxWidth, constraints.maxHeight); + return Container( + alignment: Alignment.center, + constraints: BoxConstraints.tight(Size(dim, dim)), + child: bytes.length > 0 + ? Image.memory( + bytes, + width: dim, + height: dim, + fit: BoxFit.cover, + ) + : Icon(Icons.error), + ); + }), + ), + if (entry.isVideo) + VideoTag( + entry: entry, + iconSize: iconSize, + ) + else if (entry.isGif) + GifTag(iconSize: iconSize) + else if (entry.hasGps) + GpsTag(iconSize: iconSize) + ], + ); + } +} diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 9f98ed8f8..a3c7ec355 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -213,6 +213,56 @@ class FullscreenBodyState extends State with SingleTickerProvide } } + onActionSelected(ImageEntry entry, FullscreenAction action) { + switch (action) { + case FullscreenAction.edit: + AndroidAppService.edit(entry.uri, entry.mimeType); + break; + case FullscreenAction.info: + goToVerticalPage(1); + break; + case FullscreenAction.rename: + showRenameDialog(entry); + break; + case FullscreenAction.open: + AndroidAppService.open(entry.uri, entry.mimeType); + break; + case FullscreenAction.openMap: + AndroidAppService.openMap(entry.geoUri); + break; + case FullscreenAction.rotateCCW: + rotate(entry, clockwise: false); + break; + case FullscreenAction.rotateCW: + rotate(entry, clockwise: true); + break; + case FullscreenAction.setAs: + AndroidAppService.setAs(entry.uri, entry.mimeType); + break; + case FullscreenAction.share: + AndroidAppService.share(entry.uri, entry.mimeType); + break; + } + } + + showFeedback(String message) { + Flushbar( + message: message, + margin: EdgeInsets.all(8), + borderRadius: 8, + borderColor: Colors.white30, + borderWidth: 0.5, + duration: Duration(seconds: 2), + flushbarPosition: FlushbarPosition.TOP, + animationDuration: Duration(milliseconds: 600), + )..show(context); + } + + rotate(ImageEntry entry, {@required bool clockwise}) async { + final success = await entry.rotate(clockwise: clockwise); + showFeedback(success ? 'Done!' : 'Failed'); + } + showRenameDialog(ImageEntry entry) async { final currentName = entry.title; final controller = TextEditingController(text: currentName); @@ -238,46 +288,11 @@ class FullscreenBodyState extends State with SingleTickerProvide }); if (newName == null || newName.isEmpty) return; final success = await entry.rename(newName); - Flushbar( - message: success ? 'Done!' : 'Failed', - margin: EdgeInsets.all(8), - borderRadius: 8, - borderColor: Colors.white30, - borderWidth: 0.5, - duration: Duration(seconds: 2), - flushbarPosition: FlushbarPosition.TOP, - animationDuration: Duration(milliseconds: 600), - )..show(context); - } - - onActionSelected(ImageEntry entry, FullscreenAction action) { - switch (action) { - case FullscreenAction.edit: - AndroidAppService.edit(entry.uri, entry.mimeType); - break; - case FullscreenAction.info: - goToVerticalPage(1); - break; - case FullscreenAction.rename: - showRenameDialog(entry); - break; - case FullscreenAction.open: - AndroidAppService.open(entry.uri, entry.mimeType); - break; - case FullscreenAction.openMap: - AndroidAppService.openMap(entry.geoUri); - break; - case FullscreenAction.setAs: - AndroidAppService.setAs(entry.uri, entry.mimeType); - break; - case FullscreenAction.share: - AndroidAppService.share(entry.uri, entry.mimeType); - break; - } + showFeedback(success ? 'Done!' : 'Failed'); } } -enum FullscreenAction { edit, info, open, openMap, rename, setAs, share } +enum FullscreenAction { edit, info, open, openMap, rename, rotateCCW, rotateCW, setAs, share } class ImagePage extends StatefulWidget { final List entries; diff --git a/lib/widgets/fullscreen/overlay_top.dart b/lib/widgets/fullscreen/overlay_top.dart index ba134e3fb..36cd07bfc 100644 --- a/lib/widgets/fullscreen/overlay_top.dart +++ b/lib/widgets/fullscreen/overlay_top.dart @@ -50,29 +50,39 @@ class FullscreenTopOverlay extends StatelessWidget { itemBuilder: (context) => [ PopupMenuItem( value: FullscreenAction.info, - child: Text("Info"), + child: MenuRow(text: 'Info', icon: Icons.info_outline), ), PopupMenuItem( value: FullscreenAction.rename, - child: Text("Rename"), + child: MenuRow(text: 'Rename', icon: Icons.title), ), + if (entry.canRotate) + PopupMenuItem( + value: FullscreenAction.rotateCCW, + child: MenuRow(text: 'Rotate left', icon: Icons.rotate_left), + ), + if (entry.canRotate) + PopupMenuItem( + value: FullscreenAction.rotateCW, + child: MenuRow(text: 'Rotate right', icon: Icons.rotate_right), + ), PopupMenuDivider(), PopupMenuItem( value: FullscreenAction.edit, - child: Text("Edit with…"), + child: Text('Edit with…'), ), PopupMenuItem( value: FullscreenAction.open, - child: Text("Open with…"), + child: Text('Open with…'), ), PopupMenuItem( value: FullscreenAction.setAs, - child: Text("Set as…"), + child: Text('Set as…'), ), if (entry.hasGps) PopupMenuItem( value: FullscreenAction.openMap, - child: Text("Show on map…"), + child: Text('Show on map…'), ), ], onSelected: onActionSelected, @@ -85,6 +95,28 @@ class FullscreenTopOverlay extends StatelessWidget { } } +class MenuRow extends StatelessWidget { + final String text; + final IconData icon; + + const MenuRow({ + Key key, + this.text, + this.icon, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon), + SizedBox(width: 8), + Text(text), + ], + ); + } +} + class OverlayButton extends StatelessWidget { final Animation scale; final Widget child;