diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6ed9c72..7287b11 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -340,5 +340,11 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.work.testing)
+ // androidTest only, and it has to be: UiAutomator drives the whole device, including
+ // windows belonging to other packages. The system file picker is one -- DocumentsUI runs
+ // in its own process, so Compose's matchers cannot see it and Espresso's cannot either
+ // (both are scoped to this process's view hierarchy). Nothing on the JVM has a device to
+ // drive, so there is no unit-test counterpart to add it to.
+ androidTestImplementation(libs.androidx.uiautomator)
debugImplementation(libs.compose.ui.test.manifest)
}
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..2ed15ba
--- /dev/null
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.kt b/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.kt
new file mode 100644
index 0000000..3b20033
--- /dev/null
+++ b/app/src/androidTest/java/org/libremediaconverter/saf/FixtureDocumentsProvider.kt
@@ -0,0 +1,197 @@
+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/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8441472..69f21ea 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -42,6 +42,18 @@ annotation = "1.+"
junit = "4.+"
androidxJunit = "1.+"
espressoCore = "3.+"
+# UiAutomator. FLOATING, and the argument for it is the one the guard already makes:
+# androidx.test.uiautomator is inside `floatedGroupPrefixes` ("androidx."), so `2.+` reads
+# as "the newest RELEASED 2.x" exactly the way `work = "2.+"` does -- and this library does
+# publish alphas above its stable, so without the guard it would be a pin.
+#
+# Not pinned like ktlint/detekt/robolectric, because it is not that kind of dependency. Those
+# are pinned because a new *rule* or a new *runtime* makes untouched files fail -- the tool
+# changes its verdict on code nobody edited. UiAutomator has no verdict: it clicks what a
+# selector names, and a selector that stops matching is this repo's test to fix, in a diff
+# that says so. `2.` and not bare `+` because 3.x does not exist yet and a major is where the
+# selector API would be free to change under exactly that assumption.
+uiautomator = "2.+"
# PINNED, unlike its neighbours. Under semver a 0.x minor is allowed to break, and
# this library is load-bearing exactly where breakage is hardest to see: the wrapper
# reaches for smartexception.java.Exceptions only when an FFmpeg call FAILS, so a
@@ -139,6 +151,11 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+# The only way to touch UI this app does not own. Compose's own matchers stop at this
+# process's composition, and the system file picker is a DocumentsUI activity in another
+# process -- so a SAF round trip is unreachable without it.
+androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
+
# Robolectric — an Android runtime for the JVM test source set, so file-lifecycle behaviour
# that needs a real Context can be verified without a device. The instrumented suite cannot
# run on the development host at all (see CLAUDE.md), so an androidTest-only red test is not