// lib/services/storage_service.dart import 'dart:async'; import 'package:aves/model/covers.dart'; import 'package:aves/services/common/channel.dart'; import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/services.dart'; class PickedActivityDirectory { final String treeUri; final String? path; const PickedActivityDirectory({ required this.treeUri, required this.path, }); factory PickedActivityDirectory.fromMap(Map map) { return PickedActivityDirectory( treeUri: map['treeUri'] as String, path: map['path'] as String?, ); } } abstract class StorageService { Future> getDataUsage(); Future> getStorageVolumes(); Future getExternalCacheDirectory(); Future> getUntrackedTrashPaths(Iterable knownPaths); Future> getUntrackedVaultPaths( String vaultName, Iterable knownPaths, ); Future getVaultRoot(); Future getFreeSpace(StorageVolume volume); Future> getGrantedDirectories(); Future> getInaccessibleDirectories( Iterable dirPaths, ); // returns directories with restricted access, // with the relative part in lowercase, for case-insensitive comparison Future> getRestrictedDirectoriesLowerCase(); Future revokeDirectoryAccess(String path); // returns number of deleted directories Future deleteEmptyRegularDirectories(Set dirPaths); Future deleteTempDirectory(); Future deleteExternalCache(); // returns whether user granted access to a directory of his choosing Future requestDirectoryAccess(String path); /// Opens Android Storage Access Framework directory picker and returns /// the selected tree URI and resolved path when available. /// /// This is needed for activity scanning because FIT/GPX/TCX files are not /// regular media files, and direct access to `/storage/emulated/0/...` /// may be blocked by scoped storage. Future pickActivityDirectory(); /// Ensures `main` exists under the selected Activity root SAF tree URI. /// /// Expected root: /// `/storage/emulated/0/Activity` /// /// Ensured child: /// `/storage/emulated/0/Activity/main` Future ensureActivityMainDirectory(String treeUri); /// Lists FIT/GPX/TCX files recursively under a SAF tree URI. /// /// Each returned map contains: /// - uri /// - name /// - relativePath /// - size /// - lastModified Future>> listActivityFiles(String treeUri); /// Reads an activity file from a SAF/content URI. Future readActivityFile(String uri); Future canRequestMediaFileBulkAccess(); Future canInsertMedia(Set directories); // returns whether user granted access to URIs Future requestMediaFileAccess( List uris, List mimeTypes, ); // returns whether operation succeeded (`null` if user cancelled) Future createFile( String name, String mimeType, Uint8List bytes, ); Future openFile([String? mimeType]); // returns whether operation succeeded (`null` if user cancelled) Future copyFile( String name, String mimeType, String sourceUri, ); } class PlatformStorageService implements StorageService { static const _platform = AvesMethodChannel('deckers.thibault/aves/storage'); static final _stream = AvesStreamsChannel( 'deckers.thibault/aves/activity_result_stream', ); @override Future> getDataUsage() async { try { final result = await _platform.invokeMethod('getDataUsage'); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future> getStorageVolumes() async { try { final result = await _platform.invokeMethod('getStorageVolumes'); return (result as List).cast().map(StorageVolume.fromMap).toSet(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future getExternalCacheDirectory() async { try { final result = await _platform.invokeMethod( 'getCacheDirectory', { 'external': true, }, ); return result as String; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return ''; } @override Future> getUntrackedTrashPaths( Iterable knownPaths, ) async { try { final result = await _platform.invokeMethod( 'getUntrackedTrashPaths', { 'knownPaths': knownPaths.toList(), }, ); return (result as List).cast().toSet(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future> getUntrackedVaultPaths( String vaultName, Iterable knownPaths, ) async { try { final result = await _platform.invokeMethod( 'getUntrackedVaultPaths', { 'vault': vaultName, 'knownPaths': knownPaths.toList(), }, ); return (result as List).cast().toSet(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future getVaultRoot() async { try { final result = await _platform.invokeMethod('getVaultRoot'); return result as String; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return ''; } @override Future getFreeSpace(StorageVolume volume) async { try { final result = await _platform.invokeMethod( 'getFreeSpace', { 'path': volume.path, }, ); return result as int?; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return null; } @override Future> getGrantedDirectories() async { try { final result = await _platform.invokeMethod('getGrantedDirectories'); return (result as List).cast(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return []; } @override Future> getInaccessibleDirectories( Iterable dirPaths, ) async { try { final result = await _platform.invokeMethod( 'getInaccessibleDirectories', { 'dirPaths': dirPaths.toList(), }, ); if (result != null) { return (result as List).cast().map(VolumeRelativeDirectory.fromMap).toSet(); } } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future> getRestrictedDirectoriesLowerCase() async { try { final result = await _platform.invokeMethod('getRestrictedDirectories'); if (result != null) { return (result as List) .cast() .map(VolumeRelativeDirectory.fromMap) .map( (dir) => dir.copyWith( relativeDir: dir.relativeDir.toLowerCase(), ), ) .toSet(); } } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future revokeDirectoryAccess(String path) async { try { await _platform.invokeMethod( 'revokeDirectoryAccess', { 'path': path, }, ); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } } // returns number of deleted directories @override Future deleteEmptyRegularDirectories(Set dirPaths) async { try { final result = await _platform.invokeMethod( 'deleteEmptyDirectories', { 'dirPaths': dirPaths.where((v) => covers.effectiveAlbumType(v) == AlbumType.regular).toList(), }, ); if (result != null) return result as int; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return 0; } @override Future deleteTempDirectory() async { try { final result = await _platform.invokeMethod('deleteTempDirectory'); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future deleteExternalCache() async { try { final result = await _platform.invokeMethod('deleteExternalCache'); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future canRequestMediaFileBulkAccess() async { try { final result = await _platform.invokeMethod('canRequestMediaFileBulkAccess'); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future canInsertMedia( Set directories, ) async { try { final result = await _platform.invokeMethod( 'canInsertMedia', { 'directories': directories.map((v) => v.toMap()).toList(), }, ); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } // returns whether user granted access to a directory of his choosing @override Future requestDirectoryAccess(String path) async { try { final opCompleter = Completer(); _stream .receiveBroadcastStream({ 'op': 'requestDirectoryAccess', 'path': path, }) .listen( (data) => opCompleter.complete(data as bool), onError: opCompleter.completeError, onDone: () { if (!opCompleter.isCompleted) opCompleter.complete(false); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below return await opCompleter.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future pickActivityDirectory() async { try { final opCompleter = Completer(); _stream .receiveBroadcastStream({ 'op': 'pickActivityDirectory', }) .listen( (data) { if (data == null) { opCompleter.complete(null); return; } if (data is Map) { opCompleter.complete( PickedActivityDirectory.fromMap(data), ); return; } // Backward compatibility: older native side returned only String. if (data is String && data.isNotEmpty) { opCompleter.complete( PickedActivityDirectory( treeUri: data, path: null, ), ); return; } opCompleter.complete(null); }, onError: opCompleter.completeError, onDone: () { if (!opCompleter.isCompleted) opCompleter.complete(null); }, cancelOnError: true, ); return await opCompleter.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return null; } @override Future ensureActivityMainDirectory(String treeUri) async { try { final result = await _platform.invokeMethod( 'ensureActivityMainDirectory', { 'treeUri': treeUri, }, ); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future>> listActivityFiles(String treeUri) async { try { final result = await _platform.invokeMethod( 'listActivityFiles', { 'treeUri': treeUri, }, ); return (result as List).cast().map((e) => e.cast()).toList(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return []; } @override Future readActivityFile(String uri) async { try { final result = await _platform.invokeMethod( 'readActivityFile', { 'uri': uri, }, ); if (result is Uint8List) return result; if (result is List) { return Uint8List.fromList(result); } } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return Uint8List(0); } // returns whether user granted access to URIs @override Future requestMediaFileAccess( List uris, List mimeTypes, ) async { try { final opCompleter = Completer(); _stream .receiveBroadcastStream({ 'op': 'requestMediaFileAccess', 'uris': uris, 'mimeTypes': mimeTypes, }) .listen( (data) => opCompleter.complete(data as bool), onError: opCompleter.completeError, onDone: () { if (!opCompleter.isCompleted) opCompleter.complete(false); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below return await opCompleter.future; } on PlatformException catch (e, stack) { final message = e.message; // mute issue in the specific case when an item: // 1) is a Media Store `file` content, // 2) has no `images` or `video` entry, // 3) is in a restricted directory if (message == null || !message.contains('/external/file/')) { await reportService.recordError(e, stack); } } return false; } @override Future createFile( String name, String mimeType, Uint8List bytes, ) async { try { final opCompleter = Completer(); _stream .receiveBroadcastStream({ 'op': 'createFile', 'name': name, 'mimeType': mimeType, 'bytes': bytes, }) .listen( (data) => opCompleter.complete(data as bool?), onError: opCompleter.completeError, onDone: () { if (!opCompleter.isCompleted) opCompleter.complete(false); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below return await opCompleter.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future openFile([String? mimeType]) async { try { final opCompleter = Completer(); final sink = OutputBuffer(); _stream .receiveBroadcastStream({ 'op': 'openFile', 'mimeType': mimeType, }) .listen( (data) { final chunk = data as Uint8List; sink.add(chunk); }, onError: opCompleter.completeError, onDone: () { sink.close(); opCompleter.complete(sink.bytes); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below return await opCompleter.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return Uint8List(0); } @override Future copyFile( String name, String mimeType, String sourceUri, ) async { try { final opCompleter = Completer(); _stream .receiveBroadcastStream({ 'op': 'copyFile', 'name': name, 'mimeType': mimeType, 'sourceUri': sourceUri, }) .listen( (data) => opCompleter.complete(data as bool?), onError: opCompleter.completeError, onDone: () { if (!opCompleter.isCompleted) opCompleter.complete(false); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below return await opCompleter.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } }