feat(android): add Compose node app (bridge+canvas+chat+camera)
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user