diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7287b11..a2565d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -235,8 +235,11 @@ val jacocoGeneratedExcludes = listOf( ) // AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the -// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the -// javac output (BuildConfig and R only) is not read at all. +// classic `tmp/kotlin-classes/debug`. All hand-written code in the MAIN source set is Kotlin, so +// the javac output (BuildConfig and R only) is not read at all. There is now one hand-written +// Java file in the module -- androidTest's FixtureDocumentsProvider, which cannot be Kotlin +// because the process it runs in has no Kotlin stdlib; its own header explains why. It is in +// androidTest, so it is not in this task's classDirectories and this stays accurate. val jacocoDebugKotlinClasses = layout.buildDirectory.dir( "intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", ) diff --git a/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.java b/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.java new file mode 100644 index 0000000..c5d7db9 --- /dev/null +++ b/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.java @@ -0,0 +1,234 @@ +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; + +/** + * 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. + * + *

The only Java file in this module, and it has to be

+ * + *

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. + * + *

Why a provider rather than a file in Downloads

+ * + *

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; + + /** Already in this source set, and already a real H.264 MP4 the engines can open. */ + private static final String FIXTURE_ASSET = "sample_h264.mp4"; + + private static final String[] DEFAULT_ROOT_PROJECTION = { + Root.COLUMN_ROOT_ID, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_MIME_TYPES, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = { + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + }; + + @Override + public boolean onCreate() { + return true; + } + + /** + * The single root. + * + *

{@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) + .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 { + 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)) { + throw new FileNotFoundException("no such document: " + documentId); + } + return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY); + } + + private void addDirectoryRow(MatrixCursor cursor) { + cursor.newRow() + .add(Document.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID) + .add(Document.COLUMN_DISPLAY_NAME, ROOT_TITLE) + .add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + .add(Document.COLUMN_FLAGS, 0) + .add(Document.COLUMN_SIZE, null); + } + + private void addFixtureRow(MatrixCursor cursor) throws FileNotFoundException { + File file = fixtureFile(); + cursor.newRow() + .add(Document.COLUMN_DOCUMENT_ID, FIXTURE_DOCUMENT_ID) + .add(Document.COLUMN_DISPLAY_NAME, FIXTURE_DISPLAY_NAME) + .add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE) + .add(Document.COLUMN_FLAGS, 0) + .add(Document.COLUMN_SIZE, file.length()) + .add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); + } + + /** + * The fixture on disk, unpacked from this APK's own assets the first time anything asks. + * + *

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; + } +} diff --git a/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.kt b/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.kt deleted file mode 100644 index 3b20033..0000000 --- a/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.kt +++ /dev/null @@ -1,197 +0,0 @@ -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 - -/** - * One file, offered to the system file picker, so that picking one can be tested at all. - * - * DocumentsUI does not browse the filesystem: it lists what [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 — a `ContentProvider` is instantiated by the system, never - * registered from test code. `app/src/androidTest/AndroidManifest.xml` is that declaration and - * says why each of its attributes is load-bearing. - * - * ### Why not just write a file to Downloads - * - * That would work and it would test less. Two properties of a provider are what - * [SafPickerRoundTripTest] actually needs: - * - * - **The root declares [Root.COLUMN_MIME_TYPES], and DocumentsUI filters the drawer by it.** - * That is what gives the MIME mutation a bite with a shape: ask for a type this root does not - * offer and the root itself is not in the picker, so the failure is "the fixture root is not - * there" rather than "one file among the hundreds in Downloads was not listed". - * - **The contents are exactly this and nothing else.** A shared directory accumulates whatever - * earlier runs and other tests left in it, and a picker test that finds the wrong file passes. - * - * ### It runs in its own process, so it seeds itself - * - * The instrumentation's own code executes inside the *app's* process; this provider is a - * component of the instrumentation *package*, so the system starts a separate process under - * `org.libremediaconverter.test` for it. Nothing the test sets up in a field is visible here. - * The bytes therefore come from this APK's own assets, on demand, and the test and the provider - * agree only on the constants below — which are compile-time, so they cannot drift. - * - * The descriptor is opened on a real file rather than served through a pipe, deliberately. - * `InputQuery.sizeOf` falls back to `ParcelFileDescriptor.statSize` when a provider omits - * `OpenableColumns.SIZE`, and a pipe's `statSize` is `-1` — an unknown size, which is a - * different case with its own screen. This fixture is meant to be an ordinary, fully described - * file, so the one thing under test is the round trip. - */ -class FixtureDocumentsProvider : DocumentsProvider() { - - override fun onCreate(): Boolean = true - - /** - * The single root, named [ROOT_TITLE] so a UiAutomator text selector is unambiguous. - * - * [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 fun queryRoots(projection: Array?): Cursor = - MatrixCursor(copyOf(projection, DEFAULT_ROOT_PROJECTION)).apply { - 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) - .add(Root.COLUMN_ICON, android.R.drawable.ic_menu_gallery) - } - - override fun queryDocument(documentId: String?, projection: Array?): Cursor = - MatrixCursor(copyOf(projection, DEFAULT_DOCUMENT_PROJECTION)).apply { - when (documentId) { - ROOT_DOCUMENT_ID -> addDirectoryRow() - FIXTURE_DOCUMENT_ID -> addFixtureRow() - else -> throw java.io.FileNotFoundException("no such document: $documentId") - } - } - - override fun queryChildDocuments( - parentDocumentId: String?, - projection: Array?, - sortOrder: String?, - ): Cursor = MatrixCursor(copyOf(projection, DEFAULT_DOCUMENT_PROJECTION)).apply { - if (parentDocumentId == ROOT_DOCUMENT_ID) addFixtureRow() - } - - override fun openDocument(documentId: String?, mode: String?, signal: CancellationSignal?): ParcelFileDescriptor { - if (documentId != FIXTURE_DOCUMENT_ID) { - throw java.io.FileNotFoundException("no such document: $documentId") - } - return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY) - } - - private fun MatrixCursor.addDirectoryRow() { - newRow() - .add(Document.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID) - .add(Document.COLUMN_DISPLAY_NAME, ROOT_TITLE) - .add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) - .add(Document.COLUMN_FLAGS, 0) - .add(Document.COLUMN_SIZE, null) - } - - private fun MatrixCursor.addFixtureRow() { - newRow() - .add(Document.COLUMN_DOCUMENT_ID, FIXTURE_DOCUMENT_ID) - .add(Document.COLUMN_DISPLAY_NAME, FIXTURE_DISPLAY_NAME) - .add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE) - .add(Document.COLUMN_FLAGS, 0) - .add(Document.COLUMN_SIZE, fixtureFile().length()) - .add(Document.COLUMN_LAST_MODIFIED, fixtureFile().lastModified()) - } - - /** - * The fixture on disk, unpacked from this APK's assets the first time anything asks. - * - * Idempotent rather than seeded once at [onCreate], because this process is started by - * whoever queries the provider and can be killed between two queries of the same test. - */ - private fun fixtureFile(): File { - val context = requireNotNull(context) { "provider used before onCreate" } - val file = File(context.filesDir, FIXTURE_DISPLAY_NAME) - if (file.length() == 0L) { - context.assets.open(FIXTURE_ASSET).use { source -> - file.outputStream().use(source::copyTo) - } - } - return file - } - - /** - * [projection] as a `MatrixCursor` will take it, or [fallback] when the caller asked for - * everything. - * - * Rebuilt element by element rather than spread into `arrayOf`, so nothing depends on the - * variance of the platform array type this arrives as. - */ - private fun copyOf(projection: Array?, fallback: Array): Array = - if (projection == null) fallback else Array(projection.size) { projection[it] } - - companion object { - - /** Matches `android:authorities` in `app/src/androidTest/AndroidManifest.xml`. */ - const val AUTHORITY: String = "org.libremediaconverter.test.fixtures" - - /** - * What the picker's drawer calls this root. - * - * Deliberately not a word any other root uses. DocumentsUI's drawer also lists - * "Downloads", "Images", "Videos" and the device name, and a UiAutomator selector that - * could match two of them is not a selector. - */ - const val ROOT_TITLE: String = "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 `OpenableColumns.DISPLAY_NAME` on the URI the app is handed — which is exactly - * the round trip under test. - */ - const val FIXTURE_DISPLAY_NAME: String = "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 one under which this test could pass. - */ - const val FIXTURE_MIME_TYPE: String = "video/mp4" - - private const val ROOT_ID = "lmc-r38-root" - private const val ROOT_DOCUMENT_ID = "root" - private const val FIXTURE_DOCUMENT_ID = "root/$FIXTURE_DISPLAY_NAME" - - /** Already in this source set, and already a real H.264 MP4 the engines can open. */ - private const val FIXTURE_ASSET = "sample_h264.mp4" - - private val DEFAULT_ROOT_PROJECTION = arrayOf( - Root.COLUMN_ROOT_ID, - Root.COLUMN_DOCUMENT_ID, - Root.COLUMN_TITLE, - Root.COLUMN_SUMMARY, - Root.COLUMN_MIME_TYPES, - Root.COLUMN_FLAGS, - Root.COLUMN_ICON, - ) - - private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_DISPLAY_NAME, - Document.COLUMN_MIME_TYPE, - Document.COLUMN_FLAGS, - Document.COLUMN_SIZE, - Document.COLUMN_LAST_MODIFIED, - ) - } -} diff --git a/app/src/androidTest/java/org/libremediaconverter/saf/SafPickerRoundTripTest.kt b/app/src/androidTest/java/org/libremediaconverter/saf/SafPickerRoundTripTest.kt new file mode 100644 index 0000000..c245bee --- /dev/null +++ b/app/src/androidTest/java/org/libremediaconverter/saf/SafPickerRoundTripTest.kt @@ -0,0 +1,248 @@ +package org.libremediaconverter.saf + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.media3.common.util.UnstableApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.After +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremediaconverter.MainActivity +import org.libremediaconverter.ui.TestTags + +/** + * Choosing a file, through the real system picker, and still having it after a rotation. + * + * Two defects, and neither is reachable from anywhere else in this repo. + * + * **The picker is opened with a filter, and a filter can hide the user's file.** `ConverterScreen` + * launches `ActivityResultContracts.OpenDocument` with a MIME array; DocumentsUI hides every root + * and every document that array does not match. Narrow it and the app still compiles, still + * renders, still passes every JVM test — and the user taps "Choose file" and is shown an empty + * picker. Nothing in either source set drove SAF **as a picker** before this: the only SAF coverage + * is the publish side, in `OutputPublisherPublishTest`, against hand-written `ContentProvider` + * fakes. The launcher wiring, the filter, and the read grant that comes back had never been + * executed by a test. + * + * **The picked file has to survive a rotation.** `MainActivity` declares no `configChanges`, so + * every rotation destroys and recreates it, and `ConversionViewModel` holds the picked file in a + * plain `MutableStateFlow` with no `SavedStateHandle` behind it. The only thing that carries it + * across is the retained `ViewModelStore` the Activity gets from resolving the ViewModel through + * `LocalViewModelStoreOwner`. Scope it to the composition instead and the file is gone. + * + * ### Why these two are one test class + * + * A rotation test alone has no bite of its own. `AppRootRestorationTest` already catches + * `rememberSaveable` -> `remember` on the JVM, and a second test whose only mutation is one an + * existing test catches is the vacuous test this whole decomposition exists to prevent. So the + * rotation here runs **from a real picked input**, which is a state no JVM test can produce: + * `AppRootRestorationTest` injects a stub `content` lambda specifically to avoid standing up + * either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a + * `Bundle`. + * + * ### The mutations, and what they printed + * + * Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to + * `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the + * fixture root entirely, and [pickingAFileThroughTheSystemPickerFillsInTheFileCard] fails on the + * assertion that names it. + * Making the ViewModel composition-scoped leaves the picker test alone and fails + * [thePickedInputSurvivesARealRotation], with `:app:testDebugUnitTest` still BUILD SUCCESSFUL — + * which is the divergence this ticket was filed to establish, and which was doubted on it. It is + * `viewModel()` -> `viewModel(viewModelStoreOwner = remember { })`, + * **plus** `factory = ViewModelProvider.AndroidViewModelFactory()` and a `MutableCreationExtras` + * carrying `APPLICATION_KEY`. The factory half is not decoration: an owner that is not a + * `HasDefaultViewModelProviderFactory` contributes no creation extras, and the default factory + * cannot construct an `AndroidViewModel` without them — so the owner swap alone crashes on + * construction instead of demonstrating the scope. The PR body quotes both failures verbatim. + * + * ### It has to be an unlocked emulator + * + * The Pixel 10 Pro XL is secure-locked and cannot be unlocked from a shell, so the picker cannot be + * driven there at all. That is why this gap survived as long as it did. + * `tools/local-emulator/run-e2e.sh` runs API 33-36 on the development host. + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class SafPickerRoundTripTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val device: UiDevice = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + /** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */ + private var rotated = false + + /** + * Leave the device the way it was found — and only if this test moved it. + * + * Two things are deliberate here, and both are about the *other* tests on the device rather + * than about these two. + * + * The flag, because this runs after every test in the class, not only the one that rotated. An + * unconditional restore issues a WindowManager rotation request after the picker test as well, + * which has nothing to undo; JUnit does not promise method order, so that is an interaction + * between two tests that no single-class run would ever show. Tracked as a flag rather than + * read back off `isNaturalOrientation`, because a device whose *natural* orientation is + * landscape would answer that question the wrong way round. + * + * And `unfreezeRotation`, because `setOrientationNatural` does not merely rotate: it freezes + * the rotation there. A run that stopped after it would hand the next test a device that + * cannot rotate at all. + */ + @After + fun restoreOrientation() { + if (!rotated) return + device.setOrientationNatural() + device.unfreezeRotation() + device.waitForIdle() + } + + @Test + fun pickingAFileThroughTheSystemPickerFillsInTheFileCard() { + pickTheFixture() + + composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME) + .assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME) + + // Not the same assertion twice. The name above comes from a metadata query, which a URI + // with no read grant answers just as well; this line only appears once something has + // opened the file and read its header. It is what says the picker handed back a URI the + // app can actually USE -- delete grantUriPermissions from the fixture's manifest entry and + // the name still arrives while this goes red. + // + // The whole "Container: MP4" and not "MP4": DetailRow renders the label and the value as + // one semantics node. + awaitNode(TestTags.Converter.detailRow(CONTAINER_LABEL)) + composeRule.onNodeWithTag(TestTags.Converter.detailRow(CONTAINER_LABEL)) + .assertTextEquals("$CONTAINER_LABEL: MP4") + } + + @Test + fun thePickedInputSurvivesARealRotation() { + pickTheFixture() + // The identity hash rather than the Activity itself, so nothing here keeps a destroyed + // Activity reachable across the recreation it is being used to detect. + val before = System.identityHashCode(composeRule.activity) + + device.setOrientationLandscape() + rotated = true + composeRule.waitForIdle() + + // Two guards before the assertion that matters, because both of the ways this test could + // pass while proving nothing are silent ones. + // + // A device that ignored the rotation request would leave the app exactly as it was, and + // "the file is still there" would then be a statement about a screen nothing happened to. + assertNotEquals( + "the device did not actually rotate, so nothing below is about a rotation", + NATURAL_ROTATION, + device.displayRotation, + ) + // And a rotation that did NOT recreate the Activity -- a configChanges attribute added to + // the manifest, an aspect-ratio or orientation lock -- would make this a recomposition + // test. The retained ViewModelStore is only interesting because the Activity around it + // really was destroyed and rebuilt. + assertNotEquals( + "the rotation did not recreate MainActivity, so the retained ViewModelStore was never used", + before, + System.identityHashCode(composeRule.activity), + ) + + awaitNode(TestTags.Converter.FILE_CARD_NAME) + composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME) + .assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME) + } + + // --- driving the picker --------------------------------------------------------------- + + /** + * Taps "Choose file", walks the system picker to the fixture, and returns once the app has it. + * + * Everything between the first tap and the last belongs to `com.google.android.documentsui`, + * which is why UiAutomator is here at all: Compose's matchers stop at this process's + * composition and Espresso's at its view hierarchy, and the picker is neither. + */ + private fun pickTheFixture() { + composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick() + + // THIS is the line the MIME filter mutation fails on. DocumentsUI matches the requested + // types against Root.COLUMN_MIME_TYPES and drops the roots that cannot answer, so a filter + // the fixture root does not satisfy takes the root out of the picker altogether -- along + // with "Images", "Audio", "Videos" and "Documents", measured on API 34. + val root = awaitPickerNode(By.text(FixtureDocumentsProvider.ROOT_TITLE)) { + // Which screen the picker opens on is its own business: it lands on Recent, where the + // roots are a strip at the bottom, but a device with a populated Recent may need the + // drawer. Looking in the second place widens where the root is searched for; it does + // not weaken what has to be found, which is still this root. + device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click() + } + root.click() + + awaitPickerNode(By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)).click() + + awaitNode(TestTags.Converter.FILE_CARD_NAME) + } + + /** + * The picker node [selector] names, or a failure that says which one was missing. + * + * [ifAbsent] runs once, after the first wait comes up empty, and then the wait is repeated. A + * null return from `findObject` is deliberately not an error there: it is the "already on the + * right screen" case. + */ + private fun awaitPickerNode(selector: BySelector, ifAbsent: () -> Unit = {}) = + device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS) + ?: run { + ifAbsent() + requireNotNull(device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)) { + "the system picker never showed $selector" + } + } + + /** + * Blocks until [tag] is in the composition, so an assertion cannot race the picker's result. + * + * The described overload of `waitUntil`, not the bare one. A timeout is how both of this + * class's mutations report themselves, and the bare overload's message is + * `Condition still not satisfied after 30000 ms` — which names neither the node nor the test. + * With the description it says which affordance never arrived, which is the whole finding. + */ + private fun awaitNode(tag: String) { + composeRule.waitUntil("a node tagged $tag exists", APP_TIMEOUT_MS) { + composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + } + + private companion object { + + /** + * Generous on purpose. This waits on another app being started, and on FFprobe spawning a + * native process over a `content://` URI; a timeout that merely usually passes is a flaky + * gating leg on five API levels, which costs far more than the seconds it saves. + */ + const val PICKER_TIMEOUT_MS = 30_000L + const val APP_TIMEOUT_MS = 30_000L + + /** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */ + const val NATURAL_ROTATION = 0 + + /** DocumentsUI's drawer button. It carries no text, only this description. */ + const val SHOW_ROOTS_DESCRIPTION = "Show roots" + + /** The detail row `MediaProbe` fills in for anything it could open and identify. */ + const val CONTAINER_LABEL = "Container" + } +}