perf(android): batch SAF lookups during download publication

This commit is contained in:
zarzet committed 2026-09-17 03:30:30 +07:00
1 parent 3eb44dc892
commit c868aac2cc
11 files changed
+668 -48

No files matched your search

@@ -0,0 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="com.zarz.spotiflac.LookupTestControlProvider"
android:authorities="com.spotiflac.test.documents.lookup.control"
android:exported="true" />
<!-- This fixture exists only in the instrumentation APK. -->
<provider
android:name="com.zarz.spotiflac.LookupTestDocumentsProvider"
android:authorities="com.spotiflac.test.documents.lookup"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:grantUriPermissions="true">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
@@ -0,0 +1,54 @@
package com.zarz.spotiflac;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Binder;
import android.os.Bundle;
/** Issues fixture setup calls from the test APK's UID, never the app UID. */
public final class LookupTestControlProvider extends ContentProvider {
@Override
public boolean onCreate() {
return true;
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
if (!method.startsWith("lookup.")) throw new IllegalArgumentException(method);
long identity = Binder.clearCallingIdentity();
try {
return getContext().getContentResolver().call(
Uri.parse("content://com.spotiflac.test.documents.lookup"), method, arg, extras
);
} finally {
Binder.restoreCallingIdentity(identity);
}
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] args, String order) {
return null;
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
return null;
}
@Override
public int delete(Uri uri, String selection, String[] args) {
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] args) {
return 0;
}
}
@@ -0,0 +1,232 @@
package com.zarz.spotiflac;
import android.content.Intent;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.os.Binder;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.provider.DocumentsProvider;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
/** Uses Java only: a standalone test APK provider cannot use the app's Kotlin runtime. */
public final class LookupTestDocumentsProvider extends DocumentsProvider {
private static final class Entry {
final String id;
final String parent;
final String name;
final String mime;
final File file;
Entry(String id, String parent, String name, String mime, File file) {
this.id = id;
this.parent = parent;
this.name = name;
this.mime = mime;
this.file = file;
}
}
private final Map<String, Entry> entries = new LinkedHashMap<>();
private int sequence;
private int childQueries;
private int documentQueries;
private int closedCursors;
private String projectionMode = "normal";
private boolean failFinalRename;
private File fixtureDir() {
return new File(getContext().getCacheDir(), "lookup-provider");
}
@Override
public boolean onCreate() {
return true;
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
switch (method) {
case "lookup.reset":
File[] previous = fixtureDir().listFiles();
if (previous != null) for (File file : previous) file.delete();
fixtureDir().mkdirs();
entries.clear();
sequence = 0;
projectionMode = extras.getString("mode", "normal");
failFinalRename = extras.getBoolean("failFinalRename");
add("root", null, "Root", Document.MIME_TYPE_DIR);
for (int i = 0; i < extras.getInt("count"); i++) {
add("child:" + i, "root", "Track " + i + ".flac", "audio/flac");
}
add("nested:音楽/opaque", "root", "音楽 🎵", Document.MIME_TYPE_DIR);
add("song:opaque/%", "nested:音楽/opaque", "歌 🎵.flac", "audio/flac");
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'});
} catch (IOException e) {
throw new IllegalStateException(e);
}
// Simulate the narrow tree grant normally issued by the picker.
long identity = Binder.clearCallingIdentity();
try {
getContext().grantUriPermission(
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
);
} finally {
Binder.restoreCallingIdentity(identity);
}
resetCounters();
return new Bundle();
case "lookup.clearCounters":
resetCounters();
return new Bundle();
case "lookup.stats":
Bundle stats = new Bundle();
stats.putInt("children", childQueries);
stats.putInt("documents", documentQueries);
stats.putInt("closed", closedCursors);
return stats;
default:
return super.call(method, arg, extras);
}
}
private void resetCounters() {
childQueries = 0;
documentQueries = 0;
closedCursors = 0;
}
private Entry add(String id, String parent, String name, String mime) {
Entry entry = new Entry(id, parent, name, mime, new File(fixtureDir(), "data-" + sequence++));
entries.put(id, entry);
return entry;
}
private MatrixCursor cursor(String[] columns) {
return new MatrixCursor(columns) {
@Override
public void close() {
if (!isClosed()) closedCursors++;
super.close();
}
};
}
private void addEntry(MatrixCursor cursor, Entry entry) {
String[] columns = cursor.getColumnNames();
Object[] row = new Object[columns.length];
for (int i = 0; i < columns.length; i++) {
switch (columns[i]) {
case Document.COLUMN_DOCUMENT_ID: row[i] = entry.id; break;
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_FLAGS:
row[i] = Document.FLAG_SUPPORTS_WRITE | Document.FLAG_SUPPORTS_DELETE |
Document.FLAG_SUPPORTS_RENAME | Document.FLAG_DIR_SUPPORTS_CREATE;
break;
default: break;
}
}
cursor.addRow(row);
}
@Override
public Cursor queryRoots(String[] projection) {
String[] columns = projection != null ? projection :
new String[] {Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE};
MatrixCursor cursor = new MatrixCursor(columns);
Object[] row = new Object[columns.length];
for (int i = 0; i < columns.length; i++) {
if (Root.COLUMN_ROOT_ID.equals(columns[i]) || Root.COLUMN_DOCUMENT_ID.equals(columns[i])) row[i] = "root";
else if (Root.COLUMN_TITLE.equals(columns[i])) row[i] = "Lookup fixture";
}
cursor.addRow(row);
return cursor;
}
@Override
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
documentQueries++;
Entry entry = entries.get(documentId);
if (entry == null) throw new FileNotFoundException(documentId);
MatrixCursor cursor = cursor(projection != null ? projection : defaultColumns());
addEntry(cursor, entry);
return cursor;
}
@Override
public Cursor queryChildDocuments(String parentId, String[] projection, String sortOrder) {
childQueries++;
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;
String[] columns = projected && projectionMode.equals("missing") ?
new String[] {Document.COLUMN_DOCUMENT_ID} : projection != null ? projection : defaultColumns();
MatrixCursor cursor = cursor(columns);
for (Entry entry : entries.values()) if (parentId.equals(entry.parent)) addEntry(cursor, entry);
return cursor;
}
@Override
public boolean isChildDocument(String parentId, String documentId) {
Entry entry = entries.get(documentId);
while (entry != null && entry.parent != null) {
if (parentId.equals(entry.parent)) return true;
entry = entries.get(entry.parent);
}
return false;
}
@Override
public String createDocument(String parentId, String mimeType, String displayName) {
String id = "created:" + sequence++;
add(id, parentId, displayName, mimeType);
return id;
}
@Override
public String renameDocument(String documentId, String displayName) throws FileNotFoundException {
Entry entry = entries.get(documentId);
if (entry == null) throw new FileNotFoundException(documentId);
if (failFinalRename && entry.name.endsWith(".partial") && displayName.equals("Song.flac")) {
throw new FileNotFoundException("Injected publish rename failure");
}
String newId = "renamed:" + sequence++;
entries.remove(documentId);
entries.put(newId, new Entry(newId, entry.parent, displayName, entry.mime, entry.file));
return newId;
}
@Override
public void deleteDocument(String documentId) {
Entry entry = entries.remove(documentId);
if (entry != null) entry.file.delete();
}
@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) throws FileNotFoundException {
Entry entry = entries.get(documentId);
if (entry == null) throw new FileNotFoundException(documentId);
return ParcelFileDescriptor.open(entry.file, ParcelFileDescriptor.parseMode(mode));
}
private String[] defaultColumns() {
return new String[] {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE};
}
}
@@ -0,0 +1,170 @@
package com.zarz.spotiflac
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.File
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SafDocumentLookupTest {
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 fun reset(count: Int = 0, mode: String = "normal", failFinalRename: Boolean = false): DocumentFile {
context.contentResolver.call(controlUri, "lookup.reset", null, Bundle().apply {
putInt("count", count)
putString("mode", mode)
putBoolean("failFinalRename", failFinalRename)
putString("targetPackage", context.packageName)
})
return requireNotNull(DocumentFile.fromTreeUri(context, treeUri))
}
private fun stats() = requireNotNull(context.contentResolver.call(controlUri, "lookup.stats", null, null))
private fun clearCounters() {
context.contentResolver.call(controlUri, "lookup.clearCounters", null, null)
}
@Test
fun projectedBatchReplacesThousandsOfNameQueriesAndClosesItsCursor() {
val parent = reset(count = 2000)
assertNull(parent.findFile("missing.flac"))
assertEquals(1, stats().getInt("children"))
assertEquals(2002, stats().getInt("documents"))
clearCounters()
val found = findSafChildren(context, parent, setOf("Track 0.flac", "Track 1999.flac", "missing.flac"))
assertEquals(setOf("Track 0.flac", "Track 1999.flac"), found.keys)
assertEquals(1, stats().getInt("children"))
assertEquals(0, stats().getInt("documents"))
assertEquals(1, stats().getInt("closed"))
clearCounters()
assertNull(findSafChild(context, parent, "missing.flac"))
assertEquals(1, stats().getInt("children"))
assertEquals(0, stats().getInt("documents"))
clearCounters()
assertTrue(findSafChildren(context, parent, emptySet()).isEmpty())
assertEquals(0, stats().getInt("children"))
}
@Test
fun nestedOpaqueIdsUnicodeAndRenameKeepTheChildDocument() {
val parent = reset()
val directory = requireNotNull(findSafChild(context, parent, "音楽 🎵"))
assertEquals("nested:音楽/opaque", DocumentsContract.getDocumentId(directory.uri))
assertTrue(directory.isDirectory)
val child = requireNotNull(findSafChild(context, directory, "歌 🎵.flac"))
val oldUri = child.uri
assertTrue(child.renameTo("Renamed.flac"))
assertNotEquals(oldUri, child.uri)
assertEquals("Renamed.flac", child.name)
assertEquals(child.uri, findSafChild(context, directory, "Renamed.flac")?.uri)
assertNull(findSafChild(context, parent, "Renamed.flac"))
assertNotNull(directory.createDirectory("Created"))
}
@Test
fun unsupportedProjectionNullCursorAndMissingColumnUseLegacyLookup() {
for (mode in listOf("throw", "null", "missing")) {
val parent = reset(count = 3, mode = mode)
val found = requireNotNull(findSafChild(context, parent, "Song.flac"))
assertEquals("original", DocumentsContract.getDocumentId(found.uri))
assertEquals(2, stats().getInt("children"))
assertEquals(5, stats().getInt("documents"))
assertEquals(if (mode == "missing") 7 else 6, stats().getInt("closed"))
}
}
@Test
fun rawDocumentFileRetainsLegacyBehavior() {
val directory = File(context.cacheDir, "lookup-raw").apply { mkdirs() }
try {
File(directory, "local.flac").writeText("audio")
val found = findSafChild(context, DocumentFile.fromFile(directory), "local.flac")
assertEquals("local.flac", found?.name)
} finally {
directory.deleteRecursively()
}
}
@Test
fun malformedTreeUriLeavesSourceIntact() {
val source = File(context.cacheDir, "lookup-invalid.flac").apply { writeText("original audio") }
try {
assertNull(SafDownloadHandler.writeFileToSaf(
context, "content://$authority/invalid", "", "Song.flac", "audio/flac", source.path,
))
assertEquals("original audio", source.readText())
} finally {
source.delete()
}
}
@Test
fun failedPublicationRenameRestoresOriginalWhenProviderChangesDocumentIds() {
val parent = reset(failFinalRename = true)
val source = File(context.cacheDir, "lookup-source.flac").apply { writeText("replacement") }
try {
assertNull(SafDownloadHandler.writeFileToSaf(context, treeUri.toString(), "", "Song.flac", "audio/flac", source.path))
val restored = requireNotNull(findSafChild(context, parent, "Song.flac"))
val content = context.contentResolver.openInputStream(restored.uri)?.bufferedReader()?.use { it.readText() }
assertEquals("original", content)
assertEquals("replacement", source.readText())
assertNull(findSafChild(context, parent, "Song.flac.replaced"))
assertNull(findSafChild(context, parent, "Song.flac.partial"))
} finally {
source.delete()
}
}
@Test
fun publicationPreservesBytesReportsStagesAndDetectsLaterExistingFile() {
val parent = reset(count = 2000)
val bytes = ByteArray(262144) { (it % 251).toByte() }
val source = File(context.cacheDir, "lookup-publish.flac").apply { writeBytes(bytes) }
try {
assertNull(findSafChild(context, parent, "Fresh.flac"))
clearCounters()
val result = requireNotNull(SafDownloadHandler.writeFileToSafIfAbsent(
context, treeUri.toString(), "", "Fresh.flac", "audio/flac", source.path,
))
assertTrue(!result.alreadyExists)
assertEquals(4, stats().getInt("children"))
assertEquals(1, stats().getInt("documents"))
val expectedStages = setOf(
"lock_wait", "directory", "existing_check", "cleanup", "create",
"open", "copy", "sync", "close", "replace", "total",
)
assertTrue(result.publishTimingsMs.keys.containsAll(expectedStages))
assertTrue(result.publishTimingsMs.values.all { it >= 0 })
val actual = context.contentResolver.openInputStream(Uri.parse(result.uri))?.use { it.readBytes() }
assertArrayEquals(bytes, actual)
assertEquals(result.uri, findSafChild(context, parent, "Fresh.flac")?.uri.toString())
assertNull(findSafChild(context, parent, "Fresh.flac.partial"))
source.writeText("must not replace existing audio")
val existing = requireNotNull(SafDownloadHandler.writeFileToSafIfAbsent(
context, treeUri.toString(), "", "Fresh.flac", "audio/flac", source.path,
))
assertTrue(existing.alreadyExists)
assertEquals(result.uri, existing.uri)
assertArrayEquals(bytes, context.contentResolver.openInputStream(Uri.parse(existing.uri))?.use { it.readBytes() })
} finally {
source.delete()
}
}
}