#913 recover untracked vault items
This commit is contained in:
parent
a0925273bf
commit
413794cb74
9 changed files with 97 additions and 22 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}',
|
||||||
|
|
Loading…
Reference in a new issue