Merge remote-tracking branch 'origin/main' into tmp/ios-statusicon
This commit is contained in:
@@ -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 <requestId>
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Unit>()
|
||||
|
||||
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<Unit>()
|
||||
|
||||
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<Unit>()
|
||||
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||
)
|
||||
|
||||
val invokeResLine = CompletableDeferred<String>()
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
43
apps/ios/Tests/DeepLinkParserTests.swift
Normal file
43
apps/ios/Tests/DeepLinkParserTests.swift
Normal file
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,16 @@ packages:
|
||||
ClawdisKit:
|
||||
path: ../shared/ClawdisKit
|
||||
|
||||
schemes:
|
||||
Clawdis:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
Clawdis: all
|
||||
test:
|
||||
targets:
|
||||
- ClawdisTests
|
||||
|
||||
targets:
|
||||
Clawdis:
|
||||
type: application
|
||||
|
||||
Reference in New Issue
Block a user