package org.libremediaconverter.saf; import android.database.Cursor; import android.database.MatrixCursor; import android.os.CancellationSignal; import android.os.ParcelFileDescriptor; 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.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; /** * One file, offered to the system file picker, so that picking one can be tested at all. * *
DocumentsUI does not browse a filesystem: it lists what {@link DocumentsProvider}s hand it. * So a test that drives the real picker has to supply the thing being picked, and it has to * supply it as a manifest-declared component, because a {@code ContentProvider} is instantiated * by the system and cannot be registered from test code. {@code * app/src/androidTest/AndroidManifest.xml} is that declaration and says why each of its * attributes is load-bearing. * *
Everything else here is Kotlin. This cannot be: the Kotlin standard library is not on * this class's classpath at runtime. * *
Instrumentation code normally never notices. The test APK's dex is loaded into the app's * process, where the app APK supplies {@code kotlin.jvm.internal.Intrinsics} — so the test APK is * built without it, deliberately, since packaging a second copy is what {@code * checkDebugAndroidTestDuplicateClasses} exists to prevent. A provider is different. It is a * component of the instrumentation package, so when DocumentsUI queries it the system * starts a plain {@code org.libremediaconverter.test} process with only the test APK on its dex * path, and no app APK anywhere. The Kotlin version of this file crashed there on its first * query, before returning a single row: * *
* FATAL EXCEPTION: binder:6369_2 * Process: org.libremediaconverter.test * java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics; * at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument ** *
The compiler emits that reference for the null checks on almost every function, so there is * no Kotlin dialect that avoids it. For the same reason nothing here imports {@code androidx.*}: * those classes are absent from this process for exactly the same reason. Framework and JDK only. * *
That would have worked, and it would have tested less. Two properties are what {@code * SafPickerRoundTripTest} actually needs: * *
The descriptor is opened on a real file rather than served through a pipe, deliberately. * {@code InputQuery.sizeOf} falls back to {@code ParcelFileDescriptor.statSize} when a provider * omits {@code OpenableColumns.SIZE}, and a pipe's {@code statSize} is {@code -1} — an unknown * size, which is a different case with a screen of its own. This fixture is meant to be an * ordinary, fully described file, so that the one thing under test is the round trip. */ public final class FixtureDocumentsProvider extends DocumentsProvider { /** * What the picker calls this root. * *
Deliberately not a word any other root uses. The picker's own landing screen already * offers "Images", "Audio", "Videos" and "Documents", and a UiAutomator selector that could * match two things is not a selector. */ public static final String ROOT_TITLE = "LMC R38 fixtures"; /** * What the file card has to end up showing. * *
The same string reaches the assertion two ways — as the picker row UiAutomator taps, and * as {@code OpenableColumns.DISPLAY_NAME} on the URI the app is handed — which is exactly the * round trip under test. */ public static final String FIXTURE_DISPLAY_NAME = "lmc-r38-fixture.mp4"; /** * The type the root advertises, and the one the MIME mutation has to stop matching. * *
A real type rather than something invented, so the wildcard filter the screen passes * today is not the only filter under which this test could pass. */ public static final String FIXTURE_MIME_TYPE = "video/mp4"; private static final String ROOT_ID = "lmc-r38-root"; private static final String ROOT_DOCUMENT_ID = "root"; private static final String FIXTURE_DOCUMENT_ID = "root/" + FIXTURE_DISPLAY_NAME; /** * Prefix for documents this provider CREATES, as opposed to the one it serves for reading. * *
Two namespaces rather than one so a destination can never be confused with the fixture.
* The fixture is read-only and must stay that way for the picker tests; a destination is
* writable and deletable, which is what {@code PublishToRealSafDestinationTest} needs.
*/
public static final String DESTINATION_PREFIX = "dest/";
/** Document ids {@link #deleteDocument} was called with, newest last. Cleared by {@link #reset}. */
private static final List {@link Root#COLUMN_MIME_TYPES} is the important column. Left null it would mean "this
* root supports everything", the picker would list it whatever was asked for, and the MIME
* mutation would have nothing to bite on.
*/
@Override
public Cursor queryRoots(String[] projection) {
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION);
cursor.newRow()
.add(Root.COLUMN_ROOT_ID, ROOT_ID)
.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
.add(Root.COLUMN_TITLE, ROOT_TITLE)
.add(Root.COLUMN_SUMMARY, "Instrumentation fixture")
.add(Root.COLUMN_MIME_TYPES, FIXTURE_MIME_TYPE)
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE)
.add(Root.COLUMN_ICON, android.R.drawable.ic_menu_gallery);
return cursor;
}
@Override
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
if (ROOT_DOCUMENT_ID.equals(documentId)) {
addDirectoryRow(cursor);
} else if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
addFixtureRow(cursor);
} else if (documentId != null && documentId.startsWith(DESTINATION_PREFIX)) {
addDestinationRow(cursor, documentId);
} else {
throw new FileNotFoundException("no such document: " + documentId);
}
return cursor;
}
@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
throws FileNotFoundException {
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
if (ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
addFixtureRow(cursor);
}
return cursor;
}
@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal)
throws FileNotFoundException {
if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
}
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
throw new FileNotFoundException("no such document: " + documentId);
}
int flags = "r".equals(mode)
? ParcelFileDescriptor.MODE_READ_ONLY
: ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_TRUNCATE;
return ParcelFileDescriptor.open(destinationFile(documentId), flags);
}
/**
* Creates a real, empty file and reports the document id for it.
*
* Empty is the whole point, and this provider does not get to decide it. The premise
* under test in {@code PublishToRealSafDestinationTest} is what DocumentsUI hands back
* from {@code ACTION_CREATE_DOCUMENT}, and {@code OutputPublisher.destinationIsKnownEmpty}
* authorises its cleanup delete only on a positive zero. This creates the file and writes
* nothing to it, which is what the SAF contract documents; the test asserts what actually came
* back rather than trusting either side.
*/
@Override
public String createDocument(String parentDocumentId, String mimeType, String displayName)
throws FileNotFoundException {
if (!ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
throw new FileNotFoundException("cannot create in: " + parentDocumentId);
}
String documentId = DESTINATION_PREFIX + displayName;
File file = destinationFile(documentId);
try {
if (!file.createNewFile() && !file.exists()) {
throw new FileNotFoundException("could not create: " + documentId);
}
} catch (IOException e) {
throw new FileNotFoundException("could not create " + documentId + ": " + e);
}
return documentId;
}
@Override
public void deleteDocument(String documentId) throws FileNotFoundException {
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
throw new FileNotFoundException("refusing to delete: " + documentId);
}
synchronized (DELETED) {
DELETED.add(documentId);
}
destinationFile(documentId).delete();
}
/**
* Document ids {@link #deleteDocument} was called with, newest last.
*
* Nothing reads this yet, and that is recorded rather than hidden (#250). It was
* added with #226 to assert {@code OutputPublisher.deletePartialOutput} — D4's cleanup — against
* a real {@code DocumentsProvider}. #226 only reached the success path, so the
* {@code catch} that calls it is still asserted only against {@code FakeSafProvider} under
* Robolectric. It is kept because the forcing condition is one {@code openDestination} override
* away and #250 says exactly what to add; if that ticket is closed any other way, delete this
* and {@link #DELETED} with it rather than leaving an accessor implying coverage.
*/
public static List On demand rather than seeded once in {@link #onCreate()}, because this process is started
* by whoever queries the provider and can be killed between two queries of the same test.
*
* A failure here is reported as {@link FileNotFoundException} rather than swallowed. A
* provider that answers with a zero-byte file would put the test on the "Size unknown" screen
* with nothing saying why.
*/
private File fixtureFile() throws FileNotFoundException {
File file = new File(getContext().getFilesDir(), FIXTURE_DISPLAY_NAME);
if (file.length() > 0L) {
return file;
}
try (InputStream source = getContext().getAssets().open(FIXTURE_ASSET);
OutputStream sink = new FileOutputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = source.read(buffer)) != -1) {
sink.write(buffer, 0, read);
}
} catch (IOException e) {
throw new FileNotFoundException("could not unpack " + FIXTURE_ASSET + ": " + e);
}
return file;
}
}