mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-05 02:33:32 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user