android: Compose client scaffold on tethercore (Kotlin/UniFFI)

Add an Android app that drives the same tethercore engine (SSE + RTC) as the
iOS/macOS apps via generated Kotlin/UniFFI bindings — proving the shared core
reaches a fourth language with no protocol reimplementation. Engine surface is
identical to Swift: Engine(server,room,from,source) / start / send / stop.

Includes the Gradle/Compose project, a TetherStore that bridges the engine to
ClipboardManager (auto-send via OnPrimaryClipChangedListener — Android allows
foreground clipboard observation, unlike iOS), per-install RoomIdentity, and
core/build-android.sh (cargo-ndk → jniLibs + Kotlin bindgen).

Not built here: this machine has no Android SDK/NDK, so the native .so and
Kotlin binding are generated (git-ignored), not committed. README documents the
NDK install + build steps. The binding itself was confirmed to generate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 23:42:36 -05:00
parent 1caa44e253
commit 3581c1e86c
11 changed files with 372 additions and 0 deletions

11
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Gradle / Android Studio
.gradle/
build/
local.properties
.idea/
*.iml
captures/
# Generated by core/build-android.sh — regenerate, don't commit
app/src/main/jniLibs/
app/src/main/java/uniffi/

35
android/README.md Normal file
View File

@@ -0,0 +1,35 @@
# tether — Android client
Compose UI over the shared `tethercore` Rust engine (SSE + RTC) via UniFFI
Kotlin bindings. Same engine as the iOS/macOS apps and the desktop agent; only
the UI and the binding language differ.
## Build (requires the Android NDK)
This machine had no Android toolchain when the app was scaffolded, so the
native `.so` and Kotlin bindings are **not** committed — they're generated:
1. Install the toolchain:
- Android Studio (SDK + NDK), or `sdkmanager "ndk;26.x"`
- `cargo install cargo-ndk`
- `rustup target add aarch64-linux-android x86_64-linux-android`
- export `ANDROID_NDK_HOME=/path/to/ndk`
2. Generate native libs + bindings:
```sh
../core/build-android.sh
```
This places `libtethercore.so` under `app/src/main/jniLibs/<abi>/` and the
Kotlin binding under `app/src/main/java/uniffi/tethercore/`.
3. Build/run the app: open `android/` in Android Studio (it generates the
Gradle wrapper on first sync), then Run. From the CLI, generate the wrapper
once with `gradle wrapper`, then `./gradlew :app:assembleDebug`.
## Notes
- Default server is `http://10.0.2.2:8765` — the emulator's alias for the host
machine's `localhost`. On a physical device use the relay's LAN address.
- Android (unlike iOS) lets a foreground app observe clipboard changes, so the
app auto-sends via `OnPrimaryClipChangedListener`. A foreground Service would
extend that to the background (future work).
- The room id is a per-install UUID (`RoomIdentity`); account-derived shared
rooms are future work, matching the iOS plan.

