diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index e7cca3ea5..507ac09d9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.os.storage.StorageManager +import android.util.Log import androidx.core.os.EnvironmentCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.utils.PermissionManager @@ -29,6 +30,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories) "getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories) "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) + "deleteEmptyDirectories" -> safe(call, result, ::deleteEmptyDirectories) "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) } else -> result.notImplemented() } @@ -136,6 +138,28 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(success) } + private fun deleteEmptyDirectories(call: MethodCall, result: MethodChannel.Result) { + val dirPaths = call.argument>("dirPaths") + if (dirPaths == null) { + result.error("deleteEmptyDirectories-args", "failed because of missing arguments", null) + return + } + + var deleted = 0 + dirPaths.forEach { + try { + val dir = File(it) + if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) { + Log.d("TLAD", "deleted empty directory=$dir") + deleted++ + } + } catch (e: SecurityException) { + // ignore + } + } + result.success(deleted) + } + private fun scanFile(call: MethodCall, result: MethodChannel.Result) { val path = call.argument("path") val mimeType = call.argument("mimeType") diff --git a/lib/services/android_file_service.dart b/lib/services/storage_service.dart similarity index 89% rename from lib/services/android_file_service.dart rename to lib/services/storage_service.dart index 09b3d609d..071ecdf20 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/storage_service.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; -class AndroidFileService { +class StorageService { static const platform = MethodChannel('deckers.thibault/aves/storage'); static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream'); @@ -95,6 +95,18 @@ class AndroidFileService { return false; } + // returns number of deleted directories + static Future deleteEmptyDirectories(Iterable dirPaths) async { + try { + return await platform.invokeMethod('deleteEmptyDirectories', { + 'dirPaths': dirPaths.toList(), + }); + } on PlatformException catch (e) { + debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return 0; + } + // returns media URI static Future scanFile(String path, String mimeType) async { debugPrint('scanFile with path=$path, mimeType=$mimeType'); diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 3a5ca800b..d24d433b8 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,5 +1,5 @@ import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; @@ -21,7 +21,7 @@ class AndroidFileUtils { AndroidFileUtils._private(); Future init() async { - storageVolumes = await AndroidFileService.getStorageVolumes(); + storageVolumes = await StorageService.getStorageVolumes(); // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path; dcimPath = join(primaryStorage, 'DCIM'); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index eacaa1810..59d41a721 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -7,9 +7,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -69,7 +69,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (moveType == MoveType.move) { // check whether moving is possible given OS restrictions, // before asking to pick a destination album - final restrictedDirs = await AndroidFileService.getRestrictedDirectories(); + final restrictedDirs = await StorageService.getRestrictedDirectories(); for (final selectionDir in selectionDirs) { final dir = VolumeRelativeDirectory.fromPath(selectionDir); if (restrictedDirs.contains(dir)) { @@ -124,19 +124,25 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final count = movedCount; showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count)); } + + // cleanup + if (moveType == MoveType.move) { + await StorageService.deleteEmptyDirectories(selectionDirs); + } }, ); } Future _showDeleteDialog(BuildContext context) async { - final count = selection.length; + final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet(); + final todoCount = selection.length; final confirmed = await showDialog( context: context, builder: (context) { return AvesDialog( context: context, - content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(count)), + content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -152,14 +158,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermission(context, selection)) return; + if (!await checkStoragePermissionForAlbums(context, selectionDirs)) return; - final selectionCount = selection.length; source.pauseMonitoring(); showOpReport( context: context, opStream: imageFileService.delete(selection), - itemCount: selectionCount, + itemCount: todoCount, onDone: (processed) async { final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); await source.removeEntries(deletedUris); @@ -167,10 +172,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware source.resumeMonitoring(); final deletedCount = deletedUris.length; - if (deletedCount < selectionCount) { - final count = selectionCount - deletedCount; + if (deletedCount < todoCount) { + final count = todoCount - deletedCount; showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); } + + // cleanup + await StorageService.deleteEmptyDirectories(selectionDirs); }, ); } diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index 5f74e95fc..b0da008bf 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; @@ -11,9 +11,9 @@ mixin PermissionAwareMixin { } Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths) async { - final restrictedDirs = await AndroidFileService.getRestrictedDirectories(); + final restrictedDirs = await StorageService.getRestrictedDirectories(); while (true) { - final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths); + final dirs = await StorageService.getInaccessibleDirectories(albumPaths); if (dirs == null) return false; if (dirs.isEmpty) return true; @@ -49,7 +49,7 @@ mixin PermissionAwareMixin { // abort if the user cancels in Flutter if (confirmed == null || !confirmed) return false; - final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath); + final granted = await StorageService.requestVolumeAccess(dir.volumePath); if (!granted) { // abort if the user denies access from the native dialog return false; diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index b3584fa37..ef392018d 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -20,7 +20,7 @@ mixin SizeAwareMixin { MoveType moveType, ) async { final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); - final free = await AndroidFileService.getFreeSpace(destinationVolume); + final free = await StorageService.getFreeSpace(destinationVolume); int needed; int sumSize(sum, entry) => sum + entry.sizeBytes; switch (moveType) { diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index fb29874df..a1fbbae69 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -17,7 +17,7 @@ class _DebugStorageSectionState extends State with Automati void initState() { super.initState(); androidFileUtils.storageVolumes.forEach((volume) async { - final byteCount = await AndroidFileService.getFreeSpace(volume); + final byteCount = await StorageService.getFreeSpace(volume); setState(() => _freeSpaceByVolume[volume.path] = byteCount); }); } diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 7a95a56b3..4225e4df7 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/covers.dart'; @@ -7,9 +9,9 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -132,16 +134,19 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } Future _showDeleteDialog(BuildContext context, AlbumFilter filter) async { + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); final source = context.read(); - final selection = source.visibleEntries.where(filter.test).toSet(); - final count = selection.length; + final album = filter.album; + final todoEntries = source.visibleEntries.where(filter.test).toSet(); + final todoCount = todoEntries.length; final confirmed = await showDialog( context: context, builder: (context) { return AvesDialog( context: context, - content: Text(context.l10n.deleteAlbumConfirmationDialogMessage(count)), + content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -149,7 +154,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.deleteButtonLabel), + child: Text(l10n.deleteButtonLabel), ), ], ); @@ -157,41 +162,47 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermission(context, selection)) return; + if (!await checkStoragePermissionForAlbums(context, {album})) return; - final selectionCount = selection.length; source.pauseMonitoring(); showOpReport( context: context, - opStream: imageFileService.delete(selection), - itemCount: selectionCount, + opStream: imageFileService.delete(todoEntries), + itemCount: todoCount, onDone: (processed) async { final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); await source.removeEntries(deletedUris); source.resumeMonitoring(); final deletedCount = deletedUris.length; - if (deletedCount < selectionCount) { - final count = selectionCount - deletedCount; - showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); + if (deletedCount < todoCount) { + final count = todoCount - deletedCount; + showFeedbackWithMessenger(messenger, l10n.collectionDeleteFailureFeedback(count)); } + + // cleanup + await StorageService.deleteEmptyDirectories({album}); }, ); } Future _showRenameDialog(BuildContext context, AlbumFilter filter) async { + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + final source = context.read(); final album = filter.album; + final todoEntries = source.visibleEntries.where(filter.test).toSet(); + final todoCount = todoEntries.length; // check whether renaming is possible given OS restrictions, // before asking to input a new name - final restrictedDirs = await AndroidFileService.getRestrictedDirectories(); + final restrictedDirs = await StorageService.getRestrictedDirectories(); final dir = VolumeRelativeDirectory.fromPath(album); if (restrictedDirs.contains(dir)) { await showRestrictedDirectoryDialog(context, dir); return; } - final source = context.read(); final newName = await showDialog( context: context, builder: (context) => RenameAlbumDialog(album), @@ -200,15 +211,15 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final todoEntries = source.visibleEntries.where(filter.test).toSet(); - final destinationAlbum = path.join(path.dirname(album), newName); - + final destinationAlbumParent = path.dirname(album); + final destinationAlbum = path.join(destinationAlbumParent, newName); if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; - final l10n = context.l10n; - final messenger = ScaffoldMessenger.of(context); + if (!(await File(destinationAlbum).exists())) { + // access to the destination parent is required to create the underlying destination folder + if (!await checkStoragePermissionForAlbums(context, {destinationAlbumParent})) return; + } - final todoCount = todoEntries.length; source.pauseMonitoring(); showOpReport( context: context, @@ -226,6 +237,9 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } else { showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback); } + + // cleanup + await StorageService.deleteEmptyDirectories({album}); }, ); } diff --git a/lib/widgets/settings/access_grants.dart b/lib/widgets/settings/access_grants.dart index d69ad1b1a..4b1536c9d 100644 --- a/lib/widgets/settings/access_grants.dart +++ b/lib/widgets/settings/access_grants.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; @@ -39,7 +39,7 @@ class _StorageAccessPageState extends State { _load(); } - void _load() => _pathLoader = AndroidFileService.getGrantedDirectories(); + void _load() => _pathLoader = StorageService.getGrantedDirectories(); @override Widget build(BuildContext context) { @@ -87,7 +87,7 @@ class _StorageAccessPageState extends State { trailing: IconButton( icon: Icon(AIcons.clear), onPressed: () async { - await AndroidFileService.revokeDirectoryAccess(path); + await StorageService.revokeDirectoryAccess(path); _load(); setState(() {}); }, diff --git a/test_driver/app.dart b/test_driver/app.dart index abd4bbdd0..22a5a4fb5 100644 --- a/test_driver/app.dart +++ b/test_driver/app.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:aves/main.dart' as app; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:flutter_driver/driver_extension.dart'; import 'package:path/path.dart' as path; @@ -15,7 +15,7 @@ void main() { // scan files copied from test assets // we do it via the app instead of broadcasting via ADB // because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29 - AndroidFileService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'); + StorageService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'); configureAndLaunch(); }