mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
perf(android): batch SAF library recovery metadata queries
This commit is contained in:
1 parent
1ef81b4530
commit
1387a28d66
3 files changed
+183
-21
No files matched your search
+9
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user