View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "io.pecord.tether"
compileSdk = 35
defaultConfig {
applicationId = "io.pecord.tether"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// Limit to the ABIs core/build-android.sh produces.
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
// Compose compiler comes from the org.jetbrains.kotlin.plugin.compose plugin
// (Kotlin 2.0+); no kotlinCompilerExtensionVersion needed.
// jniLibs/<abi>/libtethercore.so are placed by core/build-android.sh
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
// UniFFI Kotlin runtime requirements.
implementation("net.java.dev.jna:jna:5.14.0@aar")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="tether"
android:supportsRtl="true"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<!-- Cleartext to the LAN relay (http://). Tighten for production. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:usesCleartextTraffic="true">
<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,86 @@
package io.pecord.tether
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val store = TetherStore(applicationContext)
setContent { MaterialTheme { RoomScreen(store) } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomScreen(store: TetherStore) {
Scaffold(topBar = { TopAppBar(title = { Text("tether") }) }) { pad ->
Column(
Modifier.padding(pad).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = store.serverURL.value,
onValueChange = { store.serverURL.value = it },
label = { Text("Server URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = store.room.value,
onValueChange = { store.room.value = it },
label = { Text("Room") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(onClick = {
if (store.connected.value) store.disconnect() else store.connect()
}) {
Text(if (store.connected.value) "Disconnect" else "Connect")
}
}
Button(
onClick = { store.sendCurrentClipboard() },
enabled = store.connected.value,
modifier = Modifier.fillMaxWidth(),
) { Text("Send clipboard") }
HorizontalDivider()
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(store.feed, key = { it.id }) { item ->
Card(
onClick = { store.copyToClipboard(item) },
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(10.dp)) {
Text(item.text, maxLines = 4)
Text(item.source, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
package io.pecord.tether
import android.content.Context
import java.util.UUID
/// Stable 8-hex room id with no user configuration — the Android counterpart to
/// the iOS Keychain UUID. Generated once, persisted in SharedPreferences.
/// SiwA/account-derived identity can replace this later for cross-device rooms.
object RoomIdentity {
fun derive(context: Context): String {
val prefs = context.getSharedPreferences("tether", Context.MODE_PRIVATE)
prefs.getString("roomID", null)?.let { return it }
val id = UUID.randomUUID().toString().filter { it.isDigit() || it in 'a'..'f' }.take(8)
prefs.edit().putString("roomID", id).apply()
return id
}
}

View File

@@ -0,0 +1,97 @@
package io.pecord.tether
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import uniffi.tethercore.Engine
import uniffi.tethercore.Message
import uniffi.tethercore.MessageHandler
data class FeedItem(val text: String, val source: String, val ts: Long, val id: Long)
/// Drives the shared tethercore.Engine (SSE + RTC) and bridges it to the Android
/// clipboard. Unlike iOS, Android lets a foreground app observe clipboard
/// changes, so we auto-send via OnPrimaryClipChangedListener.
class TetherStore(private val context: Context) {
val serverURL = mutableStateOf(prefs().getString("serverURL", "http://10.0.2.2:8765")!!)
val room = mutableStateOf(RoomIdentity.derive(context))
val feed = mutableStateListOf<FeedItem>()
val connected = mutableStateOf(false)
private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
private val main = Handler(Looper.getMainLooper())
private val deviceId = "android-" + RoomIdentity.derive(context)
private var engine: Engine? = null
private var lastClip: String = ""
private var counter = 0L
fun connect() {
prefs().edit().putString("serverURL", serverURL.value).apply()
disconnect()
val e = Engine(serverURL.value.trim(), room.value, deviceId, "android")
e.start(Bridge())
engine = e
connected.value = true
clipboard.addPrimaryClipChangedListener(clipListener)
}
fun disconnect() {
clipboard.removePrimaryClipChangedListener(clipListener)
engine?.stop()
engine?.close()
engine = null
connected.value = false
}
fun sendCurrentClipboard() {
currentClipText()?.takeIf { it.isNotEmpty() }?.let { engine?.send(it) }
}
fun copyToClipboard(item: FeedItem) = writeClipboard(item.text)
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener {
val t = currentClipText() ?: return@OnPrimaryClipChangedListener
if (t.isNotEmpty() && t != lastClip) {
lastClip = t
engine?.send(t)
}
}
private fun currentClipText(): String? =
clipboard.primaryClip?.takeIf { it.itemCount > 0 }
?.getItemAt(0)?.coerceToText(context)?.toString()
private fun writeClipboard(text: String) {
lastClip = text // suppress echo
clipboard.setPrimaryClip(ClipData.newPlainText("tether", text))
}
private fun prefs() = context.getSharedPreferences("tether", Context.MODE_PRIVATE)
/// Engine callbacks fire on the Rust runtime thread — marshal to main.
private inner class Bridge : MessageHandler {
override fun onMessage(msg: Message) {
main.post {
feed.add(
0,
FeedItem(
msg.text,
msg.source.ifEmpty { "unknown" },
if (msg.ts > 0) msg.ts else System.currentTimeMillis(),
counter++,
),
)
if (feed.size > 100) feed.removeAt(feed.size - 1)
writeClipboard(msg.text) // mirror received clipboard locally
}
}
override fun onStatus(connected: Boolean) {
main.post { this@TetherStore.connected.value = connected }
}
}
}

5
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "tether"
include(":app")

29
core/build-android.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Builds tethercore for Android and drops the artifacts the Gradle app needs:
# - libtethercore.so per ABI → app/src/main/jniLibs/<abi>/
# - Kotlin bindings → app/src/main/java/uniffi/tethercore/
#
# Requires the Android NDK + cargo-ndk (cargo install cargo-ndk) and
# ANDROID_NDK_HOME pointing at the NDK. Outputs are git-ignored.
set -euo pipefail
cd "$(dirname "$0")"
APP="../android/app/src/main"
ABIS=(arm64-v8a x86_64)
if ! command -v cargo-ndk >/dev/null; then
echo "missing cargo-ndk → cargo install cargo-ndk; also install the Android NDK and set ANDROID_NDK_HOME" >&2
exit 1
fi
echo "▸ building .so for ${ABIS[*]}"
rustup target add aarch64-linux-android x86_64-linux-android >/dev/null 2>&1 || true
cargo ndk -t arm64-v8a -t x86_64 -o "$APP/jniLibs" build --release
echo "▸ generating Kotlin bindings…"
cargo build --release --lib
cargo run --release --bin uniffi-bindgen -- \
generate --library target/release/libtethercore.dylib \
--language kotlin --out-dir "$APP/java"
echo "✅ done — open ../android in Android Studio (or ./gradlew :app:assembleDebug)."