670 lines
18 KiB
Dart
670 lines
18 KiB
Dart
// 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<Map<String, int>> getDataUsage();
|
|
|
|
Future<Set<StorageVolume>> getStorageVolumes();
|
|
|
|
Future<String> getExternalCacheDirectory();
|
|
|
|
Future<Set<String>> getUntrackedTrashPaths(Iterable<String> knownPaths);
|
|
|
|
Future<Set<String>> getUntrackedVaultPaths(
|
|
String vaultName,
|
|
Iterable<String> knownPaths,
|
|
);
|
|
|
|
Future<String> getVaultRoot();
|
|
|
|
Future<int?> getFreeSpace(StorageVolume volume);
|
|
|
|
Future<List<String>> getGrantedDirectories();
|
|
|
|
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(
|
|
Iterable<String> dirPaths,
|
|
);
|
|
|
|
// returns directories with restricted access,
|
|
// with the relative part in lowercase, for case-insensitive comparison
|
|
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectoriesLowerCase();
|
|
|
|
Future<void> revokeDirectoryAccess(String path);
|
|
|
|
// returns number of deleted directories
|
|
Future<int> deleteEmptyRegularDirectories(Set<String> dirPaths);
|
|
|
|
Future<bool> deleteTempDirectory();
|
|
|
|
Future<bool> deleteExternalCache();
|
|
|
|
// returns whether user granted access to a directory of his choosing
|
|
Future<bool> 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<PickedActivityDirectory?> 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<bool> ensureActivityMainDirectory(String treeUri);
|
|
|
|
/// Lists FIT/GPX/TCX files recursively under a SAF tree URI.
|
|
///
|
|
/// Each returned map contains:
|
|
/// - uri
|
|
/// - name
|
|
/// - relativePath
|
|
/// - size
|
|
/// - lastModified
|
|
Future<List<Map<String, Object?>>> listActivityFiles(String treeUri);
|
|
|
|
/// Reads an activity file from a SAF/content URI.
|
|
Future<Uint8List> readActivityFile(String uri);
|
|
|
|
Future<bool> canRequestMediaFileBulkAccess();
|
|
|
|
Future<bool> canInsertMedia(Set<VolumeRelativeDirectory> directories);
|
|
|
|
// returns whether user granted access to URIs
|
|
Future<bool> requestMediaFileAccess(
|
|
List<String> uris,
|
|
List<String> mimeTypes,
|
|
);
|
|
|
|
// returns whether operation succeeded (`null` if user cancelled)
|
|
Future<bool?> createFile(
|
|
String name,
|
|
String mimeType,
|
|
Uint8List bytes,
|
|
);
|
|
|
|
Future<Uint8List> openFile([String? mimeType]);
|
|
|
|
// returns whether operation succeeded (`null` if user cancelled)
|
|
Future<bool?> 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<Map<String, int>> getDataUsage() async {
|
|
try {
|
|
final result = await _platform.invokeMethod('getDataUsage');
|
|
if (result != null) return (result as Map).cast<String, int>();
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
@override
|
|
Future<Set<StorageVolume>> getStorageVolumes() async {
|
|
try {
|
|
final result = await _platform.invokeMethod('getStorageVolumes');
|
|
return (result as List).cast<Map>().map(StorageVolume.fromMap).toSet();
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
@override
|
|
Future<String> getExternalCacheDirectory() async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'getCacheDirectory',
|
|
<String, dynamic>{
|
|
'external': true,
|
|
},
|
|
);
|
|
|
|
return result as String;
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
@override
|
|
Future<Set<String>> getUntrackedTrashPaths(
|
|
Iterable<String> knownPaths,
|
|
) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'getUntrackedTrashPaths',
|
|
<String, dynamic>{
|
|
'knownPaths': knownPaths.toList(),
|
|
},
|
|
);
|
|
|
|
return (result as List).cast<String>().toSet();
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
@override
|
|
Future<Set<String>> getUntrackedVaultPaths(
|
|
String vaultName,
|
|
Iterable<String> knownPaths,
|
|
) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'getUntrackedVaultPaths',
|
|
<String, dynamic>{
|
|
'vault': vaultName,
|
|
'knownPaths': knownPaths.toList(),
|
|
},
|
|
);
|
|
|
|
return (result as List).cast<String>().toSet();
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
@override
|
|
Future<String> 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<int?> getFreeSpace(StorageVolume volume) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'getFreeSpace',
|
|
<String, dynamic>{
|
|
'path': volume.path,
|
|
},
|
|
);
|
|
|
|
return result as int?;
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<List<String>> getGrantedDirectories() async {
|
|
try {
|
|
final result = await _platform.invokeMethod('getGrantedDirectories');
|
|
return (result as List).cast<String>();
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
@override
|
|
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(
|
|
Iterable<String> dirPaths,
|
|
) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'getInaccessibleDirectories',
|
|
<String, dynamic>{
|
|
'dirPaths': dirPaths.toList(),
|
|
},
|
|
);
|
|
|
|
if (result != null) {
|
|
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
|
}
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
@override
|
|
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectoriesLowerCase() async {
|
|
try {
|
|
final result = await _platform.invokeMethod('getRestrictedDirectories');
|
|
|
|
if (result != null) {
|
|
return (result as List)
|
|
.cast<Map>()
|
|
.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<void> revokeDirectoryAccess(String path) async {
|
|
try {
|
|
await _platform.invokeMethod(
|
|
'revokeDirectoryAccess',
|
|
<String, dynamic>{
|
|
'path': path,
|
|
},
|
|
);
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
}
|
|
|
|
// returns number of deleted directories
|
|
@override
|
|
Future<int> deleteEmptyRegularDirectories(Set<String> dirPaths) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'deleteEmptyDirectories',
|
|
<String, dynamic>{
|
|
'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<bool> 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<bool> 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<bool> 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<bool> canInsertMedia(
|
|
Set<VolumeRelativeDirectory> directories,
|
|
) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'canInsertMedia',
|
|
<String, dynamic>{
|
|
'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<bool> requestDirectoryAccess(String path) async {
|
|
try {
|
|
final opCompleter = Completer<bool>();
|
|
|
|
_stream
|
|
.receiveBroadcastStream(<String, dynamic>{
|
|
'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<PickedActivityDirectory?> pickActivityDirectory() async {
|
|
try {
|
|
final opCompleter = Completer<PickedActivityDirectory?>();
|
|
|
|
_stream
|
|
.receiveBroadcastStream(<String, dynamic>{
|
|
'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<bool> ensureActivityMainDirectory(String treeUri) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'ensureActivityMainDirectory',
|
|
<String, dynamic>{
|
|
'treeUri': treeUri,
|
|
},
|
|
);
|
|
|
|
if (result != null) return result as bool;
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Future<List<Map<String, Object?>>> listActivityFiles(String treeUri) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'listActivityFiles',
|
|
<String, dynamic>{
|
|
'treeUri': treeUri,
|
|
},
|
|
);
|
|
|
|
return (result as List).cast<Map>().map((e) => e.cast<String, Object?>()).toList();
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
@override
|
|
Future<Uint8List> readActivityFile(String uri) async {
|
|
try {
|
|
final result = await _platform.invokeMethod(
|
|
'readActivityFile',
|
|
<String, dynamic>{
|
|
'uri': uri,
|
|
},
|
|
);
|
|
|
|
if (result is Uint8List) return result;
|
|
|
|
if (result is List<int>) {
|
|
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<bool> requestMediaFileAccess(
|
|
List<String> uris,
|
|
List<String> mimeTypes,
|
|
) async {
|
|
try {
|
|
final opCompleter = Completer<bool>();
|
|
|
|
_stream
|
|
.receiveBroadcastStream(<String, dynamic>{
|
|
'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<bool?> createFile(
|
|
String name,
|
|
String mimeType,
|
|
Uint8List bytes,
|
|
) async {
|
|
try {
|
|
final opCompleter = Completer<bool?>();
|
|
|
|
_stream
|
|
.receiveBroadcastStream(<String, dynamic>{
|
|
'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<Uint8List> openFile([String? mimeType]) async {
|
|
try {
|
|
final opCompleter = Completer<Uint8List>();
|
|
final sink = OutputBuffer();
|
|
|
|
_stream
|
|
.receiveBroadcastStream(<String, dynamic>{
|
|
'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<bool?> copyFile(
|
|
String name,
|
|
String mimeType,
|
|
String sourceUri,
|
|
) async {
|
|
try {
|
|
final opCompleter = Completer<bool?>();
|
|
|
|
_stream
|
|
.receiveBroadcastStream(<String, dynamic>{
|
|
'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;
|
|
}
|
|
}
|