# 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.