Skip to content

Commit 4e9ef75

Browse files
fix(android): explain camera permission for setup scans
1 parent 44ec758 commit 4e9ef75

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import ai.openclaw.app.ui.design.ClawListItem
1313
import ai.openclaw.app.ui.design.ClawPanel
1414
import ai.openclaw.app.ui.design.ClawPrimaryButton
1515
import ai.openclaw.app.ui.design.ClawScaffold
16+
import ai.openclaw.app.ui.design.ClawSecondaryButton
1617
import ai.openclaw.app.ui.design.ClawStatus
1718
import ai.openclaw.app.ui.design.ClawStatusPill
1819
import ai.openclaw.app.ui.design.ClawTextField
@@ -25,6 +26,7 @@ import android.content.Intent
2526
import android.content.pm.PackageManager
2627
import android.hardware.Sensor
2728
import android.hardware.SensorManager
29+
import android.net.Uri
2830
import android.os.Build
2931
import android.os.SystemClock
3032
import android.provider.Settings
@@ -79,6 +81,7 @@ import androidx.compose.material.icons.filled.QrCode2
7981
import androidx.compose.material.icons.filled.Refresh
8082
import androidx.compose.material.icons.filled.Security
8183
import androidx.compose.material.icons.filled.Sensors
84+
import androidx.compose.material.icons.filled.Settings
8285
import androidx.compose.material.icons.filled.WifiTethering
8386
import androidx.compose.material3.AlertDialog
8487
import androidx.compose.material3.Icon
@@ -181,6 +184,14 @@ fun OnboardingFlow(
181184
val qrScanner = remember(context, qrScannerOptions) { GmsBarcodeScanning.getClient(context, qrScannerOptions) }
182185

183186
val permissionState = rememberPermissionState(context = context, viewModel = viewModel)
187+
val cameraPermissionLauncher =
188+
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
189+
setupError =
190+
handleGatewayScannerPermissionResult(
191+
granted = granted,
192+
updateCameraPermission = permissionState.updateCameraPermission,
193+
)
194+
}
184195

