feat(android): make QR scanning first-class onboarding

This commit is contained in:
Ayaan Zaidi
2026-02-25 13:34:08 +05:30
committed by Ayaan Zaidi
parent 56b8c69487
commit 2e3c05d9da
4 changed files with 339 additions and 165 deletions

View File

@@ -137,6 +137,7 @@ dependencies {
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")

View File

@@ -1,9 +1,13 @@
package ai.openclaw.android.ui
import android.util.Base64
import androidx.core.net.toUri
import java.util.Base64
import java.util.Locale
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal data class GatewayEndpointConfig(
val host: String,
@@ -26,6 +30,8 @@ internal data class GatewayConnectConfig(
val password: String,
)
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
@@ -94,14 +100,31 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
return try {
val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8)
val obj = JSONObject(decoded)
val url = obj.optString("url").trim()
val decoded = String(Base64.getDecoder().decode(padded), Charsets.UTF_8)
val obj = parseJsonObject(decoded) ?: return null
val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null
val token = obj.optString("token").trim().ifEmpty { null }
val password = obj.optString("password").trim().ifEmpty { null }
val token = jsonField(obj, "token")
val password = jsonField(obj, "password")
GatewaySetupCode(url = url, token = token, password = password)
} catch (_: Throwable) {
} catch (_: IllegalArgumentException) {
null
}
}
internal fun resolveScannedSetupCode(rawInput: String): String? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
if (decodeGatewaySetupCode(trimmed) != null) {
return trimmed
}
val obj = parseJsonObject(trimmed) ?: return null
val setupCode = jsonField(obj, "setupCode") ?: return null
return if (decodeGatewaySetupCode(setupCode) != null) {
setupCode
} else {
null
}
}
@@ -113,3 +136,12 @@ internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls:
val scheme = if (tls) "https" else "http"
return "$scheme://$host:$port"
}
private fun parseJsonObject(input: String): JsonObject? {
return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
}
private fun jsonField(obj: JsonObject, key: String): String? {
val value = obj[key]?.jsonPrimitive?.contentOrNull?.trim().orEmpty()
return value.ifEmpty { null }
}

View File

