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
@@ -973,6 +973,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"inspectSafFiles" -> {
val requestsJson = call.argument<String>("requests_json") ?: "[]"
val response = withContext(Dispatchers.IO) {
inspectSafFiles(requestsJson)
}
result.success(response)
}
"safCopyToTemp" -> {
val uriStr = call.argument<String>("uri") ?: ""
val tempPath = withContext(Dispatchers.IO) {
@@ -169,6 +169,246 @@ internal fun MainActivity.resolveSafFile(treeUriStr: String, relativeDir: String
return obj.toString()
}
private data class SafFileInspectionRequest(
val key: String,
val treeUri: String,
val relativeDir: String,
val currentUri: String,
val fileNames: List<String>,
)
/**
* Inspects many SAF history entries while walking each document tree at most
* once. The old per-file resolver could repeat a 20k-document breadth-first
* search for every missing history row and every conversion filename variant.
*/
internal fun MainActivity.inspectSafFiles(requestsJson: String): String {
val output = JSONObject()
val resultsByKey = linkedMapOf<String, JSONObject>()
val requests = mutableListOf<SafFileInspectionRequest>()
fun result(
key: String,
status: String,
uri: String = "",
fileName: String = "",
relativeDir: String = "",
) = JSONObject().apply {
put("key", key)
put("status", status)
put("uri", uri)
put("file_name", fileName)
put("relative_dir", relativeDir)
}
try {
val rawRequests = JSONArray(requestsJson)
for (index in 0 until rawRequests.length()) {
val raw = rawRequests.optJSONObject(index) ?: continue
val key = raw.optString("key").trim()
if (key.isBlank()) continue
val names = mutableListOf<String>()
val rawNames = raw.optJSONArray("file_names")
if (rawNames != null) {
for (nameIndex in 0 until rawNames.length()) {
val sanitized = SafDownloadHandler.sanitizeFilename(
rawNames.optString(nameIndex).trim(),
)
if (sanitized.isNotBlank() && sanitized !in names) {
names.add(sanitized)
}
}
}
requests.add(
SafFileInspectionRequest(
key = key,
treeUri = raw.optString("tree_uri").trim(),
relativeDir = SafDownloadHandler.sanitizeRelativeDir(
raw.optString("relative_dir"),
),
currentUri = raw.optString("current_uri").trim(),
fileNames = names,
),
)
}
val pendingByTree = linkedMapOf<String, MutableList<SafFileInspectionRequest>>()
for (request in requests) {
if (request.currentUri.startsWith("content://")) {
try {
val current = DocumentFile.fromSingleUri(this, Uri.parse(request.currentUri))
if (current != null && current.exists() && current.isFile) {
resultsByKey[request.key] = result(
key = request.key,
status = "found",
uri = request.currentUri,
fileName = current.name.orEmpty(),
relativeDir = request.relativeDir,
)
continue
}
} catch (_: Exception) {
// Fall through to the persisted tree lookup.
}
}
if (request.treeUri.isBlank() || request.fileNames.isEmpty()) {
resultsByKey[request.key] = result(request.key, "unknown")
continue
}
pendingByTree.getOrPut(request.treeUri) { mutableListOf() }.add(request)
}
for ((treeUriString, treeRequests) in pendingByTree) {
val treeUri = try {
Uri.parse(treeUriString)
} catch (_: Exception) {
null
}
val hasPermission = treeUri != null && contentResolver.persistedUriPermissions.any {
it.uri == treeUri && it.isReadPermission && it.isWritePermission
}
val root = if (hasPermission) {
try {
DocumentFile.fromTreeUri(this, treeUri)
} catch (_: Exception) {
null
}
} else {
null
}
if (root == null || !root.exists() || !root.canWrite()) {
for (request in treeRequests) {
resultsByKey[request.key] = result(request.key, "unknown")
}
continue
}
val unresolved = mutableListOf<SafFileInspectionRequest>()
val directoryCache = mutableMapOf<String, Map<String, DocumentFile>>()
val resolvedDirectoryCache = mutableMapOf<String, DocumentFile?>()
for (request in treeRequests) {
val directDir = if (resolvedDirectoryCache.containsKey(request.relativeDir)) {
resolvedDirectoryCache[request.relativeDir]
} else {
val resolved = try {
SafDownloadHandler.findDocumentDir(this, treeUri!!, request.relativeDir)
} catch (_: Exception) {
null
}
resolvedDirectoryCache[request.relativeDir] = resolved
resolved
}
val lookup = if (directDir == null) {
emptyMap()
} else {
getSafChildFileLookup(directDir, directoryCache)
}
val directName = request.fileNames.firstOrNull {
lookup.containsKey(it.lowercase(Locale.ROOT))
}
val direct = directName?.let { lookup[it.lowercase(Locale.ROOT)] }
if (direct != null && direct.isFile) {
resultsByKey[request.key] = result(
key = request.key,
status = "found",
uri = direct.uri.toString(),
fileName = direct.name ?: directName,
relativeDir = request.relativeDir,
)
} else {
unresolved.add(request)
}
}
if (unresolved.isEmpty()) continue
val wantedNames = unresolved
.flatMap { it.fileNames }
.map { it.lowercase(Locale.ROOT) }
.toSet()
val requestKeysByName = mutableMapOf<String, MutableSet<String>>()
for (request in unresolved) {
for (fileName in request.fileNames) {
requestKeysByName
.getOrPut(fileName.lowercase(Locale.ROOT)) { mutableSetOf() }
.add(request.key)
}
}
val matches = mutableMapOf<String, Pair<DocumentFile, String>>()
val matchedRequestKeys = mutableSetOf<String>()
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
var visited = 0
val maxVisited = 50000
var scanComplete = true
while (queue.isNotEmpty() && matchedRequestKeys.size < unresolved.size) {
if (visited >= maxVisited) {
scanComplete = false
break
}
val (directory, path) = queue.removeFirst()
val children = try {
directory.listFiles()
} catch (_: Exception) {
scanComplete = false
break
}
for (child in children) {
visited++
if (visited >= maxVisited) {
scanComplete = false
break
}
if (child.isDirectory) {
val childName = child.name ?: continue
val childPath = if (path.isBlank()) childName else "$path/$childName"
queue.add(child to childPath)
} else if (child.isFile) {
val childName = child.name ?: continue
val normalized = childName.lowercase(Locale.ROOT)
if (normalized in wantedNames && normalized !in matches) {
matches[normalized] = child to path
matchedRequestKeys.addAll(
requestKeysByName[normalized].orEmpty(),
)
}
}
}
}
for (request in unresolved) {
val matchedName = request.fileNames.firstOrNull {
matches.containsKey(it.lowercase(Locale.ROOT))
}
val match = matchedName?.let { matches[it.lowercase(Locale.ROOT)] }
resultsByKey[request.key] = if (match != null) {
result(
key = request.key,
status = "found",
uri = match.first.uri.toString(),
fileName = match.first.name ?: matchedName,
relativeDir = match.second,
)
} else {
result(request.key, if (scanComplete) "missing" else "unknown")
}
}
}
} catch (error: Exception) {
android.util.Log.w("SpotiFLAC", "Batch SAF inspection failed: ${error.message}")
for (request in requests) {
resultsByKey.putIfAbsent(request.key, result(request.key, "unknown"))
}
}
val results = JSONArray()
for (request in requests) {
results.put(resultsByKey[request.key] ?: result(request.key, "unknown"))
}
output.put("results", results)
return output.toString()
}
/**
* Extract the audio filename referenced by a CUE sheet file.
@@ -1001,4 +1241,3 @@ internal fun MainActivity.getSafFileModTimes(urisJson: String): String {
return result.toString()
}
+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?;
+37
View File
@@ -130,4 +130,41 @@ void main() {
expect(arguments?['request_id'], isA<String>());
expect(results.single['provider_id'], 'custom-metadata');
});
test('SAF inspection sends one batch and decodes repair results', () async {
MethodCall? capturedCall;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
capturedCall = call;
return jsonEncode({
'results': [
{
'key': 'history-1',
'status': 'found',
'uri': 'content://tree/repaired.flac',
'file_name': 'repaired.flac',
'relative_dir': 'Artist/Album',
},
],
});
});
final results = await PlatformBridge.inspectSafFiles([
{
'key': 'history-1',
'tree_uri': 'content://tree/root',
'current_uri': 'content://tree/stale.flac',
'relative_dir': 'Artist/Album',
'file_names': ['song.flac', 'song_converted.flac'],
},
]);
expect(capturedCall?.method, 'inspectSafFiles');
final arguments = capturedCall?.arguments as Map<Object?, Object?>;
final requests = jsonDecode(arguments['requests_json'] as String) as List;
expect(requests, hasLength(1));
expect((requests.single as Map)['file_names'], hasLength(2));
expect(results.single['status'], 'found');
expect(results.single['relative_dir'], 'Artist/Album');
});
}