Merge remote-tracking branch 'origin/main' into tmp/ios-statusicon
This commit is contained in:
@@ -1,10 +1,51 @@
|
|||||||
## Clawdis Node (Android) (internal)
|
## 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`.
|
- Open the folder `apps/android`.
|
||||||
|
|
||||||
### Run
|
## Build / Run
|
||||||
- `./gradlew :app:installDebug`
|
|
||||||
|
|
||||||
|
```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-lifecycle:1.3.4")
|
||||||
implementation("androidx.camera:camera-video:1.3.4")
|
implementation("androidx.camera:camera-video:1.3.4")
|
||||||
implementation("androidx.camera:camera-view: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 {
|
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.android") version "1.9.24" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") 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:
|
ClawdisKit:
|
||||||
path: ../shared/ClawdisKit
|
path: ../shared/ClawdisKit
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
Clawdis:
|
||||||
|
shared: true
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
Clawdis: all
|
||||||
|
test:
|
||||||
|
targets:
|
||||||
|
- ClawdisTests
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
Clawdis:
|
Clawdis:
|
||||||
type: application
|
type: application
|
||||||
|
|||||||
Reference in New Issue
Block a user