From 31b9b633ae841f26f0101fd9c776054d21ca4526 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 22 Feb 2024 17:49:45 +0100 Subject: [PATCH] recover untracked binned items --- CHANGELOG.md | 4 ++ .../aves/channel/calls/StorageHandler.kt | 15 ++++++ .../aves/model/provider/FileImageProvider.kt | 12 ++++- lib/model/source/collection_source.dart | 12 ++++- lib/model/source/media_store_source.dart | 11 +++- lib/model/source/trash.dart | 50 +++++++++++++++++++ lib/services/storage_service.dart | 15 ++++++ lib/utils/android_file_utils.dart | 3 +- lib/widgets/viewer/debug/db.dart | 20 +++++++- 9 files changed, 135 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9564600d9..ee05054a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to this project will be documented in this file. - upgraded Flutter to stable v3.19.1 +### Fixed + +- untracked binned items recovery + ## [v1.10.4] - 2024-02-07 ### Fixed 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 22f54d6d9..43a5d46c8 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 @@ -29,6 +29,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) } "getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) } + "getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) } "getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) } "getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) } "getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) } @@ -125,6 +126,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(volumes) } + private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) { + val knownPaths = call.argument>("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) { result.success(StorageUtils.getVaultRoot(context)) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index f1902939e..cfa0c34e8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -16,8 +16,16 @@ internal class FileImageProvider : ImageProvider() { var mimeType = sourceMimeType if (mimeType == null) { - val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) - if (extension != null) { + var extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + 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) } } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 2911e04e8..c6a4216b2 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -38,6 +38,8 @@ mixin SourceBase { Map get entryById; + Set get allEntries; + Set get visibleEntries; Set get trashedEntries; @@ -103,6 +105,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place final Set _rawEntries = {}; + @override Set get allEntries => Set.unmodifiable(_rawEntries); Set? _visibleEntries, _trashedEntries; @@ -261,8 +264,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place } }); if (entry.trashed) { - entry.contentId = null; - entry.uri = 'file://${entry.trashDetails?.path}'; + final trashPath = 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) { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index bacfaffef..74d854a43 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -148,12 +148,21 @@ class MediaStoreSource extends CollectionSource { knownDateByContentId[contentId] = 0; }); + // items to add to the collection + final pendingNewEntries = {}; + + // recover untracked trash items + debugPrint('$runtimeType refresh ${stopwatch.elapsed} recover untracked entries'); + if (directory == null) { + pendingNewEntries.addAll(await recoverLostTrashItems()); + } + // fetch new & modified entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries'); // refresh after the first 10 entries, then after 100 more, then every 1000 entries var refreshCount = 10; const refreshCountMax = 1000; - final allNewEntries = {}, pendingNewEntries = {}; + final allNewEntries = {}; void addPendingEntries() { allNewEntries.addAll(pendingNewEntries); addEntries(pendingNewEntries); diff --git a/lib/model/source/trash.dart b/lib/model/source/trash.dart index 5aac6de6e..fac1387ff 100644 --- a/lib/model/source/trash.dart +++ b/lib/model/source/trash.dart @@ -1,9 +1,14 @@ import 'dart:async'; +import 'package:aves/model/entry/entry.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/services/common/image_op_events.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 { static const Duration binKeepDuration = Duration(days: 30); @@ -32,4 +37,49 @@ mixin TrashMixin on SourceBase { ); return await completer.future; } + + Future> recoverLostTrashItems() async { + 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); + 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; + } } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 835e0027a..648a59bd3 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -12,6 +12,8 @@ abstract class StorageService { Future> getStorageVolumes(); + Future> getUntrackedTrashPaths(Iterable knownPaths); + Future getVaultRoot(); Future getFreeSpace(StorageVolume volume); @@ -71,6 +73,19 @@ class PlatformStorageService implements StorageService { return {}; } + @override + Future> getUntrackedTrashPaths(Iterable knownPaths) async { + try { + final result = await _platform.invokeMethod('getUntrackedTrashPaths', { + '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/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 2368fd0dc..e3c620ce0 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -20,7 +20,8 @@ class AndroidFileUtils { static const mediaStoreUriRoot = '$contentScheme://$mediaStoreAuthority/'; 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 dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index fded8c92d..105c13be9 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -2,11 +2,13 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.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/services/common/services.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class DbTab extends StatefulWidget { final AvesEntry entry; @@ -170,13 +172,29 @@ class _DbTabState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ 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(); + await source.removeEntries({entry.uri}, includeTrash: true); + }, + child: const Text('Untrack entry'), + ), InfoRowGroup( info: { 'dateMillis': '${data.dateMillis}', 'path': data.path, }, ), + ], ], ); },