From 1387a28d6620de87464976d3a24e3e0a9d01cf04 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:38:15 +0700 Subject: [PATCH] perf(android): batch SAF library recovery metadata queries --- .../LookupTestDocumentsProvider.java | 10 +- .../com/zarz/spotiflac/SafScanChildrenTest.kt | 130 ++++++++++++++++++ .../com/zarz/spotiflac/MainActivitySafScan.kt | 64 ++++++--- 3 files changed, 183 insertions(+), 21 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt diff --git a/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java b/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java index 5c80f65c..cff90dd7 100644 --- a/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java +++ b/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java @@ -71,6 +71,11 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { } add("nested:音楽/opaque", "root", "音楽 🎵", Document.MIME_TYPE_DIR); add("song:opaque/%", "nested:音楽/opaque", "歌 🎵.flac", "audio/flac"); + if (extras.getBoolean("mimeCases")) { + add("mime:null", "root", "Null.flac", null); + add("mime:empty", "root", "Empty.flac", ""); + add("mime:generic", "root", "Generic.flac", "application/octet-stream"); + } Entry original = add("original", "root", "Song.flac", "audio/flac"); try (FileOutputStream output = new FileOutputStream(original.file)) { output.write(new byte[] {'o', 'r', 'i', 'g', 'i', 'n', 'a', 'l'}); @@ -84,7 +89,8 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { extras.getString("targetPackage"), DocumentsContract.buildTreeDocumentUri("com.spotiflac.test.documents.lookup", "root"), Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION | + (extras.getBoolean("persistable") ? Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION : 0) ); } finally { Binder.restoreCallingIdentity(identity); @@ -136,6 +142,7 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { case Document.COLUMN_DISPLAY_NAME: row[i] = entry.name; break; case Document.COLUMN_MIME_TYPE: row[i] = entry.mime; break; case Document.COLUMN_SIZE: row[i] = entry.file.length(); break; + case Document.COLUMN_LAST_MODIFIED: row[i] = 1234L; break; case Document.COLUMN_FLAGS: row[i] = Document.FLAG_SUPPORTS_WRITE | Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME | Document.FLAG_DIR_SUPPORTS_CREATE; @@ -173,6 +180,7 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { @Override public Cursor queryChildDocuments(String parentId, String[] projection, String sortOrder) { childQueries++; + if (projectionMode.equals("all-null")) return null; boolean projected = projection != null && Arrays.asList(projection).contains(Document.COLUMN_DISPLAY_NAME); if (projected && projectionMode.equals("throw")) throw new UnsupportedOperationException("projection"); if (projected && projectionMode.equals("null")) return null; diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt new file mode 100644 index 00000000..e5d399c6 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt @@ -0,0 +1,130 @@ +package com.zarz.spotiflac + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.IOException +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SafScanChildrenTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val authority = "com.spotiflac.test.documents.lookup" + private val controlUri = Uri.parse("content://$authority.control") + private val treeUri = DocumentsContract.buildTreeDocumentUri(authority, "root") + private val grantFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + private fun reset(count: Int = 0, mode: String = "normal", mimeCases: Boolean = false): DocumentFile { + context.contentResolver.call(controlUri, "lookup.reset", null, Bundle().apply { + putInt("count", count) + putString("mode", mode) + putBoolean("mimeCases", mimeCases) + putBoolean("persistable", true) + putString("targetPackage", context.packageName) + }) + context.contentResolver.takePersistableUriPermission(treeUri, grantFlags) + return requireNotNull(DocumentFile.fromTreeUri(context, treeUri)) + } + + private fun stats() = requireNotNull(context.contentResolver.call(controlUri, "lookup.stats", null, null)) + + @After + fun releaseTreeGrant() { + if (context.contentResolver.persistedUriPermissions.any { it.uri == treeUri }) { + context.contentResolver.releasePersistableUriPermission(treeUri, grantFlags) + } + } + + @Test + fun richRowsContainFileMetadataAndTraversableChildrenWithoutNameQueries() { + val root = reset(count = 2000) + val children = context.listSafChildrenOrThrow(root) + assertEquals(2002, children.size) + assertEquals(1, stats().getInt("children")) + assertEquals(0, stats().getInt("documents")) + assertTrue(children.all { it.lastModified == 1234L }) + val directory = children.single { it.isDirectory } + assertFalse(directory.isFile) + assertEquals("nested:音楽/opaque", DocumentsContract.getDocumentId(directory.doc.uri)) + val nested = context.listSafChildrenOrThrow(directory.doc, includeLastModified = false).single() + assertEquals("歌 🎵.flac", nested.name) + assertTrue(nested.isFile) + assertEquals(0L, nested.lastModified) + assertEquals(2, stats().getInt("children")) + assertEquals(0, stats().getInt("documents")) + assertTrue(nested.doc.renameTo("Renamed.flac")) + assertEquals("Renamed.flac", nested.doc.name) + } + + @Test + fun unknownAndEmptyMimeStayNonFilesWhileGenericMimeRemainsAFile() { + val root = reset(mimeCases = true) + val entries = context.listSafChildrenOrThrow(root, includeLastModified = false).associateBy { it.name } + assertFalse(requireNotNull(entries["Null.flac"]).isFile) + assertFalse(requireNotNull(entries["Empty.flac"]).isFile) + assertTrue(requireNotNull(entries["Generic.flac"]).isFile) + assertEquals("", JSONObject(context.resolveSafFile(treeUri.toString(), "", "Null.flac")).getString("uri")) + assertEquals("", JSONObject(context.resolveSafFile(treeUri.toString(), "", "Empty.flac")).getString("uri")) + assertTrue(JSONObject(context.resolveSafFile(treeUri.toString(), "", "Generic.flac")).getString("uri").isNotBlank()) + } + + @Test + fun rejectedRichProjectionRetainsLegacyMetadataFallback() { + val root = reset(mode = "throw", mimeCases = true) + val entries = context.listSafChildrenOrThrow(root).associateBy { it.name } + assertEquals(2, stats().getInt("children")) + assertTrue(stats().getInt("documents") > 0) + assertTrue(requireNotNull(entries["音楽 🎵"]).isDirectory) + assertFalse(requireNotNull(entries["Null.flac"]).isFile) + assertFalse(requireNotNull(entries["Empty.flac"]).isFile) + assertTrue(requireNotNull(entries["Generic.flac"]).isFile) + assertTrue(entries.values.all { it.lastModified == 1234L }) + } + + @Test + fun resolverWalksNestedDirectoriesWithConstantQueryCount() { + reset(count = 2000) + val result = JSONObject(context.resolveSafFile(treeUri.toString(), "", "歌 🎵.flac")) + assertEquals("song:opaque/%", DocumentsContract.getDocumentId(Uri.parse(result.getString("uri")))) + assertEquals("音楽 🎵", result.getString("relative_dir")) + assertEquals(3, stats().getInt("children")) + assertEquals(0, stats().getInt("documents")) + } + + private fun inspect(vararg names: String): JSONArray { + val requests = JSONArray() + for ((index, name) in names.withIndex()) { + requests.put(JSONObject().put("key", index.toString()).put("tree_uri", treeUri.toString()) + .put("relative_dir", "").put("current_uri", "").put("file_names", JSONArray().put(name))) + } + return JSONObject(context.inspectSafFiles(requests.toString())).getJSONArray("results") + } + + @Test + fun batchInspectionFindsNestedRowsAndSeparatesMissingFromUnreadable() { + reset(count = 2000) + val results = inspect("歌 🎵.flac", "Missing.flac") + assertEquals("found", results.getJSONObject(0).getString("status")) + assertEquals("音楽 🎵", results.getJSONObject(0).getString("relative_dir")) + assertEquals("missing", results.getJSONObject(1).getString("status")) + assertEquals(3, stats().getInt("children")) + // Only fixed root permission/type checks; no per-child lookups. + assertTrue(stats().getInt("documents") < 10) + + val root = reset(mode = "all-null") + assertThrows(IOException::class.java) { context.listSafChildrenOrThrow(root) } + assertEquals("unknown", inspect("Missing.flac").getJSONObject(0).getString("status")) + } +} 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 5220afdc..c5a02ac3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -111,7 +111,7 @@ internal fun MainActivity.loadExistingFilesFromSnapshot(snapshotPath: String): M return result } -internal fun MainActivity.resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { +internal fun Context.resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { val obj = JSONObject() if (treeUriStr.isBlank() || fileName.isBlank()) { obj.put("uri", "") @@ -129,7 +129,7 @@ internal fun MainActivity.resolveSafFile(treeUriStr: String, relativeDir: String val treeUri = Uri.parse(treeUriStr) val targetDir = SafDownloadHandler.findDocumentDir(this, treeUri, safeRelativeDir) if (targetDir != null) { - val direct = targetDir.findFile(safeFileName) + val direct = findSafChild(this, targetDir, safeFileName) if (direct != null && direct.isFile) { obj.put("uri", direct.uri.toString()) obj.put("relative_dir", safeRelativeDir) @@ -151,15 +151,20 @@ internal fun MainActivity.resolveSafFile(treeUriStr: String, relativeDir: String while (queue.isNotEmpty()) { if (visited > maxVisited) break val (dir, path) = queue.removeFirst() - for (child in dir.listFiles()) { + 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 to childPath) + queue.add(child.doc to childPath) } else if (child.isFile) { if (child.name == safeFileName) { - obj.put("uri", child.uri.toString()) + obj.put("uri", child.doc.uri.toString()) obj.put("relative_dir", path) return obj.toString() } @@ -185,7 +190,7 @@ private data class SafFileInspectionRequest( * 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 { +internal fun Context.inspectSafFiles(requestsJson: String): String { val output = JSONObject() val resultsByKey = linkedMapOf() val requests = mutableListOf() @@ -287,7 +292,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { } val unresolved = mutableListOf() - val directoryCache = mutableMapOf>() + val directoryCache = mutableMapOf>() val resolvedDirectoryCache = mutableMapOf() for (request in treeRequests) { val directDir = if (resolvedDirectoryCache.containsKey(request.relativeDir)) { @@ -304,7 +309,15 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { val lookup = if (directDir == null) { emptyMap() } else { - getSafChildFileLookup(directDir, directoryCache) + 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)) @@ -314,7 +327,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { resultsByKey[request.key] = result( key = request.key, status = "found", - uri = direct.uri.toString(), + uri = direct.doc.uri.toString(), fileName = direct.name ?: directName, relativeDir = request.relativeDir, ) @@ -336,7 +349,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { .add(request.key) } } - val matches = mutableMapOf>() + val matches = mutableMapOf>() val matchedRequestKeys = mutableSetOf() val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -351,7 +364,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { } val (directory, path) = queue.removeFirst() val children = try { - directory.listFiles() + listSafChildrenOrThrow(directory, includeLastModified = false) } catch (_: Exception) { scanComplete = false break @@ -365,7 +378,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { if (child.isDirectory) { val childName = child.name ?: continue val childPath = if (path.isBlank()) childName else "$path/$childName" - queue.add(child to childPath) + queue.add(child.doc to childPath) } else if (child.isFile) { val childName = child.name ?: continue val normalized = childName.lowercase(Locale.ROOT) @@ -388,7 +401,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String { result( key = request.key, status = "found", - uri = match.first.uri.toString(), + uri = match.first.doc.uri.toString(), fileName = match.first.name ?: matchedName, relativeDir = match.second, ) @@ -592,21 +605,25 @@ internal data class SafChildEntry( val name: String?, val isDirectory: Boolean, val lastModified: Long, + val isFile: Boolean, ) -internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List { +internal fun Context.listSafChildrenOrThrow( + dir: DocumentFile, + includeLastModified: Boolean = true, +): List { val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( dir.uri, DocumentsContract.getDocumentId(dir.uri), ) - val projection = arrayOf( + val projection = mutableListOf( DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentsContract.Document.COLUMN_LAST_MODIFIED, ) + if (includeLastModified) projection.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED) val cursor = try { - contentResolver.query(childrenUri, projection, null, null, null) + contentResolver.query(childrenUri, projection.toTypedArray(), null, null, null) } catch (_: Exception) { // A few older providers reject the richer projection; retry with IDs. contentResolver.query( @@ -636,7 +653,7 @@ internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List= 0 && !it.isNull(displayNameIndex)) { it.getString(displayNameIndex) @@ -653,14 +670,21 @@ internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List= 0 && !it.isNull(lastModifiedIndex) ) { it.getLong(lastModifiedIndex) } else { try { child.lastModified() } catch (_: Exception) { 0L } } - add(SafChildEntry(child, name, isDirectory, lastModified)) + add(SafChildEntry(child, name, isDirectory, lastModified, isFile)) } } }