Commit Graph
22 Commits
Author SHA1 Message Date
JMR-dev 6cd17f25aa Pin shellcheck, because the unpinned one disagreed with the local run
The step added in the previous commit went red on its own PR, and the reason is the one
CLAUDE.md already gives for pinning ktlint, detekt and JaCoCo: "a new rule in a linter
makes files nobody touched stop passing, so CI goes red on a PR whose diff cannot explain
it." Here it was not even a new rule, just a different version of the same tool.

The runner's ambient shellcheck is 0.9.0. The container used to check locally was 0.11.0.
They disagree about how to report `on_signal`, which is installed as the INT and TERM trap
eleven lines below its declaration and so is never called by name:

  0.11.0  SC2329, once, on the function declaration -- "never invoked"
  0.9.0   SC2317, seven times, one per command in the body -- "appears to be unreachable"

The disable directive named SC2329, so 0.11.0 was silent and 0.9.0 reported seven findings.
Nothing about the script was wrong; the local check simply was not the check CI ran.

Two changes, because either alone still leaves a way to be surprised:

  - CI runs shellcheck from an image pinned by digest, so an upgrade is a line in this
    file that someone chose, not something that arrives on a Tuesday. The version is
    still printed, so a finding out of nowhere can be tied to that line.

  - The directive names SC2317 and SC2329 both, so a contributor whose distro ships 0.9.0
    gets the same answer locally as CI gives. Verified against both images: clean under
    0.9.0 and clean under 0.11.0.

CLAUDE.md now says to check with the pinned digest rather than with whatever is installed,
which is what would have caught this before the push.
2026-08-24 16:13:05 -05:00
JMR-dev baaaa934e0 Check the shell, and stop one-off issues falling off the board
Two gaps, both found the same way -- by something going wrong quietly.

`gh issue create` does not touch the project board. The issue is created, carries its
labels, and is invisible in the Kanban, which looks exactly like a ticket nobody filed.
On 2026-08-24 eight issues filed as a scripted batch all reached the board and one filed
as a one-off minutes later did not; it surfaced only because someone went looking for it.
A batch carries the board step inside its loop. One-offs are where it slips, so
tools/github/file-issue.sh is for one-offs.

Three things it does that a two-command shell snippet would not:

  - Resolves the project, Status field and option ids BY NAME, every run. Caching them
    is the obvious optimisation and the wrong one -- a renamed or reordered column would
    then have this writing a stale id into the board with no error anywhere.

  - Reads the item back. A mutation returning 200 says the request was accepted, not that
    the board shows what was asked for; the read-back is the only step that checks the
    claim this script exists to make. It is a GraphQL query because REST cannot do it --
    the `fields` array REST returns on a project item carries Title and nothing else, so
    a REST-only check reports every item's Status as unset.

  - Exits 3, loudly, with the issue number on a line of its own, when the issue was
    created but the board step failed. That exact combination is the failure being
    prevented; it must never be the quiet path.

Shell was the other language here with nothing checking it -- four scripts, one of them
the CI entry point. shellcheck now runs in the Static analysis job over
`git ls-files '*.sh'`, so a script added later is covered without editing the workflow,
and it runs at full severity with `info` included.

That raises two findings today and both are the tool being wrong, so both are answered
with a targeted `disable` carrying its reason rather than by lowering the severity:
run-e2e.sh's `on_signal` is reported as never invoked when it is installed as the INT and
TERM trap eleven lines below it, and the `$names` inside file-issue.sh's queries are
GraphQL variables that must not expand -- expanding them would send the shell's idea of
$owner to the API instead of declaring a parameter. A blanket --severity=warning would
have hidden both, and the next real finding with them.

The gradle step gains `if: !cancelled()` so a shellcheck failure cannot cost the
ktlint/detekt/lint lists -- the same reason that step already passes --continue.

Not covered, deliberately: shellcheck here reads .sh files, not the inline `run:` blocks
in the workflows, where a good deal of this repo's bash actually lives. actionlint does
read them, and finds one pre-existing info-level issue in build.yml. Wiring it in means
pinning a container digest, because every action here is pinned by SHA and actionlint's
usual installer is a curl-pipe-bash off a moving branch. Its own ticket, not this commit.
2026-08-24 16:03:14 -05:00
JMR-devandClaude Opus 5 225ecdd7e6 Split the API 37 leg so the part that works can gate
CI has never run the API level this app targets. The reason it did not was
never "API 37 is untestable" -- it was that two tests fail on the emulator
image, so one row would be permanently red or permanently allow-listed. This
splits that row instead of choosing between those two.

E2E API 37 gates. It runs 55 of the suite's 57 instrumented tests and must be
green. E2E API 37 Media3 hardware transcode runs the other two, reports, and
never blocks (continue-on-error). Both are driven off ONE marker,
@FailsOnEmulatorApi37: the gating job passes notAnnotation, the advisory job
passes annotation. Two lists would drift, and drift is silent in both
directions -- a test that ends up in neither job reads as green. Excluding by
class was not an option either: Media3EngineTest has four tests and two of
them pass here, so notClass would have thrown away real coverage.

