Files
IronStorage/docs/android/ci.md
T
hugo dbc0210c55
Android ARM verification / rust (push) Failing after 42m34s
Android ARM verification / android (push) Canceled after 1m50s
Establish native Linux ARM64 Android CI and verification gates (#94)
2026-10-08 23:22:11 +02:00

13 KiB

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

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 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. android/ci-toolchains.json records exact URLs and reviewed SHA-256 hashes for:

  • HomuHomu833 NDK r30, aarch64-linux-gnu: 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: 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.