@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -51,6 +52,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -74,6 +77,8 @@ import androidx.core.content.ContextCompat
import ai.openclaw.android.LocationMode
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.R
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
private enum class OnboardingStep(val index: Int, val label: String) {
Welcome(1, "Welcome"),
@@ -192,6 +197,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var gatewayUrl by rememberSaveable { mutableStateOf("") }
var gatewayPassword by rememberSaveable { mutableStateOf("") }
var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) }
var gatewayAdvancedOpen by rememberSaveable { mutableStateOf(false) }
var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") }
var manualPort by rememberSaveable { mutableStateOf("18789") }
var manualTls by rememberSaveable { mutableStateOf(false) }
@@ -246,6 +252,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
step = OnboardingStep.FinalCheck
}
val qrScanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
val contents = result.contents?.trim().orEmpty()
if (contents.isEmpty()) {
return@rememberLauncherForActivityResult
}
val scannedSetupCode = resolveScannedSetupCode(contents)
if (scannedSetupCode == null) {
gatewayError = "QR code did not contain a valid setup code."
return@rememberLauncherForActivityResult
}
setupCode = scannedSetupCode
gatewayInputMode = GatewayInputMode.SetupCode
gatewayError = null
attemptedConnect = false
}
if (pendingTrust != null) {
val prompt = pendingTrust!!
AlertDialog(
@@ -316,6 +339,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
OnboardingStep.Gateway ->
GatewayStep(
inputMode = gatewayInputMode,
advancedOpen = gatewayAdvancedOpen,
setupCode = setupCode,
manualHost = manualHost,
manualPort = manualPort,
@@ -323,6 +347,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
gatewayToken = persistedGatewayToken,
gatewayPassword = gatewayPassword,
gatewayError = gatewayError,
onScanQrClick = {
gatewayError = null
qrScanLauncher.launch(
ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("Scan OpenClaw onboarding QR")
setBeepEnabled(false)
setOrientationLocked(false)
},
)
},
onAdvancedOpenChange = { gatewayAdvancedOpen = it },
onInputModeChange = {
gatewayInputMode = it
gatewayError = null
@@ -367,7 +403,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
remoteAddress = remoteAddress,
attemptedConnect = attemptedConnect,
enabledPermissions = enabledPermissionSummary,
methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "Setup Code" else "Manual",
methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "QR / Setup Code" else "Manual",
)
}
}
@@ -429,7 +465,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (gatewayInputMode == GatewayInputMode.SetupCode) {
val parsedSetup = decodeGatewaySetupCode(setupCode)
if (parsedSetup == null) {
gatewayError = "Invalid setup code."
gatewayError = "Scan QR code first, or use Advanced setup."
return@Button
}
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
@@ -607,6 +643,7 @@ private fun WelcomeStep() {
@Composable
private fun GatewayStep(
inputMode: GatewayInputMode,
advancedOpen: Boolean,
setupCode: String,
manualHost: String,
manualPort: String,
@@ -614,6 +651,8 @@ private fun GatewayStep(
gatewayToken: String,
gatewayPassword: String,
gatewayError: String?,
onScanQrClick: () -> Unit,
onAdvancedOpenChange: (Boolean) -> Unit,
onInputModeChange: (GatewayInputMode) -> Unit,
onSetupCodeChange: (String) -> Unit,
onManualHostChange: (String) -> Unit,
@@ -626,175 +665,225 @@ private fun GatewayStep(
val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } }
StepShell(title = "Gateway Connection") {
GuideBlock(title = "Get setup code + gateway URL") {
GuideBlock(title = "Scan onboarding QR") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
Text(
"`--json` prints `setupCode` and `gatewayUrl`.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
Text(
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
CommandBlock("openclaw qr")
Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
Button(
onClick = onScanQrClick,
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
),
) {
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
if (!resolvedEndpoint.isNullOrBlank()) {
Text("QR captured. Review endpoint below.", style = onboardingCalloutStyle, color = onboardingSuccess)
ResolvedEndpoint(endpoint = resolvedEndpoint)
}
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
if (inputMode == GatewayInputMode.SetupCode) {
Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = setupCode,
onValueChange = onSetupCodeChange,
placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
if (!resolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = resolvedEndpoint)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
QuickFillChip(label = "Android Emulator", onClick = {
onManualHostChange("10.0.2.2")
onManualPortChange("18789")
onManualTlsChange(false)
})
QuickFillChip(label = "Localhost", onClick = {
onManualHostChange("127.0.0.1")
onManualPortChange("18789")
onManualTlsChange(false)
})
}
Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = manualHost,
onValueChange = onManualHostChange,
placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = manualPort,
onValueChange = onManualPortChange,
placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = onboardingSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorderStrong),
onClick = { onAdvancedOpenChange(!advancedOpen) },
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
}
Switch(
checked = manualTls,
onCheckedChange = onManualTlsChange,
colors =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
Icon(
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (advancedOpen) "Collapse advanced setup" else "Expand advanced setup",
tint = onboardingTextSecondary,
)
}
}
Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = onTokenChange,
placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
AnimatedVisibility(visible = advancedOpen) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
GuideBlock(title = "Manual setup commands") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
Text(
"`--json` prints `setupCode` and `gatewayUrl`.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
Text(
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
}
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = gatewayPassword,
onValueChange = onPasswordChange,
placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
if (inputMode == GatewayInputMode.SetupCode) {
Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = setupCode,
onValueChange = onSetupCodeChange,
placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
if (!resolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = resolvedEndpoint)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
QuickFillChip(label = "Android Emulator", onClick = {
onManualHostChange("10.0.2.2")
onManualPortChange("18789")
onManualTlsChange(false)
})
QuickFillChip(label = "Localhost", onClick = {
onManualHostChange("127.0.0.1")
onManualPortChange("18789")
onManualTlsChange(false)
})
}
if (!manualResolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = manualResolvedEndpoint)
Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = manualHost,
onValueChange = onManualHostChange,
placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = manualPort,
onValueChange = onManualPortChange,
placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
}
Switch(
checked = manualTls,
onCheckedChange = onManualTlsChange,
colors =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
)
}
Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = onTokenChange,
placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
OutlinedTextField(
value = gatewayPassword,
onValueChange = onPasswordChange,
placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
)
if (!manualResolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = manualResolvedEndpoint)
}
}
}
}

View File

@@ -0,0 +1,52 @@
package ai.openclaw.android.ui
import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class GatewayConfigResolverTest {
@Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
val qrJson =
"""
{
"setupCode": "$setupCode",
"gatewayUrl": "wss://gateway.example:18789",
"auth": "password",
"urlSource": "gateway.remote.url"
}
""".trimIndent()
val resolved = resolveScannedSetupCode(qrJson)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
}
}