The advisory job is named for what it runs, not for what we think is wrong.
Both its tests drive a full H.264 -> H.265 hardware transcode, which is what
distinguishes them from the two Media3EngineTest cases that pass -- those
never decode video. The goldfish-decoder theory sits in a comment inside the
job, where it can be corrected without renaming a check people have learned to
look for; docs/api-37-emulator-crash.md keeps measurement and inference apart.

The SystemUI disable moves into .github/scripts/e2e-run.sh behind
E2E_DISABLE_SYSTEM_UI, unset everywhere but the two API 37 jobs, so the other
four legs run byte-identical commands -- the same shape as
E2E_EXTRA_GRADLE_ARGS. It runs BEFORE the streamed logcat starts, deliberately:
`adb shell stop` would end that logcat and nothing restarts it, so a disable
placed after it would cost the leg its diagnostics for the part of the run that
matters. The body is probe v2 from api37-debug.yml -- the version measured 4/4
-- not the older one-round form: three rounds, waits for system_server to
actually be gone, verifies against `pm list packages -d`, and requires a 45 s
window with zero new aborts. The weaker probe reported success on a run that
then started SystemUI eight more times.

The caveat is written next to the row rather than left implicit: this leg runs
with SystemUI disabled and the framework restarted under it, a device
configuration no other leg and no Pixel run uses. Anything that touches system
UI must not trust it, and the Pixel check before each release is still the only
API 37 run with SystemUI intact.

docs/api-37-emulator-crash.md's "So should CI take API 37?" said no on three
reasons. Two were claims about CI that had never been measured; the section now
carries the eight runs that measured them, and the third reason is what the
split answers. docs/local-emulator.md and api37-debug.yml's header carried the
same "the matrix stops at 36" claim and are corrected with it.

CLAUDE.md is left alone deliberately -- its "CI's matrix therefore stops at API
36" clause is now false, and that correction is parked in the doc's existing
"Correction owed to CLAUDE.md" section, where two others are already waiting.

Making E2E API 37 an actually-required check is a repository-settings change
and must come after this is on main: adding a required context that does not
exist on the default branch blocks every PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:20:49 -05:00
JMR-devandClaude Opus 5 b3a705e3da Measure the API 36 control and record what CI cannot measure
Three additions to docs/api-37-emulator-crash.md, all from a CI investigation
run through .github/workflows/api37-debug.yml.

A third measured bullet: API 36 against API 37, back to back, same two tests,
same renderer, same SystemUI-disable path. 37.0 fails both on
c2.goldfish.h264.decoder (32660148155); 36 passes both in 4.603 s with the
same decoder in its logcat (32660152961). That falsifies "the stripped
configuration is what breaks these tests" -- a reading the other measurements
never addressed, because they all compare against a device that still had
SystemUI. It carries its two uncontrolled variables rather than dropping them:
API 36's framework restart happened with zero aborts logged where API 37's had
two, so a restart under an active abort loop is still uncontrolled; and the
images differ on the encoder side, which is a second reason "broken h264
decoder" is the wrong shape of claim.

The decoder-mechanism bullet is unchanged and still labelled inference. This
adds a measurement next to it; it does not retract anything.

The intact-SystemUI counterfactual is unmeasurable on a GitHub runner, and now
says why. Seven dispatches, zero verdicts, with a mechanism rather than bad
luck: while the framework crash-loops the guest cannot reliably create per-user
private directories, so an app installed during the loop has no cache dir and
the fixture copy dies in @Before before any codec exists. googlesdksetup and
nexuslauncher hit the same thing. The result XML masks it behind an
UninitializedPropertyAccessException in tearDown, which reads as a defect in
this repository and is not one.

Abort cadence corrected. "Roughly every 20 s" was the watchdog's sampling
interval, not the cadence: measured gaps are 20-90 s, median 60-70 s, three to
five per run, with sys.boot_completed held at 1 throughout. The wrong figure
lived in api37-debug.yml's own comments, so that line is corrected too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:20:40 -05:00
JMR-devandClaude Opus 5 acc71bcaee Verify the SystemUI disable instead of trusting what pm reported
Four dispatches of one configuration -- API 37.0, swiftshader_indirect,
SystemUI disabled -- came back three green and one not, and the odd one out
was not a different failure so much as the same run without the fix applied.
In 32646029143 `pm disable-user` reported `new state: disabled-user` and
SystemUI then started eight more times:

  14:41:24 ActivityManager: Start proc 6412:com.android.systemui ... GradientColorWallpaper
  14:45:05 ActivityManager: Start proc 17299:com.android.systemui ... GradientColorWallpaper

with ten more RegionSampling aborts and a surfaceflinger pid that never sat
still (489, 1570, 3524, 4396, 6038, 7987, 9732, 11520, 13208, 15048). The
framework is being SIGKILLed every twenty seconds while this runs, so a
package-state change can go down with the system_server that accepted it.

