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 5115f7c70..ab2147817 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 @@ -28,6 +28,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { } result.success(volumes) } + "getFreeSpace" -> getFreeSpace(call, result) "getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context))) "getInaccessibleDirectories" -> getInaccessibleDirectories(call, result) "revokeDirectoryAccess" -> revokeDirectoryAccess(call, result) @@ -62,6 +63,35 @@ class StorageHandler(private val context: Context) : MethodCallHandler { return volumes } + private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) { + val path = call.argument("path") + if (path == null) { + result.error("getFreeSpace-args", "failed because of missing arguments", null) + return + } + + val sm = context.getSystemService(StorageManager::class.java) + if (sm == null) { + result.error("getFreeSpace-sm", "failed because of missing Storage Manager", null) + return + } + + val file = File(path) + val volume = sm.getStorageVolume(file) + if (volume == null) { + result.error("getFreeSpace-volume", "failed because of missing volume for path=$path", null) + return + } + + // `StorageStatsManager` `getFreeBytes()` is only available from API 26, + // and non-primary volume UUIDs cannot be used with it + try { + result.success(file.freeSpace) + } catch (e: SecurityException) { + result.error("getFreeSpace-security", "failed because of missing access", e.message) + } + } + private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) { val dirPaths = call.argument>("dirPaths") if (dirPaths == null) { diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart index 979bb1331..0a0bb2f12 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/android_file_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -18,6 +19,18 @@ class AndroidFileService { return []; } + static Future getFreeSpace(StorageVolume volume) async { + try { + final result = await platform.invokeMethod('getFreeSpace', { + 'path': volume.path, + }); + return result as int; + } on PlatformException catch (e) { + debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return 0; + } + static Future> getGrantedDirectories() async { try { final result = await platform.invokeMethod('getGrantedDirectories'); diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 837776778..966ed4272 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -12,6 +12,7 @@ import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart'; import 'package:aves/widgets/common/action_delegates/feedback.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; +import 'package:aves/widgets/common/action_delegates/size_aware.dart'; import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/icons.dart'; @@ -25,7 +26,7 @@ import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { +class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionLens collection; SelectionActionDelegate({ @@ -116,6 +117,8 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { final selection = collection.selection.toList(); if (!await checkStoragePermission(context, selection)) return; + if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, copy)) return; + showOpReport( context: context, selection: selection, diff --git a/lib/widgets/common/action_delegates/size_aware.dart b/lib/widgets/common/action_delegates/size_aware.dart new file mode 100644 index 000000000..4198846bb --- /dev/null +++ b/lib/widgets/common/action_delegates/size_aware.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/android_file_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/file_utils.dart'; +import 'package:aves/widgets/common/aves_dialog.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +mixin SizeAwareMixin { + Future checkFreeSpaceForMove(BuildContext context, List selection, String destinationAlbum, bool copy) async { + final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); + final free = await AndroidFileService.getFreeSpace(destinationVolume); + int needed; + int sumSize(sum, entry) => sum + entry.sizeBytes; + if (copy) { + needed = selection.fold(0, sumSize); + } else { + // when moving, we only need space for the entries that are not already on the destination volume + final byVolume = groupBy(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)); + final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); + final fromOtherVolumes = otherVolumes.fold(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize)); + // and we need at least as much space as the largest entry because individual entries are copied then deleted + final largestSingle = selection.fold(0, (largest, entry) => max(largest, entry.sizeBytes)); + needed = max(fromOtherVolumes, largestSingle); + } + + final hasEnoughSpace = needed < free; + if (!hasEnoughSpace) { + await showDialog( + context: context, + builder: (context) { + return AvesDialog( + title: 'Not Enough Space', + content: Text('This operation needs ${formatFilesize(needed)} of free space on “${destinationVolume.description}” to complete, but there is only ${formatFilesize(free)} left.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('OK'.toUpperCase()), + ), + ], + ); + }, + ); + } + return hasEnoughSpace; + } +} diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index 804f2f1d1..1e62b90bb 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -1,32 +1,59 @@ +import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:flutter/material.dart'; -class DebugStorageSection extends StatelessWidget { +class DebugStorageSection extends StatefulWidget { + @override + _DebugStorageSectionState createState() => _DebugStorageSectionState(); +} + +class _DebugStorageSectionState extends State with AutomaticKeepAliveClientMixin { + final Map _freeSpaceByVolume = {}; + + @override + void initState() { + super.initState(); + androidFileUtils.storageVolumes.forEach((volume) async { + final byteCount = await AndroidFileService.getFreeSpace(volume); + setState(() => _freeSpaceByVolume[volume.path] = byteCount); + }); + } + @override Widget build(BuildContext context) { + super.build(context); + return AvesExpansionTile( title: 'Storage Volumes', children: [ - ...androidFileUtils.storageVolumes.expand((v) => [ - Padding( - padding: EdgeInsets.all(8), - child: Text(v.path), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: InfoRowGroup({ - 'description': '${v.description}', - 'isEmulated': '${v.isEmulated}', - 'isPrimary': '${v.isPrimary}', - 'isRemovable': '${v.isRemovable}', - 'state': '${v.state}', - }), - ), - Divider(), - ]) + ...androidFileUtils.storageVolumes.expand((v) { + final freeSpace = _freeSpaceByVolume[v.path]; + return [ + Padding( + padding: EdgeInsets.all(8), + child: Text(v.path), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: InfoRowGroup({ + 'description': '${v.description}', + 'isEmulated': '${v.isEmulated}', + 'isPrimary': '${v.isPrimary}', + 'isRemovable': '${v.isRemovable}', + 'state': '${v.state}', + if (freeSpace != null) 'freeSpace': formatFilesize(freeSpace), + }), + ), + Divider(), + ]; + }) ], ); } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 135443064..248e966da 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -7,6 +7,7 @@ import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/common/action_delegates/feedback.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/action_delegates/rename_album_dialog.dart'; +import 'package:aves/widgets/common/action_delegates/size_aware.dart'; import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; import 'package:flutter/material.dart'; @@ -33,7 +34,7 @@ class ChipActionDelegate { } } -class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin { +class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionSource source; AlbumChipActionDelegate({ @@ -113,6 +114,8 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per final selection = source.rawEntries.where(filter.filter).toList(); final destinationAlbum = path.join(path.dirname(album), newName); + if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, false)) return; + showOpReport( context: context, selection: selection,