feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split #2

Merged
sirius merged 1 commit from wt/t_b7176a1b into main 2026-09-10 23:16:45 +00:00
41 changed files with 9387 additions and 1 deletions
Showing only changes of commit 672f2c9990 - Show all commits

14
.gitignore vendored
View file

@ -1,3 +1,17 @@
/target
/dist
/.worktrees/
# Machine-local Android build config. sdk.dir/ndk.dir are absolute paths to
# whatever the developer has installed — never commit them.
/.android-env.sh
android/local.properties
# Gradle / Android build output
android/.gradle/
android/build/
android/app/build/
android/sdk/build/
# Rust: the SDK is its own Cargo workspace
crates/onionwire-sdk/target/

49
Cargo.lock generated
View file

@ -1557,6 +1557,17 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "futures-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb"
dependencies = [
"futures-io",
"rustls",
"rustls-pki-types",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
@ -3425,6 +3436,40 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@ -5338,11 +5383,15 @@ dependencies = [
"dyn-clone",
"educe",
"futures",
"futures-rustls",
"hex",
"libc",
"native-tls",
"paste",
"pin-project",
"rustls",
"rustls-pki-types",
"rustls-webpki",
"socket2",
"thiserror 2.0.20",
"tokio",

View file

@ -8,8 +8,26 @@ license = "MIT"
publish = false
repository = "https://github.com/sirius0xdev/onionwire"
# The Linux TUI build is unchanged: `default` keeps Arti's native-tls
# (OpenSSL) backend exactly as before, and ratatui remains a normal
# dependency so `src/tui.rs` is untouched.
#
# Android has no OpenSSL in the NDK, so the SDK crate depends on this crate
# with `default-features = false, features = ["rustls"]`. Arti marks these two
# backends non-additive, so exactly one may be selected per build graph — hence
# exposing them as crate features.
[features]
default = ["native-tls"]
native-tls = ["arti-client/native-tls"]
rustls = ["arti-client/rustls"]
[dependencies]
arti-client = { version = "0.46", features = ["tokio", "onion-service-client", "onion-service-service"] }
arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"onion-service-client",
"onion-service-service",
"compression",
] }
futures = "0.3"
safelog = "0.9"
ed25519-dalek = { version = "2", features = ["rand_core"] }

View file

@ -219,6 +219,33 @@ The message key is **not** wrapped with `identity_sk` (that key is in the same f
Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md).
## Android (SDK + APK)
Same repository, same protocol, separate product from the Linux TUI. An Android
peer and a Linux peer that exchange invites interoperate — one protocol, not two.
- **SDK**`onionwire-sdk-<version>.aar`. UniFFI Kotlin bindings over the same
Rust crate the TUI runs on, plus `libonionwire_sdk.so` for `arm64-v8a`.
Another Android app depends on it directly. See
[`crates/onionwire-sdk/README.md`](crates/onionwire-sdk/README.md).
- **APK**`onionwire-<version>-android-arm64-v8a.apk`. Kotlin + Jetpack
Compose + Material 3 messenger that depends on the SDK and nothing else.
It is not a WebView, and `src/tui.rs` is untouched. Both are built on a host with
the Android SDK/NDK — the Pi runner is aarch64 Linux and cannot produce an APK:
```bash
# one-time: sdkmanager platform 36 / build-tools 36.0.0 / ndk 28.2.13676358,
# rustup target add aarch64-linux-android, cargo install cargo-ndk
scripts/build-android-local.sh # -> dist/, with .sha256 files
PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # + upload to that release
```
Every asset has a matching `.sha256` so `sha256sum -c` works. The APK is
**debug-signed** and is for sideloading, not for Play. Full build notes, the
`minSdk 26` rationale, the 16 KB page-size check and the signing story are in
[`android/README.md`](android/README.md).
## Releases (maintainers)
Tags trigger Forgejo Actions on the self-hosted Pi runner. The x86_64 asset has
@ -249,6 +276,7 @@ Scripts:
- [`scripts/publish-release.sh`](scripts/publish-release.sh) — create-or-update a release and (re)upload assets via the Forgejo API. Needs `FORGEJO_TOKEN` (repo secret in CI, env locally).
- [`scripts/build-release-local.sh`](scripts/build-release-local.sh) — x86_64 build + strip + checksum + publish.
- [`scripts/build-android-local.sh`](scripts/build-android-local.sh) — Android AAR + APK build, checksums, optional publish (see the Android section above).
- [`scripts/release-body.md`](scripts/release-body.md) — release notes template (`@TAG@` is substituted).
Do not run `cargo publish`; `publish = false`. CI needs the repo secret

138
android/README.md Normal file
View file