Two things were wrong, and the second is why the first went unnoticed:

  - one disable attempt was treated as sufficient
  - the wait after `adb shell stop` was not a wait. It asked `service check`
    0.3 s later and got `found` from the system_server that was still on its
    way out, so it never waited for anything. Both the good and the bad run
    printed `services back after 5 s`, which is how a broken fix looked
    identical to a working one.

Now: up to three rounds of disable -> take the framework down and confirm
system_server is actually gone -> bring it back -> verify the package is in
`pm list packages -d` -> require a 45 s window with zero new aborts. Nothing
is believed because a command said so.

Also adds measure_baseline, default true. The 45 s pre-measurement is what
makes the rate comparable with the local figures, but it is 45 s of
crash-looping before the disable has to land, which is a worse starting
point than a real leg would have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:48:53 -05:00
JMR-devandClaude Opus 5 93398c4616 Resolve adb by path in the API 37 watchdog
The first three dispatches came back with every watchdog sample reading
`boot=? surfaceflinger=none zygote64=none dma_aborts=0`, on runs where the
device demonstrably booted and the action's own adb was working two steps
away. The watchdog was not measuring anything.

The emulator action puts platform-tools on PATH with core.addPath, which
writes GITHUB_PATH and therefore only affects LATER steps. The watchdog is
started before the action -- that is the whole point of it -- so it inherits
the runner's own PATH, where a bare `adb` is not necessarily anything. Every
call failed into `2>/dev/null` and the sampler dutifully recorded the silence
as zero.

It now resolves adb by path, preferring ANDROID_HOME, re-resolving on every
iteration in case platform-tools arrives later, and echoing the path it
settled on. The launch step prints ANDROID_HOME and `command -v adb` for the
same reason: a repeat of this failure should be one line to spot, not three
runs of quiet zeros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:27:57 -05:00
JMR-devandClaude Opus 5 8fdad6e20b Add a dispatch-only workflow for the API 37 CI question
status_check.yml stops its E2E matrix at 36 and says the android-37.0 image is
why. That is established locally under -gpu host and under ANGLE, and it is not
established for CI: runners use -gpu swiftshader_indirect, and the one local
measurement of that mode was void for a local reason -- Fedora denies execheap
to SwiftShader's JIT, so the emulator died before the guest mattered. What CI
does at API 37 has therefore never actually been measured.

This is that E2E job with the matrix replaced by workflow_dispatch inputs, so a
hypothesis costs a dispatch rather than a commit: renderer, API level, image
target, channel, SystemUI disable, boot timeout, whether the suite runs at all,
and free-form emulator and Gradle arguments. It triggers on nothing else and
gates nothing.

It calls .github/scripts/e2e-run.sh rather than forking it, and pins the same
disk-size, ram-size, action SHAs and KVM setup as the job it copies, so a run
here measures the renderer and not a different device.

The watchdog is load-bearing rather than decorative. The emulator action calls
killEmulator() from its own catch block, so a run whose emulator never boots is
torn down before any script: line executes and leaves nothing behind -- which is
the exact failure shape API 37 is suspected of. It starts before the action,
samples sys.boot_completed, the surfaceflinger and zygote pids and the
hasReadColorBufferDma abort count every 20 s, and keeps a rolling copy of the
crash buffer so the last read survives the teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:15:08 -05:00
JMR-devandClaude Opus 5 22c7914395 Find out why the emulators segfault, and make them run
CLAUDE.md has said "Emulators segfault on this host -- qemu dies on every AVD"
since the E2E matrix landed, and the PR that introduced it called the failure
"exit 139 across three AVDs and both GPU backends, environmental". That is
accurate about the symptom and wrong about the cause, and the cost of being
wrong was the whole instrumented suite being unrunnable here.

SwiftShader's Reactor JIT writes generated GLES shader code onto the heap and
mprotects it executable. Fedora's SELinux policy denies that -- execheap is not
granted to unconfined_t and selinuxuser_execheap is off -- so the mprotect
fails and the emulator takes SIGSEGV the moment it calls the routine it just
generated. The AVC denial and the core are the same event, one second apart.

The predictor is mechanical and held 7 for 7 across every -gpu mode: a run
crashes if and only if it dlopens gles_swiftshader/libGLESv2.so. host,
angle_indirect and swangle_indirect boot. auto, off, guest and
swiftshader_indirect crash -- and auto is the default, which is why the failure
looked universal rather than renderer-specific.

tools/local-emulator/run-e2e.sh picks a renderer that works and refuses the
ones that do not. It reuses .github/scripts/e2e-run.sh rather than forking it,
so the local and CI diagnostics cannot drift; the one change there adds an
optional E2E_EXTRA_GRADLE_ARGS that is unset in CI, so CI runs byte-identical
commands.

