diff --git a/apps/android/README.md b/apps/android/README.md index 576f01e7c..76be2f226 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -1,10 +1,51 @@ ## Clawdis Node (Android) (internal) -Prototype Android “node” app (Iris parity): connects to the Gateway-owned bridge (`_clawdis-bridge._tcp`) over TCP and exposes Canvas + Chat + Camera. +Modern Android “node” app (Iris parity): connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**. -### Open in Android Studio +Notes: +- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). +- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android). +- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose). + +## Open in Android Studio - Open the folder `apps/android`. -### Run -- `./gradlew :app:installDebug` +## Build / Run +```bash +cd apps/android +./gradlew :app:assembleDebug +./gradlew :app:installDebug +./gradlew :app:testDebugUnitTest +``` + +`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. + +## Connect / Pair + +1) Start the gateway (on your “master” machine): +```bash +pnpm clawdis gateway --port 18789 --verbose +``` + +2) In the Android app: +- Open **Settings** +- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port). + +3) Approve pairing (on the gateway machine): +```bash +clawdis nodes pending +clawdis nodes approve +``` + +More details: `docs/android/connect.md`. + +## Permissions + +- Discovery: + - Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES` + - Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning) +- Foreground service notification (Android 13+): `POST_NOTIFICATIONS` +- Camera: + - `CAMERA` for `camera.snap` and `camera.clip` + - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 5ace327a8..2126a55b6 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -76,4 +76,7 @@ dependencies { implementation("androidx.camera:camera-lifecycle:1.3.4") implementation("androidx.camera:camera-video:1.3.4") implementation("androidx.camera:camera-view:1.3.4") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt new file mode 100644 index 000000000..9644bb7c9 --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgePairingClientTest.kt @@ -0,0 +1,101 @@ +package com.steipete.clawdis.node.bridge + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.ServerSocket + +class BridgePairingClientTest { + @Test + fun helloOkReturnsExistingToken() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + val hello = reader.readLine() + assertTrue(hello.contains("\"type\":\"hello\"")) + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + } + } + } + + val client = BridgePairingClient() + val res = + client.pairAndHello( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgePairingClient.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = "token-123", + platform = "Android", + version = "test", + ), + ) + assertTrue(res.ok) + assertEquals("token-123", res.token) + server.await() + } + + @Test + fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + reader.readLine() // hello + writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""") + writer.write("\n") + writer.flush() + + val pairReq = reader.readLine() + assertTrue(pairReq.contains("\"type\":\"pair-request\"")) + writer.write("""{"type":"pair-ok","token":"new-token"}""") + writer.write("\n") + writer.flush() + } + } + } + + val client = BridgePairingClient() + val res = + client.pairAndHello( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgePairingClient.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + assertTrue(res.ok) + assertEquals("new-token", res.token) + server.await() + } +} + diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt new file mode 100644 index 000000000..b7d535f1a --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BridgeSessionTest.kt @@ -0,0 +1,224 @@ +package com.steipete.clawdis.node.bridge + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.ServerSocket + +class BridgeSessionTest { + @Test + fun requestReturnsPayloadJson() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val connected = CompletableDeferred() + + val session = + BridgeSession( + scope = scope, + onConnected = { _, _ -> connected.complete(Unit) }, + onDisconnected = { /* ignore */ }, + onEvent = { _, _ -> /* ignore */ }, + onInvoke = { BridgeSession.InvokeResult.ok(null) }, + ) + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + val hello = reader.readLine() + assertTrue(hello.contains("\"type\":\"hello\"")) + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + + val req = reader.readLine() + assertTrue(req.contains("\"type\":\"req\"")) + val id = extractJsonString(req, "id") + writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""") + writer.write("\n") + writer.flush() + } + } + } + + session.connect( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgeSession.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + + connected.await() + val payload = session.request(method = "health", paramsJson = null) + assertEquals("""{"value":123}""", payload) + server.await() + + session.disconnect() + scope.cancel() + } + + @Test + fun requestThrowsOnErrorResponse() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val connected = CompletableDeferred() + + val session = + BridgeSession( + scope = scope, + onConnected = { _, _ -> connected.complete(Unit) }, + onDisconnected = { /* ignore */ }, + onEvent = { _, _ -> /* ignore */ }, + onInvoke = { BridgeSession.InvokeResult.ok(null) }, + ) + + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + reader.readLine() // hello + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + + val req = reader.readLine() + val id = extractJsonString(req, "id") + writer.write( + """{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""", + ) + writer.write("\n") + writer.flush() + } + } + } + + session.connect( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgeSession.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + connected.await() + + try { + session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""") + throw AssertionError("expected request() to throw") + } catch (e: IllegalStateException) { + assertTrue(e.message?.contains("FORBIDDEN: nope") == true) + } + server.await() + + session.disconnect() + scope.cancel() + } + + @Test + fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking { + val serverSocket = ServerSocket(0) + val port = serverSocket.localPort + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val connected = CompletableDeferred() + + val session = + BridgeSession( + scope = scope, + onConnected = { _, _ -> connected.complete(Unit) }, + onDisconnected = { /* ignore */ }, + onEvent = { _, _ -> /* ignore */ }, + onInvoke = { throw IllegalStateException("FOO_BAR: boom") }, + ) + + val invokeResLine = CompletableDeferred() + val server = + async(Dispatchers.IO) { + serverSocket.use { ss -> + val sock = ss.accept() + sock.use { s -> + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + reader.readLine() // hello + writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") + writer.write("\n") + writer.flush() + + // Ask the node to invoke something; handler will throw. + writer.write("""{"type":"invoke","id":"i1","command":"screen.snapshot","paramsJSON":null}""") + writer.write("\n") + writer.flush() + + val res = reader.readLine() + invokeResLine.complete(res) + } + } + } + + session.connect( + endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port), + hello = + BridgeSession.Hello( + nodeId = "node-1", + displayName = "Android Node", + token = null, + platform = "Android", + version = "test", + ), + ) + connected.await() + + // Give the reader loop time to process. + val line = invokeResLine.await() + assertTrue(line.contains("\"type\":\"invoke-res\"")) + assertTrue(line.contains("\"ok\":false")) + assertTrue(line.contains("\"code\":\"FOO_BAR\"")) + assertTrue(line.contains("\"message\":\"boom\"")) + server.await() + + session.disconnect() + scope.cancel() + } +} + +private fun extractJsonString(raw: String, key: String): String { + val needle = "\"$key\":\"" + val start = raw.indexOf(needle) + if (start < 0) throw IllegalArgumentException("missing key $key in $raw") + val from = start + needle.length + val end = raw.indexOf('"', from) + if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw") + return raw.substring(from, end) +} diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 8c8f431e9..3504169f1 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.6.1" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false } - diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift new file mode 100644 index 000000000..35615d56a --- /dev/null +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -0,0 +1,43 @@ +import ClawdisKit +import Foundation +import Testing + +@Suite struct DeepLinkParserTests { + @Test func parseRejectsNonClawdisScheme() { + let url = URL(string: "https://example.com/agent?message=hi")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseRejectsEmptyMessage() { + let url = URL(string: "clawdis://agent?message=%20%20%0A")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseAgentLinkParsesCommonFields() { + let url = URL(string: "clawdis://agent?message=Hello&deliver=1&sessionKey=node-iris&thinking=low&timeoutSeconds=30")! + #expect( + DeepLinkParser.parse(url) == .agent( + .init( + message: "Hello", + sessionKey: "node-iris", + thinking: "low", + deliver: true, + to: nil, + channel: nil, + timeoutSeconds: 30, + key: nil))) + } + + @Test func parseRejectsNegativeTimeoutSeconds() { + let url = URL(string: "clawdis://agent?message=Hello&timeoutSeconds=-1")! + #expect(DeepLinkParser.parse(url) == .agent(.init( + message: "Hello", + sessionKey: nil, + thinking: nil, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: nil))) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2da19ab13..1c4dc1c58 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -9,6 +9,16 @@ packages: ClawdisKit: path: ../shared/ClawdisKit +schemes: + Clawdis: + shared: true + build: + targets: + Clawdis: all + test: + targets: + - ClawdisTests + targets: Clawdis: type: application