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:
11
android/.gitignore
vendored
Normal file
11
android/.gitignore
vendored
Normal 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
35
android/README.md
Normal 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.
|
||||
48
android/app/build.gradle.kts
Normal file
48
android/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
24
android/app/src/main/AndroidManifest.xml
Normal file
24
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
86
android/app/src/main/java/io/pecord/tether/MainActivity.kt
Normal file
86
android/app/src/main/java/io/pecord/tether/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
android/app/src/main/java/io/pecord/tether/RoomIdentity.kt
Normal file
17
android/app/src/main/java/io/pecord/tether/RoomIdentity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
97
android/app/src/main/java/io/pecord/tether/TetherStore.kt
Normal file
97
android/app/src/main/java/io/pecord/tether/TetherStore.kt
Normal 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
5
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
16
android/settings.gradle.kts
Normal file
16
android/settings.gradle.kts
Normal 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
29
core/build-android.sh
Executable 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)."
|
||||
Reference in New Issue
Block a user