From b5c0a66fb702c6ac3732cbcebb3f1645f87a168e Mon Sep 17 00:00:00 2001 From: yishai Date: Mon, 26 Jan 2026 16:18:51 +0200 Subject: [PATCH] Android: add manual token authentication support - Add manual token field to gateway settings with password masking and show/hide toggle - Implement token resolution priority: manual > global > null - Add error normalization to prevent token leakage in error messages - Store manual token in EncryptedSharedPreferences (AES256_GCM) - Add clear button to revert to global token - Modify connectManual() to use resolved token (manual if set, else global) Files modified: - SecurePrefs.kt: Storage layer with encrypted token persistence - NodeRuntime.kt: Token resolution and error normalization logic - MainViewModel.kt: ViewModel layer exposing token state - SettingsSheet.kt: UI layer with token field, masking, and controls - CHANGELOG.md: Document feature addition --- CHANGELOG.md | 1 + .../com/clawdbot/android/MainViewModel.kt | 9 +++ .../java/com/clawdbot/android/NodeRuntime.kt | 68 ++++++++++++++++++- .../java/com/clawdbot/android/SecurePrefs.kt | 19 ++++++ .../com/clawdbot/android/ui/SettingsSheet.kt | 57 ++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 447e77846..73cafa339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Status: unreleased. - Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) +- Android: add manual token authentication support to gateway settings with encrypted storage and error normalization. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. diff --git a/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt b/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt index 1329f06d4..696159708 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt @@ -51,6 +51,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort val manualTls: StateFlow = runtime.manualTls + val manualToken: StateFlow = runtime.manualToken val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled val chatSessionKey: StateFlow = runtime.chatSessionKey @@ -104,6 +105,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setManualTls(value) } + fun setManualToken(value: String) { + runtime.setManualToken(value) + } + + fun clearManualToken() { + runtime.clearManualToken() + } + fun setCanvasDebugStatusEnabled(value: Boolean) { runtime.setCanvasDebugStatusEnabled(value) } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt index 603e4b82b..8a8a02baa 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt @@ -242,7 +242,7 @@ class NodeRuntime(context: Context) { private fun updateStatus() { _isConnected.value = operatorConnected - _statusText.value = + val rawStatus = when { operatorConnected && nodeConnected -> "Connected" operatorConnected && !nodeConnected -> "Connected (node offline)" @@ -250,6 +250,7 @@ class NodeRuntime(context: Context) { operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText else -> nodeStatusText } + _statusText.value = normalizeGatewayError(rawStatus) } private fun resolveMainSessionKey(): String { @@ -257,6 +258,48 @@ class NodeRuntime(context: Context) { return if (trimmed.isEmpty()) "main" else trimmed } + private fun resolveToken(): String? { + val manual = prefs.manualToken.value.trim() + return if (manual.isNotEmpty()) manual else prefs.loadGatewayToken() + } + + private fun normalizeGatewayError(message: String): String { + if (message == "Connected" || message.startsWith("Connected (")) { + return message + } + + val lower = message.lowercase() + + if (("token" in lower || "auth" in lower || "unauthorized" in lower) && + ("invalid" in lower || "failed" in lower || "required" in lower || "missing" in lower)) { + return "Authentication failed. Check token in Settings." + } + + if (message.startsWith("Gateway closed:")) { + val reason = message.substringAfter("Gateway closed:").trim() + return if (reason.length > 100 || reason.contains("token=") || reason.contains("auth=")) { + "Connection closed by gateway" + } else { + "Gateway closed: $reason" + } + } + + if (message.startsWith("Gateway error:")) { + val error = message.substringAfter("Gateway error:").trim() + return if (error.length > 100) { + "Connection error" + } else { + "Gateway error: $error" + } + } + + return if (message.length > 150) { + "Connection failed" + } else { + message + } + } + private fun maybeNavigateToA2uiOnConnect() { val a2uiUrl = resolveA2uiHostUrl() ?: return val current = canvas.currentUrl()?.trim().orEmpty() @@ -284,6 +327,7 @@ class NodeRuntime(context: Context) { val manualHost: StateFlow = prefs.manualHost val manualPort: StateFlow = prefs.manualPort val manualTls: StateFlow = prefs.manualTls + val manualToken: StateFlow = prefs.manualToken val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled @@ -428,6 +472,14 @@ class NodeRuntime(context: Context) { prefs.setManualTls(value) } + fun setManualToken(value: String) { + prefs.setManualToken(value) + } + + fun clearManualToken() { + prefs.clearManualToken() + } + fun setCanvasDebugStatusEnabled(value: Boolean) { prefs.setCanvasDebugStatusEnabled(value) } @@ -604,7 +656,19 @@ class NodeRuntime(context: Context) { _statusText.value = "Failed: invalid manual host/port" return } - connect(GatewayEndpoint.manual(host = host, port = port)) + + val endpoint = GatewayEndpoint.manual(host = host, port = port) + connectedEndpoint = endpoint + operatorStatusText = "Connecting…" + nodeStatusText = "Connecting…" + updateStatus() + + val token = resolveToken() + val password = prefs.loadGatewayPassword() + val tls = resolveTlsParams(endpoint) + + operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) + nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) } fun disconnect() { diff --git a/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt b/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt index cd6270dd5..daa1f6a9c 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt @@ -74,6 +74,10 @@ class SecurePrefs(context: Context) { MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true)) val manualTls: StateFlow = _manualTls + private val _manualToken = + MutableStateFlow(readStringWithMigration("gateway.manual.token", null, "")) + val manualToken: StateFlow = _manualToken + private val _lastDiscoveredStableId = MutableStateFlow( readStringWithMigration( @@ -150,6 +154,21 @@ class SecurePrefs(context: Context) { _manualTls.value = value } + fun setManualToken(value: String) { + val trimmed = value.trim() + if (trimmed.isEmpty()) { + clearManualToken() + } else { + prefs.edit { putString("gateway.manual.token", trimmed) } + _manualToken.value = trimmed + } + } + + fun clearManualToken() { + prefs.edit { remove("gateway.manual.token") } + _manualToken.value = "" + } + fun setCanvasDebugStatusEnabled(value: Boolean) { prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } _canvasDebugStatusEnabled.value = value diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt index e3a9b3ecb..622360ed5 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt @@ -31,11 +31,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -55,6 +59,9 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -82,6 +89,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val manualHost by viewModel.manualHost.collectAsState() val manualPort by viewModel.manualPort.collectAsState() val manualTls by viewModel.manualTls.collectAsState() + val manualToken by viewModel.manualToken.collectAsState() + var tokenVisible by remember { mutableStateOf(false) } val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() val statusText by viewModel.statusText.collectAsState() val serverName by viewModel.serverName.collectAsState() @@ -403,6 +412,54 @@ fun SettingsSheet(viewModel: MainViewModel) { modifier = Modifier.fillMaxWidth(), enabled = manualEnabled, ) + OutlinedTextField( + value = manualToken, + onValueChange = viewModel::setManualToken, + label = { Text("Token (optional)") }, + supportingText = { + Text( + if (manualToken.trim().isNotEmpty()) { + "Using manual token" + } else { + "Leave blank to use global token" + } + ) + }, + visualTransformation = if (tokenVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + autoCorrectEnabled = false + ), + trailingIcon = { + Row { + if (manualToken.isNotEmpty()) { + IconButton( + onClick = { + viewModel.clearManualToken() + } + ) { + Icon(Icons.Default.Clear, contentDescription = "Clear token") + } + } + IconButton( + onClick = { tokenVisible = !tokenVisible } + ) { + Icon( + if (tokenVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (tokenVisible) "Hide token" else "Show token" + ) + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + singleLine = true, + ) ListItem( headlineContent = { Text("Require TLS") }, supportingContent = { Text("Pin the gateway certificate on first connect.") },