The API 33-36 sweep has now been run and is written down. All four levels are green
on a local emulator and match the physical Pixel 10 Pro XL baseline exactly: 49 tests,
0 failures, 0 errors, 2 skipped, every level. Those counts come from the result XML,
not the UTP console counter, which double-counts skips and reported "Finished 51 tests"
on all four. No boot log dlopens SwiftShader GLES and the sweep window holds no AVC
denial and no qemu core -- which is confirmation of the mode matrix's first row rather
than new coverage, since every one of these runs is -gpu host. The table is still seven
modes measured once each.

Two things the sweep surfaced that the doc now records: pre-build before sweeping, or a
fresh checkout spends API 33's 20-minute wrapper budget compiling and wedges before a
test runs; and the device pinning is untested by this run, because the Pixel dropped off
USB five seconds before it started.

Still offered for review rather than applied: the CLAUDE.md correction the doc drafts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:02:46 -05:00
JMR-devandClaude Opus 5 39e0900928 Adapt LibreMail's emulator instrumentation for the E2E matrix
The E2E legs could fail with almost nothing to show for it. The previous handler
was a single line of semicolons printing meminfo and 60 lines of crash logcat,
and it only ran when gradle RETURNED non-zero -- a hang left nothing at all, and
`adb logcat -d` at the end only holds whatever survived in the ring buffer, which
a chatty run evicts.

The two failure shapes want different evidence, so they are handled separately:

  FAILED  -- gradle returned non-zero. The test reports already say which test and
             why, so this captures the surrounding state: guest memory and
             storage, whether the app even installed, native crashes, and the
             runner's own kvm/memory/disk.

  WEDGED  -- gradle never returned and the wrapper timeout killed it. There are no
             reports, so the evidence has to come off the live device: which test
             was in flight per the TestRunner logcat, whether the binder services
             are published, and SIGQUIT thread dumps of both processes. That last
             one is the point -- ART writes full stacks to logcat and /data/anr,
             which is what separates a deadlocked test from a stuck MediaCodec
             from a device that stopped answering. dumpsys media.player is in
             there because both engines transcode through MediaCodec, so a hung
             conversion shows up in it.

Logcat is now streamed to a file from the start of the step and uploaded whichever
way the leg goes, since the leg worth reading is usually the one that went red once
and green on re-run -- by which time the emulator is gone.

It is a script rather than inline YAML because it has to be. The action splits its
`script` input on newlines and runs each line as its own `sh -c`, so functions and
`if` blocks cannot survive there; that constraint is what produced the one-line
handler in the first place. One line calls the script now.

The wrapper timeout is 1200s against measured ~5-minute healthy legs, so it cannot
trip on a slow-but-working run, and sits far enough under the 60-minute cap to
leave room for the capture. It wraps only the foreground gradle client, never the
emulator the action owns, so it cannot hang the leg itself.

Not adopted from LibreMail: the hand-provisioned AVD boot, its SDK-integrity
installer and its focus gate. Those answer failures this repo has not had, and
replacing a boot path that works to fix problems we do not have is how a working
matrix breaks. Every emulator setting here -- ram-size, disk-size, the ABI filter,
swiftshader -- is untouched, along with the reasoning already written next to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:20:23 -05:00
JMR-devandClaude Opus 5 8727fccba1 Declare read-only permissions on the status-check workflow
CodeQL flagged the new static-analysis job for relying on the repository's
default GITHUB_TOKEN scope. Fair, and the repo already holds the opposite
opinion elsewhere: build.yml's release job spells out contents: write with a
comment saying the token's reach should be visible at the point of use.

Set at workflow level rather than on the one job that was flagged, because none
of these four write anything -- they read the code, build it and attach reports.
It also means a repository default that widens later cannot quietly widen these
jobs with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:09:41 -05:00
JMR-devandClaude Opus 5 fd6e5325cf Raise Kotlin to 2.4.10 so the bytecode can join the toolchain on Java 25
The previous commit settled for Java 24 everywhere because Kotlin 2.2.10 refuses
jvmTarget 25. That was the wrong constraint to accept, for two reasons.

The first is that 24 turned out to be unbuyable. Adoptium's repository carries
8, 11, 17, 21, 25 and 26 -- no 24, because it is a non-LTS that went end of life
in July 2025. The builds passed only because Gradle quietly auto-provisioned
24.0.2+12 through foojay, and .idea/misc.xml had been pointed at a temurin-24
that cannot be installed. A toolchain nobody can install is not pinned, it is
lucky.

The second is that the cap was never on the toolchain at all. Kotlin's ceiling
applies to jvmTarget -- the bytecode -- and the JDK running the build is a
separate axis. Conflating them is what steered this at 24 in the first place.

So the fix is the one the sibling repo already uses: put KGP on the root
buildscript classpath, where AGP's built-in Kotlin picks it up instead of the
2.2.10 it bundles. Kotlin 2.4.10 supports jvmTarget through 26, which lifts the
ceiling above the toolchain rather than under it. The Compose compiler plugin is
versioned in lockstep and reads the same catalog entry, so the two cannot drift,
and the module now applies both by id() because they come from the classpath
rather than from plugin resolution.