185196
LaunchedEffect(startAtGatewaySetup) {
186197
if (startAtGatewaySetup) {
@@ -254,6 +265,7 @@ fun OnboardingFlow(
254265
discoveryStatusText = discoveryStatusText,
255266
discoveryStarted = runtimeInitialized,
256267
error = setupError,
268+
showCameraSettingsAction = gatewaySetupErrorShowsCameraSettings(setupError),
257269
onBack = { step = OnboardingStep.Welcome },
258270
onScan = {
259271
setupError = null
@@ -270,7 +282,16 @@ fun OnboardingFlow(
270282
return@addOnSuccessListener
271283
}
272284
setupCode = scanned.setupCode
273-
}.addOnFailureListener { setupError = "Could not open the scanner." }
285+
}.addOnFailureListener {
286+
val cameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA)
287+
setupError = gatewayScannerFailureMessage(cameraPermissionGranted)
288+
if (!cameraPermissionGranted) {
289+
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
290+
}
291+
}
292+
},
293+
onOpenCameraSettings = {
294+
openAppSettings(context)
274295
},
275296
onSetupCodeChange = {
276297
setupCode = it
@@ -493,8 +514,10 @@ private fun GatewaySetupScreen(
493514
discoveryStatusText: String,
494515
discoveryStarted: Boolean,
495516
error: String?,
517+
showCameraSettingsAction: Boolean,
496518
onBack: () -> Unit,
497519
onScan: () -> Unit,
520+
onOpenCameraSettings: () -> Unit,
498521
onSetupCodeChange: (String) -> Unit,
499522
onManualHostChange: (String) -> Unit,
500523
onManualPortChange: (String) -> Unit,
@@ -566,6 +589,18 @@ private fun GatewaySetupScreen(
566589
ClawErrorState(
567590
title = "Setup code issue",
568591
body = message,
592+
action =
593+
if (showCameraSettingsAction) {
594+
{
595+
ClawSecondaryButton(
596+
text = "Open app settings",
597+
icon = Icons.Default.Settings,
598+
onClick = onOpenCameraSettings,
599+
)
600+
}
601+
} else {
602+
null
603+
},
569604
)
570605
}
571606
}
@@ -1295,6 +1330,7 @@ private data class PermissionRowModel(
12951330
private class PermissionState(
12961331
val rows: List<PermissionRowModel>,
12971332
val applyToViewModel: () -> Unit,
1333+
val updateCameraPermission: (Boolean) -> Unit,
12981334
)
12991335

13001336
/** Onboarding finishes only after the gateway resolves node capability approval. */
@@ -1316,6 +1352,34 @@ internal fun canFinishOnboarding(
13161352
-> true
13171353
}
13181354

1355+
internal fun gatewayScannerFailureMessage(cameraPermissionGranted: Boolean): String =
1356+
if (cameraPermissionGranted) {
1357+
"Could not open the scanner. You can paste the setup code instead."
1358+
} else {
1359+
"Camera permission is required to scan setup codes. Grant Camera permission, then try again."
1360+
}
1361+
1362+
internal fun gatewayScannerFailureShowsAppSettings(cameraPermissionGranted: Boolean): Boolean = !cameraPermissionGranted
1363+
1364+
internal fun gatewayScannerPermissionResultMessage(granted: Boolean): String? =
1365+
if (granted) {
1366+
null
1367+
} else {
1368+
"Camera permission is required to scan setup codes. Open Android app settings to grant Camera permission, then try again."
1369+
}
1370+
1371+
internal fun handleGatewayScannerPermissionResult(
1372+
granted: Boolean,
1373+
updateCameraPermission: (Boolean) -> Unit,
1374+
): String? {
1375+
updateCameraPermission(granted)
1376+
return gatewayScannerPermissionResultMessage(granted)
1377+
}
1378+
1379+
internal fun gatewaySetupErrorShowsCameraSettings(error: String?): Boolean =
1380+
error == gatewayScannerFailureMessage(cameraPermissionGranted = false) ||
1381+
error == gatewayScannerPermissionResultMessage(granted = false)
1382+
13191383
/** Builds permission rows and applies granted feature toggles after onboarding. */
13201384
@Composable
13211385
private fun rememberPermissionState(
@@ -1361,6 +1425,7 @@ private fun rememberPermissionState(
13611425
LifecycleEventObserver { _, event ->
13621426
if (event == Lifecycle.Event.ON_RESUME) {
13631427
notificationListenerGranted = DeviceNotificationListenerService.isAccessEnabled(context)
1428+
cameraGranted = hasPermission(context, Manifest.permission.CAMERA)
13641429
}
13651430
}
13661431
lifecycleOwner.lifecycle.addObserver(observer)
@@ -1455,6 +1520,9 @@ private fun rememberPermissionState(
14551520
viewModel.setLocationMode(if (locationGranted) LocationMode.WhileUsing else LocationMode.Off)
14561521
viewModel.setNotificationForwardingEnabled(notificationListenerGranted)
14571522
},
1523+
updateCameraPermission = { granted ->
1524+
cameraGranted = granted
1525+
},
14581526
)
14591527
}
14601528

@@ -1463,6 +1531,16 @@ private fun hasPermission(
14631531
permission: String,
14641532
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
14651533

1534+
/** Opens this app's Android settings page for permissions that require system UI. */
1535+
private fun openAppSettings(context: Context) {
1536+
val intent =
1537+
Intent(
1538+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
1539+
Uri.fromParts("package", context.packageName, null),
1540+
)
1541+
context.startActivity(intent)
1542+
}
1543+
14661544
/** Returns true when Android exposes any motion sensor that can back node motion commands. */
14671545
private fun hasMotionCapabilities(context: Context): Boolean {
14681546
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false

apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,71 @@ class OnboardingFlowLogicTest {
5757
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true, nodeCapabilityApprovalState = GatewayNodeApprovalState.Unsupported))
5858
}
5959

60+
@Test
61+
fun scannerFailureKeepsGenericCopyWhenCameraPermissionIsGranted() {
62+
assertEquals(
63+
"Could not open the scanner. You can paste the setup code instead.",
64+
gatewayScannerFailureMessage(cameraPermissionGranted = true),
65+
)
66+
assertFalse(gatewayScannerFailureShowsAppSettings(cameraPermissionGranted = true))
67+
}
68+
69+
@Test
70+
fun scannerFailureNamesCameraPermissionWhenDenied() {
71+
assertEquals(
72+
"Camera permission is required to scan setup codes. Grant Camera permission, then try again.",
73+
gatewayScannerFailureMessage(cameraPermissionGranted = false),
74+
)
75+
assertTrue(gatewayScannerFailureShowsAppSettings(cameraPermissionGranted = false))
76+
}
77+
78+
@Test
79+
fun deniedScannerPermissionResultKeepsSettingsRecoveryVisible() {
80+
assertEquals(
81+
"Camera permission is required to scan setup codes. Open Android app settings to grant Camera permission, then try again.",
82+
gatewayScannerPermissionResultMessage(granted = false),
83+
)
84+
assertEquals(null, gatewayScannerPermissionResultMessage(granted = true))
85+
}
86+
87+
@Test
88+
fun cameraSettingsRecoveryOnlyShowsForCameraErrors() {
89+
assertTrue(gatewaySetupErrorShowsCameraSettings(gatewayScannerFailureMessage(cameraPermissionGranted = false)))
90+
assertTrue(gatewaySetupErrorShowsCameraSettings(gatewayScannerPermissionResultMessage(granted = false)))
91+
assertFalse(gatewaySetupErrorShowsCameraSettings(gatewayScannerFailureMessage(cameraPermissionGranted = true)))
92+
assertFalse(gatewaySetupErrorShowsCameraSettings("Enter a setup code or a valid gateway URL."))
93+
assertFalse(gatewaySetupErrorShowsCameraSettings(null))
94+
}
95+
96+
@Test
97+
fun grantedScannerPermissionResultUpdatesCameraState() {
98+
var cameraGranted = false
99+
100+
val message =
101+
handleGatewayScannerPermissionResult(granted = true) {
102+
cameraGranted = it
103+
}
104+
105+
assertEquals(null, message)
106+
assertTrue(cameraGranted)
107+
}
108+
109+
@Test
110+
fun deniedScannerPermissionResultKeepsCameraDisabled() {
111+
var cameraGranted = true
112+
113+
val message =
114+
handleGatewayScannerPermissionResult(granted = false) {
115+
cameraGranted = it
116+
}
117+
118+
assertEquals(
119+
"Camera permission is required to scan setup codes. Open Android app settings to grant Camera permission, then try again.",
120+
message,
121+
)
122+
assertFalse(cameraGranted)
123+
}
124+
60125
@Test
61126
fun nearbyGatewayFoundStateIsConnectable() {
62127
assertEquals(

0 commit comments

Comments
 (0)