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
|
- 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
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue