recover untracked binned items

This commit is contained in:
Thibault Deckers 2024-02-22 17:49:45 +01:00
parent 6db333f73b
commit 31b9b633ae
9 changed files with 135 additions and 7 deletions

View file

@ -14,6 +14,10 @@ All notable changes to this project will be documented in this file.
- upgraded Flutter to stable v3.19.1 - upgraded Flutter to stable v3.19.1
### Fixed
- untracked binned 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
### Fixed ### Fixed

View file

@ -29,6 +29,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
when (call.method) { when (call.method) {
"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) }
"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) }
@ -125,6 +126,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
result.success(volumes) result.success(volumes)
} }
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
val knownPaths = call.argument<List<String>>("knownPaths")
if (knownPaths == null) {
result.error("getUntrackedBinPaths-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()
result.success(untrackedPaths)
}
private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(StorageUtils.getVaultRoot(context)) result.success(StorageUtils.getVaultRoot(context))
} }

View file

@ -16,8 +16,16 @@ internal class FileImageProvider : ImageProvider() {
var mimeType = sourceMimeType var mimeType = sourceMimeType
if (mimeType == null) { if (mimeType == null) {
val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) var extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
if (extension != null) { if (extension.isEmpty()) {
uri.path?.let { path ->
val lastDotIndex = path.lastIndexOf('.')
if (lastDotIndex >= 0) {
extension = path.substring(lastDotIndex + 1)
}
}
}
if (extension.isNotEmpty()) {
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
} }
} }

View file

@ -38,6 +38,8 @@ mixin SourceBase {
Map<int, AvesEntry> get entryById; Map<int, AvesEntry> get entryById;
Set<AvesEntry> get allEntries;
Set<AvesEntry> get visibleEntries; Set<AvesEntry> get visibleEntries;
Set<AvesEntry> get trashedEntries; Set<AvesEntry> get trashedEntries;
@ -103,6 +105,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
final Set<AvesEntry> _rawEntries = {}; final Set<AvesEntry> _rawEntries = {};
@override
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries); Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries, _trashedEntries; Set<AvesEntry>? _visibleEntries, _trashedEntries;
@ -261,8 +264,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
} }
}); });
if (entry.trashed) { if (entry.trashed) {
entry.contentId = null; final trashPath = entry.trashDetails?.path;
entry.uri = 'file://${entry.trashDetails?.path}'; if (trashPath != null) {
entry.contentId = null;
entry.uri = Uri.file(trashPath).toString();
} else {
debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}');
}
} }
if (persist) { if (persist) {

View file

@ -148,12 +148,21 @@ class MediaStoreSource extends CollectionSource {
knownDateByContentId[contentId] = 0; knownDateByContentId[contentId] = 0;
}); });
// items to add to the collection
final pendingNewEntries = <AvesEntry>{};
// recover untracked trash items
debugPrint('$runtimeType refresh ${stopwatch.elapsed} recover untracked entries');
if (directory == null) {
pendingNewEntries.addAll(await recoverLostTrashItems());
}
// fetch new & modified entries // fetch new & modified entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
// refresh after the first 10 entries, then after 100 more, then every 1000 entries // refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10; var refreshCount = 10;
const refreshCountMax = 1000; const refreshCountMax = 1000;
final allNewEntries = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{}; final allNewEntries = <AvesEntry>{};
void addPendingEntries() { void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries); allNewEntries.addAll(pendingNewEntries);
addEntries(pendingNewEntries); addEntries(pendingNewEntries);

View file

