From 461e449eb22fbd8e57c96915f89c5451c8ebfe41 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:43:03 +0700 Subject: [PATCH] refactor(android): move SAF file inspection out of MainActivitySafScan.kt MainActivitySafScan.kt mixes the library scan with unrelated SAF helpers. resolveSafFile and inspectSafFiles (with its private request class) serve history file checks, not the scan, so they move verbatim into SafFileInspection.kt. Signatures and visibility are unchanged. --- .../com/zarz/spotiflac/MainActivitySafScan.kt | 314 ----------------- .../com/zarz/spotiflac/SafFileInspection.kt | 325 ++++++++++++++++++ 2 files changed, 325 insertions(+), 314 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/SafFileInspection.kt diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index 35f59c2f..510fb97c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -91,320 +91,6 @@ internal fun MainActivity.loadExistingFilesFromSnapshot(snapshotPath: String): M return result } -internal fun Context.resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { - val obj = JSONObject() - if (treeUriStr.isBlank() || fileName.isBlank()) { - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - val safeRelativeDir = SafDownloadHandler.sanitizeRelativeDir(relativeDir) - val safeFileName = SafDownloadHandler.sanitizeFilename(fileName) - if (safeFileName.isBlank()) { - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - - val treeUri = Uri.parse(treeUriStr) - val targetDir = SafDownloadHandler.findDocumentDir(this, treeUri, safeRelativeDir) - if (targetDir != null) { - val direct = findSafChild(this, targetDir, safeFileName) - if (direct != null && direct.isFile) { - obj.put("uri", direct.uri.toString()) - obj.put("relative_dir", safeRelativeDir) - return obj.toString() - } - } - - val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - - val queue: ArrayDeque> = ArrayDeque() - queue.add(root to "") - var visited = 0 - val maxVisited = 20000 - - while (queue.isNotEmpty()) { - if (visited > maxVisited) break - val (dir, path) = queue.removeFirst() - val children = try { - listSafChildrenOrThrow(dir, includeLastModified = false) - } catch (_: Exception) { - continue - } - for (child in children) { - visited++ - if (child.isDirectory) { - val childName = child.name ?: continue - val childPath = if (path.isBlank()) childName else "$path/$childName" - queue.add(child.doc to childPath) - } else if (child.isFile) { - if (child.name == safeFileName) { - obj.put("uri", child.doc.uri.toString()) - obj.put("relative_dir", path) - return obj.toString() - } - } - } - } - - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - -private data class SafFileInspectionRequest( - val key: String, - val treeUri: String, - val relativeDir: String, - val currentUri: String, - val fileNames: List, -) - -/** - * 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 Context.inspectSafFiles(requestsJson: String): String { - val output = JSONObject() - val resultsByKey = linkedMapOf() - val requests = mutableListOf() - - 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() - 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>() - 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() - val directoryCache = mutableMapOf>() - val resolvedDirectoryCache = mutableMapOf() - 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 { - directoryCache.getOrPut(directDir.uri.toString()) { - buildMap { - for (child in listSafChildrenOrThrow(directDir, includeLastModified = false)) { - if (child.isDirectory) continue - val name = child.name?.trim().orEmpty() - if (name.isNotBlank()) put(name.lowercase(Locale.ROOT), child) - } - } - } - } - 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.doc.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>() - for (request in unresolved) { - for (fileName in request.fileNames) { - requestKeysByName - .getOrPut(fileName.lowercase(Locale.ROOT)) { mutableSetOf() } - .add(request.key) - } - } - val matches = mutableMapOf>() - val matchedRequestKeys = mutableSetOf() - val queue: ArrayDeque> = 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 { - listSafChildrenOrThrow(directory, includeLastModified = false) - } 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.doc 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.doc.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() -} - private fun safScanCheckpointPath(outputPath: String): String = "$outputPath.state" private fun loadSafScanCheckpoint(path: String): MutableMap { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafFileInspection.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafFileInspection.kt new file mode 100644 index 00000000..08fb1124 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafFileInspection.kt @@ -0,0 +1,325 @@ +package com.zarz.spotiflac + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import org.json.JSONArray +import org.json.JSONObject +import java.util.Locale + +// SAF history file inspection: resolving one file by name inside a document +// tree, and batch inspection of many history entries with one walk per tree. + +internal fun Context.resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { + val obj = JSONObject() + if (treeUriStr.isBlank() || fileName.isBlank()) { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + val safeRelativeDir = SafDownloadHandler.sanitizeRelativeDir(relativeDir) + val safeFileName = SafDownloadHandler.sanitizeFilename(fileName) + if (safeFileName.isBlank()) { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + val treeUri = Uri.parse(treeUriStr) + val targetDir = SafDownloadHandler.findDocumentDir(this, treeUri, safeRelativeDir) + if (targetDir != null) { + val direct = findSafChild(this, targetDir, safeFileName) + if (direct != null && direct.isFile) { + obj.put("uri", direct.uri.toString()) + obj.put("relative_dir", safeRelativeDir) + return obj.toString() + } + } + + val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + var visited = 0 + val maxVisited = 20000 + + while (queue.isNotEmpty()) { + if (visited > maxVisited) break + val (dir, path) = queue.removeFirst() + val children = try { + listSafChildrenOrThrow(dir, includeLastModified = false) + } catch (_: Exception) { + continue + } + for (child in children) { + visited++ + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + queue.add(child.doc to childPath) + } else if (child.isFile) { + if (child.name == safeFileName) { + obj.put("uri", child.doc.uri.toString()) + obj.put("relative_dir", path) + return obj.toString() + } + } + } + } + + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + +private data class SafFileInspectionRequest( + val key: String, + val treeUri: String, + val relativeDir: String, + val currentUri: String, + val fileNames: List, +) + +/** + * 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 Context.inspectSafFiles(requestsJson: String): String { + val output = JSONObject() + val resultsByKey = linkedMapOf() + val requests = mutableListOf() + + 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() + 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>() + 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() + val directoryCache = mutableMapOf>() + val resolvedDirectoryCache = mutableMapOf() + 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 { + directoryCache.getOrPut(directDir.uri.toString()) { + buildMap { + for (child in listSafChildrenOrThrow(directDir, includeLastModified = false)) { + if (child.isDirectory) continue + val name = child.name?.trim().orEmpty() + if (name.isNotBlank()) put(name.lowercase(Locale.ROOT), child) + } + } + } + } + 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.doc.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>() + for (request in unresolved) { + for (fileName in request.fileNames) { + requestKeysByName + .getOrPut(fileName.lowercase(Locale.ROOT)) { mutableSetOf() } + .add(request.key) + } + } + val matches = mutableMapOf>() + val matchedRequestKeys = mutableSetOf() + val queue: ArrayDeque> = 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 { + listSafChildrenOrThrow(directory, includeLastModified = false) + } 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.doc 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.doc.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() +}