Checked rather than assumed, since a silent downgrade would look identical to
success: compiled classes report major version 69, which is Java 25. D8 dexes
them, R8 minifies them, and ktlint, detekt, lint, the unit tests and the
androidTest compile are all green on top.

25 is the right landing place independent of all this: it is LTS, it is in the
Adoptium repository, and temurin-25-jdk is already installed here -- so the
daemon runs on a real system JDK rather than a provisioned copy of an unpatched
one.

Two catalog plugin aliases went with it. android-application and kotlin-compose
now resolve from the buildscript classpath, so leaving aliases behind would have
left two entries that read like the source of truth and control nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:05:54 -05:00
JMR-devandClaude Opus 5 3841f58c74 Put the whole toolchain on Java 24, and take Gradle to 9.7.1
Java was scattered across four numbers that nobody had chosen together: the
daemon ran on 25 (pinned in gradle-daemon-jvm.properties), CI installed 17, the
IDE was set to 25, and the app compiled to 17 bytecode. Now all four say 24.

24 rather than 25 because 25 is not reachable end to end. Kotlin 2.2.10 refuses
jvmTarget 25 outright -- "available targets are 1.8 ... 23, 24" -- so the app's
bytecode could never have joined a 25 toolchain, and "everything on the same
version" would have stayed false in the one place it is hardest to notice. 24 is
the highest number all four can actually hold. Checked, not assumed: D8 dexes
Java 24 class files, and R8 full mode minifies them, so the shipped artifact
builds on this too.

Floating where floating is native:

  - java-version: '24' -- setup-java resolves the newest 24.x at run time.
  - toolchainVersion=24 -- Gradle reports it as "Compatible with Java 24, any
    vendor", and provisions whatever 24.x it finds or downloads.

The Gradle wrapper deliberately does NOT float, because it cannot: distributionUrl
names one archive and distributionSha256Sum is the checksum of that exact file.
That pairing is the wrapper's integrity check, and it is the same reasoning the
workflows already apply to action SHAs. Set via `./gradlew wrapper`, not by hand,
so the checksum matches the URL.

Dependencies were audited against Google Maven and Maven Central rather than
guessed at, and almost everything was already current: AGP, the Compose BOM,
core-ktx, activity, lifecycle, navigation, work, datastore, media3, room,
documentfile, annotation, espresso, androidx-junit, junit and ktlint are all at
their newest stable. Only two had moved -- detekt to 2.0.0-alpha.6 and JaCoCo to
0.8.15 -- and both are here.

Kotlin stays at 2.2.10 and that is now recorded as a verified fact rather than a
warning: the AGP 9.3.1 POM declares kotlin-gradle-plugin 2.2.10 at runtime scope,
which is what AGP's built-in Kotlin actually compiles with. Android lint suggests
2.4.10 and taking that suggestion breaks the build unless KGP is also forced onto
the root buildscript classpath. agp, kotlin and ksp move together or not at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:42:41 -05:00
JMR-devandClaude Opus 5 65a94b4ec1 Clear the 35 findings the new tools reported
detekt found 29 and Android lint 6, on a codebase neither had ever seen. Each
one was either fixed or relaxed with the reason written next to it; nothing was
suppressed to make the build quiet.

Fixed, because the tool was right:

  - ConversionDependencies constructs Media3Engine, which is @UnstableApi, and
    was not marked. Every other type here that touches Media3 propagates the
    marker rather than swallowing it with @OptIn, so this one does too. Lint
    was the only thing that had ever noticed.
  - MediaProbe converted microseconds to milliseconds with a bare 1000, twice,
    in a file that also handles a seconds-based duration from a different API.
    US_PER_MS and MS_PER_SECOND now say which is which -- that confusion is a
    real bug source in media code, not a style question.
  - Foreground service types compared SDK_INT against 34 and 35 as raw ints
    while the doc comment above spelled the version names out. VERSION_CODES
    says it in the code.
  - take(3) is a product decision about how many alternatives an error offers.
    It means nothing until it is named; MAX_SUGGESTIONS does.
  - setProgress(100, ...) is a percentage max, now PERCENT_MAX.

Relaxed, because the rule did not fit:

  - The model package is excluded from ReturnCount and CyclomaticComplexMethod
    ONLY. It is the decision layer: ConversionRouter.route scores 17 because
    the app can give 17 distinct answers to "which engine, and why", each with
    its own user-visible reason, and route's own comment records that their
    ORDER decides which message is shown. Counting those as complexity measures
    how many answers exist, not how hard the code is to follow. Everything else
    -- LongMethod, NestedBlockDepth, ComplexCondition -- still applies there.
  - A flat `when` used as a lookup table scores a point per entry, so
    MediaProbe's demuxer-name to Container map read as complexity 21 with no
    nesting and no state. ignoreSingleWhenExpression is the rule's own answer.
  - TooGenericExceptionCaught off. MediaProbe, ConversionWorker and ConcatWorker
    sit in front of native code that reports a malformed file as anything from
    IllegalArgumentException to a bare RuntimeException, undocumented.
    Enumerating that list means guessing, and a wrong guess crashes the app on a
    file it could have reported as unreadable. SwallowedException stays on, so
    these still have to log and handle.
  - SI thresholds in the byte formatter, via ignoreNumbers. Each literal sits on
    the line with the unit string it belongs to; BYTES_PER_MB would need a Long
    and a Double and say nothing the line does not.
  - allowedFunctionsPerObject, which the first pass simply missed.