@ -1,9 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
mixin TrashMixin on SourceBase { mixin TrashMixin on SourceBase {
static const Duration binKeepDuration = Duration(days: 30); static const Duration binKeepDuration = Duration(days: 30);
@ -32,4 +37,49 @@ mixin TrashMixin on SourceBase {
); );
return await completer.future; return await completer.future;
} }
Future<Set<AvesEntry>> recoverLostTrashItems() async {
final knownPaths = allEntries.map((v) => v.trashDetails?.path).whereNotNull().toSet();
final untrackedPaths = await storageService.getUntrackedTrashPaths(knownPaths);
final newEntries = <AvesEntry>{};
if (untrackedPaths.isNotEmpty) {
debugPrint('Recovering ${untrackedPaths.length} untracked bin items');
final recoveryPath = pContext.join(androidFileUtils.picturesPath, AndroidFileUtils.recoveryDir);
await Future.forEach(untrackedPaths, (untrackedPath) async {
TrashDetails _buildTrashDetails(int id) => TrashDetails(
id: id,
path: untrackedPath,
dateMillis: DateTime.now().millisecondsSinceEpoch,
);
final uri = Uri.file(untrackedPath).toString();
final entry = allEntries.firstWhereOrNull((v) => v.uri == uri);
if (entry != null) {
// there is already a matching entry
// but missing trash details, and possibly not marked as trash
final id = entry.id;
entry.contentId = null;
entry.trashed = true;
entry.trashDetails = _buildTrashDetails(id);
// persist
await metadataDb.updateEntry(id, entry);
await metadataDb.updateTrash(id, entry.trashDetails);
} else {
// there is no matching entry
final sourceEntry = await mediaFetchService.getEntry(uri, null);
if (sourceEntry != null) {
final id = metadataDb.nextId;
sourceEntry.id = id;
sourceEntry.path = pContext.join(recoveryPath, pContext.basename(untrackedPath));
sourceEntry.trashed = true;
sourceEntry.trashDetails = _buildTrashDetails(id);
newEntries.add(sourceEntry);
} else {
debugPrint('Failed to recover untracked bin item at uri=$uri');
}
}
});
}
return newEntries;
}
} }

View file

@ -12,6 +12,8 @@ abstract class StorageService {
Future<Set<StorageVolume>> getStorageVolumes(); Future<Set<StorageVolume>> getStorageVolumes();
Future<Set<String>> getUntrackedTrashPaths(Iterable<String> knownPaths);
Future<String> getVaultRoot(); Future<String> getVaultRoot();
Future<int?> getFreeSpace(StorageVolume volume); Future<int?> getFreeSpace(StorageVolume volume);
@ -71,6 +73,19 @@ class PlatformStorageService implements StorageService {
return {}; 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 @override
Future<String> getVaultRoot() async { Future<String> getVaultRoot() async {
try { try {

View file

@ -20,7 +20,8 @@ class AndroidFileUtils {
static const mediaStoreUriRoot = '$contentScheme://$mediaStoreAuthority/'; static const mediaStoreUriRoot = '$contentScheme://$mediaStoreAuthority/';
static const mediaUriPathRoots = {'/$externalVolume/images/', '/$externalVolume/video/'}; static const mediaUriPathRoots = {'/$externalVolume/images/', '/$externalVolume/video/'};
static const String trashDirPath = '#trash'; static const recoveryDir = 'Lost & Found';
static const trashDirPath = '#trash';
late final String separator, vaultRoot, primaryStorage; late final String separator, vaultRoot, primaryStorage;
late final String dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; late final String dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath;

View file

@ -2,11 +2,13 @@ import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/video_playback.dart'; import 'package:aves/model/video_playback.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DbTab extends StatefulWidget { class DbTab extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
@ -170,13 +172,29 @@ class _DbTabState extends State<DbTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('DB trash details:${data == null ? ' no row' : ''}'), Text('DB trash details:${data == null ? ' no row' : ''}'),
if (data != null) if (data != null) ...[
ElevatedButton(
onPressed: () async {
entry.trashDetails = null;
await metadataDb.updateTrash(entry.id, entry.trashDetails);
_loadDatabase();
},
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}',
'path': data.path, 'path': data.path,
}, },
), ),
],
], ],
); );
}, },