16 KiB
Android architecture decision
Accepted implementation contract for #88 in milestone 23, 06 - Implement an Android version of the software. Reviewed against the checkout's iPhone app, AutoFill extension, and shared Rust mobile APIs on 2026-10-08. The milestone goal is full native Android feature parity. This decision records ownership and release requirements; it does not claim that the Android implementation or device audit already exists.
Language and presentation
Use Kotlin, Gradle Kotlin DSL, one activity, Jetpack Compose, Material 3 and Material 3 Adaptive. Use Navigation 3: its stable release is 1.2.0. Screen ViewModels expose immutable StateFlow state and accept typed events. Compose observes with collectAsStateWithLifecycle. Hilt supplies dependencies; KSP runs its processors. Blocking UniFFI calls run on a bounded background coroutine dispatcher. Cancellation cancels the Rust operation as well as the coroutine; a late result must not restore a revoked lease or stale screen.
Rust owns repository layout, config, keys, OpenPGP, Git, conflicts, documents, generation, OTP, QR payloads, identities, external access permissions and secure-secret orchestration. Kotlin collects input, renders Rust state, and adapts OS mechanics. No app component launches a subprocess. No Kotlin feature reads repository files, parses entries/URIs or error labels, generates passwords or codes, or independently decides credential matches or authentication policy.
Use typed semantic icons/actions, stable IDs, generation/revision tokens, view-ready records and typed errors. #91 removes SF Symbol and Watch assumptions from shared phone contracts. Keep Swift compatibility through mechanical adapters. #92 generates Kotlin and Swift from one shared UniFFI surface rather than copying crates/apple conversions. A thin Android bridge may adapt platform callbacks; it must contain no password-store decisions or handwritten unsafe Rust. Generated/dependency FFI stays isolated from project-authored safe Rust.
Modules and allowed dependencies
| Module | Responsibility | Allowed project dependencies |
|---|---|---|
| android/app | Activity, composition root, navigation, manifest and service registration | features, core, platform |
| android/features | Related Compose workflows, screen ViewModels, accessibility | core only |
| android/core | Android-facing facade, immutable presentation contracts, coroutine scheduling, bridge mapping | rust-bridge |
| android/platform | Keystore, BiometricPrompt, clipboard, camera, paths and OS request translation | core contracts |
| android/rust-bridge | Generated UniFFI Kotlin and ABI native packaging | shared Rust bridge via build tasks |
| Shared Rust bridge | Mechanical records, lifetime/callback/error conversion | crates/storage |
| crates/storage | All domain behavior and trust-boundary validation | audited Rust dependencies |
Start with these five Gradle modules; split feature groups only when useful. Platform implementations are injected at app composition. Feature code cannot import generated UniFFI/JNA types or depend directly on platform/bridge modules. OS services delegate to core; exported service input is untrusted. Only Rust holds repository capabilities. Gradle dependency checks, source-boundary checks, and release manifest inspection are gates in #89/#94/#111, not substitutes for review. Generated bindings and native/build outputs are not checked in.
Support and build policy
Production package, namespace and application ID: de.rfc1437.ironstorage. Updates retain this ID and signing lineage. Tests use derived IDs; a development variant may not masquerade as the production signed installation.
Minimum SDK: 26 (Android 8, including Autofill). Compile/target SDK: 37 (Android 17) at this decision, following the SDK instructions. Recheck stable SDK and Play requirements at implementation and release. Never select preview APIs to satisfy a release policy. The installed android-37.0 SDK reports an empty codename and PreviewSdkInt=0 (revision 2); verify the same package metadata in CI before pinning the build, rather than relying on preview wording in older instructions.
Release ABIs: arm64-v8a, armeabi-v7a, x86_64, corresponding to aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android. An ABI may be removed only with a dependency audit and an amendment to this decision; emulator success alone proves no arm64 hardware coverage. All shipped native dependencies, including JNA, must pass ELF LOAD alignment, APK ZIP alignment and actual 16 KB runtime checks. Do not depend on compatibility mode. Android's page-size guidance requires auditing dependencies as well as the app library.
At #89 pin a mutually supported stable AGP/Gradle/Kotlin/KSP/Hilt set, JDK, SDK build tools and NDK, plus wrapper checksum. The current AGP 9.4 contract requires Gradle 9.6.0 and JDK 17 and supports API 37. This is a candidate build baseline, subject to actual artifact availability and plugin compatibility. The #89 scaffold pins AGP 9.4.1, Gradle 9.8.1, Kotlin 2.4.20, KSP 2.3.12, Hilt 2.60.1 and JDK 25 (JVM output 17); its catalog and build instructions live in android/README.md. These pins are verified by the scaffold build gates rather than inferred from minimum-version requirements. No dynamic versions, snapshots or automatic toolchain substitutions. #92 pins cargo-ndk, Rust targets/toolchain and the workspace UniFFI version and builds all ABIs from the locked Cargo graph. Retain separate native symbols and R8 mappings.
Support phones, tablets, foldables, resizing/multi-window, desktop windowing, portrait/landscape, system/light/dark themes, hardware keyboard, large fonts, TalkBack and process recreation. Preserve non-secret navigation/filter state; never serialize decrypted fields, passphrases, tokens, QR key payloads or a lease into SavedStateHandle, bundles, preferences, crash reports or test artifacts. Rotation/process death must not replay a mutation. In-flight mutations resolve through Rust revision/transaction state before a retry.
Apple Watch, watchOS and Watch Connectivity are excluded. Android Auto, TV, Wear OS and XR-specific experiences are outside the product; Wear is not a substitute for Watch. Do not show disabled Watch controls on Android.
Approved dependencies and review policy
These are approved purposes, not permission to add every library immediately. #89 pins only scaffold dependencies; later owners add their required artifacts. Exact versions live in one version catalog, Cargo.lock and reviewed toolchain pins. Keep Gradle dependency verification metadata and locks where supported; review checksums independently before accepting a new artifact. For each addition and upgrade, record exact coordinates/version, upstream release/maintenance check, SPDX license and bundled notices, transitives, security advisories and compatibility results. Approval of a library family does not approve every transitive version. #94 builds the resolved SBOM/license gate; #112 bundles attributions and available source obligations.
| Library/API | Single purpose | License review and maintenance source |
|---|---|---|
| Kotlin and Compose compiler plugin | Kotlin compilation | Apache-2.0, JetBrains license; check matching stable compiler/plugin releases |
| Gradle, AGP and Android Lint | Reproducible packaging and Android static checks | Apache-2.0 tool distribution notices; AGP compatibility/release notes |
| Compose BOM, Material 3, Adaptive, Activity Compose | Native controls/layout and activity hosting | Apache-2.0, AndroidX license; check each resolved artifact's stable AndroidX release |
| Navigation 3 | Typed adaptive navigation/back stack | AndroidX Apache-2.0; stable release source above |
| Lifecycle ViewModel/Runtime Compose | Lifecycle-aware screen state collection | AndroidX Apache-2.0; stable AndroidX release notes |
| kotlinx.coroutines and coroutines-test | Bounded async calls, Flow and deterministic scheduling tests | Apache-2.0, upstream; check Kotlin compatibility and tagged release |
| Hilt/Dagger | Dependency injection | Apache-2.0, upstream license; verify KSP/AGP support |
| KSP | Hilt code generation | Apache-2.0, upstream license; verify compiler/AGP support |
| UniFFI | Shared typed Swift/Kotlin bridge generation | MPL-2.0, Mozilla license; pin workspace tool/library together |
| JNA Android AAR | UniFFI native loading/calls | Choose Apache-2.0 under JNA dual license; verify Android ABI/16 KB binaries per version |
| cargo-ndk and NDK | ABI cross compilation/packaging | MIT OR Apache-2.0 for cargo-ndk; retain NDK component notices and review stable toolchain |
| AndroidX Biometric | OS authentication prompt | AndroidX Apache-2.0; choose stable API supporting CryptoObject and required auth policy |
| CameraX Preview/ImageAnalysis | Camera capture and frame delivery | AndroidX Apache-2.0; check stable artifacts and device support; Rust decodes/parses frames |
| AndroidX Credentials provider | Android 14+ password-provider integration | AndroidX Apache-2.0; verify provider support and stable artifact API |
| AndroidX Test and Compose UI Test | Instrumented/system and Compose behavior checks | AndroidX Apache-2.0; align with Compose BOM and test runner |
| JUnit | JVM unit tests | EPL-1.0 for JUnit 4; test-only, pin maintained 4.13.x patch |
| ktlint | Kotlin formatting/static style | MIT, upstream; pin CLI/rules together and review Kotlin compatibility |
| Macrobenchmark/Baseline Profiles | Measure startup and optimize measured bottlenecks | AndroidX Apache-2.0; add only with reproducible measurements in #105 |
| WorkManager | Explicitly approved durable noninteractive work | AndroidX Apache-2.0; add only when an operation survives process loss safely |
| Android framework | Keystore, KeyguardManager, AutofillService, clipboard, FLAG_SECURE, private paths, backup rules, connectivity and permissions | OS APIs; no extra bundled crypto/storage implementation; behavior audited on supported APIs/OEMs |
DataStore is allowed only for presentation-only preferences if necessary. Shared preferences use Rust configuration. Room, Retrofit/OkHttp, JGit, Bouncy Castle/OpenPGP, Kotlin OTP/password/parser libraries, ML Kit payload interpretation and a competing QR format are prohibited. Reuse existing Rust crypto/Git/QR code. Do not schedule interactive unlock, secret display or repository mutations as background WorkManager tasks.
Threat model and state lifetime
Protect the pass repository/key material, passphrases, HTTPS application tokens, OTP secrets/codes, decrypted fields, credential selections and identity grants. Untrusted inputs include remote endpoints/data, repository entries and policies, QR/images, OS provider callers, restored files, and concurrent/stale UI requests. Rust validates transports before any repository mutation or connection and uses repository remotes as authoritative. Android starts HTTPS-only with SSH disabled; SSH endpoints still parse to typed unsupported errors. Adding SSH requires an explicit audited support decision while preserving storage-owned transport policy.
The sandbox and Keystore protect against ordinary cross-app access and accidental backup, not a fully compromised/rooted device. Debuggable builds, logs, screenshots, recents thumbnails, clipboard history, accessibility, keyboard suggestions and crash telemetry are disclosure paths. #100 uses FLAG_SECURE, redaction, background relock, bounded clipboard ownership/expiry and memory cleanup; never claim complete Kotlin/JVM memory erasure. Clear platform references and revoke Rust generations.
Keystore holds non-exportable AES-GCM wrapping keys. Only sealed envelopes reach private storage. Rust decides secret type, identity, enrollment/invalidation, authentication policy, lease and recovery. Kotlin translates OS error facts. BiometricPrompt authorizes the required CryptoObject; device credential fallback must match the Rust policy/API level and must not silently weaken it. Changed biometrics, lock-screen removal, restored-without-key data, cancellation and a stale callback fail closed and require explicit recovery. Provider candidates are non-secret until authorized user/system selection plus a valid lease.
Paths, backup, update and recovery
#90 receives canonical filesDir, noBackupFilesDir and cacheDir from Android once; Rust creates scoped capabilities and owns all contents. Never infer HOME or use public/shared storage. Place repository, keys, config, TOTP discovery catalog, Keystore envelopes and transactional durable scratch under noBackupFilesDir. Disposable camera/image buffers may use cacheDir only when cleanup is safe; never use the cache for the only copy of a repository or credential.
Set allowBackup=false and explicit legacy backup and Android 12+ data-extraction rules excluding all sensitive roots from cloud backup and device transfer. Test OEM transfer behavior; no claim that one manifest flag defeats every OEM tool. No automatic key/repository transfer. The TOTP catalog contains only canonical path, ciphertext SHA-256 and is_totp; treat even that metadata as private.
An APK/Play update installs over the same ID/signing lineage and retains valid private state and Keystore aliases. Migrations are Rust-owned, versioned and atomic; migration failure retains prior recoverable data. Process death revokes in-memory authentication, preserves valid completed cache checkpoints and leaves atomic transactions recoverable. Low storage/read-only/corruption produce typed errors without deleting unrelated data. Uninstall/reset deletes app data and can make keys unrecoverable; reinstall/restore is not an update. Recovery needs the user's externally retained encrypted key material/passphrase and, when configured, a remote clone. Do not seed app data or commit credentials to prove recovery.
Verification and acceptance
The parity matrix assigns every current non-Watch mobile capability and every Android issue to a Rust owner and Android API. Its pending rows are implementation obligations, not accepted gaps. #111 must replace pending status with evidence and have no unexplained gap before #112 distribution.
Every issue runs the AGENTS.md Rust gates before commit. Android changes also run build/lint/unit/instrumented checks appropriate to the issue and computer-use validation on the existing emulator. Update by installation over the existing app, never uninstall/reset. If unlocking needs an unavailable user, label the issue untested and document exactly what could not be checked. The user clarified on 2026-10-08 that validation for every milestone issue is emulator-only; no physical devices are included in testing. Record emulator/API limitations explicitly and use untested for an unavailable required emulator check. This does not change production security behavior or claim Play approval. Apple changes require XcodeGen and affected simulator builds and UI checks; never re-enroll an already enrolled iPhone simulator in ordinary validation.
#94 owns reproducible CI and test boundaries, #105 accessibility/performance, #111 the complete architecture/security/parity audit, and #112 emulator install/update evidence and Play release preparation. No release approval is implied by this ADR.