Lint's three version-freshness nags are off. They do not describe this code --
they go red the day someone else publishes a release, which turns a PR red for
something its author cannot see in their diff, and they want the network at
lint time. Upgrades here are deliberate; Kotlin in particular is pinned to AGP's
bundled KGP and is not free to follow the newest release.

UsableSpace is informational rather than disabled, because it is a real finding
that this commit is choosing not to act on. hasSpaceFor reads File.usableSpace,
which ignores reclaimable cache, so the app can refuse a conversion it had room
for. StorageManager.getAllocatableBytes is the better answer, but it changes
when a job is rejected and can throw -- a behaviour change to a safety check,
which deserves its own commit and its own test rather than a drive-by here.
informational keeps it in every lint report instead of hiding it.

Also adds the CI gate and a CLAUDE.md. Coverage is reported and not gated: the
measured baseline is 31% of lines, which is exactly why LibreMail's 0.84 floor
was evidence about LibreMail and not a number to copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:21:15 -05:00
JMR-devandClaude Opus 5 4e6fe6b75a Drop API 37 from the E2E matrix and write down why
The android-37.0 emulator image crash-loops surfaceflinger inside its own
gralloc mapper: RegionSamplingThread calls GraphicBuffer::lock, which reaches
GoldfishMapper::readFromHost, which asserts that the host has not negotiated
ReadColorBufferDma. It has, so surfaceflinger aborts, restarts, and aborts
again. Nothing this app does can survive that, and it reproduces on a GitHub
runner under swiftshader_indirect and on a workstation under -gpu host alike.

There is no ATD image at android-37.0 to fall back to, and -feature -GLDMA is
accepted by the emulator but does not prevent the assertion.

Correcting the previous commit, which is already pushed so its message stands:
ram-size was not the cause of that failure. Setting it did move the job from
failing at install to failing during the test run, which is how the real
crash became visible, but at 2560M the guest had 1.5 GB free when it died.
The setting is kept because the emulator's own floor varies by API level --
2048M at 33, 2560M at 34 to 36 -- and pinning it makes the matrix uniform.

Also corrected: a comment claiming this could not be reproduced locally. It
can, and the local crash was the same one all along.

Dropped the dmesg probe. adb shell is not root, so klogctl is denied and it
only ever printed a permission error -- which a later reader would reasonably
misread as "no OOM kills".

docs/api-37-emulator-crash.md carries the evidence, the ruled-out fixes, the
reproduction, and how to file it upstream, so re-adding the row later starts
from what is already known rather than from scratch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:35:04 -05:00
JMR-devandClaude Opus 5 2fe1aa9f9c Stop the memory probe from being able to fail the run
The probe line runs before the tests and its exit status is grep's, so a run
where adb returned nothing would have exited 1 on the first line and reded the
job before Gradle started -- on all five levels, four of them currently green.
The action passes no ignoreReturnCode, so exec throws straight into
setFailed.

A diagnostic must never be the thing that turns a run red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:12:43 -05:00
JMR-devandClaude Opus 5 a79ff62b61 Give the API 37 emulator the RAM every other level already gets
API 37 was the only red job in the matrix, and the last two fixes each
corrected a real problem only to reveal the next one. This is the cause of
the third failure.

The emulator raises an undersized guest to 2560M on its own, but only for API
levels it recognises, and it does not recognise "37.0". Comparing the two CI
logs from the same emulator binary (37.1.11.0) shows the asymmetry directly:
the API 36 job logs "Increasing RAM size to 2560MB" and the API 37 job has no
such line. So four levels were quietly running at 2560M while API 37 ran at
the pixel_6 default of 1536M, lost system_server partway through installing
the 82 MB APK, and surfaced it as "Can't find service: package".

2560M is not a guess at a sufficient value -- it is the value the other four
levels already pass at, so this makes the matrix uniform rather than
introducing a fifth configuration.

Verified that the setting actually lands: the action appends hw.ramSize to a
config.ini that already has one from the profile, so the fix only works if the
later key wins. Appending a distinctive 3072M to an API 36 AVD produced
MemTotal 3047924 kB and suppressed the automatic bump, confirming it does.

