recover untracked binned items
This commit is contained in:
parent
6db333f73b
commit
31b9b633ae
9 changed files with 135 additions and 7 deletions
|
@ -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
|
||||
|
||||
## <a id="v1.10.4"></a>[v1.10.4] - 2024-02-07
|
||||
|
||||
### Fixed
|
||||
|
|
|
@ -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<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) {
|
||||
result.success(StorageUtils.getVaultRoot(context))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,8 @@ mixin SourceBase {
|
|||
|
||||
Map<int, AvesEntry> get entryById;
|
||||
|
||||
Set<AvesEntry> get allEntries;
|
||||
|
||||
Set<AvesEntry> get visibleEntries;
|
||||
|
||||
Set<AvesEntry> get trashedEntries;
|
||||
|
@ -103,6 +105,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
|||
|
||||
final Set<AvesEntry> _rawEntries = {};
|
||||
|
||||
@override
|
||||
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
|
||||
|
||||
Set<AvesEntry>? _visibleEntries, _trashedEntries;
|
||||
|
@ -261,8 +264,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
|||
}
|
||||
});
|
||||
if (entry.trashed) {
|
||||
final trashPath = entry.trashDetails?.path;
|
||||
if (trashPath != null) {
|
||||
entry.contentId = null;
|
||||
entry.uri = 'file://${entry.trashDetails?.path}';
|
||||
entry.uri = Uri.file(trashPath).toString();
|
||||
} else {
|
||||
debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}');
|
||||
}
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
|
|
|
@ -148,12 +148,21 @@ class MediaStoreSource extends CollectionSource {
|
|||
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
|
||||
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 = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
|
||||
final allNewEntries = <AvesEntry>{};
|
||||
void addPendingEntries() {
|
||||
allNewEntries.addAll(pendingNewEntries);
|
||||
addEntries(pendingNewEntries);
|
||||
|
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ abstract class StorageService {
|
|||
|
||||
Future<Set<StorageVolume>> getStorageVolumes();
|
||||
|
||||
Future<Set<String>> getUntrackedTrashPaths(Iterable<String> knownPaths);
|
||||
|
||||
Future<String> getVaultRoot();
|
||||
|
||||
Future<int?> getFreeSpace(StorageVolume volume);
|
||||
|
@ -71,6 +73,19 @@ class PlatformStorageService implements StorageService {
|
|||
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
|
||||
Future<String> getVaultRoot() async {
|
||||
try {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,7 +172,22 @@ class _DbTabState extends State<DbTab> {
|
|||
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<CollectionSource>();
|
||||
await source.removeEntries({entry.uri}, includeTrash: true);
|
||||
},
|
||||
child: const Text('Untrack entry'),
|
||||
),
|
||||
InfoRowGroup(
|
||||
info: {
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
|
@ -178,6 +195,7 @@ class _DbTabState extends State<DbTab> {
|
|||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
Loading…
Reference in a new issue