@ -0,0 +1,138 @@
# 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.
## 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:
```bash
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
```bash
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:
```bash
./gradlew :app:assembleDebug -Ponionwire.abis=arm64-v8a,x86_64
```
Or from the repo root, with checksums and an optional publish step:
```bash
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:
```bash
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)
```

View file

@ -0,0 +1,90 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.siriusdevops.onionwire"
compileSdk = 36
// Same ABI selection as :sdk. JNA ships libjnidispatch.so for several ABIs;
// without this filter the APK carries native code for ABIs we never built
// our Rust lib for.
val onionwireAbis: List<String> = (findProperty("onionwire.abis") as String?)
?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)
?: listOf("arm64-v8a")
defaultConfig {
applicationId = "com.siriusdevops.onionwire"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
ndk {
abiFilters += onionwireAbis
}
}
buildTypes {
debug {
// Sideload / device-test builds. Signed with the standard debug key.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
// Deliberately NOT minified in this card: there is no on-device
// crash-deobfuscation story yet, and shipping R8 without testing
// the JNI/uniffi surface on a real device is how you get a
// mysterious UnsatisfiedLinkError in the field.
// Enable together with mapping-file upload; keeps already exist in
// :sdk/consumer-rules.pro.
isMinifyEnabled = false
isShrinkResources = false
// Signed with the debug keystore so the release APK installs.
// This is NOT a Play upload key — see android/README.md.
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(project(":sdk"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
INTERNET is the only permission requested at first launch.
No camera (invites are typed/pasted, not scanned), no contacts, no
storage. Tor runs in-process; there is no foreground service and no
keep-alive notification in v1, so the node only lives while the app does.
-->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.OnionWire">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,120 @@
package com.siriusdevops.onionwire
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.siriusdevops.onionwire.ui.ChatScreen
import com.siriusdevops.onionwire.ui.PasteInviteScreen
import com.siriusdevops.onionwire.ui.RosterScreen
import com.siriusdevops.onionwire.ui.RotateScreen
import com.siriusdevops.onionwire.ui.ShareInviteScreen
import com.siriusdevops.onionwire.ui.UnlockScreen
import com.siriusdevops.onionwire.ui.OnionWireTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
OnionWireTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
AppRoot()
}
}
}
}
}
@Composable
private fun AppRoot(vm: WireViewModel = viewModel()) {
val phase by vm.phase.collectAsStateWithLifecycle()
when (val p = phase) {
is Phase.Ready -> ReadyGraph(vm, p)
else -> UnlockScreen(phase = p, onUnlock = vm::unlock)
}
}
@Composable
private fun ReadyGraph(vm: WireViewModel, ready: Phase.Ready) {
val nav = rememberNavController()
val friends by vm.friends.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
NavHost(navController = nav, startDestination = "roster") {
composable("roster") {
RosterScreen(
fingerprint = ready.fingerprint,
onion = ready.onion,
sdkVersion = ready.sdkVersion,
friends = friends,
onOpenChat = { nav.navigate("chat/${it.pubkeyHex}") },
onShare = { nav.navigate("share") },
onPaste = { nav.navigate("paste") },
onRotate = { nav.navigate("rotate") },
onWipe = { scope.launch { vm.wipeMessages() } },
onLock = vm::lock,
)
}
composable("share") {
ShareInviteScreen(ownerInvite = vm::invite, onBack = { nav.popBackStack() })
}
composable("paste") {
PasteInviteScreen(
onDecode = vm::decode,
onAdd = vm::addFriend,
onBack = { nav.popBackStack() },
)
}
composable("rotate") {
RotateScreen(
currentOnion = ready.onion,
onRotate = {
val out = vm.rotateOnion()
out.notified.toInt() to out.friends.toInt()
},
onBack = { nav.popBackStack() },
)
}
composable(
route = "chat/{pk}",
arguments = listOf(navArgument("pk") { type = NavType.StringType }),
) { entry ->
val pk = entry.arguments?.getString("pk").orEmpty()
val friend = friends.firstOrNull { it.pubkeyHex == pk }
ChatScreen(
title = friend?.petname?.takeIf { it.isNotBlank() }
?: friend?.fingerprint
?: pk.take(16),
fingerprint = friend?.fingerprint ?: pk.take(16),
onion = friend?.onion.orEmpty(),
loadMessages = { vm.messages(pk) },
onSend = { body -> vm.send(pk, body) },
onBack = { nav.popBackStack() },
)
}
}
}

View file

@ -0,0 +1,124 @@
package com.siriusdevops.onionwire
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import uniffi.onionwire_sdk.ChatMessage
import uniffi.onionwire_sdk.FriendInfo
import uniffi.onionwire_sdk.InviteInfo
import uniffi.onionwire_sdk.RotateOutcome
import uniffi.onionwire_sdk.Wire
import uniffi.onionwire_sdk.decodeInvite
import uniffi.onionwire_sdk.openWire
import java.io.File
sealed interface Phase {
/** No identity unlocked. Nothing Tor-related is running. */
data object Locked : Phase
/** Bootstrap + onion publish. Cold start is minutes, not seconds. */
data object Opening : Phase
data class Ready(
val fingerprint: String,
val onion: String,
val sdkVersion: String,
) : Phase
data class Failed(val message: String) : Phase
}
/**
* Owns the single Wire node for the process.
*
* The node lives in the ViewModel because a configuration change must not
* drop a published onion service and re-bootstrap Tor.
*/
class WireViewModel(app: Application) : AndroidViewModel(app) {
/** `context.filesDir` — app-private, never external storage. */
private val home: File = File(app.filesDir, "onionwire")
private val _phase = MutableStateFlow<Phase>(Phase.Locked)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _friends = MutableStateFlow<List<FriendInfo>>(emptyList())
val friends: StateFlow<List<FriendInfo>> = _friends.asStateFlow()
private var wire: Wire? = null
/**
* Opens (or on first run, creates) the identity and starts hosting.
*
* An empty passphrase fails closed the SDK maps it straight to an error,
* it is not a "skip" path.
*/
fun unlock(passphrase: String) {
if (_phase.value is Phase.Opening) return
_phase.value = Phase.Opening
viewModelScope.launch {
try {
val w = openWire(home.absolutePath, passphrase)
wire = w
_phase.value = Phase.Ready(
fingerprint = w.fingerprint(),
onion = w.onion(),
sdkVersion = w.version(),
)
refreshFriends()
} catch (t: Throwable) {
_phase.value = Phase.Failed(t.message ?: t.toString())
}
}
}
/** Drops our handle. The onion service stops; nothing is sent after this. */
fun lock() {
wire = null
_friends.value = emptyList()
_phase.value = Phase.Locked
}
private fun requireWire(): Wire = wire ?: error("locked")
suspend fun refreshFriends() {
_friends.value = requireWire().friends()
}
suspend fun invite(): String = requireWire().invite()
suspend fun addFriend(invite: String): FriendInfo {
val f = requireWire().addFriend(invite)
refreshFriends()
return f
}
suspend fun setPetname(pubkeyHex: String, petname: String?) {
requireWire().setPetname(pubkeyHex, petname?.takeIf { it.isNotBlank() })
refreshFriends()
}
suspend fun messages(pubkeyHex: String): List<ChatMessage> =
requireWire().messages(pubkeyHex)
suspend fun send(pubkeyHex: String, body: String) =
requireWire().send(pubkeyHex, body)
suspend fun wipeMessages() {
requireWire().wipeMessages()
}
suspend fun rotateOnion(): RotateOutcome {
val out = requireWire().rotateOnion()
_phase.value = (_phase.value as? Phase.Ready)?.copy(onion = requireWire().onion())
?: _phase.value
refreshFriends()
return out
}
suspend fun decode(invite: String): InviteInfo = decodeInvite(invite)
}

View file

@ -0,0 +1,206 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import uniffi.onionwire_sdk.ChatMessage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
title: String,
fingerprint: String,
onion: String,
loadMessages: suspend () -> List<ChatMessage>,
onSend: suspend (String) -> Unit,
onBack: () -> Unit,
) {
var messages by remember { mutableStateOf<List<ChatMessage>?>(null) }
var draft by remember { mutableStateOf("") }
var pending by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
// Poll: the node appends inbound messages in the background; there is no
// push channel into the UI in v1.
LaunchedEffect(Unit) {
while (true) {
runCatching { loadMessages() }
.onSuccess { messages = it }
.onFailure { error = it.message ?: it.toString() }
delay(2_000)
}
}
// Fail-closed send. `send` retries the peer's onion for up to 3 minutes and
// then errors — nothing is spooled, so a failure is a real failure.
LaunchedEffect(pending) {
val body = pending ?: return@LaunchedEffect
error = null
runCatching { onSend(body) }
.onFailure { error = it.message ?: it.toString() }
pending = null
}
LaunchedEffect(messages?.size) {
val n = messages?.size ?: 0
if (n > 0) listState.animateScrollToItem(n - 1)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
fingerprint,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
}
},
navigationIcon = {
IconButton(onClick = onBack) { Text("<") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Text(
"onion: $onion",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
val list = messages
when {
list == null -> {
Spacer(Modifier.height(16.dp))
CircularProgressIndicator(Modifier.padding(16.dp))
}
list.isEmpty() -> {
Column(
Modifier.fillMaxWidth().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("No messages", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(6.dp))
Text(
"Sends fail closed: if their onion is down the message is " +
"not spooled anywhere. There is no outbox in v1.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(list) { m ->
val mine = m.direction == "out"
Row(
Modifier.fillMaxWidth(),
horizontalArrangement =
if (mine) Arrangement.End else Arrangement.Start,
) {
Text(
m.body,
style = MaterialTheme.typography.bodyMedium,
color = if (mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.background(
if (mine) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
}
}
}
error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
Row(
Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
maxLines = 4,
label = { Text("message") },
)
Spacer(Modifier.width(8.dp))
if (pending != null) {
CircularProgressIndicator()
} else {
Button(
enabled = draft.isNotBlank(),
onClick = {
pending = draft
draft = ""
},
) { Text("Send") }
}
}
}
}
}

View file

@ -0,0 +1,259 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import uniffi.onionwire_sdk.FriendInfo
import uniffi.onionwire_sdk.InviteInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareInviteScreen(ownerInvite: suspend () -> String, onBack: () -> Unit) {
var invite by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var copied by remember { mutableStateOf(false) }
val clip = LocalClipboardManager.current
LaunchedEffect(Unit) {
runCatching { ownerInvite() }
.onSuccess { invite = it }
.onFailure { error = it.message ?: it.toString() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Your invite") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
Text(
"Hand this to one person, over a channel you already trust. " +
"It is not published anywhere and cannot be looked up.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
when {
error != null -> Text(
error!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
invite == null -> CircularProgressIndicator()
else -> {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
invite!!,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
clip.setText(AnnotatedString(invite!!))
copied = true
}) { Text(if (copied) "Copied" else "Copy") }
}
Spacer(Modifier.height(20.dp))
Text("What is in it", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(6.dp))
Mono("k ed25519 identity pubkey — who you are")
Mono("o current onion locator — where you are now")
Mono("spk x25519 signed prekey — session start material")
Mono("sig signature over k‖o‖spk — reject if it fails")
Spacer(Modifier.height(12.dp))
Text(
"The onion in here goes stale when you rotate. The key does not. " +
"If you re-share later, the same k updates their entry — it never " +
"creates a second friend.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PasteInviteScreen(
onDecode: suspend (String) -> InviteInfo,
onAdd: suspend (String) -> FriendInfo,
onBack: () -> Unit,
) {
var raw by remember { mutableStateOf("") }
var preview by remember { mutableStateOf<InviteInfo?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var added by remember { mutableStateOf<FriendInfo?>(null) }
var busy by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add a friend") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
OutlinedTextField(
value = raw,
onValueChange = {
raw = it
preview = null
error = null
added = null
},
label = { Text("onionwire:v1:…") },
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = raw.isNotBlank() && !busy,
onClick = {
busy = true
error = null
},
) { Text("Verify") }
if (added != null) {
TextButton(onClick = onBack) { Text("Done") }
}
}
LaunchedEffect(busy) {
if (!busy) return@LaunchedEffect
runCatching { onDecode(raw.trim()) }
.onSuccess { preview = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
Spacer(Modifier.height(16.dp))
error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
"Unsigned or tampered invites are rejected here, before anything is stored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
preview?.let { p ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
Label("THEY ARE")
Mono(p.pubkeyHex)
Spacer(Modifier.height(8.dp))
Text(
"fingerprint ${p.pubkeyHex.take(16)}… — compare this with them " +
"over a second channel before you trust it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Label("REACHABLE AT")
Mono(p.onion)
}
}
Spacer(Modifier.height(12.dp))
Button(
enabled = !busy && added == null,
onClick = {
busy = true
error = null
},
) { Text("Approve & add") }
LaunchedEffect(busy, preview) {
if (!busy || preview == null || added != null) return@LaunchedEffect
runCatching { onAdd(raw.trim()) }
.onSuccess { added = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
}
added?.let { f ->
Spacer(Modifier.height(16.dp))
Text(
"Added ${f.petname ?: f.fingerprint}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
Text(
"If that key was already in your roster, only the locator was updated. " +
"One key is always one friend.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View file

@ -0,0 +1,229 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import uniffi.onionwire_sdk.FriendInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RosterScreen(
fingerprint: String,
onion: String,
sdkVersion: String,
friends: List<FriendInfo>,
onOpenChat: (FriendInfo) -> Unit,
onShare: () -> Unit,
onPaste: () -> Unit,
onRotate: () -> Unit,
onWipe: () -> Unit,
onLock: () -> Unit,
) {
var confirmWipe by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("OnionWire") },
actions = {
TextButton(onClick = onLock) { Text("Lock") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize()) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Label("YOU (stable forever)")
Mono(fingerprint)
Spacer(Modifier.height(10.dp))
Label("ONION (locator — rotates)")
Mono(onion)
Spacer(Modifier.height(10.dp))
Text(
"sdk $sdkVersion · identity = ed25519 pubkey",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(8.dp))
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = onShare) { Text("Invite") }
TextButton(onClick = onPaste) { Text("Add") }
TextButton(onClick = onRotate) { Text("Rotate") }
TextButton(onClick = { confirmWipe = true }) { Text("Wipe chat") }
}
if (confirmWipe) {
ConfirmStrip(
text = "Delete every message body? Identity and friends stay.",
confirm = "WIPE",
onConfirm = { confirmWipe = false; onWipe() },
onCancel = { confirmWipe = false },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
if (friends.isEmpty()) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No friends yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Share your invite over a channel you already trust, or paste " +
"someone else's. Invites are not discoverable — nothing is published.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(friends, key = { it.pubkeyHex }) { f ->
FriendRow(f) { onOpenChat(f) }
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun FriendRow(f: FriendInfo, onClick: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
f.petname?.takeIf { it.isNotBlank() } ?: f.fingerprint,
style = MaterialTheme.typography.titleSmall,
)
if (!f.lastConnectOk) {
Spacer(Modifier.height(0.dp))
Text(
" locator stale",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.height(2.dp))
Text(
f.fingerprint,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
Text(
f.onion,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
fun Label(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
fun Mono(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
@Composable
fun ConfirmStrip(
text: String,
confirm: String,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth().padding(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(text, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
androidx.compose.material3.OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("Type $confirm") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onCancel) { Text("Cancel") }
TextButton(
onClick = onConfirm,
enabled = typed.trim().equals(confirm, ignoreCase = true),
) { Text("Confirm") }
}
}
}
}

View file

@ -0,0 +1,139 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Rotating publishes a brand new onion service and hard-cuts the old one.
*
* It is a typed confirmation on purpose: it is not a single tap, and it is not
* hidden. Friends who are online get a signed `loc` update; friends who are
* offline cannot find you again until they re-scan your invite. Your identity
* key and therefore your fingerprint does not change.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RotateScreen(
currentOnion: String,
onRotate: suspend () -> Pair<Int, Int>, // notified to friends
onBack: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Rotate onion") },
navigationIcon = { IconButton(onClick = onBack) { Text("<") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
) { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
) {
Text("Rotate onion address?", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(12.dp))
Text("Your identity key stays the same.", style = MaterialTheme.typography.bodyMedium)
Text(
"Online friends get a signed location update.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
"Offline friends CANNOT find you until they re-scan your invite.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(16.dp))
Label("CURRENT")
Mono(currentOnion)
Spacer(Modifier.height(20.dp))
if (result == null) {
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
label = { Text("Type ROTATE to confirm") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = typed.trim().equals("ROTATE", ignoreCase = true) &&
!busy,
onClick = { busy = true; error = null },
) { Text("Rotate") }
if (busy) CircularProgressIndicator()
}
}
LaunchedEffect(busy) {
if (!busy) return@LaunchedEffect
runCatching { onRotate() }
.onSuccess { result = it }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
error?.let {
Spacer(Modifier.height(16.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
result?.let { (notified, friends) ->
Spacer(Modifier.height(20.dp))
Text(
"Rotated · notified $notified/$friends friends",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
"The old onion service is already gone. Anyone still holding the old " +
"invite will fail to connect — that is the documented v1 behaviour, " +
"not a bug.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onBack) { Text("Back — share the new invite") }
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val OnionViolet = Color(0xFF7C4DFF)
private val WireCyan = Color(0xFF38F2C7)
private val DeepVoid = Color(0xFF0B0B10)
private val PanelVoid = Color(0xFF15151C)
private val OnionWireColors = darkColorScheme(
primary = WireCyan,
onPrimary = DeepVoid,
primaryContainer = OnionViolet,
onPrimaryContainer = Color.White,
secondary = OnionViolet,
onSecondary = Color.White,
background = DeepVoid,
onBackground = Color(0xFFE6E6F0),
surface = DeepVoid,
onSurface = Color(0xFFE6E6F0),
surfaceVariant = PanelVoid,
onSurfaceVariant = Color(0xFFB9B9CC),
outline = Color(0xFF3A3A4A),
error = Color(0xFFFF6B6B),
onError = DeepVoid,
)
@Composable
fun OnionWireTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = OnionWireColors, content = content)
}

View file

@ -0,0 +1,130 @@
package com.siriusdevops.onionwire.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.siriusdevops.onionwire.Phase
@Composable
fun UnlockScreen(phase: Phase, onUnlock: (String) -> Unit) {
var pass by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("ONIONWIRE", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(4.dp))
Text(
"Tor messenger. No server, no account. Your identity is a key.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
when (phase) {
is Phase.Opening -> {
Text("Publishing onion service…", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
Text(
"First launch bootstraps Arti and uploads a v3 onion descriptor. " +
"On a cold network this takes minutes, not seconds. Keep the screen on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
}
else -> {
Text(
"Passphrase",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pass,
onValueChange = { pass = it },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
label = { Text("wraps the local message key") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Text(
"Empty is not a bypass — it fails closed. There is no recovery: " +
"lose this and the stored chat bodies stay unreadable.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
Button(
onClick = { onUnlock(pass) },
enabled = pass.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Open") }
if (phase is Phase.Failed) {
Spacer(Modifier.height(20.dp))
Text(
"Failed to start",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(4.dp))
Text(
(phase as Phase.Failed).message,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
"OnionWire fails closed: if the onion service cannot publish, " +
"it does not fall back to a clearnet or C-tor path.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(24.dp))
Text(
"INTERNET is the only permission this app requests.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0B0B10"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Onion bulb. Adaptive-icon foreground: keep art inside the 66dp safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- bulb -->
<path
android:fillColor="#7C4DFF"
android:pathData="M54,28 C72,28 82,46 82,62 C82,78 69,88 54,88 C39,88 26,78 26,62 C26,46 36,28 54,28 Z" />
<!-- sprout -->
<path
android:strokeColor="#38F2C7"
android:strokeWidth="3"
android:strokeLineCap="round"
android:pathData="M54,28 L54,18" />
<!-- layers -->
<path
android:strokeColor="#38F2C7"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M38,40 C33,54 35,74 48,84" />
<path
android:strokeColor="#38F2C7"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M70,40 C75,54 73,74 60,84" />
<path
android:strokeColor="#0B0B10"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M54,32 L54,86" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">OnionWire</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose paints the real UI; this only sets the window before the
first frame so there is no white flash on a dark app. -->
<style name="Theme.OnionWire" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#FF0B0B10</item>
<item name="android:statusBarColor">#FF0B0B10</item>
<item name="android:navigationBarColor">#FF0B0B10</item>
</style>
</resources>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Chat bodies are encrypted at rest, but nothing is cloud-synced or
device-transferred: an Android auto-backup of onionwire.db would hand the
wrapped message key and the plaintext identity keys to Google Drive.
Identity is local by design — back it up with the SDK's owbak1 export instead.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
</cloud-backup>
<device-transfer>
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
</device-transfer>
</data-extraction-rules>

6
android/build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

12
android/gradle.properties Normal file
View file

@ -0,0 +1,12 @@
# Gradle
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=false
# Android
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin
kotlin.code.style=official

View file

@ -0,0 +1,31 @@
[versions]
agp = "8.13.2"
kotlin = "2.2.21"
coreKtx = "1.17.0"
activityCompose = "1.12.4"
lifecycle = "2.9.4"
navigationCompose = "2.9.8"
composeBom = "2025.12.01"
jna = "5.19.1"
coroutines = "1.11.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,163 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
// ---------------------------------------------------------------------------
// Rust: build crates/onionwire-sdk for the requested Android ABIs and generate
// the UniFFI Kotlin bindings from the host cdylib.
//
// Nothing here touches the Linux TUI crate's feature graph: the SDK crate is
// its own Cargo workspace that selects Arti's rustls backend.
// ---------------------------------------------------------------------------
val repoRoot: File = rootProject.projectDir.parentFile
val sdkCrateDir: File = repoRoot.resolve("crates/onionwire-sdk")
/** NDK version used for the Rust link step. Must match an installed NDK. */
val onionwireNdkVersion: String = (findProperty("onionwire.ndk") as String?)
?: "28.2.13676358"
/** Compile against this API level. Keep in sync with `minSdk`. */
val onionwireApiLevel: Int = ((findProperty("onionwire.api") as String?) ?: "26").toInt()
val androidSdkDir: File = sequenceOf(
System.getenv("ANDROID_HOME"),
System.getenv("ANDROID_SDK_ROOT"),
).filterNotNull().map(::File).firstOrNull()
?: rootProject.file("local.properties")
.takeIf { it.exists() }
?.readLines()
?.firstOrNull { it.startsWith("sdk.dir=") }
?.substringAfter('=')
?.trim()
?.let(::File)
?: error("ANDROID_HOME / ANDROID_SDK_ROOT unset and no sdk.dir in android/local.properties")
val ndkDir: File = androidSdkDir.resolve("ndk/$onionwireNdkVersion")
require(ndkDir.isDirectory) {
"NDK $onionwireNdkVersion not found at $ndkDir — run: sdkmanager --install \"ndk;$onionwireNdkVersion\""
}
val cargoExe: String = (findProperty("onionwire.cargo") as String?)
?: System.getenv("CARGO")
?: File(System.getProperty("user.home"), ".cargo/bin/cargo").takeIf { it.canExecute() }?.absolutePath
?: "cargo"
/** `-Ponionwire.abis=arm64-v8a,x86_64` to build more than one ABI. */
val onionwireAbis: List<String> = (findProperty("onionwire.abis") as String?)
?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)
?: listOf("arm64-v8a")
val jniLibsDir = layout.buildDirectory.dir("rustJniLibs")
val uniffiKotlinDir = layout.buildDirectory.dir("generated/uniffi")
/** Host cdylib. UniFFI's `--library` mode dlopens it, so it must be host-arch. */
val cargoHostLib by tasks.registering(Exec::class) {
group = "onionwire"
description = "Builds libonionwire_sdk.so for the host (used only to emit bindings)."
workingDir = sdkCrateDir
environment("ANDROID_HOME", androidSdkDir.absolutePath)
commandLine(cargoExe, "build", "--release", "--lib")
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "onionwire"
description = "Generates the Kotlin UniFFI bindings into build/generated/uniffi."
dependsOn(cargoHostLib)
workingDir = sdkCrateDir
inputs.file(sdkCrateDir.resolve("src/lib.rs"))
outputs.dir(uniffiKotlinDir)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
doFirst { uniffiKotlinDir.get().asFile.mkdirs() }
commandLine(
cargoExe, "run", "--release", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "target/release/libonionwire_sdk.so",
"--language", "kotlin",
"--no-format",
"--config", "uniffi.toml",
"--out-dir", uniffiKotlinDir.get().asFile.absolutePath,
)
}
val cargoBuildAndroid by tasks.registering(Exec::class) {
group = "onionwire"
description = "Cross-compiles crates/onionwire-sdk for ${onionwireAbis.joinToString()}."
workingDir = sdkCrateDir
inputs.dir(sdkCrateDir.resolve("src"))
inputs.file(sdkCrateDir.resolve("Cargo.toml"))
inputs.file(sdkCrateDir.resolve("Cargo.lock"))
inputs.dir(repoRoot.resolve("src"))
inputs.file(repoRoot.resolve("Cargo.toml"))
outputs.dir(jniLibsDir)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
environment("ANDROID_NDK_HOME", ndkDir.absolutePath)
environment("NDK_HOME", ndkDir.absolutePath)
commandLine(
buildList {
add(cargoExe); add("ndk")
onionwireAbis.forEach { add("-t"); add(it) }
add("--platform"); add(onionwireApiLevel.toString())
add("-o"); add(jniLibsDir.get().asFile.absolutePath)
add("build"); add("--release"); add("--lib")
}
)
}
// ---------------------------------------------------------------------------
android {
namespace = "com.siriusdevops.onionwire.sdk"
compileSdk = 36
ndkVersion = onionwireNdkVersion
defaultConfig {
minSdk = onionwireApiLevel
consumerProguardFiles("consumer-rules.pro")
ndk {
abiFilters += onionwireAbis
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets["main"].jniLibs.srcDir(jniLibsDir)
sourceSets["main"].kotlin.srcDir(uniffiKotlinDir)
buildTypes {
release {
isMinifyEnabled = false
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
// UniFFI's Kotlin backend talks to the Rust cdylib through JNA direct
// mapping, and the async bindings need coroutines. Both are part of the
// public API surface, so they are `api` — a consumer of the AAR needs them
// on its own compile classpath. JNA's `aar` variant is what carries
// libjnidispatch.so for each ABI.
api(variantOf(libs.jna) { artifactType("aar") })
api(libs.kotlinx.coroutines.core)
}
tasks.withType<KotlinCompile>().configureEach {
dependsOn(uniffiBindgen)
}
tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn(cargoBuildAndroid, uniffiBindgen)
}

View file

@ -0,0 +1,9 @@
# Applied to any app that depends on :sdk (AAR consumer rules).
#
# UniFFI calls back into Kotlin from the Rust side over JNI, so the generated
# classes are reachable only via reflection/native lookup. Without these keeps
# an R8-enabled consumer app strips them and fails at load time.
-keep class uniffi.onionwire_sdk.** { *; }
-keepclasseswithmembernames class uniffi.onionwire_sdk.** {
native <methods>;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The only permission the SDK itself needs: Tor runs over INTERNET. -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "onionwire-android"
// The Android library that ships as the AAR (Kotlin bindings + native .so).
include(":sdk")
// The installable Compose messenger. Depends on :sdk only — never on the TUI.
include(":app")

6567
crates/onionwire-sdk/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
[package]
name = "onionwire-sdk"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
description = "OnionWire Android/Kotlin SDK: in-process Arti onion transport, Noise IK, identity = ed25519 pubkey."
license = "MIT"
publish = false
# Standalone workspace on purpose.
#
# Arti's TLS backends (`native-tls` vs `rustls`) are non-additive: exactly one
# may be enabled per feature resolution. The Linux TUI crate keeps native-tls,
# Android has no OpenSSL in the NDK and needs rustls. Two separate workspaces
# keep the two resolutions from colliding — this crate path-depends on the
# root package without joining its feature graph.
[workspace]
[lib]
name = "onionwire_sdk"
crate-type = ["cdylib", "lib"]
[dependencies]
onionwire = { path = "../..", default-features = false, features = ["rustls"] }
# `static-sqlite`: Android has no system libsqlite3 to link against.
arti-client = { version = "0.46", default-features = false, features = [
"tokio",
"onion-service-client",
"onion-service-service",
"compression",
"rustls",
"static-sqlite",
] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
uniffi = { version = "0.32", features = ["cli", "tokio"] }
thiserror = "2"
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"

View file

@ -0,0 +1,122 @@
# 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>
```
* `sig` is a signature over the concatenation `k ‖ o ‖ spk` as raw ASCII bytes.
* A malformed, unsigned, or tampered invite is rejected before anything is stored.
* **Same `k` never 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 `prf` frames, 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`.
```kotlin
// 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 against `android-26`.
* `abiFilters` must include an ABI the AAR ships. The release AAR includes
`arm64-v8a`. Add `x86_64` at build time with `-Ponionwire.abis=arm64-v8a,x86_64`
if you need an emulator.
* Keep `uniffi.onionwire_sdk.**` (see the AAR's `consumer-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.
```kotlin
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.

View file

@ -0,0 +1,8 @@
//! UniFFI binding generator entry point.
//!
//! `cargo run -p onionwire-sdk --bin uniffi-bindgen -- generate --library \
//! target/release/libonionwire_sdk.so --language kotlin --out-dir <dir>`
fn main() {
uniffi::uniffi_bindgen_main()
}

View file

@ -0,0 +1,229 @@
//! OnionWire Android/Kotlin SDK.
//!
//! UniFFI façade over the existing `onionwire` library — the same crate the
//! Linux TUI runs on. Nothing about the wire protocol is reimplemented here:
//! an Android peer and a Linux peer that exchange invites interoperate.
//!
//! Locked facts this surface assumes:
//! * Identity is an ed25519 pubkey. Fingerprint is a display of that key.
//! * `onionwire:v1:k=…:o=…:spk=…:sig=…` — the onion is a *locator* and can
//! change; re-scanning the same `k` updates the locator, never duplicates
//! the friend.
//! * Transport is in-process Arti. There is no `tor` binary, no torrc, no
//! Orbot pipe. If the onion service cannot publish, this fails closed.
//! * Chat bodies are encrypted at rest. Identity keys in sqlite are not.
//!
//! The façade deliberately exposes only plain data (records + hex strings) so
//! no TUI/ratatui type leaks into the AAR.
use std::path::PathBuf;
use std::sync::Arc;
use onionwire::node::Node;
use onionwire::qr;
uniffi::setup_scaffolding!();
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum WireError {
#[error("{message}")]
Failed { message: String },
}
impl WireError {
fn new(e: impl std::fmt::Display) -> Self {
Self::Failed {
message: e.to_string(),
}
}
}
type WResult<T> = std::result::Result<T, WireError>;
#[derive(Debug, Clone, uniffi::Record)]
pub struct FriendInfo {
/// ed25519 identity pubkey, lowercase hex. This is the roster key.
pub pubkey_hex: String,
/// Display form of the same key.
pub fingerprint: String,
/// Local label. Never sent on the wire.
pub petname: Option<String>,
/// Current locator. May be stale if the peer rotated while you were down.
pub onion: String,
pub last_connect_ok: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChatMessage {
/// "in" or "out".
pub direction: String,
pub body: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct RotateOutcome {
pub notified: u32,
pub friends: u32,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct InviteInfo {
pub pubkey_hex: String,
pub onion: String,
}
/// A running OnionWire node: identity + Arti client + hosted v3 onion service.
#[derive(uniffi::Object)]
pub struct Wire {
inner: Arc<Node>,
}
/// Open (or create) the identity in `home` and start hosting an onion service.
///
/// `home` must be app-private storage (`context.filesDir`), not external.
/// `passphrase` wraps the local message key; an empty passphrase fails closed.
///
/// This resolves only once the onion service is published — on a cold network
/// that is minutes, not seconds. Call it off the main thread.
#[uniffi::export(async_runtime = "tokio")]
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> {
let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
.await
.map_err(WireError::new)?;
Ok(Arc::new(Wire { inner: node }))
}
/// Decode an invite without adding the friend. Verifies the signature.
#[uniffi::export]
pub fn decode_invite(invite: String) -> WResult<InviteInfo> {
let p = qr::decode(&invite).map_err(WireError::new)?;
Ok(InviteInfo {
pubkey_hex: hex(&p.pubkey),
onion: p.onion,
})
}
#[uniffi::export(async_runtime = "tokio")]
impl Wire {
/// Fingerprint of our own identity key. Stable forever for this install.
pub async fn fingerprint(&self) -> WResult<String> {
self.inner.fingerprint().map_err(WireError::new)
}
/// Current onion locator. Changes on rotate; identity does not.
pub async fn onion(&self) -> String {
self.inner.onion()
}
/// `onionwire:v1:…` invite string to hand to the other person out of band.
pub async fn invite(&self) -> WResult<String> {
self.inner.qr_payload().map_err(WireError::new)
}
/// Roster, sorted by petname then fingerprint.
pub async fn friends(&self) -> WResult<Vec<FriendInfo>> {
let list = self.inner.list_friends().map_err(WireError::new)?;
Ok(list
.into_iter()
.map(|f| FriendInfo {
pubkey_hex: hex(&f.pubkey),
fingerprint: f.fingerprint,
petname: f.petname,
onion: f.onion,
last_connect_ok: f.last_connect_ok,
})
.collect())
}
/// Add a friend from an invite, or update the locator if the key is known.
/// Same `k` never creates a second row.
pub async fn add_friend(&self, invite: String) -> WResult<FriendInfo> {
self.inner.add_friend_from_qr(&invite).map_err(WireError::new)?;
let p = qr::decode(&invite).map_err(WireError::new)?;
let f = self.inner.friend(&p.pubkey).map_err(WireError::new)?;
Ok(FriendInfo {
pubkey_hex: hex(&f.pubkey),
fingerprint: f.fingerprint,
petname: f.petname,
onion: f.onion,
last_connect_ok: f.last_connect_ok,
})
}
/// Local label. Pass `None` to clear. Never goes on the wire.
pub async fn set_petname(&self, pubkey_hex: String, petname: Option<String>) -> WResult<()> {
let pk = unhex(&pubkey_hex)?;
self.inner
.set_petname(&pk, petname.as_deref())
.map_err(WireError::new)
}
/// Send one message. Fail closed: if the peer's onion is down, this errors
/// after retrying. There is no outbox in v1 — no silent spooling.
pub async fn send(&self, pubkey_hex: String, body: String) -> WResult<()> {
let pk = unhex(&pubkey_hex)?;
self.inner
.send(&pk, body.as_bytes())
.await
.map_err(WireError::new)
}
/// Chat history for a friend, oldest first. Bodies are decrypted here;
/// they are stored encrypted at rest.
pub async fn messages(&self, pubkey_hex: String) -> WResult<Vec<ChatMessage>> {
let pk = unhex(&pubkey_hex)?;
let rows = self.inner.list_messages(&pk).map_err(WireError::new)?;
Ok(rows
.into_iter()
.map(|m| ChatMessage {
direction: m.dir,
body: String::from_utf8_lossy(&m.plaintext).into_owned(),
})
.collect())
}
/// Delete every message body. Identity and friends survive.
pub async fn wipe_messages(&self) -> WResult<()> {
self.inner.wipe_messages().map_err(WireError::new)
}
/// Publish a NEW onion service and hard-cut the old one, then push a signed
/// location update to every reachable friend.
///
/// The identity key does not change. Friends who are offline cannot find
/// you until they rescan your invite — that is the documented v1 behaviour,
/// not a bug. Confirm with a typed phrase in the UI; never one tap.
pub async fn rotate_onion(&self) -> WResult<RotateOutcome> {
let r = self.inner.rotate().await.map_err(WireError::new)?;
Ok(RotateOutcome {
notified: r.notified as u32,
friends: r.friends as u32,
})
}
/// SDK version, for diagnostics.
pub async fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn unhex(s: &str) -> WResult<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err(WireError::new("pubkey_hex must be even-length hex"));
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| WireError::new(format!("hex: {e}")))
})
.collect()
}

View file

@ -0,0 +1,9 @@
# UniFFI Kotlin bindings configuration.
#
# `disable_java_cleaner`: the JVM cleaner path pulls in androidx.annotation and
# branches on API 34. JNA's own Cleaner works on every API level we support, so
# we take the single code path and drop the extra dependency.
[bindings.kotlin]
package_name = "uniffi.onionwire_sdk"
disable_java_cleaner = true
kotlin_target_version = "2.2.21"

66
scripts/build-android-local.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Build the Android artifacts on this host and publish them to a Forgejo
# release. There is no Android toolchain in CI: .forgejo/workflows/release.yml
# runs on an aarch64 Linux container and cannot produce an APK. This is the
# Android path, exactly like scripts/build-release-local.sh is the x86_64 path
# for the Linux binary.
#
# Usage:
# scripts/build-android-local.sh [version] # build + checksums only
# PUBLISH_TAG=v0.3.0 scripts/build-android-local.sh # also upload
#
# Requires: ANDROID_HOME (or android/local.properties), an installed NDK, the
# Rust android targets, cargo-ndk, JDK 17+, and a working `cargo` on PATH.
# Does not create or push tags: creating the tag is a human decision.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
version="${1:-$(sed -n 's/.*versionName = "\(.*\)".*/\1/p' android/app/build.gradle.kts | head -1)}"
[ -n "$version" ] || { echo "could not determine version" >&2; exit 1; }
abi="${ONIONWIRE_ANDROID_ABI:-arm64-v8a}"
gradlew="$root/android/gradlew"
echo "==> android build: version $version, abi $abi"
( cd android && "./gradlew" --no-daemon \
"-Ponionwire.abis=$abi" \
:sdk:assembleRelease :app:assembleRelease )
mkdir -p dist
apk_src="android/app/build/outputs/apk/release/app-release.apk"
aar_src="android/sdk/build/outputs/aar/sdk-release.aar"
apk="dist/onionwire-$version-android-$abi.apk"
aar="dist/onionwire-sdk-$version.aar"
for f in "$apk_src" "$aar_src"; do
[ -s "$f" ] || { echo "missing or empty build output: $f" >&2; exit 1; }
done
cp "$apk_src" "$apk"
cp "$aar_src" "$aar"
# Refuse 0-byte release assets before they ever reach a release.
for f in "$apk" "$aar"; do
bytes=$(wc -c < "$f")
[ "$bytes" -gt 0 ] || { echo "refusing to ship empty $f" >&2; exit 1; }
( cd dist && sha256sum "$(basename "$f")" > "$(basename "$f").sha256" )
done
echo "==> verify"
( cd dist && sha256sum -c ./*.sha256 )
file "$apk" "$aar"
ls -l "$apk" "$aar" "$apk.sha256" "$aar.sha256"
if [ -n "${PUBLISH_TAG:-}" ]; then
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN required to publish}"
echo "==> publishing to release $PUBLISH_TAG"
scripts/publish-release.sh "$PUBLISH_TAG" "OnionWire $PUBLISH_TAG" \
scripts/release-body.md \
"$apk" "$apk.sha256" "$aar" "$aar.sha256"
else
echo "==> not publishing (set PUBLISH_TAG=<tag> and FORGEJO_TOKEN to upload)"
fi

View file

@ -201,6 +201,24 @@ impl Node {
.map_err(|e| e.to_string())
}
/// Fingerprint of our own identity key (identity = pubkey).
pub fn fingerprint(&self) -> Result<String, String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.self_fingerprint()
.map_err(|e| e.to_string())
}
/// Local-only label. Does not go on the wire.
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<(), String> {
self.store
.lock()
.map_err(|e| e.to_string())?
.set_petname(pubkey, petname)
.map_err(|e| e.to_string())
}
pub fn friend(&self, pubkey: &[u8]) -> Result<Friend, String> {
self.store
.lock()

View file

@ -385,6 +385,23 @@ impl Store {
.map_err(Into::into)
}
/// Display fingerprint of our own identity key (identity = pubkey).
pub fn self_fingerprint(&self) -> Result<String> {
Ok(fingerprint(&self.self_identity()?.identity_pk))
}
/// Local petname only. Never part of the wire protocol.
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<()> {
let n = self.conn.execute(
"UPDATE friends SET petname = ?1 WHERE pubkey = ?2",
params![petname, pubkey],
)?;
if n == 0 {
return Err(Error("friend not found".into()));
}
Ok(())
}
pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> {
let now = unix_now();
let fp = fingerprint(pubkey);