This failure cannot be reproduced locally -- API 37 will not boot on a
workstation under either GPU mode, aborting surfaceflinger in the goldfish
mapper under -gpu host and segfaulting the emulator under swiftshader_indirect
-- so the job now reports guest memory on every run and dumps OOM kills and
native crashes on failure. That makes the next run conclusive either way
instead of producing another bare "Can't find service: package".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:10:38 -05:00
JMR-devandClaude Opus 5 97cddea973 Pin build.yml's actions and verify what it publishes
Brings the release workflow in line with the status check. It matters more
here, not less: these jobs publish the artifacts people install, so running
whatever a mutable tag points at on the day is a worse bargain than it is on
a pull request.

Every action is pinned to a commit with its release in a trailing comment,
and each hash was checked to resolve to the tag it claims. gradle/actions is
dropped for the same reason as before -- its v6 caching component is closed
source and carries separate terms -- with Gradle running through the
committed wrapper, which verifies its own distribution against
distributionSha256Sum.

The release job now checks what it is about to publish. A release that
shipped a single ABI, or that lost 16 KB alignment in a rebuild, installs
fine on a test device and then fails for users or at Play submission. Both
are cheap to assert and expensive to discover afterwards. It deliberately
does not pass -PabiFilters: that override exists so emulator jobs skip
libraries they cannot execute, and a released artifact must carry every ABI.

The contents permission is declared explicitly rather than inherited from the
repository default, so the token's reach is visible in the file that uses it.

The corresponding-source tarball now includes bin/README.md as PREBUILT.md,
so the GPL source drop carries the shipped binary's SHA-256 and configure
line rather than only the recipe that produces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:08:51 -05:00
JMR-devandClaude Opus 5 5b47764c70 Build only the emulator's own ABI for instrumented tests
API 37 got as far as running the suite this time and then failed to install:

  'package install-create ... -S 117817978'
  java.io.IOException: Requested internal only, but not enough space

The 117 MB debug APK did not fit on the emulator's data partition. The
DELETE_FAILED_INTERNAL_ERROR that followed was the same exhaustion, not a
second problem.

It surfaced on API 37 because that system image is the largest and leaves the
least free userdata. The margin was thin at every level, so this was never
really an API 37 bug -- the others were simply further from the edge and would
have caught up as the APK grew.

Roughly half that APK is arm64-v8a FFmpeg libraries that an x86_64 emulator
can never load. abiFilters is now overridable, so a test run builds only what
it will execute: 114 MB becomes 80 MB. Release builds ignore the property and
still ship both ABIs, so nothing about what gets distributed changes.

disk-size is raised to 8G for every level rather than only the one that
failed, since fixing just API 37 would leave the rest waiting their turn.

Verified locally on an API 36 emulator with an x86_64-only APK: 40
instrumented tests, 0 failures, and the installed APK contains lib/x86_64
only. 66 unit tests still pass, and a release build still carries both ABIs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:04:07 -05:00
JMR-devandClaude Opus 5 2d2687aa3c Pin CI actions to commit hashes and fix the API 37 emulator run
The API 37 job failed after 23 seconds, before an emulator ever started. The
CI log names the cause exactly:

  sdkmanager --install 'build-tools;37.0.0' platform-tools 'platforms;android-37'
  Warning: Failed to find package 'platforms;android-37'

There is no platforms;android-37. The release is published as android-37.0,
alongside 37.1 and the 37.2 betas. The earlier attempt to fix this with
system-image-api-level was aimed at the wrong package: that input only names
the system image, while the platform is installed from api-level directly.
Setting api-level to 37.0 resolves all three packages, and build-tools is a
hardcoded constant in the action rather than derived from api-level, so it is
unaffected. A separate label field keeps the job name reading "API 37".

Every action is now pinned to a commit hash with its release in a trailing
comment. A tag is mutable: the owner can repoint v4 at new code whenever they
like, so a tag reference amounts to running whatever that repository contains
tomorrow. Each hash was verified to resolve to the tag its comment claims,
because a wrong hash is worse than a tag -- it looks deliberate.

Versions moved a long way in the process: checkout v4 -> v7.0.1, setup-java
v4 -> v5.7.0, upload-artifact v4 -> v7.0.1.

gradle/actions is gone rather than upgraded. Its v6 release moved the caching
component closed-source and states that upgrading accepts Gradle's Terms of
Use for it. That has no bearing on the project's own licence -- a CI tool is
never combined with or distributed alongside the app, unlike the FFmpeg
libraries that make the APK GPL -- but it is a component in the build path
that cannot be audited or forked. Gradle now runs through the committed
wrapper, which verifies its own distribution against distributionSha256Sum,
and caching is a handful of lines of actions/cache.

The rest of the matrix passed on this run: API 33, 34, 35 and 36 all green,
along with the unit tests and the FFmpeg archive check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:48:09 -05:00
JMR-devandClaude Opus 5 128763e99c Commit the FFmpeg binary so test runs stop depending on a rebuild
CI rebuilt FFmpeg on every cold cache, which made results ambiguous: a red run
could mean the code was broken or that a forty-minute cross-compile of FFmpeg,
x264, x265 and SVT-AV1 had hiccuped. Those are not the same signal, and only
one of them is worth a developer's attention. The archive is now checked in
under bin/, so a failing run points at code.

