From c78241e204b2c1be8dd1c2a943560f8a1724f4f8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 15 Aug 2019 13:38:56 +0900 Subject: [PATCH] rename --- android/app/build.gradle | 1 + .../deckers/thibault/aves/MainActivity.java | 26 +++ .../channelhandlers/ImageDecodeHandler.java | 74 +++++-- .../aves/model/provider/ImageProvider.java | 97 +++++++++ .../model/provider/ImageProviderFactory.java | 31 +++ .../provider/MediaStoreImageProvider.java | 4 +- .../thibault/aves/utils/Constants.java | 2 + .../java/deckers/thibault/aves/utils/Env.java | 51 +++++ .../thibault/aves/utils/PathComponents.java | 37 ++++ .../aves/utils/PermissionManager.java | 53 +++++ .../thibault/aves/utils/StorageUtils.java | 187 ++++++++++++++++++ .../app/src/main/res/values-v28/styles.xml | 2 +- android/app/src/main/res/values/styles.xml | 2 +- lib/model/image_decode_service.dart | 14 ++ lib/model/image_entry.dart | 30 ++- lib/widgets/fullscreen/image_page.dart | 69 ++++++- .../fullscreen/info/metadata_section.dart | 7 +- lib/widgets/fullscreen/overlay_top.dart | 4 + 18 files changed, 662 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/Env.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java create mode 100644 android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java diff --git a/android/app/build.gradle b/android/app/build.gradle index 8d76c9468..3d3544c9d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -58,6 +58,7 @@ 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.google.guava:guava:28.0-android' 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 13b2d9c7e..0273b7443 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -1,11 +1,16 @@ package deckers.thibault.aves; +import android.content.Intent; +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.MediaStoreStreamHandler; import deckers.thibault.aves.channelhandlers.MetadataHandler; +import deckers.thibault.aves.utils.Constants; +import deckers.thibault.aves.utils.Env; +import deckers.thibault.aves.utils.PermissionManager; import io.flutter.app.FlutterActivity; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; @@ -26,5 +31,26 @@ public class MainActivity extends FlutterActivity { new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE && resultCode == RESULT_OK) { + Uri sdCardDocumentUri = data.getData(); + if (sdCardDocumentUri == null) { + return; + } + + Env.setSdCardDocumentUri(this, sdCardDocumentUri.toString()); + + // save access permissions across reboots + final int takeFlags = data.getFlags() + & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + getContentResolver().takePersistableUriPermission(sdCardDocumentUri, takeFlags); + + // resume pending action + PermissionManager.onPermissionGranted(requestCode); + } + } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java index eab47245c..973e9122f 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeHandler.java @@ -5,6 +5,8 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.Intent; import android.net.Uri; +import android.os.Handler; +import android.os.Looper; import android.provider.Settings; import androidx.annotation.NonNull; @@ -19,6 +21,8 @@ import com.karumi.dexter.listener.single.PermissionListener; import java.util.Map; import deckers.thibault.aves.model.ImageEntry; +import deckers.thibault.aves.model.provider.ImageProvider; +import deckers.thibault.aves.model.provider.ImageProviderFactory; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -41,30 +45,68 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler { 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); + case "getImageBytes": + getImageBytes(call, result); break; - } - case "cancelGetImageBytes": { - String uri = call.argument("uri"); - imageDecodeTaskManager.cancel(uri); - result.success(null); + case "cancelGetImageBytes": + cancelGetImageBytes(call, result); + break; + case "rename": + rename(call, result); break; - } default: result.notImplemented(); break; } } + private void getImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + Map map = call.argument("entry"); + Integer width = call.argument("width"); + Integer height = call.argument("height"); + if (map == null || width == null || height == null) { + result.error("getImageBytes-args", "failed because of missing arguments", null); + return; + } + ImageEntry entry = new ImageEntry(map); + imageDecodeTaskManager.fetch(result, entry, width, height); + } + + private void cancelGetImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String uri = call.argument("uri"); + imageDecodeTaskManager.cancel(uri); + result.success(null); + } + + private void rename(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + Map map = call.argument("entry"); + String newName = call.argument("newName"); + if (map == null || newName == null) { + result.error("rename-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("rename-provider", "failed to find provider for uri=" + uri, null); + return; + } + provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() { + @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("rename-failure", "failed to rename", 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/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java new file mode 100644 index 000000000..16e7f92bb --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -0,0 +1,97 @@ +package deckers.thibault.aves.model.provider; + +import android.app.Activity; +import android.content.ContentUris; +import android.database.Cursor; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.Log; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import deckers.thibault.aves.utils.Env; +import deckers.thibault.aves.utils.PermissionManager; +import deckers.thibault.aves.utils.StorageUtils; +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) { + if (oldPath == null) { + Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri); + callback.onFailure(); + return; + } + + Map newFields = new HashMap<>(); + File oldFile = new File(oldPath); + File newFile = new File(oldFile.getParent(), newFilename); + if (oldFile.equals(newFile)) { + Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath); + callback.onSuccess(newFields); + return; + } + + // Before KitKat, we do whatever we want on the SD card. + // 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 { + // rename with DocumentFile + Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); + if (sdCardTreeUri == null) { + Runnable runnable = () -> rename(activity, oldPath, oldUri, mimeType, newFilename, callback); + PermissionManager.showSdCardAccessDialog(activity, runnable); + return; + } + renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename); + } + + if (!renamed) { + Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath); + callback.onFailure(); + return; + } + + MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null); + MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, uri) -> { + Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", uri=" + uri); + if (uri != null) { + // we retrieve updated fields as the renamed file became a new entry in the Media Store + String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE}; + try { + Cursor cursor = activity.getContentResolver().query(uri, projection, null, null, null); + if (cursor != null) { + if (cursor.moveToNext()) { + long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); + Uri itemUri = ContentUris.withAppendedId(MediaStoreImageProvider.FILES_URI, contentId); + newFields.put("uri", itemUri.toString()); + newFields.put("contentId", contentId); + newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA))); + newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))); + } + cursor.close(); + } + } catch (Exception e) { + Log.w(LOG_TAG, "failed to update MediaStore after renaming entry at path=" + oldPath, e); + callback.onFailure(); + return; + } + } + callback.onSuccess(newFields); + }); + } + + public interface RenameCallback { + void onSuccess(Map newFields); + + void onFailure(); + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java new file mode 100644 index 000000000..d1397aafa --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java @@ -0,0 +1,31 @@ +package deckers.thibault.aves.model.provider; + +import android.content.ContentResolver; +import android.net.Uri; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; + +public class ImageProviderFactory { + public static ImageProvider getProvider(@NonNull Uri uri) { + String scheme = uri.getScheme(); + if (scheme != null) { + switch (scheme) { + case ContentResolver.SCHEME_CONTENT: // content:// + String authority = uri.getAuthority(); + if (authority != null) { + switch (authority) { + case MediaStore.AUTHORITY: + return new MediaStoreImageProvider(); +// case Constants.DOWNLOADS_AUTHORITY: +// return new DownloadImageProvider(); + } + } + return null; +// case ContentResolver.SCHEME_FILE: // file:// +// return new FileImageProvider(); + } + } + return null; + } +} 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 192808ca9..fe490849d 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 @@ -13,10 +13,10 @@ import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.utils.Utils; -public class MediaStoreImageProvider { +public class MediaStoreImageProvider extends ImageProvider { private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class); - private Uri FILES_URI = MediaStore.Files.getContentUri("external"); + public static Uri FILES_URI = MediaStore.Files.getContentUri("external"); private static final String[] PROJECTION = { // image & video 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 2c6cc251a..94aaea67d 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 @@ -6,6 +6,8 @@ import java.util.HashMap; import java.util.Map; public class Constants { + public static final int SD_CARD_PERMISSION_REQUEST_CODE = 1; + // mime types public static final String MIME_VIDEO = "video"; 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 new file mode 100644 index 000000000..5c26a1266 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Env.java @@ -0,0 +1,51 @@ +package deckers.thibault.aves.utils; + +import android.app.Activity; +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; + // SD card path as a content URI from the Documents Provider + // e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A + private static String mSdCardDocumentUri; + + private static final String PREF_SD_CARD_DOCUMENT_URI = "sd_card_document_uri"; + + public static void setSdCardDocumentUri(final Activity activity, String SdCardDocumentUri) { + mSdCardDocumentUri = SdCardDocumentUri; + SharedPreferences.Editor preferences = activity.getPreferences(Context.MODE_PRIVATE).edit(); + preferences.putString(PREF_SD_CARD_DOCUMENT_URI, mSdCardDocumentUri); + preferences.apply(); + } + + public static String getSdCardDocumentUri(final Activity activity) { + if (mSdCardDocumentUri == null) { + SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE); + mSdCardDocumentUri = preferences.getString(PREF_SD_CARD_DOCUMENT_URI, null); + } + return mSdCardDocumentUri; + } + + public static String[] getStorageVolumes(final Activity activity) { + if (mStorageVolumes == null) { + mStorageVolumes = StorageUtils.getStorageVolumes(activity); + } + return mStorageVolumes; + } + + private static String getExternalStorage() { + if (mExternalStorage == null) { + mExternalStorage = Environment.getExternalStorageDirectory().toString(); + } + return mExternalStorage; + } + + public static boolean isOnSdCard(final Activity activity, @NonNull String path) { + return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage()); + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java b/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java new file mode 100644 index 000000000..f1daa7e1f --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java @@ -0,0 +1,37 @@ +package deckers.thibault.aves.utils; + +import androidx.annotation.NonNull; + +import java.io.File; + +public class PathComponents { + private String storage; + private String folder; + private String filename; + + public PathComponents(@NonNull String path, @NonNull String[] storageVolumes) { + for (int i = 0; i < storageVolumes.length && storage == null; i++) { + if (path.startsWith(storageVolumes[i])) { + storage = storageVolumes[i]; + } + } + + int lastSeparatorIndex = path.lastIndexOf(File.separator) + 1; + if (lastSeparatorIndex > storage.length()) { + filename = path.substring(lastSeparatorIndex); + folder = path.substring(storage.length(), lastSeparatorIndex); + } + } + + public String getStorage() { + return storage; + } + + String getFolder() { + return folder; + } + + String getFilename() { + return filename; + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java new file mode 100644 index 000000000..434f7085b --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -0,0 +1,53 @@ +package deckers.thibault.aves.utils; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Intent; +import android.content.UriPermission; +import android.net.Uri; +import android.os.Build; +import android.util.Log; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.ActivityCompat; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class PermissionManager { + private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class); + + // permission request code to pending runnable + private static ConcurrentHashMap pendingPermissionMap = new ConcurrentHashMap<>(); + + // check access permission to SD card directory & return its content URI if available + public static Uri getSdCardTreeUri(Activity activity) { + final String sdCardDocumentUri = Env.getSdCardDocumentUri(activity); + Optional uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream() + .filter(uriPermission -> uriPermission.getUri().toString().equals(sdCardDocumentUri)) + .findFirst(); + return uriPermissionOptional.map(UriPermission::getUri).orElse(null); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public static void showSdCardAccessDialog(final Activity activity, final Runnable pendingRunnable) { + new AlertDialog.Builder(activity) + .setTitle("SD Card Access") + .setMessage("Please select the root directory of the SD card in the next screen, so that this app has permission to access it and complete your request.") + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { + Log.i(LOG_TAG, "request user to select and grant access permission to SD card"); + pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, pendingRunnable); + ActivityCompat.startActivityForResult(activity, + new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), + Constants.SD_CARD_PERMISSION_REQUEST_CODE, null); + }) + .show(); + } + + public static void onPermissionGranted(int requestCode) { + Runnable runnable = pendingPermissionMap.remove(requestCode); + if (runnable != null) { + runnable.run(); + } + } +} 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 new file mode 100644 index 000000000..2a80fa782 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -0,0 +1,187 @@ +package deckers.thibault.aves.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.text.TextUtils; +import android.util.Log; + +import androidx.documentfile.provider.DocumentFile; + +import com.google.common.base.Splitter; +import com.google.common.collect.Lists; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Optional; +import java.util.Set; + +public class StorageUtils { + private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class); + + /** + * Returns all available SD-Cards in the system (include emulated) + *

