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:
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -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")
|
||||||
Executable
+29
@@ -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)."
|
||||||
Reference in New Issue
Block a user