#913 recover untracked vault items

This commit is contained in:
Thibault Deckers 2024-02-22 20:14:31 +01:00
parent a0925273bf
commit 413794cb74
9 changed files with 97 additions and 22 deletions

View file

@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- untracked binned items recovery - untracked binned items recovery
- untracked vault items recovery
## <a id="v1.10.4"></a>[v1.10.4] - 2024-02-07 ## <a id="v1.10.4"></a>[v1.10.4] - 2024-02-07

View file

@ -30,6 +30,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) } "getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) } "getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) } "getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) } "getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) } "getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) } "getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
@ -129,13 +130,28 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) { private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
val knownPaths = call.argument<List<String>>("knownPaths") val knownPaths = call.argument<List<String>>("knownPaths")
if (knownPaths == null) { if (knownPaths == null) {
result.error("getUntrackedBinPaths-args", "missing arguments", null) result.error("getUntrackedTrashPaths-args", "missing arguments", null)
return return
} }
val trashDirs = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) } val trashDirs = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) }
val trashPaths = trashDirs.flatMap { dir -> dir.listFiles()?.map { file -> file.path } ?: listOf() } val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.map { file -> file.path } ?: listOf() }
val untrackedPaths = trashPaths.filter { !knownPaths.contains(it) }.toList() val untrackedPaths = trashItemPaths.filterNot(knownPaths::contains).toList()
result.success(untrackedPaths)
}
private fun getUntrackedVaultPaths(call: MethodCall, result: MethodChannel.Result) {
val vault = call.argument<String>("vault")
val knownPaths = call.argument<List<String>>("knownPaths")
if (vault == null || knownPaths == null) {
result.error("getUntrackedVaultPaths-args", "missing arguments", null)
return
}
val vaultDir = File(StorageUtils.getVaultRoot(context), vault)
val vaultItemPaths = vaultDir.listFiles()?.map { file -> file.path } ?: listOf()
val untrackedPaths = vaultItemPaths.filterNot(knownPaths::contains).toList()
result.success(untrackedPaths) result.success(untrackedPaths)
} }

View file

