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:
|
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`.
|
- **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`.
|
- **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**.
|
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.
|
- `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.
|
- 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
|
## macOS app
|
||||||
|
|
||||||
### User setting (default off)
|
### 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).
|
- 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).
|
- Video clips are intentionally short to avoid oversized bridge payloads (base64 overhead + WebSocket message limits).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user