perf(history): batch SAF orphan inspection #511

This commit is contained in:
zarzet
2026-08-11 16:50:35 +07:00
parent bec9e33c94
commit 6dbca64a80
6 changed files with 434 additions and 123 deletions
+87 -65
View File
@@ -59,7 +59,6 @@ String? resolvePersistedHistoryQuality({
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _initialHistoryLoadLimit = 100;
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _orphanCleanupMaxPerLaunch = 80;
static const int _audioMetadataBackfillMaxPerLaunch = 24;
@@ -697,80 +696,36 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final replacementFileNames = <String, String>{};
final replacementRelativeDirs = <String, String>{};
final pathById = <String, String>{};
final regularEntries = <Map<String, dynamic>>[];
final safEntries = <Map<String, dynamic>>[];
for (final entry in entries) {
final id = entry['id'] as String;
final filePath = (entry['file_path'] as String? ?? '').trim();
if (filePath.isEmpty) continue;
pathById[id] = filePath;
if (entry['storage_mode'] == 'saf') {
safEntries.add(entry);
} else {
regularEntries.add(entry);
}
}
const checkChunkSize = 16;
for (var i = 0; i < entries.length; i += checkChunkSize) {
final end = (i + checkChunkSize < entries.length)
for (var i = 0; i < regularEntries.length; i += checkChunkSize) {
final end = (i + checkChunkSize < regularEntries.length)
? i + checkChunkSize
: entries.length;
final chunk = entries.sublist(i, end);
: regularEntries.length;
final chunk = regularEntries.sublist(i, end);
final checks = await Future.wait<MapEntry<String, bool?>?>(
chunk.map((entry) async {
final id = entry['id'] as String;
final filePath = entry['file_path'] as String?;
if (filePath == null || filePath.isEmpty) return null;
pathById[id] = filePath;
final filePath = entry['file_path'] as String;
try {
if (await fileExists(filePath)) return MapEntry(id, true);
if (entry['storage_mode'] == 'saf') {
final treeUri = (entry['download_tree_uri'] as String? ?? '')
.trim();
var fileName = (entry['saf_file_name'] as String? ?? '').trim();
if (fileName.isEmpty && isContentUri(filePath)) {
fileName = _fileNameFromUri(filePath);
}
if (treeUri.isEmpty || fileName.isEmpty) {
return MapEntry(id, null);
}
bool treeAccessible;
try {
treeAccessible = await PlatformBridge.validateSafTreeAccess(
treeUri,
);
} catch (error) {
_historyLog.w(
'Unable to verify SAF tree while checking $id: $error',
);
return MapEntry(id, null);
}
if (!treeAccessible) {
return MapEntry(id, null);
}
for (final candidate in _conversionRenameCandidates(
fileName,
includeAlternateExtensions: true,
)) {
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: treeUri,
relativeDir: entry['saf_relative_dir'] as String? ?? '',
fileName: candidate,
);
final uri = (resolved['uri'] as String? ?? '').trim();
if (uri.isEmpty) continue;
replacementPaths[id] = uri;
replacementFileNames[id] = candidate;
final relativeDir =
(resolved['relative_dir'] as String? ?? '').trim();
if (relativeDir.isNotEmpty) {
replacementRelativeDirs[id] = relativeDir;
}
pathById[id] = uri;
return MapEntry(id, true);
} catch (error) {
_historyLog.w(
'Unable to resolve SAF file while checking $id: $error',
);
return MapEntry(id, null);
}
}
return MapEntry(id, false);
}
final sibling = await _findConvertedSibling(filePath);
if (sibling != null) {
_historyLog.i(
@@ -798,6 +753,73 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
}
if (safEntries.isNotEmpty && Platform.isAndroid) {
final requests = <Map<String, dynamic>>[];
for (final entry in safEntries) {
final id = entry['id'] as String;
final filePath = entry['file_path'] as String;
var fileName = (entry['saf_file_name'] as String? ?? '').trim();
if (fileName.isEmpty && isContentUri(filePath)) {
fileName = _fileNameFromUri(filePath);
}
requests.add({
'key': id,
'tree_uri': entry['download_tree_uri'] as String? ?? '',
'relative_dir': entry['saf_relative_dir'] as String? ?? '',
'current_uri': isContentUri(filePath) ? filePath : '',
'file_names': _conversionRenameCandidates(
fileName,
includeAlternateExtensions: true,
),
});
}
try {
final results = await PlatformBridge.inspectSafFiles(requests);
final resultsById = <String, Map<String, dynamic>>{
for (final result in results)
if ((result['key'] as String? ?? '').isNotEmpty)
result['key'] as String: result,
};
for (final entry in safEntries) {
final id = entry['id'] as String;
final result = resultsById[id];
final status = result?['status'] as String? ?? 'unknown';
if (status == 'missing') {
orphanedIds.add(id);
_historyLog.d(
'Found orphaned SAF entry: $id (${pathById[id] ?? ''})',
);
continue;
}
if (status != 'found' || result == null) continue;
final uri = (result['uri'] as String? ?? '').trim();
if (uri.isEmpty) continue;
final fileName = (result['file_name'] as String? ?? '').trim();
final relativeDir = (result['relative_dir'] as String? ?? '').trim();
final oldPath = pathById[id] ?? '';
final oldFileName = (entry['saf_file_name'] as String? ?? '').trim();
final oldRelativeDir = (entry['saf_relative_dir'] as String? ?? '')
.trim();
if (uri != oldPath ||
(fileName.isNotEmpty && fileName != oldFileName) ||
(relativeDir.isNotEmpty && relativeDir != oldRelativeDir)) {
replacementPaths[id] = uri;
if (fileName.isNotEmpty) replacementFileNames[id] = fileName;
if (relativeDir.isNotEmpty) {
replacementRelativeDirs[id] = relativeDir;
}
pathById[id] = uri;
}
}
} catch (error) {
// A bridge or provider error is inconclusive. Never delete SAF history
// when the batch inspection could not establish that files are absent.
_historyLog.w('Unable to inspect SAF history entries: $error');
}
}
return (
orphanedIds: orphanedIds,
replacementPaths: replacementPaths,
@@ -166,27 +166,11 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
var verifiedCount = 0;
try {
for (var c = 0; c < selectedIndexes.length; c++) {
final i = selectedIndexes[c];
final requests = <Map<String, dynamic>>[];
for (final i in selectedIndexes) {
final item = items[i];
final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
if (isDirectSafUri) {
final exists = await fileExists(rawPath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
persistedUpdates.add(verified.toJson());
continue;
}
}
var fallbackName = (item.safFileName ?? '').trim();
if (fallbackName.isEmpty && isDirectSafUri) {
fallbackName = _fileNameFromUri(rawPath);
@@ -195,48 +179,53 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
requests.add({
'key': item.id,
'tree_uri': item.downloadTreeUri,
'relative_dir': item.safRelativeDir ?? '',
'current_uri': isDirectSafUri ? rawPath : '',
'file_names': _conversionRenameCandidates(fallbackName),
});
}
try {
Map<String, dynamic>? resolved;
String? resolvedFileName;
for (final candidate in _conversionRenameCandidates(fallbackName)) {
final candidateResult = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: candidate,
);
final candidateUri = (candidateResult['uri'] as String? ?? '')
.trim();
if (candidateUri.isEmpty) continue;
resolved = candidateResult;
resolvedFileName = candidate;
break;
}
if (resolved == null || resolvedFileName == null) continue;
final newUri = (resolved['uri'] as String).trim();
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: resolvedFileName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
final results = await PlatformBridge.inspectSafFiles(requests);
final resultsById = <String, Map<String, dynamic>>{
for (final result in results)
if ((result['key'] as String? ?? '').isNotEmpty)
result['key'] as String: result,
};
final indexById = <String, int>{
for (final index in selectedIndexes) items[index].id: index,
};
for (final result in resultsById.values) {
if (result['status'] != 'found') continue;
final id = result['key'] as String;
final index = indexById[id];
if (index == null) continue;
final item = items[index];
final newUri = (result['uri'] as String? ?? '').trim();
if (newUri.isEmpty) continue;
final resultFileName = (result['file_name'] as String? ?? '').trim();
final resultRelativeDir = (result['relative_dir'] as String? ?? '')
.trim();
final updated = item.copyWith(
filePath: newUri,
safRelativeDir: resultRelativeDir.isNotEmpty
? resultRelativeDir
: item.safRelativeDir,
safFileName: resultFileName.isNotEmpty
? resultFileName
: item.safFileName,
safRepaired: true,
);
updatedItems[index] = updated;
changed = true;
if (newUri == item.filePath) {
verifiedCount++;
} else {
repairedCount++;
persistedUpdates.add(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
if ((c + 1) % DownloadHistoryNotifier._safRepairBatchSize == 0) {
await Future<void>.delayed(const Duration(milliseconds: 16));
}
persistedUpdates.add(updated.toJson());
}
if (changed) {
+17
View File
@@ -646,6 +646,23 @@ class PlatformBridge {
});
}
/// Checks and repairs many SAF file references in a single native pass.
/// Android groups requests by tree and scans each tree no more than once.
static Future<List<Map<String, dynamic>>> inspectSafFiles(
List<Map<String, dynamic>> requests,
) async {
if (requests.isEmpty) return const [];
final response = await _invokeMap('inspectSafFiles', {
'requests_json': jsonEncode(requests),
});
final results = response['results'];
if (results is! List) return const [];
return results
.whereType<Map<Object?, Object?>>()
.map((result) => result.map((key, value) => MapEntry('$key', value)))
.toList(growable: false);
}
static Future<String?> copyContentUriToTemp(String uri) async {
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
return result as String?;