@ -159,7 +159,7 @@ object SafePngMetadataReader {
// Only compression method allowed by the spec is zero: deflate // Only compression method allowed by the spec is zero: deflate
if (compressionMethod.toInt() == 0) { if (compressionMethod.toInt() == 0) {
// bytes left for compressed text is: // bytes left for compressed text is:
// total bytes length - (profilenamebytes length + null byte + compression method byte) // total bytes length - (profileNameBytes length + null byte + compression method byte)
val bytesLeft = bytes.size - (profileNameBytes.size + 1 + 1) val bytesLeft = bytes.size - (profileNameBytes.size + 1 + 1)
val compressedProfile = reader.getBytes(bytesLeft) val compressedProfile = reader.getBytes(bytesLeft)
try { try {

View file

@ -154,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
// recover untracked trash items // recover untracked trash items
debugPrint('$runtimeType refresh ${stopwatch.elapsed} recover untracked entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} recover untracked entries');
if (directory == null) { if (directory == null) {
pendingNewEntries.addAll(await recoverLostTrashItems()); pendingNewEntries.addAll(await recoverUntrackedTrashItems());
} }
// fetch new & modified entries // fetch new & modified entries

View file

@ -38,10 +38,11 @@ mixin TrashMixin on SourceBase {
return await completer.future; return await completer.future;
} }
Future<Set<AvesEntry>> recoverLostTrashItems() async { Future<Set<AvesEntry>> recoverUntrackedTrashItems() async {
final newEntries = <AvesEntry>{};
final knownPaths = allEntries.map((v) => v.trashDetails?.path).whereNotNull().toSet(); final knownPaths = allEntries.map((v) => v.trashDetails?.path).whereNotNull().toSet();
final untrackedPaths = await storageService.getUntrackedTrashPaths(knownPaths); final untrackedPaths = await storageService.getUntrackedTrashPaths(knownPaths);
final newEntries = <AvesEntry>{};
if (untrackedPaths.isNotEmpty) { if (untrackedPaths.isNotEmpty) {
debugPrint('Recovering ${untrackedPaths.length} untracked bin items'); debugPrint('Recovering ${untrackedPaths.length} untracked bin items');
final recoveryPath = pContext.join(androidFileUtils.picturesPath, AndroidFileUtils.recoveryDir); final recoveryPath = pContext.join(androidFileUtils.picturesPath, AndroidFileUtils.recoveryDir);
@ -75,7 +76,7 @@ mixin TrashMixin on SourceBase {
sourceEntry.trashDetails = _buildTrashDetails(id); sourceEntry.trashDetails = _buildTrashDetails(id);
newEntries.add(sourceEntry); newEntries.add(sourceEntry);
} else { } else {
debugPrint('Failed to recover untracked bin item at uri=$uri'); await reportService.recordError('Failed to recover untracked bin item at uri=$uri', null);
} }
} }
}); });

View file

@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/origins.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/vaults/details.dart'; import 'package:aves/model/vaults/details.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves_screen_state/aves_screen_state.dart'; import 'package:aves_screen_state/aves_screen_state.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
final Vaults vaults = Vaults._private(); final Vaults vaults = Vaults._private();
@ -14,6 +18,8 @@ class Vaults extends ChangeNotifier {
Set<VaultDetails> _rows = {}; Set<VaultDetails> _rows = {};
final Set<String> _unlockedDirPaths = {}; final Set<String> _unlockedDirPaths = {};
static const _fileScheme = 'file';
Vaults._private(); Vaults._private();
Future<void> init() async { Future<void> init() async {
@ -118,7 +124,7 @@ class Vaults extends ChangeNotifier {
bool isVaultEntryUri(String uriString) { bool isVaultEntryUri(String uriString) {
final uri = Uri.parse(uriString); final uri = Uri.parse(uriString);
if (uri.scheme != 'file') return false; if (uri.scheme != _fileScheme) return false;
final path = uri.pathSegments.fold('', (prev, v) => '$prev${pContext.separator}$v'); final path = uri.pathSegments.fold('', (prev, v) => '$prev${pContext.separator}$v');
return vaultDirectories.any(path.startsWith); return vaultDirectories.any(path.startsWith);
@ -132,13 +138,47 @@ class Vaults extends ChangeNotifier {
_onLockStateChanged(); _onLockStateChanged();
} }
void unlock(String dirPath) { Future<void> unlock(BuildContext context, String dirPath) async {
if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return; if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return;
// recover untracked vault items
final source = context.read<CollectionSource>();
final newEntries = await recoverUntrackedItems(source, dirPath);
if (newEntries.isNotEmpty) {
source.addEntries(newEntries);
await metadataDb.saveEntries(newEntries);
unawaited(source.analyze(null, entries: newEntries));
}
_unlockedDirPaths.add(dirPath); _unlockedDirPaths.add(dirPath);
_onLockStateChanged(); _onLockStateChanged();
} }
Future<Set<AvesEntry>> recoverUntrackedItems(CollectionSource source, String dirPath) async {
final newEntries = <AvesEntry>{};
final vaultName = detailsForPath(dirPath)?.name;
if (vaultName == null) return newEntries;
final knownPaths = source.allEntries.where((v) => v.origin == EntryOrigins.vault && v.directory == dirPath).map((v) => v.path).whereNotNull().toSet();
final untrackedPaths = await storageService.getUntrackedVaultPaths(vaultName, knownPaths);
if (untrackedPaths.isNotEmpty) {
debugPrint('Recovering ${untrackedPaths.length} untracked vault items');
await Future.forEach(untrackedPaths, (untrackedPath) async {
final uri = Uri.file(untrackedPath).toString();
final sourceEntry = await mediaFetchService.getEntry(uri, null);
if (sourceEntry != null) {
sourceEntry.id = metadataDb.nextId;
sourceEntry.origin = EntryOrigins.vault;
newEntries.add(sourceEntry);
} else {
await reportService.recordError('Failed to recover untracked vault item at uri=$uri', null);
}
});
}
return newEntries;
}
void _onScreenOff() => lock(all.where((v) => v.autoLockScreenOff).map((v) => v.path).toSet()); void _onScreenOff() => lock(all.where((v) => v.autoLockScreenOff).map((v) => v.path).toSet());
void _onLockStateChanged() { void _onLockStateChanged() {

View file

@ -14,6 +14,8 @@ abstract class StorageService {
Future<Set<String>> getUntrackedTrashPaths(Iterable<String> knownPaths); Future<Set<String>> getUntrackedTrashPaths(Iterable<String> knownPaths);
Future<Set<String>> getUntrackedVaultPaths(String vaultName, Iterable<String> knownPaths);
Future<String> getVaultRoot(); Future<String> getVaultRoot();
Future<int?> getFreeSpace(StorageVolume volume); Future<int?> getFreeSpace(StorageVolume volume);
@ -86,6 +88,20 @@ class PlatformStorageService implements StorageService {
return {}; 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 @override
Future<String> getVaultRoot() async { Future<String> getVaultRoot() async {
try { try {

View file

@ -16,7 +16,7 @@ import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
mixin VaultAwareMixin on FeedbackMixin { mixin VaultAwareMixin on FeedbackMixin {
Future<bool> _tryUnlock(String dirPath, BuildContext context) async { Future<bool> _tryUnlock(BuildContext context, String dirPath) async {
if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return true; if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return true;
final details = vaults.detailsForPath(dirPath); final details = vaults.detailsForPath(dirPath);
@ -67,12 +67,12 @@ mixin VaultAwareMixin on FeedbackMixin {
if (confirmed == null || !confirmed) return false; if (confirmed == null || !confirmed) return false;
vaults.unlock(dirPath); await vaults.unlock(context, dirPath);
return true; return true;
} }
Future<bool> unlockAlbum(BuildContext context, String dirPath) async { Future<bool> unlockAlbum(BuildContext context, String dirPath) async {
final success = await _tryUnlock(dirPath, context); final success = await _tryUnlock(context, dirPath);
if (!success) { if (!success) {
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback); showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
} }

View file

@ -85,7 +85,14 @@ class _DbTabState extends State<DbTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('DB entry:${data == null ? ' no row' : ''}'), Text('DB entry:${data == null ? ' no row' : ''}'),
if (data != null) if (data != null) ...[
ElevatedButton(
onPressed: () async {
final source = context.read<CollectionSource>();
await source.removeEntries({entry.uri}, includeTrash: true);
},
child: const Text('Untrack entry'),
),
InfoRowGroup( InfoRowGroup(
info: { info: {
'uri': data.uri, 'uri': data.uri,
@ -104,6 +111,7 @@ class _DbTabState extends State<DbTab> {
}, },
), ),
], ],
],
); );
}, },
), ),
@ -181,13 +189,6 @@ class _DbTabState extends State<DbTab> {
}, },
child: const Text('Remove details'), child: const Text('Remove details'),
), ),
ElevatedButton(
onPressed: () async {
final source = context.read<CollectionSource>();
await source.removeEntries({entry.uri}, includeTrash: true);
},
child: const Text('Untrack entry'),
),
InfoRowGroup( InfoRowGroup(
info: { info: {
'dateMillis': '${data.dateMillis}', 'dateMillis': '${data.dateMillis}',