Files
IronStorage/docs/android/ci.md
T
hugo b3853a75c8
Android ARM verification / rust (push) Failing after 7s
Android ARM verification / android (push) Failing after 7s
Establish native Linux ARM64 Android CI and verification gates (#94)
2026-10-09 00:25:06 +02:00

220 lines
13 KiB
Markdown

# Android verification and reproducible ARM builds
Issue #94 uses the existing Gitea `linux-arm64` runner. No macOS runner, physical
Android device, or x86 Android target is required. The release set is
`arm64-v8a` and `armeabi-v7a`; the minimum SDK is 26 and compile/target SDK is 37.
## CI jobs and local commands
`.gitea/workflows/android.yml` runs for main, Codex branches and pull requests.
The Rust job runs the repository formatting, warnings-as-errors workspace
check, Clippy and workspace tests with Rust 1.97.1. System build libraries are
installed inside the job container. Native Linux LLD and one build worker bound
workspace test-linking memory. The CI host test profile uses `line-tables-only`
debug information for backtrace file/line diagnostics and disables incremental
caches to bound disk use; every test remains enabled. This does not change the
Android release profile's required FULL debug-symbol archive.
Jobs check available disk space before installing tools: at least 16 GiB for
the host Rust gates and 24 GiB for Android, including SDK/dependencies, both
native variants and package assembly. Insufficient capacity fails immediately
with an explicit diagnostic. Each job schedules an `always()` cleanup step for
only this checkout's generated Cargo/Android build directories after its gates/artifact upload;
cleanup rejects symlinks and refuses to run outside CI. It does not prune
shared runner caches, SDK installations, other repositories or emulator data.
Hard runner termination can prevent cleanup; retained job data then needs
runner-host administration.
The Android job compiles the UniFFI host tool before starting Gradle. Host/debug
builds use line-table debug information and no incremental caches.
Release Rust libraries retain their explicit FULL debug information. These
profile/environment inputs participate in Gradle's native-task cache keys.
With `CI=true`, Cargo uses the owned `android/build/native-cargo` scratch
directory. Each native variant is compiled, audited and converted to bindings
before its Android-target compiler intermediates are removed. Copied libraries,
generated bindings and the host audit tool remain for the full Gradle verification
and assembly phase. This bounds peak disk use without removing tests or release
symbols; ordinary local development retains its Cargo intermediates.
The Gradle wrapper retries transient distribution-download failures three times
with bounded exponential backoff; its pinned distribution checksum remains required.
The Android job has a 150-minute timeout for the measured single-runner build
and uses one worker, a 1 GB Gradle heap and a 512 MB Kotlin compiler heap. It uses native ARM64 Java 25,
Rust 1.97.1, cargo-ndk 4.1.2, locked Gradle dependencies and strict SHA-256
verification. It builds both debug/release Rust ABIs, compiles Kotlin with
warnings as errors, runs JVM tests, KtLint and Android Lint, compiles Compose
instrumentation, and builds a debug APK, unsigned release APK and unsigned AAB.
There are no Play credentials or upload signing keys in this workflow. #112
owns protected distribution signing; Gitea's `environment` key alone is not a
protection mechanism.
From the repository root, with `JAVA_HOME` and `ANDROID_HOME` set:
```sh
cargo fmt --all -- --check
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
python3 .gitea/scripts/android_ci.py test
python3 .gitea/scripts/android_ci.py build
python3 .gitea/scripts/android_ci.py emulator emulator-5554
```
`CARGO` can specify the absolute rustup Cargo executable. `androidVerification`
is also directly available through `android/gradlew -p android`. The dependency
inventory task resolves each module under its own Gradle project lock and
requires configuration caching to be disabled. Ordinary build tasks retain
the existing incremental/configuration-cache behavior. Linker flags, selected
ABIs, SDK/NDK versions and release debug settings are explicit native task inputs.
## Native Linux ARM64 host tools
Google's [NDK r30 Linux download](https://developer.android.com/ndk/downloads)
contains x86_64 host binaries, independently of the target Android ABI. CI uses
an explicitly pinned native ARM64 community rebuild of that NDK, following the
approach described by [zongou/android-build](https://github.com/zongou/android-build).
`android/ci-toolchains.json` records exact URLs and reviewed SHA-256 hashes for:
- [HomuHomu833 NDK r30, aarch64-linux-gnu](https://github.com/HomuHomu833/android-ndk-custom/releases/tag/r30):
revision 30.0.16248370, Clang/LLD 21, Android compiler base r574158c. The inspected
compiler identifies its rebuild source as `a6285403ecfb895b7515518d40da02422ffffb20`.
- [lzhiyong native SDK tools 35.0.2](https://github.com/lzhiyong/android-sdk-tools/releases/tag/35.0.2):
statically linked ARM64 AAPT2 selected through Gradle's documented
`android.aapt2FromMavenOverride`. This host tool is separately pinned; it is
not represented as Google's build-tools 36 binary. A small argument adapter
splits AGP's `--resource-path-shortening-map=path` into the option and path
required by this AAPT2. Optimization remains enabled and exit codes propagate.
- Google's Java-based command-line tools 20.0, build 14742923. The newer 23.0
SDK manager delegates to a Linux x86_64 executable; the pinned Java manager
provisions the exact `platforms;android-37.0` and `build-tools;36.0.0` packages
without CPU emulation. APK Analyzer is also Java-based.
`prepare-linux` verifies every downloaded archive before extraction and runs
native compiler/linker/AAPT2 version probes. cargo-ndk 4.1.2 hardcodes the
`linux-x86_64` directory name on Linux, so a compatibility directory symlink
points at the native `linux-arm64` tools. It does not change executable
architecture. No binfmt registration, privileged container or emulator host
patch is used. Mac development retains its official installed SDK/NDK.
The community toolchain is an additional supply-chain input. Changing an asset,
compiler base, hash or host tool requires review and a clean build plus ARM
emulator validation. A changed release asset fails its committed checksum.
Retain the NDK's NOTICE files; toolchains are build inputs, not app runtime code.
## Artifact gates and reports
The Rust build tool checks module/source boundaries, subprocess references,
matching UniFFI metadata across ABIs and every packaged Rust/JNA ELF. APK native
libraries must be stored and aligned to 16 KB; ELF LOAD segments must also be
16 KB compatible. Generated bindings preserve contract/function checksums and
fail closed on a changed generator hardening template. Only the upstream eager
object-initialization function suppresses Kotlin's `UNUSED_EXPRESSION` false
positive; application warnings remain errors.
The packaged APK manifest is decoded and audited for the exact application ID,
backup and cleartext flags, reviewed permissions and exported launcher. The reviewed
AndroidX profile receiver must retain the privileged `DUMP` permission and exact
profile actions. Other components must explicitly remain unexported. The unused
Compose test-host manifest dependency is removed so it cannot export another
Activity in debug APKs. The packaged manifest's backup resource IDs must match
the APK's compiled resource table for the reviewed rules. APK Analyzer resolves their actual
paths, including resource-shortened release paths, and Rust audits those decoded
APK XML files. Backup rules must exclude
every credential/device-protected domain from cloud backup and device transfer;
instrumentation verifies the actual installed XML resources as well.
The ChromeOS x86 ABI recommendation is disabled because it conflicts with the
user's explicit ARM-only Android scope. No general Lint baseline is used.
Release Rust libraries must have no debug/symbol-table sections, and each FULL
`.so.dbg` file must contain debug information and the identical ELF build ID.
JNA's supplied symbol table must also match its stripped library's build ID.
The only packaged native libraries allowed are Rust, JNA and AndroidX graphics;
unreviewed names or duplicate entries fail.
AGP can include symbols for JNA's unselected ABIs; the published symbol ZIP
selects only both supported ARM directories, then the Rust gate verifies it
against the actual release APK. The artifact checksum manifest covers the
packages, symbol ZIP, R8 mapping, decoded manifests, test summaries and SBOM.
The CycloneDX inventory includes actual resolved Android application/test
artifacts, their configurations and SHA-256 values, plus the Rust normal feature
graph for both ARM targets with Cargo.lock registry hashes. It is a dependency
inventory, not a claim that every graph crate survives linker elimination.
Exact Android component versions and Rust license expressions require review
in `dependency-policy.json`. Runtime/test JGit, Bouncy Castle, Room, Retrofit,
OkHttp, PGPainless and ML Kit dependencies fail even transitively. Lint's own
build-tool dependencies are separate from the app's domain boundary. Gradle
strict verification additionally covers build/plugin/POM/module artifacts;
RustSec runs in the existing security-audit workflow.
Published JVM reports retain only hashed identifiers and pass/fail/error/skip
status. Assertion messages, parameter values, stack traces, properties, test
stdout/stderr and XML names are omitted. Failures remain failures and make the
gate fail; empty/missing/invalid reports are not accepted. Raw instrumentation
failures remain in `android/build/local-validation` with private permissions,
never in `ci-artifacts`. Do not upload emulator snapshots, app containers,
credential files, screenshot dumps or raw logs containing application data.
## Emulator matrix and deterministic fixtures
The existing Gitea runner has no supported native ARM Android Emulator package.
Google's current Linux emulator archives are x86_64 host binaries. Instrumented
validation therefore runs locally on the available ARM emulators; this limitation
is explicit rather than a silently skipped/passing CI emulator job. No additional
runner or physical device is provisioned.
Required local matrix for this issue:
| ARM emulator | Checks |
| --- | --- |
| API 26, 4 KB | Minimum-SDK native/JNI and Compose tests; installed backup resources; cold starts and native load |
| API 37, 4 KB | Current-SDK same tests; update over existing app data |
| API 37, strict 16 KB | Same tests with compatibility mode disabled; ELF/ZIP gate is not a substitute |
ARMv7 binaries are compiled and audited; an ARM64-only system image cannot run
an ARMv7-only APK. Record that runtime limit without requiring an x86 or physical
device. API 26 lacks `getconf`; the controller reads its kernel page-size facts
from `/proc/self/smaps`. ADB validates emulator identity and rejects physical
and unsupported-ABI targets. Installs use `-r`; force-stop/relaunch changes only
process state. Never uninstall or wipe existing test state.
Rust's shell fixtures cover all 25 tab/state combinations. Existing fake clocks
exercise lease expiry, background/foreground boundaries and activity refresh in
workspace tests. Rust secret-backend fixtures cover every typed protection/error
result and late completion. Kotlin's controlled coroutine fixture verifies that
cancelled work cannot replace newer state. Generated JNI tests cover secret
redaction, checksum/contract rejection, callback ownership/cancellation and
Keystore lockout. The private-runtime fixture creates a public-only password
store through Rust and reopens it, checking durable layout/tab state. Platform
Keystore tests from #93 remain available separately for real emulator biometrics;
they require synthetic enrollment and active-prompt ADB control, not a real
user's passphrase.
## Performance baseline
The controller records five process-cold Activity `TotalTime` samples and five
fresh-process native-load/first-Rust-fixture samples using monotonic Android
nanoseconds. Reports record SDK, ABI and actual page size. These are observations,
not flaky latency pass/fail thresholds. Never compare a debug emulator result to
a signed physical-device release measurement.
Initial #94 local debug baseline (five samples, medians):
| ARM64 image | Activity cold start | Native load and first Rust fixture |
| --- | ---: | ---: |
| API 26, 4 KB | 161 ms | 25.4 ms |
| API 37, 4 KB | 896 ms | 42.7 ms |
| API 37, strict 16 KB | 730 ms | 62.0 ms |
All three images passed ten JNI tests and two app/Compose tests covering all
25 Rust shell states and installed backup resources. The community-toolchain
Linux debug APK and both test APKs additionally passed those same suites on the
strict 16 KB image after re-signing with the existing local debug identity;
installation preserved the app data. Original Linux release packages remain
unsigned. Emulators ran concurrently with build work, so these observations
are a reproducible measurement method, not a calibrated performance budget.
#105 will add Macrobenchmark/Baseline Profiles once representative Android
screens exist. Its case definitions are: first page and scroll in 10,000 synthetic
entries; search update after each query edit; warm cached TOTP page with 100
synthetic seeds and an injected clock; editor open, field edits, reorder and save.
Use generated public fixtures, fixed queries/clocks, ten warm-up iterations and
30 measured iterations; record median/p95, allocations and device/API/toolchain.
Do not substitute the current shell for measurements of unimplemented features.