From 413794cb7497b45560b8ce73518eb181c699a3b7 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 22 Feb 2024 20:14:31 +0100 Subject: [PATCH] #913 recover untracked vault items --- CHANGELOG.md | 1 + .../aves/channel/calls/StorageHandler.kt | 22 +++++++-- .../SafePngMetadataReader.kt | 2 +- lib/model/source/media_store_source.dart | 2 +- lib/model/source/trash.dart | 7 +-- lib/model/vaults/vaults.dart | 46 +++++++++++++++++-- lib/services/storage_service.dart | 16 +++++++ .../common/action_mixins/vault_aware.dart | 6 +-- lib/widgets/viewer/debug/db.dart | 17 +++---- 9 files changed, 97 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee05054a6..c67c31539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file. ### Fixed - untracked binned items recovery +- untracked vault items recovery ## [v1.10.4] - 2024-02-07 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 43a5d46c8..62754b9be 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 @@ -30,6 +30,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) } "getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) } "getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) } + "getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) } "getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) } "getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) } "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) { val knownPaths = call.argument>("knownPaths") if (knownPaths == null) { - result.error("getUntrackedBinPaths-args", "missing arguments", null) + result.error("getUntrackedTrashPaths-args", "missing arguments", null) return } val trashDirs = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) } - val trashPaths = trashDirs.flatMap { dir -> dir.listFiles()?.map { file -> file.path } ?: listOf() } - val untrackedPaths = trashPaths.filter { !knownPaths.contains(it) }.toList() + val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.map { file -> file.path } ?: listOf() } + val untrackedPaths = trashItemPaths.filterNot(knownPaths::contains).toList() + + result.success(untrackedPaths) + } + + private fun getUntrackedVaultPaths(call: MethodCall, result: MethodChannel.Result) { + val vault = call.argument("vault") + val knownPaths = call.argument>("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) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt index 05822f03a..97498c0e4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt @@ -159,7 +159,7 @@ object SafePngMetadataReader { // Only compression method allowed by the spec is zero: deflate if (compressionMethod.toInt() == 0) { // 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 compressedProfile = reader.getBytes(bytesLeft) try { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 74d854a43..d118dd101 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -154,7 +154,7 @@ class MediaStoreSource extends CollectionSource { // recover untracked trash items debugPrint('$runtimeType refresh ${stopwatch.elapsed} recover untracked entries'); if (directory == null) { - pendingNewEntries.addAll(await recoverLostTrashItems()); + pendingNewEntries.addAll(await recoverUntrackedTrashItems()); } // fetch new & modified entries diff --git a/lib/model/source/trash.dart b/lib/model/source/trash.dart index fac1387ff..0249da2d4 100644 --- a/lib/model/source/trash.dart +++ b/lib/model/source/trash.dart @@ -38,10 +38,11 @@ mixin TrashMixin on SourceBase { return await completer.future; } - Future> recoverLostTrashItems() async { + Future> recoverUntrackedTrashItems() async { + final newEntries = {}; + final knownPaths = allEntries.map((v) => v.trashDetails?.path).whereNotNull().toSet(); final untrackedPaths = await storageService.getUntrackedTrashPaths(knownPaths); - final newEntries = {}; if (untrackedPaths.isNotEmpty) { debugPrint('Recovering ${untrackedPaths.length} untracked bin items'); final recoveryPath = pContext.join(androidFileUtils.picturesPath, AndroidFileUtils.recoveryDir); @@ -75,7 +76,7 @@ mixin TrashMixin on SourceBase { sourceEntry.trashDetails = _buildTrashDetails(id); newEntries.add(sourceEntry); } else { - debugPrint('Failed to recover untracked bin item at uri=$uri'); + await reportService.recordError('Failed to recover untracked bin item at uri=$uri', null); } } }); diff --git a/lib/model/vaults/vaults.dart b/lib/model/vaults/vaults.dart index da80ec112..90a442dab 100644 --- a/lib/model/vaults/vaults.dart +++ b/lib/model/vaults/vaults.dart @@ -1,11 +1,15 @@ import 'dart:async'; 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/services/common/services.dart'; import 'package:aves_screen_state/aves_screen_state.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(); @@ -14,6 +18,8 @@ class Vaults extends ChangeNotifier { Set _rows = {}; final Set _unlockedDirPaths = {}; + static const _fileScheme = 'file'; + Vaults._private(); Future init() async { @@ -118,7 +124,7 @@ class Vaults extends ChangeNotifier { bool isVaultEntryUri(String 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'); return vaultDirectories.any(path.startsWith); @@ -132,13 +138,47 @@ class Vaults extends ChangeNotifier { _onLockStateChanged(); } - void unlock(String dirPath) { + Future unlock(BuildContext context, String dirPath) async { if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return; + // recover untracked vault items + final source = context.read(); + 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); _onLockStateChanged(); } + Future> recoverUntrackedItems(CollectionSource source, String dirPath) async { + final newEntries = {}; + + 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 _onLockStateChanged() { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 648a59bd3..6bf1b681d 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -14,6 +14,8 @@ abstract class StorageService { Future> getUntrackedTrashPaths(Iterable knownPaths); + Future> getUntrackedVaultPaths(String vaultName, Iterable knownPaths); + Future getVaultRoot(); Future getFreeSpace(StorageVolume volume); @@ -86,6 +88,20 @@ class PlatformStorageService implements StorageService { 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 { diff --git a/lib/widgets/common/action_mixins/vault_aware.dart b/lib/widgets/common/action_mixins/vault_aware.dart index 2da5a7662..22f6f9f74 100644 --- a/lib/widgets/common/action_mixins/vault_aware.dart +++ b/lib/widgets/common/action_mixins/vault_aware.dart @@ -16,7 +16,7 @@ import 'package:local_auth/error_codes.dart' as auth_error; import 'package:local_auth/local_auth.dart'; mixin VaultAwareMixin on FeedbackMixin { - Future _tryUnlock(String dirPath, BuildContext context) async { + Future _tryUnlock(BuildContext context, String dirPath) async { if (!vaults.isVault(dirPath) || !vaults.isLocked(dirPath)) return true; final details = vaults.detailsForPath(dirPath); @@ -67,12 +67,12 @@ mixin VaultAwareMixin on FeedbackMixin { if (confirmed == null || !confirmed) return false; - vaults.unlock(dirPath); + await vaults.unlock(context, dirPath); return true; } Future unlockAlbum(BuildContext context, String dirPath) async { - final success = await _tryUnlock(dirPath, context); + final success = await _tryUnlock(context, dirPath); if (!success) { showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback); } diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index 105c13be9..7d156a89e 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -85,7 +85,14 @@ class _DbTabState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('DB entry:${data == null ? ' no row' : ''}'), - if (data != null) + if (data != null) ...[ + ElevatedButton( + onPressed: () async { + final source = context.read(); + await source.removeEntries({entry.uri}, includeTrash: true); + }, + child: const Text('Untrack entry'), + ), InfoRowGroup( info: { 'uri': data.uri, @@ -103,6 +110,7 @@ class _DbTabState extends State { 'trashed': '${data.trashed}', }, ), + ], ], ); }, @@ -181,13 +189,6 @@ class _DbTabState extends State { }, child: const Text('Remove details'), ), - ElevatedButton( - onPressed: () async { - final source = context.read(); - await source.removeEntries({entry.uri}, includeTrash: true); - }, - child: const Text('Untrack entry'), - ), InfoRowGroup( info: { 'dateMillis': '${data.dateMillis}',