Adds the Android product alongside the existing Linux TUI, in one repo. SDK — crates/onionwire-sdk is a UniFFI facade over the very same `onionwire` crate the TUI runs on. Identity, invite, friend upsert, send/receive, rotate and wipe all delegate; no protocol is reimplemented, so an Android peer and a Linux peer interoperate. It is its own Cargo workspace because Arti's TLS backends are non-additive: the TUI keeps native-tls (OpenSSL), Android needs rustls + static-sqlite (no OpenSSL, no system libsqlite3 in the NDK). android/ — Gradle project. :sdk produces the AAR (Kotlin bindings generated at build time + libonionwire_sdk.so via cargo-ndk), :app is a Kotlin/Compose/M3 messenger depending on :sdk only. minSdk 26, targetSdk/compileSdk 36, INTERNET-only, data in filesDir, backups excluded. Root Cargo.toml grows `native-tls` (default) and `rustls` features so exactly one Arti TLS backend is selected per build graph. The default build is unchanged: same backend, ratatui still a normal dependency, src/tui.rs untouched. Also: Store::self_fingerprint/set_petname + Node wrappers (additive only), scripts/build-android-local.sh, README sections, .gitignore for local SDK paths.
4.6 KiB
onionwire-sdk (Android)
Kotlin-facing UniFFI bindings over the same Rust crate the OnionWire Linux TUI runs on. An Android peer and a Linux peer that exchange invites interoperate — there is one protocol, not two.
This is not a WebView wrapper and not a client for a hosted service.
Each install runs Arti in-process (arti-client 0.46, onion-service-client +
onion-service-service), hosts its own v3 onion service, and dials friends'
onions directly. There is no tor binary, no torrc, no Orbot as the pipe.
Locked protocol facts
| Piece | Rule |
|---|---|
| Identity | ed25519 keypair. The pubkey is who you are. |
| Roster key | friends.pubkey, UNIQUE. Petnames are local only. |
| Onion | A locator, not an identity. It rotates. |
| Invite | onionwire:v1:k=…:o=…:spk=…:sig=… |
| Crypto | Noise IK (Noise_IK_25519_ChaChaPoly_BLAKE2s). spk is x25519. |
| Fail closed | Peer onion down → the send fails. No outbox, no spool in v1. |
| At rest | Chat bodies encrypted (Argon2id wrap + ChaCha20-Poly1305). Identity keys in sqlite are plaintext — that is honest, not an oversight. |
Invite string
onionwire:v1:k=<ed25519 pubkey hex64>:o=<v3 onion address>:spk=<x25519 prekey hex64>:sig=<ed25519 sig hex128>
sigis a signature over the concatenationk ‖ o ‖ spkas raw ASCII bytes.- A malformed, unsigned, or tampered invite is rejected before anything is stored.
- Same
knever creates a second friend. It updates the stored locator on the existing row. That is the whole point of keying the roster on the pubkey. - The invite carries no display name and no Monero address — those arrive later
over the wire as signed
prfframes, not through the invite.
Adding the AAR to another Android app
The SDK ships as onionwire-sdk-<version>.aar on the Forgejo release, with a
matching .sha256.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
flatDir { dirs("libs") } // or however you vendor the AAR
}
}
// app/build.gradle.kts
dependencies {
implementation(files("libs/onionwire-sdk-0.1.0.aar"))
}
Requirements:
minSdk >= 26. The AAR is built with the NDK againstandroid-26.abiFiltersmust include an ABI the AAR ships. The release AAR includesarm64-v8a. Addx86_64at build time with-Ponionwire.abis=arm64-v8a,x86_64if you need an emulator.- Keep
uniffi.onionwire_sdk.**(see the AAR'sconsumer-rules.pro, applied automatically by AGP) if you enable R8.
Kotlin surface
Everything is a suspend fun — Arti bootstrap and onion publish take minutes,
and a send retries the peer for up to 3 minutes before failing.
import uniffi.onionwire_sdk.*
// 1. Open (or create) the identity inside app-private storage.
// `filesDir`, never external storage. Empty passphrase fails closed.
val wire: Wire = openWire(File(context.filesDir, "onionwire").absolutePath, passphrase)
// 2. Who you are, and where you are right now.
val fingerprint: String = wire.fingerprint() // stable forever
val onion: String = wire.onion() // changes on rotate
// 3. Invite out / invite in.
val mine: String = wire.invite()
val preview: InviteInfo = decodeInvite(pasted) // verifies sig, stores nothing
val friend: FriendInfo = wire.addFriend(pasted)
// 4. Roster and chat.
val roster: List<FriendInfo> = wire.friends()
wire.setPetname(friend.pubkeyHex, "ada")
wire.send(friend.pubkeyHex, "hello wire")
val msgs: List<ChatMessage> = wire.messages(friend.pubkeyHex)
// 5. Locator rotation. Do NOT wire this to a single tap — the app that embeds
// the SDK owns the typed confirmation.
val out: RotateOutcome = wire.rotateOnion() // out.notified / out.friends
All peer references are lowercase hex strings so nothing Rust-shaped leaks into the AAR.
Building the bindings yourself
The Gradle task :sdk:uniffiBindgen runs, inside crates/onionwire-sdk:
cargo build --release --lib # host cdylib
cargo run --release --bin uniffi-bindgen -- generate \
--library target/release/libonionwire_sdk.so \
--language kotlin --out-dir <generated dir>
Do not hand-edit anything under android/sdk/build/generated/; it is a build
output.
Why this crate has its own Cargo workspace
Arti's TLS backends are non-additive: exactly one of native-tls and
rustls may be enabled in a given feature resolution. The Linux TUI keeps
native-tls (OpenSSL, as it always has). Android has no OpenSSL in the NDK, so
this crate selects rustls plus static-sqlite (no system libsqlite3 on
Android). Two separate workspaces keep the two resolutions from colliding.