fix: keep Android gateway settings save idempotent (#98277)

* fix(android): keep gateway settings save idempotent

* fix(android): centralize gateway settings auth ownership

Co-authored-by: Colin <colin@solvely.net>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Colin Johnson
2026-07-02 18:53:47 -04:00
committed by GitHub
parent c54dc67381
commit ffda2f00a0
9 changed files with 478 additions and 285 deletions

View File

@@ -11,6 +11,8 @@ import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.ui.GatewayConnectPlan
import ai.openclaw.app.ui.GatewaySavedAuthAction
import ai.openclaw.app.voice.VoiceConversationEntry
import android.app.Application
import androidx.lifecycle.AndroidViewModel
@@ -279,10 +281,6 @@ class MainViewModel(
prefs.setManualTls(value)
}
fun setGatewayToken(value: String) {
prefs.setGatewayToken(value)
}
fun setGatewayBootstrapToken(value: String) {
prefs.setGatewayBootstrapToken(value)
}
@@ -304,37 +302,44 @@ class MainViewModel(
deviceAuthStore.clearToken(deviceId, "operator")
}
fun saveGatewayConfigAndConnect(
host: String,
port: Int,
tls: Boolean,
token: String,
bootstrapToken: String,
password: String,
resetSetupAuth: Boolean,
) {
internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) {
// Gateway pairing touches encrypted prefs, identity files, and sockets; keep
// the whole sequence off the Compose thread so retries cannot trigger ANRs.
viewModelScope.launch(Dispatchers.Default) {
if (resetSetupAuth) {
val config = plan.config
val replacesSavedAuth = plan.savedAuthAction != GatewaySavedAuthAction.PRESERVE
val hasExplicitAuth =
config.token.isNotEmpty() || config.bootstrapToken.isNotEmpty() || config.password.isNotEmpty()
if (replacesSavedAuth) {
resetGatewaySetupAuth()
}
prefs.setManualEnabled(true)
prefs.setManualHost(host)
prefs.setManualPort(port)
prefs.setManualTls(tls)
prefs.setGatewayBootstrapToken(bootstrapToken)
prefs.setGatewayToken(token)
prefs.setGatewayPassword(password)
ensureRuntime()
.connect(
GatewayEndpoint.manual(host = host, port = port),
prefs.setManualHost(config.host)
prefs.setManualPort(config.port)
prefs.setManualTls(config.tls)
// A blank same-endpoint save means "keep access". Secrets remain runtime-owned,
// including password-only setups that Compose deliberately cannot read back.
if (replacesSavedAuth || hasExplicitAuth) {
prefs.setGatewayBootstrapToken(config.bootstrapToken)
prefs.setGatewayToken(config.token)
prefs.setGatewayPassword(config.password)
}
val runtime = ensureRuntime()
val endpoint = GatewayEndpoint.manual(host = config.host, port = config.port)
if (replacesSavedAuth || hasExplicitAuth) {
runtime.connect(
endpoint,
NodeRuntime.GatewayConnectAuth(
token = token.ifEmpty { null },
bootstrapToken = bootstrapToken.ifEmpty { null },
password = password.ifEmpty { null },
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
),
)
} else {
runtime.connect(endpoint)
}
}
}

View File

@@ -77,7 +77,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
val manualTls by viewModel.manualTls.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val gatewayBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
var advancedOpen by rememberSaveable { mutableStateOf(false) }
@@ -95,6 +94,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) }
var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) }
var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) }
var tokenInput by remember { mutableStateOf("") }
var passwordInput by rememberSaveable { mutableStateOf("") }
var validationText by rememberSaveable { mutableStateOf<String?>(null) }
@@ -260,8 +260,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
return@Button
}
val config =
resolveGatewayConnectConfig(
val plan =
resolveGatewayConnectPlan(
useSetupCode = inputMode == ConnectInputMode.SetupCode,
setupCode = setupCode,
savedManualHost = manualHost,
@@ -270,12 +270,12 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
manualHostInput = manualHostInput,
manualPortInput = manualPortInput,
manualTlsInput = manualTlsInput,
fallbackBootstrapToken = gatewayBootstrapToken,
fallbackToken = gatewayToken,
fallbackPassword = passwordInput,
bootstrapTokenInput = "",
tokenInput = tokenInput,
passwordInput = passwordInput,
)
if (config == null) {
if (plan == null) {
validationText =
if (inputMode == ConnectInputMode.SetupCode) {
val parsedSetup = decodeGatewaySetupCode(setupCode)
@@ -300,15 +300,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
validationText = null
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = inputMode == ConnectInputMode.SetupCode,
)
viewModel.saveGatewayConfigAndConnect(plan)
tokenInput = ""
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
@@ -538,9 +531,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = { viewModel.setGatewayToken(it) },
placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) },
value = tokenInput,
onValueChange = { tokenInput = it },
placeholder = { Text("Leave blank to keep saved token", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),

View File

@@ -36,6 +36,19 @@ internal data class GatewayConnectConfig(
val password: String,
)
/** How a connection attempt may update credentials already owned by the runtime. */
internal enum class GatewaySavedAuthAction {
PRESERVE,
REPLACE_ENDPOINT,
REPLACE_SETUP,
}
/** Endpoint plus the credential ownership decision applied by MainViewModel. */
internal data class GatewayConnectPlan(
val config: GatewayConnectConfig,
val savedAuthAction: GatewaySavedAuthAction,
)
/** Validation reason used by setup, QR, and manual endpoint copy. */
internal enum class GatewayEndpointValidationError {
INVALID_URL,
@@ -67,37 +80,38 @@ private const val remoteGatewaySecurityRule =
private const val remoteGatewaySecurityFix =
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
/** Resolves setup-code or manual UI fields into a connection config. */
/** Resolves setup-code or manual UI fields without reading stored credentials. */
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHostInput: String,
manualPortInput: String,
manualTlsInput: Boolean,
fallbackBootstrapToken: String,
fallbackToken: String,
fallbackPassword: String,
bootstrapTokenInput: String,
tokenInput: String,
passwordInput: String,
): GatewayConnectConfig? {
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
val setupBootstrapToken =
setup.bootstrapToken
?.trim()
.orEmpty()
.ifEmpty { bootstrapTokenInput.trim() }
// Bootstrap setup codes intentionally suppress stale shared credentials;
// the bootstrap token owns the first authenticated pairing exchange.
val sharedToken =
when {
!setup.token.isNullOrBlank() -> setup.token.trim()
setupBootstrapToken.isNotEmpty() -> ""
else -> fallbackToken.trim()
else -> tokenInput.trim()
}
val sharedPassword =
when {
!setup.password.isNullOrBlank() -> setup.password.trim()
setupBootstrapToken.isNotEmpty() -> ""
else -> fallbackPassword.trim()
setupBootstrapToken.isNotEmpty() || sharedToken.isNotEmpty() -> ""
else -> passwordInput.trim()
}
return GatewayConnectConfig(
host = parsed.host,
@@ -111,26 +125,70 @@ internal fun resolveGatewayConnectConfig(
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let { parseGatewayEndpointResult(it).config }
val preserveBootstrapToken =
savedManualEndpoint != null &&
savedManualEndpoint.host == parsed.host &&
savedManualEndpoint.port == parsed.port &&
savedManualEndpoint.tls == parsed.tls &&
fallbackToken.isBlank() &&
fallbackPassword.isBlank()
val token = tokenInput.trim()
val bootstrapToken = bootstrapTokenInput.trim().takeIf { token.isEmpty() }.orEmpty()
val password = passwordInput.trim().takeIf { token.isEmpty() && bootstrapToken.isEmpty() }.orEmpty()
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
bootstrapToken = if (preserveBootstrapToken) fallbackBootstrapToken.trim() else "",
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
bootstrapToken = bootstrapToken,
token = token,
password = password,
)
}
/**
* Produces one closed endpoint/auth plan. Blank auth fields preserve secrets
* only for the saved endpoint; neither Compose nor this resolver reads them.
*/
internal fun resolveGatewayConnectPlan(
useSetupCode: Boolean,
setupCode: String,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHostInput: String,
manualPortInput: String,
manualTlsInput: Boolean,
tokenInput: String,
bootstrapTokenInput: String,
passwordInput: String,
): GatewayConnectPlan? {
val config =
resolveGatewayConnectConfig(
useSetupCode = useSetupCode,
setupCode = setupCode,
manualHostInput = manualHostInput,
manualPortInput = manualPortInput,
manualTlsInput = manualTlsInput,
tokenInput = tokenInput,
bootstrapTokenInput = bootstrapTokenInput,
passwordInput = passwordInput,
) ?: return null
if (useSetupCode) {
return GatewayConnectPlan(config, GatewaySavedAuthAction.REPLACE_SETUP)
}
if (config.bootstrapToken.isNotEmpty()) {
// Bootstrap auth requests a fresh pairing exchange. Retained role tokens
// would otherwise win before the bootstrap credential is attempted.
return GatewayConnectPlan(config, GatewaySavedAuthAction.REPLACE_SETUP)
}
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let { parseGatewayEndpointResult(it).config }
val action =
if (savedManualEndpoint?.sameEndpoint(config) == true) {
GatewaySavedAuthAction.PRESERVE
} else {
GatewaySavedAuthAction.REPLACE_ENDPOINT
}
return GatewayConnectPlan(config, action)
}
private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = host.equals(config.host, ignoreCase = true) && port == config.port && tls == config.tls
/** Parses an endpoint string and returns only the valid connection config. */
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config

View File

@@ -99,7 +99,7 @@ private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!summary.devicePairingAvailable) {
ClawPanel {
Text(text = "Device pairing admin needs elevated access. Connected nodes still work.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = devicePairingAdminUnavailableText(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
if (summary.pendingDevices.isNotEmpty()) {
@@ -246,6 +246,10 @@ private fun nodeApprovalSubtitle(approvalState: GatewayNodeApprovalState): Strin
-> null
}
internal fun devicePairingAdminUnavailableText(): String =
"This gateway sign-in can list connected nodes, but it cannot approve new phone pairing. " +
"Pair new phones from a gateway admin session. Node capability approval is separate and still uses nodes approve <request id>."
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")

View File

@@ -151,6 +151,9 @@ fun OnboardingFlow(
val gateways by viewModel.gateways.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val savedToken by viewModel.gatewayToken.collectAsState()
val savedManualHost by viewModel.manualHost.collectAsState()
val savedManualPort by viewModel.manualPort.collectAsState()
val savedManualTls by viewModel.manualTls.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
val ready =
@@ -186,29 +189,21 @@ fun OnboardingFlow(
val permissionState = rememberPermissionState(context = context, viewModel = viewModel)
fun connectToGatewayConfig(
config: GatewayConnectConfig,
resetSetupAuth: Boolean,
) {
fun connectToGatewayPlan(plan: GatewayConnectPlan) {
setupError = null
attemptedGatewayName = null
attemptedConnect = true
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = resetSetupAuth,
)
viewModel.saveGatewayConfigAndConnect(plan)
step = OnboardingStep.Recovery
}
fun resolveCurrentGatewayConfig(setupCodeValue: String = setupCode): GatewayConnectConfig? =
resolveOnboardingGatewayConnectConfig(
fun resolveCurrentGatewayPlan(setupCodeValue: String = setupCode): GatewayConnectPlan? =
resolveOnboardingGatewayConnectPlan(
setupCode = setupCodeValue,
savedManualHost = savedManualHost,
savedManualPort = savedManualPort.toString(),
savedManualTls = savedManualTls,
manualHost = manualHost,
manualPort = manualPort,
manualTls = manualTls,
@@ -320,8 +315,8 @@ fun OnboardingFlow(
)
return@addOnSuccessListener
}
val config = resolveCurrentGatewayConfig(setupCodeValue = scannedSetupCode)
if (config == null) {
val plan = resolveCurrentGatewayPlan(setupCodeValue = scannedSetupCode)
if (plan == null) {
setupError =
gatewayEndpointValidationMessage(
GatewayEndpointValidationError.INVALID_URL,
@@ -330,7 +325,7 @@ fun OnboardingFlow(
return@addOnSuccessListener
}
setupCode = scannedSetupCode
connectToGatewayConfig(config, resetSetupAuth = true)
connectToGatewayPlan(plan)
}.addOnFailureListener { setupError = "Could not open the scanner." }
},
onSetupCodeChange = {
@@ -357,12 +352,12 @@ fun OnboardingFlow(
step = OnboardingStep.Recovery
},
onPair = {
val config = resolveCurrentGatewayConfig()
if (config == null) {
val plan = resolveCurrentGatewayPlan()
if (plan == null) {
setupError = "Enter a setup code or a valid gateway URL."
return@GatewaySetupScreen
}
connectToGatewayConfig(config, resetSetupAuth = true)
connectToGatewayPlan(plan)
},
)
OnboardingStep.Recovery ->
@@ -378,8 +373,8 @@ fun OnboardingFlow(
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
onBack = { step = OnboardingStep.Gateway },
onRetry = {
val config = resolveCurrentGatewayConfig() ?: return@GatewayRecoveryScreen
connectToGatewayConfig(config, resetSetupAuth = false)
val plan = resolveCurrentGatewayPlan() ?: return@GatewayRecoveryScreen
connectToGatewayPlan(plan.copy(savedAuthAction = GatewaySavedAuthAction.PRESERVE))
},
onEdit = { step = OnboardingStep.Gateway },
onContinue = { step = OnboardingStep.Permissions },
@@ -1178,27 +1173,30 @@ internal fun recoveryGatewayName(
?.takeIf { it.isNotEmpty() }
?: "Home Gateway"
/** Resolves onboarding setup-code or manual fields into the gateway config used for connect. */
internal fun resolveOnboardingGatewayConnectConfig(
/** Resolves onboarding setup-code or manual fields into the gateway plan used for connect. */
internal fun resolveOnboardingGatewayConnectPlan(
setupCode: String,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHost: String,
manualPort: String,
manualTls: Boolean,
token: String,
password: String,
): GatewayConnectConfig? =
resolveGatewayConnectConfig(
): GatewayConnectPlan? =
resolveGatewayConnectPlan(
useSetupCode = setupCode.isNotBlank(),
setupCode = setupCode,
savedManualHost = manualHost,
savedManualPort = manualPort,
savedManualTls = manualTls,
savedManualHost = savedManualHost,
savedManualPort = savedManualPort,
savedManualTls = savedManualTls,
manualHostInput = manualHost,
manualPortInput = manualPort,
manualTlsInput = manualTls,
fallbackBootstrapToken = "",
fallbackToken = token,
fallbackPassword = password,
bootstrapTokenInput = "",
tokenInput = token,
passwordInput = password,
)
/** Selects the recovery detail line from endpoint metadata and transient gateway status. */

View File

@@ -85,11 +85,13 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -894,8 +896,6 @@ private fun GatewaySettingsScreen(
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val savedBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val savedGatewayToken by viewModel.gatewayToken.collectAsState()
var setupCode by remember { mutableStateOf("") }
var hostInput by remember(manualHost) { mutableStateOf(manualHost.ifBlank { "127.0.0.1" }) }
var portInput by remember(manualPort) { mutableStateOf(manualPort.toString()) }
@@ -905,6 +905,42 @@ private fun GatewaySettingsScreen(
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
var pendingSetupResetPlan by remember { mutableStateOf<GatewayConnectPlan?>(null) }
fun saveAndConnect(plan: GatewayConnectPlan) {
validationText = null
viewModel.saveGatewayConfigAndConnect(plan)
}
pendingSetupResetPlan?.let { plan ->
AlertDialog(
onDismissRequest = { pendingSetupResetPlan = null },
title = { Text("Replace gateway setup?") },
text = {
Text(
gatewaySettingsSetupResetConfirmationText(),
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
)
},
confirmButton = {
TextButton(
onClick = {
pendingSetupResetPlan = null
saveAndConnect(plan)
},
) {
Text("Replace setup")
}
},
dismissButton = {
TextButton(onClick = { pendingSetupResetPlan = null }) {
Text("Cancel")
}
},
containerColor = ClawTheme.colors.surface,
)
}
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -965,45 +1001,29 @@ private fun GatewaySettingsScreen(
ClawPrimaryButton(
text = "Save & Connect",
onClick = {
val setup = setupCode.trim().takeIf { it.isNotEmpty() }?.let(::decodeGatewaySetupCode)
val endpointConfig =
if (setup != null) {
parseGatewayEndpointResult(setup.url).config
} else {
composeGatewayManualUrl(hostInput, portInput, tlsInput)?.let { parseGatewayEndpointResult(it).config }
}
if (endpointConfig == null) {
val plan =
resolveGatewayConnectPlan(
useSetupCode = setupCode.isNotBlank(),
setupCode = setupCode,
savedManualHost = manualHost,
savedManualPort = manualPort.toString(),
savedManualTls = manualTls,
manualHostInput = hostInput,
manualPortInput = portInput,
manualTlsInput = tlsInput,
tokenInput = tokenInput,
bootstrapTokenInput = bootstrapTokenInput,
passwordInput = passwordInput,
)
if (plan == null) {
validationText = "Enter a valid setup code or gateway address."
return@ClawPrimaryButton
}
val bootstrapToken =
setup
?.bootstrapToken
?.trim()
.orEmpty()
.ifEmpty { bootstrapTokenInput.trim().ifEmpty { savedBootstrapToken } }
val token =
setup
?.token
?.trim()
.orEmpty()
.ifEmpty { tokenInput.trim().ifEmpty { if (bootstrapToken.isBlank()) savedGatewayToken else "" } }
val password =
setup
?.password
?.trim()
.orEmpty()
.ifEmpty { passwordInput.trim() }
validationText = null
viewModel.saveGatewayConfigAndConnect(
host = endpointConfig.host,
port = endpointConfig.port,
tls = endpointConfig.tls,
token = token,
bootstrapToken = bootstrapToken,
password = password,
resetSetupAuth = setup != null,
)
if (plan.savedAuthAction == GatewaySavedAuthAction.REPLACE_SETUP) {
pendingSetupResetPlan = plan
} else {
saveAndConnect(plan)
}
},
modifier = Modifier.fillMaxWidth(),
)
@@ -1119,6 +1139,10 @@ internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): St
else -> flavor.trim()
}
internal fun gatewaySettingsSetupResetConfirmationText(): String =
"Replacing the setup code clears this phone's saved setup credentials and device tokens before reconnecting. " +
"This phone may need node capability approval again; continue only when you mean to pair with a fresh gateway setup code."
@Composable
private fun AboutStatusRow(
title: String,

View File

@@ -407,116 +407,82 @@ class GatewayConfigResolverTest {
@Test
fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
encodeSetupCode(
"""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""",
)
val resolved =
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
manualTlsInput = false,
bootstrapTokenInput = "",
tokenInput = "shared-token",
passwordInput = "shared-password",
)
assertEquals("gateway.example", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(true, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
}
@Test
fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""")
val resolved =
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
assertEquals("gateway.example", resolved?.host)
assertEquals(443, resolved?.port)
assertEquals(true, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
}
@Test
fun resolveGatewayConnectConfigAllowsMdnsCleartextSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""")
val resolved =
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
savedManualHost = "",
savedManualPort = "",
savedManualTls = false,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = false,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
assertEquals("gateway.local", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
}
@Test
fun resolveGatewayConnectConfigManualPreservesBootstrapTokenWhenNoReplacementAuthExists() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
)
assertEquals("127.0.0.1", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenReplacementPasswordExists() {
fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() {
val setupCode =
encodeSetupCode(
"""{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""",
)
val resolved =
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = false,
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals("gateway.example", resolved?.host)
assertEquals(443, resolved?.port)
assertEquals(true, resolved?.tls)
}
@Test
fun resolveGatewayConnectConfigAllowsMdnsCleartextSetupCode() {
val setupCode =
encodeSetupCode(
"""{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""",
)
val resolved =
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = false,
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals("gateway.local", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
}
@Test
fun resolveGatewayConnectPlanPreservesRuntimeOwnedAuthForUnchangedEndpoint() {
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
@@ -525,20 +491,21 @@ class GatewayConfigResolverTest {
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "password-1",
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals("", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("password-1", resolved?.password)
assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction)
assertEquals("", plan?.config?.bootstrapToken)
assertEquals("", plan?.config?.token)
assertEquals("", plan?.config?.password)
}
@Test
fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenEndpointChanges() {
val resolved =
resolveGatewayConnectConfig(
fun resolveGatewayConnectPlanReplacesAuthWhenEndpointChanges() {
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
@@ -547,13 +514,123 @@ class GatewayConfigResolverTest {
manualHostInput = "127.0.0.2",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals("", resolved?.bootstrapToken)
assertEquals("127.0.0.2", resolved?.host)
assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction)
assertEquals("127.0.0.2", plan?.config?.host)
}
@Test
fun resolveGatewayConnectPlanTreatsMissingSavedEndpointAsReplacement() {
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
setupCode = "",
savedManualHost = "",
savedManualPort = "",
savedManualTls = false,
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction)
}
@Test
fun resolveGatewayConnectPlanMarksSetupCodeAsExplicitReplacement() {
val setupCode =
encodeSetupCode(
"""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""",
)
val plan =
resolveGatewayConnectPlan(
useSetupCode = true,
setupCode = setupCode,
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction)
assertEquals("bootstrap-1", plan?.config?.bootstrapToken)
assertEquals("", plan?.config?.token)
}
@Test
fun resolveGatewayConnectPlanUsesOneExplicitCredentialFamily() {
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
bootstrapTokenInput = "bootstrap",
tokenInput = "token",
passwordInput = "password",
)
assertEquals("token", plan?.config?.token)
assertEquals("", plan?.config?.bootstrapToken)
assertEquals("", plan?.config?.password)
}
@Test
fun resolveGatewayConnectPlanReplacesStalePairingForExplicitBootstrapAuth() {
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
setupCode = "",
savedManualHost = "gateway.local",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "gateway.local",
manualPortInput = "18789",
manualTlsInput = false,
bootstrapTokenInput = "replacement-bootstrap",
tokenInput = "",
passwordInput = "",
)
assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction)
assertEquals("replacement-bootstrap", plan?.config?.bootstrapToken)
}
@Test
fun resolveGatewayConnectPlanPreservesAuthForHostnameCaseOnlyEdit() {
val plan =
resolveGatewayConnectPlan(
useSetupCode = false,
setupCode = "",
savedManualHost = "Gateway.Local",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "gateway.local",
manualPortInput = "18789",
manualTlsInput = false,
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction)
}
@Test
@@ -562,15 +639,12 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "",
savedManualPort = "",
savedManualTls = false,
manualHostInput = "192.168.31.100",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
bootstrapTokenInput = "bootstrap-1",
tokenInput = "",
passwordInput = "",
)
assertEquals("192.168.31.100", resolved?.host)
@@ -584,15 +658,12 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "",
savedManualPort = "",
savedManualTls = false,
manualHostInput = "gateway.local",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
bootstrapTokenInput = "bootstrap-1",
tokenInput = "",
passwordInput = "",
)
assertEquals("gateway.local", resolved?.host)
@@ -629,15 +700,12 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "",
savedManualPort = "",
savedManualTls = false,
manualHostInput = "ws://192.168.178.57:18790",
manualPortInput = "18789",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "",
fallbackPassword = "",
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals("192.168.178.57", resolved?.host)
@@ -698,15 +766,12 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "mydevice.tail1234.ts.net",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "",
fallbackPassword = "",
bootstrapTokenInput = "",
tokenInput = "",
passwordInput = "",
)
assertEquals("mydevice.tail1234.ts.net", resolved?.host)

View File

@@ -498,9 +498,12 @@ class OnboardingFlowLogicTest {
encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""")
val scanned = resolveScannedSetupCodeResult(setupCode)
val resolved =
resolveOnboardingGatewayConnectConfig(
val plan =
resolveOnboardingGatewayConnectPlan(
setupCode = requireNotNull(scanned.setupCode),
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHost = "127.0.0.1",
manualPort = "18789",
manualTls = false,
@@ -508,20 +511,24 @@ class OnboardingFlowLogicTest {
password = "stale-shared-password",
)
assertEquals("10.0.2.2", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("", resolved?.password)
assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction)
assertEquals("10.0.2.2", plan?.config?.host)
assertEquals(18789, plan?.config?.port)
assertEquals(false, plan?.config?.tls)
assertEquals("bootstrap-1", plan?.config?.bootstrapToken)
assertEquals("", plan?.config?.token)
assertEquals("", plan?.config?.password)
assertNull(scanned.error)
}
@Test
fun resolvesOnboardingManualConnectConfigWhenSetupCodeIsBlank() {
val resolved =
resolveOnboardingGatewayConnectConfig(
val plan =
resolveOnboardingGatewayConnectPlan(
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHost = "127.0.0.1",
manualPort = "18789",
manualTls = false,
@@ -529,12 +536,33 @@ class OnboardingFlowLogicTest {
password = "shared-password",
)
assertEquals("127.0.0.1", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
assertEquals("", resolved?.bootstrapToken)
assertEquals("shared-token", resolved?.token)
assertEquals("shared-password", resolved?.password)
assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction)
assertEquals("127.0.0.1", plan?.config?.host)
assertEquals(18789, plan?.config?.port)
assertEquals(false, plan?.config?.tls)
assertEquals("", plan?.config?.bootstrapToken)
assertEquals("shared-token", plan?.config?.token)
assertEquals("", plan?.config?.password)
}
@Test
fun onboardingManualEndpointChangeReplacesSavedGatewayAuth() {
val plan =
resolveOnboardingGatewayConnectPlan(
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHost = "10.0.2.2",
manualPort = "18790",
manualTls = false,
token = "replacement-token",
password = "",
)
assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction)
assertEquals("10.0.2.2", plan?.config?.host)
assertEquals("replacement-token", plan?.config?.token)
}
private fun encodeSetupCode(payloadJson: String): String = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))

View File

@@ -49,6 +49,24 @@ class SettingsScreensTest {
assertEquals("Cannot reach gateway", gatewayStatusLabel("Connection failed", isConnected = false, gatewayConnectionProblem = problem))
}
@Test
fun gatewaySetupResetCopyExplainsCredentialAndApprovalImpact() {
val text = gatewaySettingsSetupResetConfirmationText()
assertEquals(true, text.contains("saved setup credentials"))
assertEquals(true, text.contains("device tokens"))
assertEquals(true, text.contains("node capability approval"))
}
@Test
fun devicePairingAdminCopySeparatesPairingFromNodeApproval() {
val text = devicePairingAdminUnavailableText()
assertEquals(true, text.contains("approve new phone pairing"))
assertEquals(true, text.contains("Node capability approval is separate"))
assertEquals(true, text.contains("nodes approve <request id>"))
}
private fun authProblem(code: String): GatewayConnectionProblem =
GatewayConnectionProblem(
code = code,