diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index ffe7d1d77c3..dda17320625 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -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") diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt index 5036c6290d3..c9b545e0ce9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt @@ -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 } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt index 8c732d9c360..d9f3ec44fa6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -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) + } + } } } diff --git a/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt new file mode 100644 index 00000000000..421f0b3ad6d --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt @@ -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)) + } +}