Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d79c212a | ||
|
|
bc66906dc3 | ||
|
|
3fb25235c0 | ||
|
|
c0d99f7f86 | ||
|
|
1535b61a96 | ||
|
|
2efd1f9a0d | ||
|
|
240528facb | ||
|
|
f98e49942f | ||
|
|
25f162923c | ||
|
|
d0b9745220 | ||
|
|
b36d56c932 | ||
|
|
3f140fc2b1 | ||
|
|
b3208ef8c7 | ||
|
|
5a8aedf53d | ||
|
|
a0b6a3dde8 | ||
|
|
ba27b8306b | ||
|
|
bda5abea6c | ||
|
|
8bd5fedcc8 | ||
|
|
21eeb6f3f8 | ||
|
|
a83cb60c61 | ||
|
|
2063fe06aa | ||
|
|
47a423413b | ||
|
|
dab28d5f44 | ||
|
|
aed4d83e70 | ||
|
|
4aba3bbd2e | ||
|
|
dbba213c51 | ||
|
|
8ac6e2b1c2 | ||
|
|
4ff44be1d7 | ||
|
|
7f951baf8f | ||
|
|
5ec2bba64b | ||
|
|
fd2bb1d889 | ||
|
|
ad28293b72 |
@@ -81,7 +81,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify the released artifacts
|
- name: Verify the released artifacts
|
||||||
run: |
|
run: |
|
||||||
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
|
# A glob, not `ls | head`: the glob is already here, and parsing ls is what
|
||||||
|
# SC2012 is about. Gradle's names have no spaces today, which is exactly the
|
||||||
|
# kind of assumption that holds until it does not.
|
||||||
|
apks=(app/build/outputs/apk/release/*.apk)
|
||||||
|
APK="${apks[0]}"
|
||||||
# A release that shipped one ABI, or lost 16 KB alignment, would install
|
# A release that shipped one ABI, or lost 16 KB alignment, would install
|
||||||
# fine on a test device and fail for users or at Play submission. Both are
|
# fine on a test device and fail for users or at Play submission. Both are
|
||||||
# cheap to check and expensive to discover later.
|
# cheap to check and expensive to discover later.
|
||||||
|
|||||||
@@ -182,6 +182,23 @@ jobs:
|
|||||||
docker run --rm "$SHELLCHECK" --version
|
docker run --rm "$SHELLCHECK" --version
|
||||||
git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK"
|
git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK"
|
||||||
|
|
||||||
|
# actionlint closes the half shellcheck cannot see. The step above reads .sh files;
|
||||||
|
# a good deal of this repo's bash lives in inline `run:` blocks instead -- the release
|
||||||
|
# verification here, the emulator setup and teardown in this file and in
|
||||||
|
# api37-debug.yml. actionlint parses each workflow and runs shellcheck over every
|
||||||
|
# `run:`, on top of its own checks for expression syntax, `needs:` references, matrix
|
||||||
|
# keys and action input names.
|
||||||
|
#
|
||||||
|
# Pinned by digest for the same reason shellcheck is, and with a second reason of its
|
||||||
|
# own: actionlint's documented install is `bash <(curl -s .../download-actionlint.bash)`
|
||||||
|
# off a moving branch, which would sit badly in a repo that pins every action by SHA.
|
||||||
|
- name: actionlint
|
||||||
|
env:
|
||||||
|
ACTIONLINT: rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7
|
||||||
|
run: |
|
||||||
|
docker run --rm "$ACTIONLINT" -version
|
||||||
|
docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT" -color
|
||||||
|
|
||||||
# `!cancelled()` rather than a plain sequence: a shellcheck failure above must not
|
# `!cancelled()` rather than a plain sequence: a shellcheck failure above must not
|
||||||
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
|
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
|
||||||
# round trip should produce every list, not stop at the first.
|
# round trip should produce every list, not stop at the first.
|
||||||
|
|||||||
@@ -185,11 +185,12 @@ install for code that can never run — and on API 37 the full APK does not fit
|
|||||||
`podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... <files>`
|
`podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... <files>`
|
||||||
(the digest is in `status_check.yml`; there is no shellcheck system package on this host).
|
(the digest is in `status_check.yml`; there is no shellcheck system package on this host).
|
||||||
|
|
||||||
**It does not cover inline `run:` blocks in the workflows**, and a good deal of this repo's bash
|
**`actionlint` covers the half shellcheck cannot see** — the inline `run:` blocks, where a good
|
||||||
lives there. `actionlint` does cover them — it runs shellcheck over each `run:` — and reports one
|
deal of this repo's bash lives. It runs shellcheck over each `run:` plus its own checks on
|
||||||
pre-existing `info` finding in `build.yml`. It is not wired in because every action here is
|
expression syntax, `needs:` references, matrix keys and action inputs. It sits in the same job,
|
||||||
pinned by SHA, and actionlint's usual installer is a `curl | bash` off a moving branch; doing it
|
**pinned by digest** for the reason above and one of its own: its documented installer is a
|
||||||
properly means pinning a container digest. Tracked separately rather than bolted on.
|
`curl | bash` off a moving branch, which does not belong in a repo that pins every action by SHA.
|
||||||
|
Locally: `podman run --rm -v "$PWD:/repo:z" -w /repo docker.io/rhysd/actionlint@sha256:9d360886... -color`.
|
||||||
|
|
||||||
## Dependency versions
|
## Dependency versions
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,14 @@ container × codec matrix — including combinations that cannot work, which it
|
|||||||
offers alternatives for rather than hiding.
|
offers alternatives for rather than hiding.
|
||||||
|
|
||||||
Conversions run as durable background work, so they survive leaving the app and are
|
Conversions run as durable background work, so they survive leaving the app and are
|
||||||
restored after a restart.
|
restored when you reopen it.
|
||||||
|
|
||||||
|
One case is not restored, and it is worth knowing about. If Android refuses to let a job
|
||||||
|
restart in the background, it is retried on an exponential backoff for about eight and a
|
||||||
|
half hours and then given up on — and a job that has been given up on does not come back
|
||||||
|
when you reopen the app. Reopening the app is what grants permission to run, so a
|
||||||
|
conversion that has stalled this way is best started again from the app rather than waited
|
||||||
|
on.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|||||||
@@ -333,9 +333,9 @@ dependencies {
|
|||||||
// the tests stay green.
|
// the tests stay green.
|
||||||
testImplementation(platform(libs.compose.bom))
|
testImplementation(platform(libs.compose.bom))
|
||||||
testImplementation(libs.compose.ui.test.junit4)
|
testImplementation(libs.compose.ui.test.junit4)
|
||||||
// For `runTest` alone, in EscapedCoroutineErrors.kt. It arrives transitively with the
|
// For `runTest` alone, in ConversionViewModelProbeFailureTest. It arrives transitively
|
||||||
// rule above anyway; declared because a test file imports it directly, and an import of
|
// with the rule above anyway; declared because a test file imports it directly, and an
|
||||||
// something nobody asked for breaks the day the library that pulled it in stops.
|
// import of something nobody asked for breaks the day the library that pulled it in stops.
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.compose.bom))
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.libremediaconverter.saf
|
package org.libremediaconverter.saf
|
||||||
|
|
||||||
|
import android.app.UiAutomation
|
||||||
|
import androidx.compose.ui.test.ComposeTimeoutException
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
@@ -10,6 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.uiautomator.By
|
import androidx.test.uiautomator.By
|
||||||
import androidx.test.uiautomator.BySelector
|
import androidx.test.uiautomator.BySelector
|
||||||
|
import androidx.test.uiautomator.Configurator
|
||||||
import androidx.test.uiautomator.StaleObjectException
|
import androidx.test.uiautomator.StaleObjectException
|
||||||
import androidx.test.uiautomator.UiDevice
|
import androidx.test.uiautomator.UiDevice
|
||||||
import androidx.test.uiautomator.Until
|
import androidx.test.uiautomator.Until
|
||||||
@@ -52,12 +55,138 @@ import org.libremediaconverter.ui.TestTags
|
|||||||
* either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a
|
* either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a
|
||||||
* `Bundle`.
|
* `Bundle`.
|
||||||
*
|
*
|
||||||
|
* ### #93: what actually failed was reading the screen, not the picker
|
||||||
|
*
|
||||||
|
* Ninety minutes after this class landed it started failing on gating legs at API 33, 34, 35 and
|
||||||
|
* 37 — on diffs that were two KDoc comments, a MIME lookup table and a README paragraph (#93).
|
||||||
|
* Every failure named the fixture root, so it read as a root-discovery race, and the ticket was
|
||||||
|
* filed on that reading. It was not one, and it was not the `StaleObjectException` #80 had fixed
|
||||||
|
* an hour earlier either.
|
||||||
|
*
|
||||||
|
* **DocumentsUI was fine.** On the API 34 leg of run 32806342548 its own
|
||||||
|
* `ProvidersAccess: Matched roots` names
|
||||||
|
* `content://org.libremediaconverter.test.fixtures/root/lmc-r38-root` five times inside the sixty
|
||||||
|
* seconds the test spent failing, `ActivityTaskManager` logged the `PickActivity` as `Displayed`,
|
||||||
|
* and the provider process started on cue.
|
||||||
|
*
|
||||||
|
* **This process could not read any window at all.** Two counts settle it. Across that whole leg
|
||||||
|
* UiAutomator logged `Retrieving node with selector` 1095 times and `Node not found with selector`
|
||||||
|
* 1095 times — not one selector ever matched, from the first query of the run. The green leg of
|
||||||
|
* the same job asked 7 times and found 5. `UiDevice.getWindowRoots` builds its search set from
|
||||||
|
* `UiAutomation.getWindows()` and, on API 21 and up, from nothing else; an empty list there makes
|
||||||
|
* every selector unfindable and says nothing whatever about SAF. The corroborating detail is that
|
||||||
|
* `By.desc("Show roots")` — the toolbar button, present on that screen whether the roots list is
|
||||||
|
* stale or not — was also not found, 28 s after the picker was displayed.
|
||||||
|
*
|
||||||
|
* **A fresh picker is not the repair, and this was measured rather than assumed.** The same leg
|
||||||
|
* opened a *second* `PickActivity` for the second test, in the same DocumentsUI process
|
||||||
|
* (pid 3299), and read exactly as little from it. So whatever was broken outlived one window.
|
||||||
|
* [requireAReadableScreen] is the part aimed at that: it asks whether this process can see the
|
||||||
|
* app's own window *before* the picker is opened, and [rebuildUiAutomation] tears the connection
|
||||||
|
* down and builds another if it cannot.
|
||||||
|
*
|
||||||
|
* **The check has since caught the real thing, in CI, and the connection rebuild did not repair
|
||||||
|
* it.** Run 32811493607, API 35 and API 37 legs, both tests, 12 s each instead of 60:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* java.lang.AssertionError: UiAutomator cannot see this app's own window, so it could not have
|
||||||
|
* seen the picker's either. This is not a SAF failure.
|
||||||
|
* at SafPickerRoundTripTest.requireAReadableScreen
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* That is the diagnosis this class could not previously give, and it moves the question off SAF
|
||||||
|
* for good.
|
||||||
|
*
|
||||||
|
* ### What the window list said, and why nothing here can fix it
|
||||||
|
*
|
||||||
|
* [describeWindows] was added to that failure so the next occurrence would close the question
|
||||||
|
* rather than reopen it. It did — on the API 34 leg of run 32812248131 and again, character for
|
||||||
|
* character, on the API 33 leg of run 32812892103:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* ... Waking the device, dismissing the keyguard and rebuilding the UiAutomation connection all
|
||||||
|
* failed to make it readable. What it could see: com.android.systemui[type=3], android[type=3]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `type=3` is `AccessibilityWindowInfo.TYPE_SYSTEM`. The list is **not** empty — it holds the
|
||||||
|
* system windows and **not one `TYPE_APPLICATION` window**, on a device where the framework had
|
||||||
|
* already logged `Displayed org.libremediaconverter/.MainActivity`. So the application layer
|
||||||
|
* never reaches accessibility on those boots, and every selector in this class, the picker's and
|
||||||
|
* the app's alike, is unfindable for the whole instrumentation run.
|
||||||
|
*
|
||||||
|
* Three CI runs on this branch caught the fault, at API 33, 34, 35 and 37, and every one of them
|
||||||
|
* printed that same list. It is not one level's quirk.
|
||||||
|
*
|
||||||
|
* ### And that list is what identified the occluder
|
||||||
|
*
|
||||||
|
* `android[type=3]` is `system_server`, and what it was holding is in the same logcat, minutes
|
||||||
|
* before this class ever ran:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||||
|
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||||
|
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **The launcher ANRs on a loaded runner emulator, and the dialog it leaves behind never goes
|
||||||
|
* away.** It is opaque and fullscreen, so `AccessibilityWindowManager` drops every application
|
||||||
|
* window beneath it — which is how the app can be `Displayed` and unreadable at once, the
|
||||||
|
* contradiction that made #93 look like a SAF bug for six PRs. It is present on both legs
|
||||||
|
* examined, at API 33 and 34, at the failure timestamp.
|
||||||
|
*
|
||||||
|
* So [dismissASystemErrorDialog] is tried first, and it is the remedy with a mechanism behind it.
|
||||||
|
* The other two are kept behind it and are **measured as not the cause**: [unlockTheDevice] (the
|
||||||
|
* keyguard theory, from `KeyguardViewMediator` reporting an unprovisioned device — dismissing it
|
||||||
|
* changed nothing) and [rebuildUiAutomation]. A second `PickActivity` is not a remedy for this
|
||||||
|
* either, and that was measured too: the first failing leg opened one and read as little from it.
|
||||||
|
*
|
||||||
|
* **What is honest about the dialog remedy: it has been shown to do no harm, not to work.** It
|
||||||
|
* was forced on with no dialog present and the suite stayed green, which is the way a blind
|
||||||
|
* `click()` could have broken a healthy run. Dismissing a real ANR dialog has not been observed,
|
||||||
|
* because the fault has never been reproduced locally — not on six warm runs, not on cold
|
||||||
|
* full-suite runs at API 34 and 35 on freshly created AVDs under `swangle_indirect` at two cores,
|
||||||
|
* not under host load. If it recurs, the message now names the dialog and the window list, so the
|
||||||
|
* next step is a measurement rather than another theory.
|
||||||
|
*
|
||||||
|
* ### The whole pick is retried, which is a separate and smaller claim
|
||||||
|
*
|
||||||
|
* [pickTheFixture] also backs out and asks for another picker when the walk comes up short. That
|
||||||
|
* is not the answer to the paragraph above; it is the answer to a picker whose *lists* were built
|
||||||
|
* before their data arrived, which is a real thing DocumentsUI does and which
|
||||||
|
* [tapPickerNode]'s re-find cannot reach either — it re-acquires a handle inside the one picker.
|
||||||
|
*
|
||||||
|
* One API 37 run failed a step deeper than the rest: the root appeared and
|
||||||
|
* `[TEXT='\Qlmc-r38-fixture.mp4\E']` did not. **That shape has not been reproduced or
|
||||||
|
* diagnosed.** It is covered here only because a fresh pick re-walks from Recent, and that is
|
||||||
|
* worth writing down rather than letting the retry read as a fix for something nobody measured.
|
||||||
|
*
|
||||||
* ### The mutations, and what they printed
|
* ### The mutations, and what they printed
|
||||||
*
|
*
|
||||||
* Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to
|
* 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
|
* `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the
|
||||||
* fixture root entirely, and [pickingAFileThroughTheSystemPickerFillsInTheFileCard] fails on the
|
* fixture root entirely, and both tests fail on the assertion that names it. **Re-run after the
|
||||||
* assertion that names it.
|
* #93 retry landed**, because a retry that tolerated an absent root would have made this mutation
|
||||||
|
* vacuous, which is the one thing that must not happen here:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'],
|
||||||
|
* in 3 separate pickers (the last one left org.libremediaconverter in front)
|
||||||
|
* at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The root is absent from all three pickers, so all three report it, and the cost of saying so is
|
||||||
|
* bounded: 126 s and 127 s for the two tests, against the 1200 s wrapper timeout in
|
||||||
|
* `.github/scripts/e2e-run.sh`. The clause about what was left in front is not decoration either
|
||||||
|
* — it is what says the retry really did get back to the app between attempts rather than tapping
|
||||||
|
* behind a picker that never closed.
|
||||||
|
*
|
||||||
|
* **That mutation only shows the retry failing correctly.** Showing it *recovering* needs a
|
||||||
|
* failure that goes away, so one was injected: a field making the first
|
||||||
|
* [walkThePickerToTheFixture] of each test return a selector nothing matches. Both tests then
|
||||||
|
* passed, with `ActivityTaskManager` logging four `OPEN_DOCUMENT` starts for the two of them —
|
||||||
|
* two pickers each. That is the run which says the reopened pick completes: that
|
||||||
|
* `pickInput.launch` is not refused from the re-resumed Activity, and that the second test's
|
||||||
|
* reopen, which lands in the last-accessed stack rather than on Recent, still walks to the file.
|
||||||
* Making the ViewModel composition-scoped leaves the picker test alone and fails
|
* Making the ViewModel composition-scoped leaves the picker test alone and fails
|
||||||
* [thePickedInputSurvivesARealRotation], with `:app:testDebugUnitTest` still BUILD SUCCESSFUL —
|
* [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
|
* which is the divergence this ticket was filed to establish, and which was doubted on it. It is
|
||||||
@@ -115,6 +244,10 @@ class SafPickerRoundTripTest {
|
|||||||
private val device: UiDevice =
|
private val device: UiDevice =
|
||||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||||
|
|
||||||
|
/** The app under test, whose own window is what [requireAReadableScreen] asks for. */
|
||||||
|
private val appPackage: String =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.packageName
|
||||||
|
|
||||||
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
|
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
|
||||||
private var rotated = false
|
private var rotated = false
|
||||||
|
|
||||||
@@ -208,25 +341,276 @@ class SafPickerRoundTripTest {
|
|||||||
* Everything between the first tap and the last belongs to `com.google.android.documentsui`,
|
* 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
|
* 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.
|
* composition and Espresso's at its view hierarchy, and the picker is neither.
|
||||||
|
*
|
||||||
|
* **What is retried here is the whole pick.** [tapPickerNode]'s re-find re-acquires a handle
|
||||||
|
* to a node inside the picker that is already open, so it cannot reach a list that was built
|
||||||
|
* before its data arrived. Backing out and tapping "Choose file" again gets a *second*
|
||||||
|
* `PickActivity`, which rebuilds every list in it — and is what a user does when a picker
|
||||||
|
* comes up wrong. It is **not** the answer to the unreadable-screen failure in the class
|
||||||
|
* KDoc; [requireAReadableScreen], one line above, is the part aimed at that.
|
||||||
|
*
|
||||||
|
* The first attempt keeps the full [PICKER_TIMEOUT_MS]; the later ones use
|
||||||
|
* [REOPENED_TIMEOUT_MS], because by then the picker's process, its provider and its root cache
|
||||||
|
* are all warm and the only thing being waited on is one screen. That is what keeps the cost
|
||||||
|
* of a genuinely absent root bounded — see the class KDoc.
|
||||||
*/
|
*/
|
||||||
private fun pickTheFixture() {
|
private fun pickTheFixture() {
|
||||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
var missing: BySelector? = null
|
||||||
|
repeat(PICK_ATTEMPTS) { attempt ->
|
||||||
// THIS is the line the MIME filter mutation fails on. DocumentsUI matches the requested
|
requireAReadableScreen()
|
||||||
// types against Root.COLUMN_MIME_TYPES and drops the roots that cannot answer, so a filter
|
openThePicker()
|
||||||
// the fixture root does not satisfy takes the root out of the picker altogether -- along
|
missing = walkThePickerToTheFixture(
|
||||||
// with "Images", "Audio", "Videos" and "Documents", measured on API 34.
|
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
|
||||||
tapPickerNode(By.text(FixtureDocumentsProvider.ROOT_TITLE)) {
|
)
|
||||||
// Which screen the picker opens on is its own business: it lands on Recent, where the
|
if (missing == null) {
|
||||||
// roots are a strip at the bottom, but a device with a populated Recent may need the
|
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||||
// drawer. Looking in the second place widens where the root is searched for; it does
|
return
|
||||||
// not weaken what has to be found, which is still this root.
|
}
|
||||||
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
dismissThePicker()
|
||||||
}
|
}
|
||||||
|
throw AssertionError(
|
||||||
|
"the system picker never showed $missing, in $PICK_ATTEMPTS separate pickers " +
|
||||||
|
"(the last one left ${device.currentPackageName} in front)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
tapPickerNode(By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME))
|
/**
|
||||||
|
* Refuses to go near the picker until this process can read a window it already knows is there.
|
||||||
|
*
|
||||||
|
* **This is the check that would have answered #93 outright**, instead of leaving six PRs to
|
||||||
|
* infer a SAF fault from a picker that was never the problem. It is here because of what the
|
||||||
|
* failing logcat counts. Across the whole API 34 leg UiAutomator
|
||||||
|
* asked for a node 1095 times and logged `Node not found` 1095 times — it never read anything,
|
||||||
|
* from the first query of the run onwards. The green leg of the same job asked 7 times and
|
||||||
|
* found 5. So the window list `UiDevice` searches, `UiAutomation.getWindows()`, was empty for
|
||||||
|
* that entire instrumentation run; on API 21 and up that list is the *only* place
|
||||||
|
* `getWindowRoots` looks, so an empty one makes every selector unfindable and says nothing
|
||||||
|
* about the app, the picker or the fixture.
|
||||||
|
*
|
||||||
|
* The probe is deliberately the app's **own** window, asked while the app is in front and
|
||||||
|
* before anything is tapped. It is the one window that must be readable for any of the rest to
|
||||||
|
* mean anything, so a failure here is unambiguous — where "the picker never showed the root"
|
||||||
|
* was not, and is what sent #93 looking at package installation and root caches.
|
||||||
|
*
|
||||||
|
* The repair is [rebuildUiAutomation]. It has been forced on and measured — a rebuilt
|
||||||
|
* connection still reads windows, which is the way it could have been worse than nothing —
|
||||||
|
* but it has **never been run against the real fault**, because the fault has never been
|
||||||
|
* reproduced on demand. See the class KDoc. What is certain is that a fresh picker is *not*
|
||||||
|
* the repair: the failing leg opened a second `PickActivity` for the second test, in the
|
||||||
|
* same DocumentsUI process, and read exactly as little from it.
|
||||||
|
*/
|
||||||
|
private fun requireAReadableScreen() {
|
||||||
|
val app = By.pkg(appPackage)
|
||||||
|
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||||
|
dismissASystemErrorDialog()
|
||||||
|
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||||
|
unlockTheDevice()
|
||||||
|
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||||
|
rebuildUiAutomation()
|
||||||
|
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) != true) {
|
||||||
|
throw AssertionError(
|
||||||
|
"UiAutomator cannot see this app's own window, so it could not have seen the " +
|
||||||
|
"picker's either. This is not a SAF failure. Closing a system error dialog, " +
|
||||||
|
"waking the device, dismissing the keyguard and rebuilding the UiAutomation " +
|
||||||
|
"connection all failed to make it readable. What it could see: " +
|
||||||
|
describeWindows(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
/**
|
||||||
|
* Closes a system "isn't responding" dialog, if that is what is on top of the app.
|
||||||
|
*
|
||||||
|
* **This is the occluder #93 turned out to have**, and it took the window list in the failure
|
||||||
|
* message to find it. `AppNotRespondingDialog` belongs to `system_server`, so it is the
|
||||||
|
* `android[type=3]` in `com.android.systemui[type=3], android[type=3]` — and it is opaque and
|
||||||
|
* fullscreen, so `AccessibilityWindowManager` drops every application window beneath it. The
|
||||||
|
* app is `Displayed` and unreadable at the same time, which is exactly the contradiction this
|
||||||
|
* class spent #93 failing to explain. It is not even this app's dialog:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||||
|
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||||
|
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The launcher ANRs on a loaded runner emulator minutes before this class runs, and the dialog
|
||||||
|
* it leaves behind never goes away on its own.
|
||||||
|
*
|
||||||
|
* Dismissed by resource id rather than by button text, because the text is localised and the
|
||||||
|
* ids are not, and by id rather than by "the first button in the system window", because that
|
||||||
|
* would click whatever system window happened to be there. `aerr_wait` first: it dismisses the
|
||||||
|
* dialog and leaves the offending app alone, which is the polite answer when the app is not
|
||||||
|
* ours. Back is not tried — `BaseErrorDialog` swallows key events.
|
||||||
|
*/
|
||||||
|
private fun dismissASystemErrorDialog() {
|
||||||
|
for (id in ERROR_DIALOG_BUTTONS) {
|
||||||
|
val button = device.findObject(By.res(id)) ?: continue
|
||||||
|
button.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wakes the display and asks the keyguard to go away.
|
||||||
|
*
|
||||||
|
* The cheapest explanation for "this process cannot see the app's own window" is that
|
||||||
|
* something is in front of it, and on a runner emulator that something is the lock screen:
|
||||||
|
* these images come up unprovisioned, and `KeyguardViewMediator` says so in as many words --
|
||||||
|
* `we need to show the keyguard since the device isn't provisioned yet`. An occluded window is
|
||||||
|
* not in the accessibility window list, which is the same symptom as a broken connection and
|
||||||
|
* has a far more ordinary cause.
|
||||||
|
*
|
||||||
|
* `wm dismiss-keyguard` rather than a swipe, because it is a request to the window manager
|
||||||
|
* rather than a gesture that has to land somewhere this process cannot see. It is only
|
||||||
|
* attempted on the failure path -- a device that was readable never reaches here -- so a run
|
||||||
|
* where the keyguard was never up pays nothing and is not altered.
|
||||||
|
*/
|
||||||
|
private fun unlockTheDevice() {
|
||||||
|
device.wakeUp()
|
||||||
|
device.executeShellCommand("wm dismiss-keyguard")
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accessibility window list, for a failure message that says what was actually there. */
|
||||||
|
private fun describeWindows(): String {
|
||||||
|
val windows = InstrumentationRegistry.getInstrumentation().uiAutomation.windows
|
||||||
|
if (windows.isEmpty()) return "no windows at all (UiAutomation.getWindows() is empty)"
|
||||||
|
return windows.joinToString(", ") { "${it.root?.packageName ?: "?"}[type=${it.type}]" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down this run's `UiAutomation` connection and establishes a new one.
|
||||||
|
*
|
||||||
|
* `Instrumentation.getUiAutomation` hands back the existing connection unless the flags differ
|
||||||
|
* from the ones it was created with, in which case it destroys it and builds another — so
|
||||||
|
* asking for different flags and then for the original ones back is how a test reaches the
|
||||||
|
* connection at all. `UiDevice` re-reads the flags from `Configurator` on every call rather
|
||||||
|
* than caching an instance, so the next selector goes through the new connection.
|
||||||
|
*
|
||||||
|
* `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES` is toggled rather than chosen: it is only being
|
||||||
|
* used as a value that differs from whatever is configured, and it is put back.
|
||||||
|
*
|
||||||
|
* **Forced on and measured, because the obvious way for this to be worse than nothing is
|
||||||
|
* silent.** `UiDevice` puts `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` on the service info during its
|
||||||
|
* own initialisation, and `getWindows()` is empty without it — so a rebuilt connection that
|
||||||
|
* did not get the flag back would cause exactly the emptiness this is meant to cure, on the
|
||||||
|
* one path where it is the last hope. Run unconditionally on every attempt, on a cold API 34
|
||||||
|
* emulator, both tests passed, and logcat shows the connection really being replaced rather
|
||||||
|
* than handed back: `Init UiAutomation[id=2, flags=0]`, then `id=4, flags=1`, then
|
||||||
|
* `id=6, flags=0`, with `Registering UiTestAutomationService` between each.
|
||||||
|
*/
|
||||||
|
private fun rebuildUiAutomation() {
|
||||||
|
val configurator = Configurator.getInstance()
|
||||||
|
val flags = configurator.uiAutomationFlags
|
||||||
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
configurator.uiAutomationFlags = flags xor UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
|
||||||
|
instrumentation.getUiAutomation(configurator.uiAutomationFlags)
|
||||||
|
configurator.uiAutomationFlags = flags
|
||||||
|
instrumentation.getUiAutomation(flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for the app to be showing its own screen again, then asks for a picker. */
|
||||||
|
private fun openThePicker() {
|
||||||
|
awaitNode(TestTags.Converter.CHOOSE_FILE)
|
||||||
|
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null once the fixture URI is with the app, or the selector whose list never carried it.
|
||||||
|
*
|
||||||
|
* Three things have to be there, in order, and the `when` names them in that order so that a
|
||||||
|
* failure says which one was missing rather than "the picker did not work".
|
||||||
|
*
|
||||||
|
* **The first branch is what tells an unreadable picker from an absent root.** In #93 neither
|
||||||
|
* the root *nor the toolbar's "Show roots" button* could be found for sixty seconds, and a
|
||||||
|
* stale roots list would have left the toolbar findable. Both arrived as one message. Asking
|
||||||
|
* for the picker's package on its own separates them: `never showed BySelector [PKG=...]`
|
||||||
|
* means the picker was not readable, and the root selector means the root was not offered.
|
||||||
|
*
|
||||||
|
* The second 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.
|
||||||
|
*
|
||||||
|
* **The third takes no recovery action of its own, and that is deliberate rather than an
|
||||||
|
* oversight.** [openTheRootsDrawer] exists because a root has a *second* place it can be
|
||||||
|
* shown; a document in a directory listing has no second place, so there is nothing an
|
||||||
|
* in-picker action could do. Its recovery is the outer loop: a fresh picker re-walks from
|
||||||
|
* Recent into the root, which rebuilds the directory listing as well as the roots strip.
|
||||||
|
*/
|
||||||
|
private fun walkThePickerToTheFixture(timeoutMs: Long): BySelector? {
|
||||||
|
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
|
||||||
|
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
|
||||||
|
val fixture = By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||||
|
return when {
|
||||||
|
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
|
||||||
|
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
|
||||||
|
!tapPickerNode(fixture, timeoutMs) -> fixture
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker's own drawer, opened only when the root was not on the screen it landed on.
|
||||||
|
*
|
||||||
|
* **In practice it never runs, and #80 was right to say so.** A hierarchy dump taken on a
|
||||||
|
* cold API 34 emulator while this test was passing has the fixture root on the landing
|
||||||
|
* screen — `text="LMC R38 fixtures"` at `android:id/title`, under a `BROWSE FILES IN OTHER
|
||||||
|
* APPS` header — with the drawer shut (`Show roots` present, `Hide roots` absent). So the
|
||||||
|
* roots strip is the normal path and the drawer is a widening, kept because a device with a
|
||||||
|
* populated Recent may push the strip off screen. Looking in a second place widens where the
|
||||||
|
* root is searched for; it does not weaken what has to be found, which is still this root.
|
||||||
|
*/
|
||||||
|
private fun openTheRootsDrawer() {
|
||||||
|
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs out of the picker until the app has the window focus again.
|
||||||
|
*
|
||||||
|
* **The focus is asked of the Activity, not of UiAutomator, and that is not a stylistic
|
||||||
|
* choice.** The failure this retry exists for is a picker window UiAutomator cannot see, so a
|
||||||
|
* probe that went through the same accessibility window list would cheerfully report "the
|
||||||
|
* picker is gone" about the window that is still in front — and the reopened pick would then
|
||||||
|
* tap "Choose file" behind it. `Activity.hasWindowFocus` comes from the framework instead, and
|
||||||
|
* answers about the app rather than about the picker.
|
||||||
|
*
|
||||||
|
* It is also why this counts backs rather than pressing a fixed number of them. One back is
|
||||||
|
* enough from Recent and two are needed from inside the root, but a third from Recent would
|
||||||
|
* finish `MainActivity` and take the rest of the test with it.
|
||||||
|
*/
|
||||||
|
private fun dismissThePicker() {
|
||||||
|
repeat(BACK_PRESSES) {
|
||||||
|
if (awaitAppFocus()) return
|
||||||
|
// Before the back press, not instead of it: an app-error dialog swallows key events,
|
||||||
|
// so a back aimed at the picker lands on the dialog and nothing moves. Measured --
|
||||||
|
// API 34 of run 32813885120 exhausted all four presses with `android` in front, which
|
||||||
|
// is that dialog, while the launcher it belonged to went on ANRing behind everything.
|
||||||
|
dismissASystemErrorDialog()
|
||||||
|
device.pressBack()
|
||||||
|
}
|
||||||
|
// The check after the last press, and not a spare one: `repeat` presses on its final
|
||||||
|
// iteration too, so without this a dismissal that worked on the last press would still be
|
||||||
|
// reported as a failure to close.
|
||||||
|
if (!awaitAppFocus()) {
|
||||||
|
throw AssertionError(
|
||||||
|
"the system picker would not close: after $BACK_PRESSES back presses the app " +
|
||||||
|
"still does not have the window focus, and ${device.currentPackageName} is " +
|
||||||
|
"in front. What could be seen: " + describeWindows(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once [MainActivity] has the window focus, false if it does not take it in time. */
|
||||||
|
private fun awaitAppFocus(): Boolean = try {
|
||||||
|
composeRule.waitUntil("the app has the window focus back", FOCUS_TIMEOUT_MS) {
|
||||||
|
composeRule.activity.hasWindowFocus()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: ComposeTimeoutException) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,21 +628,24 @@ class SafPickerRoundTripTest {
|
|||||||
* at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
|
* at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* So what is retried is *acquiring a handle to a node that has to be there anyway* — every
|
* So what is retried is *acquiring a handle to a node that has to be there anyway*. **A node
|
||||||
* attempt still goes through [awaitPickerNode], which fails outright if the node is absent.
|
* that is simply not in this picker is reported rather than retried here** — it comes back as
|
||||||
* The MIME mutation's bite is untouched: a root that is not in the picker is not found on any
|
* `false`, and [pickTheFixture] answers it with a whole new picker, which is the only thing
|
||||||
* attempt, and the failure is still "the system picker never showed" rather than a stale one.
|
* that rebuilds a list or a window. The MIME mutation's bite is untouched either way: a root
|
||||||
|
* that is not in the picker is not found on any attempt or in any picker, and the failure is
|
||||||
|
* still "the system picker never showed" rather than a stale one.
|
||||||
*/
|
*/
|
||||||
private fun tapPickerNode(selector: BySelector, ifAbsent: () -> Unit = {}) {
|
private fun tapPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit = {}): Boolean {
|
||||||
var stale: StaleObjectException? = null
|
var stale: StaleObjectException? = null
|
||||||
repeat(TAP_ATTEMPTS) { attempt ->
|
repeat(TAP_ATTEMPTS) { attempt ->
|
||||||
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
|
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
|
||||||
// already reached would walk away from the node.
|
// already reached would walk away from the node.
|
||||||
val node = awaitPickerNode(selector, if (attempt == 0) ifAbsent else ({}))
|
val node = awaitPickerNode(selector, timeoutMs, if (attempt == 0) ifAbsent else ({}))
|
||||||
|
?: return false
|
||||||
device.waitForIdle()
|
device.waitForIdle()
|
||||||
try {
|
try {
|
||||||
node.click()
|
node.click()
|
||||||
return
|
return true
|
||||||
} catch (e: StaleObjectException) {
|
} catch (e: StaleObjectException) {
|
||||||
stale = e
|
stale = e
|
||||||
}
|
}
|
||||||
@@ -267,19 +654,17 @@ class SafPickerRoundTripTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The picker node [selector] names, or a failure that says which one was missing.
|
* The picker node [selector] names, or null if this picker never showed it.
|
||||||
*
|
*
|
||||||
* [ifAbsent] runs once, after the first wait comes up empty, and then the wait is repeated. A
|
* [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
|
* null return from `findObject` is deliberately not an error there: it is the "already on the
|
||||||
* right screen" case.
|
* right screen" case.
|
||||||
*/
|
*/
|
||||||
private fun awaitPickerNode(selector: BySelector, ifAbsent: () -> Unit = {}) =
|
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
|
||||||
device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)
|
device.wait(Until.findObject(selector), timeoutMs)
|
||||||
?: run {
|
?: run {
|
||||||
ifAbsent()
|
ifAbsent()
|
||||||
requireNotNull(device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)) {
|
device.wait(Until.findObject(selector), timeoutMs)
|
||||||
"the system picker never showed $selector"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -306,9 +691,69 @@ class SafPickerRoundTripTest {
|
|||||||
const val PICKER_TIMEOUT_MS = 30_000L
|
const val PICKER_TIMEOUT_MS = 30_000L
|
||||||
const val APP_TIMEOUT_MS = 30_000L
|
const val APP_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same wait once a picker has already come and gone, and shorter for a reason.
|
||||||
|
*
|
||||||
|
* What [PICKER_TIMEOUT_MS] is generous about is a cold start: DocumentsUI's process, the
|
||||||
|
* fixture's provider process, the root cache. By the second attempt all three are warm and
|
||||||
|
* the only thing left to wait on is one screen being laid out — measured at 2.7 to 3.4 s
|
||||||
|
* from the picker starting, on cold CI emulators at API 33, 34 and 35. Ten seconds is
|
||||||
|
* three times the worst of those, and it is what keeps a genuinely absent root — the MIME
|
||||||
|
* mutation — from costing three full-length attempts.
|
||||||
|
*/
|
||||||
|
const val REOPENED_TIMEOUT_MS = 10_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the app is given to take the window focus back after a back press.
|
||||||
|
*
|
||||||
|
* Short, because this is asked once per back press and the first one is always asked while
|
||||||
|
* the picker is still in front, where it is *expected* to time out.
|
||||||
|
*/
|
||||||
|
const val FOCUS_TIMEOUT_MS = 3_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long this process is given to be able to read the screen at all.
|
||||||
|
*
|
||||||
|
* Short, and it is not waiting on anything being drawn: the app is already in front
|
||||||
|
* when this is asked. It is waiting only on the accessibility window list existing,
|
||||||
|
* which either does within a poll or two or -- as in #93 -- not at all.
|
||||||
|
*/
|
||||||
|
const val READABLE_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
/** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */
|
/** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */
|
||||||
const val NATURAL_ROTATION = 0
|
const val NATURAL_ROTATION = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many pickers the fixture may fail to appear in before that is the finding.
|
||||||
|
*
|
||||||
|
* Three. Each one is a fresh `PickActivity` -- a fresh window, a fresh accessibility
|
||||||
|
* registration, a fresh roots query and a fresh directory load -- so this bounds the thing
|
||||||
|
* #93 measured, which is a picker that came up unreadable *once*. A root that is genuinely
|
||||||
|
* not offered is absent from all three, which is what keeps #64's MIME mutation red.
|
||||||
|
*/
|
||||||
|
const val PICK_ATTEMPTS = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many back presses may be spent getting out of a picker.
|
||||||
|
*
|
||||||
|
* One is enough from Recent, two from inside the fixture's own directory. Four leaves room
|
||||||
|
* for a picker that has been navigated deeper than this test ever navigates it, and stops
|
||||||
|
* well short of the count that would start finishing `MainActivity` instead.
|
||||||
|
*/
|
||||||
|
const val BACK_PRESSES = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The package the system picker runs in.
|
||||||
|
*
|
||||||
|
* Named rather than resolved: `PackageManager.resolveActivity` is deprecated from API 33
|
||||||
|
* and its replacement is a lint argument this test does not need to have. A wrong value
|
||||||
|
* here cannot pass silently -- it is the first thing [walkThePickerToTheFixture] looks
|
||||||
|
* for, so the failure would read `never showed BySelector [PKG='...']` on every device.
|
||||||
|
* It is `com.google.android.documentsui` on every `google_apis` emulator image the CI
|
||||||
|
* matrix uses and on the Pixel 10 Pro XL.
|
||||||
|
*/
|
||||||
|
const val DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many times a picker node may be re-found before its staleness is the finding.
|
* How many times a picker node may be re-found before its staleness is the finding.
|
||||||
*
|
*
|
||||||
@@ -319,6 +764,19 @@ class SafPickerRoundTripTest {
|
|||||||
*/
|
*/
|
||||||
const val TAP_ATTEMPTS = 3
|
const val TAP_ATTEMPTS = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buttons on the framework's app-error dialogs, by resource id.
|
||||||
|
*
|
||||||
|
* `aerr_wait` is first because it dismisses the dialog without killing the app under it,
|
||||||
|
* and the app under it is usually the launcher rather than anything this suite owns.
|
||||||
|
* `button1` catches the plainer `BaseErrorDialog` shapes that have no `aerr_` ids.
|
||||||
|
*/
|
||||||
|
val ERROR_DIALOG_BUTTONS = listOf(
|
||||||
|
"android:id/aerr_wait",
|
||||||
|
"android:id/aerr_close",
|
||||||
|
"android:id/button1",
|
||||||
|
)
|
||||||
|
|
||||||
/** DocumentsUI's drawer button. It carries no text, only this description. */
|
/** DocumentsUI's drawer button. It carries no text, only this description. */
|
||||||
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
|
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ class AndroidDeviceCodecs private constructor(
|
|||||||
return AndroidDeviceCodecs(encoders, decoders)
|
return AndroidDeviceCodecs(encoders, decoders)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
|
/**
|
||||||
|
* `internal` rather than `private` so the cross-check test can ask what a [VideoCodec]
|
||||||
|
* means here and compare it with what [NAME_TO_MIME] says the same codec's names mean.
|
||||||
|
* The JVM test source set is a friend of `main`, so this stays invisible outside the
|
||||||
|
* module — the precedent is `MainActivity`'s `Destination`.
|
||||||
|
*/
|
||||||
|
internal fun mimeFor(codec: VideoCodec): String? = when (codec) {
|
||||||
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
|
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||||
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
|
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
|
||||||
@@ -87,20 +93,62 @@ class AndroidDeviceCodecs private constructor(
|
|||||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
|
/**
|
||||||
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
|
* FFprobe-style codec names, and the MediaFormat MIME type each one asks about.
|
||||||
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
|
*
|
||||||
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
* This is the same vocabulary `CodecNames.VIDEO_ALIASES` holds, written out a second time
|
||||||
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
|
* because this side has to answer in platform MIME types and `model` does not depend on
|
||||||
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
|
* Android. Two copies of one vocabulary drift, and these had: `x264`, `hev1`, `x265` and
|
||||||
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
|
* `vp09` resolved for display and routing and fell through to null here, so the app ran
|
||||||
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
|
* the capability check blind on inputs it had already identified (#87). They are listed
|
||||||
// Unknown to us: assume the platform can handle it and let a failed export
|
* now, which **changes behaviour** for those four names — see [mimeForCodecName].
|
||||||
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
|
*
|
||||||
else -> null
|
* A map rather than a `when` because a `when` cannot be enumerated, and `CodecVocabularyTest`
|
||||||
}
|
* has to walk both key sets to notice the next divergence.
|
||||||
|
*/
|
||||||
|
internal val NAME_TO_MIME: Map<String, String> = mapOf(
|
||||||
|
"h264" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||||
|
"avc" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||||
|
"avc1" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||||
|
"x264" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||||
|
"hevc" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
"h265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
"hvc1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
"hev1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
"x265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
"vp8" to MediaFormat.MIMETYPE_VIDEO_VP8,
|
||||||
|
"vp9" to MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||||
|
"vp09" to MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||||
|
"av1" to MediaFormat.MIMETYPE_VIDEO_AV1,
|
||||||
|
"av01" to MediaFormat.MIMETYPE_VIDEO_AV1,
|
||||||
|
"mpeg4" to MediaFormat.MIMETYPE_VIDEO_MPEG4,
|
||||||
|
)
|
||||||
|
|
||||||
/** Test seam: lets instrumented tests build a probe from explicit sets. */
|
/**
|
||||||
|
* The names in [NAME_TO_MIME] that no [VideoCodec] member spells, and why.
|
||||||
|
*
|
||||||
|
* MPEG-4 Part 2 is decodable input the app never targets, so there is no enum for it and
|
||||||
|
* `CodecNames` is right not to carry it. That makes it the one place the two tables
|
||||||
|
* legitimately differ. It is listed rather than implied so the cross-check can tell a
|
||||||
|
* documented asymmetry from a fresh drift — and so the list itself is checked: a name here
|
||||||
|
* that `CodecNames` does resolve is a divergence being waved through, and the test fails on
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
internal val DECODE_ONLY_NAMES: Set<String> = setOf("mpeg4")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an FFprobe-style codec name onto a MediaFormat MIME type.
|
||||||
|
*
|
||||||
|
* Null keeps its documented meaning — unknown to us: assume the platform can handle it and
|
||||||
|
* let a failed export trigger the FFmpeg fallback, rather than pre-emptively refusing
|
||||||
|
* hardware. What changed with #87 is which names are unknown. Four that FFmpeg genuinely
|
||||||
|
* emits used to land here and be treated as unknown while the rest of the app knew exactly
|
||||||
|
* what they were; a device without the matching decoder now routes them to FFmpeg up front
|
||||||
|
* instead of spending a doomed hardware attempt to find out.
|
||||||
|
*/
|
||||||
|
internal fun mimeForCodecName(name: String): String? = NAME_TO_MIME[name.lowercase()]
|
||||||
|
|
||||||
|
/** Test seam: lets a test build a probe from explicit sets, on a device or on the JVM. */
|
||||||
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
|
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,29 @@ class ConversionViewModel @JvmOverloads constructor(
|
|||||||
* the first screen.
|
* the first screen.
|
||||||
*/
|
*/
|
||||||
private val cleanupDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val cleanupDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
/**
|
||||||
|
* Where the two blocking hops behind a pick run — the metadata query and the probe.
|
||||||
|
*
|
||||||
|
* A seam for the probe above all, because that is the one call in this class that throws
|
||||||
|
* on purpose. [probeOrUnreadable] rethrows anything that is not a native load failure, and
|
||||||
|
* the `launch` it runs in has no exception handler by design: on a device the error reaches
|
||||||
|
* the thread's default handler and takes the process down, which is what an
|
||||||
|
* [OutOfMemoryError] should do.
|
||||||
|
*
|
||||||
|
* On the JVM there is no such handler. kotlinx-coroutines-test installs a process-wide
|
||||||
|
* collector, once and for the life of the classloader, that keeps an escaped error and
|
||||||
|
* hands it to whichever `runTest` starts next — so it failed a Compose test class that had
|
||||||
|
* nothing to do with it, and *which* class moved between runs of identical code. Naming the
|
||||||
|
* dispatcher is what lets a test keep the throw inside its own window, where it fails the
|
||||||
|
* test that caused it and is consumed rather than collected.
|
||||||
|
*
|
||||||
|
* Both hops rather than the probe alone, which is where this differs from the seam issue #66
|
||||||
|
* proposed: leaving the metadata query on a real [Dispatchers.IO] makes the coroutine resume
|
||||||
|
* on a main looper that Robolectric leaves paused, and that bounce is precisely the
|
||||||
|
* asynchrony that made delivery unpredictable. One dispatcher covers a whole pick, and
|
||||||
|
* leaves nothing about it to timing.
|
||||||
|
*/
|
||||||
|
private val pickDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : AndroidViewModel(app) {
|
) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val workManager = WorkManager.getInstance(app)
|
private val workManager = WorkManager.getInstance(app)
|
||||||
@@ -241,13 +264,13 @@ class ConversionViewModel @JvmOverloads constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Both the metadata query and the probe touch disk, and the probe spawns FFprobe.
|
// Both the metadata query and the probe touch disk, and the probe spawns FFprobe.
|
||||||
// Neither belongs on the main thread.
|
// Neither belongs on the main thread.
|
||||||
val file = withContext(Dispatchers.IO) { InputQuery.describe(getApplication(), uri) }
|
val file = withContext(pickDispatcher) { InputQuery.describe(getApplication(), uri) }
|
||||||
// Show the file as soon as its name and size are known. Probing now runs FFprobe on
|
// Show the file as soon as its name and size are known. Probing now runs FFprobe on
|
||||||
// every pick, which is a native process spawn, and making the whole screen wait on it
|
// every pick, which is a native process spawn, and making the whole screen wait on it
|
||||||
// would read as the app having ignored the tap.
|
// would read as the app having ignored the tap.
|
||||||
_state.value = ConversionState.Ready(file)
|
_state.value = ConversionState.Ready(file)
|
||||||
|
|
||||||
val probe = withContext(Dispatchers.IO) { probeOrUnreadable(uri) }
|
val probe = withContext(pickDispatcher) { probeOrUnreadable(uri) }
|
||||||
// Only fill in the probe if the user has not moved on in the meantime.
|
// Only fill in the probe if the user has not moved on in the meantime.
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
if (current is ConversionState.Ready && current.input.uri == uri) {
|
if (current is ConversionState.Ready && current.input.uri == uri) {
|
||||||
|
|||||||
@@ -138,31 +138,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
|
|
||||||
*
|
|
||||||
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
|
|
||||||
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and lets
|
|
||||||
* Transformer pick, rather than silently substituting H.265 the way the old mapping did.
|
|
||||||
*/
|
|
||||||
private fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
|
|
||||||
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
|
||||||
VideoCodec.H265 -> MimeTypes.VIDEO_H265
|
|
||||||
// Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
|
|
||||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
|
||||||
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
|
|
||||||
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
|
|
||||||
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
|
|
||||||
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
|
|
||||||
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
|
|
||||||
AudioCodec.COPY, AudioCodec.NONE -> null
|
|
||||||
// MP3 and FLAC have no Android encoder; the router routes them to FFmpeg.
|
|
||||||
AudioCodec.MP3, AudioCodec.FLAC -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls export progress on the Transformer's own thread.
|
* Polls export progress on the Transformer's own thread.
|
||||||
*
|
*
|
||||||
@@ -192,7 +167,57 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
|||||||
thread.quitSafely()
|
thread.quitSafely()
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
/**
|
||||||
|
* The progress interval, and the two enum-to-MIME tables.
|
||||||
|
*
|
||||||
|
* The tables are pure functions of a codec enum, so they sit here rather than on the instance:
|
||||||
|
* a JVM test can then exercise every arm without constructing an engine, which would start a
|
||||||
|
* real [HandlerThread] to answer a lookup. `internal` rather than `private` for the reason
|
||||||
|
* `MainActivity`'s `Destination` records — the JVM test source set is a friend of `main`, so
|
||||||
|
* these stay invisible to anything outside the module.
|
||||||
|
*/
|
||||||
|
internal companion object {
|
||||||
const val PROGRESS_INTERVAL_MS = 250L
|
const val PROGRESS_INTERVAL_MS = 250L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
|
||||||
|
*
|
||||||
|
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
|
||||||
|
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and
|
||||||
|
* lets Transformer pick, rather than silently substituting H.265 as the old mapping did.
|
||||||
|
*/
|
||||||
|
internal fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
|
||||||
|
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
||||||
|
VideoCodec.H265 -> MimeTypes.VIDEO_H265
|
||||||
|
// Never reached, and no longer only asserted: `Media3EngineMimeTypesTest` drives
|
||||||
|
// `CopyPlanner` over every spec it can be handed and shows that no Encode plan carries
|
||||||
|
// either, which is what turns "COPY/NONE are not Encode" into a checked claim.
|
||||||
|
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||||
|
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media3 encodes AAC, Opus and PCM. Three arms below are dead, not two.
|
||||||
|
*
|
||||||
|
* The comment this replaces named MP3 and FLAC as the exceptions, which reads as though
|
||||||
|
* every other arm were live. **Vorbis is not.** A single router rule diverts every audio
|
||||||
|
* codec outside {AAC, Opus, PCM} to FFmpeg, and Vorbis is outside it, so
|
||||||
|
* `VORBIS -> AUDIO_VORBIS` names a MIME type Transformer is never actually asked for.
|
||||||
|
*
|
||||||
|
* The arm stays because the mapping is correct — deleting a right answer out of
|
||||||
|
* unreachable code buys nothing — but it is an entry waiting on a routing change rather
|
||||||
|
* than a live one. `Media3EngineMimeTypesTest` routes all six encodable codecs and asserts
|
||||||
|
* which three arrive, so if that set moves, the disagreement fails rather than surprises.
|
||||||
|
*/
|
||||||
|
internal fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
|
||||||
|
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
|
||||||
|
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
|
||||||
|
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
|
||||||
|
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
|
||||||
|
AudioCodec.COPY, AudioCodec.NONE -> null
|
||||||
|
// MP3 and FLAC have no Android encoder at any API level, so the router sends them to
|
||||||
|
// FFmpeg before an encoder is ever asked for.
|
||||||
|
AudioCodec.MP3, AudioCodec.FLAC -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,8 +242,20 @@ object MediaProbe {
|
|||||||
else -> Container.MKV
|
else -> Container.MKV
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FFprobe describes still images through the image demuxers rather than a media container. */
|
/**
|
||||||
private fun isImageFormat(formatName: String): Boolean {
|
* FFprobe describes still images through the image demuxers rather than a media container.
|
||||||
|
*
|
||||||
|
* The two halves of the rule are not interchangeable. `image2` is a whole name — what FFprobe
|
||||||
|
* reports for a numbered image sequence — while `_pipe` has to be a *suffix* test, because the
|
||||||
|
* piped demuxers are named one per image codec: `png_pipe`, `jpeg_pipe`, `webp_pipe`, and
|
||||||
|
* thirty more. Relaxing that suffix to a substring would swallow `yuv4mpegpipe`, which is raw
|
||||||
|
* video, and `classify` checks this before anything else — so a false positive makes the
|
||||||
|
* source-info card describe a video as an image.
|
||||||
|
*
|
||||||
|
* `internal` so the unit tests can name both halves; the JVM test source set is a friend of
|
||||||
|
* `main`, so this stays invisible outside the module.
|
||||||
|
*/
|
||||||
|
internal fun isImageFormat(formatName: String): Boolean {
|
||||||
val names = formatName.split(',').map { it.trim().lowercase() }
|
val names = formatName.split(',').map { it.trim().lowercase() }
|
||||||
return names.any { it == "image2" || it.endsWith("_pipe") }
|
return names.any { it == "image2" || it.endsWith("_pipe") }
|
||||||
}
|
}
|
||||||
@@ -284,11 +296,35 @@ object MediaProbe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
|
/**
|
||||||
|
* One track property as an Int, or [fallback] when the format has no Int to give.
|
||||||
|
*
|
||||||
|
* `containsKey` alone is not enough, because `MediaFormat` is a heterogeneous map: a key it
|
||||||
|
* holds as a Float answers `getInteger` with a `ClassCastException` rather than a coercion, and
|
||||||
|
* `KEY_FRAME_RATE` — which [probeForConcat] reads — is legitimately set either way. The
|
||||||
|
* `runCatching` is therefore load-bearing rather than defensive. Without it a single
|
||||||
|
* oddly-typed field throws past the whole track loop, and the catch there answers with an empty
|
||||||
|
* [ConcatInput], discarding the codec and dimensions that had already been read.
|
||||||
|
*
|
||||||
|
* `internal` for the unit tests, as [shortName].
|
||||||
|
*/
|
||||||
|
internal fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
|
||||||
if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
|
if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
|
||||||
|
|
||||||
/** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */
|
/**
|
||||||
private fun shortName(mime: String): String = when (mime) {
|
* MediaFormat MIME -> the short codec names the router and FFmpeg both speak.
|
||||||
|
*
|
||||||
|
* A lookup table over platform constants is the shape that rots quietly. Most of these arms are
|
||||||
|
* translations rather than trimming — `video/avc` is `h264`, `audio/mp4a-latm` is `aac`,
|
||||||
|
* `video/x-vnd.on2.vp9` is `vp9` — so a dropped arm does not fail. It falls through to
|
||||||
|
* `substringAfter('/')` and reports a different, plausible-looking string that
|
||||||
|
* `CodecNames` may or may not still recognise, and an unrecognised codec is how a
|
||||||
|
* stream-copyable file quietly becomes a re-encode.
|
||||||
|
*
|
||||||
|
* `internal` so the unit tests can name every arm; the JVM test source set is a friend of
|
||||||
|
* `main`, so this stays invisible outside the module.
|
||||||
|
*/
|
||||||
|
internal fun shortName(mime: String): String = when (mime) {
|
||||||
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
||||||
MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
|
MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
|
||||||
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
||||||
|
|||||||
@@ -15,36 +15,97 @@ package org.libremediaconverter.model
|
|||||||
*/
|
*/
|
||||||
object CodecNames {
|
object CodecNames {
|
||||||
|
|
||||||
fun videoFromName(name: String?): VideoCodec? = when (name?.lowercase()) {
|
/**
|
||||||
null, InputProbe.UNPARSEABLE -> null
|
* The video vocabulary, as data rather than a `when`.
|
||||||
"h264", "avc", "avc1", "x264" -> VideoCodec.H264
|
*
|
||||||
"hevc", "h265", "hvc1", "hev1", "x265" -> VideoCodec.H265
|
* This is not the only place the app spells these names. `AndroidDeviceCodecs` reads the same
|
||||||
"vp8" -> VideoCodec.VP8
|
* FFprobe strings to decide what the device can decode, and answers in platform MIME types,
|
||||||
"vp9", "vp09" -> VideoCodec.VP9
|
* which `model` cannot name without depending on Android. The two copies drifted apart:
|
||||||
"av1", "av01" -> VideoCodec.AV1
|
* `x264`, `hev1`, `x265` and `vp09` resolved here and returned null there, so the app
|
||||||
else -> null
|
* identified the codec for display and routing and then ran the device check blind, attempting
|
||||||
}
|
* a hardware path it had enough information to skip (#87).
|
||||||
|
*
|
||||||
|
* The reason this is a map is that **a `when` cannot be enumerated**, so nothing could compare
|
||||||
|
* the two tables. `CodecVocabularyTest` walks both key sets, so a name added to or removed
|
||||||
|
* from one side alone now fails the build rather than waiting for a wasted transcode to show
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* Keys are lowercase; [videoFromName] lowercases before looking one up.
|
||||||
|
*/
|
||||||
|
internal val VIDEO_ALIASES: Map<String, VideoCodec> = mapOf(
|
||||||
|
"h264" to VideoCodec.H264,
|
||||||
|
"avc" to VideoCodec.H264,
|
||||||
|
"avc1" to VideoCodec.H264,
|
||||||
|
"x264" to VideoCodec.H264,
|
||||||
|
"hevc" to VideoCodec.H265,
|
||||||
|
"h265" to VideoCodec.H265,
|
||||||
|
"hvc1" to VideoCodec.H265,
|
||||||
|
"hev1" to VideoCodec.H265,
|
||||||
|
"x265" to VideoCodec.H265,
|
||||||
|
"vp8" to VideoCodec.VP8,
|
||||||
|
"vp9" to VideoCodec.VP9,
|
||||||
|
"vp09" to VideoCodec.VP9,
|
||||||
|
"av1" to VideoCodec.AV1,
|
||||||
|
"av01" to VideoCodec.AV1,
|
||||||
|
)
|
||||||
|
|
||||||
fun audioFromName(name: String?): AudioCodec? = when (name?.lowercase()) {
|
/**
|
||||||
null -> null
|
* The audio vocabulary, data for the same reason.
|
||||||
"aac", "mp4a", "aac_latm" -> AudioCodec.AAC
|
*
|
||||||
"opus" -> AudioCodec.OPUS
|
* Nothing cross-checks this one yet, and that is a gap rather than a decision: the device
|
||||||
"vorbis" -> AudioCodec.VORBIS
|
* capability check is video-only, so this module holds no second audio table to compare it
|
||||||
"mp3", "mp3float", "mpga" -> AudioCodec.MP3
|
* against. `Media3Engine.audioMimeTypeFor` is the other half, and #85 owns that file.
|
||||||
"flac" -> AudioCodec.FLAC
|
*/
|
||||||
"pcm", "raw", "pcm_s16le", "pcm_s24le", "pcm_f32le" -> AudioCodec.PCM
|
internal val AUDIO_ALIASES: Map<String, AudioCodec> = mapOf(
|
||||||
else -> null
|
"aac" to AudioCodec.AAC,
|
||||||
}
|
"mp4a" to AudioCodec.AAC,
|
||||||
|
"aac_latm" to AudioCodec.AAC,
|
||||||
|
"opus" to AudioCodec.OPUS,
|
||||||
|
"vorbis" to AudioCodec.VORBIS,
|
||||||
|
"mp3" to AudioCodec.MP3,
|
||||||
|
"mp3float" to AudioCodec.MP3,
|
||||||
|
"mpga" to AudioCodec.MP3,
|
||||||
|
"flac" to AudioCodec.FLAC,
|
||||||
|
"pcm" to AudioCodec.PCM,
|
||||||
|
"raw" to AudioCodec.PCM,
|
||||||
|
"pcm_s16le" to AudioCodec.PCM,
|
||||||
|
"pcm_s24le" to AudioCodec.PCM,
|
||||||
|
"pcm_f32le" to AudioCodec.PCM,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun videoFromName(name: String?): VideoCodec? = asCodecName(name)?.let(VIDEO_ALIASES::get)
|
||||||
|
|
||||||
|
fun audioFromName(name: String?): AudioCodec? = asCodecName(name)?.let(AUDIO_ALIASES::get)
|
||||||
|
|
||||||
/** Human-readable name for the source-info card. Falls back to the raw probe string. */
|
/** Human-readable name for the source-info card. Falls back to the raw probe string. */
|
||||||
fun describeVideo(name: String?): String = when {
|
fun describeVideo(name: String?): String = describe(name) { videoFromName(it)?.label }
|
||||||
|
|
||||||
|
fun describeAudio(name: String?): String = describe(name) { audioFromName(it)?.label }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercases a probe string, and answers null for the two inputs that are not codec names at
|
||||||
|
* all: absent, and the [InputProbe.UNPARSEABLE] sentinel.
|
||||||
|
*
|
||||||
|
* The sentinel would miss every key anyway, so naming it changes no answer. Naming it is still
|
||||||
|
* the point: `videoFromName` excluded it explicitly and `audioFromName` did not, which read as
|
||||||
|
* though the two disagreed about what the sentinel means — the same asymmetry as #74 one
|
||||||
|
* function further up.
|
||||||
|
*/
|
||||||
|
private fun asCodecName(name: String?): String? =
|
||||||
|
if (name == null || name == InputProbe.UNPARSEABLE) null else name.lowercase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared body of [describeVideo] and [describeAudio].
|
||||||
|
*
|
||||||
|
* They are one function apiece over one vocabulary, and they had stopped matching:
|
||||||
|
* `describeVideo` answered "Unrecognised" for [InputProbe.UNPARSEABLE] and `describeAudio` fell
|
||||||
|
* through to `?: name` instead. The sentinel opens with a NUL, so that fallback would have put
|
||||||
|
* a U+0000 into a `Text` on the source-info card (#74). Sharing the arms is what stops the next
|
||||||
|
* one being added to one side only.
|
||||||
|
*/
|
||||||
|
private fun describe(name: String?, label: (String) -> String?): String = when {
|
||||||
name == null -> "Unknown"
|
name == null -> "Unknown"
|
||||||
name == InputProbe.UNPARSEABLE -> "Unrecognised"
|
name == InputProbe.UNPARSEABLE -> "Unrecognised"
|
||||||
else -> videoFromName(name)?.label ?: name
|
else -> label(name) ?: name
|
||||||
}
|
|
||||||
|
|
||||||
fun describeAudio(name: String?): String = when {
|
|
||||||
name == null -> "Unknown"
|
|
||||||
else -> audioFromName(name)?.label ?: name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.test.assertIsSelected
|
import androidx.compose.ui.test.assertIsSelected
|
||||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
@@ -33,19 +34,22 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
* representation survives a `Bundle` round trip. A JVM round-trip test on the
|
* representation survives a `Bundle` round trip. A JVM round-trip test on the
|
||||||
* saver covers the representation.
|
* saver covers the representation.
|
||||||
*
|
*
|
||||||
* Robolectric rather than the instrumented suite, deliberately. The instrumented tests
|
* Robolectric rather than the instrumented suite, deliberately -- but not because the
|
||||||
* cannot run on the development host at all (see CLAUDE.md), and a red test nobody can
|
* instrumented suite is unavailable. It runs on this host for API 33-36
|
||||||
* execute is not a loop anyone can work in.
|
* (`tools/local-emulator/run-e2e.sh`), and CI runs 33-37. The reason is cost: this test
|
||||||
|
* needs a composition and a saved-state round trip, nothing a device supplies, and it runs
|
||||||
|
* in the same `./gradlew` invocation as every other JVM test instead of booting an
|
||||||
|
* emulator. A loop measured in seconds is a loop people stay inside.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class AppRootRestorationTest {
|
class AppRootRestorationTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors]. Every Compose test
|
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
||||||
// class in this source set starts there, whether or not it is the one that happens to be
|
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
||||||
// running when another test's escaped coroutine error is delivered.
|
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
private val restoration = StateRestorationTester(composeRule)
|
private val restoration = StateRestorationTester(composeRule)
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package org.libremediaconverter
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears coroutine errors this module's tests deliberately let escape, so they land on the test
|
|
||||||
* that caused them instead of on the next one to start.
|
|
||||||
*
|
|
||||||
* **Every Compose test class in `src/test` has to start here.** `createComposeRule` runs the
|
|
||||||
* composition inside `runTest`, and `runTest` opens by throwing `UncaughtExceptionsBeforeTest` for
|
|
||||||
* anything already sitting in kotlinx-coroutines-test's collector -- a process-wide
|
|
||||||
* `CoroutineExceptionHandler` it installs once and never removes.
|
|
||||||
*
|
|
||||||
* There is one deposit into that collector here, and it is not a mistake:
|
|
||||||
* `ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed` proves an OOM raised
|
|
||||||
* inside the probe is rethrown rather than reported as an unreadable file. `onInputPicked` runs it
|
|
||||||
* in `viewModelScope.launch`, which has no exception handler by design -- the ViewModel's own KDoc
|
|
||||||
* says a real OOM should reach the thread's handler and take the process down. On the JVM the
|
|
||||||
* collector takes it instead, holds it, and hands it to whichever `runTest` starts next.
|
|
||||||
*
|
|
||||||
* It surfaced as two *different* Compose test classes failing on two consecutive runs of the same,
|
|
||||||
* green, code, with a message naming neither the test nor the error's origin. Which class catches
|
|
||||||
* it moves because the throw happens on a real `Dispatchers.IO` thread, after the state assertion
|
|
||||||
* that ends the test that caused it -- so it can be delivered long after that class is done.
|
|
||||||
*
|
|
||||||
* A `@Before` method cannot do this: the compose rule's `runTest` wraps the statement that calls
|
|
||||||
* `@Before`, so it has already thrown. `@BeforeClass` cannot either -- Robolectric runs it outside
|
|
||||||
* the sandbox classloader, where the collector is a different object. Draining while the rule is
|
|
||||||
* being *constructed* is early enough, because JUnit builds a fresh test-class instance, and with
|
|
||||||
* it every `@get:Rule` field, before evaluating any rule.
|
|
||||||
*
|
|
||||||
* The real fix is a seam: give the probe hop an injectable dispatcher the way
|
|
||||||
* `ConversionViewModel`'s constructor already does for `cleanupDispatcher`, and the error would
|
|
||||||
* have somewhere to land. That is a production change, so it belongs in its own commit.
|
|
||||||
*/
|
|
||||||
fun drainEscapedCoroutineErrors() {
|
|
||||||
// Entering a test scope is what flushes the collector; the flush is reported as this
|
|
||||||
// throwing, and there is nothing to assert about an error another test already asserted on.
|
|
||||||
runCatching { runTest {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [createComposeRule], with [drainEscapedCoroutineErrors] run first. Use this rather than
|
|
||||||
* `createComposeRule` directly in `src/test`.
|
|
||||||
*
|
|
||||||
* It also keeps the one mixed import in one place: the rule comes from the **v2** package
|
|
||||||
* (`androidx.compose.ui.test.junit4.v2`) while `StateRestorationTester`, which takes it, does not.
|
|
||||||
*/
|
|
||||||
fun createDrainedComposeRule() = run {
|
|
||||||
drainEscapedCoroutineErrors()
|
|
||||||
createComposeRule()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package org.libremediaconverter.codec
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.libremediaconverter.model.CodecNames
|
||||||
|
import org.libremediaconverter.model.VideoCodec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bites on #87: two tables read one codec vocabulary and had stopped agreeing.
|
||||||
|
*
|
||||||
|
* `CodecNames.VIDEO_ALIASES` answers "which enum is this FFprobe name", for the source-info card
|
||||||
|
* and for routing. `AndroidDeviceCodecs.NAME_TO_MIME` answers "which MIME do I ask this device
|
||||||
|
* about", for the capability check. On `ad28293` five names lived in one and not the other: `x264`,
|
||||||
|
* `hev1`, `x265` and `vp09` were identified for display and then fell through the device check as
|
||||||
|
* unknown, so the app attempted a hardware path it had enough information to skip; `mpeg4` ran the
|
||||||
|
* other way and rendered as a raw name on the card.
|
||||||
|
*
|
||||||
|
* Per-table arm tests would have passed on both tables and encoded the disagreement, which is why
|
||||||
|
* these walk the key sets instead. A name added to — or removed from — one side alone fails here.
|
||||||
|
*/
|
||||||
|
class CodecVocabularyTest {
|
||||||
|
|
||||||
|
private val aliases = CodecNames.VIDEO_ALIASES
|
||||||
|
private val mimes = AndroidDeviceCodecs.NAME_TO_MIME
|
||||||
|
private val decodeOnly = AndroidDeviceCodecs.DECODE_ONLY_NAMES
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no video codec name resolves for display without also resolving for the device check`() {
|
||||||
|
assertEquals(
|
||||||
|
"resolve in CodecNames but return null from mimeForCodecName, so the device check runs blind",
|
||||||
|
emptySet<String>(),
|
||||||
|
aliases.keys - mimes.keys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no video codec name resolves for the device check without being a name the app can label`() {
|
||||||
|
assertEquals(
|
||||||
|
"resolve in AndroidDeviceCodecs but not in CodecNames, and are not listed as decode-only",
|
||||||
|
emptySet<String>(),
|
||||||
|
mimes.keys - aliases.keys - decodeOnly,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Membership is not enough: `"x265" to MIMETYPE_VIDEO_AVC` would satisfy both key sets and
|
||||||
|
* still ask the device about the wrong codec.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the two tables agree on what each name means, not merely that they know it`() {
|
||||||
|
aliases.forEach { (name, codec) ->
|
||||||
|
val expected = AndroidDeviceCodecs.mimeFor(codec)
|
||||||
|
assertNotNull("$name maps to $codec, which has no MIME to ask about", expected)
|
||||||
|
assertEquals("$name is $codec in CodecNames", expected, mimes[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exception list is the escape hatch: any future divergence could be waved through by
|
||||||
|
* adding the name to it. Guard both directions so it cannot be.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the decode-only names are genuinely decode-only`() {
|
||||||
|
decodeOnly.forEach { name ->
|
||||||
|
assertNotNull("$name is listed as decode-only but the device check cannot resolve it", mimes[name])
|
||||||
|
assertNull(
|
||||||
|
"$name is listed as decode-only, but CodecNames does resolve it — that is a divergence " +
|
||||||
|
"being waved through rather than a documented exception",
|
||||||
|
CodecNames.videoFromName(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five names #87 measured, pinned by name so the specific regression cannot come back
|
||||||
|
* quietly even if someone rewrites the tables above.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the names that used to resolve on one side only resolve on both`() {
|
||||||
|
mapOf(
|
||||||
|
"x264" to VideoCodec.H264,
|
||||||
|
"hev1" to VideoCodec.H265,
|
||||||
|
"x265" to VideoCodec.H265,
|
||||||
|
"vp09" to VideoCodec.VP9,
|
||||||
|
).forEach { (name, codec) ->
|
||||||
|
assertEquals("$name is a name FFmpeg emits", codec, CodecNames.videoFromName(name))
|
||||||
|
assertEquals(
|
||||||
|
"$name has to reach the device check too, or the app identifies it and then asks blind",
|
||||||
|
AndroidDeviceCodecs.mimeFor(codec),
|
||||||
|
AndroidDeviceCodecs.mimeForCodecName(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The one that runs the other way: decodable input with no enum to name it.
|
||||||
|
assertNull("mpeg4 is not an output the app can target", CodecNames.videoFromName("mpeg4"))
|
||||||
|
assertNotNull("mpeg4 is still decodable input", AndroidDeviceCodecs.mimeForCodecName("mpeg4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without this the agreement test above could pass on two nulls.
|
||||||
|
*
|
||||||
|
* `MediaFormat.MIMETYPE_VIDEO_AVC` is a Java compile-time constant, so it is inlined and the
|
||||||
|
* unit-test classpath's stubbed `android.jar` never has to supply it. If that ever stops being
|
||||||
|
* true, every MIME comparison here would be `null == null` and green — the vacuous-mutation
|
||||||
|
* failure this repo has counted before. Assert one literal so the stub fails loudly instead.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the MIME constants are real strings rather than stubs`() {
|
||||||
|
assertEquals("video/avc", AndroidDeviceCodecs.mimeForCodecName("h264"))
|
||||||
|
assertEquals("video/hevc", AndroidDeviceCodecs.mimeForCodecName("hevc"))
|
||||||
|
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `codec names are matched case-insensitively on both sides`() {
|
||||||
|
assertEquals(VideoCodec.H265, CodecNames.videoFromName("HEV1"))
|
||||||
|
assertEquals("video/hevc", AndroidDeviceCodecs.mimeForCodecName("HEV1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a name neither table knows still resolves to nothing`() {
|
||||||
|
assertNull(CodecNames.videoFromName("cinepak"))
|
||||||
|
assertNull(AndroidDeviceCodecs.mimeForCodecName("cinepak"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The behaviour #87 actually changes, at the seam that uses it.
|
||||||
|
*
|
||||||
|
* `canDecode` treats an unresolved name as "assume the platform copes". Before the alias
|
||||||
|
* landed, a device with no HEVC decoder answered true for `x265` and Media3 was handed a job it
|
||||||
|
* could not do; now the router sends it to FFmpeg without spending the attempt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a device without the decoder now says so for the aliases it used to wave through`() {
|
||||||
|
val hevcOnly = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/hevc"))
|
||||||
|
assertTrue("x265 is HEVC by another name", hevcOnly.canDecode("x265"))
|
||||||
|
assertFalse("this device has no AVC decoder, and x264 is AVC", hevcOnly.canDecode("x264"))
|
||||||
|
assertTrue("a name nobody knows keeps the permissive answer", hevcOnly.canDecode("cinepak"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other half of the null policy, at the seam it exists for — #86.
|
||||||
|
*
|
||||||
|
* `mimeFor`'s `COPY, NONE -> null` arm carries its consequence in a comment: "Returning null
|
||||||
|
* makes canEncode answer true, which is the right answer: a copied or absent track places no
|
||||||
|
* demand on the hardware." That is a product decision, and until this test nothing held it. A
|
||||||
|
* MIME appearing in that arm would make a device with no matching encoder refuse a stream copy
|
||||||
|
* — a job that never encodes anything — and the router would send it to FFmpeg to re-mux what
|
||||||
|
* Media3 could have re-muxed.
|
||||||
|
*
|
||||||
|
* The `H264` line is what makes the other two mean something: without it, a `canEncode` that
|
||||||
|
* simply returned `true` would satisfy this test. `NONE` is asserted separately from `COPY`
|
||||||
|
* because they are one arm today and two answers, and splitting the arm must not silently
|
||||||
|
* halve the coverage.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a device with no video encoder at all still permits a copied or absent track`() {
|
||||||
|
val noEncoders = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/avc"))
|
||||||
|
assertTrue(
|
||||||
|
"a copied track is re-muxed, not encoded, so no encoder is required",
|
||||||
|
noEncoders.canEncode(VideoCodec.COPY),
|
||||||
|
)
|
||||||
|
assertTrue("an absent track places no demand on the hardware", noEncoders.canEncode(VideoCodec.NONE))
|
||||||
|
assertFalse(
|
||||||
|
"this device has no AVC encoder, so an H.264 target has to be refused — without this, " +
|
||||||
|
"a canEncode that always answered true would satisfy the two assertions above",
|
||||||
|
noEncoders.canEncode(VideoCodec.H264),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package org.libremediaconverter.codec
|
||||||
|
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.libremediaconverter.convert.Media3Engine
|
||||||
|
import org.libremediaconverter.model.VideoCodec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bites on #86: a fifth `VideoCodec -> MIME` table, and nothing checking it agrees with the fourth.
|
||||||
|
*
|
||||||
|
* [AndroidDeviceCodecs.mimeFor] and [Media3Engine.videoMimeTypeFor] take the same enum and return a
|
||||||
|
* MIME string, from opposite ends of one export. The first asks the device *"have you an encoder
|
||||||
|
* for this?"*; the second tells Transformer *"produce this."* If they name different MIME types for
|
||||||
|
* the same codec, the app checks for one encoder and then requests another — the check passes, the
|
||||||
|
* export succeeds, and the user's H.265 file contains H.264. Both were `private` until #85 and #87
|
||||||
|
* widened them, so this assertion could not be written before; each table had per-arm tests that
|
||||||
|
* pinned its own answers and could not see the other side.
|
||||||
|
*
|
||||||
|
* **They do not agree everywhere, and must not be forced to.** Three buckets, all pinned below:
|
||||||
|
*
|
||||||
|
* - **H.264 and H.265** — both tables name a MIME, and it has to be the same one. This is the
|
||||||
|
* bucket the defect lives in.
|
||||||
|
* - **VP8, VP9 and AV1** — the device table names a real MIME, Transformer's returns null. That is
|
||||||
|
* correct, not drift: `Transformer.setVideoMimeType` will not accept them, so the router sends
|
||||||
|
* them to FFmpeg before Media3 is asked anything, while a device may still genuinely own a VP9
|
||||||
|
* encoder and `canEncode` has to give a truthful answer about it. Flattening `mimeFor` to null
|
||||||
|
* here to "make the tables agree" would make `canEncode(VP9)` answer true on hardware that has
|
||||||
|
* no VP9 encoder. The routing half of that claim is proved in
|
||||||
|
* `Media3EngineMimeTypesTest.the router sends exactly H264 and H265 video encodes to Media3`,
|
||||||
|
* which drives the real router; it is not repeated here.
|
||||||
|
* - **COPY and NONE** — neither names a MIME, because neither is encoded at all.
|
||||||
|
*
|
||||||
|
* The fourth bucket is asserted empty: a codec Transformer names and the device check cannot ask
|
||||||
|
* about would mean `canEncode` waving through a target the app then really does encode.
|
||||||
|
*
|
||||||
|
* **Audio has no partner, and that is a gap rather than a decision.** [Media3Engine.audioMimeTypeFor]
|
||||||
|
* is the same shape one enum over — `AudioCodec -> MIME` — but [AndroidDeviceCodecs] enumerates
|
||||||
|
* `video/` MIME types only, so there is no device-side audio table to cross-check it against. An
|
||||||
|
* audio encoder this device lacks is therefore not caught up front the way a video one is; the job
|
||||||
|
* reaches Media3 and falls back after failing. Named here so the asymmetry reads as unfinished
|
||||||
|
* rather than intended.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
class VideoCodecMimeAgreementTest {
|
||||||
|
|
||||||
|
/** Both tables name a MIME. The pair has to match; this is the whole point of the file. */
|
||||||
|
private val bothNameAMime = setOf(VideoCodec.H264, VideoCodec.H265)
|
||||||
|
|
||||||
|
/** Only the device table names one, because Transformer is never asked for these. */
|
||||||
|
private val deviceOnly = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1)
|
||||||
|
|
||||||
|
/** Neither names one: nothing is encoded, so there is no encoder to name. */
|
||||||
|
private val neitherNamesOne = setOf(VideoCodec.COPY, VideoCodec.NONE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts every [VideoCodec] by what the two tables actually answer, then compares the sorting
|
||||||
|
* with the buckets documented above.
|
||||||
|
*
|
||||||
|
* This is what makes the agreement test below non-vacuous, and it is deliberately an exact
|
||||||
|
* comparison in all four directions. A codec added to the enum lands in some bucket and fails
|
||||||
|
* here rather than arriving unclassified. A table that starts returning null for everything —
|
||||||
|
* the shape a filtered loop would pass on — empties two buckets and fails here. And a
|
||||||
|
* *convergence* fails too: giving `videoMimeTypeFor(VP9)` a real MIME moves VP9 out of
|
||||||
|
* `deviceOnly`, which is the point. The divergence should be deliberate and visible, so
|
||||||
|
* changing it should require saying so in this file.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `each video codec is in the bucket the two tables actually put it in`() {
|
||||||
|
assertEquals(
|
||||||
|
"codecs both tables name a MIME for",
|
||||||
|
bothNameAMime,
|
||||||
|
VideoCodec.entries.filter { device(it) != null && transformer(it) != null }.toSet(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"codecs only the device check names a MIME for, because Transformer will not encode them",
|
||||||
|
deviceOnly,
|
||||||
|
VideoCodec.entries.filter { device(it) != null && transformer(it) == null }.toSet(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"codecs neither table names a MIME for, because nothing is encoded",
|
||||||
|
neitherNamesOne,
|
||||||
|
VideoCodec.entries.filter { device(it) == null && transformer(it) == null }.toSet(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"codecs Transformer names a MIME for that the device check cannot ask about — canEncode " +
|
||||||
|
"would answer true without looking, for a codec Media3 really is told to produce",
|
||||||
|
emptySet<VideoCodec>(),
|
||||||
|
VideoCodec.entries.filter { device(it) == null && transformer(it) != null }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cross-check itself.
|
||||||
|
*
|
||||||
|
* Per-arm tests in either file cannot catch this: each pins its own table's answers, so a pair
|
||||||
|
* changed in lockstep with its own expectations stays green on both sides while the two tables
|
||||||
|
* describe different codecs.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `where both tables name a MIME they name the same one`() {
|
||||||
|
bothNameAMime.forEach { codec ->
|
||||||
|
val asked = device(codec)
|
||||||
|
val requested = transformer(codec)
|
||||||
|
assertNotNull("AndroidDeviceCodecs has no MIME to ask the device about for ${codec.label}", asked)
|
||||||
|
assertNotNull("Media3Engine has no MIME to give Transformer for ${codec.label}", requested)
|
||||||
|
assertEquals(
|
||||||
|
"${codec.label}: the device is asked about $asked and Transformer is then told to " +
|
||||||
|
"produce $requested, so the capability check answers about a codec that is not the output",
|
||||||
|
asked,
|
||||||
|
requested,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The documented divergence, asserted rather than described.
|
||||||
|
*
|
||||||
|
* Both halves matter. The null side is Media3's refusal; the non-null side is the device
|
||||||
|
* check's genuine question, and it is the half a reader "tidying up" the disagreement would
|
||||||
|
* delete.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the codecs Transformer will not encode are still codecs this device may or may not have`() {
|
||||||
|
deviceOnly.forEach { codec ->
|
||||||
|
assertNotNull(
|
||||||
|
"${codec.label} goes to FFmpeg, but canEncode still has to answer truthfully about " +
|
||||||
|
"this device's encoder — a null here makes it answer true without looking",
|
||||||
|
device(codec),
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
"Transformer rejects ${codec.label}, so naming a MIME for it would request an export " +
|
||||||
|
"Media3 cannot perform",
|
||||||
|
transformer(codec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards every comparison above against passing as `null == null`.
|
||||||
|
*
|
||||||
|
* `MediaFormat`'s MIME types are Java compile-time constants and are inlined, so the unit-test
|
||||||
|
* classpath's stubbed `android.jar` never supplies them; `MimeTypes`' come from a real
|
||||||
|
* `media3-common` jar. If either stopped holding, the buckets would collapse and this fails
|
||||||
|
* first, with the reason. Same guard, and the same reason, as
|
||||||
|
* `CodecVocabularyTest.the MIME constants are real strings rather than stubs`.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `both tables return real MIME strings rather than stubs`() {
|
||||||
|
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
|
||||||
|
assertEquals("video/hevc", AndroidDeviceCodecs.mimeFor(VideoCodec.H265))
|
||||||
|
assertEquals("video/x-vnd.on2.vp9", AndroidDeviceCodecs.mimeFor(VideoCodec.VP9))
|
||||||
|
assertEquals("video/avc", Media3Engine.videoMimeTypeFor(VideoCodec.H264))
|
||||||
|
assertEquals("video/hevc", Media3Engine.videoMimeTypeFor(VideoCodec.H265))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun device(codec: VideoCodec): String? = AndroidDeviceCodecs.mimeFor(codec)
|
||||||
|
|
||||||
|
private fun transformer(codec: VideoCodec): String? = Media3Engine.videoMimeTypeFor(codec)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
|
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
|
||||||
import androidx.compose.runtime.saveable.SaveableStateRegistry
|
import androidx.compose.runtime.saveable.SaveableStateRegistry
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -15,7 +16,6 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
import org.libremediaconverter.model.AudioCodec
|
||||||
import org.libremediaconverter.model.Container
|
import org.libremediaconverter.model.Container
|
||||||
import org.libremediaconverter.model.OutputSpec
|
import org.libremediaconverter.model.OutputSpec
|
||||||
@@ -57,9 +57,8 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class AdvancedPanelSavedStateTest {
|
class AdvancedPanelSavedStateTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `canBeSaved = { true }` deliberately.
|
* `canBeSaved = { true }` deliberately.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.hasAnyAncestor
|
|||||||
import androidx.compose.ui.test.hasTestTag
|
import androidx.compose.ui.test.hasTestTag
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
@@ -16,7 +17,6 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
import org.libremediaconverter.model.AudioCodec
|
||||||
import org.libremediaconverter.model.Container
|
import org.libremediaconverter.model.Container
|
||||||
import org.libremediaconverter.model.ContainerCapabilities
|
import org.libremediaconverter.model.ContainerCapabilities
|
||||||
@@ -61,9 +61,11 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class AdvancedPickerTest {
|
class AdvancedPickerTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
|
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
||||||
|
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
||||||
|
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
private val restoration = StateRestorationTester(composeRule)
|
private val restoration = StateRestorationTester(composeRule)
|
||||||
|
|
||||||
|
|||||||
+25
-26
@@ -2,14 +2,15 @@ package org.libremediaconverter.convert
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Looper
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -18,8 +19,6 @@ import org.libremediaconverter.model.InputProbe
|
|||||||
import org.libremediaconverter.work.ConversionWorker
|
import org.libremediaconverter.work.ConversionWorker
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
import org.robolectric.Shadows.shadowOf
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
|
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
|
||||||
@@ -87,19 +86,35 @@ class ConversionViewModelProbeFailureTest {
|
|||||||
* is out of memory" into "this video looks unreadable" and let the app carry on in a
|
* is out of memory" into "this video looks unreadable" and let the app carry on in a
|
||||||
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
|
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
|
||||||
* have introduced, and the reason this defect was left open rather than fixed carelessly.
|
* have introduced, and the reason this defect was left open rather than fixed carelessly.
|
||||||
|
*
|
||||||
|
* **The error itself is what is asserted here, and that is what the `pickDispatcher` seam
|
||||||
|
* bought.** With the hop hard-coded to `Dispatchers.IO` this was impossible: the throw
|
||||||
|
* happened on a pool thread some time after this method had returned, so all a test could do
|
||||||
|
* was infer it from a card that never filled in — which is also what a probe returning null
|
||||||
|
* would look like. Worse, the escaped error went into kotlinx-coroutines-test's process-wide
|
||||||
|
* collector and was rethrown at whichever `runTest` started next, which is a *different*
|
||||||
|
* Compose class between runs of identical code. Putting the pick on [Dispatchers.Unconfined]
|
||||||
|
* runs it inline, inside a `runTest` whose scope owns the collector's callback: the error is
|
||||||
|
* handed to this test and consumed, rather than stored for a stranger.
|
||||||
|
*
|
||||||
|
* Note where it surfaces — at the end of `runTest`, not inside `onInputPicked`. `launch`
|
||||||
|
* gives an escaped error to the handler chain and never to its caller, so nothing can catch
|
||||||
|
* it at the call itself. This is as close as the coroutine machinery allows, and unlike the
|
||||||
|
* old assertion it is the real [OutOfMemoryError] instance.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `an OutOfMemoryError is not swallowed`() {
|
fun `an OutOfMemoryError is not swallowed`() {
|
||||||
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
|
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
|
||||||
|
// Unconfined for the pick, so the whole of onInputPicked runs inline on this thread and
|
||||||
|
// has thrown before runTest can leave the scope that has to receive the error.
|
||||||
|
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined, Dispatchers.Unconfined)
|
||||||
|
|
||||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
val escaped = assertThrows(OutOfMemoryError::class.java) { runTest { viewModel.onInputPicked(INPUT) } }
|
||||||
viewModel.onInputPicked(INPUT)
|
|
||||||
|
|
||||||
// The observable difference, and the reason this is asserted on state rather than on a
|
assertEquals("Failed to allocate 512 MB", escaped.message)
|
||||||
// caught throwable: the probe hop is on Dispatchers.IO, so an error that escapes lands
|
// The other half of the contract, unchanged: an OOM is about the process, so the card is
|
||||||
// on that thread's handler rather than at this call. What must not happen is the card
|
// left as it was rather than filled in with a verdict the app would then act on.
|
||||||
// filling in with an "unreadable" verdict the app would then act on.
|
val settled = viewModel.state.value
|
||||||
val settled = settle(viewModel)
|
|
||||||
// `sizeBytes = null`, not `0L`: no provider is registered for this authority, so the
|
// `sizeBytes = null`, not `0L`: no provider is registered for this authority, so the
|
||||||
// metadata query returns nothing and the descriptor cannot be opened either. That is the
|
// metadata query returns nothing and the descriptor cannot be opened either. That is the
|
||||||
// unknown, and it stopped being spelled the same way as "empty" -- see [InputQuery].
|
// unknown, and it stopped being spelled the same way as "empty" -- see [InputQuery].
|
||||||
@@ -132,23 +147,7 @@ class ConversionViewModelProbeFailureTest {
|
|||||||
return (ready as ConversionState.Ready).input.probe
|
return (ready as ConversionState.Ready).input.probe
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pumps the looper the way [awaitState] does, but for a fixed span and without requiring
|
|
||||||
* anything to happen — here "the pick never came back" is the expected outcome, so there
|
|
||||||
* is no predicate to wait on.
|
|
||||||
*/
|
|
||||||
private fun settle(viewModel: ConversionViewModel): ConversionState {
|
|
||||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SETTLE_MS)
|
|
||||||
while (System.nanoTime() < deadline) {
|
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
|
||||||
Thread.sleep(POLL_MS)
|
|
||||||
}
|
|
||||||
return viewModel.state.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
|
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
|
||||||
const val SETTLE_MS = 500L
|
|
||||||
const val POLL_MS = 5L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.libremediaconverter.convert
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
@@ -9,7 +10,6 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
import org.libremediaconverter.model.AudioCodec
|
||||||
import org.libremediaconverter.model.Container
|
import org.libremediaconverter.model.Container
|
||||||
import org.libremediaconverter.model.EnginePreference
|
import org.libremediaconverter.model.EnginePreference
|
||||||
@@ -47,7 +47,7 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
class ConverterLeafTagsTest {
|
class ConverterLeafTagsTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
private fun assertResolvesToOneNode(tag: String) {
|
private fun assertResolvesToOneNode(tag: String) {
|
||||||
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
|
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertIsSelected
|
|||||||
import androidx.compose.ui.test.hasAnyAncestor
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
import androidx.compose.ui.test.hasTestTag
|
import androidx.compose.ui.test.hasTestTag
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -13,7 +14,6 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.EnginePreference
|
import org.libremediaconverter.model.EnginePreference
|
||||||
import org.libremediaconverter.model.OutputFormat
|
import org.libremediaconverter.model.OutputFormat
|
||||||
import org.libremediaconverter.model.QualityTier
|
import org.libremediaconverter.model.QualityTier
|
||||||
@@ -48,7 +48,7 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
class ConverterPickerSelectionTest {
|
class ConverterPickerSelectionTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chip carrying [label] inside the row tagged [rowTag].
|
* The chip carrying [label] inside the row tagged [rowTag].
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.libremediaconverter.convert
|
package org.libremediaconverter.convert
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
@@ -9,7 +10,6 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.Validation
|
import org.libremediaconverter.model.Validation
|
||||||
import org.libremediaconverter.ui.TestTags
|
import org.libremediaconverter.ui.TestTags
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
@@ -39,9 +39,8 @@ import java.io.File
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class ConverterScreenContentTest {
|
class ConverterScreenContentTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||||
private val savedAs = mutableListOf<String>()
|
private val savedAs = mutableListOf<String>()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertIsEnabled
|
|||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
@@ -15,7 +16,6 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
import org.libremediaconverter.model.AudioCodec
|
||||||
import org.libremediaconverter.model.Container
|
import org.libremediaconverter.model.Container
|
||||||
import org.libremediaconverter.model.OutputSpec
|
import org.libremediaconverter.model.OutputSpec
|
||||||
@@ -72,9 +72,8 @@ import java.io.File
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class ConverterStateAffordancesTest {
|
class ConverterStateAffordancesTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every callback the screen fired, in order, tagged with the value it carried.
|
* Every callback the screen fired, in order, tagged with the value it carried.
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package org.libremediaconverter.convert
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onChildren
|
import androidx.compose.ui.test.onChildren
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
import org.libremediaconverter.model.AudioCodec
|
||||||
import org.libremediaconverter.model.Container
|
import org.libremediaconverter.model.Container
|
||||||
import org.libremediaconverter.model.InputKind
|
import org.libremediaconverter.model.InputKind
|
||||||
@@ -56,7 +56,7 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
class FileCardTest {
|
class FileCardTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a file no provider could measure says so in words rather than showing a zero`() {
|
fun `a file no provider could measure says so in words rather than showing a zero`() {
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package org.libremediaconverter.convert
|
||||||
|
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.libremediaconverter.model.AudioCodec
|
||||||
|
import org.libremediaconverter.model.AudioPlan
|
||||||
|
import org.libremediaconverter.model.Container
|
||||||
|
import org.libremediaconverter.model.ConversionPlan
|
||||||
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
|
import org.libremediaconverter.model.ConversionRouter
|
||||||
|
import org.libremediaconverter.model.CopyPlanner
|
||||||
|
import org.libremediaconverter.model.DeviceCodecs
|
||||||
|
import org.libremediaconverter.model.Engine
|
||||||
|
import org.libremediaconverter.model.InputProbe
|
||||||
|
import org.libremediaconverter.model.OutputSpec
|
||||||
|
import org.libremediaconverter.model.VideoCodec
|
||||||
|
import org.libremediaconverter.model.VideoPlan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards [Media3Engine]'s two enum-to-MIME tables and the claims written above them.
|
||||||
|
*
|
||||||
|
* The defect: neither table was exercised at all, so nothing stood between a wrong entry and the
|
||||||
|
* user's file. Point `H265` at `VIDEO_H264` and every hardware HEVC export writes H.264 into a
|
||||||
|
* file the user asked to be H.265 — Transformer does exactly as told, the export succeeds, and
|
||||||
|
* the only symptom is a codec nobody chose.
|
||||||
|
*
|
||||||
|
* Worse, one arm carried an assertion instead of a value:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* // Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* That is a claim about *callers* parked in a branch of a callee. It happens to be true, and
|
||||||
|
* nothing whatsoever checked it, so it would have gone on reading as true after it stopped being.
|
||||||
|
*
|
||||||
|
* Three kinds of test, because arm-by-arm equality alone would only pin today's answers:
|
||||||
|
*
|
||||||
|
* 1. Every arm of both tables, nulls included.
|
||||||
|
* 2. The "never reached" claim, proved over every plan [CopyPlanner] can produce.
|
||||||
|
* 3. The tables against [ConversionRouter]'s actual decisions rather than against its codec sets —
|
||||||
|
* the comments claim behaviour ("the router routes them to FFmpeg"), and a set can be right
|
||||||
|
* while the rule that reads it is wrong.
|
||||||
|
*
|
||||||
|
* A JVM test rather than an instrumented one: both tables take an enum and return a constant.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
class Media3EngineMimeTypesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every video codec maps to the MIME type Transformer will be given`() {
|
||||||
|
assertEquals(
|
||||||
|
"EXPECTED_VIDEO_MIME must name every VideoCodec, so a new one cannot arrive untested",
|
||||||
|
VideoCodec.entries.toSet(),
|
||||||
|
EXPECTED_VIDEO_MIME.keys,
|
||||||
|
)
|
||||||
|
VideoCodec.entries.forEach { codec ->
|
||||||
|
assertEquals(
|
||||||
|
"videoMimeTypeFor(${codec.label})",
|
||||||
|
EXPECTED_VIDEO_MIME.getValue(codec),
|
||||||
|
Media3Engine.videoMimeTypeFor(codec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every audio codec maps to the MIME type Transformer will be given`() {
|
||||||
|
assertEquals(
|
||||||
|
"EXPECTED_AUDIO_MIME must name every AudioCodec, so a new one cannot arrive untested",
|
||||||
|
AudioCodec.entries.toSet(),
|
||||||
|
EXPECTED_AUDIO_MIME.keys,
|
||||||
|
)
|
||||||
|
AudioCodec.entries.forEach { codec ->
|
||||||
|
assertEquals(
|
||||||
|
"audioMimeTypeFor(${codec.label})",
|
||||||
|
EXPECTED_AUDIO_MIME.getValue(codec),
|
||||||
|
Media3Engine.audioMimeTypeFor(codec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "never reached" claim, proved rather than repeated.
|
||||||
|
*
|
||||||
|
* [Media3Engine] asks these tables only for `plan.video as? VideoPlan.Encode`, and every plan
|
||||||
|
* it sees comes from [CopyPlanner]. So the claim reduces to a property of the planner: over
|
||||||
|
* every spec it can be handed, an `Encode` never carries `COPY` or `NONE`. That holds because
|
||||||
|
* both codecs are answered before the `Encode` branch, and the fallback draws from
|
||||||
|
* `ContainerCapabilities.encodableVideo`, which contains neither — but this asserts it instead
|
||||||
|
* of trusting the reading.
|
||||||
|
*
|
||||||
|
* The counters are not decoration. `(plan.video as? VideoPlan.Encode)?.let { ... }` asserts
|
||||||
|
* nothing at all for a `Drop` or `Copy` plan, so a sweep that stopped producing `Encode` plans
|
||||||
|
* would stay green while checking nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `no plan CopyPlanner can produce carries COPY or NONE inside an Encode`() {
|
||||||
|
var videoEncodes = 0
|
||||||
|
var audioEncodes = 0
|
||||||
|
everyPlan().forEach { (spec, probe, plan) ->
|
||||||
|
(plan.video as? VideoPlan.Encode)?.let {
|
||||||
|
videoEncodes++
|
||||||
|
assertTrue(
|
||||||
|
"CopyPlanner produced VideoPlan.Encode(${it.codec}) for $spec against $probe",
|
||||||
|
it.codec != VideoCodec.COPY && it.codec != VideoCodec.NONE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(plan.audio as? AudioPlan.Encode)?.let {
|
||||||
|
audioEncodes++
|
||||||
|
assertTrue(
|
||||||
|
"CopyPlanner produced AudioPlan.Encode(${it.codec}) for $spec against $probe",
|
||||||
|
it.codec != AudioCodec.COPY && it.codec != AudioCodec.NONE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("the sweep produced no video Encode plan, so it asserted nothing", videoEncodes > 0)
|
||||||
|
assertTrue("the sweep produced no audio Encode plan, so it asserted nothing", audioEncodes > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The video table's other claim: VP8, VP9 and AV1 targets "never reach here".
|
||||||
|
*
|
||||||
|
* Asked of the router rather than of its private codec set, so the rule is what is under test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the router sends exactly H264 and H265 video encodes to Media3`() {
|
||||||
|
val onMedia3 = REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.MEDIA3 }
|
||||||
|
assertEquals(listOf(VideoCodec.H264, VideoCodec.H265), onMedia3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audio table's sibling claim, and where it turned out to be incomplete.
|
||||||
|
*
|
||||||
|
* The comment named MP3 and FLAC. One rule — `audioEncode !in MEDIA3_AUDIO` — diverts Vorbis
|
||||||
|
* by exactly the same logic, so three of the six encodable codecs never reach the table, not
|
||||||
|
* two. Asserted as the whole set rather than as two memberships, which is what makes the
|
||||||
|
* omission visible.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the router keeps MP3 FLAC and Vorbis audio encodes off Media3`() {
|
||||||
|
val onMedia3 = REAL_AUDIO_CODECS.filter { engineForAudioEncode(it) == Engine.MEDIA3 }
|
||||||
|
assertEquals(listOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM), onMedia3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The binding that makes the two halves above one test rather than two coincidences.
|
||||||
|
*
|
||||||
|
* A codec the router starts sending to Media3 must have a MIME type here, or Transformer is
|
||||||
|
* left to pick its own and the user gets a codec they did not choose.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `every codec the router sends to Media3 has a MIME type`() {
|
||||||
|
REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.MEDIA3 }.forEach { codec ->
|
||||||
|
assertNotNull(
|
||||||
|
"${codec.label} is routed to Media3 but videoMimeTypeFor returns null",
|
||||||
|
Media3Engine.videoMimeTypeFor(codec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
REAL_AUDIO_CODECS.filter { engineForAudioEncode(it) == Engine.MEDIA3 }.forEach { codec ->
|
||||||
|
assertNotNull(
|
||||||
|
"${codec.label} is routed to Media3 but audioMimeTypeFor returns null",
|
||||||
|
Media3Engine.audioMimeTypeFor(codec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reverse direction, which holds for video and not for audio.
|
||||||
|
*
|
||||||
|
* Every video codec the router withholds has a null entry, so that table is exactly the set of
|
||||||
|
* codecs Media3 is asked to encode. Audio has one entry more than the router will ever use:
|
||||||
|
* `VORBIS -> AUDIO_VORBIS` is correct and unreachable. Pinned deliberately — if a routing
|
||||||
|
* change makes Vorbis live, this is the test that says the arm above stopped being dead.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `Vorbis is the one MIME type the router never asks for`() {
|
||||||
|
REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.FFMPEG }.forEach { codec ->
|
||||||
|
assertEquals(
|
||||||
|
"${codec.label} never reaches Media3, so it must not name a MIME type",
|
||||||
|
null,
|
||||||
|
Media3Engine.videoMimeTypeFor(codec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val namedButUnrouted = REAL_AUDIO_CODECS
|
||||||
|
.filter { Media3Engine.audioMimeTypeFor(it) != null }
|
||||||
|
.filter { engineForAudioEncode(it) == Engine.FFMPEG }
|
||||||
|
assertEquals(listOf(AudioCodec.VORBIS), namedButUnrouted)
|
||||||
|
assertEquals(MimeTypes.AUDIO_VORBIS, Media3Engine.audioMimeTypeFor(AudioCodec.VORBIS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a video-only re-encode to [codec] and reports the engine chosen.
|
||||||
|
*
|
||||||
|
* `mpeg2video` is the load-bearing detail: [CopyPlanner] upgrades a request to a stream copy
|
||||||
|
* when the source codec matches, and a `Copy` plan would answer a different question. A name
|
||||||
|
* `CodecNames` cannot resolve forces an `Encode` for every codec, which the assertion pins so
|
||||||
|
* that a planner change cannot quietly turn this sweep into a sweep of `Copy` plans.
|
||||||
|
*/
|
||||||
|
private fun engineForVideoEncode(codec: VideoCodec): Engine {
|
||||||
|
val request = ConversionRequest(
|
||||||
|
spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE),
|
||||||
|
probe = InputProbe(videoCodec = "mpeg2video", container = Container.MKV),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"this request no longer plans a video Encode, so its engine says nothing about $codec",
|
||||||
|
VideoPlan.Encode(codec),
|
||||||
|
CopyPlanner.plan(request.spec, request.probe).video,
|
||||||
|
)
|
||||||
|
return ConversionRouter.route(request, DeviceCodecs.PERMISSIVE).engine
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The audio counterpart. `ac3` is unresolvable for the same reason `mpeg2video` is. */
|
||||||
|
private fun engineForAudioEncode(codec: AudioCodec): Engine {
|
||||||
|
val request = ConversionRequest(
|
||||||
|
spec = OutputSpec(Container.MP4, VideoCodec.NONE, codec),
|
||||||
|
probe = InputProbe(audioCodec = "ac3", hasVideo = false, container = Container.MKV),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"this request no longer plans an audio Encode, so its engine says nothing about $codec",
|
||||||
|
AudioPlan.Encode(codec),
|
||||||
|
CopyPlanner.plan(request.spec, request.probe).audio,
|
||||||
|
)
|
||||||
|
return ConversionRouter.route(request, DeviceCodecs.PERMISSIVE).engine
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun everyPlan(): List<Triple<OutputSpec, InputProbe, ConversionPlan>> =
|
||||||
|
ALL_SPECS.flatMap { spec -> PROBES.map { Triple(spec, it, CopyPlanner.plan(spec, it)) } }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
/** Every arm of `videoMimeTypeFor`, including the ones the tests above prove unreachable. */
|
||||||
|
val EXPECTED_VIDEO_MIME: Map<VideoCodec, String?> = mapOf(
|
||||||
|
VideoCodec.H264 to MimeTypes.VIDEO_H264,
|
||||||
|
VideoCodec.H265 to MimeTypes.VIDEO_H265,
|
||||||
|
VideoCodec.VP8 to null,
|
||||||
|
VideoCodec.VP9 to null,
|
||||||
|
VideoCodec.AV1 to null,
|
||||||
|
// Unreachable, and asserted anyway: the proof lives in another test, and a reader
|
||||||
|
// deleting these would leave the arms themselves unexercised.
|
||||||
|
VideoCodec.COPY to null,
|
||||||
|
VideoCodec.NONE to null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val EXPECTED_AUDIO_MIME: Map<AudioCodec, String?> = mapOf(
|
||||||
|
AudioCodec.AAC to MimeTypes.AUDIO_AAC,
|
||||||
|
AudioCodec.OPUS to MimeTypes.AUDIO_OPUS,
|
||||||
|
AudioCodec.VORBIS to MimeTypes.AUDIO_VORBIS,
|
||||||
|
AudioCodec.PCM to MimeTypes.AUDIO_RAW,
|
||||||
|
AudioCodec.MP3 to null,
|
||||||
|
AudioCodec.FLAC to null,
|
||||||
|
AudioCodec.COPY to null,
|
||||||
|
AudioCodec.NONE to null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Codecs a user can actually ask to be produced: `COPY` and `NONE` are instructions. */
|
||||||
|
val REAL_VIDEO_CODECS = VideoCodec.entries - VideoCodec.COPY - VideoCodec.NONE
|
||||||
|
val REAL_AUDIO_CODECS = AudioCodec.entries - AudioCodec.COPY - AudioCodec.NONE
|
||||||
|
|
||||||
|
/** Every output a spec can name — 15 containers by 7 video codecs by 8 audio codecs. */
|
||||||
|
val ALL_SPECS: List<OutputSpec> = Container.entries.flatMap { container ->
|
||||||
|
VideoCodec.entries.flatMap { video ->
|
||||||
|
AudioCodec.entries.map { audio -> OutputSpec(container, video, audio) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inputs chosen to reach each of [CopyPlanner]'s branches. */
|
||||||
|
val PROBES = listOf(
|
||||||
|
// Nothing known about the source at all.
|
||||||
|
InputProbe(),
|
||||||
|
// Identified, and the container changes: the copy upgrade applies.
|
||||||
|
InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
|
||||||
|
// Identified, container unchanged: the copy upgrade deliberately does not apply.
|
||||||
|
InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MP4),
|
||||||
|
// Copyable but not encodable by either engine — the fallback's reason for existing.
|
||||||
|
InputProbe(videoCodec = "av1", audioCodec = "flac", container = Container.MKV),
|
||||||
|
// Real codecs this app cannot name, so a copy is never proven safe.
|
||||||
|
InputProbe(videoCodec = "mpeg2video", audioCodec = "ac3", container = Container.AVI),
|
||||||
|
// The platform extractor could not open it.
|
||||||
|
InputProbe(videoCodec = InputProbe.UNPARSEABLE),
|
||||||
|
// Audio only.
|
||||||
|
InputProbe(videoCodec = null, audioCodec = "opus", hasVideo = false, container = Container.OGG),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package org.libremediaconverter.convert
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image-demuxer rule, which looks arbitrary until it is read as a suffix.
|
||||||
|
*
|
||||||
|
* `MediaProbe.classify` asks [MediaProbe.isImageFormat] before anything else, so this one boolean
|
||||||
|
* overrides everything both probes found: true and the source-info card says "Image" and a size,
|
||||||
|
* false and it says container, codec and length. Neither mistake fails loudly.
|
||||||
|
*
|
||||||
|
* The rule has two halves and they are not the same shape. `image2` is a whole format name —
|
||||||
|
* FFprobe reports it for a numbered image sequence — while the piped demuxers are named one per
|
||||||
|
* image codec, so `_pipe` has to be matched as a *suffix*: `png_pipe`, `jpeg_pipe`, `webp_pipe`
|
||||||
|
* and some thirty more. Widening that suffix to a substring is the tempting simplification and it
|
||||||
|
* is wrong, because `yuv4mpegpipe` is raw video.
|
||||||
|
*
|
||||||
|
* The image names were measured rather than recalled. `ffprobe -show_entries format=format_name`
|
||||||
|
* reports `png_pipe` for a `.png`, `jpeg_pipe` for a `.jpg`, `yuv4mpegpipe` for a `.y4m`, and
|
||||||
|
* `image2` only when that demuxer is named explicitly. The container names come from
|
||||||
|
* [MediaProbeFormatTest], and the case and spacing variants are synthetic — those exercise the
|
||||||
|
* normalisation rather than anything FFprobe emits.
|
||||||
|
*
|
||||||
|
* One real format name is deliberately not asserted either way. `image2pipe` gets a false answer
|
||||||
|
* here, being neither `image2` nor a `_pipe` suffix, and that is inert rather than a latent bug:
|
||||||
|
* FFprobe only selects it when the demuxer is named with `-f image2pipe`, while `probeWithFFprobe`
|
||||||
|
* forces no format at all, so a picked image arrives as `png_pipe` or its own codec's equivalent.
|
||||||
|
* Pinning today's answer for a name this app cannot receive would be a test about FFmpeg's command
|
||||||
|
* line rather than about this rule.
|
||||||
|
*/
|
||||||
|
class MediaProbeImageFormatTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a numbered image sequence is an image`() {
|
||||||
|
assertIsImage("image2")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a picked PNG or JPEG actually reports, and the reason the suffix rule exists. */
|
||||||
|
@Test
|
||||||
|
fun `the per-codec piped demuxers are images`() {
|
||||||
|
assertIsImage("png_pipe")
|
||||||
|
assertIsImage("jpeg_pipe")
|
||||||
|
assertIsImage("webp_pipe")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half that a substring match would break.
|
||||||
|
*
|
||||||
|
* `yuv4mpegpipe` contains `pipe` and is not an image: it is raw uncompressed video, and
|
||||||
|
* describing it as an image would hide its codec, its size and its length from the card while
|
||||||
|
* leaving the file perfectly convertible.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a format that merely contains pipe is not an image`() {
|
||||||
|
assertNotImage("yuv4mpegpipe")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ordinary media containers, which is what the false answer is mostly for. */
|
||||||
|
@Test
|
||||||
|
fun `a real container is not an image`() {
|
||||||
|
assertNotImage("mov,mp4,m4a,3gp,3g2,mj2")
|
||||||
|
assertNotImage("matroska,webm")
|
||||||
|
assertNotImage("mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFprobe names every format sharing the demuxer, so the entry that matters can be anywhere in
|
||||||
|
* the list — and the padding and case are normalised the same way [MediaProbe.containerFrom]
|
||||||
|
* normalises them.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an image entry is found anywhere in the list, whatever its spacing or case`() {
|
||||||
|
assertIsImage("PNG_PIPE")
|
||||||
|
assertIsImage(" image2 ")
|
||||||
|
assertIsImage("something_else, tiff_pipe")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing to go on is not an image; the card falls back to describing an unknown container. */
|
||||||
|
@Test
|
||||||
|
fun `an empty format name is not an image`() {
|
||||||
|
assertNotImage("")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertIsImage(formatName: String) =
|
||||||
|
assertTrue("isImageFormat(\"$formatName\")", MediaProbe.isImageFormat(formatName))
|
||||||
|
|
||||||
|
private fun assertNotImage(formatName: String) =
|
||||||
|
assertFalse("isImageFormat(\"$formatName\")", MediaProbe.isImageFormat(formatName))
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package org.libremediaconverter.convert
|
||||||
|
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MIME -> short codec name table, which nothing downstream would notice going wrong.
|
||||||
|
*
|
||||||
|
* `MediaExtractor` answers in platform MIME spellings; the router, the copy planner and the
|
||||||
|
* source-info card all speak FFmpeg's short names. [MediaProbe.shortName] is the one place those
|
||||||
|
* two vocabularies meet, and most of its arms are translations rather than trimming — `video/avc`
|
||||||
|
* is `h264`, `audio/mp4a-latm` is `aac`, `video/x-vnd.on2.vp9` is `vp9`.
|
||||||
|
*
|
||||||
|
* So a dropped or mistyped arm does not throw. It falls through to `substringAfter('/')` and
|
||||||
|
* reports a different, entirely plausible-looking string. `CodecNames` carries alias lists that
|
||||||
|
* happen to rescue some of those (`avc`, `av01`, `raw`) and not others (`mp4a-latm`,
|
||||||
|
* `x-vnd.on2.vp9`), which is exactly why leaning on the rescue is not a plan: an unrecognised
|
||||||
|
* codec is how a stream-copyable file quietly becomes a re-encode, and how the card ends up naming
|
||||||
|
* a codec no user has heard of. This table is the only place those arms are pinned.
|
||||||
|
*
|
||||||
|
* A plain JVM test rather than Robolectric: `MediaFormat.MIMETYPE_*` are Java compile-time String
|
||||||
|
* constants, so this test and `MediaProbe` alike carry the literals in their own bytecode and the
|
||||||
|
* framework class is never loaded.
|
||||||
|
*
|
||||||
|
* Every case names its MIME in the failure message, because the MIME is the thing that has to be
|
||||||
|
* looked up when one of these goes red.
|
||||||
|
*/
|
||||||
|
class MediaProbeMimeNamesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an AVC track is reported as h264, which is what everything downstream calls it`() {
|
||||||
|
assertShortName("h264", MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On2's vendor MIME looks nothing like the codec name FFmpeg and the router use. */
|
||||||
|
@Test
|
||||||
|
fun `the VP8 and VP9 vendor MIMEs are reported without their vendor prefix`() {
|
||||||
|
assertShortName("vp8", MediaFormat.MIMETYPE_VIDEO_VP8)
|
||||||
|
assertShortName("vp9", MediaFormat.MIMETYPE_VIDEO_VP9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AV1 and MPEG-4 are reported by codec name rather than by MIME spelling`() {
|
||||||
|
assertShortName("av1", MediaFormat.MIMETYPE_VIDEO_AV1)
|
||||||
|
assertShortName("mpeg4", MediaFormat.MIMETYPE_VIDEO_MPEG4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an AAC track is reported as aac, not as the mp4a-latm its MIME says`() {
|
||||||
|
assertShortName("aac", MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uncompressed audio is reported as pcm, which is not what its MIME says either`() {
|
||||||
|
assertShortName("pcm", MediaFormat.MIMETYPE_AUDIO_RAW)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four arms produce exactly what the fallback would produce anyway.
|
||||||
|
*
|
||||||
|
* `video/hevc` -> `hevc`, `audio/opus` -> `opus`, `audio/flac` -> `flac`,
|
||||||
|
* `audio/vorbis` -> `vorbis`: for these the `when` arm and `substringAfter('/')` agree, so
|
||||||
|
* deleting the arm changes no observable behaviour and no test can catch it. That is a
|
||||||
|
* property of the code rather than a gap here, and it is reported as such rather than dressed
|
||||||
|
* up as coverage. The assertions still earn their place — they pin the promise the router is
|
||||||
|
* given (`hevc`, whatever the MIME happens to spell) against a later edit that changes the
|
||||||
|
* mapping rather than deleting it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the arms whose MIME subtype already is the short name still map to it`() {
|
||||||
|
assertShortName("hevc", MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||||
|
assertShortName("opus", MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||||
|
assertShortName("flac", MediaFormat.MIMETYPE_AUDIO_FLAC)
|
||||||
|
assertShortName("vorbis", MediaFormat.MIMETYPE_AUDIO_VORBIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fallback, which is what makes an unlisted codec describable at all.
|
||||||
|
*
|
||||||
|
* These are real `MediaFormat` MIMEs with no arm of their own. Dropping the subtype is the
|
||||||
|
* right guess far more often than reporting the whole MIME would be — FFprobe calls the first
|
||||||
|
* of these `ac3` too.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a MIME with no arm of its own falls back to its subtype`() {
|
||||||
|
assertShortName("ac3", MediaFormat.MIMETYPE_AUDIO_AC3)
|
||||||
|
assertShortName("mpeg2", MediaFormat.MIMETYPE_VIDEO_MPEG2)
|
||||||
|
assertShortName("dolby-vision", MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The surprising half of `substringAfter`'s contract, pinned deliberately.
|
||||||
|
*
|
||||||
|
* With no `/` in the string it returns the whole input rather than the empty string. Today's
|
||||||
|
* callers gate on a `video/` or `audio/` prefix so they cannot reach this, but "report what
|
||||||
|
* you were given" rather than "report nothing" is what would keep a malformed MIME visible on
|
||||||
|
* the card instead of blank.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a MIME with no subtype separator is reported unchanged`() {
|
||||||
|
assertShortName("weird", "weird")
|
||||||
|
assertShortName("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertShortName(expected: String, mime: String) =
|
||||||
|
assertEquals("shortName(\"$mime\")", expected, MediaProbe.shortName(mime))
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package org.libremediaconverter.convert
|
||||||
|
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading Int track properties out of a `MediaFormat`, which is a heterogeneous map.
|
||||||
|
*
|
||||||
|
* [MediaProbe.intOr] guards two different failures with one expression, and only one of them is
|
||||||
|
* obvious. A key the format does not carry is the easy half. The other is a key it *does* carry
|
||||||
|
* with a value of another type: `getInteger` casts rather than coerces, so a frame rate stored as
|
||||||
|
* a Float answers with a `ClassCastException`. `probeForConcat` reads `KEY_FRAME_RATE`, which the
|
||||||
|
* platform accepts either way, and its `catch` sits outside the track loop — so without the
|
||||||
|
* `runCatching` one oddly-typed field would discard the codec and dimensions already read from
|
||||||
|
* that file and the join would re-encode for no reason.
|
||||||
|
*
|
||||||
|
* Robolectric rather than a plain JVM test, unlike the two sibling `MediaProbe` helper tests: this
|
||||||
|
* one needs a real `MediaFormat` instance, not just its compile-time String constants.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class MediaProbeTrackFieldsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a property the format carries as an Int is read`() {
|
||||||
|
val format = videoFormat()
|
||||||
|
|
||||||
|
assertEquals(1920, with(MediaProbe) { format.intOr(MediaFormat.KEY_WIDTH) })
|
||||||
|
assertEquals(1080, with(MediaProbe) { format.intOr(MediaFormat.KEY_HEIGHT) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A track that simply does not say. `MediaExtractor` omits `KEY_FRAME_RATE` for plenty of real
|
||||||
|
* files, and 0 is what `ConcatPlanner` reads as "cannot prove a match".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a key the format does not carry gives the fallback`() {
|
||||||
|
val format = videoFormat()
|
||||||
|
|
||||||
|
assertEquals(0, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE) })
|
||||||
|
assertEquals(-1, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE, -1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The premise of the `runCatching`, pinned against the platform rather than assumed.
|
||||||
|
*
|
||||||
|
* If `getInteger` coerced a Float instead of throwing, the guard below would be testing
|
||||||
|
* nothing at all — so the throw is asserted directly first.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `getInteger refuses a Float rather than coercing it`() {
|
||||||
|
val format = videoFormat()
|
||||||
|
format.setFloat(MediaFormat.KEY_FRAME_RATE, NON_INTEGRAL_FRAME_RATE)
|
||||||
|
|
||||||
|
val thrown = runCatching { format.getInteger(MediaFormat.KEY_FRAME_RATE) }.exceptionOrNull()
|
||||||
|
|
||||||
|
assertTrue("expected getInteger to refuse a Float, got $thrown", thrown is ClassCastException)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** And that refusal is answered with the fallback, not passed on to the caller. */
|
||||||
|
@Test
|
||||||
|
fun `a frame rate the format carries as a Float gives the fallback rather than throwing`() {
|
||||||
|
val format = videoFormat()
|
||||||
|
format.setFloat(MediaFormat.KEY_FRAME_RATE, NON_INTEGRAL_FRAME_RATE)
|
||||||
|
|
||||||
|
assertEquals(0, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE) })
|
||||||
|
assertEquals(-1, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE, -1) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun videoFormat(): MediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1920, 1080)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** NTSC's 30000/1001, the frame rate that cannot be stored as an Int in the first place. */
|
||||||
|
const val NON_INTEGRAL_FRAME_RATE = 29.97f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,10 @@ import java.util.UUID
|
|||||||
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
|
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
|
||||||
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
|
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
|
||||||
*
|
*
|
||||||
* The instrumented suite cannot run on the development host, so this is the only place the
|
* The instrumented suite could also catch the "Start over leaks a full-size copy" defect --
|
||||||
* "Start over leaks a full-size copy" defect can be caught before CI.
|
* it runs on this host for API 33-36 (`tools/local-emulator/run-e2e.sh`) and on CI for
|
||||||
|
* 33-37. Here rather than there because a real `cacheDir` is all the defect needs, and
|
||||||
|
* finding it costs an emulator boot there and a few seconds here.
|
||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class OutputPublisherStagingTest {
|
class OutputPublisherStagingTest {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package org.libremediaconverter.join
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.convert.InputFile
|
import org.libremediaconverter.convert.InputFile
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.ui.TestTags
|
import org.libremediaconverter.ui.TestTags
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
class JoinLeafTagsTest {
|
class JoinLeafTagsTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
private fun input(displayName: String) = InputFile(
|
private fun input(displayName: String) = InputFile(
|
||||||
uri = Uri.parse("content://test/$displayName"),
|
uri = Uri.parse("content://test/$displayName"),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.libremediaconverter.join
|
package org.libremediaconverter.join
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
@@ -8,7 +9,6 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.ConcatStrategy
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
import org.libremediaconverter.ui.TestTags
|
import org.libremediaconverter.ui.TestTags
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
@@ -35,9 +35,8 @@ import java.io.File
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class JoinScreenContentTest {
|
class JoinScreenContentTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||||
private val savedAs = mutableListOf<String>()
|
private val savedAs = mutableListOf<String>()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.semantics.getOrNull
|
|||||||
import androidx.compose.ui.test.SemanticsMatcher
|
import androidx.compose.ui.test.SemanticsMatcher
|
||||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
@@ -17,7 +18,6 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.convert.InputFile
|
import org.libremediaconverter.convert.InputFile
|
||||||
import org.libremediaconverter.createDrainedComposeRule
|
|
||||||
import org.libremediaconverter.model.ConcatStrategy
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
import org.libremediaconverter.ui.TestTags
|
import org.libremediaconverter.ui.TestTags
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
@@ -60,9 +60,8 @@ import java.io.File
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class JoinStateAffordancesTest {
|
class JoinStateAffordancesTest {
|
||||||
|
|
||||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createDrainedComposeRule()
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
|
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
|
||||||
private val events = mutableListOf<String>()
|
private val events = mutableListOf<String>()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.libremediaconverter.model
|
package org.libremediaconverter.model
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -10,6 +11,16 @@ import org.junit.Test
|
|||||||
* Three vocabularies meet: `MediaExtractor` MIME types, FFprobe `codec_name` strings, and the
|
* Three vocabularies meet: `MediaExtractor` MIME types, FFprobe `codec_name` strings, and the
|
||||||
* enums. Stream copy depends on the round trip, so a missing alias here shows up as "we could not
|
* enums. Stream copy depends on the round trip, so a missing alias here shows up as "we could not
|
||||||
* identify the source codec" and silently costs the user a re-encode.
|
* identify the source codec" and silently costs the user a re-encode.
|
||||||
|
*
|
||||||
|
* Also bites on #74: `describeVideo` and `describeAudio` are one function apiece over one
|
||||||
|
* vocabulary and had stopped matching. Only the video side special-cased
|
||||||
|
* [InputProbe.UNPARSEABLE]; the audio side fell through to the raw name, and that sentinel opens
|
||||||
|
* with a NUL, so the source-info card would have rendered a control character. The arms are shared
|
||||||
|
* now, and the tests below assert both sides so the symmetric bug cannot reappear on the other one.
|
||||||
|
*
|
||||||
|
* The tables these read are cross-checked against the device capability check by
|
||||||
|
* `CodecVocabularyTest` (#87). Deliberately not repeated here: this file is what each name means,
|
||||||
|
* that one is whether the app's two copies of the vocabulary still agree.
|
||||||
*/
|
*/
|
||||||
class CodecNamesTest {
|
class CodecNamesTest {
|
||||||
|
|
||||||
@@ -48,4 +59,52 @@ class CodecNamesTest {
|
|||||||
// An unrecognised but real codec name is more useful shown than hidden.
|
// An unrecognised but real codec name is more useful shown than hidden.
|
||||||
assertEquals("cinepak", CodecNames.describeVideo("cinepak"))
|
assertEquals("cinepak", CodecNames.describeVideo("cinepak"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The audio row of the same card, which had none of the above. */
|
||||||
|
@Test
|
||||||
|
fun `audio descriptions degrade exactly the way video ones do`() {
|
||||||
|
assertEquals("AAC", CodecNames.describeAudio("mp4a"))
|
||||||
|
assertEquals("Unknown", CodecNames.describeAudio(null))
|
||||||
|
assertEquals("Unrecognised", CodecNames.describeAudio(InputProbe.UNPARSEABLE))
|
||||||
|
assertEquals("qdm2", CodecNames.describeAudio("qdm2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #74's actual failure mode, stated as the thing the user would have seen.
|
||||||
|
*
|
||||||
|
* `InputProbe.UNPARSEABLE` is `"\u0000unparseable"`. Falling through to `?: name` does not
|
||||||
|
* mislabel the track, it puts U+0000 into a `Text`.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `no description can put a control character on the card`() {
|
||||||
|
listOf(CodecNames.describeAudio(InputProbe.UNPARSEABLE), CodecNames.describeVideo(InputProbe.UNPARSEABLE))
|
||||||
|
.forEach { assertFalse("$it leaks the sentinel", it.contains('\u0000')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every alias, pinned one at a time.
|
||||||
|
*
|
||||||
|
* The tables became maps so `CodecVocabularyTest` could enumerate them; this is what catches a
|
||||||
|
* key mistyped or a value pointing at the wrong enum while that rewrite happened.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `every name in the tables resolves to the codec it spells`() {
|
||||||
|
CodecNames.VIDEO_ALIASES.forEach { (name, codec) ->
|
||||||
|
assertEquals(name, codec, CodecNames.videoFromName(name))
|
||||||
|
}
|
||||||
|
CodecNames.AUDIO_ALIASES.forEach { (name, codec) ->
|
||||||
|
assertEquals(name, codec, CodecNames.audioFromName(name))
|
||||||
|
}
|
||||||
|
assertEquals(VideoCodec.H264, CodecNames.videoFromName("x264"))
|
||||||
|
assertEquals(VideoCodec.VP9, CodecNames.videoFromName("vp09"))
|
||||||
|
assertEquals(AudioCodec.MP3, CodecNames.audioFromName("mpga"))
|
||||||
|
assertEquals(AudioCodec.OPUS, CodecNames.audioFromName("opus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The audio lookup reads the sentinel the same way the video one does. */
|
||||||
|
@Test
|
||||||
|
fun `the unparseable sentinel resolves to nothing on the audio side too`() {
|
||||||
|
assertNull(CodecNames.audioFromName(InputProbe.UNPARSEABLE))
|
||||||
|
assertNull(CodecNames.audioFromName(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,8 +162,9 @@ androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomato
|
|||||||
# a TDD loop anyone here can execute.
|
# a TDD loop anyone here can execute.
|
||||||
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||||
|
|
||||||
# Only for its `runTest`, and only to drain the collector kotlinx-coroutines-test installs
|
# Only for its `runTest`, and only so the one test that deliberately lets a coroutine error
|
||||||
# process-wide. See EscapedCoroutineErrors.kt in the JVM test source set.
|
# escape owns the collector callback while it does -- otherwise the error is kept process-wide
|
||||||
|
# and rethrown at whichever `runTest` starts next. See ConversionViewModelProbeFailureTest.
|
||||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -236,10 +236,24 @@ ensure_avd() {
|
|||||||
else
|
else
|
||||||
if [ ! -d "$img_dir" ]; then
|
if [ ! -d "$img_dir" ]; then
|
||||||
echo " installing $pkg"
|
echo " installing $pkg"
|
||||||
yes | sdkmanager --install "$pkg" > /dev/null 2>&1 || {
|
# Read sdkmanager's own status, not the pipeline's. `yes` never ends, so the moment
|
||||||
|
# sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE with 141 -- and this
|
||||||
|
# script runs under `pipefail`, which takes the rightmost NON-ZERO status. A package
|
||||||
|
# that installed perfectly therefore reported "FAILED to install".
|
||||||
|
#
|
||||||
|
# Measured rather than reasoned: under `set -o pipefail`, `yes | true` exits 141 on
|
||||||
|
# every run, and `yes | sh -c 'exit 3'` exits 3 -- so the pipeline status cannot tell
|
||||||
|
# a clean install from a broken one, while ${PIPESTATUS[1]} reports 0 and 3.
|
||||||
|
#
|
||||||
|
# The `echo no | avdmanager` below is deliberately NOT changed. One line fits the pipe
|
||||||
|
# buffer, so echo has already exited before the close and there is no signal to
|
||||||
|
# receive; `echo no | true` measured 0 on every run. Only an unbounded producer is
|
||||||
|
# exposed to this.
|
||||||
|
yes | sdkmanager --install "$pkg" > /dev/null 2>&1
|
||||||
|
if [ "${PIPESTATUS[1]}" -ne 0 ]; then
|
||||||
echo " FAILED to install $pkg"
|
echo " FAILED to install $pkg"
|
||||||
return 1
|
return 1
|
||||||
}
|
fi
|
||||||
fi
|
fi
|
||||||
echo " creating AVD $avd from $pkg"
|
echo " creating AVD $avd from $pkg"
|
||||||
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
|
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
|
||||||
|
|||||||
Reference in New Issue
Block a user