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()
}