onionwire/android/README.md
Sirius DevOps 22b3a0062a
Some checks are pending
ci / test (pull_request) Waiting to run
docs: add PROTOCOL.md and retire the stale audit writeup
Wire format lives in docs/PROTOCOL.md so the handshake, invite, and
frames can be read without the Rust. Threat model points at it. The
in-house audit notes are a finding-status table, not a third-party
audit. SDK README no longer claims the legacy concat invite signature
is what we encode.
2026-09-11 13:15:51 -04:00

5.2 KiB

OnionWire for Android

Two artifacts out of one Gradle build:

Module Output What it is
:sdk sdk-release.aar The reusable library. UniFFI Kotlin bindings + libonionwire_sdk.so. Any Android app can depend on this.
:app app-release.apk / app-debug.apk A Jetpack Compose + Material 3 messenger that depends on :sdk and nothing else.

The Linux TUI in the repository root is a separate product with a separate Cargo workspace. Nothing here turns it into a WebView, and nothing here imports its TUI code. Same wire protocol: docs/PROTOCOL.md.

Requirements

  • JDK 17+ (built and tested on JDK 21)
  • Android SDK with platform 36 and build-tools 36.0.0
  • NDK 28.2.13676358 (NDK r28c) — also what Rust's Android link step uses
  • Rust with the aarch64-linux-android target and cargo-ndk
  • ANDROID_HOME set, or sdk.dir in android/local.properties (gitignored)

One-time setup:

sdkmanager --install "platform-tools" "platforms;android-36" \
                      "build-tools;36.0.0" "ndk;28.2.13676358"
rustup target add aarch64-linux-android x86_64-linux-android
cargo install cargo-ndk

Build

cd android
./gradlew :sdk:assembleRelease :app:assembleDebug     # or :app:assembleRelease

The Rust cross-compile and the UniFFI Kotlin bindings are wired into the Gradle tasks — there is no manual codegen step. :sdk:preBuild depends on them.

Emulator / x86_64 slice:

./gradlew :app:assembleDebug -Ponionwire.abis=arm64-v8a,x86_64

Or from the repo root, with checksums and an optional publish step:

scripts/build-android-local.sh                 # -> dist/onionwire-<v>-android-arm64-v8a.apk
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh

Decisions you should know about

minSdk = 26 (Android 8.0)

The floor is not NDK-imposed — Rust's aarch64-linux-android std and NDK 28 both happily target API 21. We take 26 deliberately:

  • it is the first API level where adaptive icons and notification channels are unconditional, so there is one icon path instead of two;
  • it keeps a single arm64-v8a APK inside Play's currently-supported range without carrying legacy ART/JIT workarounds for pre-O devices;
  • the crypto stack (ring, rustls, argon2, chacha20poly1305) is exercised on 4.4+ kernels here, which is what we actually test.

Devices cut: Android 7.1 and older. If you need those, the change is -Ponionwire.api=24 and a real device test — do not bump it silently.

16 KB page size

The NDK r28 link step emits max-page-size=16384 for 64-bit Android, which is what Play requires for new apps. Verify with:

readelf -l android/sdk/build/rustJniLibs/arm64-v8a/libonionwire_sdk.so | grep LOAD

Permissions

INTERNET only, declared at first launch. No camera (invites are pasted, not scanned), no contacts, no storage permission — the data directory is context.filesDir, which is already app-private. There is no FOREGROUND_SERVICE and no keep-alive notification in v1: the node lives only while the process does, and the UI says so. Adding one is a product decision (battery, Play's FGS type declarations), not a silent fix.

Data at rest

context.filesDir/onionwire/ (mode 0700), holding onionwire.db and the Arti state dir. android:allowBackup="false" plus explicit data_extraction_rules exclusions, because an auto-backup of onionwire.db would hand the wrapped message key and the plaintext identity keys to Google Drive. Chat bodies are encrypted at rest; identity keys are not — that is the locked v1 posture.

Signing

Both debug and release are signed with the standard debug keystore (~/.android/debug.keystore, auto-created by AGP). This is what makes the release APK installable for sideloading. It is not a Play upload key; nothing here should be uploaded to Play. When a real upload key exists, put it in ~/.gradle/gradle.properties or CI secrets and reference it from signingConfigs — never in this repository.

Release builds are not minified

isMinifyEnabled = false for release. Keeps for the UniFFI/JNI surface already exist in sdk/consumer-rules.pro, but enabling R8 without a mapping-file upload path and an on-device test of the JNI surface is how you ship an UnsatisfiedLinkError nobody can read. Turn it on together with crash deobfuscation, not before.

CI

.forgejo/workflows/release.yml runs on an aarch64 Linux container on the Pi. It cannot build an APK. Android artifacts are built on a host with the SDK/NDK via scripts/build-android-local.sh, exactly like the x86_64 Linux binary is built via scripts/build-release-local.sh.

Layout

android/
  settings.gradle.kts
  build.gradle.kts            # plugin aliases only
  gradle/libs.versions.toml   # version catalog
  sdk/                        # -> AAR
    build.gradle.kts          # cargo-ndk + uniffi-bindgen wiring
    consumer-rules.pro
    src/main/AndroidManifest.xml
  app/                        # -> APK
    build.gradle.kts
    src/main/java/com/siriusdevops/onionwire/
      MainActivity.kt         # NavHost
      WireViewModel.kt        # owns the single Wire node
      ui/                     # Compose screens (M3)