Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3806641cb2 | ||
|
|
5f9498150c | ||
|
|
8d8703ab49 | ||
|
|
27b7654418 | ||
|
|
4a8e30099e | ||
|
|
e7d84cc69f | ||
|
|
a8b494b846 | ||
|
|
865a4a7c8e | ||
|
|
e856679395 | ||
|
|
49c483d877 | ||
|
|
1b220856ab | ||
|
|
40ae524388 | ||
|
|
58a29ab093 | ||
|
|
a1d79c212a | ||
|
|
bc66906dc3 | ||
|
|
d37c391c60 | ||
|
|
3fb25235c0 | ||
|
|
c0d99f7f86 | ||
|
|
95902a7889 | ||
|
|
1535b61a96 | ||
|
|
2efd1f9a0d | ||
|
|
240528facb | ||
|
|
f98e49942f | ||
|
|
25f162923c | ||
|
|
d0b9745220 | ||
|
|
b36d56c932 | ||
|
|
3f140fc2b1 | ||
|
|
b3208ef8c7 | ||
|
|
5a8aedf53d | ||
|
|
47a423413b |
@@ -13,6 +13,17 @@ on:
|
||||
# reference amounts to running whatever that repository contains tomorrow. This matters
|
||||
# more here than on pull requests: these jobs sign nothing today, but they do publish
|
||||
# the artifacts people install.
|
||||
# Declared here rather than inherited, for the reason status_check.yml gives for its own
|
||||
# block: the token's reach should be readable in the file that uses it, and a repository
|
||||
# default that widens later should not silently widen these jobs with it. The repository
|
||||
# default is `read` today, so this changes nothing about what runs -- it fixes what a
|
||||
# reader can know without leaving the file, and it is what CodeQL alert #1 asked for.
|
||||
#
|
||||
# The `release` job below overrides this with `contents: write`, which is how job-level
|
||||
# permissions work: this is a default, not a ceiling.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GRADLE_CACHE_PATHS: |
|
||||
~/.gradle/caches
|
||||
@@ -81,7 +92,11 @@ jobs:
|
||||
|
||||
- name: Verify the released artifacts
|
||||
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
|
||||
# fine on a test device and fail for users or at Play submission. Both are
|
||||
# cheap to check and expensive to discover later.
|
||||
|
||||
@@ -182,6 +182,23 @@ jobs:
|
||||
docker run --rm "$SHELLCHECK" --version
|
||||
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
|
||||
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
|
||||
# round trip should produce every list, not stop at the first.
|
||||
@@ -263,8 +280,13 @@ jobs:
|
||||
# docs/api-37-emulator-crash.md has the per-method measurements, and the
|
||||
# correction that produced them.
|
||||
#
|
||||
# api-level must be "37.0". A bare 37 is not an SDK package and fails
|
||||
# during setup, which cost a run to discover.
|
||||
# api-level must be a POINT release. A bare 37 is not an SDK package and
|
||||
# fails during setup, which cost a run to discover. `37.0` is the choice
|
||||
# here rather than the only option: `37.1` and `37.2-beta*` exist and
|
||||
# abort the same way, and api37-debug.yml's inputs document both, with
|
||||
# the wrinkle that above 37.0 they ship only as google_apis_ps16k.
|
||||
# docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side
|
||||
# by side, so pinning 37.0 is a decision, not a constraint.
|
||||
#
|
||||
# notAnnotation removes the three tests that do not pass on this image; they
|
||||
# run in the advisory job below, off the same marker so they cannot end up
|
||||
|
||||
@@ -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>`
|
||||
(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
|
||||
lives there. `actionlint` does cover them — it runs shellcheck over each `run:` — and reports one
|
||||
pre-existing `info` finding in `build.yml`. It is not wired in because every action here is
|
||||
pinned by SHA, and actionlint's usual installer is a `curl | bash` off a moving branch; doing it
|
||||
properly means pinning a container digest. Tracked separately rather than bolted on.
|
||||
**`actionlint` covers the half shellcheck cannot see** — the inline `run:` blocks, where a good
|
||||
deal of this repo's bash lives. It runs shellcheck over each `run:` plus its own checks on
|
||||
expression syntax, `needs:` references, matrix keys and action inputs. It sits in the same job,
|
||||
**pinned by digest** for the reason above and one of its own: its documented installer is a
|
||||
`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
|
||||
|
||||
|
||||
@@ -113,7 +113,14 @@ container × codec matrix — including combinations that cannot work, which it
|
||||
offers alternatives for rather than hiding.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
|
||||
plugins {
|
||||
@@ -206,6 +207,16 @@ detekt {
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
|
||||
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
|
||||
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
|
||||
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
|
||||
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
|
||||
// A guard that does not re-run when its subject changes is not a guard.
|
||||
inputs.file(rootProject.file(".github/workflows/build.yml"))
|
||||
.withPropertyName("releaseWorkflow")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
|
||||
@@ -36,8 +36,20 @@ import java.io.File
|
||||
* 1. that the hardware path is worth having a second engine for at all, and
|
||||
* 2. that x264's CRF is worth the GPL licence the app carries for it.
|
||||
*
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI. Populate with:
|
||||
* adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI — every green E2E
|
||||
* leg reports two skips, and these are they.
|
||||
*
|
||||
* The two files it looks for, by exact name:
|
||||
*
|
||||
* - [H264_SAMPLE] for [hardwareVersusSoftwareOnRealVideo]
|
||||
* - [AV1_SAMPLE] for [av1InputRoutesAccordingToDeviceDecodeSupport]
|
||||
*
|
||||
* **Where they go, and how, is on [samples] — read it before staging anything.** This used to
|
||||
* carry an `adb push` line naming the external files dir, which [samples] then explains cannot
|
||||
* work: a pushed file stays owned by the shell user and the app reads EACCES, surfacing as an
|
||||
* unparseable input rather than a permission error. The instruction and its own refutation sat
|
||||
* twelve lines apart. It is named in one place now rather than restated here, because restating
|
||||
* it is what let the two drift.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.junit4.v2.createAndroidComposeRule
|
||||
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.uiautomator.By
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.Configurator
|
||||
import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
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
|
||||
* `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
|
||||
*
|
||||
* Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to
|
||||
* `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the
|
||||
* fixture root entirely, and [pickingAFileThroughTheSystemPickerFillsInTheFileCard] fails on the
|
||||
* assertion that names it.
|
||||
* fixture root entirely, and both tests fail on the assertion that names it. **Re-run after the
|
||||
* #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
|
||||
* [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
|
||||
@@ -115,6 +244,10 @@ class SafPickerRoundTripTest {
|
||||
private val device: UiDevice =
|
||||
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. */
|
||||
private var rotated = false
|
||||
|
||||
@@ -208,25 +341,276 @@ class SafPickerRoundTripTest {
|
||||
* Everything between the first tap and the last belongs to `com.google.android.documentsui`,
|
||||
* which is why UiAutomator is here at all: Compose's matchers stop at this process's
|
||||
* composition and Espresso's at its view hierarchy, and the picker is neither.
|
||||
*
|
||||
* **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() {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
|
||||
// THIS is the line the MIME filter mutation fails on. DocumentsUI matches the requested
|
||||
// types against Root.COLUMN_MIME_TYPES and drops the roots that cannot answer, so a filter
|
||||
// the fixture root does not satisfy takes the root out of the picker altogether -- along
|
||||
// with "Images", "Audio", "Videos" and "Documents", measured on API 34.
|
||||
tapPickerNode(By.text(FixtureDocumentsProvider.ROOT_TITLE)) {
|
||||
// Which screen the picker opens on is its own business: it lands on Recent, where the
|
||||
// roots are a strip at the bottom, but a device with a populated Recent may need the
|
||||
// drawer. Looking in the second place widens where the root is searched for; it does
|
||||
// not weaken what has to be found, which is still this root.
|
||||
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
||||
var missing: BySelector? = null
|
||||
repeat(PICK_ATTEMPTS) { attempt ->
|
||||
requireAReadableScreen()
|
||||
openThePicker()
|
||||
missing = walkThePickerToTheFixture(
|
||||
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
|
||||
)
|
||||
if (missing == null) {
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
return
|
||||
}
|
||||
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)
|
||||
* ```
|
||||
*
|
||||
* So what is retried is *acquiring a handle to a node that has to be there anyway* — every
|
||||
* attempt still goes through [awaitPickerNode], which fails outright if the node is absent.
|
||||
* The MIME mutation's bite is untouched: a root that is not in the picker is not found on any
|
||||
* attempt, and the failure is still "the system picker never showed" rather than a stale one.
|
||||
* So what is retried is *acquiring a handle to a node that has to be there anyway*. **A node
|
||||
* that is simply not in this picker is reported rather than retried here** — it comes back as
|
||||
* `false`, and [pickTheFixture] answers it with a whole new picker, which is the only thing
|
||||
* 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
|
||||
repeat(TAP_ATTEMPTS) { attempt ->
|
||||
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
|
||||
// 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()
|
||||
try {
|
||||
node.click()
|
||||
return
|
||||
return true
|
||||
} catch (e: StaleObjectException) {
|
||||
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
|
||||
* null return from `findObject` is deliberately not an error there: it is the "already on the
|
||||
* right screen" case.
|
||||
*/
|
||||
private fun awaitPickerNode(selector: BySelector, ifAbsent: () -> Unit = {}) =
|
||||
device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)
|
||||
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
?: run {
|
||||
ifAbsent()
|
||||
requireNotNull(device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)) {
|
||||
"the system picker never showed $selector"
|
||||
}
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,9 +691,69 @@ class SafPickerRoundTripTest {
|
||||
const val PICKER_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. */
|
||||
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.
|
||||
*
|
||||
@@ -319,6 +764,19 @@ class SafPickerRoundTripTest {
|
||||
*/
|
||||
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. */
|
||||
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.libremediaconverter.ci
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That the release job still holds the one permission it needs to publish.
|
||||
*
|
||||
* `build.yml`'s `release` job declares `contents: write`, and nothing was checking it. Deleting
|
||||
* those two lines leaves actionlint clean and CodeQL silent — a *narrower* permission is not an
|
||||
* alert — and the job is `if: startsWith(github.ref, 'refs/tags/v')`, so no pull request and no
|
||||
* merge to `main` can exercise it. Measured: with the declaration removed, every gating check
|
||||
* still passes. The first thing that would notice is a release failing to publish, at the moment
|
||||
* someone is trying to cut one.
|
||||
*
|
||||
* The deletion also looks like tidying. A top-level `permissions: contents: read` now sits
|
||||
* directly above it, so a reader could reasonably take the job-level block for a duplicate. It is
|
||||
* an override, not a duplicate, and a comment saying so is not a check.
|
||||
*
|
||||
* `BackupExclusionsTest` is the precedent: a file that is configuration rather than code, load
|
||||
* bearing, and unguarded because nothing compiles it.
|
||||
*
|
||||
* **What this pins, and what it does not.** It asserts the declaration exists in the `release`
|
||||
* job's block. It cannot assert that a release actually publishes — that needs a tag push, which
|
||||
* is the thing no PR can do. So this is a tripwire against silent removal, not proof the release
|
||||
* path works.
|
||||
*/
|
||||
class ReleasePermissionTest {
|
||||
|
||||
@Test
|
||||
fun `the release job declares the write permission it needs to publish`() {
|
||||
val release = jobBlock("release")
|
||||
assertTrue(
|
||||
"build.yml's `release` job no longer declares `contents: write`. It is the only " +
|
||||
"permission that lets the job create a release, the top-level block above it is " +
|
||||
"`contents: read`, and nothing else in CI would catch this until a tag failed to " +
|
||||
"publish. If the release moved elsewhere, delete this test deliberately.",
|
||||
release.any { it.trimStart().startsWith("contents: write") },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines of one top-level job, from its ` <name>:` header to the next job at that indent.
|
||||
*
|
||||
* Line-based rather than parsed: the module has no YAML dependency, and adding one to read two
|
||||
* lines would be a worse trade than a scan that fails loudly when the shape changes.
|
||||
*/
|
||||
private fun jobBlock(name: String): List<String> {
|
||||
val lines = workflow.readLines()
|
||||
val start = lines.indexOfFirst { it == " $name:" }
|
||||
check(start >= 0) { "no ` $name:` job in ${workflow.path} — has the file been restructured?" }
|
||||
val rest = lines.drop(start + 1)
|
||||
val end = rest.indexOfFirst { it.matches(Regex("^ {2}[A-Za-z0-9_-]+:.*")) }
|
||||
return if (end < 0) rest else rest.take(end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
|
||||
* unit tests is the module, but that is a default rather than a promise.
|
||||
*/
|
||||
private val workflow: File
|
||||
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.map { File(it, ".github/workflows/build.yml") }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("could not find .github/workflows/build.yml above ${File(".").absolutePath}")
|
||||
}
|
||||
@@ -140,4 +140,34 @@ class CodecVocabularyTest {
|
||||
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
-1
@@ -140,10 +140,16 @@ class ConversionViewModelProbeFailureTest {
|
||||
private fun pickedProbe(): InputProbe? {
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(INPUT)
|
||||
// The predicate is the guard, and it is the only one needed. It requires `Ready`, so a
|
||||
// pick that ended in `Failed` never satisfies it and `awaitState` fails on its timeout
|
||||
// naming what it was waiting for -- "Ready with a probe" -- which says more than a
|
||||
// separate assertion could. A `ready as? ConversionState.Failed` check used to sit here
|
||||
// and was dead: `Ready` and `Failed` are sibling subtypes of one sealed interface, so
|
||||
// the cast was always null and the assertNull could never fire. Measured, not assumed --
|
||||
// flipping it to assertNotNull failed all three callers of this helper.
|
||||
val ready = awaitState(viewModel.state, "Ready with a probe") {
|
||||
it is ConversionState.Ready && it.input.probe != null
|
||||
}
|
||||
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
|
||||
return (ready as ConversionState.Ready).input.probe
|
||||
}
|
||||
|
||||
|
||||
@@ -236,10 +236,24 @@ ensure_avd() {
|
||||
else
|
||||
if [ ! -d "$img_dir" ]; then
|
||||
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"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
echo " creating AVD $avd from $pkg"
|
||||
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
|
||||
|
||||
Reference in New Issue
Block a user