delete empty directories, if possible, after move/rename file ops

This commit is contained in:
Thibault Deckers 2021-03-23 10:33:15 +09:00
parent 66c1566eeb
commit 0464bd8678
10 changed files with 103 additions and 45 deletions

View file

@ -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<List<String>>("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<String>("path")
val mimeType = call.argument<String>("mimeType")

View file

@ -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<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
try {
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
'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<Uri> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType');

View file

@ -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<void> 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');

View file

@ -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<void> _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<bool>(
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<ImageOpEvent>(
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);
},
);
}

View file

@ -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<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> 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;

View file

@ -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) {

View file

@ -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<DebugStorageSection> 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);
});
}

View file

@ -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<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>();
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<bool>(
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<ImageOpEvent>(
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<void> _showRenameDialog(BuildContext context, AlbumFilter filter) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>();
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<CollectionSource>();
final newName = await showDialog<String>(
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<MoveOpEvent>(
context: context,
@ -226,6 +237,9 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
} else {
showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback);
}
// cleanup
await StorageService.deleteEmptyDirectories({album});
},
);
}

View file

@ -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<StorageAccessPage> {
_load();
}
void _load() => _pathLoader = AndroidFileService.getGrantedDirectories();
void _load() => _pathLoader = StorageService.getGrantedDirectories();
@override
Widget build(BuildContext context) {
@ -87,7 +87,7 @@ class _StorageAccessPageState extends State<StorageAccessPage> {
trailing: IconButton(
icon: Icon(AIcons.clear),
onPressed: () async {
await AndroidFileService.revokeDirectoryAccess(path);
await StorageService.revokeDirectoryAccess(path);
_load();
setState(() {});
},

View file

@ -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();
}