From 64318d7624a80fb05a86bbb5bb5d69fdacb5fa56 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:12 +1000 Subject: [PATCH] Rework Android gateway onboarding setup (#98752) * feat(android): rework gateway onboarding setup * fix(android): restore protocol mismatch onboarding guidance * fix(android): sync onboarding native i18n * fix(android): preserve LAN manual setup prefill * fix(android): refine onboarding node approval step * fix(android): polish onboarding recovery actions * test(android): cover onboarding protocol mismatch copy * fix(android): separate onboarding node approval * fix(android): guard onboarding node approval transitions * fix(android): keep onboarding approval states reachable * fix(android): wait for node access before onboarding continue * fix(android): refresh node approval after operator handoff * fix(android): refresh onboarding approval states * fix(android): require approval after onboarding permission changes * fix(android): keep onboarding approval gates active * fix(android): preserve permission reapproval state * fix(android): skip node approval on legacy gateways * fix(android): wait for node approval refresh * fix(android): preserve camera setting on upgrade * fix(android): avoid stuck approval check spinner * fix(android): request talk secrets on operator connects * fix(android): avoid missed node approval completion * fix(android): keep nearby LAN setup local * fix(android): complete onboarding after node approval * fix(android): reconcile onboarding with gateway auth plans * chore(android): refresh native i18n inventory after rebase * Fix Android onboarding review edge cases * Fix native i18n onboarding sentinels --- apps/.i18n/native-source.json | 712 ++-- apps/android/app/build.gradle.kts | 3 +- .../main/java/ai/openclaw/app/NodeRuntime.kt | 94 +- .../main/java/ai/openclaw/app/SecurePrefs.kt | 15 +- .../ai/openclaw/app/gateway/GatewaySession.kt | 1 + .../ai/openclaw/app/node/ConnectionManager.kt | 35 +- .../openclaw/app/ui/GatewayConfigResolver.kt | 4 +- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 2943 ++++++++++++----- .../java/ai/openclaw/app/ui/ShellScreen.kt | 10 +- .../app/src/main/res/values-ar/strings.xml | 10 - .../app/src/main/res/values-de/strings.xml | 10 - .../app/src/main/res/values-es/strings.xml | 10 - .../app/src/main/res/values-fa/strings.xml | 10 - .../app/src/main/res/values-fr/strings.xml | 10 - .../app/src/main/res/values-hi/strings.xml | 10 - .../app/src/main/res/values-in/strings.xml | 10 - .../app/src/main/res/values-it/strings.xml | 10 - .../app/src/main/res/values-ja/strings.xml | 10 - .../app/src/main/res/values-ko/strings.xml | 10 - .../app/src/main/res/values-nl/strings.xml | 10 - .../app/src/main/res/values-pl/strings.xml | 10 - .../src/main/res/values-pt-rBR/strings.xml | 10 - .../app/src/main/res/values-ru/strings.xml | 10 - .../app/src/main/res/values-sv/strings.xml | 10 - .../app/src/main/res/values-th/strings.xml | 10 - .../app/src/main/res/values-tr/strings.xml | 10 - .../app/src/main/res/values-uk/strings.xml | 10 - .../app/src/main/res/values-vi/strings.xml | 10 - .../src/main/res/values-zh-rCN/strings.xml | 10 - .../src/main/res/values-zh-rTW/strings.xml | 10 - .../app/src/main/res/values/strings.xml | 10 - .../openclaw/app/GatewayBootstrapAuthTest.kt | 69 + .../app/GatewayNodeApprovalStateTest.kt | 153 +- .../java/ai/openclaw/app/SecurePrefsTest.kt | 31 + .../app/gateway/GatewaySessionInvokeTest.kt | 7 +- .../app/node/ConnectionManagerTest.kt | 26 +- .../app/ui/GatewayConfigResolverTest.kt | 49 + .../app/ui/OnboardingFlowLogicTest.kt | 1071 ++++-- apps/android/gradle/libs.versions.toml | 5 +- docs/channels/pairing.md | 6 +- docs/channels/telegram.md | 2 +- docs/cli/qr.md | 2 +- docs/gateway/protocol.md | 13 +- docs/help/faq.md | 2 +- src/gateway/server.auth.control-ui.suite.ts | 3 - .../server/ws-connection/message-handler.ts | 3 +- src/shared/device-bootstrap-profile.test.ts | 1 + test/scripts/native-app-i18n.test.ts | 4 +- 48 files changed, 3849 insertions(+), 1635 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index eac9d826396..fde7bb21239 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -179,7 +179,7 @@ }, { "kind": "ui-state-text", - "line": 498, + "line": 499, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -187,7 +187,7 @@ }, { "kind": "conditional-branch", - "line": 1994, + "line": 2007, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", "surface": "android", @@ -195,7 +195,7 @@ }, { "kind": "conditional-branch", - "line": 1996, + "line": 2009, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -203,7 +203,7 @@ }, { "kind": "conditional-branch", - "line": 1998, + "line": 2011, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -371,7 +371,7 @@ }, { "kind": "conditional-branch", - "line": 1095, + "line": 1096, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -379,7 +379,7 @@ }, { "kind": "conditional-branch", - "line": 1095, + "line": 1096, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "conditional-branch", - "line": 290, + "line": 292, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "conditional-branch", - "line": 292, + "line": 294, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 294, + "line": 296, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "$remoteGatewaySecurityRule $remoteGatewaySecurityFix", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "conditional-branch", - "line": 299, + "line": 301, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Setup code has invalid gateway URL.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "conditional-branch", - "line": 300, + "line": 302, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "QR code did not contain a valid setup code.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "conditional-branch", - "line": 301, + "line": 303, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Enter a valid manual endpoint to connect.", "surface": "android", @@ -1666,48 +1666,240 @@ "id": "native.android.0bbe0d733b1328fd" }, { - "kind": "ui-named-argument", - "line": 437, + "kind": "ui-call", + "line": 654, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "OPENCLAW", + "source": "Trust", "surface": "android", - "id": "native.android.d42fe72f8e76b450" + "id": "native.android.9ac2d4498b17d478" + }, + { + "kind": "ui-call", + "line": 659, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Cancel", + "surface": "android", + "id": "native.android.e26ee07a1d1911be" }, { "kind": "ui-named-argument", - "line": 442, + "line": 856, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Your personal AI assistant.\nExfoliate! Exfoliate!", + "source": "Welcome to OpenClaw", "surface": "android", - "id": "native.android.a5c2d7f27576f0c4" + "id": "native.android.64c8db13ef91513c" }, { "kind": "ui-named-argument", - "line": 452, + "line": 938, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Connect to your Gateway", + "surface": "android", + "id": "native.android.cc878f72a6b70b23" + }, + { + "kind": "ui-named-argument", + "line": 939, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Choose device permissions", + "surface": "android", + "id": "native.android.bf3750bf264f0063" + }, + { + "kind": "ui-named-argument", + "line": 940, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Use OpenClaw from your phone", + "surface": "android", + "id": "native.android.3bfe29d4d13d3c3e" + }, + { + "kind": "ui-named-argument", + "line": 962, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Security notice", + "surface": "android", + "id": "native.android.c0b539eeb4612f5d" + }, + { + "kind": "ui-named-argument", + "line": 964, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "The connected OpenClaw agent can use device capabilities you enable. Continue only if you trust the Gateway and agent you connect to.", + "surface": "android", + "id": "native.android.5aed4b5c8d6b246d" + }, + { + "kind": "ui-named-argument", + "line": 1005, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connect Gateway", "surface": "android", "id": "native.android.63c31ee564d63347" }, { - "kind": "ui-named-argument", - "line": 596, + "kind": "ui-toast", + "line": 1015, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Setup code issue", + "source": "Could not open setup guide.", "surface": "android", - "id": "native.android.afdd3e2c10f56b75" + "id": "native.android.58d80589d9d4b7ff" }, { "kind": "ui-named-argument", - "line": 613, + "line": 1023, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Advanced", + "source": "Scan QR or setup code", "surface": "android", - "id": "native.android.084eda7046e91737" + "id": "native.android.18d6dd32cfcc1c9a" }, { "kind": "ui-named-argument", - "line": 622, + "line": 1029, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Set up manually", + "surface": "android", + "id": "native.android.8b8acc5b98b8c99f" + }, + { + "kind": "ui-named-argument", + "line": 1051, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Before you start", + "surface": "android", + "id": "native.android.debd7b3e59db6ab2" + }, + { + "kind": "ui-named-argument", + "line": 1057, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Access to the Gateway device", + "surface": "android", + "id": "native.android.9cfcf68aae2c05c6" + }, + { + "kind": "ui-named-argument", + "line": 1061, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Phone can reach the Gateway", + "surface": "android", + "id": "native.android.71ce00f359981a0b" + }, + { + "kind": "ui-named-argument", + "line": 1068, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Android setup guide", + "surface": "android", + "id": "native.android.68a31cee94a9db86" + }, + { + "kind": "ui-named-argument", + "line": 1122, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Setup Gateway", + "surface": "android", + "id": "native.android.d514a981f80779e9" + }, + { + "kind": "ui-named-argument", + "line": 1128, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Start your Gateway.", + "surface": "android", + "id": "native.android.6a45e2dae35ab5a7" + }, + { + "kind": "ui-named-argument", + "line": 1134, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Generate a QR code.", + "surface": "android", + "id": "native.android.c6f9da7008df05a6" + }, + { + "kind": "ui-named-argument", + "line": 1155, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Choose from gallery", + "surface": "android", + "id": "native.android.96d40475de2885d6" + }, + { + "kind": "ui-named-argument", + "line": 1209, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "QR code not accepted", + "surface": "android", + "id": "native.android.fd83bd36419ef526" + }, + { + "kind": "ui-named-argument", + "line": 1224, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Choose another image", + "surface": "android", + "id": "native.android.4dde43318559c65d" + }, + { + "kind": "ui-named-argument", + "line": 1277, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Scan QR code", + "surface": "android", + "id": "native.android.54bab6e4054484d0" + }, + { + "kind": "ui-named-argument", + "line": 1279, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Open the camera and frame the code from openclaw qr.", + "surface": "android", + "id": "native.android.05987e822ac102ec" + }, + { + "kind": "ui-named-argument", + "line": 1313, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Align the QR code inside the square.", + "surface": "android", + "id": "native.android.90fa932d2e0adbd9" + }, + { + "kind": "ui-named-argument", + "line": 1331, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Camera access is needed to scan the setup QR.", + "surface": "android", + "id": "native.android.7a817ddf54373dbf" + }, + { + "kind": "ui-named-argument", + "line": 1336, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Allow camera", + "surface": "android", + "id": "native.android.c8d5f12cb28f3853" + }, + { + "kind": "ui-named-argument", + "line": 1357, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Close scanner", + "surface": "android", + "id": "native.android.7efba153567b1232" + }, + { + "kind": "ui-named-argument", + "line": 1519, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Enter setup code", + "surface": "android", + "id": "native.android.b8d5431e1e441f74" + }, + { + "kind": "ui-named-argument", + "line": 1520, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Setup code", "surface": "android", @@ -1715,7 +1907,47 @@ }, { "kind": "ui-named-argument", - "line": 624, + "line": 1524, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Paste setup code", + "surface": "android", + "id": "native.android.06a2214da1eee841" + }, + { + "kind": "ui-named-argument", + "line": 1528, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Setup code was not accepted", + "surface": "android", + "id": "native.android.35aec8a97ac12e1c" + }, + { + "kind": "ui-named-argument", + "line": 1532, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Use setup code", + "surface": "android", + "id": "native.android.9aaee5a05de93dbe" + }, + { + "kind": "ui-named-argument", + "line": 1563, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Manual setup", + "surface": "android", + "id": "native.android.073c93545602b198" + }, + { + "kind": "ui-named-argument", + "line": 1566, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Gateway URL", + "surface": "android", + "id": "native.android.46d58ca58c32ad52" + }, + { + "kind": "ui-named-argument", + "line": 1568, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Host", "surface": "android", @@ -1723,47 +1955,55 @@ }, { "kind": "ui-named-argument", - "line": 625, + "line": 1569, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Port", "surface": "android", "id": "native.android.e7f368b47b705410" }, { - "kind": "conditional-branch", - "line": 628, + "kind": "ui-named-argument", + "line": 1572, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "TLS off", + "source": "Use the Gateway computer's LAN address or secure remote hostname.", "surface": "android", - "id": "native.android.20d94e1cedd3f319" - }, - { - "kind": "conditional-branch", - "line": 628, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "TLS on", - "surface": "android", - "id": "native.android.a3a158b4a10d34ba" + "id": "native.android.1b0d9893ae4c0682" }, { "kind": "ui-named-argument", - "line": 629, + "line": 1579, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Local", + "source": "Token", "surface": "android", - "id": "native.android.a02f48c59c8b7b9d" + "id": "native.android.bfc48711d6deed92" }, { "kind": "ui-named-argument", - "line": 631, + "line": 1580, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Token optional", + "source": "Paste token", "surface": "android", - "id": "native.android.aae4212f2ff4df98" + "id": "native.android.7ac2b9aec9d78cda" }, { "kind": "ui-named-argument", - "line": 632, + "line": 1582, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Paste a shared Gateway token or operator-issued token.", + "surface": "android", + "id": "native.android.96ba0e3919c3955a" + }, + { + "kind": "ui-named-argument", + "line": 1589, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Password", + "surface": "android", + "id": "native.android.4a5a1702f0130328" + }, + { + "kind": "ui-named-argument", + "line": 1590, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Password optional", "surface": "android", @@ -1771,39 +2011,159 @@ }, { "kind": "ui-named-argument", - "line": 637, + "line": 1594, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Pair with Gateway", + "source": "Connection type", "surface": "android", - "id": "native.android.369ee061f6d7881f" + "id": "native.android.1259d0d0d1671ed7" }, { "kind": "ui-named-argument", - "line": 710, + "line": 1596, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Last gateway", + "source": "Local network", "surface": "android", - "id": "native.android.82b296ed39647687" + "id": "native.android.278d9abb3abc12e6" }, { "kind": "ui-named-argument", - "line": 775, + "line": 1597, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Edit connection", + "source": "Secure remote", "surface": "android", - "id": "native.android.e0e5d2bb055863fb" + "id": "native.android.4aa2b8be11a971eb" }, { "kind": "ui-named-argument", - "line": 778, + "line": 1600, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Copy diagnostic", + "source": "Local works for LAN or emulator hosts. Secure remote is for wss:// or Tailscale Serve/Funnel.", "surface": "android", - "id": "native.android.ea2f18dad7749613" + "id": "native.android.8c31e2762b496d61" }, { "kind": "ui-named-argument", - "line": 825, + "line": 1608, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Could not test connection", + "surface": "android", + "id": "native.android.24a0a4819f223b87" + }, + { + "kind": "ui-named-argument", + "line": 1613, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Test connection", + "surface": "android", + "id": "native.android.9a8f8ba539d4dd68" + }, + { + "kind": "ui-call", + "line": 1791, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "View details", + "surface": "android", + "id": "native.android.a1c6ad798cdb4e32" + }, + { + "kind": "ui-call", + "line": 1824, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Connection details", + "surface": "android", + "id": "native.android.ebdc41fd34eab0a5" + }, + { + "kind": "ui-call", + "line": 1836, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Copy", + "surface": "android", + "id": "native.android.f982d1f6ad7b3e5d" + }, + { + "kind": "ui-call", + "line": 1841, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Close", + "surface": "android", + "id": "native.android.fa2a2369f23aa195" + }, + { + "kind": "ui-toast", + "line": 1853, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Details copied", + "surface": "android", + "id": "native.android.50cca1e186823cd6" + }, + { + "kind": "ui-named-argument", + "line": 1938, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Approve node access", + "surface": "android", + "id": "native.android.31a4bf3ed4071f7c" + }, + { + "kind": "ui-named-argument", + "line": 1945, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Gateway pairing is complete. Approve this phone as a node so OpenClaw can use the device capabilities you enable.", + "surface": "android", + "id": "native.android.f43cb6ed03dc6766" + }, + { + "kind": "ui-named-argument", + "line": 1953, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "On the Gateway computer, run:", + "surface": "android", + "id": "native.android.8e49feba12c350ac" + }, + { + "kind": "ui-named-argument", + "line": 1962, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Use the requestId from the pending command in the approve command.", + "surface": "android", + "id": "native.android.6189b5e2af7cc793" + }, + { + "kind": "ui-named-argument", + "line": 1973, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "I have approved", + "surface": "android", + "id": "native.android.53bcd45eeb574d74" + }, + { + "kind": "ui-named-argument", + "line": 1986, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Still waiting for approval", + "surface": "android", + "id": "native.android.cdc44aaeb8bd6e4a" + }, + { + "kind": "ui-named-argument", + "line": 1989, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Run the approve command on the Gateway computer, then check again.", + "surface": "android", + "id": "native.android.df5cc00eb95c21ad" + }, + { + "kind": "ui-named-argument", + "line": 1996, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "OK", + "surface": "android", + "id": "native.android.f579f91c216dd6c4" + }, + { + "kind": "ui-named-argument", + "line": 2146, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Copy approval command", "surface": "android", @@ -1811,39 +2171,23 @@ }, { "kind": "ui-named-argument", - "line": 852, + "line": 2172, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Allow permissions", + "source": "Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings.", "surface": "android", - "id": "native.android.a31f2beb68298aa8" + "id": "native.android.d4c3bcf880727fb3" }, { "kind": "ui-named-argument", - "line": 857, + "line": 2184, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "These permissions keep OpenClaw secure\nand useful.", + "source": "Continue", "surface": "android", - "id": "native.android.b82104d8df1d4b4a" + "id": "native.android.d7ea800cc2475013" }, { "kind": "ui-named-argument", - "line": 935, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Open $title", - "surface": "android", - "id": "native.android.03b95088ba72bd8e" - }, - { - "kind": "ui-call", - "line": 996, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Choose what this phone can share with OpenClaw. You can change these later in Settings.", - "surface": "android", - "id": "native.android.f38ca3445453a6bb" - }, - { - "kind": "ui-named-argument", - "line": 1017, + "line": 2211, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Back", "surface": "android", @@ -1851,71 +2195,23 @@ }, { "kind": "ui-named-argument", - "line": 1021, + "line": 2258, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Permission Setup", + "source": "Permissions", "surface": "android", - "id": "native.android.4b54af42d17dec84" + "id": "native.android.f3f0527db434d756" }, { "kind": "conditional-branch", - "line": 1076, + "line": 2583, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Granted", + "source": "The code may have expired or been generated for another Gateway.", "surface": "android", - "id": "native.android.437599c024df158e" + "id": "native.android.29732759e050c874" }, { "kind": "conditional-branch", - "line": 1076, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Not granted", - "surface": "android", - "id": "native.android.cf8344d3562e28e3" - }, - { - "kind": "ui-named-argument", - "line": 1101, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Continue", - "surface": "android", - "id": "native.android.d7ea800cc2475013" - }, - { - "kind": "conditional-branch", - "line": 1156, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Scan fresh setup code", - "surface": "android", - "id": "native.android.547f11a819721ffb" - }, - { - "kind": "conditional-branch", - "line": 1158, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Retry connection", - "surface": "android", - "id": "native.android.3b169db8b57795b5" - }, - { - "kind": "conditional-branch", - "line": 1337, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Setup code expired. Scan a fresh setup QR.", - "surface": "android", - "id": "native.android.e44224ed4df1700f" - }, - { - "kind": "conditional-branch", - "line": 1344, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Gateway access needs review. Check gateway authentication scopes, then retry.", - "surface": "android", - "id": "native.android.e03bb4ea463ba7a5" - }, - { - "kind": "conditional-branch", - "line": 1345, + "line": 2587, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway password is required. Enter it again or edit this connection.", "surface": "android", @@ -1923,7 +2219,7 @@ }, { "kind": "conditional-branch", - "line": 1346, + "line": 2588, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway password is invalid. Re-enter it or reset this gateway connection.", "surface": "android", @@ -1931,7 +2227,7 @@ }, { "kind": "conditional-branch", - "line": 1347, + "line": 2589, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway token is required. Enter it again or edit this connection.", "surface": "android", @@ -1939,7 +2235,7 @@ }, { "kind": "conditional-branch", - "line": 1349, + "line": 2591, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway requires this device identity. Re-authenticate or reset this gateway connection.", "surface": "android", @@ -1947,7 +2243,7 @@ }, { "kind": "conditional-branch", - "line": 1353, + "line": 2595, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Saved authentication is invalid. Re-authenticate or reset this gateway connection.", "surface": "android", @@ -1955,15 +2251,15 @@ }, { "kind": "conditional-branch", - "line": 1354, + "line": 2596, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Gateway authentication is not configured. Configure it on the gateway host, then retry.", + "source": "Gateway authentication is not configured. Edit this connection and try again.", "surface": "android", - "id": "native.android.3f61f2c34784ed0f" + "id": "native.android.214b03b4cd2199b1" }, { "kind": "conditional-branch", - "line": 1355, + "line": 2597, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway authentication needs review. Check gateway settings, then retry.", "surface": "android", @@ -1971,7 +2267,7 @@ }, { "kind": "conditional-branch", - "line": 1372, + "line": 2614, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "$summary $it", "surface": "android", @@ -1979,19 +2275,27 @@ }, { "kind": "ui-toast", - "line": 1419, + "line": 2757, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Approval command copied", "surface": "android", "id": "native.android.a7933196a18ec924" }, { - "kind": "ui-toast", - "line": 1446, + "kind": "conditional-branch", + "line": 2819, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", - "source": "Diagnostic copied", + "source": "Granted", "surface": "android", - "id": "native.android.bc9715de99c769c7" + "id": "native.android.437599c024df158e" + }, + { + "kind": "conditional-branch", + "line": 2819, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", + "source": "Not granted", + "surface": "android", + "id": "native.android.cf8344d3562e28e3" }, { "kind": "ui-named-argument", @@ -3929,6 +4233,14 @@ "surface": "android", "id": "native.android.f23eab3d31c5e25c" }, + { + "kind": "ui-named-argument", + "line": 1213, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", + "source": "Open ${row.title}", + "surface": "android", + "id": "native.android.8452bcda8a619361" + }, { "kind": "ui-named-argument", "line": 1287, @@ -4105,17 +4417,9 @@ "surface": "android", "id": "native.android.45005deba537cc59" }, - { - "kind": "ui-named-argument", - "line": 1772, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", - "source": "Open ${row.title}", - "surface": "android", - "id": "native.android.8452bcda8a619361" - }, { "kind": "conditional-branch", - "line": 1791, + "line": 1793, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Main session", "surface": "android", @@ -5445,98 +5749,18 @@ "kind": "resource-string", "line": 25, "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Gateway Setup", - "surface": "android", - "id": "native.android.3d9d01cc0af8727d" - }, - { - "kind": "resource-string", - "line": 26, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Connect to your Gateway", - "surface": "android", - "id": "native.android.fff6a6f130e88354" - }, - { - "kind": "resource-string", - "line": 27, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Scan setup code", - "surface": "android", - "id": "native.android.77a54beda22bfeb7" - }, - { - "kind": "resource-string", - "line": 28, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Use your Gateway QR or setup code", - "surface": "android", - "id": "native.android.8f525f9218215b42" - }, - { - "kind": "resource-string", - "line": 29, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Nearby gateway", - "surface": "android", - "id": "native.android.40da32c36971b959" - }, - { - "kind": "resource-string", - "line": 30, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Enter gateway URL", - "surface": "android", - "id": "native.android.7423bd0086e6897e" - }, - { - "kind": "resource-string", - "line": 31, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Connect using a manual URL", - "surface": "android", - "id": "native.android.c9420b6801f5a76f" - }, - { - "kind": "resource-string", - "line": 32, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Permissions", - "surface": "android", - "id": "native.android.4b55356d9bf6e444" - }, - { - "kind": "resource-string", - "line": 33, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Done", - "surface": "android", - "id": "native.android.c49a911b95af5d5d" - }, - { - "kind": "resource-string", - "line": 34, - "path": "apps/android/app/src/main/res/values/strings.xml", "source": "Verify the certificate fingerprint before trusting this gateway.\n\n%1$s", "surface": "android", "id": "native.android.288b83f93172c633" }, { "kind": "resource-string", - "line": 35, + "line": 26, "path": "apps/android/app/src/main/res/values/strings.xml", "source": "The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n%1$s\n\nNew SHA-256:\n%2$s", "surface": "android", "id": "native.android.6ad2d3f084142bed" }, - { - "kind": "resource-string", - "line": 36, - "path": "apps/android/app/src/main/res/values/strings.xml", - "source": "Gateway Recovery", - "surface": "android", - "id": "native.android.6ffa3d752514836d" - }, { "kind": "plist-string", "line": 8, diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b5aa7c33951..4c0e91ba30c 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -230,8 +230,9 @@ dependencies { implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.video) - implementation(libs.play.services.code.scanner) + implementation(libs.barcode.scanning) // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. implementation(libs.dnsjava) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 77e80a74e49..88908b30d6f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.gateway.DeviceAuthEntry import ai.openclaw.app.gateway.DeviceAuthStore import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewayDiscovery @@ -760,6 +761,9 @@ class NodeRuntime( updateStatus { nodeConnectionProblem = gatewayConnectionProblem(error, pauseReconnect) } + if (operatorConnected && nodeConnectFailureNeedsApprovalRefresh(error)) { + scope.launch { refreshNodesDevicesFromGateway() } + } }, onEvent = { _, _ -> }, onInvoke = { req -> @@ -1851,10 +1855,13 @@ class NodeRuntime( ) { activeGatewayAuth = auth val tls = connectionManager.resolveTlsParams(endpoint) + val storedOperatorEntry = loadStoredRoleDeviceAuthEntry("operator") + val usesStoredOperatorDeviceToken = + operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token) val operatorAuth = resolveOperatorSessionConnectAuth( auth = auth, - storedOperatorToken = loadStoredRoleDeviceToken("operator"), + storedOperatorToken = storedOperatorEntry?.token, ) if (operatorAuth == null) { updateStatus { @@ -1869,7 +1876,13 @@ class NodeRuntime( operatorAuth.token, operatorAuth.bootstrapToken, operatorAuth.password, - connectionManager.buildOperatorConnectOptions(), + connectionManager.buildOperatorConnectOptions( + scopes = + operatorConnectScopesForAuth( + usesStoredDeviceToken = usesStoredOperatorDeviceToken, + storedOperatorScopes = storedOperatorEntry?.scopes, + ), + ), tls, ) } @@ -2015,9 +2028,9 @@ class NodeRuntime( connect(GatewayEndpoint.manual(host = host, port = port)) } - private fun loadStoredRoleDeviceToken(role: String): String? { + private fun loadStoredRoleDeviceAuthEntry(role: String): DeviceAuthEntry? { val deviceId = identityStore.loadOrCreate().deviceId - return deviceAuthStore.loadToken(deviceId, role) + return deviceAuthStore.loadEntry(deviceId, role) } private fun maybeStartOperatorSessionAfterNodeConnect( @@ -2027,10 +2040,13 @@ class NodeRuntime( if (operatorConnected) { return } + val storedOperatorEntry = loadStoredRoleDeviceAuthEntry("operator") + val usesStoredOperatorDeviceToken = + operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token) val operatorAuth = resolveOperatorSessionConnectAuth( auth = auth, - storedOperatorToken = loadStoredRoleDeviceToken("operator"), + storedOperatorToken = storedOperatorEntry?.token, ) ?: return updateStatus { operatorStatusText = "Connecting…" @@ -2041,7 +2057,13 @@ class NodeRuntime( operatorAuth.token, operatorAuth.bootstrapToken, operatorAuth.password, - connectionManager.buildOperatorConnectOptions(), + connectionManager.buildOperatorConnectOptions( + scopes = + operatorConnectScopesForAuth( + usesStoredDeviceToken = usesStoredOperatorDeviceToken, + storedOperatorScopes = storedOperatorEntry?.scopes, + ), + ), connectionManager.resolveTlsParams(endpoint), ) } @@ -2449,6 +2471,7 @@ class NodeRuntime( if (!refreshStarted) return if (!operatorConnected) { nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { + _nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading _nodesDevicesSummary.value = GatewayNodesDevicesSummary( nodes = emptyList(), @@ -2462,12 +2485,14 @@ class NodeRuntime( try { val nodesRes = operatorSession.request("node.list", "{}") val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull() - val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray) + val nodes = parseGatewayNodeList(nodesRoot) + val selfNodeId = identityStore.loadOrCreate().deviceId val approval = currentNodeCapabilityApproval( nodes = nodes, - selfNodeId = identityStore.loadOrCreate().deviceId, + selfNodeId = selfNodeId, ) + val selfNodeConnected = nodes.firstOrNull { it.id == selfNodeId }?.connected == true val publishedApproval = nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) { _nodeCapabilityApproval.value = approval @@ -2475,6 +2500,13 @@ class NodeRuntime( if (!publishedApproval) { return } + if (selfNodeConnected && !_nodeConnected.value) { + updateStatus { + nodeConnectionProblem = null + _nodeConnected.value = true + nodeStatusText = "Connected" + } + } scheduleNodeApprovalCommandRefresh(refreshGeneration, approval) val devicesRoot = try { @@ -3409,6 +3441,27 @@ internal fun resolveOperatorSessionConnectAuth( ) } +internal fun operatorSessionUsesStoredDeviceToken( + auth: NodeRuntime.GatewayConnectAuth, + storedOperatorToken: String?, +): Boolean { + val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() } + if (storedToken == null) return false + val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() } + val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() } + return explicitToken == null && explicitPassword == null +} + +internal fun operatorConnectScopesForAuth( + usesStoredDeviceToken: Boolean, + storedOperatorScopes: List?, +): List { + if (usesStoredDeviceToken && storedOperatorScopes != null) { + return ConnectionManager.operatorScopesForStoredDeviceToken(storedOperatorScopes) + } + return ConnectionManager.nativeClientOperatorScopes +} + private enum class HomeCanvasGatewayState { Connected, Connecting, @@ -3572,6 +3625,12 @@ internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalSta else -> GatewayNodeApprovalState.Loading } +internal fun gatewayEventInvalidatesNodesDevices(event: String): Boolean = + event == "node.pair.requested" || event == "node.pair.resolved" + +internal fun nodeConnectFailureNeedsApprovalRefresh(error: GatewaySession.ErrorShape): Boolean = + error.details?.code == "PAIRING_REQUIRED" + internal fun currentNodeCapabilityApproval( nodes: List, selfNodeId: String, @@ -3618,6 +3677,25 @@ internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? { ) } +internal fun parseGatewayNodeList(root: JsonObject?): List { + if (root == null) return emptyList() + val seen = mutableSetOf() + val result = mutableListOf() + + fun append(nodes: JsonArray?) { + for (node in nodes?.mapNotNull(::parseGatewayNodeSummary).orEmpty()) { + if (seen.add(node.id)) { + result.add(node) + } + } + } + + append(root["nodes"] as? JsonArray) + append(root["pending"] as? JsonArray) + append(root["paired"] as? JsonArray) + return result +} + data class GatewayNodeSummary( val id: String, val displayName: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index 66a63352c52..fa3b35c11a1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -41,6 +41,7 @@ class SecurePrefs( "notifications.forwarding.maxEventsPerMinute" private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey" private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled" + private const val cameraEnabledKey = "camera.enabled" private const val voiceMicEnabledKey = "voice.micEnabled" private const val appearanceThemeModeKey = "appearance.themeMode" } @@ -51,6 +52,7 @@ class SecurePrefs( // Non-secret UI/runtime preferences stay readable for migration and backup behavior. private val plainPrefs: SharedPreferences = appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE) + private val hadPlainPrefsBeforeInit = plainPrefs.all.isNotEmpty() // Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences. private val masterKey by lazy { @@ -68,7 +70,7 @@ class SecurePrefs( MutableStateFlow(loadOrMigrateDisplayName(context = context)) val displayName: StateFlow = _displayName - private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true)) + private val _cameraEnabled = MutableStateFlow(loadCameraEnabled()) val cameraEnabled: StateFlow = _cameraEnabled private val _locationMode = MutableStateFlow(loadLocationMode()) @@ -199,7 +201,7 @@ class SecurePrefs( } fun setCameraEnabled(value: Boolean) { - plainPrefs.edit { putBoolean("camera.enabled", value) } + plainPrefs.edit { putBoolean(cameraEnabledKey, value) } _cameraEnabled.value = value } @@ -572,6 +574,15 @@ class SecurePrefs( return resolved } + private fun loadCameraEnabled(): Boolean { + if (plainPrefs.contains(cameraEnabledKey)) { + return plainPrefs.getBoolean(cameraEnabledKey, false) + } + val migratedValue = hadPlainPrefsBeforeInit + plainPrefs.edit { putBoolean(cameraEnabledKey, migratedValue) } + return migratedValue + } + private fun loadWakeWords(): List { val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim() if (raw.isNullOrEmpty()) return defaultWakeWords diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index e8ada570ba1..dd7e782a2f1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -705,6 +705,7 @@ class GatewaySession( setOf( "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ) scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index f6b5e4734fe..0ebc0b9dc4a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -32,6 +32,30 @@ class ConnectionManager( private val manualTls: () -> Boolean, ) { companion object { + internal val legacyOperatorScopes: List = + listOf( + "operator.approvals", + "operator.read", + "operator.write", + ) + + internal val nativeClientOperatorScopes: List = + listOf( + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ) + + internal fun operatorScopesForStoredDeviceToken(storedScopes: List): List { + val normalized = + storedScopes + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + return normalized.ifEmpty { legacyOperatorScopes } + } + /** * Decide whether a discovered/manual endpoint must use pinned TLS or can stay local cleartext. */ @@ -187,15 +211,12 @@ class ConnectionManager( ) /** Connect options for the Android operator session that drives approvals and UI actions. */ - fun buildOperatorConnectOptions(): GatewayConnectOptions = + fun buildOperatorConnectOptions( + scopes: List = nativeClientOperatorScopes, + ): GatewayConnectOptions = GatewayConnectOptions( role = "operator", - scopes = - listOf( - "operator.approvals", - "operator.read", - "operator.write", - ), + scopes = scopes, caps = emptyList(), commands = emptyList(), permissions = emptyMap(), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index a5ff936ae21..f58d854a392 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -92,7 +92,7 @@ internal fun resolveGatewayConnectConfig( passwordInput: String, ): GatewayConnectConfig? { if (useSetupCode) { - val setup = decodeGatewaySetupCode(setupCode) ?: return null + val setup = resolveSetupCodeCandidate(setupCode)?.let(::decodeGatewaySetupCode) ?: return null val parsed = parseGatewayEndpointResult(setup.url).config ?: return null val setupBootstrapToken = setup.bootstrapToken @@ -264,6 +264,8 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { } } +internal fun manualTokenLooksLikeSetupCode(rawInput: String): Boolean = resolveSetupCodeCandidate(rawInput)?.let(::decodeGatewaySetupCode) != null + /** Resolves QR scanner text to setup-code or validation error for UI copy. */ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult { val setupCode = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 45e78311f0f..677adcdf915 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -6,18 +6,15 @@ import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.R import ai.openclaw.app.SensitiveFeatureConfig -import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.isLocalCleartextGatewayHost import ai.openclaw.app.hasPhotoReadPermission import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.photoReadPermissionsForRequest import ai.openclaw.app.ui.design.ClawDesignTheme -import ai.openclaw.app.ui.design.ClawErrorState -import ai.openclaw.app.ui.design.ClawListItem -import ai.openclaw.app.ui.design.ClawPanel import ai.openclaw.app.ui.design.ClawPrimaryButton import ai.openclaw.app.ui.design.ClawScaffold -import ai.openclaw.app.ui.design.ClawStatus -import ai.openclaw.app.ui.design.ClawStatusPill +import ai.openclaw.app.ui.design.ClawSecondaryButton import ai.openclaw.app.ui.design.ClawTextField import ai.openclaw.app.ui.design.ClawTheme import android.Manifest @@ -32,58 +29,74 @@ import android.os.Build import android.os.SystemClock import android.provider.Settings import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.UseCase +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.QrCode2 -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Sensors import androidx.compose.material.icons.filled.WifiTethering import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -100,11 +113,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -112,23 +125,122 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions -import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import com.google.mlkit.vision.common.InputImage import kotlinx.coroutines.delay +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean -private enum class OnboardingStep { +internal enum class OnboardingStep { Welcome, Gateway, + SetupCode, + EnterSetupCode, + Manual, Recovery, + NodeApproval, Permissions, } +internal enum class OnboardingGatewayInputSource { + SetupScanner, + SetupGallery, + SetupEntry, + Manual, +} + private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L +private const val GATEWAY_CONNECT_TIMEOUT_MS = 20_000L +private const val NODE_APPROVAL_REFRESH_OBSERVE_TIMEOUT_MS = 750L +private const val NODE_APPROVAL_AUTO_REFRESH_MS = 2_000L +private const val ANDROID_SETUP_GUIDE_URL = "https://docs.openclaw.ai/platforms/android" +private val OnboardingHorizontalPadding = 24.dp +private val OnboardingTopPadding = 12.dp +private val OnboardingBottomPadding = 20.dp +private val OnboardingHeroTopOffset = 70.dp +private val OnboardingHeroTopOffsetAfterHeader = 0.dp +private val OnboardingHeroMarkSize = 78.dp +private val OnboardingButtonHeight = 56.dp +private val OnboardingActionGap = 10.dp +private val OnboardingBottomInset = 16.dp + +private fun onboardingContentPadding() = + PaddingValues( + start = OnboardingHorizontalPadding, + top = OnboardingTopPadding, + end = OnboardingHorizontalPadding, + bottom = OnboardingBottomPadding, + ) + +private fun Modifier.onboardingActionButton() = fillMaxWidth().height(OnboardingButtonHeight) + +internal data class OnboardingBackDestination( + val step: OnboardingStep, + val inlineQrScannerActive: Boolean = false, +) + +internal data class OnboardingBackState( + val step: OnboardingStep, + val inlineQrScannerActive: Boolean = false, + val setupCodeEntryOpenedFromScanner: Boolean = false, +) + +internal fun onboardingBackDestination( + step: OnboardingStep, + lastGatewayInputSource: OnboardingGatewayInputSource = OnboardingGatewayInputSource.SetupScanner, + nodeApprovalBackStep: OnboardingStep = OnboardingStep.Recovery, + permissionsBackStep: OnboardingStep = OnboardingStep.NodeApproval, +): OnboardingBackDestination? = + when (step) { + OnboardingStep.Welcome -> null + OnboardingStep.Gateway -> OnboardingBackDestination(OnboardingStep.Welcome) + OnboardingStep.SetupCode -> OnboardingBackDestination(OnboardingStep.Gateway) + OnboardingStep.EnterSetupCode -> OnboardingBackDestination(OnboardingStep.SetupCode) + OnboardingStep.Manual -> OnboardingBackDestination(OnboardingStep.Gateway) + OnboardingStep.Recovery -> + when (lastGatewayInputSource) { + OnboardingGatewayInputSource.SetupScanner -> OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = true) + OnboardingGatewayInputSource.SetupGallery, + OnboardingGatewayInputSource.SetupEntry, + -> OnboardingBackDestination(OnboardingStep.SetupCode) + OnboardingGatewayInputSource.Manual -> OnboardingBackDestination(OnboardingStep.Manual) + } + OnboardingStep.NodeApproval -> OnboardingBackDestination(nodeApprovalBackStep) + OnboardingStep.Permissions -> OnboardingBackDestination(permissionsBackStep) + } + +internal fun onboardingBackStateAfterBack( + step: OnboardingStep, + lastGatewayInputSource: OnboardingGatewayInputSource = OnboardingGatewayInputSource.SetupScanner, + setupCodeEntryOpenedFromScanner: Boolean = false, + nodeApprovalBackStep: OnboardingStep = OnboardingStep.Recovery, + permissionsBackStep: OnboardingStep = OnboardingStep.NodeApproval, +): OnboardingBackState? { + if (step == OnboardingStep.EnterSetupCode) { + return OnboardingBackState( + step = OnboardingStep.SetupCode, + inlineQrScannerActive = setupCodeEntryOpenedFromScanner, + ) + } + val destination = + onboardingBackDestination( + step = step, + lastGatewayInputSource = lastGatewayInputSource, + nodeApprovalBackStep = nodeApprovalBackStep, + permissionsBackStep = permissionsBackStep, + ) ?: return null + return OnboardingBackState(step = destination.step, inlineQrScannerActive = destination.inlineQrScannerActive) +} /** First-run Android onboarding flow for gateway pairing and permission setup. */ @Composable @@ -146,11 +258,9 @@ fun OnboardingFlow( val isConnected = gatewayConnectionDisplay.isConnected val isNodeConnected by viewModel.isNodeConnected.collectAsState() val nodeCapabilityApproval by viewModel.nodeCapabilityApproval.collectAsState() - val runtimeInitialized by viewModel.runtimeInitialized.collectAsState() + val nodesDevicesRefreshing by viewModel.nodesDevicesRefreshing.collectAsState() val serverName by viewModel.serverName.collectAsState() - val remoteAddress by viewModel.remoteAddress.collectAsState() 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() @@ -166,84 +276,74 @@ fun OnboardingFlow( var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) } var setupCode by rememberSaveable { mutableStateOf("") } - var manualHost by rememberSaveable { mutableStateOf("127.0.0.1") } + var manualHost by rememberSaveable { mutableStateOf("") } var manualPort by rememberSaveable { mutableStateOf("18789") } var manualTls by rememberSaveable { mutableStateOf(false) } var token by rememberSaveable { mutableStateOf(savedToken) } var password by rememberSaveable { mutableStateOf("") } var setupError by rememberSaveable { mutableStateOf(null) } + var setupScanError by rememberSaveable { mutableStateOf(null) } var attemptedConnect by rememberSaveable { mutableStateOf(false) } var attemptedGatewayName by rememberSaveable { mutableStateOf(null) } + var lastGatewayInputSource by rememberSaveable { mutableStateOf(OnboardingGatewayInputSource.SetupScanner) } + var inlineQrScannerActive by rememberSaveable { mutableStateOf(false) } + var setupCodeEntryOpenedFromScanner by rememberSaveable { mutableStateOf(false) } var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) } var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + var nodeApprovalBackStep by rememberSaveable { mutableStateOf(OnboardingStep.Recovery) } + var permissionsBackStep by rememberSaveable { mutableStateOf(OnboardingStep.NodeApproval) } + var nodeApprovalCheckRequested by rememberSaveable { mutableStateOf(false) } + var nodeApprovalCheckRefreshStarted by rememberSaveable { mutableStateOf(false) } + var nodeApprovalAutoContinueEnabled by rememberSaveable { mutableStateOf(false) } - OpenClawSystemBarAppearance(lightAppearance = !onboardingDark && step != OnboardingStep.Welcome) + OpenClawSystemBarAppearance(lightAppearance = !onboardingDark) - val qrScannerOptions = + var cameraPermissionGranted by rememberSaveable { + mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) + } + val setupBarcodeScannerOptions = remember { - GmsBarcodeScannerOptions + BarcodeScannerOptions .Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) .build() } - val qrScanner = remember(context, qrScannerOptions) { GmsBarcodeScanning.getClient(context, qrScannerOptions) } + val setupBarcodeScanner = remember(setupBarcodeScannerOptions) { BarcodeScanning.getClient(setupBarcodeScannerOptions) } + val cameraPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + cameraPermissionGranted = granted + } val permissionState = rememberPermissionState(context = context, viewModel = viewModel) - fun connectToGatewayPlan(plan: GatewayConnectPlan) { - setupError = null - attemptedGatewayName = null - attemptedConnect = true - connectAttemptStartedAtMs = SystemClock.elapsedRealtime() - viewModel.saveGatewayConfigAndConnect(plan) - step = OnboardingStep.Recovery + DisposableEffect(setupBarcodeScanner) { + onDispose { setupBarcodeScanner.close() } } - fun resolveCurrentGatewayPlan(setupCodeValue: String = setupCode): GatewayConnectPlan? = - resolveOnboardingGatewayConnectPlan( - setupCode = setupCodeValue, - savedManualHost = savedManualHost, - savedManualPort = savedManualPort.toString(), - savedManualTls = savedManualTls, - manualHost = manualHost, - manualPort = manualPort, - manualTls = manualTls, - token = token, - password = password, - ) + fun goBack() { + val next = + onboardingBackStateAfterBack( + step = step, + lastGatewayInputSource = lastGatewayInputSource, + setupCodeEntryOpenedFromScanner = setupCodeEntryOpenedFromScanner, + nodeApprovalBackStep = nodeApprovalBackStep, + permissionsBackStep = permissionsBackStep, + ) ?: return + inlineQrScannerActive = next.inlineQrScannerActive + setupCodeEntryOpenedFromScanner = next.setupCodeEntryOpenedFromScanner + step = next.step + } - fun scanGatewaySetupCode() { - setupError = null - qrScanner - .startScan() - .addOnSuccessListener { barcode -> - val scanned = resolveScannedSetupCodeResult(barcode.rawValue.orEmpty()) - val scannedSetupCode = scanned.setupCode - if (scannedSetupCode == null) { - setupError = - gatewayEndpointValidationMessage( - scanned.error ?: GatewayEndpointValidationError.INVALID_URL, - GatewayEndpointInputSource.QR_SCAN, - ) - step = OnboardingStep.Gateway - return@addOnSuccessListener - } - val plan = resolveCurrentGatewayPlan(setupCodeValue = scannedSetupCode) - if (plan == null) { - setupError = - gatewayEndpointValidationMessage( - GatewayEndpointValidationError.INVALID_URL, - GatewayEndpointInputSource.QR_SCAN, - ) - step = OnboardingStep.Gateway - return@addOnSuccessListener - } - setupCode = scannedSetupCode - connectToGatewayPlan(plan) - }.addOnFailureListener { - setupError = "Could not open the scanner." - step = OnboardingStep.Gateway - } + BackHandler( + enabled = + onboardingBackDestination( + step = step, + lastGatewayInputSource = lastGatewayInputSource, + nodeApprovalBackStep = nodeApprovalBackStep, + permissionsBackStep = permissionsBackStep, + ) != null, + ) { + goBack() } LaunchedEffect(startAtGatewaySetup) { @@ -254,35 +354,284 @@ fun OnboardingFlow( } LaunchedEffect(step) { - if (step == OnboardingStep.Gateway) { + if (step == OnboardingStep.Gateway || step == OnboardingStep.Manual) { viewModel.startGatewayDiscovery() } } - LaunchedEffect(ready, attemptedConnect) { - if (attemptedConnect && ready) { - step = OnboardingStep.Permissions - } - } - LaunchedEffect(step, connectAttemptStartedAtMs) { if (step != OnboardingStep.Recovery || connectAttemptStartedAtMs <= 0L) return@LaunchedEffect recoveryNowMs = SystemClock.elapsedRealtime() - delay(GATEWAY_CONNECT_SETTLING_MS) - recoveryNowMs = SystemClock.elapsedRealtime() + while (true) { + delay(1_000L) + recoveryNowMs = SystemClock.elapsedRealtime() + } + } + + LaunchedEffect(nodeApprovalCheckRequested, nodesDevicesRefreshing) { + if (nodeApprovalCheckRequested && nodesDevicesRefreshing) { + nodeApprovalCheckRefreshStarted = true + } + } + + LaunchedEffect(step, nodeApprovalCheckRequested, nodeApprovalCheckRefreshStarted, nodesDevicesRefreshing) { + if ( + !nodeApprovalCheckShouldClearUnobservedRefresh( + step = step, + checkRequested = nodeApprovalCheckRequested, + refreshStarted = nodeApprovalCheckRefreshStarted, + nodesDevicesRefreshing = nodesDevicesRefreshing, + ) + ) { + return@LaunchedEffect + } + delay(NODE_APPROVAL_REFRESH_OBSERVE_TIMEOUT_MS) + if ( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = step, + checkRequested = nodeApprovalCheckRequested, + refreshStarted = nodeApprovalCheckRefreshStarted, + nodesDevicesRefreshing = nodesDevicesRefreshing, + ) + ) { + nodeApprovalCheckRequested = false + } + } + + LaunchedEffect(step, ready, nodeApprovalCheckRequested, nodeApprovalCheckRefreshStarted, nodesDevicesRefreshing) { + if ( + step == OnboardingStep.NodeApproval && + nodeApprovalCheckCanContinue( + checkRequested = nodeApprovalCheckRequested, + refreshStarted = nodeApprovalCheckRefreshStarted, + nodesDevicesRefreshing = nodesDevicesRefreshing, + ready = ready, + ) + ) { + nodeApprovalCheckRequested = false + nodeApprovalCheckRefreshStarted = false + permissionsBackStep = OnboardingStep.NodeApproval + step = OnboardingStep.Permissions + } + } + + LaunchedEffect(step, ready, nodeCapabilityApproval, nodeApprovalAutoContinueEnabled) { + if ( + nodeApprovalShouldAutoContinue( + step = step, + ready = ready, + nodeCapabilityApproval = nodeCapabilityApproval, + autoContinueEnabled = nodeApprovalAutoContinueEnabled, + ) + ) { + nodeApprovalCheckRequested = false + nodeApprovalCheckRefreshStarted = false + nodeApprovalAutoContinueEnabled = false + permissionsBackStep = OnboardingStep.NodeApproval + step = OnboardingStep.Permissions + } + } + + LaunchedEffect(step, nodeCapabilityApproval, nodesDevicesRefreshing) { + if ( + step != OnboardingStep.NodeApproval || + !nodeCapabilityApprovalNeedsUserAction(nodeCapabilityApproval) || + nodesDevicesRefreshing + ) { + return@LaunchedEffect + } + while (true) { + delay(NODE_APPROVAL_AUTO_REFRESH_MS) + viewModel.refreshNodesDevices() + } + } + + fun connectGateway( + plan: GatewayConnectPlan, + inputSource: OnboardingGatewayInputSource, + attemptedName: String? = null, + ) { + setupError = null + setupScanError = null + attemptedGatewayName = attemptedName + attemptedConnect = true + lastGatewayInputSource = inputSource + connectAttemptStartedAtMs = SystemClock.elapsedRealtime() + viewModel.saveGatewayConfigAndConnect(plan) + step = OnboardingStep.Recovery + } + + fun continueFromGatewayPairing() { + when ( + gatewayPairingContinueDestination( + ready = ready, + nodeCapabilityApproval = nodeCapabilityApproval, + ) + ) { + OnboardingStep.Permissions -> { + permissionsBackStep = OnboardingStep.Recovery + step = OnboardingStep.Permissions + } + OnboardingStep.NodeApproval -> { + nodeApprovalCheckRequested = false + nodeApprovalCheckRefreshStarted = false + nodeApprovalAutoContinueEnabled = true + nodeApprovalBackStep = OnboardingStep.Recovery + step = OnboardingStep.NodeApproval + } + else -> { + viewModel.refreshNodesDevices() + viewModel.refreshGatewayConnection() + } + } + } + + fun checkNodeApproval() { + nodeApprovalCheckRequested = true + nodeApprovalCheckRefreshStarted = false + viewModel.refreshNodesDevices() + viewModel.refreshGatewayConnection() + } + + fun showSetupScanError(message: String) { + setupError = null + setupScanError = message + inlineQrScannerActive = false + } + + fun pairFromSetupCode( + code: String, + inputSource: OnboardingGatewayInputSource, + ) { + val trimmed = code.trim() + if (trimmed.isEmpty()) { + setupError = "Enter the setup code from openclaw qr." + return + } + val plan = + resolveGatewayConnectPlan( + useSetupCode = true, + setupCode = trimmed, + savedManualHost = manualHost, + savedManualPort = manualPort, + savedManualTls = manualTls, + manualHostInput = manualHost, + manualPortInput = manualPort, + manualTlsInput = manualTls, + bootstrapTokenInput = "", + tokenInput = token, + passwordInput = password, + ) + if (plan == null) { + setupError = "Setup code was not accepted. Generate a fresh code with openclaw qr." + return + } + connectGateway(plan = plan, inputSource = inputSource) + } + + fun handleScannedSetupCode( + rawValue: String, + inputSource: OnboardingGatewayInputSource, + ) { + val scanned = resolveScannedSetupCodeResult(rawValue) + if (scanned.setupCode == null) { + val message = + if (scanned.error == GatewayEndpointValidationError.INSECURE_REMOTE_URL) { + gatewayEndpointValidationMessage(GatewayEndpointValidationError.INSECURE_REMOTE_URL, GatewayEndpointInputSource.QR_SCAN) + } else { + "That QR code is not an OpenClaw setup QR. Generate a fresh code with openclaw qr, then try again." + } + showSetupScanError(message) + return + } + setupCode = scanned.setupCode + setupScanError = null + pairFromSetupCode(scanned.setupCode, inputSource = inputSource) + } + + fun pairFromManualFields() { + if (manualTokenLooksLikeSetupCode(token)) { + setupError = "That looks like a setup code. Go back and choose Setup Gateway, then Use setup code." + return + } + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = savedManualHost, + savedManualPort = savedManualPort.toString(), + savedManualTls = savedManualTls, + manualHostInput = manualHost, + manualPortInput = manualPort, + manualTlsInput = manualTls, + bootstrapTokenInput = "", + tokenInput = token, + passwordInput = password, + ) + if (plan == null) { + setupError = "Enter a valid Gateway URL and any required auth details." + return + } + connectGateway(plan = plan, inputSource = OnboardingGatewayInputSource.Manual) + } + + fun prefillManualFromNearby(endpoint: GatewayEndpoint) { + manualHost = endpoint.host + manualPort = nearbyGatewayManualPort(endpoint) + manualTls = nearbyGatewayManualTls(endpoint) + attemptedGatewayName = endpoint.name + setupError = null + step = OnboardingStep.Manual + } + + val galleryPicker = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + setupError = null + val image = + try { + InputImage.fromFilePath(context, uri) + } catch (_: Exception) { + showSetupScanError("Could not read that image. Choose a clear screenshot or image of the QR from openclaw qr.") + return@rememberLauncherForActivityResult + } + setupBarcodeScanner + .process(image) + .addOnSuccessListener { barcodes -> + val rawValue = barcodes.firstNotNullOfOrNull { barcode -> barcode.rawValue?.takeIf { it.isNotBlank() } } + if (rawValue == null) { + showSetupScanError("No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually.") + return@addOnSuccessListener + } + handleScannedSetupCode(rawValue, inputSource = OnboardingGatewayInputSource.SetupGallery) + }.addOnFailureListener { + showSetupScanError("Could not read a QR code from that image. Choose a clearer image or enter the setup code manually.") + } + } + + setupScanError?.let { message -> + SetupScanErrorDialog( + message = message, + onDismiss = { setupScanError = null }, + onChooseAnotherImage = { + setupScanError = null + galleryPicker.launch("image/*") + }, + onEnterSetupCode = { + setupScanError = null + setupError = null + inlineQrScannerActive = false + setupCodeEntryOpenedFromScanner = false + step = OnboardingStep.EnterSetupCode + }, + ) } pendingTrust?.let { prompt -> AlertDialog( onDismissRequest = viewModel::declineGatewayTrustPrompt, containerColor = ClawTheme.colors.surfaceRaised, - title = { - Text( - stringResource(R.string.trust_this_gateway), - style = ClawTheme.type.section, - color = ClawTheme.colors.text, - ) - }, + title = { Text(stringResource(R.string.trust_this_gateway), style = ClawTheme.type.section, color = ClawTheme.colors.text) }, text = { val message = if (prompt.previousFingerprintSha256.isNullOrBlank()) { @@ -302,12 +651,12 @@ fun OnboardingFlow( }, confirmButton = { TextButton(onClick = viewModel::acceptGatewayTrustPrompt) { - Text(stringResource(R.string.trust_and_continue)) + Text("Trust") } }, dismissButton = { TextButton(onClick = viewModel::declineGatewayTrustPrompt) { - Text(stringResource(R.string.cancel)) + Text("Cancel") } }, ) @@ -315,31 +664,88 @@ fun OnboardingFlow( when (step) { OnboardingStep.Welcome -> - ClawDesignTheme(dark = true) { - WelcomeScreen( - modifier = modifier, - onConnect = { step = OnboardingStep.Gateway }, - ) - } + WelcomeScreen( + modifier = modifier, + onConnect = { step = OnboardingStep.Gateway }, + ) OnboardingStep.Gateway -> GatewaySetupScreen( + modifier = modifier, + nearbyGateway = gateways.firstOrNull(), + onBack = ::goBack, + onSetupCode = { + setupError = null + setupScanError = null + inlineQrScannerActive = false + step = OnboardingStep.SetupCode + }, + onManualSetup = { + setupError = null + setupScanError = null + val nearbyGateway = gateways.firstOrNull() + if (nearbyGateway == null) { + attemptedGatewayName = null + step = OnboardingStep.Manual + } else { + prefillManualFromNearby(nearbyGateway) + } + }, + ) + OnboardingStep.SetupCode -> + SetupCodeInstructionsScreen( + modifier = modifier, + scannerActive = inlineQrScannerActive, + cameraPermissionGranted = cameraPermissionGranted, + scanner = setupBarcodeScanner, + onBack = ::goBack, + onScan = { + setupError = null + setupScanError = null + inlineQrScannerActive = true + if (!cameraPermissionGranted) { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + onRequestCameraPermission = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) }, + onCodeScanned = { rawValue -> handleScannedSetupCode(rawValue, inputSource = OnboardingGatewayInputSource.SetupScanner) }, + onCameraError = { + showSetupScanError("Could not start the camera. Choose a QR image from gallery or enter the setup code manually.") + }, + onCloseScanner = { inlineQrScannerActive = false }, + onChooseFromGallery = { + inlineQrScannerActive = false + galleryPicker.launch("image/*") + }, + onEnterSetupCode = { + setupError = null + setupScanError = null + setupCodeEntryOpenedFromScanner = inlineQrScannerActive + inlineQrScannerActive = false + step = OnboardingStep.EnterSetupCode + }, + ) + OnboardingStep.EnterSetupCode -> + SetupCodeEntryScreen( modifier = modifier, setupCode = setupCode, + error = setupError, + onBack = ::goBack, + onSetupCodeChange = { + setupCode = it + setupError = null + }, + onUseSetupCode = { pairFromSetupCode(setupCode, inputSource = OnboardingGatewayInputSource.SetupEntry) }, + ) + OnboardingStep.Manual -> + ManualGatewaySetupScreen( + modifier = modifier, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls, token = token, password = password, - nearbyGatewayName = gateways.firstOrNull()?.name, - discoveryStatusText = discoveryStatusText, - discoveryStarted = runtimeInitialized, error = setupError, - onBack = { step = OnboardingStep.Welcome }, - onScan = ::scanGatewaySetupCode, - onSetupCodeChange = { - setupCode = it - setupError = null - }, + onBack = ::goBack, onManualHostChange = { manualHost = it setupError = null @@ -349,24 +755,15 @@ fun OnboardingFlow( setupError = null }, onManualTlsChange = { manualTls = it }, - onTokenChange = { token = it }, - onPasswordChange = { password = it }, - onUseNearby = { - val endpoint = gateways.firstOrNull() ?: return@GatewaySetupScreen - attemptedGatewayName = endpoint.name - attemptedConnect = true - connectAttemptStartedAtMs = SystemClock.elapsedRealtime() - viewModel.connectInBackground(endpoint) - step = OnboardingStep.Recovery + onTokenChange = { + token = it + setupError = null }, - onPair = { - val plan = resolveCurrentGatewayPlan() - if (plan == null) { - setupError = "Enter a setup code or a valid gateway URL." - return@GatewaySetupScreen - } - connectToGatewayPlan(plan) + onPasswordChange = { + password = it + setupError = null }, + onPair = ::pairFromManualFields, ) OnboardingStep.Recovery -> GatewayRecoveryScreen( @@ -374,28 +771,73 @@ fun OnboardingFlow( statusText = statusText, serverName = serverName, attemptedGatewayName = attemptedGatewayName, - remoteAddress = remoteAddress, - ready = ready, - nodeCapabilityApproval = nodeCapabilityApproval, + gatewayPaired = isConnected, + gatewayPairingCanContinue = + isConnected && + gatewayPairingContinueDestination( + ready = ready, + nodeCapabilityApproval = nodeCapabilityApproval, + ) != null, gatewayConnectionProblem = gatewayConnectionProblem, connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS, - onBack = { step = OnboardingStep.Gateway }, + connectTimedOut = recoveryNowMs - connectAttemptStartedAtMs >= GATEWAY_CONNECT_TIMEOUT_MS, + onBack = ::goBack, onRetry = { - val plan = resolveCurrentGatewayPlan() ?: return@GatewayRecoveryScreen - connectToGatewayPlan(plan.copy(savedAuthAction = GatewaySavedAuthAction.PRESERVE)) + connectAttemptStartedAtMs = SystemClock.elapsedRealtime() + viewModel.refreshGatewayConnection() }, - onEdit = { step = OnboardingStep.Gateway }, - onScan = ::scanGatewaySetupCode, - onContinue = { step = OnboardingStep.Permissions }, + onContinue = ::continueFromGatewayPairing, + ) + OnboardingStep.NodeApproval -> + NodeApprovalScreen( + modifier = modifier, + approval = nodeCapabilityApproval, + checkingApproval = + nodeApprovalCheckingInProgress( + checkRequested = nodeApprovalCheckRequested, + refreshStarted = nodeApprovalCheckRefreshStarted, + nodesDevicesRefreshing = nodesDevicesRefreshing, + ), + checkRequested = nodeApprovalCheckRequested, + ready = ready, + onBack = ::goBack, + onCopyCommand = { command -> copyApprovalCommand(context, command) }, + onCheckApproval = ::checkNodeApproval, ) OnboardingStep.Permissions -> PermissionSetupScreen( modifier = modifier, permissionState = permissionState, - onBack = { step = OnboardingStep.Gateway }, + onBack = ::goBack, onContinue = { + val requiresNodeSurfaceRefresh = permissionState.requiresNodeApprovalAfterApply permissionState.applyToViewModel() - viewModel.setOnboardingCompleted(true) + if ( + permissionContinueNeedsNodeApproval( + ready = ready, + requiresNodeApprovalAfterApply = requiresNodeSurfaceRefresh, + nodeCapabilityApproval = nodeCapabilityApproval, + ) + ) { + val reapprovalBackSteps = + permissionReapprovalBackSteps( + currentNodeApprovalBackStep = nodeApprovalBackStep, + currentPermissionsBackStep = permissionsBackStep, + ) + nodeApprovalBackStep = reapprovalBackSteps.nodeApprovalBackStep + permissionsBackStep = reapprovalBackSteps.permissionsBackStep + nodeApprovalCheckRequested = false + nodeApprovalCheckRefreshStarted = false + nodeApprovalAutoContinueEnabled = false + viewModel.refreshNodesDevices() + viewModel.refreshGatewayConnection() + step = OnboardingStep.NodeApproval + } else { + if (requiresNodeSurfaceRefresh) { + viewModel.refreshGatewayConnection() + } + viewModel.setOnboardingCompleted(true) + } }, ) } @@ -407,51 +849,22 @@ private fun WelcomeScreen( onConnect: () -> Unit, modifier: Modifier = Modifier, ) { - val welcomeBackground = - Brush.verticalGradient( - colors = - listOf( - Color(0xFFFF4D4D), - Color(0xFFD73332), - Color(0xFF991B1B), - Color(0xFF260707), - ), - ) - - Box( - modifier = - modifier - .fillMaxSize() - .background(welcomeBackground) - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(horizontal = 24.dp, vertical = 18.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(96.dp)) - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) { - WelcomeLogo() - Text( - text = "OPENCLAW", - style = ClawTheme.type.display.copy(fontSize = 34.sp, lineHeight = 38.sp, fontWeight = FontWeight.Black), - color = ClawTheme.colors.text, - ) - Text( - text = "Your personal AI assistant.\nExfoliate! Exfoliate!", - style = ClawTheme.type.section, - color = ClawTheme.colors.text, - textAlign = TextAlign.Center, - ) - } + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingHeroTopSpacer(afterHeader = false) + OnboardingIntroHero( + title = "Welcome to OpenClaw", + subtitle = "Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools.", + mark = { WelcomeLogo() }, + ) + Spacer(modifier = Modifier.height(24.dp)) + WelcomeChecklist() + Spacer(modifier = Modifier.height(16.dp)) + SecurityNotice() Spacer(modifier = Modifier.weight(1f)) - WelcomeHorizon() - Spacer(modifier = Modifier.height(30.dp)) - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { - HeroPrimaryAction(title = "Connect Gateway", onClick = onConnect) + OnboardingActions { + ClawPrimaryButton(text = "Continue", onClick = onConnect, modifier = Modifier.onboardingActionButton()) } - Spacer(modifier = Modifier.height(104.dp)) } } } @@ -459,10 +872,11 @@ private fun WelcomeScreen( @Composable private fun WelcomeLogo() { Surface( - modifier = Modifier.size(82.dp), + modifier = Modifier.size(OnboardingHeroMarkSize), shape = CircleShape, - color = Color.White.copy(alpha = 0.92f), + color = ClawTheme.colors.surfaceRaised, contentColor = Color.Unspecified, + border = BorderStroke(1.dp, ClawTheme.colors.border), ) { Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) { Image(painter = painterResource(id = R.drawable.openclaw_logo), contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize()) @@ -471,327 +885,1234 @@ private fun WelcomeLogo() { } @Composable -private fun WelcomeHorizon() { - Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) { - val arcWidth = size.width * 1.24f - val arcHeight = size.height * 1.18f - val left = (size.width - arcWidth) / 2f - val top = size.height * 0.28f - drawArc( - color = Color.White.copy(alpha = 0.7f), - startAngle = 202f, - sweepAngle = 136f, - useCenter = false, - topLeft = - androidx.compose.ui.geometry - .Offset(left, top), - size = - androidx.compose.ui.geometry - .Size(arcWidth, arcHeight), - style = Stroke(width = 2.2f), +private fun GatewayLogo() { + Surface( + modifier = Modifier.size(OnboardingHeroMarkSize), + shape = CircleShape, + color = ClawTheme.colors.surfaceRaised, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.QrCode2, contentDescription = null, modifier = Modifier.size(40.dp), tint = ClawTheme.colors.text) + } + } +} + +@Composable +private fun OnboardingIntroHero( + title: String, + subtitle: String, + mark: @Composable () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + mark() + Spacer(modifier = Modifier.height(26.dp)) + Text( + text = title, + style = ClawTheme.type.display.copy(fontSize = 31.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold), + color = ClawTheme.colors.text, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) - drawArc( - color = Color.White.copy(alpha = 0.16f), - startAngle = 200f, - sweepAngle = 140f, - useCenter = false, - topLeft = - androidx.compose.ui.geometry - .Offset(left, top + 8f), - size = - androidx.compose.ui.geometry - .Size(arcWidth, arcHeight), - style = Stroke(width = 10f), + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = subtitle, + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) } } +@Composable +private fun OnboardingHeroTopSpacer(afterHeader: Boolean) { + Spacer(modifier = Modifier.height(if (afterHeader) OnboardingHeroTopOffsetAfterHeader else OnboardingHeroTopOffset)) +} + +@Composable +private fun WelcomeChecklist() { + SoftPanel { + Column(verticalArrangement = Arrangement.spacedBy(13.dp)) { + WelcomeChecklistRow(icon = Icons.Default.Link, text = "Connect to your Gateway") + WelcomeChecklistRow(icon = Icons.Default.Security, text = "Choose device permissions") + WelcomeChecklistRow(icon = Icons.Default.CheckCircle, text = "Use OpenClaw from your phone") + } + } +} + +@Composable +private fun WelcomeChecklistRow( + icon: ImageVector, + text: String, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = ClawTheme.colors.text) + Text(text = text, style = ClawTheme.type.section, color = ClawTheme.colors.text) + } +} + +@Composable +private fun SecurityNotice() { + SoftPanel { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) { + Icon(imageVector = Icons.Default.ErrorOutline, contentDescription = null, modifier = Modifier.size(24.dp), tint = ClawTheme.colors.warning) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Security notice", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text( + text = "The connected OpenClaw agent can use device capabilities you enable. Continue only if you trust the Gateway and agent you connect to.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + ) + } + } + } +} + +@Composable +private fun SoftPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(ClawTheme.radii.panel), + color = ClawTheme.colors.surfaceRaised, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Column(modifier = Modifier.padding(18.dp), content = content) + } +} + @Composable private fun GatewaySetupScreen( + nearbyGateway: GatewayEndpoint?, + onBack: () -> Unit, + onSetupCode: () -> Unit, + onManualSetup: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { + Column(modifier = Modifier.fillMaxSize()) { + OnboardingHeader(title = "", onBack = onBack) + OnboardingHeroTopSpacer(afterHeader = true) + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingIntroHero( + title = "Connect Gateway", + subtitle = "Scan a QR code or use the setup code from your OpenClaw Gateway.", + mark = { GatewayLogo() }, + ) + Spacer(modifier = Modifier.height(24.dp)) + GatewayPrerequisites( + onOpenSetupGuide = { + runCatching { + uriHandler.openUri(ANDROID_SETUP_GUIDE_URL) + }.onFailure { + Toast.makeText(context, "Could not open setup guide.", Toast.LENGTH_SHORT).show() + } + }, + ) + } + Spacer(modifier = Modifier.weight(1f)) + OnboardingActions { + ClawPrimaryButton( + text = "Scan QR or setup code", + icon = Icons.Default.QrCode2, + onClick = onSetupCode, + modifier = Modifier.onboardingActionButton(), + ) + ClawSecondaryButton( + text = "Set up manually", + icon = Icons.Default.Link, + onClick = onManualSetup, + modifier = Modifier.onboardingActionButton(), + ) + } + } + } +} + +@Composable +private fun OnboardingActions(content: @Composable ColumnScope.() -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(OnboardingActionGap), content = content) + Spacer(modifier = Modifier.height(OnboardingBottomInset)) + } +} + +@Composable +private fun GatewayPrerequisites(onOpenSetupGuide: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + text = "Before you start", + style = ClawTheme.type.label, + color = ClawTheme.colors.text, + modifier = Modifier.fillMaxWidth(), + ) + GatewayPrerequisiteRow( + title = "Access to the Gateway device", + body = "Have a terminal open on the device running OpenClaw.", + ) + GatewayPrerequisiteRow( + title = "Phone can reach the Gateway", + body = "Use the same network, or a secure remote Gateway URL.", + ) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + TextButton(onClick = onOpenSetupGuide) { + Icon(imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.primary) + Spacer(modifier = Modifier.width(7.dp)) + Text(text = "Android setup guide", style = ClawTheme.type.label, color = ClawTheme.colors.primary) + } + } + } +} + +@Composable +private fun GatewayPrerequisiteRow( + title: String, + body: String, +) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(10.dp).background(ClawTheme.colors.primary, CircleShape)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = title, style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold), color = ClawTheme.colors.text) + Text(text = body, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + } + } +} + +@Composable +private fun GatewayCommand( + label: String, + command: String, +) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + Text(text = command, style = ClawTheme.type.mono.copy(fontSize = 16.sp, lineHeight = 22.sp), color = ClawTheme.colors.text) + } +} + +@Composable +private fun SetupCodeInstructionsScreen( + scannerActive: Boolean, + cameraPermissionGranted: Boolean, + scanner: BarcodeScanner, + onBack: () -> Unit, + onScan: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCodeScanned: (String) -> Unit, + onCameraError: () -> Unit, + onCloseScanner: () -> Unit, + onChooseFromGallery: () -> Unit, + onEnterSetupCode: () -> Unit, + modifier: Modifier = Modifier, +) { + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { + Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item { + OnboardingHeader(title = "Setup Gateway", onBack = onBack) + } + item { + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) { + SetupInstruction( + step = "Step 1", + title = "Start your Gateway.", + body = "openclaw gateway", + monospaceBody = true, + ) + SetupInstruction( + step = "Step 2", + title = "Generate a QR code.", + body = "openclaw qr", + monospaceBody = true, + ) + } + } + item { + ScanQrTile( + scannerActive = scannerActive, + cameraPermissionGranted = cameraPermissionGranted, + scanner = scanner, + onClick = onScan, + onClose = onCloseScanner, + onRequestCameraPermission = onRequestCameraPermission, + onCodeScanned = onCodeScanned, + onCameraError = onCameraError, + ) + } + } + OnboardingActions { + ClawSecondaryButton( + text = "Choose from gallery", + icon = Icons.Default.Image, + onClick = onChooseFromGallery, + modifier = Modifier.onboardingActionButton(), + ) + ClawSecondaryButton( + text = "Enter setup code", + icon = Icons.Default.QrCode2, + onClick = onEnterSetupCode, + modifier = Modifier.onboardingActionButton(), + ) + } + } + } +} + +@Composable +private fun SetupScanErrorDialog( + message: String, + onDismiss: () -> Unit, + onChooseAnotherImage: () -> Unit, + onEnterSetupCode: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp), + shape = RoundedCornerShape(ClawTheme.radii.sheet), + color = ClawTheme.colors.surfaceRaised, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.borderStrong), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.size(38.dp), + shape = CircleShape, + color = ClawTheme.colors.warningSoft, + contentColor = ClawTheme.colors.warning, + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.ErrorOutline, contentDescription = null, modifier = Modifier.size(22.dp)) + } + } + Text( + text = "QR code not accepted", + style = ClawTheme.type.title, + color = ClawTheme.colors.text, + modifier = Modifier.weight(1f), + ) + } + + Text( + text = message, + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + ) + + Column(verticalArrangement = Arrangement.spacedBy(OnboardingActionGap), modifier = Modifier.fillMaxWidth()) { + ClawPrimaryButton( + text = "Choose another image", + icon = Icons.Default.Image, + onClick = onChooseAnotherImage, + modifier = Modifier.onboardingActionButton(), + ) + ClawSecondaryButton( + text = "Enter setup code", + icon = Icons.Default.QrCode2, + onClick = onEnterSetupCode, + modifier = Modifier.onboardingActionButton(), + ) + } + } + } + } +} + +@Composable +private fun ScanQrTile( + scannerActive: Boolean, + cameraPermissionGranted: Boolean, + scanner: BarcodeScanner, + onClick: () -> Unit, + onClose: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCodeScanned: (String) -> Unit, + onCameraError: () -> Unit, + modifier: Modifier = Modifier, +) { + val tileShape = RoundedCornerShape(12.dp) + + if (!scannerActive) { + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth().aspectRatio(1f), + shape = tileShape, + color = ClawTheme.colors.surfaceRaised, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.borderStrong), + ) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Surface( + modifier = Modifier.size(56.dp), + shape = CircleShape, + color = ClawTheme.colors.surfacePressed, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.CameraAlt, contentDescription = null, modifier = Modifier.size(28.dp)) + } + } + Text(text = "Scan QR code", style = ClawTheme.type.title.copy(fontSize = 20.sp, lineHeight = 25.sp), color = ClawTheme.colors.text, textAlign = TextAlign.Center) + Text( + text = "Open the camera and frame the code from openclaw qr.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + textAlign = TextAlign.Center, + ) + } + } + } + return + } + + Surface( + modifier = modifier.fillMaxWidth().aspectRatio(1f), + shape = tileShape, + color = ClawTheme.colors.surfaceRaised, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.borderStrong), + ) { + if (cameraPermissionGranted) { + Box(modifier = Modifier.fillMaxSize()) { + QrCameraPreview(scanner = scanner, onCodeScanned = onCodeScanned, onCameraError = onCameraError, modifier = Modifier.fillMaxSize()) + ScannerCloseButton(onClick = onClose, modifier = Modifier.align(Alignment.TopEnd).padding(12.dp)) + Box( + modifier = Modifier.fillMaxSize().padding(42.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .border(2.dp, ClawTheme.colors.primary, RoundedCornerShape(24.dp)), + ) + } + Text( + text = "Align the QR code inside the square.", + style = ClawTheme.type.caption, + color = Color.White, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.62f)) + .padding(horizontal = 14.dp, vertical = 12.dp), + textAlign = TextAlign.Center, + ) + } + } else { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + ScannerCloseButton(onClick = onClose, modifier = Modifier.align(Alignment.TopEnd)) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(imageVector = Icons.Default.CameraAlt, contentDescription = null, modifier = Modifier.size(36.dp), tint = ClawTheme.colors.text) + Text( + text = "Camera access is needed to scan the setup QR.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + textAlign = TextAlign.Center, + ) + ClawPrimaryButton(text = "Allow camera", icon = Icons.Default.CameraAlt, onClick = onRequestCameraPermission, modifier = Modifier.onboardingActionButton()) + } + } + } + } +} + +@Composable +private fun ScannerCloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + modifier = modifier.size(40.dp), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.68f), + contentColor = Color.White, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.26f)), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.Close, contentDescription = "Close scanner", modifier = Modifier.size(20.dp)) + } + } +} + +@Composable +private fun QrCameraPreview( + scanner: BarcodeScanner, + onCodeScanned: (String) -> Unit, + onCameraError: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val previewView = + remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + } + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + val processingFrame = remember { AtomicBoolean(false) } + val handledScan = remember { AtomicBoolean(false) } + val scanActive = remember { AtomicBoolean(true) } + + DisposableEffect(context, lifecycleOwner, scanner, previewView) { + scanActive.set(true) + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + var cameraProvider: ProcessCameraProvider? = null + var preview: Preview? = null + var analysis: ImageAnalysis? = null + var disposed = false + val listener = + Runnable { + val provider = + try { + cameraProviderFuture.get() + } catch (_: Exception) { + if (!disposed) onCameraError() + return@Runnable + } + if (disposed) return@Runnable + val previewUseCase = + Preview + .Builder() + .build() + .also { it.surfaceProvider = previewView.surfaceProvider } + val analysisUseCase = + ImageAnalysis + .Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + analysisUseCase.setAnalyzer(analysisExecutor) { imageProxy -> + analyzeSetupQrFrame( + imageProxy = imageProxy, + scanner = scanner, + processingFrame = processingFrame, + handledScan = handledScan, + scanActive = scanActive, + onCodeScanned = onCodeScanned, + ) + } + + try { + val selector = + setupQrCameraSelector(provider) ?: run { + analysisUseCase.clearAnalyzer() + if (!disposed) onCameraError() + return@Runnable + } + if (disposed) { + analysisUseCase.clearAnalyzer() + return@Runnable + } + provider.bindToLifecycle(lifecycleOwner, selector, previewUseCase, analysisUseCase) + if (disposed) { + analysisUseCase.clearAnalyzer() + provider.unbind(previewUseCase, analysisUseCase) + return@Runnable + } + cameraProvider = provider + preview = previewUseCase + analysis = analysisUseCase + } catch (_: Exception) { + analysisUseCase.clearAnalyzer() + if (!disposed) onCameraError() + } + } + cameraProviderFuture.addListener(listener, ContextCompat.getMainExecutor(context)) + + onDispose { + disposed = true + scanActive.set(false) + analysis?.clearAnalyzer() + val boundUseCases = listOfNotNull(preview, analysis) + if (boundUseCases.isNotEmpty()) { + cameraProvider?.unbind(*boundUseCases.toTypedArray()) + } + } + } + + DisposableEffect(Unit) { + onDispose { analysisExecutor.shutdown() } + } + + AndroidView(factory = { previewView }, modifier = modifier) +} + +private fun setupQrCameraSelector(provider: ProcessCameraProvider): CameraSelector? = + when { + provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) -> CameraSelector.DEFAULT_BACK_CAMERA + provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) -> CameraSelector.DEFAULT_FRONT_CAMERA + else -> null + } + +@androidx.annotation.OptIn(ExperimentalGetImage::class) +private fun analyzeSetupQrFrame( + imageProxy: ImageProxy, + scanner: BarcodeScanner, + processingFrame: AtomicBoolean, + handledScan: AtomicBoolean, + scanActive: AtomicBoolean, + onCodeScanned: (String) -> Unit, +) { + if (!scanActive.get() || handledScan.get() || !processingFrame.compareAndSet(false, true)) { + imageProxy.close() + return + } + val mediaImage = imageProxy.image + if (mediaImage == null) { + processingFrame.set(false) + imageProxy.close() + return + } + + val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner + .process(inputImage) + .addOnSuccessListener { barcodes -> + val rawValue = barcodes.firstNotNullOfOrNull { barcode -> barcode.rawValue?.takeIf { it.isNotBlank() } } + if (rawValue != null && scanActive.get() && handledScan.compareAndSet(false, true)) { + onCodeScanned(rawValue) + } + }.addOnCompleteListener { + processingFrame.set(false) + imageProxy.close() + } +} + +@Composable +private fun SetupCodeEntryScreen( setupCode: String, + error: String?, + onBack: () -> Unit, + onSetupCodeChange: (String) -> Unit, + onUseSetupCode: () -> Unit, + modifier: Modifier = Modifier, +) { + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { + Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) { + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + OnboardingHeader(title = "Enter setup code", onBack = onBack) + LabeledField(label = "Setup code") { + ClawTextField( + value = setupCode, + onValueChange = onSetupCodeChange, + placeholder = "Paste setup code", + ) + } + error?.let { message -> + InlineError(title = "Setup code was not accepted", body = message) + } + } + OnboardingActions { + ClawPrimaryButton(text = "Use setup code", icon = Icons.Default.QrCode2, onClick = onUseSetupCode, modifier = Modifier.onboardingActionButton()) + } + } + } +} + +@Composable +private fun ManualGatewaySetupScreen( manualHost: String, manualPort: String, manualTls: Boolean, token: String, password: String, - nearbyGatewayName: String?, - discoveryStatusText: String, - discoveryStarted: Boolean, error: String?, onBack: () -> Unit, - onScan: () -> Unit, - onSetupCodeChange: (String) -> Unit, onManualHostChange: (String) -> Unit, onManualPortChange: (String) -> Unit, onManualTlsChange: (Boolean) -> Unit, onTokenChange: (String) -> Unit, onPasswordChange: (String) -> Unit, - onUseNearby: () -> Unit, onPair: () -> Unit, modifier: Modifier = Modifier, ) { - var advancedOpen by rememberSaveable { mutableStateOf(false) } - var nearbySearchTimedOut by remember { mutableStateOf(false) } - - LaunchedEffect(nearbyGatewayName, discoveryStatusText, discoveryStarted) { - if (!nearbyGatewayName.isNullOrBlank()) { - nearbySearchTimedOut = false - return@LaunchedEffect - } - if (!discoveryStarted) { - nearbySearchTimedOut = false - return@LaunchedEffect - } - nearbySearchTimedOut = false - delay(5_000) - nearbySearchTimedOut = true - } - - val nearbyGateway = - nearbyGatewayUiState( - nearbyGatewayName = nearbyGatewayName, - discoveryStatusText = discoveryStatusText, - discoveryStarted = discoveryStarted, - searchTimedOut = nearbySearchTimedOut, - ) - - ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) { + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) { - LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { item { - OnboardingHeader( - title = stringResource(R.string.gateway_setup), - subtitle = stringResource(R.string.connect_to_gateway), - onBack = onBack, - ) + OnboardingHeader(title = "Manual setup", onBack = onBack) } item { - GatewayOption( - icon = Icons.Default.QrCode2, - title = stringResource(R.string.scan_setup_code), - subtitle = stringResource(R.string.use_gateway_qr), - onClick = onScan, - ) - } - item { - GatewayOption( - icon = Icons.Default.WifiTethering, - title = stringResource(R.string.nearby_gateway), - subtitle = nearbyGateway.subtitle, - status = nearbyGateway.status, - onClick = onUseNearby.takeIf { nearbyGateway.canConnect }, - ) - } - item { - GatewayOption( - icon = Icons.Default.Link, - title = stringResource(R.string.enter_gateway_url), - subtitle = stringResource(R.string.connect_manual_url), - onClick = { advancedOpen = true }, - ) - } - error?.let { message -> - item { - ClawErrorState( - title = "Setup code issue", - body = message, + LabeledField(label = "Gateway URL") { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ClawTextField(value = manualHost, onValueChange = onManualHostChange, placeholder = "Host", modifier = Modifier.weight(1f)) + ClawTextField(value = manualPort, onValueChange = onManualPortChange, placeholder = "Port", modifier = Modifier.width(104.dp)) + } + Text( + text = "Use the Gateway computer's LAN address or secure remote hostname.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, ) } } item { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Surface( - onClick = { advancedOpen = !advancedOpen }, - color = Color.Transparent, - contentColor = ClawTheme.colors.text, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = "Advanced", style = ClawTheme.type.section, color = ClawTheme.colors.text) - Icon( - imageVector = if (advancedOpen) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.size(25.dp), - ) - } - } - if (advancedOpen) { - ClawTextField(value = setupCode, onValueChange = onSetupCodeChange, placeholder = "Setup code") - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ClawTextField(value = manualHost, onValueChange = onManualHostChange, placeholder = "Host", modifier = Modifier.weight(1f)) - ClawTextField(value = manualPort, onValueChange = onManualPortChange, placeholder = "Port", modifier = Modifier.width(104.dp)) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - TogglePill(text = if (manualTls) "TLS on" else "TLS off", selected = manualTls, onClick = { onManualTlsChange(!manualTls) }) - TogglePill(text = "Local", selected = !manualTls, onClick = { onManualTlsChange(false) }) - } - ClawTextField(value = token, onValueChange = onTokenChange, placeholder = "Token optional") - ClawTextField(value = password, onValueChange = onPasswordChange, placeholder = "Password optional") + LabeledField(label = "Token") { + ClawTextField(value = token, onValueChange = onTokenChange, placeholder = "Paste token") + Text( + text = "Paste a shared Gateway token or operator-issued token.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + } + } + item { + LabeledField(label = "Password") { + ClawTextField(value = password, onValueChange = onPasswordChange, placeholder = "Password optional") + } + } + item { + LabeledField(label = "Connection type") { + Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) { + TogglePill(text = "Local network", selected = !manualTls, onClick = { onManualTlsChange(false) }) + TogglePill(text = "Secure remote", selected = manualTls, onClick = { onManualTlsChange(true) }) } + Text( + text = "Local works for LAN or emulator hosts. Secure remote is for wss:// or Tailscale Serve/Funnel.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + } + } + error?.let { message -> + item { + InlineError(title = "Could not test connection", body = message) } } } - ClawPrimaryButton(text = "Pair with Gateway", icon = Icons.Default.Security, onClick = onPair, modifier = Modifier.fillMaxWidth()) + OnboardingActions { + ClawPrimaryButton(text = "Test connection", icon = Icons.Default.Security, onClick = onPair, modifier = Modifier.onboardingActionButton()) + } } } } +@Composable +private fun SetupInstruction( + step: String, + title: String, + body: String, + modifier: Modifier = Modifier, + monospaceBody: Boolean = false, +) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = step, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle) + Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text) + if (monospaceBody) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = 3.dp), + shape = RoundedCornerShape(ClawTheme.radii.control), + color = ClawTheme.colors.surfaceRaised, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Text(text = body, modifier = Modifier.padding(horizontal = 11.dp, vertical = 9.dp), style = ClawTheme.type.mono, color = ClawTheme.colors.text) + } + } else { + Text(text = body, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + } + } +} + +@Composable +private fun LabeledField( + label: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(7.dp)) { + Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + content() + } +} + +@Composable +private fun InlineError( + title: String, + body: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.warning) + Text(text = body, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + } +} + @Composable private fun GatewayRecoveryScreen( statusText: String, serverName: String?, attemptedGatewayName: String?, - remoteAddress: String?, - ready: Boolean, - nodeCapabilityApproval: GatewayNodeCapabilityApproval, + gatewayPaired: Boolean, + gatewayPairingCanContinue: Boolean, gatewayConnectionProblem: GatewayConnectionProblem?, connectSettling: Boolean, + connectTimedOut: Boolean, onBack: () -> Unit, onRetry: () -> Unit, - onEdit: () -> Unit, - onScan: () -> Unit, onContinue: () -> Unit, modifier: Modifier = Modifier, ) { val recoveryState = - gatewayRecoveryUiState( - ready = ready, + gatewayPairingUiState( + gatewayPaired = gatewayPaired, + gatewayPairingCanContinue = gatewayPairingCanContinue, statusText = statusText, connectSettling = connectSettling, - nodeCapabilityApproval = nodeCapabilityApproval, + connectTimedOut = connectTimedOut, gatewayConnectionProblem = gatewayConnectionProblem, ) - val primaryAction = gatewayRecoveryPrimaryAction(ready, gatewayConnectionProblem) val context = LocalContext.current + val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem) + val recoveryTitle = + when { + recoveryState == GatewayRecoveryUiState.Connected -> "Gateway paired" + gatewayConnectionProblem?.code == "AUTH_BOOTSTRAP_TOKEN_INVALID" -> "Setup code was not accepted" + else -> recoveryState.title + } + val recoveryMessage = + when { + recoveryState == GatewayRecoveryUiState.Connected -> + "Your phone is paired with ${recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName)}. " + + "Continue to finish node access." + gatewayConnectionProblem != null && recoveryState == GatewayRecoveryUiState.Failed -> + recoveryGatewayAuthDetail(gatewayConnectionProblem) + else -> recoveryState.message + } + val recoveryProgressItems = + gatewayRecoveryProgressItems( + state = recoveryState, + statusText = statusText, + connectSettling = connectSettling, + ) + val primaryAction = gatewayRecoveryPrimaryAction(recoveryState) + val showDiagnosticAction = + gatewayRecoveryShowsDiagnosticAction( + state = recoveryState, + gatewayConnectionProblem = gatewayConnectionProblem, + ) + val diagnosticText = + remember( + statusText, + serverName, + attemptedGatewayName, + gatewayPaired, + gatewayPairingCanContinue, + gatewayConnectionProblem, + ) { + gatewayRecoveryDiagnosticText( + statusText = statusText, + gatewayName = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), + gatewayPaired = gatewayPaired, + gatewayPairingCanContinue = gatewayPairingCanContinue, + gatewayConnectionProblem = gatewayConnectionProblem, + ) + } + var diagnosticDialogVisible by rememberSaveable { mutableStateOf(false) } - ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) { - Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) { - OnboardingHeader(title = stringResource(R.string.gateway_recovery), onBack = onBack) - Spacer(modifier = Modifier.height(12.dp)) - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { - Icon( - imageVector = - when (recoveryState) { - GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle - GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.WifiTethering - GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering - GatewayRecoveryUiState.AuthenticationRequired -> Icons.Default.Security - GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering - GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering - GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline - }, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = - when (recoveryState) { - GatewayRecoveryUiState.Connected -> ClawTheme.colors.success - GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning - GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning - GatewayRecoveryUiState.AuthenticationRequired -> ClawTheme.colors.warning - GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text - GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text - GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning - }, - ) - Text(text = recoveryState.title, style = ClawTheme.type.display, color = ClawTheme.colors.text) + if (diagnosticDialogVisible) { + GatewayRecoveryDiagnosticDialog( + diagnosticText = diagnosticText, + onDismiss = { diagnosticDialogVisible = false }, + onCopy = { copyGatewayDiagnostic(context = context, diagnosticText = diagnosticText) }, + ) + } + + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { + Column(modifier = Modifier.fillMaxSize()) { + OnboardingHeader( + title = + when (recoveryState) { + GatewayRecoveryUiState.Connected -> "Gateway paired" + else -> "Pair Gateway" + }, + onBack = onBack, + ) + + Column( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 6.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + GatewayRecoveryIcon(state = recoveryState) + Spacer(modifier = Modifier.height(13.dp)) + Text(text = recoveryTitle, style = ClawTheme.type.display, color = ClawTheme.colors.text, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = recoveryState.message, + text = recoveryMessage, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, textAlign = TextAlign.Center, ) - } - - ClawPanel { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) - Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text( - text = - recoveryGatewayDetail( - ready = ready, - remoteAddress = remoteAddress, - statusText = statusText, - nodeCapabilityApproval = nodeCapabilityApproval, - gatewayConnectionProblem = gatewayConnectionProblem, - ), - style = ClawTheme.type.body, - color = ClawTheme.colors.textMuted, - ) - recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem)?.let { command -> - ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) }) + approvalCommand?.let { command -> + Spacer(modifier = Modifier.height(18.dp)) + ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) }) + } + if (recoveryProgressItems.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + GatewayRecoveryProgress(items = recoveryProgressItems) + } + if (showDiagnosticAction) { + Spacer(modifier = Modifier.height(14.dp)) + TextButton(onClick = { diagnosticDialogVisible = true }) { + Text("View details", style = ClawTheme.type.body, color = ClawTheme.colors.text) } - ClawStatusPill( - text = - when (recoveryState) { - GatewayRecoveryUiState.Connected -> "Healthy" - GatewayRecoveryUiState.NodeCapabilityApprovalPending -> "Node approval" - GatewayRecoveryUiState.ApprovalRequired -> "Needs approval" - GatewayRecoveryUiState.AuthenticationRequired -> "Needs authentication" - GatewayRecoveryUiState.Pairing -> "Pairing" - GatewayRecoveryUiState.Finishing -> "Connecting" - GatewayRecoveryUiState.Failed -> "Needs attention" - }, - status = - when (recoveryState) { - GatewayRecoveryUiState.Connected -> ClawStatus.Success - GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawStatus.Warning - GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning - GatewayRecoveryUiState.AuthenticationRequired -> ClawStatus.Warning - GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral - GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral - GatewayRecoveryUiState.Failed -> ClawStatus.Warning + } + } + + primaryAction?.let { action -> + OnboardingActions { + ClawPrimaryButton( + text = action.text, + icon = action.icon, + onClick = + when (action) { + GatewayRecoveryPrimaryAction.Finish -> onContinue + GatewayRecoveryPrimaryAction.Retry -> onRetry + GatewayRecoveryPrimaryAction.Back -> onBack }, + modifier = Modifier.onboardingActionButton(), + ) + } + } + } + } +} + +@Composable +private fun GatewayRecoveryDiagnosticDialog( + diagnosticText: String, + onDismiss: () -> Unit, + onCopy: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = ClawTheme.colors.surfaceRaised, + title = { Text("Connection details", style = ClawTheme.type.section, color = ClawTheme.colors.text) }, + text = { + SelectionContainer { + Text( + text = diagnosticText, + style = ClawTheme.type.mono, + color = ClawTheme.colors.textMuted, + ) + } + }, + confirmButton = { + TextButton(onClick = onCopy) { + Text("Copy") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + ) +} + +private fun copyGatewayDiagnostic( + context: Context, + diagnosticText: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostic", diagnosticText)) + Toast.makeText(context, "Details copied", Toast.LENGTH_SHORT).show() +} + +@Composable +private fun GatewayRecoveryIcon(state: GatewayRecoveryUiState) { + val icon = + when (state) { + GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle + GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.Security + GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering + GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering + GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering + GatewayRecoveryUiState.TakingLonger -> Icons.Default.WifiTethering + GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline + } + val tint = + when (state) { + GatewayRecoveryUiState.Connected -> ClawTheme.colors.success + GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning + GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning + GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text + GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text + GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warning + GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning + } + Surface( + modifier = Modifier.size(62.dp), + shape = CircleShape, + color = + when (state) { + GatewayRecoveryUiState.Connected -> ClawTheme.colors.successSoft + GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warningSoft + GatewayRecoveryUiState.Failed -> ClawTheme.colors.warningSoft + else -> ClawTheme.colors.surfaceRaised + }, + contentColor = tint, + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(36.dp), tint = tint) + } + } +} + +@Composable +private fun NodeApprovalScreen( + approval: GatewayNodeCapabilityApproval, + checkingApproval: Boolean, + checkRequested: Boolean, + ready: Boolean, + onBack: () -> Unit, + onCopyCommand: (String) -> Unit, + onCheckApproval: () -> Unit, + modifier: Modifier = Modifier, +) { + val approveCommand = recoveryNodeApprovalCommand(approvalRequestId(approval)) + var waitingDialogDismissed by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(checkingApproval) { + if (checkingApproval) { + waitingDialogDismissed = false + } + } + val showWaitingDialog = + checkRequested && + !checkingApproval && + !ready && + nodeCapabilityApprovalNeedsUserAction(approval) && + !waitingDialogDismissed + + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { + Column(modifier = Modifier.fillMaxSize()) { + OnboardingHeader(title = "Approve node access", onBack = onBack) + + Column( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 6.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + GatewayRecoveryIcon(state = GatewayRecoveryUiState.NodeCapabilityApprovalPending) + Spacer(modifier = Modifier.height(13.dp)) + Text( + text = "Approve node access", + style = ClawTheme.type.display, + color = ClawTheme.colors.text, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Gateway pairing is complete. Approve this phone as a node so OpenClaw can use the device capabilities you enable.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(18.dp)) + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "On the Gateway computer, run:", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + ApprovalCommandBlock(command = "openclaw nodes pending", onCopy = { onCopyCommand("openclaw nodes pending") }) + ApprovalCommandBlock(command = approveCommand, onCopy = { onCopyCommand(approveCommand) }) + Text( + text = "Use the requestId from the pending command in the approve command.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textSubtle, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) } } - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - ClawPrimaryButton( - text = gatewayRecoveryPrimaryActionLabel(primaryAction), - icon = - when (primaryAction) { - GatewayRecoveryPrimaryAction.Continue -> Icons.Default.CheckCircle - GatewayRecoveryPrimaryAction.ScanFreshSetupCode -> Icons.Default.QrCode2 - GatewayRecoveryPrimaryAction.EditConnection -> Icons.Default.Edit - GatewayRecoveryPrimaryAction.RetryConnection -> Icons.Default.Refresh - }, - onClick = - when (primaryAction) { - GatewayRecoveryPrimaryAction.Continue -> onContinue - GatewayRecoveryPrimaryAction.ScanFreshSetupCode -> onScan - GatewayRecoveryPrimaryAction.EditConnection -> onEdit - GatewayRecoveryPrimaryAction.RetryConnection -> onRetry - }, - modifier = Modifier.fillMaxWidth(), - ) - if ( - primaryAction != GatewayRecoveryPrimaryAction.ScanFreshSetupCode && - primaryAction != GatewayRecoveryPrimaryAction.EditConnection - ) { - OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit) - } - OutlinedAction( - title = "Copy diagnostic", - icon = Icons.Default.ContentCopy, - onClick = { - copyGatewayDiagnostic( - context, - statusText, - serverName, - remoteAddress, - ready, - nodeCapabilityApproval, - gatewayConnectionProblem, - ) - }, + OnboardingActions { + OnboardingLoadingPrimaryButton( + text = "I have approved", + loadingText = "Checking approval…", + loading = checkingApproval, + modifier = Modifier.onboardingActionButton(), + onClick = onCheckApproval, ) } } } + + if (showWaitingDialog) { + AlertDialog( + onDismissRequest = { waitingDialogDismissed = true }, + title = { Text(text = "Still waiting for approval") }, + text = { + Text( + text = "Run the approve command on the Gateway computer, then check again.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + ) + }, + confirmButton = { + TextButton(onClick = { waitingDialogDismissed = true }) { + Text(text = "OK") + } + }, + ) + } +} + +@Composable +private fun OnboardingLoadingPrimaryButton( + text: String, + loadingText: String, + loading: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = !loading, + modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget), + shape = RoundedCornerShape(ClawTheme.radii.button), + colors = + ButtonDefaults.buttonColors( + containerColor = ClawTheme.colors.primary, + contentColor = ClawTheme.colors.primaryText, + disabledContainerColor = ClawTheme.colors.surfacePressed, + disabledContentColor = ClawTheme.colors.textMuted, + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = ClawTheme.colors.textMuted, + strokeWidth = 2.dp, + trackColor = Color.Transparent, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = if (loading) loadingText else text, style = ClawTheme.type.label) + } +} + +@Composable +private fun GatewayRecoveryProgress(items: List) { + val transition = rememberInfiniteTransition(label = "gateway-progress") + val currentAlpha by + transition.animateFloat( + initialValue = 0.36f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 680), + repeatMode = RepeatMode.Reverse, + ), + label = "current-step-alpha", + ) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items.forEach { item -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GatewayRecoveryProgressDot(status = item.status, currentAlpha = currentAlpha) + Text( + text = item.label, + style = ClawTheme.type.caption, + color = + when (item.status) { + GatewayRecoveryProgressStatus.Complete -> ClawTheme.colors.success + GatewayRecoveryProgressStatus.Current -> ClawTheme.colors.text + GatewayRecoveryProgressStatus.Pending -> ClawTheme.colors.textMuted + }, + ) + } + } + } +} + +@Composable +private fun GatewayRecoveryProgressDot( + status: GatewayRecoveryProgressStatus, + currentAlpha: Float, +) { + Box(modifier = Modifier.width(18.dp), contentAlignment = Alignment.Center) { + when (status) { + GatewayRecoveryProgressStatus.Complete -> + Surface( + modifier = Modifier.size(9.dp), + shape = CircleShape, + color = ClawTheme.colors.success, + contentColor = Color.Transparent, + ) {} + GatewayRecoveryProgressStatus.Current -> { + Surface( + modifier = Modifier.size(18.dp).alpha(currentAlpha), + shape = CircleShape, + color = ClawTheme.colors.warningSoft, + contentColor = Color.Transparent, + ) {} + Surface( + modifier = Modifier.size(9.dp), + shape = CircleShape, + color = ClawTheme.colors.warning, + contentColor = Color.Transparent, + ) {} + } + GatewayRecoveryProgressStatus.Pending -> + Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = ClawTheme.colors.border, + contentColor = Color.Transparent, + ) {} + } + } } @Composable @@ -836,7 +2157,7 @@ private fun PermissionSetupScreen( onContinue: () -> Unit, modifier: Modifier = Modifier, ) { - ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) { + ClawScaffold(modifier = modifier, contentPadding = onboardingContentPadding()) { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween) { LazyColumn( modifier = Modifier.weight(1f), @@ -847,48 +2168,21 @@ private fun PermissionSetupScreen( PermissionTopBar(onBack = onBack) } item { - Column(modifier = Modifier.padding(top = 10.dp, bottom = 18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = "Allow permissions", - style = ClawTheme.type.title.copy(fontSize = 20.sp, lineHeight = 25.sp, fontWeight = FontWeight.Bold), - color = ClawTheme.colors.text, - ) - Text( - text = "These permissions keep OpenClaw secure\nand useful.", - style = ClawTheme.type.body, - color = ClawTheme.colors.textMuted, - ) - } + Text( + text = "Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp), + ) } items(permissionState.rows, key = { it.title }) { row -> PermissionRow(row = row) } } - PermissionContinueButton(onClick = onContinue) - } - } -} - -@Composable -private fun HeroPrimaryAction( - title: String, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - modifier = Modifier.fillMaxWidth().height(46.dp), - shape = RoundedCornerShape(23.dp), - color = ClawTheme.colors.primary, - contentColor = ClawTheme.colors.primaryText, - ) { - Row( - modifier = Modifier.padding(horizontal = 22.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Icon(imageVector = Icons.Default.Security, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(text = title, style = ClawTheme.type.title.copy(fontSize = 14.5.sp, lineHeight = 18.sp), modifier = Modifier.weight(1f), textAlign = TextAlign.Center) - Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(21.dp)) + OnboardingActions { + ClawPrimaryButton(text = "Continue", onClick = onContinue, modifier = Modifier.onboardingActionButton()) + } } } } @@ -899,68 +2193,42 @@ private fun OnboardingHeader( modifier: Modifier = Modifier, subtitle: String? = null, onBack: (() -> Unit)? = null, + action: (@Composable () -> Unit)? = null, ) { - Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - onBack?.let { - Surface(onClick = it, modifier = Modifier.size(34.dp), color = Color.Transparent, contentColor = ClawTheme.colors.text) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(23.dp)) + Surface(modifier = modifier.fillMaxWidth(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) { + Box(modifier = Modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget), contentAlignment = Alignment.Center) { + onBack?.let { + Surface( + onClick = it, + modifier = + Modifier + .align(Alignment.CenterStart) + .size(ClawTheme.spacing.touchTarget), + color = Color.Transparent, + contentColor = ClawTheme.colors.text, + ) { + Box(contentAlignment = Alignment.CenterStart) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(23.dp)) + } + } + } + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (title.isNotBlank()) { + Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, textAlign = TextAlign.Center) + } + subtitle?.let { + Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, textAlign = TextAlign.Center) + } + } + action?.let { + Box(modifier = Modifier.align(Alignment.CenterEnd), contentAlignment = Alignment.Center) { + it() } } - } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = title, style = ClawTheme.type.display, color = ClawTheme.colors.text) - subtitle?.let { Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } - } - } -} - -@Composable -private fun GatewayOption( - icon: ImageVector, - title: String, - subtitle: String, - onClick: (() -> Unit)?, - status: String? = null, -) { - ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { - ClawListItem( - title = title, - subtitle = subtitle, - metadata = status, - leading = { Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = ClawTheme.colors.text) }, - trailing = - onClick?.let { - { - Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text) - } - }, - onClick = onClick, - ) - } -} - -@Composable -private fun OutlinedAction( - title: String, - icon: ImageVector, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(ClawTheme.radii.control), - color = Color.Transparent, - contentColor = ClawTheme.colors.text, - border = BorderStroke(1.dp, ClawTheme.colors.borderStrong), - ) { - Row( - modifier = Modifier.padding(horizontal = 15.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(20.dp)) - Text(text = title, style = ClawTheme.type.section, modifier = Modifier.weight(1f)) } } } @@ -973,69 +2241,21 @@ private fun TogglePill( ) { Surface( onClick = onClick, + modifier = Modifier.height(34.dp), shape = RoundedCornerShape(ClawTheme.radii.pill), color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised, contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted, border = BorderStroke(1.dp, if (selected) ClawTheme.colors.primary else ClawTheme.colors.border), ) { - Text(text = text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), style = ClawTheme.type.label) + Box(modifier = Modifier.fillMaxHeight().padding(horizontal = 12.dp), contentAlignment = Alignment.Center) { + Text(text = text, style = ClawTheme.type.label) + } } } @Composable private fun PermissionTopBar(onBack: () -> Unit) { - var showHelp by remember { mutableStateOf(false) } - if (showHelp) { - AlertDialog( - onDismissRequest = { showHelp = false }, - containerColor = ClawTheme.colors.surfaceRaised, - title = { - Text(stringResource(R.string.permissions), style = ClawTheme.type.section, color = ClawTheme.colors.text) - }, - text = { - Text( - "Choose what this phone can share with OpenClaw. You can change these later in Settings.", - style = ClawTheme.type.body, - color = ClawTheme.colors.textMuted, - ) - }, - confirmButton = { - TextButton(onClick = { showHelp = false }) { - Text(stringResource(R.string.done)) - } - }, - ) - } - Box(modifier = Modifier.fillMaxWidth().height(38.dp), contentAlignment = Alignment.Center) { - Surface( - onClick = onBack, - modifier = Modifier.align(Alignment.CenterStart).size(34.dp), - color = Color.Transparent, - contentColor = ClawTheme.colors.text, - ) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(22.dp)) - } - } - Text( - text = "Permission Setup", - style = ClawTheme.type.title.copy(fontSize = 15.2.sp, lineHeight = 19.sp), - color = ClawTheme.colors.text, - maxLines = 1, - ) - Surface( - onClick = { showHelp = true }, - modifier = Modifier.align(Alignment.CenterEnd).size(28.dp), - shape = CircleShape, - color = Color.Transparent, - contentColor = ClawTheme.colors.text, - border = BorderStroke(1.dp, ClawTheme.colors.text), - ) { - Box(contentAlignment = Alignment.Center) { - Text(text = "?", style = ClawTheme.type.label.copy(fontSize = 13.sp, lineHeight = 16.sp), color = ClawTheme.colors.text) - } - } - } + OnboardingHeader(title = "Permissions", onBack = onBack) } @Composable @@ -1053,7 +2273,12 @@ private fun PermissionRow(row: PermissionRowModel) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Surface(modifier = Modifier.size(30.dp), shape = CircleShape, color = ClawTheme.colors.surfacePressed, border = BorderStroke(1.dp, ClawTheme.colors.border)) { + Surface( + modifier = Modifier.size(30.dp), + shape = CircleShape, + color = ClawTheme.colors.surfacePressed, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { Box(contentAlignment = Alignment.Center) { Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text) } @@ -1072,11 +2297,11 @@ private fun PermissionRow(row: PermissionRowModel) { maxLines = 1, ) } - Text( - text = if (row.granted) "Granted" else "Not granted", - style = ClawTheme.type.body, - color = if (row.granted) ClawTheme.colors.success else ClawTheme.colors.textMuted, - maxLines = 1, + Icon( + imageVector = if (row.granted) Icons.Default.CheckCircle else Icons.Default.Close, + contentDescription = row.statusText, + modifier = Modifier.size(20.dp), + tint = if (row.granted) ClawTheme.colors.success else ClawTheme.colors.danger, ) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, @@ -1088,27 +2313,6 @@ private fun PermissionRow(row: PermissionRowModel) { } } -@Composable -private fun PermissionContinueButton(onClick: () -> Unit) { - Surface( - onClick = onClick, - modifier = Modifier.fillMaxWidth().height(44.dp), - shape = RoundedCornerShape(22.dp), - color = ClawTheme.colors.primary, - contentColor = ClawTheme.colors.primaryText, - ) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text(text = "Continue", style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp), color = ClawTheme.colors.primaryText) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 20.dp).size(19.dp), - tint = ClawTheme.colors.primaryText, - ) - } - } -} - internal enum class GatewayRecoveryUiState( val title: String, val message: String, @@ -1121,10 +2325,6 @@ internal enum class GatewayRecoveryUiState( title = "Pairing Gateway", message = "Approve this phone on the gateway.\nThen retry the connection.", ), - AuthenticationRequired( - title = "Authentication needed", - message = "Update this Gateway connection to continue.", - ), NodeCapabilityApprovalPending( title = "Node Approval Pending", message = "Gateway pairing worked.\nApprove this phone's node capabilities from an operator UI.", @@ -1137,130 +2337,178 @@ internal enum class GatewayRecoveryUiState( title = "Connecting Gateway", message = "OpenClaw is checking gateway and node access.", ), + TakingLonger( + title = "Still connecting", + message = "This is taking longer than expected.\nCheck that the Gateway is running and reachable.", + ), Failed( title = "Connection issue", message = "We could not reach your Gateway.\nLet's fix this.", ), } -internal enum class GatewayRecoveryPrimaryAction { - Continue, - ScanFreshSetupCode, - EditConnection, - RetryConnection, +internal enum class GatewayRecoveryPrimaryAction( + val text: String, + val icon: ImageVector? = null, +) { + Finish(text = "Continue"), + Retry(text = "Retry connection", icon = Icons.Default.WifiTethering), + Back(text = "Go back", icon = Icons.AutoMirrored.Filled.ArrowBack), } -internal fun gatewayRecoveryPrimaryActionLabel(action: GatewayRecoveryPrimaryAction): String = - when (action) { - GatewayRecoveryPrimaryAction.Continue -> "Continue" - GatewayRecoveryPrimaryAction.ScanFreshSetupCode -> "Scan fresh setup code" - GatewayRecoveryPrimaryAction.EditConnection -> "Edit connection" - GatewayRecoveryPrimaryAction.RetryConnection -> "Retry connection" - } +internal enum class GatewayRecoveryProgressStatus { + Complete, + Current, + Pending, +} -/** Selects the action that can actually recover the current structured gateway failure. */ -internal fun gatewayRecoveryPrimaryAction( - ready: Boolean, - problem: GatewayConnectionProblem?, -): GatewayRecoveryPrimaryAction = - when { - ready -> GatewayRecoveryPrimaryAction.Continue - problem?.code == "AUTH_BOOTSTRAP_TOKEN_INVALID" -> GatewayRecoveryPrimaryAction.ScanFreshSetupCode - gatewayProblemNeedsCredentialUpdate(problem) -> GatewayRecoveryPrimaryAction.EditConnection - else -> GatewayRecoveryPrimaryAction.RetryConnection - } - -private fun gatewayProblemNeedsCredentialUpdate(problem: GatewayConnectionProblem?): Boolean = - when (problem?.code) { - "AUTH_DEVICE_TOKEN_MISMATCH", - "AUTH_TOKEN_MISMATCH", - "AUTH_SCOPE_MISMATCH", - "AUTH_PASSWORD_MISSING", - "AUTH_PASSWORD_MISMATCH", - "AUTH_TOKEN_MISSING", - "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", - "DEVICE_IDENTITY_REQUIRED", - -> true - else -> - problem?.recommendedNextStep == "update_auth_credentials" - } - -private fun gatewayProblemNeedsAuthenticationRecovery(problem: GatewayConnectionProblem?): Boolean = - gatewayProblemNeedsCredentialUpdate(problem) || - problem?.code == "AUTH_BOOTSTRAP_TOKEN_INVALID" || - problem?.code == "AUTH_TOKEN_NOT_CONFIGURED" || - problem?.code == "AUTH_PASSWORD_NOT_CONFIGURED" || - problem?.recommendedNextStep == "update_auth_configuration" || - problem?.recommendedNextStep == "review_auth_configuration" - -internal data class NearbyGatewayUiState( - val subtitle: String, - val status: String?, - val canConnect: Boolean, +internal data class GatewayRecoveryProgressItem( + val label: String, + val status: GatewayRecoveryProgressStatus, ) -/** Maps best-effort discovery into row copy and clickability for onboarding. */ -internal fun nearbyGatewayUiState( - nearbyGatewayName: String?, - discoveryStatusText: String, - discoveryStarted: Boolean = true, - searchTimedOut: Boolean = false, -): NearbyGatewayUiState { - val name = nearbyGatewayName?.trim().takeUnless { it.isNullOrEmpty() } - if (name != null) { - return NearbyGatewayUiState(subtitle = name, status = "Found", canConnect = true) - } - if (!discoveryStarted) { - return NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false) +internal fun gatewayRecoveryPrimaryAction(state: GatewayRecoveryUiState): GatewayRecoveryPrimaryAction? = + when (state) { + GatewayRecoveryUiState.Connected -> GatewayRecoveryPrimaryAction.Finish + GatewayRecoveryUiState.Failed -> GatewayRecoveryPrimaryAction.Back + GatewayRecoveryUiState.ApprovalRequired -> GatewayRecoveryPrimaryAction.Retry + GatewayRecoveryUiState.NodeCapabilityApprovalPending, + GatewayRecoveryUiState.Pairing, + GatewayRecoveryUiState.Finishing, + -> null + GatewayRecoveryUiState.TakingLonger -> GatewayRecoveryPrimaryAction.Retry } - val status = discoveryStatusText.trim() - val searching = - status.isEmpty() || - status.equals("Searching…", ignoreCase = true) || - status.contains("Searching", ignoreCase = true) || - status.endsWith("?", ignoreCase = true) - return if (searching) { - if (searchTimedOut) { - NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false) - } else { - NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false) - } - } else { - NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false) - } -} +internal fun gatewayRecoveryShowsDiagnosticAction( + state: GatewayRecoveryUiState, + gatewayConnectionProblem: GatewayConnectionProblem?, +): Boolean = + state == GatewayRecoveryUiState.Failed || + state == GatewayRecoveryUiState.TakingLonger || + gatewayConnectionProblem != null -/** Derives recovery screen state from gateway/node readiness and transient status text. */ -internal fun gatewayRecoveryUiState( - ready: Boolean, +internal fun gatewayRecoveryDiagnosticText( + statusText: String, + gatewayName: String, + gatewayPaired: Boolean, + gatewayPairingCanContinue: Boolean, + gatewayConnectionProblem: GatewayConnectionProblem?, +): String = + listOf( + "OpenClaw Android gateway diagnostic", + "Gateway: $gatewayName", + "Status: ${gatewayStatusForDisplay(statusText)}", + "Gateway paired: $gatewayPaired", + "Ready to continue: $gatewayPairingCanContinue", + "Error code: ${gatewayConnectionProblem?.code ?: "n/a"}", + "Reason: ${gatewayConnectionProblem?.reason ?: "n/a"}", + "Request ID: ${gatewayConnectionProblem?.requestId ?: "n/a"}", + "Next step: ${gatewayConnectionProblem?.recommendedNextStep ?: "n/a"}", + "Retryable: ${gatewayConnectionProblem?.retryable ?: false}", + ).joinToString("\n") + +internal fun gatewayPairingUiState( + gatewayPaired: Boolean, + gatewayPairingCanContinue: Boolean, statusText: String, connectSettling: Boolean, - nodeCapabilityApproval: GatewayNodeCapabilityApproval, + connectTimedOut: Boolean = false, gatewayConnectionProblem: GatewayConnectionProblem? = null, ): GatewayRecoveryUiState = when { - ready -> GatewayRecoveryUiState.Connected - nodeCapabilityApproval.needsApproval() -> GatewayRecoveryUiState.NodeCapabilityApprovalPending + gatewayPairingCanContinue -> GatewayRecoveryUiState.Connected gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing - gatewayProblemNeedsAuthenticationRecovery(gatewayConnectionProblem) -> - GatewayRecoveryUiState.AuthenticationRequired gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed - nodeCapabilityApproval == GatewayNodeCapabilityApproval.Loading -> GatewayRecoveryUiState.Finishing - connectSettling -> GatewayRecoveryUiState.Finishing gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing - gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing - else -> GatewayRecoveryUiState.Failed + gatewayStatusLooksLikeFailure(statusText) -> GatewayRecoveryUiState.Failed + gatewayPaired -> if (connectTimedOut) GatewayRecoveryUiState.TakingLonger else GatewayRecoveryUiState.Finishing + connectSettling -> GatewayRecoveryUiState.Finishing + connectTimedOut -> GatewayRecoveryUiState.TakingLonger + else -> GatewayRecoveryUiState.Finishing } +internal fun gatewayRecoveryProgressItems( + state: GatewayRecoveryUiState, + statusText: String = "", + connectSettling: Boolean = false, +): List = + when (state) { + GatewayRecoveryUiState.Finishing -> + finishingGatewayProgressItems( + statusText = statusText, + connectSettling = connectSettling, + ) + GatewayRecoveryUiState.TakingLonger -> + finishingGatewayProgressItems( + statusText = statusText, + connectSettling = connectSettling, + ) + GatewayRecoveryUiState.Pairing -> + listOf( + GatewayRecoveryProgressItem("Gateway received this phone", GatewayRecoveryProgressStatus.Complete), + GatewayRecoveryProgressItem("Waiting for device approval", GatewayRecoveryProgressStatus.Current), + GatewayRecoveryProgressItem("Retrying automatically", GatewayRecoveryProgressStatus.Pending), + ) + GatewayRecoveryUiState.ApprovalRequired -> + listOf( + GatewayRecoveryProgressItem("Gateway needs device approval", GatewayRecoveryProgressStatus.Current), + GatewayRecoveryProgressItem("Run the approval command on the Gateway", GatewayRecoveryProgressStatus.Pending), + ) + GatewayRecoveryUiState.NodeCapabilityApprovalPending -> emptyList() + GatewayRecoveryUiState.Connected, + GatewayRecoveryUiState.Failed, + -> emptyList() + } + +private fun finishingGatewayProgressItems( + statusText: String, + connectSettling: Boolean, +): List { + val gatewayAccessComplete = gatewayStatusLooksLikePartialConnect(statusText) + val nodeAccessCurrent = gatewayAccessComplete + return listOf( + GatewayRecoveryProgressItem( + label = "Opening Gateway connection", + status = + if (gatewayAccessComplete) { + GatewayRecoveryProgressStatus.Complete + } else { + GatewayRecoveryProgressStatus.Current + }, + ), + GatewayRecoveryProgressItem( + label = "Checking pairing access", + status = + when { + gatewayAccessComplete -> GatewayRecoveryProgressStatus.Complete + else -> GatewayRecoveryProgressStatus.Pending + }, + ), + GatewayRecoveryProgressItem( + label = "Checking node access", + status = + when { + nodeAccessCurrent -> GatewayRecoveryProgressStatus.Current + else -> GatewayRecoveryProgressStatus.Pending + }, + ), + ) +} + /** Detects gateway-approved states where the Android node is still coming online. */ internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean { val lower = gatewayStatusForDisplay(statusText).lowercase() return lower.contains("operator offline") || lower.contains("node offline") } +/** Detects explicit endpoint/auth failures surfaced as status text without structured details. */ +internal fun gatewayStatusLooksLikeFailure(statusText: String): Boolean { + val lower = gatewayStatusForDisplay(statusText).lowercase() + return lower.startsWith("failed:") || lower.startsWith("error:") || lower.startsWith("gateway error:") +} + internal fun recoveryGatewayName( serverName: String?, attemptedGatewayName: String?, @@ -1309,12 +2557,10 @@ internal fun recoveryGatewayDetail( ): String = if (ready) { remoteAddress?.takeIf { it.isNotBlank() } ?: "Ready for chat and voice" - } else if (nodeCapabilityApproval.needsApproval()) { - recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem) - ?.let { "Gateway paired. Run this on the gateway host:" } - ?: "Gateway paired. Waiting for node capability approval." + } else if (nodeCapabilityApprovalNeedsUserAction(nodeCapabilityApproval)) { + "Gateway paired. Waiting for node capability approval." } else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) { - recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem) + recoveryGatewayApprovalCommand(gatewayConnectionProblem) ?.let { "Gateway approval is pending. Run this on the gateway host:" } ?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry." } else if (gatewayConnectionProblem?.isPairingRequired == true && gatewayConnectionProblem.canAutoRetry) { @@ -1334,14 +2580,10 @@ internal fun recoveryGatewayDetail( internal fun recoveryGatewayAuthDetail(gatewayConnectionProblem: GatewayConnectionProblem): String = when (gatewayConnectionProblem.code) { "PROTOCOL_MISMATCH" -> recoveryGatewayProtocolMismatchDetail(gatewayConnectionProblem) - "AUTH_BOOTSTRAP_TOKEN_INVALID" -> "Setup code expired. Scan a fresh setup QR." + "AUTH_BOOTSTRAP_TOKEN_INVALID" -> "The code may have expired or been generated for another Gateway." "AUTH_DEVICE_TOKEN_MISMATCH", "AUTH_TOKEN_MISMATCH", -> "Saved authentication is invalid. Re-authenticate or reset this gateway connection." - "AUTH_TOKEN_NOT_CONFIGURED", - "AUTH_PASSWORD_NOT_CONFIGURED", - -> "Gateway authentication is not configured. Configure it on the gateway host, then retry." - "AUTH_SCOPE_MISMATCH" -> "Gateway access needs review. Check gateway authentication scopes, then retry." "AUTH_PASSWORD_MISSING" -> "Gateway password is required. Enter it again or edit this connection." "AUTH_PASSWORD_MISMATCH" -> "Gateway password is invalid. Re-enter it or reset this gateway connection." "AUTH_TOKEN_MISSING" -> "Gateway token is required. Enter it again or edit this connection." @@ -1351,7 +2593,7 @@ internal fun recoveryGatewayAuthDetail(gatewayConnectionProblem: GatewayConnecti else -> when (gatewayConnectionProblem.recommendedNextStep) { "update_auth_credentials" -> "Saved authentication is invalid. Re-authenticate or reset this gateway connection." - "update_auth_configuration" -> "Gateway authentication is not configured. Configure it on the gateway host, then retry." + "update_auth_configuration" -> "Gateway authentication is not configured. Edit this connection and try again." "review_auth_configuration" -> "Gateway authentication needs review. Check gateway settings, then retry." else -> gatewayConnectionProblem.message.takeIf { it.isNotBlank() } ?: "Gateway authentication needs attention." } @@ -1364,10 +2606,10 @@ private fun recoveryGatewayProtocolMismatchDetail(gatewayConnectionProblem: Gate val summary = when { clientMax != null && expected != null && clientMax < expected -> - "This app is older than the gateway. Update OpenClaw on this device, then retry." + "This app is older than the Gateway. Update OpenClaw on this device, then retry." clientMin != null && expected != null && clientMin > expected -> - "The gateway is older than this app. Update OpenClaw on the gateway host, then retry." - else -> "The app and gateway use incompatible protocol versions. Update OpenClaw on both, then retry." + "The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry." + else -> "The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry." } return protocolMismatchVersions(clientMin, clientMax, expected)?.let { "$summary $it" } ?: summary } @@ -1391,18 +2633,9 @@ private fun protocolMismatchVersions( ?.joinToString(prefix = "(", postfix = ").") } -private fun GatewayNodeCapabilityApproval.needsApproval(): Boolean = - this is GatewayNodeCapabilityApproval.PendingApproval || - this is GatewayNodeCapabilityApproval.PendingReapproval || - this == GatewayNodeCapabilityApproval.Unapproved - -internal fun recoveryGatewayApprovalCommand( - nodeCapabilityApproval: GatewayNodeCapabilityApproval, - gatewayConnectionProblem: GatewayConnectionProblem?, -): String? { - gatewayNodeApprovalCommand(nodeCapabilityApproval)?.let { return it } +private fun recoveryGatewayApprovalCommand(gatewayConnectionProblem: GatewayConnectionProblem?): String? { if (gatewayConnectionProblem?.isPairingRequired != true || gatewayConnectionProblem.canAutoRetry) return null - val requestId = normalizeGatewayApprovalRequestId(gatewayConnectionProblem.requestId) + val requestId = gatewayConnectionProblem.requestId?.trim()?.takeIf { it.isNotEmpty() } return if (requestId != null) { "openclaw devices approve $requestId" } else { @@ -1410,6 +2643,111 @@ internal fun recoveryGatewayApprovalCommand( } } +internal fun recoveryNodeApprovalCommand(pendingRequestId: String?): String { + val requestId = pendingRequestId?.trim()?.takeIf { it.isNotEmpty() } + return if (requestId != null) { + "openclaw nodes approve $requestId" + } else { + "openclaw nodes approve REQUEST_ID" + } +} + +private fun approvalRequestId(approval: GatewayNodeCapabilityApproval): String? = + when (approval) { + is GatewayNodeCapabilityApproval.PendingApproval -> approval.requestId + is GatewayNodeCapabilityApproval.PendingReapproval -> approval.requestId + else -> null + } + +internal fun nodeCapabilityApprovalNeedsUserAction(approval: GatewayNodeCapabilityApproval): Boolean = + approval is GatewayNodeCapabilityApproval.PendingApproval || + approval is GatewayNodeCapabilityApproval.PendingReapproval || + approval == GatewayNodeCapabilityApproval.Unapproved + +internal fun gatewayPairingContinueDestination( + ready: Boolean, + nodeCapabilityApproval: GatewayNodeCapabilityApproval, +): OnboardingStep? = + when { + ready -> OnboardingStep.Permissions + nodeCapabilityApprovalNeedsUserAction(nodeCapabilityApproval) -> OnboardingStep.NodeApproval + else -> null + } + +internal data class OnboardingPermissionReapprovalBackSteps( + val nodeApprovalBackStep: OnboardingStep, + val permissionsBackStep: OnboardingStep, +) + +internal fun permissionReapprovalBackSteps( + currentNodeApprovalBackStep: OnboardingStep, + currentPermissionsBackStep: OnboardingStep, +): OnboardingPermissionReapprovalBackSteps { + val originalPermissionsBackStep = + if (currentNodeApprovalBackStep == OnboardingStep.Permissions) { + currentPermissionsBackStep + } else { + currentNodeApprovalBackStep + } + return OnboardingPermissionReapprovalBackSteps( + nodeApprovalBackStep = OnboardingStep.Permissions, + permissionsBackStep = originalPermissionsBackStep, + ) +} + +internal fun nodeApprovalCheckingInProgress( + checkRequested: Boolean, + refreshStarted: Boolean, + nodesDevicesRefreshing: Boolean, +): Boolean = checkRequested && (!refreshStarted || nodesDevicesRefreshing) + +internal fun nodeApprovalCheckShouldClearUnobservedRefresh( + step: OnboardingStep, + checkRequested: Boolean, + refreshStarted: Boolean, + nodesDevicesRefreshing: Boolean, +): Boolean = + step == OnboardingStep.NodeApproval && + checkRequested && + !refreshStarted && + !nodesDevicesRefreshing + +internal fun nodeApprovalCheckCanContinue( + checkRequested: Boolean, + refreshStarted: Boolean, + nodesDevicesRefreshing: Boolean, + ready: Boolean, +): Boolean = + checkRequested && + refreshStarted && + !nodesDevicesRefreshing && + ready + +internal fun nodeApprovalShouldAutoContinue( + step: OnboardingStep, + ready: Boolean, + nodeCapabilityApproval: GatewayNodeCapabilityApproval, + autoContinueEnabled: Boolean, +): Boolean = + step == OnboardingStep.NodeApproval && + autoContinueEnabled && + ready && + !nodeCapabilityApprovalNeedsUserAction(nodeCapabilityApproval) + +internal fun permissionContinueNeedsNodeApproval( + ready: Boolean, + requiresNodeApprovalAfterApply: Boolean, + nodeCapabilityApproval: GatewayNodeCapabilityApproval, +): Boolean = + ( + requiresNodeApprovalAfterApply && + nodeCapabilityApproval != GatewayNodeCapabilityApproval.Unsupported + ) || + ( + !ready && + nodeCapabilityApprovalNeedsUserAction(nodeCapabilityApproval) + ) + private fun copyApprovalCommand( context: Context, command: String, @@ -1419,45 +2757,20 @@ private fun copyApprovalCommand( Toast.makeText(context, "Approval command copied", Toast.LENGTH_SHORT).show() } -/** Copies the onboarding recovery snapshot for support without including credentials. */ -private fun copyGatewayDiagnostic( - context: Context, - statusText: String, - serverName: String?, - remoteAddress: String?, - ready: Boolean, - nodeCapabilityApproval: GatewayNodeCapabilityApproval, - gatewayConnectionProblem: GatewayConnectionProblem?, -) { - val approvalCommand = recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem) - val diagnostic = - listOfNotNull( - "OpenClaw Android gateway diagnostic", - "Status: $statusText", - gatewayConnectionProblem?.message?.let { "Gateway problem: $it" }, - gatewayConnectionProblem?.requestId?.let { "Pairing request: $it" }, - approvalCommand?.let { "Approval command: $it" }, - "Gateway: ${serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"}", - "Address: ${remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"}", - "Ready: ${if (ready) "yes" else "no"}", - ).joinToString("\n") - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostic", diagnostic)) - Toast.makeText(context, "Diagnostic copied", Toast.LENGTH_SHORT).show() -} - /** One permission row plus launcher callback for onboarding's final setup step. */ private data class PermissionRowModel( val title: String, val subtitle: String, val icon: ImageVector, val granted: Boolean, + val statusText: String = permissionRowStatusText(granted), val onClick: () -> Unit, ) /** Permission screen model plus a commit hook that persists granted feature toggles. */ private class PermissionState( val rows: List, + val requiresNodeApprovalAfterApply: Boolean, val applyToViewModel: () -> Unit, ) @@ -1483,14 +2796,51 @@ internal fun canFinishOnboarding( private val requiredContactPermissions = listOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS) private val requiredCalendarPermissions = listOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) +internal fun initialCameraCapabilityEnabled( + savedCapabilityEnabled: Boolean, + androidCameraPermissionGranted: Boolean, +): Boolean = savedCapabilityEnabled && androidCameraPermissionGranted + +internal fun cameraPermissionRowStatusText( + capabilityEnabled: Boolean, + androidCameraPermissionGranted: Boolean, +): String = + when { + capabilityEnabled -> "Enabled" + androidCameraPermissionGranted -> "Off" + else -> "Not allowed" + } + +internal fun cameraCapabilityAfterRowTap( + currentCapabilityEnabled: Boolean, + androidCameraPermissionGranted: Boolean, +): Boolean? = if (androidCameraPermissionGranted) !currentCapabilityEnabled else null + +private fun permissionRowStatusText(granted: Boolean): String = if (granted) "Granted" else "Not granted" + +internal fun permissionChangesRequireNodeApproval( + currentCameraEnabled: Boolean, + requestedCameraEnabled: Boolean, + currentLocationMode: LocationMode, + requestedLocationMode: LocationMode, + currentSmsGranted: Boolean, + requestedSmsGranted: Boolean, +): Boolean = + currentCameraEnabled != requestedCameraEnabled || + currentLocationMode != requestedLocationMode || + currentSmsGranted != requestedSmsGranted + /** Builds permission rows and applies granted feature toggles after onboarding. */ @Composable private fun rememberPermissionState( context: Context, viewModel: MainViewModel, ): PermissionState { + val currentCameraEnabled by viewModel.cameraEnabled.collectAsState() + val currentLocationMode by viewModel.locationMode.collectAsState() var microphoneGranted by rememberSaveable { mutableStateOf(hasPermission(context, Manifest.permission.RECORD_AUDIO)) } - var cameraGranted by rememberSaveable { mutableStateOf(hasPermission(context, Manifest.permission.CAMERA)) } + val cameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA) + var cameraGranted by rememberSaveable { mutableStateOf(initialCameraCapabilityEnabled(currentCameraEnabled, cameraPermissionGranted)) } var locationGranted by rememberSaveable { mutableStateOf(hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) } @@ -1513,17 +2863,15 @@ private fun rememberPermissionState( SensitiveFeatureConfig.smsEnabled && context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } + val currentSmsGranted = + !smsAvailable || + ( + hasPermission(context, Manifest.permission.SEND_SMS) && + hasPermission(context, Manifest.permission.READ_SMS) + ) val callLogAvailable = SensitiveFeatureConfig.callLogEnabled var motionGranted by rememberSaveable { mutableStateOf(!motionAvailable || hasPermission(context, Manifest.permission.ACTIVITY_RECOGNITION)) } - var smsGranted by rememberSaveable { - mutableStateOf( - !smsAvailable || - ( - hasPermission(context, Manifest.permission.SEND_SMS) && - hasPermission(context, Manifest.permission.READ_SMS) - ), - ) - } + var smsGranted by rememberSaveable { mutableStateOf(currentSmsGranted) } var callLogGranted by rememberSaveable { mutableStateOf(!callLogAvailable || hasPermission(context, Manifest.permission.READ_CALL_LOG)) } val lifecycleOwner = LocalLifecycleOwner.current @@ -1579,52 +2927,73 @@ private fun rememberPermissionState( permissionLauncher.launch(permissions.filterNot { hasPermission(context, it) }.toTypedArray()) } + fun requestCameraCapability() { + val nextCapabilityEnabled = + cameraCapabilityAfterRowTap( + currentCapabilityEnabled = cameraGranted, + androidCameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA), + ) + if (nextCapabilityEnabled != null) { + cameraGranted = nextCapabilityEnabled + } else { + request(Manifest.permission.CAMERA) + } + } + val rows = listOfNotNull( - PermissionRowModel("Voice", "Record and transcribe audio", Icons.Default.Mic, microphoneGranted) { + PermissionRowModel("Voice", "Transcribe voice prompts", Icons.Default.Mic, microphoneGranted) { request(Manifest.permission.RECORD_AUDIO) }, - PermissionRowModel("Camera", "Capture photos and video", Icons.Default.CameraAlt, cameraGranted) { - request(Manifest.permission.CAMERA) - }, - PermissionRowModel("Location", "Use location when needed", Icons.Default.LocationOn, locationGranted) { + PermissionRowModel( + "Camera", + "Capture photos and clips from this phone", + Icons.Default.CameraAlt, + cameraGranted, + cameraPermissionRowStatusText( + capabilityEnabled = cameraGranted, + androidCameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA), + ), + ::requestCameraCapability, + ), + PermissionRowModel("Location", "Read this phone's location", Icons.Default.LocationOn, locationGranted) { request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) }, if (photosAvailable) { - PermissionRowModel("Photos", "Attach photos and media", Icons.Default.Image, photosGranted) { + PermissionRowModel("Photos", "Read recent photos and media", Icons.Default.Image, photosGranted) { request(*photosPermissions.toTypedArray()) } } else { null }, - PermissionRowModel("Contacts", "Read contacts securely", Icons.Default.Person, contactsGranted) { + PermissionRowModel("Contacts", "Find people and contact details", Icons.Default.Person, contactsGranted) { request(*requiredContactPermissions.toTypedArray()) }, - PermissionRowModel("Calendar", "Read events and schedules", Icons.Default.CalendarMonth, calendarGranted) { + PermissionRowModel("Calendar", "Read and update events", Icons.Default.CalendarMonth, calendarGranted) { request(*requiredCalendarPermissions.toTypedArray()) }, - PermissionRowModel("Notifications", "Send important alerts", Icons.Default.Notifications, notificationsGranted) { + PermissionRowModel("Notifications", "Show OpenClaw alerts", Icons.Default.Notifications, notificationsGranted) { if (Build.VERSION.SDK_INT >= 33) request(Manifest.permission.POST_NOTIFICATIONS) }, - PermissionRowModel("Notification listener", "Forward selected app alerts", Icons.Default.Sensors, notificationListenerGranted) { + PermissionRowModel("Notification listener", "Read selected app notifications", Icons.Default.Sensors, notificationListenerGranted) { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }, if (motionAvailable) { - PermissionRowModel("Motion", "Read activity and step context", Icons.Default.Sensors, motionGranted) { + PermissionRowModel("Motion", "Share steps and activity", Icons.Default.Sensors, motionGranted) { request(Manifest.permission.ACTIVITY_RECOGNITION) } } else { null }, if (smsAvailable) { - PermissionRowModel("SMS", "Send and read messages when approved", Icons.Default.Notifications, smsGranted) { + PermissionRowModel("SMS", "Read and send SMS messages", Icons.Default.Notifications, smsGranted) { request(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS) } } else { null }, if (callLogAvailable) { - PermissionRowModel("Call Log", "Read recent call context", Icons.Default.Person, callLogGranted) { + PermissionRowModel("Call Log", "Show recent call history", Icons.Default.Person, callLogGranted) { request(Manifest.permission.READ_CALL_LOG) } } else { @@ -1634,6 +3003,15 @@ private fun rememberPermissionState( return PermissionState( rows = rows, + requiresNodeApprovalAfterApply = + permissionChangesRequireNodeApproval( + currentCameraEnabled = currentCameraEnabled, + requestedCameraEnabled = cameraGranted, + currentLocationMode = currentLocationMode, + requestedLocationMode = if (locationGranted) LocationMode.WhileUsing else LocationMode.Off, + currentSmsGranted = currentSmsGranted, + requestedSmsGranted = smsGranted, + ), applyToViewModel = { viewModel.setCameraEnabled(cameraGranted) viewModel.setLocationMode(if (locationGranted) LocationMode.WhileUsing else LocationMode.Off) @@ -1649,6 +3027,13 @@ internal fun mergedRequiredPermissionGrantState( currentlyGranted: (String) -> Boolean, ): Boolean = requiredPermissions.all { permission -> permissions[permission] ?: currentlyGranted(permission) } +internal fun nearbyGatewayManualPort(endpoint: GatewayEndpoint): String = endpoint.port.toString() + +internal fun nearbyGatewayManualTls(endpoint: GatewayEndpoint): Boolean = + endpoint.tlsEnabled || + !endpoint.tlsFingerprintSha256.isNullOrBlank() || + !isLocalCleartextGatewayHost(endpoint.host) + private fun hasPermission( context: Context, permission: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index d873b4c5d3e..f30f53537a0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -1459,9 +1459,9 @@ private fun SettingsShellScreen( } item { SettingsGroup( - rows = listOf(SettingsRow("Sign Out", "Disconnect", Icons.AutoMirrored.Filled.ExitToApp)), + rows = listOf(SettingsRow("Sign Out", "Return to setup", Icons.AutoMirrored.Filled.ExitToApp)), onOpen = { }, - onAction = { viewModel.disconnect() }, + onAction = { viewModel.pairNewGateway() }, ) } @@ -1724,6 +1724,7 @@ private fun SettingsGroup( rows.forEachIndexed { index, row -> SettingsListRow( row = row, + showDisclosure = row.route != null || onAction != null, onClick = { val rowRoute = row.route if (rowRoute == null) { @@ -1744,6 +1745,7 @@ private fun SettingsGroup( @Composable private fun SettingsListRow( row: SettingsRow, + showDisclosure: Boolean, onClick: () -> Unit, ) { Row( @@ -1766,10 +1768,10 @@ private fun SettingsListRow( row.status?.let { active -> Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (active) ClawTheme.colors.success else ClawTheme.colors.textSubtle)) } - if (row.route != null) { + if (showDisclosure) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Open ${row.title}", + contentDescription = if (row.route != null) "Open ${row.title}" else row.title, modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text, ) diff --git a/apps/android/app/src/main/res/values-ar/strings.xml b/apps/android/app/src/main/res/values-ar/strings.xml index 0133dabda11..c583913e5ab 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -22,16 +22,6 @@ كلمة المرور تشغيل الإعداد الأولي مرة أخرى نقطة النهاية التي تم حلها - إعداد البوابة - الاتصال ببوابتك - مسح رمز الإعداد - استخدم رمز QR الخاص ببوابتك أو رمز الإعداد - بوابة قريبة - أدخل عنوان URL للبوابة - الاتصال باستخدام عنوان URL يدوي - الأذونات - تم تحقق من بصمة الشهادة قبل الوثوق بهذه البوابة.\n\n%1$s تم تغيير شهادة البوابة. تابع فقط إذا كنت تتوقع هذا التغيير.\n\nSHA-256 القديم:\n%1$s\n\nSHA-256 الجديد:\n%2$s - استرداد البوابة diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 6f836659cf8..4482fffed42 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -22,16 +22,6 @@ Passwort Onboarding erneut ausführen Aufgelöster Endpunkt - Gateway-Einrichtung - Mit Ihrem Gateway verbinden - Einrichtungscode scannen - Verwenden Sie Ihren Gateway-QR- oder Einrichtungscode - Gateway in der Nähe - Gateway-URL eingeben - Über eine manuelle URL verbinden - Berechtigungen - Fertig Überprüfen Sie den Zertifikatfingerabdruck, bevor Sie diesem Gateway vertrauen.\n\n%1$s Das Gateway-Zertifikat wurde geändert. Fahren Sie nur fort, wenn Sie diese Änderung erwartet haben.\n\nAlter SHA-256-Wert:\n%1$s\n\nNeuer SHA-256-Wert:\n%2$s - Gateway-Wiederherstellung diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index b55920f0f06..d25ef96d780 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -22,16 +22,6 @@ Contraseña Ejecutar la incorporación de nuevo Endpoint resuelto - Configuración de Gateway - Conéctate a tu Gateway - Escanear código de configuración - Usa el QR o código de configuración de tu Gateway - Gateway cercano - Introduce la URL del gateway - Conectar usando una URL manual - Permisos - Listo Verifica la huella digital del certificado antes de confiar en este gateway.\n\n%1$s El certificado del gateway cambió. Continúa solo si esperabas este cambio.\n\nSHA-256 anterior:\n%1$s\n\nSHA-256 nuevo:\n%2$s - Recuperación del gateway diff --git a/apps/android/app/src/main/res/values-fa/strings.xml b/apps/android/app/src/main/res/values-fa/strings.xml index 8f9252e506e..f4a8b2ba850 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -22,16 +22,6 @@ رمز عبور اجرای دوباره فرایند شروع به کار نقطه پایانی حل‌شده - راه‌اندازی دروازه - به دروازه خود متصل شوید - اسکن کد راه‌اندازی - از QR دروازه یا کد راه‌اندازی خود استفاده کنید - دروازه نزدیک - URL دروازه را وارد کنید - اتصال با استفاده از URL دستی - مجوزها - انجام شد پیش از اعتماد به این دروازه، اثر انگشت گواهی را تأیید کنید.\n\n%1$s گواهی دروازه تغییر کرده است. فقط در صورتی ادامه دهید که انتظار این تغییر را داشتید.\n\nSHA-256 قدیمی:\n%1$s\n\nSHA-256 جدید:\n%2$s - بازیابی دروازه diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index 3d77aaaf2a0..e747b3f367b 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -22,16 +22,6 @@ Mot de passe Relancer l’intégration Point de terminaison résolu - Configuration de la passerelle - Connectez-vous à votre Gateway - Scanner le code de configuration - Utilisez le QR de votre Gateway ou le code de configuration - Passerelle à proximité - Saisir l’URL de la passerelle - Se connecter avec une URL manuelle - Autorisations - Terminé Vérifiez l’empreinte du certificat avant d’accorder votre confiance à cette passerelle.\n\n%1$s Le certificat de la passerelle a changé. Continuez uniquement si vous vous attendiez à ce changement.\n\nAncien SHA-256 :\n%1$s\n\nNouveau SHA-256 :\n%2$s - Récupération de la passerelle diff --git a/apps/android/app/src/main/res/values-hi/strings.xml b/apps/android/app/src/main/res/values-hi/strings.xml index 61a42b9ea41..cf7948cca56 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -22,16 +22,6 @@ पासवर्ड ऑनबोर्डिंग फिर से चलाएँ रिज़ॉल्व किया गया एंडपॉइंट - गेटवे सेटअप - अपने गेटवे से कनेक्ट करें - सेटअप कोड स्कैन करें - अपने गेटवे QR या सेटअप कोड का उपयोग करें - नज़दीकी गेटवे - गेटवे URL दर्ज करें - मैन्युअल URL का उपयोग करके कनेक्ट करें - अनुमतियाँ - हो गया इस गेटवे पर भरोसा करने से पहले प्रमाणपत्र फ़िंगरप्रिंट सत्यापित करें।\n\n%1$s गेटवे प्रमाणपत्र बदल गया है। केवल तभी जारी रखें जब आपको इस बदलाव की अपेक्षा थी।\n\nपुराना SHA-256:\n%1$s\n\nनया SHA-256:\n%2$s - गेटवे पुनर्प्राप्ति diff --git a/apps/android/app/src/main/res/values-in/strings.xml b/apps/android/app/src/main/res/values-in/strings.xml index b20a6ecfde5..238119e7100 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -22,16 +22,6 @@ Kata sandi Jalankan onboarding lagi Endpoint yang diselesaikan - Penyiapan Gateway - Hubungkan ke Gateway Anda - Pindai kode penyiapan - Gunakan QR Gateway atau kode penyiapan Anda - Gateway terdekat - Masukkan URL gateway - Hubungkan menggunakan URL manual - Izin - Selesai Verifikasi sidik jari sertifikat sebelum memercayai gateway ini.\n\n%1$s Sertifikat gateway berubah. Lanjutkan hanya jika Anda mengharapkan perubahan ini.\n\nSHA-256 lama:\n%1$s\n\nSHA-256 baru:\n%2$s - Pemulihan gateway diff --git a/apps/android/app/src/main/res/values-it/strings.xml b/apps/android/app/src/main/res/values-it/strings.xml index df6253c8f40..0534c36d319 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -22,16 +22,6 @@ Password Esegui di nuovo l’onboarding Endpoint risolto - Configurazione gateway - Connettiti al tuo Gateway - Scansiona codice di configurazione - Usa il QR del tuo Gateway o il codice di configurazione - Gateway nelle vicinanze - Inserisci URL del gateway - Connetti usando un URL manuale - Autorizzazioni - Fine Verifica l’impronta digitale del certificato prima di considerare attendibile questo gateway.\n\n%1$s Il certificato del gateway è cambiato. Continua solo se ti aspettavi questa modifica.\n\nSHA-256 precedente:\n%1$s\n\nNuovo SHA-256:\n%2$s - Ripristino del gateway diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index 6b7283464a3..29b3970847c 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -22,16 +22,6 @@ パスワード オンボーディングを再実行 解決済みエンドポイント - ゲートウェイ設定 - ゲートウェイに接続 - セットアップコードをスキャン - ゲートウェイの QR またはセットアップコードを使用 - 近くのゲートウェイ - ゲートウェイ URL を入力 - 手動 URL で接続 - 権限 - 完了 このゲートウェイを信頼する前に、証明書のフィンガープリントを確認してください。\n\n%1$s ゲートウェイの証明書が変更されました。想定した変更である場合のみ続行してください。\n\n以前の SHA-256:\n%1$s\n\n新しい SHA-256:\n%2$s - ゲートウェイの復旧 diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index bafa7d2acd7..f83ce4b474c 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -22,16 +22,6 @@ 비밀번호 온보딩 다시 실행 확인된 엔드포인트 - 게이트웨이 설정 - 게이트웨이에 연결 - 설정 코드 스캔 - 게이트웨이 QR 또는 설정 코드 사용 - 주변 게이트웨이 - 게이트웨이 URL 입력 - 수동 URL을 사용하여 연결 - 권한 - 완료 이 게이트웨이를 신뢰하기 전에 인증서 지문을 확인하세요.\n\n%1$s 게이트웨이 인증서가 변경되었습니다. 예상한 변경인 경우에만 계속하세요.\n\n이전 SHA-256:\n%1$s\n\n새 SHA-256:\n%2$s - 게이트웨이 복구 diff --git a/apps/android/app/src/main/res/values-nl/strings.xml b/apps/android/app/src/main/res/values-nl/strings.xml index 136654d0a9b..799acf74dda 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -22,16 +22,6 @@ Wachtwoord Onboarding opnieuw uitvoeren Opgelost endpoint - Gateway instellen - Verbinden met je Gateway - Setupcode scannen - Gebruik je Gateway-QR-code of setupcode - Gateway in de buurt - Gateway-URL invoeren - Verbinden met een handmatige URL - Machtigingen - Gereed Controleer de certificaatvingerafdruk voordat je deze gateway vertrouwt.\n\n%1$s Het gatewaycertificaat is gewijzigd. Ga alleen door als je deze wijziging verwachtte.\n\nOude SHA-256:\n%1$s\n\nNieuwe SHA-256:\n%2$s - Gatewayherstel diff --git a/apps/android/app/src/main/res/values-pl/strings.xml b/apps/android/app/src/main/res/values-pl/strings.xml index abf9bdc212a..b9e440dc1f4 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -22,16 +22,6 @@ Hasło Uruchom ponownie wdrażanie Rozpoznany punkt końcowy - Konfiguracja bramy - Połącz ze swoją bramą - Zeskanuj kod konfiguracji - Użyj kodu QR bramy lub kodu konfiguracji - Pobliska brama - Wprowadź URL bramy - Połącz, używając ręcznego URL - Uprawnienia - Gotowe Sprawdź odcisk certyfikatu, zanim zaufasz tej bramie.\n\n%1$s Certyfikat bramy uległ zmianie. Kontynuuj tylko wtedy, gdy oczekujesz tej zmiany.\n\nStary SHA-256:\n%1$s\n\nNowy SHA-256:\n%2$s - Odzyskiwanie bramy diff --git a/apps/android/app/src/main/res/values-pt-rBR/strings.xml b/apps/android/app/src/main/res/values-pt-rBR/strings.xml index e7bf5e1c0fe..ee5471f1a56 100644 --- a/apps/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/apps/android/app/src/main/res/values-pt-rBR/strings.xml @@ -22,16 +22,6 @@ Senha Executar integração novamente Endpoint resolvido - Configuração do Gateway - Conecte-se ao seu Gateway - Escanear código de configuração - Use o QR do seu Gateway ou o código de configuração - Gateway próximo - Inserir URL do gateway - Conectar usando uma URL manual - Permissões - Concluído Verifique a impressão digital do certificado antes de confiar neste gateway.\n\n%1$s O certificado do gateway foi alterado. Continue somente se você esperava essa alteração.\n\nSHA-256 antigo:\n%1$s\n\nSHA-256 novo:\n%2$s - Recuperação do gateway diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index c96319ca500..7155b7db8ca 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -22,16 +22,6 @@ Пароль Запустить настройку заново Разрешенная конечная точка - Настройка шлюза - Подключитесь к своему шлюзу - Сканировать код настройки - Используйте QR-код или код настройки вашего шлюза - Шлюз поблизости - Введите URL шлюза - Подключиться с помощью URL вручную - Разрешения - Готово Проверьте отпечаток сертификата, прежде чем доверять этому шлюзу.\n\n%1$s Сертификат шлюза изменился. Продолжайте, только если вы ожидали это изменение.\n\nСтарый SHA-256:\n%1$s\n\nНовый SHA-256:\n%2$s - Восстановление шлюза diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml index 6864ed16472..780a57f2cae 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -22,16 +22,6 @@ Lösenord Kör introduktionen igen Löst slutpunkt - Gateway-konfiguration - Anslut till din gateway - Skanna konfigurationskod - Använd gatewayens QR-kod eller konfigurationskod - Gateway i närheten - Ange gateway-URL - Anslut med en manuell URL - Behörigheter - Klar Verifiera certifikatets fingeravtryck innan du litar på denna gateway.\n\n%1$s Gateway-certifikatet har ändrats. Fortsätt bara om du förväntade dig denna ändring.\n\nTidigare SHA-256:\n%1$s\n\nNy SHA-256:\n%2$s - Gateway-återställning diff --git a/apps/android/app/src/main/res/values-th/strings.xml b/apps/android/app/src/main/res/values-th/strings.xml index 0ddbbb61a65..cb860dec229 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -22,16 +22,6 @@ รหัสผ่าน เรียกใช้การเริ่มต้นใช้งานอีกครั้ง เอนด์พอยต์ที่แก้ไขแล้ว - การตั้งค่าเกตเวย์ - เชื่อมต่อกับเกตเวย์ของคุณ - สแกนรหัสตั้งค่า - ใช้ QR ของเกตเวย์หรือรหัสตั้งค่าของคุณ - เกตเวย์ใกล้เคียง - ป้อน URL เกตเวย์ - เชื่อมต่อโดยใช้ URL ด้วยตนเอง - สิทธิ์ - เสร็จสิ้น ตรวจสอบลายนิ้วมือของใบรับรองก่อนเชื่อถือเกตเวย์นี้\n\n%1$s ใบรับรองของเกตเวย์มีการเปลี่ยนแปลง ดำเนินการต่อเมื่อคุณคาดว่าจะมีการเปลี่ยนแปลงนี้เท่านั้น\n\nSHA-256 เดิม:\n%1$s\n\nSHA-256 ใหม่:\n%2$s - การกู้คืนเกตเวย์ diff --git a/apps/android/app/src/main/res/values-tr/strings.xml b/apps/android/app/src/main/res/values-tr/strings.xml index bcb956b02de..2022b2cc4a8 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -22,16 +22,6 @@ Parola Başlangıç sürecini tekrar çalıştır Çözümlenen uç nokta - Ağ Geçidi Kurulumu - Ağ Geçidinize Bağlanın - Kurulum kodunu tara - Gateway QR kodunuzu veya kurulum kodunuzu kullanın - Yakındaki ağ geçidi - Ağ geçidi URL’sini girin - Manuel URL kullanarak bağlan - İzinler - Bitti Bu ağ geçidine güvenmeden önce sertifika parmak izini doğrulayın.\n\n%1$s Ağ geçidi sertifikası değişti. Yalnızca bu değişikliği bekliyorsanız devam edin.\n\nEski SHA-256:\n%1$s\n\nYeni SHA-256:\n%2$s - Ağ geçidi kurtarma diff --git a/apps/android/app/src/main/res/values-uk/strings.xml b/apps/android/app/src/main/res/values-uk/strings.xml index b86fd7926fd..cbb70370df4 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -22,16 +22,6 @@ Пароль Запустити адаптацію знову Визначена кінцева точка - Налаштування шлюзу - Підключіться до свого шлюзу - Сканувати код налаштування - Використайте QR-код свого шлюзу або код налаштування - Шлюз поблизу - Введіть URL-адресу шлюзу - Підключитися за допомогою URL-адреси вручну - Дозволи - Готово Перевірте відбиток сертифіката, перш ніж довіряти цьому шлюзу.\n\n%1$s Сертифікат шлюзу змінився. Продовжуйте, лише якщо ви очікували цю зміну.\n\nСтарий SHA-256:\n%1$s\n\nНовий SHA-256:\n%2$s - Відновлення шлюзу diff --git a/apps/android/app/src/main/res/values-vi/strings.xml b/apps/android/app/src/main/res/values-vi/strings.xml index cc77feeff37..b45a2829cf1 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -22,16 +22,6 @@ Mật khẩu Chạy hướng dẫn thiết lập lại Điểm cuối đã phân giải - Thiết lập cổng - Kết nối với Gateway của bạn - Quét mã thiết lập - Sử dụng mã QR Gateway hoặc mã thiết lập của bạn - Cổng gần đây - Nhập URL cổng - Kết nối bằng URL thủ công - Quyền - Xong Xác minh dấu vân tay chứng chỉ trước khi tin cậy cổng này.\n\n%1$s Chứng chỉ cổng đã thay đổi. Chỉ tiếp tục nếu bạn mong đợi thay đổi này.\n\nSHA-256 cũ:\n%1$s\n\nSHA-256 mới:\n%2$s - Khôi phục cổng diff --git a/apps/android/app/src/main/res/values-zh-rCN/strings.xml b/apps/android/app/src/main/res/values-zh-rCN/strings.xml index 383571c56c3..ddae206297c 100644 --- a/apps/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rCN/strings.xml @@ -22,16 +22,6 @@ 密码 再次运行引导流程 已解析的端点 - 网关设置 - 连接到你的网关 - 扫描设置代码 - 使用你的网关 QR 码或设置代码 - 附近的网关 - 输入网关 URL - 使用手动 URL 连接 - 权限 - 完成 验证证书指纹后再信任此网关。\n\n%1$s 网关证书已更改。仅当这是您预期的更改时才继续。\n\n旧 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s - 网关恢复 diff --git a/apps/android/app/src/main/res/values-zh-rTW/strings.xml b/apps/android/app/src/main/res/values-zh-rTW/strings.xml index 5524a3ed367..60de856d6b8 100644 --- a/apps/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rTW/strings.xml @@ -22,16 +22,6 @@ 密碼 再次執行新手導覽 已解析的端點 - 閘道設定 - 連接到您的閘道 - 掃描設定碼 - 使用您的 Gateway QR 或設定碼 - 附近的閘道 - 輸入閘道 URL - 使用手動 URL 連接 - 權限 - 完成 請先驗證憑證指紋,再信任此閘道。\n\n%1$s 閘道憑證已變更。僅在這是您預期的變更時繼續。\n\n舊 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s - 閘道復原 diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index a1afbe12da7..228618ae588 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -22,16 +22,6 @@ Password Run onboarding again Resolved endpoint - Gateway Setup - Connect to your Gateway - Scan setup code - Use your Gateway QR or setup code - Nearby gateway - Enter gateway URL - Connect using a manual URL - Permissions - Done Verify the certificate fingerprint before trusting this gateway.\n\n%1$s The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n%1$s\n\nNew SHA-256:\n%2$s - Gateway Recovery diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 7fa27fad049..44318c77d05 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -7,6 +7,7 @@ import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewayTlsProbeFailure import ai.openclaw.app.gateway.GatewayTlsProbeResult +import ai.openclaw.app.node.ConnectionManager import ai.openclaw.app.node.InvokeDispatcher import ai.openclaw.app.protocol.OpenClawTalkCommand import ai.openclaw.app.voice.TalkModeManager @@ -212,6 +213,74 @@ class GatewayBootstrapAuthTest { ) } + @Test + fun operatorConnectScopesForAuthUsesNativeScopesWhenNoStoredOperatorMetadata() { + assertEquals( + ConnectionManager.nativeClientOperatorScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = false, + storedOperatorScopes = null, + ), + ) + } + + @Test + fun operatorConnectScopesForAuthPreservesStoredScopesForReconnects() { + val storedScopes = listOf("operator.approvals", "operator.read", "operator.write") + + assertEquals( + storedScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = true, + storedOperatorScopes = storedScopes, + ), + ) + } + + @Test + fun operatorConnectScopesForAuthFallsBackToLegacyScopesForOldStoredDeviceTokens() { + assertEquals( + ConnectionManager.legacyOperatorScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = true, + storedOperatorScopes = emptyList(), + ), + ) + } + + @Test + fun operatorConnectScopesForAuthUsesNativeScopesForExplicitReauth() { + assertEquals( + ConnectionManager.nativeClientOperatorScopes, + operatorConnectScopesForAuth( + usesStoredDeviceToken = false, + storedOperatorScopes = listOf("operator.approvals", "operator.read", "operator.write"), + ), + ) + } + + @Test + fun operatorSessionUsesStoredDeviceTokenOnlyWithoutExplicitSharedAuth() { + assertTrue( + operatorSessionUsesStoredDeviceToken( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = "stored-token", + ), + ) + assertFalse( + operatorSessionUsesStoredDeviceToken( + auth = NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + storedOperatorToken = "stored-token", + ), + ) + assertFalse( + operatorSessionUsesStoredDeviceToken( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = "password"), + storedOperatorToken = "stored-token", + ), + ) + } + @Test fun nodeConnectStartsOperatorAfterBootstrapHandoffWhenOperatorWasConnecting() { val app = RuntimeEnvironment.getApplication() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt index 6215b0c0a9c..ce7307ddb7f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt @@ -1,6 +1,9 @@ package ai.openclaw.app +import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewaySession import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -8,33 +11,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class GatewayNodeApprovalStateTest { - @Test - fun exactApprovalCommandsAgeOutToStatusFallbacks() { - assertEquals( - GatewayNodeCapabilityApproval.PendingApproval(requestId = null), - GatewayNodeCapabilityApproval.PendingApproval(requestId = "request-1").withoutExactRequestId(), - ) - assertEquals( - GatewayNodeCapabilityApproval.PendingReapproval(requestId = null), - GatewayNodeCapabilityApproval.PendingReapproval(requestId = "request-2").withoutExactRequestId(), - ) - assertNull(GatewayNodeCapabilityApproval.PendingApproval(requestId = null).withoutExactRequestId()) - - val summary = - GatewayNodesDevicesSummary( - nodes = listOf(pendingNode(requestId = "request-1")), - pendingDevices = emptyList(), - pairedDevices = emptyList(), - ) - assertNull( - summary - .withoutExactApprovalRequestIds() - .nodes - .single() - .pendingRequestId, - ) - } - @Test fun parsesGatewayNodeApprovalState() { assertEquals(GatewayNodeApprovalState.Approved, parseGatewayNodeApprovalState("approved")) @@ -45,6 +21,48 @@ class GatewayNodeApprovalStateTest { assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState("future-state")) } + @Test + fun nodePairingEventsInvalidateNodeDeviceState() { + assertTrue(gatewayEventInvalidatesNodesDevices("node.pair.requested")) + assertTrue(gatewayEventInvalidatesNodesDevices("node.pair.resolved")) + assertFalse(gatewayEventInvalidatesNodesDevices("device.pair.resolved")) + assertFalse(gatewayEventInvalidatesNodesDevices("exec.approval.resolved")) + } + + @Test + fun nodePairingFailuresRefreshNodeDeviceState() { + assertTrue( + nodeConnectFailureNeedsApprovalRefresh( + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayConnectErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + reason = "not-paired", + ), + ), + ), + ) + assertFalse( + nodeConnectFailureNeedsApprovalRefresh( + GatewaySession.ErrorShape( + code = "UNAUTHORIZED", + message = "token mismatch", + details = + GatewayConnectErrorDetails( + code = "AUTH_TOKEN_MISMATCH", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ), + ), + ) + } + @Test fun parsesNodeListApprovalFields() { val node = @@ -71,6 +89,45 @@ class GatewayNodeApprovalStateTest { assertEquals(listOf("device.status"), node.commands) } + @Test + fun parsesSplitNodeListShapeFromGateway() { + val root = + Json + .parseToJsonElement( + """ + { + "pending": [ + { + "nodeId": "pending-node", + "paired": false, + "connected": false, + "approvalState": "pending-approval", + "pendingRequestId": "request-pending" + } + ], + "paired": [ + { + "nodeId": "self", + "paired": true, + "connected": true, + "approvalState": "approved", + "caps": ["device"], + "commands": ["device.status"] + } + ] + } + """.trimIndent(), + ).jsonObject + + val nodes = parseGatewayNodeList(root) + + assertEquals(2, nodes.size) + assertEquals( + GatewayNodeCapabilityApproval.Approved, + currentNodeCapabilityApproval(nodes = nodes, selfNodeId = "self"), + ) + } + @Test fun treatsMissingNodeApprovalStateAsUnsupported() { val node = @@ -113,14 +170,14 @@ class GatewayNodeApprovalStateTest { paired = true, connected = true, approvalState = GatewayNodeApprovalState.PendingApproval, - pendingRequestId = null, + pendingRequestId = "request-self", capabilities = emptyList(), commands = emptyList(), ), ) assertEquals( - GatewayNodeCapabilityApproval.PendingApproval(requestId = null), + GatewayNodeCapabilityApproval.PendingApproval("request-self"), currentNodeCapabilityApproval(nodes = nodes, selfNodeId = "self"), ) assertEquals( @@ -129,47 +186,17 @@ class GatewayNodeApprovalStateTest { ) } - @Test - fun currentPhoneApprovalCarriesOnlySafePendingRequestIds() { - val safe = pendingNode(requestId = "request-1") - val unsafe = pendingNode(requestId = "request-1;echo unsafe") - - assertEquals( - GatewayNodeCapabilityApproval.PendingApproval("request-1"), - currentNodeCapabilityApproval(nodes = listOf(safe), selfNodeId = "self"), - ) - assertEquals( - GatewayNodeCapabilityApproval.PendingApproval(requestId = null), - currentNodeCapabilityApproval(nodes = listOf(unsafe), selfNodeId = "self"), - ) - } - @Test fun ignoresStaleNodeApprovalRefreshResults() { val guard = GatewayNodeApprovalRefreshGuard() - var approval: GatewayNodeCapabilityApproval = GatewayNodeCapabilityApproval.Loading + var approvalState = GatewayNodeApprovalState.Loading val staleRefresh = guard.begin() val currentRefresh = guard.begin() - assertFalse(guard.publishIfCurrent(staleRefresh) { approval = GatewayNodeCapabilityApproval.Approved }) + assertFalse(guard.publishIfCurrent(staleRefresh) { approvalState = GatewayNodeApprovalState.Approved }) assertTrue( - guard.publishIfCurrent(currentRefresh) { approval = GatewayNodeCapabilityApproval.PendingReapproval("request-2") }, + guard.publishIfCurrent(currentRefresh) { approvalState = GatewayNodeApprovalState.PendingReapproval }, ) - assertEquals(GatewayNodeCapabilityApproval.PendingReapproval("request-2"), approval) + assertEquals(GatewayNodeApprovalState.PendingReapproval, approvalState) } - - private fun pendingNode(requestId: String): GatewayNodeSummary = - GatewayNodeSummary( - id = "self", - displayName = null, - remoteIp = null, - version = null, - deviceFamily = null, - paired = true, - connected = true, - approvalState = GatewayNodeApprovalState.PendingApproval, - pendingRequestId = requestId, - capabilities = emptyList(), - commands = emptyList(), - ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt index c923b6fdde4..7616d1eb30b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -77,6 +77,37 @@ class SecurePrefsTest { assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false)) } + @Test + fun cameraSharing_defaultsOffAndPersistsOptIn() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = SecurePrefs(context) + + assertFalse(prefs.cameraEnabled.value) + assertFalse(plainPrefs.getBoolean("camera.enabled", true)) + + prefs.setCameraEnabled(true) + + assertTrue(prefs.cameraEnabled.value) + assertTrue(plainPrefs.getBoolean("camera.enabled", false)) + } + + @Test + fun cameraSharing_migratesExistingInstallsToPreviousDefault() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs + .edit() + .clear() + .putString("node.instanceId", "existing-node") + .commit() + val prefs = SecurePrefs(context) + + assertTrue(prefs.cameraEnabled.value) + assertTrue(plainPrefs.getBoolean("camera.enabled", false)) + } + @Test fun appearanceThemeMode_defaultsDarkForExistingInstalls() { val context = RuntimeEnvironment.getApplication() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 3d514fbac79..32ad134a92b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -423,6 +423,7 @@ class GatewaySessionInvokeTest { "operator.approvals", "operator.pairing", "operator.read", + "operator.talk.secrets", "operator.write", ), ) @@ -441,6 +442,7 @@ class GatewaySessionInvokeTest { listOf( "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ), params.scopes(), @@ -602,7 +604,10 @@ class GatewaySessionInvokeTest { assertEquals("bootstrap-node-token", nodeEntry?.token) assertEquals(emptyList(), nodeEntry?.scopes) assertEquals("bootstrap-operator-token", operatorEntry?.token) - assertEquals(listOf("operator.approvals", "operator.read", "operator.write"), operatorEntry?.scopes) + assertEquals( + listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"), + operatorEntry?.scopes, + ) } finally { shutdownHarness(harness, server) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index 71177ac9c37..1e38ef319b6 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -427,19 +427,33 @@ class ConnectionManagerTest { } @Test - fun buildOperatorConnectOptions_requestsQrBootstrapHandoffScopes() { + fun buildOperatorConnectOptions_requestsNativeClientOperatorScopes() { val options = newManager().buildOperatorConnectOptions() assertEquals( - listOf( - "operator.approvals", - "operator.read", - "operator.write", - ), + ConnectionManager.nativeClientOperatorScopes, options.scopes, ) } + @Test + fun operatorScopesForStoredDeviceToken_preservesRecordedScopes() { + assertEquals( + listOf("operator.read", "operator.write"), + ConnectionManager.operatorScopesForStoredDeviceToken( + listOf("operator.read", "operator.write", "operator.read", " "), + ), + ) + } + + @Test + fun operatorScopesForStoredDeviceToken_fallsBackToLegacyScopesWhenMetadataMissing() { + assertEquals( + ConnectionManager.legacyOperatorScopes, + ConnectionManager.operatorScopesForStoredDeviceToken(emptyList()), + ) + } + @Test fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() { val options = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 0571e0e33e3..f3b25ee74ca 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -248,6 +248,17 @@ class GatewayConfigResolverTest { assertNull(resolved.error) } + @Test + fun resolveScannedSetupCodeResultAcceptsEmulatorSetupCode() { + val setupCode = + encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCodeResult(setupCode) + + assertEquals(setupCode, resolved.setupCode) + assertNull(resolved.error) + } + @Test fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() { val setupCode = @@ -404,6 +415,18 @@ class GatewayConfigResolverTest { assertNull(decoded?.password) } + @Test + fun manualTokenDetectsSetupCodePayloads() { + val setupCode = + encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""") + val qrPayload = """{"setupCode":"$setupCode"}""" + + assertEquals(true, manualTokenLooksLikeSetupCode(setupCode)) + assertEquals(true, manualTokenLooksLikeSetupCode(qrPayload)) + assertEquals(false, manualTokenLooksLikeSetupCode("local-mobile-test")) + assertEquals(false, manualTokenLooksLikeSetupCode("")) + } + @Test fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() { val setupCode = @@ -431,6 +454,32 @@ class GatewayConfigResolverTest { assertEquals("", resolved?.password) } + @Test + fun resolveGatewayConnectConfigAcceptsQrJsonSetupCodePayload() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + val qrPayload = """{"setupCode":"$setupCode"}""" + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = qrPayload, + manualHostInput = "", + manualPortInput = "", + 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) + assertEquals("", resolved?.token) + assertEquals("", resolved?.password) + } + @Test fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() { val setupCode = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt index ec38076201e..03c09d4fb87 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt @@ -2,6 +2,8 @@ package ai.openclaw.app.ui import ai.openclaw.app.GatewayConnectionProblem import ai.openclaw.app.GatewayNodeCapabilityApproval +import ai.openclaw.app.LocationMode +import ai.openclaw.app.gateway.GatewayEndpoint import android.Manifest import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch @@ -15,7 +17,291 @@ import java.util.Base64 class OnboardingFlowLogicTest { @Test - fun blocksFinishWhenOnlyOperatorIsConnected() { + fun onboardingBackDestinationsMatchTheVisibleFlow() { + assertEquals(null, onboardingBackDestination(OnboardingStep.Welcome)) + assertEquals(OnboardingBackDestination(OnboardingStep.Welcome), onboardingBackDestination(OnboardingStep.Gateway)) + assertEquals(OnboardingBackDestination(OnboardingStep.Gateway), onboardingBackDestination(OnboardingStep.SetupCode)) + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode), + onboardingBackDestination(OnboardingStep.EnterSetupCode), + ) + assertEquals(OnboardingBackDestination(OnboardingStep.Gateway), onboardingBackDestination(OnboardingStep.Manual)) + assertEquals(OnboardingBackDestination(OnboardingStep.Recovery), onboardingBackDestination(OnboardingStep.NodeApproval)) + assertEquals(OnboardingBackDestination(OnboardingStep.NodeApproval), onboardingBackDestination(OnboardingStep.Permissions)) + } + + @Test + fun permissionsBackCanReturnToRecoveryWhenNodeApprovalWasSkipped() { + assertEquals( + OnboardingBackDestination(OnboardingStep.Recovery), + onboardingBackDestination( + step = OnboardingStep.Permissions, + permissionsBackStep = OnboardingStep.Recovery, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.Recovery), + onboardingBackStateAfterBack( + step = OnboardingStep.Permissions, + permissionsBackStep = OnboardingStep.Recovery, + ), + ) + } + + @Test + fun nodeApprovalBackCanReturnToPermissionsDuringPermissionReapproval() { + assertEquals( + OnboardingBackDestination(OnboardingStep.Permissions), + onboardingBackDestination( + step = OnboardingStep.NodeApproval, + nodeApprovalBackStep = OnboardingStep.Permissions, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.Permissions), + onboardingBackStateAfterBack( + step = OnboardingStep.NodeApproval, + nodeApprovalBackStep = OnboardingStep.Permissions, + ), + ) + } + + @Test + fun permissionReapprovalBackStepsReturnThroughPermissionsWithoutLooping() { + val reapprovalBackSteps = + permissionReapprovalBackSteps( + currentNodeApprovalBackStep = OnboardingStep.Recovery, + currentPermissionsBackStep = OnboardingStep.NodeApproval, + ) + + assertEquals(OnboardingStep.Permissions, reapprovalBackSteps.nodeApprovalBackStep) + assertEquals(OnboardingStep.Recovery, reapprovalBackSteps.permissionsBackStep) + assertEquals( + OnboardingBackState(step = OnboardingStep.Permissions), + onboardingBackStateAfterBack( + step = OnboardingStep.NodeApproval, + nodeApprovalBackStep = reapprovalBackSteps.nodeApprovalBackStep, + permissionsBackStep = reapprovalBackSteps.permissionsBackStep, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.Recovery), + onboardingBackStateAfterBack( + step = OnboardingStep.Permissions, + nodeApprovalBackStep = reapprovalBackSteps.nodeApprovalBackStep, + permissionsBackStep = reapprovalBackSteps.permissionsBackStep, + ), + ) + + val repeatedReapprovalBackSteps = + permissionReapprovalBackSteps( + currentNodeApprovalBackStep = reapprovalBackSteps.nodeApprovalBackStep, + currentPermissionsBackStep = reapprovalBackSteps.permissionsBackStep, + ) + + assertEquals(OnboardingStep.Permissions, repeatedReapprovalBackSteps.nodeApprovalBackStep) + assertEquals(OnboardingStep.Recovery, repeatedReapprovalBackSteps.permissionsBackStep) + assertEquals( + OnboardingBackState(step = OnboardingStep.Recovery), + onboardingBackStateAfterBack( + step = OnboardingStep.Permissions, + nodeApprovalBackStep = repeatedReapprovalBackSteps.nodeApprovalBackStep, + permissionsBackStep = repeatedReapprovalBackSteps.permissionsBackStep, + ), + ) + } + + @Test + fun setupCodeEntryBackRestoresInlineScannerOnlyWhenOpenedFromScanner() { + assertEquals( + OnboardingBackState(step = OnboardingStep.SetupCode, inlineQrScannerActive = true), + onboardingBackStateAfterBack( + step = OnboardingStep.EnterSetupCode, + setupCodeEntryOpenedFromScanner = true, + ), + ) + assertEquals( + OnboardingBackState(step = OnboardingStep.SetupCode, inlineQrScannerActive = false), + onboardingBackStateAfterBack( + step = OnboardingStep.EnterSetupCode, + setupCodeEntryOpenedFromScanner = false, + ), + ) + } + + @Test + fun onboardingBackStateClearsScannerOriginAfterBack() { + assertEquals( + OnboardingBackState(step = OnboardingStep.SetupCode, inlineQrScannerActive = true, setupCodeEntryOpenedFromScanner = false), + onboardingBackStateAfterBack( + step = OnboardingStep.EnterSetupCode, + setupCodeEntryOpenedFromScanner = true, + ), + ) + } + + @Test + fun recoveryBackRestoresInlineScannerOnlyForScannerConnections() { + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = true), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.SetupScanner), + ) + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = false), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.SetupGallery), + ) + assertEquals( + OnboardingBackDestination(OnboardingStep.SetupCode, inlineQrScannerActive = false), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.SetupEntry), + ) + } + + @Test + fun recoveryBackReturnsToManualFormAfterManualConnection() { + assertEquals( + OnboardingBackDestination(OnboardingStep.Manual), + onboardingBackDestination(OnboardingStep.Recovery, lastGatewayInputSource = OnboardingGatewayInputSource.Manual), + ) + } + + @Test + fun cameraCapabilityStartsOffEvenWhenScannerPermissionWasGranted() { + assertFalse(initialCameraCapabilityEnabled(savedCapabilityEnabled = false, androidCameraPermissionGranted = false)) + assertFalse(initialCameraCapabilityEnabled(savedCapabilityEnabled = false, androidCameraPermissionGranted = true)) + assertFalse(initialCameraCapabilityEnabled(savedCapabilityEnabled = true, androidCameraPermissionGranted = false)) + assertTrue(initialCameraCapabilityEnabled(savedCapabilityEnabled = true, androidCameraPermissionGranted = true)) + } + + @Test + fun cameraPermissionRowDistinguishesAndroidPermissionFromCapabilityOptIn() { + assertEquals("Not allowed", cameraPermissionRowStatusText(capabilityEnabled = false, androidCameraPermissionGranted = false)) + assertEquals("Off", cameraPermissionRowStatusText(capabilityEnabled = false, androidCameraPermissionGranted = true)) + assertEquals("Enabled", cameraPermissionRowStatusText(capabilityEnabled = true, androidCameraPermissionGranted = true)) + } + + @Test + fun cameraPermissionRowTogglesCapabilityWhenAndroidPermissionAlreadyGranted() { + assertNull(cameraCapabilityAfterRowTap(currentCapabilityEnabled = false, androidCameraPermissionGranted = false)) + assertTrue(cameraCapabilityAfterRowTap(currentCapabilityEnabled = false, androidCameraPermissionGranted = true)!!) + assertFalse(cameraCapabilityAfterRowTap(currentCapabilityEnabled = true, androidCameraPermissionGranted = true)!!) + } + + @Test + fun permissionChangesRequireNodeApprovalWhenAdvertisedSurfaceChanges() { + assertTrue( + permissionChangesRequireNodeApproval( + currentCameraEnabled = false, + requestedCameraEnabled = true, + currentLocationMode = LocationMode.Off, + requestedLocationMode = LocationMode.Off, + currentSmsGranted = true, + requestedSmsGranted = true, + ), + ) + assertTrue( + permissionChangesRequireNodeApproval( + currentCameraEnabled = false, + requestedCameraEnabled = false, + currentLocationMode = LocationMode.Off, + requestedLocationMode = LocationMode.WhileUsing, + currentSmsGranted = true, + requestedSmsGranted = true, + ), + ) + assertTrue( + permissionChangesRequireNodeApproval( + currentCameraEnabled = false, + requestedCameraEnabled = false, + currentLocationMode = LocationMode.Off, + requestedLocationMode = LocationMode.Off, + currentSmsGranted = false, + requestedSmsGranted = true, + ), + ) + assertFalse( + permissionChangesRequireNodeApproval( + currentCameraEnabled = true, + requestedCameraEnabled = true, + currentLocationMode = LocationMode.WhileUsing, + requestedLocationMode = LocationMode.WhileUsing, + currentSmsGranted = true, + requestedSmsGranted = true, + ), + ) + } + + @Test + fun nearbyGatewayManualPortUsesResolvedDiscoveryEndpointPort() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Home", + name = "Home", + host = "192.168.1.12", + port = 53122, + gatewayPort = 18789, + ) + + assertEquals("53122", nearbyGatewayManualPort(endpoint)) + } + + @Test + fun nearbyGatewayManualTlsPreservesDiscoverySecurityPolicy() { + assertFalse( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Lan", + name = "Lan", + host = "192.168.1.12", + port = 18789, + ), + ), + ) + assertTrue( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Tls", + name = "Tls", + host = "192.168.1.12", + port = 18789, + tlsEnabled = true, + ), + ), + ) + assertTrue( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Pinned", + name = "Pinned", + host = "127.0.0.1", + port = 18789, + tlsFingerprintSha256 = "abc123", + ), + ), + ) + assertTrue( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Remote", + name = "Remote", + host = "gateway.example.com", + port = 443, + ), + ), + ) + assertFalse( + nearbyGatewayManualTls( + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Loopback", + name = "Loopback", + host = "127.0.0.1", + port = 18789, + ), + ), + ) + } + + @Test + fun blocksFinishWhenGatewayHasNotReportedNodeConnected() { assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved)) } @@ -31,20 +317,8 @@ class OnboardingFlowLogicTest { @Test fun blocksFinishWhenNodeCapabilityApprovalIsPending() { - assertFalse( - canFinishOnboarding( - isConnected = true, - isNodeConnected = true, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(requestId = "request-1"), - ), - ) - assertFalse( - canFinishOnboarding( - isConnected = true, - isNodeConnected = true, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(requestId = "request-2"), - ), - ) + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null))) + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(null))) assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unapproved)) } @@ -62,7 +336,7 @@ class OnboardingFlowLogicTest { assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = approvalState)) - delayedNodeList.complete(GatewayNodeCapabilityApproval.PendingApproval(requestId = "request-1")) + delayedNodeList.complete(GatewayNodeCapabilityApproval.PendingApproval(null)) refresh.join() assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = approvalState)) } @@ -72,6 +346,11 @@ class OnboardingFlowLogicTest { assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported)) } + @Test + fun blocksFinishForLegacyNodeListUntilNodeConnects() { + assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported)) + } + @Test fun splitSmsPermissionCallbacksMergePerPermissionGrantState() { val requiredPermissions = listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS) @@ -128,46 +407,6 @@ class OnboardingFlowLogicTest { } } - @Test - fun nearbyGatewayFoundStateIsConnectable() { - assertEquals( - NearbyGatewayUiState(subtitle = "Studio Gateway", status = "Found", canConnect = true), - nearbyGatewayUiState(nearbyGatewayName = "Studio Gateway", discoveryStatusText = "Searching…", discoveryStarted = false), - ) - } - - @Test - fun nearbyGatewayBeforeDiscoveryStartsIsNotConnectable() { - assertEquals( - NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false), - nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching…", discoveryStarted = false, searchTimedOut = true), - ) - } - - @Test - fun nearbyGatewaySearchingStateIsNotConnectable() { - assertEquals( - NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false), - nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…"), - ) - } - - @Test - fun nearbyGatewayTimedOutSearchShowsEmptyState() { - assertEquals( - NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false), - nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…", searchTimedOut = true), - ) - } - - @Test - fun nearbyGatewayEmptyResultStateIsNotConnectable() { - assertEquals( - NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false), - nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Local: 0 • Wide: 0"), - ) - } - @Test fun recoveryGatewayNamePrefersServerThenAttemptedGateway() { assertEquals("Server Gateway", recoveryGatewayName(serverName = "Server Gateway", attemptedGatewayName = "Discovered Gateway")) @@ -176,66 +415,282 @@ class OnboardingFlowLogicTest { } @Test - fun showsPairingStateForPairingRequiredGatewayStatus() { - assertEquals( - GatewayRecoveryUiState.Pairing, - gatewayRecoveryUiState( - ready = false, - statusText = "Gateway error: pairing required; approval in progress", - connectSettling = false, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, - ), - ) + fun recoveryNodeApprovalCommandUsesRequestIdWhenAvailable() { + assertEquals("openclaw nodes approve request-1", recoveryNodeApprovalCommand(" request-1 ")) + assertEquals("openclaw nodes approve REQUEST_ID", recoveryNodeApprovalCommand(null)) + assertEquals("openclaw nodes approve REQUEST_ID", recoveryNodeApprovalCommand(" ")) } @Test - fun showsConnectedStateWhenGatewayBecomesReady() { + fun nodeCapabilityApprovalNeedsUserActionOnlyForPendingStates() { + assertTrue(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.PendingApproval(null))) + assertTrue(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.PendingReapproval(null))) + assertTrue(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Unapproved)) + assertFalse(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Approved)) + assertFalse(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Loading)) + assertFalse(nodeCapabilityApprovalNeedsUserAction(GatewayNodeCapabilityApproval.Unsupported)) + } + + @Test + fun gatewayPairingContinueOnlyRoutesToNodeApprovalWhenApprovalNeedsUserAction() { assertEquals( - GatewayRecoveryUiState.Connected, - gatewayRecoveryUiState( + OnboardingStep.Permissions, + gatewayPairingContinueDestination( ready = true, - statusText = "Gateway error: pairing required", - connectSettling = false, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null), ), ) - } - - @Test - fun showsNodeApprovalStateWhenCapabilityApprovalIsPending() { assertEquals( - GatewayRecoveryUiState.NodeCapabilityApprovalPending, - gatewayRecoveryUiState( + OnboardingStep.NodeApproval, + gatewayPairingContinueDestination( ready = false, - statusText = "Connected", - connectSettling = false, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(requestId = "request-1"), + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null), ), ) - } - - @Test - fun showsFinishingStateWhileNodeApprovalLoads() { assertEquals( - GatewayRecoveryUiState.Finishing, - gatewayRecoveryUiState( + OnboardingStep.NodeApproval, + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(null), + ), + ) + assertEquals( + OnboardingStep.NodeApproval, + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unapproved, + ), + ) + assertNull( + gatewayPairingContinueDestination( ready = false, - statusText = "Connected", - connectSettling = false, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Loading, ), ) + assertNull( + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + assertNull( + gatewayPairingContinueDestination( + ready = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported, + ), + ) } @Test - fun showsApprovalRequiredForPausedPairingProblem() { - assertEquals( - GatewayRecoveryUiState.ApprovalRequired, - gatewayRecoveryUiState( + fun permissionContinueReturnsToNodeApprovalWhenApprovalIsStillPending() { + assertTrue( + permissionContinueNeedsNodeApproval( ready = false, - statusText = "Connecting…", - connectSettling = false, + requiresNodeApprovalAfterApply = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingReapproval(null), + ), + ) + assertTrue( + permissionContinueNeedsNodeApproval( + ready = false, + requiresNodeApprovalAfterApply = true, nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + assertTrue( + permissionContinueNeedsNodeApproval( + ready = true, + requiresNodeApprovalAfterApply = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + assertFalse( + permissionContinueNeedsNodeApproval( + ready = true, + requiresNodeApprovalAfterApply = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Unsupported, + ), + ) + assertFalse( + permissionContinueNeedsNodeApproval( + ready = true, + requiresNodeApprovalAfterApply = false, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + ), + ) + } + + @Test + fun nodeApprovalCheckingOnlyTracksActiveRefresh() { + assertTrue( + nodeApprovalCheckingInProgress( + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ), + ) + assertTrue( + nodeApprovalCheckingInProgress( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = true, + ), + ) + assertFalse( + nodeApprovalCheckingInProgress( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ), + ) + assertFalse( + nodeApprovalCheckingInProgress( + checkRequested = false, + refreshStarted = true, + nodesDevicesRefreshing = true, + ), + ) + } + + @Test + fun nodeApprovalCheckClearsUnobservedRefreshOnlyOnApprovalScreen() { + assertTrue( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.NodeApproval, + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ), + ) + assertFalse( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.NodeApproval, + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ), + ) + assertFalse( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.NodeApproval, + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = true, + ), + ) + assertFalse( + nodeApprovalCheckShouldClearUnobservedRefresh( + step = OnboardingStep.Permissions, + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ), + ) + } + + @Test + fun nodeApprovalCheckContinuesWhenRequestedCheckFindsGatewayReady() { + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = false, + ready = true, + ), + ) + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = false, + nodesDevicesRefreshing = true, + ready = true, + ), + ) + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = true, + ready = true, + ), + ) + assertFalse( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ready = false, + ), + ) + assertTrue( + nodeApprovalCheckCanContinue( + checkRequested = true, + refreshStarted = true, + nodesDevicesRefreshing = false, + ready = true, + ), + ) + } + + @Test + fun nodeApprovalAutoContinuesWhenGatewayReportsReady() { + assertTrue( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.NodeApproval, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + autoContinueEnabled = true, + ), + ) + assertFalse( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.NodeApproval, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.PendingApproval(null), + autoContinueEnabled = true, + ), + ) + assertFalse( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.Permissions, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + autoContinueEnabled = true, + ), + ) + assertFalse( + nodeApprovalShouldAutoContinue( + step = OnboardingStep.NodeApproval, + ready = true, + nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, + autoContinueEnabled = false, + ), + ) + } + + @Test + fun gatewayPairingStopsAtConnectedEvenWhenNodeApprovalIsStillPending() { + assertEquals( + GatewayRecoveryUiState.Connected, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = true, + statusText = "Waiting for node approval", + connectSettling = false, + connectTimedOut = true, + ), + ) + } + + @Test + fun gatewayPairingContinueWinsOverStaleNodePairingRequiredProblem() { + assertEquals( + GatewayRecoveryUiState.Connected, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = true, + statusText = "Connected (node offline)", + connectSettling = false, gatewayConnectionProblem = GatewayConnectionProblem( code = "PAIRING_REQUIRED", @@ -251,14 +706,37 @@ class OnboardingFlowLogicTest { } @Test - fun showsPairingForRetryablePairingProblem() { + fun gatewayPairingPrefersManualApprovalErrorOverPartialOperatorConnect() { + assertEquals( + GatewayRecoveryUiState.ApprovalRequired, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun gatewayPairingPrefersRetryableApprovalErrorOverPartialOperatorConnect() { assertEquals( GatewayRecoveryUiState.Pairing, - gatewayRecoveryUiState( - ready = false, - statusText = "Connecting…", + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", connectSettling = false, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, gatewayConnectionProblem = GatewayConnectionProblem( code = "PAIRING_REQUIRED", @@ -273,6 +751,88 @@ class OnboardingFlowLogicTest { ) } + @Test + fun gatewayPairingWaitsWhenOperatorConnectedButNoContinueDestinationExists() { + assertEquals( + GatewayRecoveryUiState.Finishing, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + connectTimedOut = false, + ), + ) + assertEquals( + GatewayRecoveryUiState.TakingLonger, + gatewayPairingUiState( + gatewayPaired = true, + gatewayPairingCanContinue = false, + statusText = "Connected (node offline)", + connectSettling = false, + connectTimedOut = true, + ), + ) + } + + @Test + fun gatewayPairingShowsSlowConnectionWhenGatewayNeverPairs() { + assertEquals( + GatewayRecoveryUiState.Finishing, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Connecting…", + connectSettling = false, + connectTimedOut = false, + ), + ) + assertEquals( + GatewayRecoveryUiState.TakingLonger, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Connecting…", + connectSettling = false, + connectTimedOut = true, + ), + ) + } + + @Test + fun gatewayPairingPreservesExplicitFailureStatusText() { + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", + connectSettling = false, + connectTimedOut = false, + ), + ) + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", + connectSettling = false, + connectTimedOut = true, + ), + ) + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayPairingUiState( + gatewayPaired = false, + gatewayPairingCanContinue = false, + statusText = "Gateway error: unauthorized: gateway token missing", + connectSettling = false, + connectTimedOut = false, + ), + ) + } + @Test fun recoveryGatewayDetailPreservesRetryablePairingGuidance() { assertEquals( @@ -346,10 +906,8 @@ class OnboardingFlowLogicTest { fun recoveryGatewayAuthDetailShowsSpecificAuthRecoveryActions() { val cases = listOf( - "AUTH_BOOTSTRAP_TOKEN_INVALID" to "Setup code expired. Scan a fresh setup QR.", + "AUTH_BOOTSTRAP_TOKEN_INVALID" to "The code may have expired or been generated for another Gateway.", "AUTH_DEVICE_TOKEN_MISMATCH" to "Saved authentication is invalid. Re-authenticate or reset this gateway connection.", - "AUTH_TOKEN_NOT_CONFIGURED" to "Gateway authentication is not configured. Configure it on the gateway host, then retry.", - "AUTH_SCOPE_MISMATCH" to "Gateway access needs review. Check gateway authentication scopes, then retry.", "AUTH_PASSWORD_MISMATCH" to "Gateway password is invalid. Re-enter it or reset this gateway connection.", "AUTH_TOKEN_MISSING" to "Gateway token is required. Enter it again or edit this connection.", "DEVICE_IDENTITY_REQUIRED" to "Gateway requires this device identity. Re-authenticate or reset this gateway connection.", @@ -374,118 +932,9 @@ class OnboardingFlowLogicTest { } @Test - fun authFailuresStopShowingAConnectingState() { - val cases = - listOf( - "AUTH_BOOTSTRAP_TOKEN_INVALID" to "scan_fresh_setup_code", - "AUTH_DEVICE_TOKEN_MISMATCH" to "update_auth_credentials", - "AUTH_TOKEN_NOT_CONFIGURED" to "update_auth_configuration", - "AUTH_SCOPE_MISMATCH" to "review_auth_configuration", - ) - - for ((code, nextStep) in cases) { - assertEquals( - GatewayRecoveryUiState.AuthenticationRequired, - gatewayRecoveryUiState( - ready = false, - statusText = "Connecting…", - connectSettling = true, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Loading, - gatewayConnectionProblem = authProblem(code = code, recommendedNextStep = nextStep), - ), - ) - } - } - - @Test - fun recoveryPrimaryActionRepairsTheStructuredAuthFailure() { + fun recoveryGatewayAuthDetailPreservesProtocolMismatchGuidance() { assertEquals( - GatewayRecoveryPrimaryAction.ScanFreshSetupCode, - gatewayRecoveryPrimaryAction( - ready = false, - problem = authProblem(code = "AUTH_BOOTSTRAP_TOKEN_INVALID"), - ), - ) - assertEquals( - GatewayRecoveryPrimaryAction.EditConnection, - gatewayRecoveryPrimaryAction( - ready = false, - problem = authProblem(code = "AUTH_DEVICE_TOKEN_MISMATCH", recommendedNextStep = "update_auth_credentials"), - ), - ) - assertEquals( - GatewayRecoveryPrimaryAction.RetryConnection, - gatewayRecoveryPrimaryAction( - ready = false, - problem = authProblem(code = "AUTH_TOKEN_NOT_CONFIGURED"), - ), - ) - assertEquals( - GatewayRecoveryPrimaryAction.EditConnection, - gatewayRecoveryPrimaryAction( - ready = false, - problem = authProblem(code = "AUTH_SCOPE_MISMATCH", recommendedNextStep = "review_auth_configuration"), - ), - ) - assertEquals( - GatewayRecoveryPrimaryAction.RetryConnection, - gatewayRecoveryPrimaryAction(ready = false, problem = null), - ) - } - - @Test - fun recoveryPrimaryActionLabelsDescribeTheirActualAction() { - val expected = - mapOf( - GatewayRecoveryPrimaryAction.Continue to "Continue", - GatewayRecoveryPrimaryAction.ScanFreshSetupCode to "Scan fresh setup code", - GatewayRecoveryPrimaryAction.EditConnection to "Edit connection", - GatewayRecoveryPrimaryAction.RetryConnection to "Retry connection", - ) - - for ((action, label) in expected) { - assertEquals(label, gatewayRecoveryPrimaryActionLabel(action)) - } - } - - @Test - fun recoveryApprovalCommandPrefersTheExactNodeRequestId() { - assertEquals( - "openclaw nodes approve request-1", - recoveryGatewayApprovalCommand( - GatewayNodeCapabilityApproval.PendingApproval(requestId = "request-1"), - gatewayConnectionProblem = null, - ), - ) - assertEquals( - "openclaw nodes status", - recoveryGatewayApprovalCommand( - GatewayNodeCapabilityApproval.PendingApproval(requestId = "request-1; unsafe"), - gatewayConnectionProblem = null, - ), - ) - assertEquals( - "openclaw devices list", - recoveryGatewayApprovalCommand( - GatewayNodeCapabilityApproval.Approved, - gatewayConnectionProblem = - GatewayConnectionProblem( - code = "PAIRING_REQUIRED", - message = "pairing required", - reason = "not-paired", - requestId = "request-1; unsafe", - recommendedNextStep = null, - pauseReconnect = true, - retryable = false, - ), - ), - ) - } - - @Test - fun recoveryGatewayAuthDetailIdentifiesOlderAppProtocolMismatch() { - assertEquals( - "This app is older than the gateway. Update OpenClaw on this device, then retry. (app protocol v4, gateway protocol v5).", + "This app is older than the Gateway. Update OpenClaw on this device, then retry. (app protocol v4, gateway protocol v5).", recoveryGatewayAuthDetail( GatewayConnectionProblem( code = "PROTOCOL_MISMATCH", @@ -498,16 +947,36 @@ class OnboardingFlowLogicTest { clientMinProtocol = 4, clientMaxProtocol = 4, expectedProtocol = 5, - minimumProbeProtocol = 4, ), ), ) } @Test - fun recoveryGatewayAuthDetailIdentifiesOlderGatewayProtocolMismatch() { + fun recoveryGatewayAuthDetailExplainsOlderGatewayProtocolMismatch() { assertEquals( - "The gateway is older than this app. Update OpenClaw on the gateway host, then retry. (app protocol v4, gateway protocol v3).", + "The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry. (app protocol v6, gateway protocol v5).", + recoveryGatewayAuthDetail( + GatewayConnectionProblem( + code = "PROTOCOL_MISMATCH", + message = "protocol mismatch", + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + clientMinProtocol = 6, + clientMaxProtocol = 6, + expectedProtocol = 5, + ), + ), + ) + } + + @Test + fun recoveryGatewayAuthDetailExplainsIncompatibleProtocolMismatch() { + assertEquals( + "The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry. (app protocols v4-v6).", recoveryGatewayAuthDetail( GatewayConnectionProblem( code = "PROTOCOL_MISMATCH", @@ -518,27 +987,8 @@ class OnboardingFlowLogicTest { pauseReconnect = true, retryable = false, clientMinProtocol = 4, - clientMaxProtocol = 4, - expectedProtocol = 3, - minimumProbeProtocol = 3, - ), - ), - ) - } - - @Test - fun recoveryGatewayAuthDetailKeepsProtocolMismatchFallbackActionable() { - assertEquals( - "The app and gateway use incompatible protocol versions. Update OpenClaw on both, then retry.", - recoveryGatewayAuthDetail( - GatewayConnectionProblem( - code = "PROTOCOL_MISMATCH", - message = "protocol mismatch", - reason = null, - requestId = null, - recommendedNextStep = null, - pauseReconnect = true, - retryable = false, + clientMaxProtocol = 6, + expectedProtocol = null, ), ), ) @@ -547,7 +997,7 @@ class OnboardingFlowLogicTest { @Test fun recoveryGatewayAuthDetailUsesRecommendedNextStepFallbacks() { assertEquals( - "Gateway authentication is not configured. Configure it on the gateway host, then retry.", + "Gateway authentication is not configured. Edit this connection and try again.", recoveryGatewayAuthDetail( GatewayConnectionProblem( code = "UNKNOWN", @@ -577,58 +1027,123 @@ class OnboardingFlowLogicTest { } @Test - fun showsFinishingStateWhileGatewayConnectionSettles() { - assertEquals( + fun recoveryPrimaryActionOnlyAppearsForCompleteFailureOrSlowConnectionStates() { + assertEquals(GatewayRecoveryPrimaryAction.Finish, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.Connected)) + assertEquals(GatewayRecoveryPrimaryAction.Back, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.Failed)) + assertEquals(GatewayRecoveryPrimaryAction.Retry, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.TakingLonger)) + assertEquals(GatewayRecoveryPrimaryAction.Retry, gatewayRecoveryPrimaryAction(GatewayRecoveryUiState.ApprovalRequired)) + + listOf( + GatewayRecoveryUiState.NodeCapabilityApprovalPending, + GatewayRecoveryUiState.Pairing, GatewayRecoveryUiState.Finishing, - gatewayRecoveryUiState( - ready = false, - statusText = "Offline", + ).forEach { state -> + assertEquals(null, gatewayRecoveryPrimaryAction(state)) + } + } + + @Test + fun recoveryDiagnosticActionAppearsForFailuresSlowStatesAndGatewayProblems() { + assertTrue(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.Failed, gatewayConnectionProblem = null)) + assertTrue(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.TakingLonger, gatewayConnectionProblem = null)) + assertTrue( + gatewayRecoveryShowsDiagnosticAction( + GatewayRecoveryUiState.Pairing, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + retryable = true, + ), + ), + ) + assertFalse(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.Finishing, gatewayConnectionProblem = null)) + assertFalse(gatewayRecoveryShowsDiagnosticAction(GatewayRecoveryUiState.Connected, gatewayConnectionProblem = null)) + } + + @Test + fun recoveryDiagnosticTextIncludesRecoveryStateWithoutCredentials() { + val diagnostic = + gatewayRecoveryDiagnosticText( + statusText = "Gateway closed: token mismatch", + gatewayName = "Home Gateway", + gatewayPaired = false, + gatewayPairingCanContinue = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "AUTH_TOKEN_MISMATCH", + message = "token mismatch", + reason = "bad-token", + requestId = "request-1", + recommendedNextStep = "update_auth_credentials", + pauseReconnect = true, + retryable = false, + ), + ) + + assertTrue(diagnostic.contains("OpenClaw Android gateway diagnostic")) + assertTrue(diagnostic.contains("Gateway: Home Gateway")) + assertTrue(diagnostic.contains("Status: Gateway closed: token mismatch")) + assertTrue(diagnostic.contains("Gateway paired: false")) + assertTrue(diagnostic.contains("Ready to continue: false")) + assertTrue(diagnostic.contains("Error code: AUTH_TOKEN_MISMATCH")) + assertTrue(diagnostic.contains("Reason: bad-token")) + assertTrue(diagnostic.contains("Request ID: request-1")) + assertTrue(diagnostic.contains("Next step: update_auth_credentials")) + assertFalse(diagnostic.contains("secret")) + } + + @Test + fun recoveryProgressStartsAtGatewayEndpointWhileConnecting() { + assertEquals( + listOf( + GatewayRecoveryProgressItem("Opening Gateway connection", GatewayRecoveryProgressStatus.Current), + GatewayRecoveryProgressItem("Checking pairing access", GatewayRecoveryProgressStatus.Pending), + GatewayRecoveryProgressItem("Checking node access", GatewayRecoveryProgressStatus.Pending), + ), + gatewayRecoveryProgressItems( + state = GatewayRecoveryUiState.Finishing, + statusText = "Connecting…", connectSettling = true, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, ), ) } @Test - fun showsFinishingStateForPartialGatewayConnection() { + fun recoveryProgressDoesNotAdvanceToGatewayAccessJustBecauseSettlingEnds() { assertEquals( - GatewayRecoveryUiState.Finishing, - gatewayRecoveryUiState( - ready = false, + listOf( + GatewayRecoveryProgressItem("Opening Gateway connection", GatewayRecoveryProgressStatus.Current), + GatewayRecoveryProgressItem("Checking pairing access", GatewayRecoveryProgressStatus.Pending), + GatewayRecoveryProgressItem("Checking node access", GatewayRecoveryProgressStatus.Pending), + ), + gatewayRecoveryProgressItems( + state = GatewayRecoveryUiState.Finishing, + statusText = "Connecting…", + connectSettling = false, + ), + ) + } + + @Test + fun recoveryProgressMovesDownToNodeAccessAfterGatewayConnects() { + assertEquals( + listOf( + GatewayRecoveryProgressItem("Opening Gateway connection", GatewayRecoveryProgressStatus.Complete), + GatewayRecoveryProgressItem("Checking pairing access", GatewayRecoveryProgressStatus.Complete), + GatewayRecoveryProgressItem("Checking node access", GatewayRecoveryProgressStatus.Current), + ), + gatewayRecoveryProgressItems( + state = GatewayRecoveryUiState.Finishing, statusText = "Connected (node offline)", - connectSettling = false, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, ), ) } - @Test - fun showsConnectionIssueForNonPairingFailure() { - assertEquals( - GatewayRecoveryUiState.Failed, - gatewayRecoveryUiState( - ready = false, - statusText = "Gateway error: connection refused", - connectSettling = false, - nodeCapabilityApproval = GatewayNodeCapabilityApproval.Approved, - ), - ) - } - - private fun authProblem( - code: String, - recommendedNextStep: String? = null, - ): GatewayConnectionProblem = - GatewayConnectionProblem( - code = code, - message = "authentication needed", - reason = null, - requestId = null, - recommendedNextStep = recommendedNextStep, - pauseReconnect = true, - retryable = false, - ) - @Test fun resolvesOnboardingSetupCodeConnectConfigForScannedQr() { val setupCode = diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index d4616a19761..f24201b81d3 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -22,7 +22,7 @@ ktlint-gradle = "14.2.0" kotlin = "2.4.0" material = "1.14.0" okhttp = "5.3.2" -play-services-code-scanner = "16.1.0" +barcode-scanning = "17.3.0" robolectric = "4.16.1" serialization-json = "1.11.0" @@ -32,6 +32,7 @@ androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" } androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } @@ -47,6 +48,7 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" } +barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcode-scanning" } commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } @@ -63,7 +65,6 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa material = { module = "com.google.android.material:material", version.ref = "material" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } -play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } [plugins] diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index a4a5504347f..d1312151619 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -127,9 +127,9 @@ That bootstrap token carries the built-in pairing bootstrap profile: `node` plus a bounded `operator` handoff - the handed-off `node` token stays `scopes: []` - the handed-off `operator` token is limited to `operator.approvals`, - `operator.read`, and `operator.write` -- `operator.admin` and `operator.pairing` are not granted by QR/setup-code - bootstrap; they require a separate approved operator pairing or token flow + `operator.read`, `operator.talk.secrets`, and `operator.write` +- `operator.admin` is not granted by QR/setup-code bootstrap; it requires a + separate approved operator pairing or token flow - later token rotation/revocation remains bounded by both the device's approved role contract and the caller session's operator scopes diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 85dbdf87ac0..3a575965f4b 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -495,7 +495,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `/pair approve` when there is only one pending request - `/pair approve latest` for most recent - The setup code carries a short-lived bootstrap token. Built-in setup-code bootstrap is node-only: the first connect creates a pending node request, and after approval the Gateway returns a durable node token with `scopes: []`. It does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow. + The setup code carries a short-lived bootstrap token. Built-in setup-code bootstrap returns a durable node token with `scopes: []` plus a bounded operator handoff token for trusted mobile onboarding. That operator token can read setup-time native configuration, but it does not grant pairing mutation scopes or `operator.admin`. If a device retries with changed auth details (for example role/scopes/public key), the previous pending request is superseded and the new request uses a different `requestId`. Re-run `/pair pending` before approving. diff --git a/docs/cli/qr.md b/docs/cli/qr.md index abb612e35ef..793aee7f9fd 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -36,7 +36,7 @@ openclaw qr --url wss://gateway.example/ws - `--token` and `--password` are mutually exclusive. - The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. - Built-in setup-code bootstrap returns a primary `node` token with `scopes: []` plus a bounded `operator` handoff token for trusted mobile onboarding. -- The handed-off operator token is limited to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`; `operator.admin` and `operator.pairing` require a separate approved operator pairing or token flow. +- The handed-off operator token is limited to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`; pairing mutation scopes and `operator.admin` still require a separate approved operator pairing or token flow. - Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL. - With `--remote`, OpenClaw requires either `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel`. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 094cf392aaf..374e5c74291 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -169,10 +169,11 @@ operator token: ``` The operator handoff is intentionally bounded so QR onboarding can start the -mobile operator loop without granting `operator.admin` or `operator.pairing`. -It does include `operator.talk.secrets` so the native client can read the Talk -configuration it needs after bootstrap. Broader admin and pairing scopes require -a separate approved operator pairing or token flow. Clients should persist +mobile operator loop and complete native setup without granting pairing +mutation scopes or `operator.admin`. It includes `operator.talk.secrets` so the +native client can read the Talk configuration it needs after bootstrap. Broader +pairing and admin access requires a separate approved operator pairing or token +flow. Clients should persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap auth on trusted transport such as `wss://` or loopback/local pairing. @@ -731,8 +732,8 @@ rather than the pre-handshake defaults. - Built-in setup-code bootstrap returns the primary node `hello-ok.auth.deviceToken` plus a bounded operator token in `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token - includes `operator.talk.secrets` for native Talk configuration reads and - excludes `operator.admin` and `operator.pairing`. + includes `operator.talk.secrets` for native Talk configuration reads, but + excludes pairing mutation scopes and `operator.admin`. - While a non-baseline setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Clients should keep reconnecting with the same diff --git a/docs/help/faq.md b/docs/help/faq.md index 5c32495f36b..31f7d42c877 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1531,7 +1531,7 @@ lives on the [Models FAQ](/help/faq-models). - On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`). - That cached-token retry now reuses the cached approved scopes stored with the device token. Explicit `deviceToken` / explicit `scopes` callers still keep their requested scope set instead of inheriting cached scopes. - Outside that retry path, connect auth precedence is explicit shared token/password first, then explicit `deviceToken`, then stored device token, then bootstrap token. - - Built-in setup-code bootstrap is node-only. After approval, it returns a node device token with `scopes: []` and does not return a handed-off operator token. + - Built-in setup-code bootstrap returns a node device token with `scopes: []` plus a bounded operator handoff token for trusted mobile onboarding. The operator handoff can read setup-time native configuration but does not grant pairing mutation scopes or `operator.admin`. Fix: diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index d0d7f1f9491..8595ffb968b 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1201,7 +1201,6 @@ export function registerControlUiAndPairingSuite(): void { "operator.write", ]); expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); const pendingAfterInitial = await listDevicePairing(); const pendingForDevice = pendingAfterInitial.pending.filter( @@ -1370,7 +1369,6 @@ export function registerControlUiAndPairingSuite(): void { "operator.write", ]); expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); const pendingAfterInitial = await listDevicePairing(); expect( @@ -1510,7 +1508,6 @@ export function registerControlUiAndPairingSuite(): void { "operator.write", ]); expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); wsRetry.close(); await expect( diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index cab7dea36ed..9e2493ba04d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1398,8 +1398,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar // This is the native QR/setup-code onboarding seam. Mobile clients // must prove their canonical client id and platform/family metadata // agree before the Gateway can skip owner approval and hand off the - // bounded operator token below. Admin/pairing scopes still require - // an explicit owner flow. + // bounded operator token below. Admin/pairing still require an explicit owner flow. const bootstrapPairingRoles = allowSetupCodeMobileBootstrapPairing ? uniqueStrings([role, ...boundBootstrapProfile.roles]) : undefined; diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index 728349c2863..3f317f7caed 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -36,6 +36,7 @@ describe("device bootstrap profile", () => { "node.exec", "operator.admin", "operator.approvals", + "operator.pairing", "operator.read", "operator.talk.secrets", "operator.write", diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index e3e1e5f1d5c..a43ee53a823 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -60,8 +60,8 @@ describe("native app i18n inventory", () => { (entry) => entry.source === "Talk failed: Realtime provider closed unexpectedly.", ), ).toBe(true); - expect(entries.some((entry) => entry.source === "Scan fresh setup code")).toBe(true); - expect(entries.some((entry) => entry.source === "Retry connection")).toBe(true); + expect(entries.some((entry) => entry.source === "Scan QR code")).toBe(true); + expect(entries.some((entry) => entry.source === "Test connection")).toBe(true); expect(entries.some((entry) => entry.source === "Searching…")).toBe(true); expect(entries.some((entry) => entry.source === "Run now")).toBe(true); expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true);