mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-01 05:39:34 +02:00
perf(android): batch SAF lookups during download publication
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user