feat(android): add Compose node app (bridge+canvas+chat+camera)

This commit is contained in:
Peter Steinberger
2025-12-14 01:54:58 +00:00
parent e2451484d9
commit b2378c01ea
27 changed files with 2518 additions and 1 deletions

5
apps/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
.idea/
**/*.iml

10
apps/android/README.md Normal file
View File

@@ -0,0 +1,10 @@
## 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.
### Open in Android Studio
- Open the folder `apps/android`.
### Run
- `./gradlew :app:installDebug`

View File

@@ -0,0 +1,79 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.steipete.clawdis.node"
compileSdk = 34
defaultConfig {
applicationId = "com.steipete.clawdis.node"
minSdk = 31
targetSdk = 34
versionCode = 1
versionName = "0.1"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.activity:activity-compose:1.9.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.7.7")
debugImplementation("androidx.compose.ui:ui-tooling")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.3.4")
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-video:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
}

View File

@@ -0,0 +1,27 @@
<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" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ClawdisNode">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,62 @@
package com.steipete.clawdis.node
import android.Manifest
import android.os.Bundle
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.ui.RootScreen
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestDiscoveryPermissionsIfNeeded()
viewModel.camera.attachLifecycleOwner(this)
setContent {
MaterialTheme {
Surface(modifier = Modifier) {
RootScreen(viewModel = viewModel)
}
}
}
}
override fun onStart() {
super.onStart()
viewModel.setForeground(true)
}
override fun onStop() {
viewModel.setForeground(false)
super.onStop()
}
private fun requestDiscoveryPermissionsIfNeeded() {
if (Build.VERSION.SDK_INT >= 33) {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.NEARBY_WIFI_DEVICES,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
}
} else {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
}
}
}
}

View File

@@ -0,0 +1,403 @@
package com.steipete.clawdis.node
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.steipete.clawdis.node.bridge.BridgeDiscovery
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient
import com.steipete.clawdis.node.bridge.BridgeSession
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val prefs = SecurePrefs(app)
val canvas = CanvasController()
val camera = CameraCaptureManager(app)
private val json = Json { ignoreUnknownKeys = true }
private val discovery = BridgeDiscovery(app)
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _statusText = MutableStateFlow("Not connected")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _serverName = MutableStateFlow<String?>(null)
val serverName: StateFlow<String?> = _serverName.asStateFlow()
private val _remoteAddress = MutableStateFlow<String?>(null)
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private val session =
BridgeSession(
scope = viewModelScope,
onConnected = { name, remote ->
_statusText.value = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
},
onDisconnected = { message ->
_statusText.value = message
_serverName.value = null
_remoteAddress.value = null
_isConnected.value = false
},
onEvent = { event, payloadJson ->
handleBridgeEvent(event, payloadJson)
},
onInvoke = { req ->
handleInvoke(req.command, req.paramsJson)
},
)
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
private var didAutoConnect = false
init {
viewModelScope.launch(Dispatchers.Default) {
bridges.collect { list ->
if (list.isNotEmpty()) {
// Persist the last discovered bridge (best-effort UX parity with iOS).
prefs.setLastDiscoveredStableId(list.last().stableId)
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
didAutoConnect = true
connect(BridgeEndpoint.manual(host = host, port = port))
}
return@collect
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
didAutoConnect = true
connect(target)
}
}
}
fun setForeground(value: Boolean) {
_isForeground.value = value
}
fun setDisplayName(value: String) {
prefs.setDisplayName(value)
}
fun setCameraEnabled(value: Boolean) {
prefs.setCameraEnabled(value)
}
fun setManualEnabled(value: Boolean) {
prefs.setManualEnabled(value)
}
fun setManualHost(value: String) {
prefs.setManualHost(value)
}
fun setManualPort(value: Int) {
prefs.setManualPort(value)
}
fun connect(endpoint: BridgeEndpoint) {
viewModelScope.launch(Dispatchers.IO) {
_statusText.value = "Connecting…"
val token = prefs.loadBridgeToken()
val resolved =
if (token.isNullOrBlank()) {
_statusText.value = "Pairing…"
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello = BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = null,
platform = "Android",
version = "dev",
),
)
} else {
BridgePairingClient.PairResult(ok = true, token = token.trim())
}
if (!resolved.ok || resolved.token.isNullOrBlank()) {
_statusText.value = "Failed: pairing required"
return@launch
}
prefs.saveBridgeToken(resolved.token!!)
session.connect(
endpoint = endpoint,
hello = BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = resolved.token,
platform = "Android",
version = "dev",
),
)
}
}
fun connectManual() {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port <= 0 || port > 65535) {
_statusText.value = "Failed: invalid manual host/port"
return
}
connect(BridgeEndpoint.manual(host = host, port = port))
}
fun disconnect() {
session.disconnect()
}
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
private val _chatError = MutableStateFlow<String?>(null)
val chatError: StateFlow<String?> = _chatError.asStateFlow()
private val pendingRuns = mutableSetOf<String>()
private val _pendingRunCount = MutableStateFlow(0)
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
fun loadChat(sessionKey: String = "main") {
viewModelScope.launch(Dispatchers.IO) {
_chatError.value = null
try {
// Best-effort; push events are optional, but improve latency.
session.sendEvent("chat.subscribe", """{"sessionKey":"$sessionKey"}""")
} catch (_: Throwable) {
// ignore
}
try {
val res = session.request("chat.history", """{"sessionKey":"$sessionKey"}""")
_chatMessages.value = parseHistory(res)
} catch (e: Exception) {
_chatError.value = e.message
}
}
}
fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") {
val trimmed = message.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_chatError.value = null
val idem = java.util.UUID.randomUUID().toString()
_chatMessages.value =
_chatMessages.value +
ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = "user",
text = trimmed,
timestampMs = System.currentTimeMillis(),
)
try {
val params =
"""{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}"""
val res = session.request("chat.send", params)
val runId = parseRunId(res) ?: idem
pendingRuns.add(runId)
_pendingRunCount.value = pendingRuns.size
} catch (e: Exception) {
_chatError.value = e.message
}
}
}
private fun handleBridgeEvent(event: String, payloadJson: String?) {
if (event != "chat" || payloadJson.isNullOrBlank()) return
try {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val state = payload["state"].asStringOrNull()
val runId = payload["runId"].asStringOrNull()
if (!runId.isNullOrBlank()) {
pendingRuns.remove(runId)
_pendingRunCount.value = pendingRuns.size
}
when (state) {
"final" -> {
val msgObj = payload["message"].asObjectOrNull()
val role = msgObj?.get("role").asStringOrNull() ?: "assistant"
val text = extractTextFromMessage(msgObj)
if (!text.isNullOrBlank()) {
_chatMessages.value =
_chatMessages.value +
ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = role,
text = text,
timestampMs = System.currentTimeMillis(),
)
}
}
"error" -> {
_chatError.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
}
}
} catch (_: Throwable) {
// ignore
}
}
private fun parseHistory(historyJson: String): List<ChatMessage> {
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
val raw = root["messages"] ?: return emptyList()
val array = raw as? JsonArray ?: return emptyList()
return array.mapNotNull { item ->
val obj = item as? JsonObject ?: return@mapNotNull null
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
val text = extractTextFromMessage(obj) ?: return@mapNotNull null
ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = role,
text = text,
timestampMs = null,
)
}
}
private fun extractTextFromMessage(msgObj: JsonObject?): String? {
if (msgObj == null) return null
val content = msgObj["content"] ?: return null
return when (content) {
is JsonPrimitive -> content.asStringOrNull()
else -> {
val arr = (content as? JsonArray) ?: return null
arr.mapNotNull { part ->
val p = part as? JsonObject ?: return@mapNotNull null
p["text"].asStringOrNull()
}.joinToString("\n").trim().ifBlank { null }
}
}
}
private fun parseRunId(resJson: String): String? {
return try {
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
} catch (_: Throwable) {
null
}
}
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
if ((command.startsWith("screen.") || command.startsWith("camera.")) && !isForeground.value) {
return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: screen/camera commands require foreground",
)
}
if (command.startsWith("camera.") && !cameraEnabled.value) {
return BridgeSession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
return when (command) {
"screen.show" -> BridgeSession.InvokeResult.ok(null)
"screen.hide" -> BridgeSession.InvokeResult.ok(null)
"screen.setMode" -> {
val mode = CanvasController.parseMode(paramsJson)
canvas.setMode(mode)
BridgeSession.InvokeResult.ok(null)
}
"screen.navigate" -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
if (url != null) canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
}
"screen.eval" -> {
val js = CanvasController.parseEvalJs(paramsJson) ?: return BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required",
)
val result = canvas.eval(js)
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
"screen.snapshot" -> {
val maxWidth = CanvasController.parseSnapshotMaxWidth(paramsJson)
val base64 = canvas.snapshotPngBase64(maxWidth = maxWidth)
BridgeSession.InvokeResult.ok("""{"format":"png","base64":"$base64"}""")
}
"camera.snap" -> {
val res = camera.snap(paramsJson)
BridgeSession.InvokeResult.ok(res.payloadJson)
}
"camera.clip" -> {
val res = camera.clip(paramsJson)
BridgeSession.InvokeResult.ok(res.payloadJson)
}
else ->
BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
private fun String.toJsonString(): String {
val escaped =
this.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$escaped\""
}

View File

@@ -0,0 +1,97 @@
package com.steipete.clawdis.node
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
class SecurePrefs(context: Context) {
private val masterKey =
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs =
EncryptedSharedPreferences.create(
context,
"clawdis.node.secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
private val _displayName = MutableStateFlow(prefs.getString("node.displayName", "Android Node")!!)
val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
val manualHost: StateFlow<String> = _manualHost
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
val manualPort: StateFlow<Int> = _manualPort
private val _lastDiscoveredStableId =
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
prefs.edit().putString("node.displayName", trimmed).apply()
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
prefs.edit().putBoolean("camera.enabled", value).apply()
_cameraEnabled.value = value
}
fun setManualEnabled(value: Boolean) {
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.manual.host", trimmed).apply()
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit().putInt("bridge.manual.port", value).apply()
_manualPort.value = value
}
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)
}
fun saveBridgeToken(token: String) {
val key = "bridge.token.${_instanceId.value}"
prefs.edit().putString(key, token.trim()).apply()
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit().putString("node.instanceId", fresh).apply()
return fresh
}
}

View File

@@ -0,0 +1,88 @@
package com.steipete.clawdis.node.bridge
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
class BridgeDiscovery(context: Context) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val serviceType = "_clawdis-bridge._tcp."
private val byId = ConcurrentHashMap<String, BridgeEndpoint>()
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
resolve(serviceInfo)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val id = stableId(serviceInfo)
byId.remove(id)
publish()
}
}
init {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun resolve(serviceInfo: NsdServiceInfo) {
nsd.resolveService(
serviceInfo,
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
override fun onServiceResolved(resolved: NsdServiceInfo) {
val host = resolved.host?.hostAddress ?: return
val port = resolved.port
if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
val id = stableId(resolved)
byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish()
}
},
)
}
private fun publish() {
_bridges.value = byId.values.sortedBy { it.name.lowercase() }
}
private fun stableId(info: NsdServiceInfo): String {
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}"
}
private fun normalizeName(raw: String): String {
return raw.trim().split(Regex("\\s+")).joinToString(" ")
}
private fun txt(info: NsdServiceInfo, key: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
} catch (_: Throwable) {
null
}
}
}

View File

@@ -0,0 +1,19 @@
package com.steipete.clawdis.node.bridge
data class BridgeEndpoint(
val stableId: String,
val name: String,
val host: String,
val port: Int,
) {
companion object {
fun manual(host: String, port: Int): BridgeEndpoint =
BridgeEndpoint(
stableId = "manual|$host|$port",
name = "$host:$port",
host = host,
port = port,
)
}
}

View File

@@ -0,0 +1,118 @@
package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.Socket
class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true }
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
)
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
try {
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
},
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
}
}
}
PairResult(ok = false, token = null, error = "pairing failed")
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}

View File

@@ -0,0 +1,303 @@
package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
) {
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
)
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
companion object {
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
fun error(code: String, message: String) =
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
}
}
data class ErrorShape(val code: String, val message: String)
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
desired = endpoint to hello
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
}
fun disconnect() {
desired = null
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
job = null
onDisconnected("Disconnected")
}
}
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("event"))
put("event", JsonPrimitive(event))
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
},
)
}
suspend fun request(method: String, paramsJson: String?): String {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
},
)
val res = deferred.await()
if (res.ok) return res.payloadJson ?: ""
val err = res.error
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
val remoteAddress: String? =
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
suspend fun sendJson(obj: JsonObject) {
writeLock.withLock {
writer.write(obj.toString())
writer.write("\n")
writer.flush()
}
}
fun closeQuietly() {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
@Volatile private var currentConnection: Connection? = null
private suspend fun runLoop() {
var attempt = 0
while (scope.isActive) {
val target = desired
if (target == null) {
currentConnection?.closeQuietly()
currentConnection = null
delay(250)
continue
}
val (endpoint, hello) = target
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello)
attempt = 0
} catch (err: Throwable) {
attempt += 1
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs)
}
}
}
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
val parts = msg.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
}
}
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
}
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
val conn = Connection(socket, reader, writer, writeLock)
currentConnection = conn
try {
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
},
)
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
onConnected(name, conn.remoteAddress)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: return@withContext
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
}
"ping" -> {
val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
}
"res" -> {
val id = frame["id"].asStringOrNull() ?: continue
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payloadJSON"].asStringOrNull()
val error =
frame["error"]?.let {
val obj = it.asObjectOrNull() ?: return@let null
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
ErrorShape(code, msg)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
}
"invoke" -> {
val id = frame["id"].asStringOrNull() ?: continue
val command = frame["command"].asStringOrNull() ?: ""
val params = frame["paramsJSON"].asStringOrNull()
val result =
try {
onInvoke(InvokeRequest(id, command, params))
} catch (err: Throwable) {
invokeErrorFromThrowable(err)
}
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("invoke-res"))
put("id", JsonPrimitive(id))
put("ok", JsonPrimitive(result.ok))
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
if (result.error != null) {
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(result.error.code))
put("message", JsonPrimitive(result.error.message))
},
)
}
},
)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
}
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> {
val c = content.trim()
when {
c.equals("true", ignoreCase = true) -> true
c.equals("false", ignoreCase = true) -> false
else -> null
}
}
else -> null
}

View File

@@ -0,0 +1,235 @@
package com.steipete.clawdis.node.node
import android.Manifest
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.content.pm.PackageManager
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class CameraCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
@Volatile private var lifecycleOwner: LifecycleOwner? = null
fun attachLifecycleOwner(owner: LifecycleOwner) {
lifecycleOwner = owner
}
private fun requireCameraPermission() {
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (!granted) throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
private fun requireMicPermission() {
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (!granted) throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
suspend fun snap(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
requireCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(paramsJson)
val provider = context.cameraProvider()
val capture = ImageCapture.Builder().build()
val selector =
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture)
val bytes = capture.takeJpegBytes(context.mainExecutor())
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val scaled =
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
val h =
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt()
.coerceAtLeast(1)
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
} else {
decoded
}
val out = ByteArrayOutputStream()
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
Payload(
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
)
}
suspend fun clip(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
requireCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 45_000)
val includeAudio = parseIncludeAudio(paramsJson) ?: true
if (includeAudio) requireMicPermission()
val provider = context.cameraProvider()
val recorder = Recorder.Builder().build()
val videoCapture = VideoCapture.withOutput(recorder)
val selector =
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
provider.unbindAll()
provider.bindToLifecycle(owner, selector, videoCapture)
val file = File.createTempFile("clawdis-clip-", ".mp4")
val outputOptions = FileOutputOptions.Builder(file).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
val recording: Recording =
videoCapture.output
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}
.start(context.mainExecutor()) { event ->
if (event is VideoRecordEvent.Finalize) {
finalized.complete(event)
}
}
try {
kotlinx.coroutines.delay(durationMs.toLong())
} finally {
recording.stop()
}
val finalizeEvent =
try {
withTimeout(10_000) { finalized.await() }
} catch (err: Throwable) {
file.delete()
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
}
if (finalizeEvent.hasError()) {
file.delete()
throw IllegalStateException("UNAVAILABLE: camera clip failed")
}
val bytes = file.readBytes()
file.delete()
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
Payload(
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
)
}
private fun parseFacing(paramsJson: String?): String? =
when {
paramsJson?.contains("\"front\"") == true -> "front"
paramsJson?.contains("\"back\"") == true -> "back"
else -> null
}
private fun parseQuality(paramsJson: String?): Double? =
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
private fun parseMaxWidth(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
private fun parseDurationMs(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
val raw = paramsJson ?: return null
val key = "\"includeAudio\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + key.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return when {
tail.startsWith("true") -> true
tail.startsWith("false") -> false
else -> null
}
}
private fun parseNumber(paramsJson: String?, key: String): String? {
val raw = paramsJson ?: return null
val needle = "\"$key\""
val idx = raw.indexOf(needle)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + needle.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return tail.takeWhile { it.isDigit() || it == '.' }
}
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
}
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
suspendCancellableCoroutine { cont ->
val future = ProcessCameraProvider.getInstance(this)
future.addListener(
{
try {
cont.resume(future.get())
} catch (e: Exception) {
cont.resumeWithException(e)
}
},
ContextCompat.getMainExecutor(this),
)
}
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("clawdis-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture(
options,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {
cont.resumeWithException(exception)
}
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try {
val bytes = file.readBytes()
cont.resume(bytes)
} catch (e: Exception) {
cont.resumeWithException(e)
} finally {
file.delete()
}
}
},
)
}

View File

@@ -0,0 +1,242 @@
package com.steipete.clawdis.node.node
import android.graphics.Bitmap
import android.os.Build
import android.graphics.Canvas
import android.webkit.WebView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
import kotlin.coroutines.resume
class CanvasController {
enum class Mode { CANVAS, WEB }
@Volatile private var webView: WebView? = null
@Volatile private var mode: Mode = Mode.CANVAS
@Volatile private var url: String = ""
fun attach(webView: WebView) {
this.webView = webView
reload()
}
fun setMode(mode: Mode) {
this.mode = mode
reload()
}
fun navigate(url: String) {
this.url = url
reload()
}
private fun reload() {
val wv = webView ?: return
when (mode) {
Mode.WEB -> wv.loadUrl(url.trim())
Mode.CANVAS -> wv.loadDataWithBaseURL(null, canvasHtml, "text/html", "utf-8", null)
}
}
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
suspendCancellableCoroutine { cont ->
wv.evaluateJavascript(javaScript) { result ->
cont.resume(result ?: "")
}
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
} else {
bmp
}
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
private suspend fun WebView.captureBitmap(): Bitmap =
suspendCancellableCoroutine { cont ->
val width = width.coerceAtLeast(1)
val height = height.coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
// cross-version snapshot for this lightweight "canvas" use-case.
draw(Canvas(bitmap))
cont.resume(bitmap)
}
companion object {
fun parseMode(paramsJson: String?): Mode {
val raw = paramsJson ?: return Mode.CANVAS
return if (raw.contains("\"web\"")) Mode.WEB else Mode.CANVAS
}
fun parseNavigateUrl(paramsJson: String?): String? {
val raw = paramsJson ?: return null
val key = "\"url\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val start = raw.indexOf('"', idx + key.length)
if (start < 0) return null
val end = raw.indexOf('"', start + 1)
if (end < 0) return null
return raw.substring(start + 1, end)
}
fun parseEvalJs(paramsJson: String?): String? {
val raw = paramsJson ?: return null
val key = "\"javaScript\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val start = raw.indexOf('"', idx + key.length)
if (start < 0) return null
val end = raw.lastIndexOf('"')
if (end <= start) return null
return raw.substring(start + 1, end)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
}
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
val raw = paramsJson ?: return null
val key = "\"maxWidth\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + key.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
val num = tail.takeWhile { it.isDigit() }
return num.toIntOrNull()
}
}
}
private val canvasHtml =
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
html,body { height:100%; margin:0; }
body {
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
#000;
overflow: hidden;
}
body::before {
content:"";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px);
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
}
canvas {
display:block;
width:100vw;
height:100vh;
touch-action: none;
}
#clawdis-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
#clawdis-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
backdrop-filter: blur(14px);
}
#clawdis-status .title {
font: 600 20px system-ui, sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
}
#clawdis-status .subtitle {
margin-top: 6px;
font: 500 12px system-ui, sans-serif;
color: rgba(255,255,255,0.58);
}
</style>
</head>
<body>
<canvas id="clawdis-canvas"></canvas>
<div id="clawdis-status">
<div class="card">
<div class="title" id="clawdis-status-title">Ready</div>
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
window.__clawdis = {
canvas,
ctx,
setStatus: (title, subtitle) => {
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
};
})();
</script>
</body>
</html>
""".trimIndent()

View File

@@ -0,0 +1,73 @@
package com.steipete.clawdis.node.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.MainViewModel
@Composable
fun ChatSheet(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
val error by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
var input by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.loadChat("main")
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Clawd Chat · session main")
if (!error.isNullOrBlank()) {
Text("Error: $error")
}
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
items(messages) { msg ->
Text("${msg.role}: ${msg.text}")
}
if (pendingRunCount > 0) {
item { Text("assistant: …") }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
label = { Text("Message") },
)
Button(
onClick = {
val text = input
input = ""
viewModel.sendChat("main", text)
},
enabled = input.trim().isNotEmpty(),
) {
Text("Send")
}
}
}
}

View File

@@ -0,0 +1,80 @@
package com.steipete.clawdis.node.ui
import android.annotation.SuppressLint
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.steipete.clawdis.node.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel)
Box(modifier = Modifier.align(Alignment.TopEnd).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
}
Box(modifier = Modifier.align(Alignment.TopStart).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
}
}
if (sheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (sheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
null -> {}
}
}
}
}
private enum class Sheet {
Chat,
Settings,
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun CanvasView(viewModel: MainViewModel) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = false
webViewClient = WebViewClient()
setBackgroundColor(0x00000000)
viewModel.canvas.attach(this)
}
},
)
}

View File

@@ -0,0 +1,146 @@
package com.steipete.clawdis.node.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.Divider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.MainViewModel
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val bridges by viewModel.bridges.collectAsState()
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val cameraOk = perms[Manifest.permission.CAMERA] == true
viewModel.setCameraEnabled(cameraOk)
}
Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Node")
OutlinedTextField(
value = displayName,
onValueChange = viewModel::setDisplayName,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
Text("Instance ID: $instanceId")
Divider()
Text("Camera")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(
checked = cameraEnabled,
onCheckedChange = { enabled ->
if (!enabled) {
viewModel.setCameraEnabled(false)
return@Switch
}
val cameraOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (cameraOk) {
viewModel.setCameraEnabled(true)
} else {
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
}
},
)
Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled")
}
Text("Tip: grant Microphone permission for video clips with audio.")
Divider()
Text("Bridge")
Text("Status: $statusText")
if (serverName != null) Text("Server: $serverName")
if (remoteAddress != null) Text("Address: $remoteAddress")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = viewModel::disconnect) { Text("Disconnect") }
}
Divider()
Text("Advanced")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled)
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled")
}
OutlinedTextField(
value = manualHost,
onValueChange = viewModel::setManualHost,
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = manualPort.toString(),
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
label = { Text("Port") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
Button(onClick = viewModel::connectManual, enabled = manualEnabled) { Text("Connect (Manual)") }
Divider()
Text("Discovered Bridges")
if (bridges.isEmpty()) {
Text("No bridges found yet.")
} else {
LazyColumn(modifier = Modifier.fillMaxWidth().height(240.dp)) {
items(bridges) { bridge ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(bridge.name)
Text("${bridge.host}:${bridge.port}")
}
Spacer(modifier = Modifier.padding(4.dp))
Button(onClick = { viewModel.connect(bridge) }) { Text("Connect") }
}
Divider()
}
}
}
Spacer(modifier = Modifier.height(20.dp))
}
}

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Clawdis Node</string>
</resources>

View File

@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>

View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.5.2" 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
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

276
apps/android/gradlew vendored Executable file
View File

@@ -0,0 +1,276 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Android Gradle Plugin requires a supported JDK (typically 17).
# On macOS, prefer JDK 17 when JAVA_HOME isn't set.
if [ "$darwin" = "true" ] && [ -z "$JAVA_HOME" ] ; then
if [ -x "/usr/libexec/java_home" ] ; then
jdk17=$(/usr/libexec/java_home -v 17 2>/dev/null)
if [ -n "$jdk17" ] && [ -d "$jdk17" ] ; then
JAVA_HOME=$jdk17
export JAVA_HOME
fi
fi
fi
# If the Android SDK isn't configured, try common default locations.
if [ -z "$ANDROID_SDK_ROOT" ] ; then
if [ -d "$HOME/Library/Android/sdk" ] ; then
ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
export ANDROID_SDK_ROOT
elif [ -d "$HOME/Android/Sdk" ] ; then
ANDROID_SDK_ROOT="$HOME/Android/Sdk"
export ANDROID_SDK_ROOT
fi
fi
if [ -z "$ANDROID_HOME" ] && [ -n "$ANDROID_SDK_ROOT" ] ; then
ANDROID_HOME="$ANDROID_SDK_ROOT"
export ANDROID_HOME
fi
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
apps/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ClawdisNodeAndroid"
include(":app")

94
docs/android/connect.md Normal file
View File

@@ -0,0 +1,94 @@
---
summary: "Runbook: connect/pair the Android node to a Clawdis Gateway and use Canvas/Chat/Camera"
read_when:
- Pairing or reconnecting the Android node
- Debugging Android bridge discovery or auth
- Verifying chat history parity across clients
---
# Android Node Connection Runbook
Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
## Prerequisites
- You can run the Gateway on the “master” machine.
- Android device/emulator is on the same LAN (mDNS must work) or you know the gateways LAN IP for manual connect.
- You can run the CLI (`clawdis`) on the gateway machine (or via SSH).
## 1) Start the Gateway (with bridge enabled)
Bridge is enabled by default (disable via `CLAWDIS_BRIDGE_ENABLED=0`).
```bash
pnpm clawdis gateway --port 18789 --verbose
```
Confirm in logs you see something like:
- `bridge listening on tcp://0.0.0.0:18790 (Iris)`
## 2) Verify discovery (optional)
From the gateway machine:
```bash
dns-sd -B _clawdis-bridge._tcp local.
```
More debugging notes: `docs/bonjour.md`.
## 3) Connect from Android
In the Android app:
- Open **Settings**.
- Under **Discovered Bridges**, select your gateway and hit **Connect**.
- If mDNS is blocked, use **Advanced → Manual Bridge** (host + port) and **Connect (Manual)**.
After the first successful pairing, Android auto-reconnects on launch:
- Manual endpoint (if enabled), otherwise
- The last discovered bridge (best-effort).
## 4) Approve pairing (CLI)
On the gateway machine:
```bash
clawdis nodes pending
clawdis nodes approve <requestId>
```
Pairing details: `docs/gateway/pairing.md`.
## 5) Verify the node is connected
- Via nodes list:
```bash
clawdis nodes list
```
- Via Gateway:
```bash
clawdis gateway call node.list --params "{}"
```
## 6) Chat + history
The Android nodes Chat sheet uses the gateways **primary session key** (`main`), so history and replies are shared with WebChat and other clients:
- History: `chat.history`
- Send: `chat.send`
- Push updates (best-effort): `chat.subscribe` → `event:"chat"`
## 7) Canvas + camera
Canvas commands (foreground only):
- `screen.eval`, `screen.snapshot`, `screen.navigate`, `screen.setMode`
Camera commands (foreground only; permission-gated):
- `camera.snap` (jpg)
- `camera.clip` (mp4)
See `docs/camera.md` for parameters and CLI helpers.

View File

@@ -10,6 +10,7 @@ read_when:
Clawdis supports **camera capture** for agent workflows:
- **iOS node** (paired via Gateway): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `node.invoke`.
- **Android node** (paired via Gateway): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `node.invoke`.
- **macOS app** (local control socket): capture a **photo** (`jpg`) or **short video clip** (`mp4`, with optional audio) via `clawdis-mac`.
All camera access is gated behind **user-controlled settings**.
@@ -68,6 +69,26 @@ Notes:
- `nodes camera snap` defaults to **both** facings to give the agent both views.
- Output files are temporary (in the OS temp directory) unless you build your own wrapper.
## Android node
### User setting (default on)
- Android Settings sheet → **Camera****Allow Camera** (`camera.enabled`)
- Default: **on** (missing key is treated as enabled).
- When off: `camera.*` commands return `CAMERA_DISABLED`.
### Permissions
- Android requires runtime permissions:
- `CAMERA` for both `camera.snap` and `camera.clip`.
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`.
If permissions are denied, `camera.*` requests fail with a `*_PERMISSION_REQUIRED` error.
### Foreground requirement
Like `screen.*`, the Android node only allows `camera.*` commands in the **foreground**. Background invocations return `NODE_BACKGROUND_UNAVAILABLE`.
## macOS app
### User setting (default off)
@@ -95,4 +116,3 @@ clawdis-mac camera clip --no-audio
- Camera and microphone access trigger the usual OS permission prompts (and require usage strings in Info.plist).
- Video clips are intentionally short to avoid oversized bridge payloads (base64 overhead + WebSocket message limits).