perf(android): batch SAF library recovery metadata queries

This commit is contained in:
zarzet committed 2026-09-17 17:38:15 +07:00
1 parent 1ef81b4530
commit 1387a28d66
3 files changed
+183 -21

No files matched your search

@@ -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;
@@ -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"))
}
}
@@ -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<String, JSONObject>()
val requests = mutableListOf<SafFileInspectionRequest>()
@@ -287,7 +292,7 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String {
}
val unresolved = mutableListOf<SafFileInspectionRequest>()
val directoryCache = mutableMapOf<String, Map<String, DocumentFile>>()
val directoryCache = mutableMapOf<String, Map<String, SafChildEntry>>()
val resolvedDirectoryCache = mutableMapOf<String, DocumentFile?>()
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<String, Pair<DocumentFile, String>>()
val matches = mutableMapOf<String, Pair<SafChildEntry, String>>()
val matchedRequestKeys = mutableSetOf<String>()
val queue: ArrayDeque<Pair<DocumentFile, String>> = 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<SafChildEntry> {
internal fun Context.listSafChildrenOrThrow(
dir: DocumentFile,
includeLastModified: Boolean = true,
): List<SafChildEntry> {
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<SafChi
dir.uri,
it.getString(documentIdIndex),
)
val child = DocumentFile.fromSingleUri(this@listSafChildrenOrThrow, childUri)
val child = DocumentFile.fromTreeUri(this@listSafChildrenOrThrow, childUri)
?: throw IOException("Invalid SAF child URI: $childUri")
val name = if (displayNameIndex >= 0 && !it.isNull(displayNameIndex)) {
it.getString(displayNameIndex)
@@ -653,14 +670,21 @@ internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List<SafChi
} else {
try { child.isDirectory } catch (_: Exception) { false }
}
val lastModified = if (
val isFile = if (mimeType != null) {
mimeType.isNotEmpty() && !isDirectory
} else {
!isDirectory && try { child.isFile } catch (_: Exception) { false }
}
val lastModified = if (!includeLastModified) {
0L
} else if (
lastModifiedIndex >= 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))
}
}
}