feat(android): add Compose node app (bridge+canvas+chat+camera)
This commit is contained in:
5
apps/android/.gitignore
vendored
Normal file
5
apps/android/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
.idea/
|
||||
**/*.iml
|
||||
10
apps/android/README.md
Normal file
10
apps/android/README.md
Normal 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`
|
||||
|
||||
79
apps/android/app/build.gradle.kts
Normal file
79
apps/android/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
27
apps/android/app/src/main/AndroidManifest.xml
Normal file
27
apps/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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\""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
4
apps/android/app/src/main/res/values/strings.xml
Normal file
4
apps/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Clawdis Node</string>
|
||||
</resources>
|
||||
|
||||
8
apps/android/app/src/main/res/values/themes.xml
Normal file
8
apps/android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
|
||||
6
apps/android/build.gradle.kts
Normal file
6
apps/android/build.gradle.kts
Normal 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
|
||||
}
|
||||
|
||||
4
apps/android/gradle.properties
Normal file
4
apps/android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
apps/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
apps/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
276
apps/android/gradlew
vendored
Executable 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
92
apps/android/gradlew.bat
vendored
Normal 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
|
||||
19
apps/android/settings.gradle.kts
Normal file
19
apps/android/settings.gradle.kts
Normal 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
94
docs/android/connect.md
Normal 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 gateway’s 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 node’s Chat sheet uses the gateway’s **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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user