+ * Warning: Hack! Based on Android source code of version 4.3 (API 18) + * Because there is no standard way to get it. + * Edited by hendrawd + * + * @return paths to all available SD-Cards in the system (include emulated) + */ + @SuppressLint("ObsoleteSdkInt") + public static String[] getStorageVolumes(Context context) { + // Final set of paths + final Set rv = new HashSet<>(); + + // Primary emulated SD-CARD + final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET"); + + if (TextUtils.isEmpty(rawEmulatedStorageTarget)) { + // fix of empty raw emulated storage on marshmallow + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + File[] files = context.getExternalFilesDirs(null); + for (File file : files) { + String applicationSpecificAbsolutePath = file.getAbsolutePath(); + String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data")); + rv.add(emulatedRootPath); + } + } else { + // Primary physical SD-CARD (not emulated) + final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE"); + + // Device has physical external storage; use plain paths. + if (TextUtils.isEmpty(rawExternalStorage)) { + // EXTERNAL_STORAGE undefined; falling back to default. + rv.addAll(Arrays.asList(getPhysicalPaths())); + } else { + rv.add(rawExternalStorage); + } + } + } else { + // Device has emulated storage; external storage paths should have userId burned into them. + final String rawUserId; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + rawUserId = ""; + } else { + final String path = Environment.getExternalStorageDirectory().getAbsolutePath(); + final String[] folders = path.split(File.separator); + final String lastFolder = folders[folders.length - 1]; + boolean isDigit = TextUtils.isDigitsOnly(lastFolder); + rawUserId = isDigit ? lastFolder : ""; + } + // /storage/emulated/0[1,2,...] + if (TextUtils.isEmpty(rawUserId)) { + rv.add(rawEmulatedStorageTarget); + } else { + rv.add(rawEmulatedStorageTarget + File.separator + rawUserId); + } + } + + // All Secondary SD-CARDs (all exclude primary) separated by ":" + final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE"); + + // Add all secondary storages + if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) { + // All Secondary SD-CARDs split into array + final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator); + Collections.addAll(rv, rawSecondaryStorages); + } + + String[] paths = rv.toArray(new String[0]); + for (int i = 0; i < paths.length; i++) { + String path = paths[i]; + if (path.endsWith(File.separator)) { + paths[i] = path.substring(0, path.length() - 1); + } + } + return paths; + } + + /** + * @return physicalPaths based on phone model + */ + @SuppressLint("SdCardPath") + private static String[] getPhysicalPaths() { + return new String[]{ + "/storage/sdcard0", + "/storage/sdcard1", //Motorola Xoom + "/storage/extsdcard", //Samsung SGS3 + "/storage/sdcard0/external_sdcard", //User request + "/mnt/extsdcard", + "/mnt/sdcard/external_sd", //Samsung galaxy family + "/mnt/external_sd", + "/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3 + "/removable/microsd", //Asus transformer prime + "/mnt/emmc", + "/storage/external_SD", //LG + "/storage/ext_sd", //HTC One Max + "/storage/removable/sdcard1", //Sony Xperia Z1 + "/data/sdext", + "/data/sdext2", + "/data/sdext3", + "/data/sdext4", + "/sdcard1", //Sony Xperia Z + "/sdcard2", //HTC One M8s + "/storage/microsd" //ASUS ZenFone 2 + }; + } + + private static Optional getSdCardDocumentFile(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path) { + if (sdCardTreeUri == null || storageVolumes == null || path == null) { + return Optional.empty(); + } + + PathComponents pathComponents = new PathComponents(path, storageVolumes); + ArrayList pathSegments = Lists.newArrayList(Splitter.on(File.separatorChar) + .trimResults().omitEmptyStrings().split(pathComponents.getFolder())); + pathSegments.add(pathComponents.getFilename()); + Iterator pathIterator = pathSegments.iterator(); + + // follow the entry path down the document tree + boolean found = true; + DocumentFile documentFile = DocumentFile.fromTreeUri(context, sdCardTreeUri); + while (pathIterator.hasNext() && found) { + String segment = pathIterator.next(); + found = false; + if (documentFile != null) { + DocumentFile[] children = documentFile.listFiles(); + for (int i = children.length - 1; i >= 0 && !found; i--) { + DocumentFile child = children[i]; + if (segment.equals(child.getName())) { + found = true; + documentFile = child; + } + } + } + } + + return found && documentFile != null ? Optional.of(documentFile) : Optional.empty(); + } + + /** + * Delete the specified file on SD card + * Note that it does not update related content providers such as the Media Store. + */ + public static boolean deleteFromSdCard(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path) { + Optional documentFile = getSdCardDocumentFile(context, sdCardTreeUri, storageVolumes, path); + boolean success = documentFile.isPresent() && documentFile.get().delete(); + Log.d(LOG_TAG, "deleteFromSdCard success=" + success + " for sdCardTreeUri=" + sdCardTreeUri + ", path=" + path); + return success; + } + + /** + * Rename the specified file on SD card + * Note that it does not update related content providers such as the Media Store. + */ + public static boolean renameOnSdCard(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path, String newFilename) { + Log.d(LOG_TAG, "renameOnSdCard with path=" + path + ", newFilename=" + newFilename); + Optional documentFile = getSdCardDocumentFile(context, sdCardTreeUri, storageVolumes, path); + return documentFile.isPresent() && documentFile.get().renameTo(newFilename); + } +} diff --git a/android/app/src/main/res/values-v28/styles.xml b/android/app/src/main/res/values-v28/styles.xml index b398f5351..1f776a923 100644 --- a/android/app/src/main/res/values-v28/styles.xml +++ b/android/app/src/main/res/values-v28/styles.xml @@ -1,6 +1,6 @@ - diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 8fcc5e55e..7565d7c6f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,6 +1,6 @@ - diff --git a/lib/model/image_decode_service.dart b/lib/model/image_decode_service.dart index d2be1a3a6..3ded37b09 100644 --- a/lib/model/image_decode_service.dart +++ b/lib/model/image_decode_service.dart @@ -41,4 +41,18 @@ class ImageDecodeService { debugPrint('cancelGetImageBytes failed with exception=${e.message}'); } } + + static Future rename(ImageEntry entry, String newName) async { + try { + // return map with: 'contentId' 'path' 'title' 'uri' (all optional) + final result = await platform.invokeMethod('rename', { + 'entry': entry.toMap(), + 'newName': newName, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('rename failed with exception=${e.message}'); + } + return Map(); + } } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 4d8804e67..a986b7850 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -1,23 +1,25 @@ import 'dart:collection'; +import 'package:aves/model/image_decode_service.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_service.dart'; import 'package:flutter/material.dart'; import 'package:geocoder/geocoder.dart'; +import 'package:path/path.dart'; import 'package:tuple/tuple.dart'; import 'mime_types.dart'; class ImageEntry with ChangeNotifier { - final String uri; - final String path; - final int contentId; + String uri; + String path; + int contentId; final String mimeType; final int width; final int height; final int orientationDegrees; final int sizeBytes; - final String title; + String title; final int dateModifiedSecs; final int sourceDateTakenMillis; final String bucketDisplayName; @@ -82,6 +84,8 @@ class ImageEntry with ChangeNotifier { return 'ImageEntry{uri=$uri, path=$path}'; } + String get filename => basenameWithoutExtension(path); + bool get isGif => mimeType == MimeTypes.MIME_GIF; bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO); @@ -183,4 +187,22 @@ class ImageEntry with ChangeNotifier { if (isLocated && addressDetails.addressLine.toLowerCase().contains(query)) return true; return false; } + + Future rename(String newName) async { + if (newName == filename) return true; + + final newFields = await ImageDecodeService.rename(this, '$newName${extension(this.path)}'); + if (newFields.isEmpty) return false; + + final uri = newFields['uri']; + if (uri != null) this.uri = uri; + final path = newFields['path']; + if (path != null) this.path = path; + final contentId = newFields['contentId']; + if (contentId != null) this.contentId = contentId; + final title = newFields['title']; + if (title != null) this.title = title; + notifyListeners(); + return true; + } } diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 73399f067..2254c3519 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -14,7 +14,7 @@ import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:screen/screen.dart'; -class FullscreenPage extends StatefulWidget { +class FullscreenPage extends StatelessWidget { final List entries; final String initialUri; @@ -25,10 +25,39 @@ class FullscreenPage extends StatefulWidget { }) : super(key: key); @override - FullscreenPageState createState() => FullscreenPageState(); + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () { + Screen.keepOn(false); + SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); + return Future.value(true); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: FullscreenBody( + entries: entries, + initialUri: initialUri, + ), + ), + ); + } } -class FullscreenPageState extends State with SingleTickerProviderStateMixin { +class FullscreenBody extends StatefulWidget { + final List entries; + final String initialUri; + + const FullscreenBody({ + Key key, + this.entries, + this.initialUri, + }) : super(key: key); + + @override + FullscreenBodyState createState() => FullscreenBodyState(); +} + +class FullscreenBodyState extends State with SingleTickerProviderStateMixin { bool _isInitialScale = true; int _currentHorizontalPage, _currentVerticalPage = 0; PageController _horizontalPager, _verticalPager; @@ -193,6 +222,35 @@ class FullscreenPageState extends State with SingleTickerProvide } } + showRenameDialog(ImageEntry entry) async { + final currentName = entry.title; + final controller = TextEditingController(text: currentName); + final newName = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + content: TextField( + controller: controller, + autofocus: true, + ), + actions: [ + FlatButton( + onPressed: () => Navigator.pop(context), + child: Text('CANCEL'), + ), + FlatButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text('APPLY'), + ), + ], + ); + }); + if (newName == null || newName.isEmpty) return; + final success = await entry.rename(newName); + final snackBar = SnackBar(content: Text(success ? 'Done!' : 'Failed')); + Scaffold.of(context).showSnackBar(snackBar); + } + onActionSelected(ImageEntry entry, FullscreenAction action) { switch (action) { case FullscreenAction.edit: @@ -201,6 +259,9 @@ class FullscreenPageState extends State with SingleTickerProvide case FullscreenAction.info: goToVerticalPage(1); break; + case FullscreenAction.rename: + showRenameDialog(entry); + break; case FullscreenAction.setAs: AndroidAppService.setAs(entry.uri, entry.mimeType); break; @@ -214,7 +275,7 @@ class FullscreenPageState extends State with SingleTickerProvide } } -enum FullscreenAction { edit, info, setAs, share, showOnMap } +enum FullscreenAction { edit, info, rename, setAs, share, showOnMap } class ImagePage extends StatefulWidget { final List entries; diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index 9cfca7a0d..3c1b843cc 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -17,6 +17,8 @@ class MetadataSection extends StatefulWidget { class MetadataSectionState extends State { Future _metadataLoader; + static const int maxValueLength = 140; + @override void initState() { super.initState(); @@ -56,7 +58,10 @@ class MetadataSectionState extends State { padding: EdgeInsets.symmetric(vertical: 4.0), child: Text(directoryName, style: TextStyle(fontSize: 18)), ), - ...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])), + ...tagKeys.map((tagKey) { + final value = directory[tagKey] as String; + return InfoRow(tagKey, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}…' : value); + }), SizedBox(height: 16), ]; }, diff --git a/lib/widgets/fullscreen/overlay_top.dart b/lib/widgets/fullscreen/overlay_top.dart index 18b4fbedc..cda124805 100644 --- a/lib/widgets/fullscreen/overlay_top.dart +++ b/lib/widgets/fullscreen/overlay_top.dart @@ -56,6 +56,10 @@ class FullscreenTopOverlay extends StatelessWidget { value: FullscreenAction.edit, child: Text("Edit"), ), + PopupMenuItem( + value: FullscreenAction.rename, + child: Text("Rename"), + ), PopupMenuItem( value: FullscreenAction.setAs, child: Text("Set as"),