diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java index 47a68f0e6..8131438e9 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java @@ -51,6 +51,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { case "cancelGetImageBytes": cancelGetImageBytes(call, result); break; + case "delete": + delete(call, result); + break; case "rename": rename(call, result); break; @@ -64,14 +67,14 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } private void getImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - Map map = call.argument("entry"); + Map entryMap = call.argument("entry"); Integer width = call.argument("width"); Integer height = call.argument("height"); - if (map == null || width == null || height == null) { + if (entryMap == null || width == null || height == null) { result.error("getImageBytes-args", "failed because of missing arguments", null); return; } - ImageEntry entry = new ImageEntry(map); + ImageEntry entry = new ImageEntry(entryMap); imageDecodeTaskManager.fetch(result, entry, width, height); } @@ -81,16 +84,43 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { result.success(null); } + private void delete(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + Map entryMap = call.argument("entry"); + if (entryMap == null) { + result.error("delete-args", "failed because of missing arguments", null); + return; + } + Uri uri = Uri.parse((String) entryMap.get("uri")); + String path = (String) entryMap.get("path"); + + ImageProvider provider = ImageProviderFactory.getProvider(uri); + if (provider == null) { + result.error("delete-provider", "failed to find provider for uri=" + uri, null); + return; + } + provider.delete(activity, path, uri, 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("delete-failure", "failed to delete", null)); + } + }); + } + private void rename(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - Map map = call.argument("entry"); + Map entryMap = call.argument("entry"); String newName = call.argument("newName"); - if (map == null || newName == null) { + if (entryMap == 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"); + Uri uri = Uri.parse((String) entryMap.get("uri")); + String path = (String) entryMap.get("path"); + String mimeType = (String) entryMap.get("mimeType"); ImageProvider provider = ImageProviderFactory.getProvider(uri); if (provider == null) { @@ -111,15 +141,15 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } private void rotate(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - Map map = call.argument("entry"); + Map entryMap = call.argument("entry"); Boolean clockwise = call.argument("clockwise"); - if (map == null || clockwise == null) { + if (entryMap == 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"); + Uri uri = Uri.parse((String) entryMap.get("uri")); + String path = (String) entryMap.get("path"); + String mimeType = (String) entryMap.get("mimeType"); ImageProvider provider = ImageProviderFactory.getProvider(uri); if (provider == null) { @@ -151,7 +181,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { @Override public void onPermissionDenied(PermissionDeniedResponse response) { - AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setMessage("This permission is needed for use this features of the app so please, allow it!"); builder.setTitle("We need this permission"); 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 946f98a0b..1f3bb11fd 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 @@ -32,6 +32,10 @@ import deckers.thibault.aves.utils.Utils; public abstract class ImageProvider { private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); + public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { + callback.onFailure(); + } + 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); @@ -114,7 +118,7 @@ public abstract class ImageProvider { } } - private void rotateJpeg(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) { + private void rotateJpeg(final 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) { @@ -176,7 +180,7 @@ public abstract class ImageProvider { } } - private void rotatePng(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) { + private void rotatePng(final Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) { if (path == null) { callback.onFailure(); return; 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 fe490849d..6885d9f73 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 @@ -11,6 +11,9 @@ import java.util.ArrayList; import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; +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 class MediaStoreImageProvider extends ImageProvider { @@ -111,4 +114,35 @@ public class MediaStoreImageProvider extends ImageProvider { } return entries.stream(); } + + @Override + public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { + // check write access permission to SD card + // 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. + if (Env.isOnSdCard(activity, path)) { + Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); + if (sdCardTreeUri == null) { + Runnable runnable = () -> delete(activity, path, uri, callback); + PermissionManager.showSdCardAccessDialog(activity, runnable); + return; + } + + // if the file is on SD card, calling the content resolver delete() removes the entry from the MediaStore + // but it doesn't delete the file, even if the app has the permission + StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path); + Log.d(LOG_TAG, "deleted from SD card at path=" + uri); + callback.onSuccess(null); + return; + } + + if (activity.getContentResolver().delete(uri, null, null) > 0) { + Log.d(LOG_TAG, "deleted from content resolver uri=" + uri); + callback.onSuccess(null); + return; + } + + callback.onFailure(); + } } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index eca6e4a73..4de8fed7d 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -188,6 +188,8 @@ class ImageEntry with ChangeNotifier { return false; } + Future delete() => ImageFileService.delete(this); + Future rename(String newName) async { if (newName == filename) return true; diff --git a/lib/model/image_file_service.dart b/lib/model/image_file_service.dart index 713788b5f..ef47f4f03 100644 --- a/lib/model/image_file_service.dart +++ b/lib/model/image_file_service.dart @@ -42,6 +42,18 @@ class ImageFileService { } } + static Future delete(ImageEntry entry) async { + try { + await platform.invokeMethod('delete', { + 'entry': entry.toMap(), + }); + return true; + } on PlatformException catch (e) { + debugPrint('delete failed with exception=${e.message}'); + } + return false; + } + static Future rename(ImageEntry entry, String newName) async { try { // return map with: 'contentId' 'path' 'title' 'uri' (all optional) diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index a3c7ec355..7e85a78fd 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -215,6 +215,9 @@ class FullscreenBodyState extends State with SingleTickerProvide onActionSelected(ImageEntry entry, FullscreenAction action) { switch (action) { + case FullscreenAction.delete: + showDeleteDialog(entry); + break; case FullscreenAction.edit: AndroidAppService.edit(entry.uri, entry.mimeType); break; @@ -263,6 +266,32 @@ class FullscreenBodyState extends State with SingleTickerProvide showFeedback(success ? 'Done!' : 'Failed'); } + showDeleteDialog(ImageEntry entry) async { + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + content: Text('Are you sure?'), + actions: [ + FlatButton( + onPressed: () => Navigator.pop(context), + child: Text('CANCEL'), + ), + FlatButton( + onPressed: () => Navigator.pop(context, true), + child: Text('DELETE'), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + if (await entry.delete()) + entries.remove(entry); + else + showFeedback('Failed'); + } + showRenameDialog(ImageEntry entry) async { final currentName = entry.title; final controller = TextEditingController(text: currentName); @@ -287,12 +316,11 @@ class FullscreenBodyState extends State with SingleTickerProvide ); }); if (newName == null || newName.isEmpty) return; - final success = await entry.rename(newName); - showFeedback(success ? 'Done!' : 'Failed'); + showFeedback(await entry.rename(newName) ? 'Done!' : 'Failed'); } } -enum FullscreenAction { edit, info, open, openMap, rename, rotateCCW, rotateCW, setAs, share } +enum FullscreenAction { delete, 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 36cd07bfc..3c6799772 100644 --- a/lib/widgets/fullscreen/overlay_top.dart +++ b/lib/widgets/fullscreen/overlay_top.dart @@ -52,6 +52,10 @@ class FullscreenTopOverlay extends StatelessWidget { value: FullscreenAction.info, child: MenuRow(text: 'Info', icon: Icons.info_outline), ), + PopupMenuItem( + value: FullscreenAction.delete, + child: MenuRow(text: 'Delete', icon: Icons.delete_outline), + ), PopupMenuItem( value: FullscreenAction.rename, child: MenuRow(text: 'Rename', icon: Icons.title),