diff --git a/apps/android/app/src/main/assets/CanvasScaffold/scaffold.html b/apps/android/app/src/main/assets/CanvasScaffold/scaffold.html
new file mode 100644
index 000000000..6c77189ec
--- /dev/null
+++ b/apps/android/app/src/main/assets/CanvasScaffold/scaffold.html
@@ -0,0 +1,190 @@
+
+
+
+
+
+ Canvas
+
+
+
+
+
+
+
Ready
+
Waiting for agent
+
+
+
+
+
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt
index 245e0156c..28d702975 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt
@@ -38,6 +38,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow = runtime.manualEnabled
val manualHost: StateFlow = runtime.manualHost
val manualPort: StateFlow = runtime.manualPort
+ val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow = runtime.chatSessionKey
val chatSessionId: StateFlow = runtime.chatSessionId
@@ -78,6 +79,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
+ fun setCanvasDebugStatusEnabled(value: Boolean) {
+ runtime.setCanvasDebugStatusEnabled(value)
+ }
+
fun setWakeWords(words: List) {
runtime.setWakeWords(words)
}
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt
index f711a4178..7cfd8c53f 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt
@@ -166,6 +166,7 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow = prefs.manualHost
val manualPort: StateFlow = prefs.manualPort
val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId
+ val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled
private var didAutoConnect = false
private var suppressWakeWordsSync = false
@@ -246,6 +247,22 @@ class NodeRuntime(context: Context) {
connect(target)
}
}
+
+ scope.launch {
+ combine(
+ canvasDebugStatusEnabled,
+ statusText,
+ serverName,
+ remoteAddress,
+ ) { debugEnabled, status, server, remote ->
+ Quad(debugEnabled, status, server, remote)
+ }.distinctUntilChanged()
+ .collect { (debugEnabled, status, server, remote) ->
+ canvas.setDebugStatusEnabled(debugEnabled)
+ if (!debugEnabled) return@collect
+ canvas.setDebugStatus(status, server ?: remote)
+ }
+ }
}
fun setForeground(value: Boolean) {
@@ -276,6 +293,10 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
+ fun setCanvasDebugStatusEnabled(value: Boolean) {
+ prefs.setCanvasDebugStatusEnabled(value)
+ }
+
fun setWakeWords(words: List) {
prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded()
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt
index a0be15e13..8d7ceb0a2 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt
@@ -63,6 +63,10 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId
+ private val _canvasDebugStatusEnabled =
+ MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
+ val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled
+
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow> = _wakeWords
@@ -107,6 +111,11 @@ class SecurePrefs(context: Context) {
_manualPort.value = value
}
+ fun setCanvasDebugStatusEnabled(value: Boolean) {
+ prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
+ _canvasDebugStatusEnabled.value = value
+ }
+
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt
index d89dc83cb..5b4a09b64 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt
@@ -11,6 +11,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
+import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@@ -25,6 +26,9 @@ class CanvasController {
@Volatile private var webView: WebView? = null
@Volatile private var url: String? = null
+ @Volatile private var debugStatusEnabled: Boolean = false
+ @Volatile private var debugStatusTitle: String? = null
+ @Volatile private var debugStatusSubtitle: String? = null
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -36,6 +40,7 @@ class CanvasController {
fun attach(webView: WebView) {
this.webView = webView
reload()
+ applyDebugStatus()
}
fun navigate(url: String) {
@@ -48,6 +53,21 @@ class CanvasController {
fun isDefaultCanvas(): Boolean = url == null
+ fun setDebugStatusEnabled(enabled: Boolean) {
+ debugStatusEnabled = enabled
+ applyDebugStatus()
+ }
+
+ fun setDebugStatus(title: String?, subtitle: String?) {
+ debugStatusTitle = title
+ debugStatusSubtitle = subtitle
+ applyDebugStatus()
+ }
+
+ fun onPageFinished() {
+ applyDebugStatus()
+ }
+
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
val wv = webView ?: return
if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -68,6 +88,32 @@ class CanvasController {
}
}
+ private fun applyDebugStatus() {
+ val enabled = debugStatusEnabled
+ val title = debugStatusTitle
+ val subtitle = debugStatusSubtitle
+ withWebViewOnMain { wv ->
+ val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
+ val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
+ val js = """
+ (() => {
+ try {
+ const api = globalThis.__clawdis;
+ if (!api) return;
+ if (typeof api.setDebugStatusEnabled === 'function') {
+ api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
+ }
+ if (!${if (enabled) "true" else "false"}) return;
+ if (typeof api.setStatus === 'function') {
+ api.setStatus($titleJs, $subtitleJs);
+ }
+ } catch (_) {}
+ })();
+ """.trimIndent()
+ wv.evaluateJavascript(js, null)
+ }
+ }
+
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt
index 38b61ed63..4ee3afa1a 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt
@@ -187,6 +187,10 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
+
+ override fun onPageFinished(view: WebView, url: String?) {
+ viewModel.canvas.onPageFinished()
+ }
}
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt
index 899817639..038ef9faf 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt
@@ -64,6 +64,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
+ val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -394,6 +395,23 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
+ item { HorizontalDivider() }
+
+ // Debug
+ item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
+ item {
+ ListItem(
+ headlineContent = { Text("Debug Canvas Status") },
+ supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
+ trailingContent = {
+ Switch(
+ checked = canvasDebugStatusEnabled,
+ onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
+ )
+ },
+ )
+ }
+
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}