mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-28 13:50:35 +02:00
perf(history): batch SAF orphan inspection #511
This commit is contained in:
@@ -973,6 +973,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"inspectSafFiles" -> {
|
||||||
|
val requestsJson = call.argument<String>("requests_json") ?: "[]"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
inspectSafFiles(requestsJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"safCopyToTemp" -> {
|
"safCopyToTemp" -> {
|
||||||
val uriStr = call.argument<String>("uri") ?: ""
|
val uriStr = call.argument<String>("uri") ?: ""
|
||||||
val tempPath = withContext(Dispatchers.IO) {
|
val tempPath = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -169,6 +169,246 @@ internal fun MainActivity.resolveSafFile(treeUriStr: String, relativeDir: String
|
|||||||
return obj.toString()
|
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.
|
* Extract the audio filename referenced by a CUE sheet file.
|
||||||
@@ -1001,4 +1241,3 @@ internal fun MainActivity.getSafFileModTimes(urisJson: String): String {
|
|||||||
|
|
||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ String? resolvePersistedHistoryQuality({
|
|||||||
|
|
||||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||||
static const int _initialHistoryLoadLimit = 100;
|
static const int _initialHistoryLoadLimit = 100;
|
||||||
static const int _safRepairBatchSize = 20;
|
|
||||||
static const int _safRepairMaxPerLaunch = 60;
|
static const int _safRepairMaxPerLaunch = 60;
|
||||||
static const int _orphanCleanupMaxPerLaunch = 80;
|
static const int _orphanCleanupMaxPerLaunch = 80;
|
||||||
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
||||||
@@ -697,80 +696,36 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final replacementFileNames = <String, String>{};
|
final replacementFileNames = <String, String>{};
|
||||||
final replacementRelativeDirs = <String, String>{};
|
final replacementRelativeDirs = <String, String>{};
|
||||||
final pathById = <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;
|
const checkChunkSize = 16;
|
||||||
|
|
||||||
for (var i = 0; i < entries.length; i += checkChunkSize) {
|
for (var i = 0; i < regularEntries.length; i += checkChunkSize) {
|
||||||
final end = (i + checkChunkSize < entries.length)
|
final end = (i + checkChunkSize < regularEntries.length)
|
||||||
? i + checkChunkSize
|
? i + checkChunkSize
|
||||||
: entries.length;
|
: regularEntries.length;
|
||||||
final chunk = entries.sublist(i, end);
|
final chunk = regularEntries.sublist(i, end);
|
||||||
|
|
||||||
final checks = await Future.wait<MapEntry<String, bool?>?>(
|
final checks = await Future.wait<MapEntry<String, bool?>?>(
|
||||||
chunk.map((entry) async {
|
chunk.map((entry) async {
|
||||||
final id = entry['id'] as String;
|
final id = entry['id'] as String;
|
||||||
final filePath = entry['file_path'] as String?;
|
final filePath = entry['file_path'] as String;
|
||||||
if (filePath == null || filePath.isEmpty) return null;
|
|
||||||
pathById[id] = filePath;
|
|
||||||
try {
|
try {
|
||||||
if (await fileExists(filePath)) return MapEntry(id, true);
|
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);
|
final sibling = await _findConvertedSibling(filePath);
|
||||||
if (sibling != null) {
|
if (sibling != null) {
|
||||||
_historyLog.i(
|
_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 (
|
return (
|
||||||
orphanedIds: orphanedIds,
|
orphanedIds: orphanedIds,
|
||||||
replacementPaths: replacementPaths,
|
replacementPaths: replacementPaths,
|
||||||
|
|||||||
@@ -166,27 +166,11 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
|
|||||||
var verifiedCount = 0;
|
var verifiedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (var c = 0; c < selectedIndexes.length; c++) {
|
final requests = <Map<String, dynamic>>[];
|
||||||
final i = selectedIndexes[c];
|
for (final i in selectedIndexes) {
|
||||||
final item = items[i];
|
final item = items[i];
|
||||||
final rawPath = item.filePath.trim();
|
final rawPath = item.filePath.trim();
|
||||||
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
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();
|
var fallbackName = (item.safFileName ?? '').trim();
|
||||||
if (fallbackName.isEmpty && isDirectSafUri) {
|
if (fallbackName.isEmpty && isDirectSafUri) {
|
||||||
fallbackName = _fileNameFromUri(rawPath);
|
fallbackName = _fileNameFromUri(rawPath);
|
||||||
@@ -195,48 +179,53 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
|
|||||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
requests.add({
|
||||||
|
'key': item.id,
|
||||||
|
'tree_uri': item.downloadTreeUri,
|
||||||
|
'relative_dir': item.safRelativeDir ?? '',
|
||||||
|
'current_uri': isDirectSafUri ? rawPath : '',
|
||||||
|
'file_names': _conversionRenameCandidates(fallbackName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
final results = await PlatformBridge.inspectSafFiles(requests);
|
||||||
Map<String, dynamic>? resolved;
|
final resultsById = <String, Map<String, dynamic>>{
|
||||||
String? resolvedFileName;
|
for (final result in results)
|
||||||
for (final candidate in _conversionRenameCandidates(fallbackName)) {
|
if ((result['key'] as String? ?? '').isNotEmpty)
|
||||||
final candidateResult = await PlatformBridge.resolveSafFile(
|
result['key'] as String: result,
|
||||||
treeUri: item.downloadTreeUri!,
|
};
|
||||||
relativeDir: item.safRelativeDir ?? '',
|
final indexById = <String, int>{
|
||||||
fileName: candidate,
|
for (final index in selectedIndexes) items[index].id: index,
|
||||||
);
|
};
|
||||||
final candidateUri = (candidateResult['uri'] as String? ?? '')
|
for (final result in resultsById.values) {
|
||||||
.trim();
|
if (result['status'] != 'found') continue;
|
||||||
if (candidateUri.isEmpty) continue;
|
final id = result['key'] as String;
|
||||||
resolved = candidateResult;
|
final index = indexById[id];
|
||||||
resolvedFileName = candidate;
|
if (index == null) continue;
|
||||||
break;
|
final item = items[index];
|
||||||
}
|
final newUri = (result['uri'] as String? ?? '').trim();
|
||||||
if (resolved == null || resolvedFileName == null) continue;
|
if (newUri.isEmpty) continue;
|
||||||
final newUri = (resolved['uri'] as String).trim();
|
final resultFileName = (result['file_name'] as String? ?? '').trim();
|
||||||
|
final resultRelativeDir = (result['relative_dir'] as String? ?? '')
|
||||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
.trim();
|
||||||
final updated = item.copyWith(
|
final updated = item.copyWith(
|
||||||
filePath: newUri,
|
filePath: newUri,
|
||||||
safRelativeDir:
|
safRelativeDir: resultRelativeDir.isNotEmpty
|
||||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
? resultRelativeDir
|
||||||
? newRelativeDir
|
: item.safRelativeDir,
|
||||||
: item.safRelativeDir,
|
safFileName: resultFileName.isNotEmpty
|
||||||
safFileName: resolvedFileName,
|
? resultFileName
|
||||||
safRepaired: true,
|
: item.safFileName,
|
||||||
);
|
safRepaired: true,
|
||||||
|
);
|
||||||
updatedItems[i] = updated;
|
updatedItems[index] = updated;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
if (newUri == item.filePath) {
|
||||||
|
verifiedCount++;
|
||||||
|
} else {
|
||||||
repairedCount++;
|
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) {
|
if (changed) {
|
||||||
|
|||||||
@@ -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 {
|
static Future<String?> copyContentUriToTemp(String uri) async {
|
||||||
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
|
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
|
||||||
return result as String?;
|
return result as String?;
|
||||||
|
|||||||
@@ -130,4 +130,41 @@ void main() {
|
|||||||
expect(arguments?['request_id'], isA<String>());
|
expect(arguments?['request_id'], isA<String>());
|
||||||
expect(results.single['provider_id'], 'custom-metadata');
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user