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
This commit is contained in:
yishai 2026-01-26 16:18:51 +02:00
parent 72fea5e305
commit b5c0a66fb7
5 changed files with 152 additions and 2 deletions

View File

@ -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.

View File

@ -51,6 +51,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val manualToken: StateFlow<String> = runtime.manualToken
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = 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)
}

View File

@ -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<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val manualToken: StateFlow<String> = prefs.manualToken
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = 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() {

View File

@ -74,6 +74,10 @@ class SecurePrefs(context: Context) {
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _manualToken =
MutableStateFlow(readStringWithMigration("gateway.manual.token", null, ""))
val manualToken: StateFlow<String> = _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

View File

@ -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.") },