It also removes roughly forty minutes from a cold run and lets a fresh clone
build without a container toolchain.

bin/README.md records provenance -- upstream tag, FFmpeg version, NDK, ABIs,
SHA-256 and the full configure line read back out of the shipped libavutil --
so the binary is auditable rather than opaque. The recipe in tools/ffmpeg
remains the authority: this archive is its output, and is also what satisfies
the GPL corresponding-source obligation.

The status check is now seven independent runners: one validating the archive,
one for the JVM tests, and one per API level from 33 to 37. The FFmpeg job
verifies rather than builds. It asserts native libraries are present for both
ABIs and that every one is 16 KB aligned, which is a Play requirement that is
easy to lose in a rebuild and expensive to discover at submission. Checking
for file existence alone would not do: a Git LFS pointer checked out without
LFS passes that and then surfaces as an obscure linker error much later.

It is a separate job rather than a step in each emulator run so a bad archive
reports once, clearly, instead of five confusing emulator failures.

build.yml no longer builds FFmpeg either, and keeps only its post-merge and
release duties.

Two costs, deliberately accepted. The repository goes from about 1 MB to
35 MB, and every future rebuild adds another 35 MB blob to history
permanently, so bin/README.md says to regenerate only when the FFmpeg version
or the configure flags actually change. And F-Droid's scanner flags checked-in
native libraries, so submitting there needs a scandelete entry for bin/ --
noted in bin/README.md, and nothing prevents a from-source build.

Verified against the relocated archive: 66 unit tests, and 40 instrumented
tests on an API 36 emulator, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:18:10 -05:00
JMR-devandClaude Opus 5 54d6e9c57b Add a pull-request status check across API 33-37
Runs the JVM tests and the instrumented suite on every pull request to main,
with one emulator job per supported API level.

The matrix is the whole range rather than a single level because the
foreground-service type differs across it -- none below 34, dataSync at 34,
mediaProcessing from 35 -- so testing one level would leave two thirds of that
branch unexercised. Running the range locally is what caught a test that had
baked in an assumption about the host's encoders.

API 37 needs its image level stated separately. It is published as
android-37.0, not android-37, so a plain integer resolves to nothing and the
image download silently finds no package.

FFmpeg is built once and shared. The AAR is not committed -- 35 MB of native
code, and F-Droid strips checked-in binaries -- but every job needs it, since
the app compiles against it and the instrumented tests exercise it for real.
Building it is a full cross-compile of FFmpeg, x264, x265 and SVT-AV1, so it
is cached on the contents of tools/ffmpeg, which is what actually determines
the output. The job also asserts the AAR carries native libraries for both
ABIs: a truncated or stub archive would otherwise pass a file-exists check and
send the matrix off to fail confusingly five times over.

fail-fast is off. Knowing whether a failure is universal or specific to one
API level is most of the diagnosis.

build.yml no longer runs on pull requests. It triggered on every PR with no
branch filter, so both workflows would have run, and its unit job falls back
to a stub AAR -- a weaker check that could mask a compile break the real one
would catch. It keeps its post-merge and release duties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:08:06 -05:00
JMR-devandClaude Opus 5 bb969ece64 Enable R8 and add release, store and F-Droid infrastructure
Turning on R8 immediately surfaced a latent runtime bug: the ffmpeg-kit-next
wrapper references com.arthenica.smartexception.java.Exceptions from
AbstractSession.fail() in eighteen places, but a local .aar carries no
transitive dependencies, so nothing was pulling it in. Debug builds tolerate
this through lazy class loading -- the class is only touched on an error
path -- so it would have shipped as a crash the first time an FFmpeg
conversion failed. Declared explicitly now.

Keep rules cover the JNI boundary. The native library resolves classes and
methods by name, which R8 cannot see, so without them the FFmpeg calls fail
with NoSuchMethodError in release builds only. Workers are kept too, since
WorkManager reconstructs them reflectively from a class name persisted in its
database, and a rename breaks jobs enqueued before the update.

Verified on the produced artifacts rather than assumed: all 22 native
libraries survive minification and every one is still 16 KB aligned inside
the APK. Release is 82 MB against 115 MB for debug; the AAB is 40 MB and Play
splits it per ABI.

The privacy policy lists every permission, including the three WorkManager
adds automatically (WAKE_LOCK, RECEIVE_BOOT_COMPLETED, ACCESS_NETWORK_STATE).
Checking the merged manifest showed those, and a policy that omitted them
would look dishonest to anyone who inspected the app. INTERNET is genuinely
absent, so "files stay on the device" is enforced by the OS rather than a
promise.

CI runs unit tests on every push and builds the FFmpeg AAR only for release
tags, since that is a full cross-compile. Releases attach the FFmpeg
corresponding source next to the APK: GPL-3.0 requires it, and FFmpeg's
instruction to host it "on the same webserver" cannot be satisfied by a Play
listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:45:40 -05:00