From d9506b2000ef962006949de74418ed3b1d15bbd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 01:05:36 -0700 Subject: [PATCH] fix(android): preserve location opt-in during onboarding (#129961) --- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 52 +++++++++++++------ .../app/ui/OnboardingFlowLogicTest.kt | 47 ++++++++++++----- 2 files changed, 69 insertions(+), 30 deletions(-) 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 bafa4aa23294..8a1e622753ff 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 @@ -3104,25 +3104,25 @@ 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( +internal fun initialDeviceCapabilityEnabled( savedCapabilityEnabled: Boolean, - androidCameraPermissionGranted: Boolean, -): Boolean = savedCapabilityEnabled && androidCameraPermissionGranted + androidPermissionGranted: Boolean, +): Boolean = savedCapabilityEnabled && androidPermissionGranted -internal fun cameraPermissionRowStatusText( +internal fun deviceCapabilityRowStatusText( capabilityEnabled: Boolean, - androidCameraPermissionGranted: Boolean, + androidPermissionGranted: Boolean, ): NativeText = when { capabilityEnabled -> nativeText("Enabled") - androidCameraPermissionGranted -> nativeText("Off") + androidPermissionGranted -> nativeText("Off") else -> nativeText("Not allowed") } -internal fun cameraCapabilityAfterRowTap( +internal fun deviceCapabilityAfterRowTap( currentCapabilityEnabled: Boolean, - androidCameraPermissionGranted: Boolean, -): Boolean? = if (androidCameraPermissionGranted) !currentCapabilityEnabled else null + androidPermissionGranted: Boolean, +): Boolean? = if (androidPermissionGranted) !currentCapabilityEnabled else null private fun permissionRowStatusText(granted: Boolean): NativeText = if (granted) nativeText("Granted") else nativeText("Not granted") @@ -3148,9 +3148,15 @@ private fun rememberPermissionState( val currentLocationMode by viewModel.locationMode.collectAsState() var microphoneGranted by rememberSaveable { mutableStateOf(hasPermission(context, Manifest.permission.RECORD_AUDIO)) } val cameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA) - var cameraGranted by rememberSaveable { mutableStateOf(initialCameraCapabilityEnabled(currentCameraEnabled, cameraPermissionGranted)) } + var cameraGranted by rememberSaveable { mutableStateOf(initialDeviceCapabilityEnabled(currentCameraEnabled, cameraPermissionGranted)) } + + fun hasLocationPermission(): Boolean = + hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || + hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + + val locationPermissionGranted = hasLocationPermission() var locationGranted by rememberSaveable { - mutableStateOf(hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) + mutableStateOf(initialDeviceCapabilityEnabled(currentLocationMode != LocationMode.Off, locationPermissionGranted)) } val photosPermissions = photoReadPermissionsForRequest() var photosGranted by rememberSaveable { mutableStateOf(hasPhotoReadPermission(context)) } @@ -3238,9 +3244,9 @@ private fun rememberPermissionState( fun requestCameraCapability() { val nextCapabilityEnabled = - cameraCapabilityAfterRowTap( + deviceCapabilityAfterRowTap( currentCapabilityEnabled = cameraGranted, - androidCameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA), + androidPermissionGranted = hasPermission(context, Manifest.permission.CAMERA), ) if (nextCapabilityEnabled != null) { cameraGranted = nextCapabilityEnabled @@ -3260,14 +3266,26 @@ private fun rememberPermissionState( nativeText("Capture photos and clips from this phone"), Icons.Default.CameraAlt, cameraGranted, - cameraPermissionRowStatusText( + deviceCapabilityRowStatusText( capabilityEnabled = cameraGranted, - androidCameraPermissionGranted = hasPermission(context, Manifest.permission.CAMERA), + androidPermissionGranted = cameraPermissionGranted, ), ::requestCameraCapability, ), - PermissionRowModel(PermissionRowId.Location, nativeText("Location"), nativeText("Read this phone's location"), Icons.Default.LocationOn, locationGranted) { - request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + PermissionRowModel( + PermissionRowId.Location, + nativeText("Location"), + nativeText("Read this phone's location"), + Icons.Default.LocationOn, + locationGranted, + deviceCapabilityRowStatusText(locationGranted, locationPermissionGranted), + ) { + val nextCapabilityEnabled = deviceCapabilityAfterRowTap(locationGranted, hasLocationPermission()) + if (nextCapabilityEnabled != null) { + locationGranted = nextCapabilityEnabled + } else { + request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + } }, if (photosAvailable) { PermissionRowModel(PermissionRowId.Photos, nativeText("Photos"), nativeText("Read recent photos and media"), Icons.Default.Image, photosGranted) { 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 5aea5718e2c0..8986ee4e5ec7 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 @@ -180,21 +180,42 @@ class OnboardingFlowLogicTest { } @Test - fun cameraCapabilityStartsOffEvenWhenScannerPermissionWasGranted() { + fun deviceCapabilityStartsOffEvenWhenAndroidPermissionWasGranted() { assertBooleanCases( - false to initialCameraCapabilityEnabled(savedCapabilityEnabled = false, androidCameraPermissionGranted = false), - false to initialCameraCapabilityEnabled(savedCapabilityEnabled = false, androidCameraPermissionGranted = true), - false to initialCameraCapabilityEnabled(savedCapabilityEnabled = true, androidCameraPermissionGranted = false), - true to initialCameraCapabilityEnabled(savedCapabilityEnabled = true, androidCameraPermissionGranted = true), + false to initialDeviceCapabilityEnabled(savedCapabilityEnabled = false, androidPermissionGranted = false), + false to initialDeviceCapabilityEnabled(savedCapabilityEnabled = false, androidPermissionGranted = true), + false to initialDeviceCapabilityEnabled(savedCapabilityEnabled = true, androidPermissionGranted = false), + true to initialDeviceCapabilityEnabled(savedCapabilityEnabled = true, androidPermissionGranted = true), ) } @Test - fun cameraPermissionRowDistinguishesAndroidPermissionFromCapabilityOptIn() { + fun locationCapabilityRequiresBothTheSavedModeAndAndroidPermission() { + listOf( + Triple(LocationMode.Off, false, false), + Triple(LocationMode.Off, true, false), + Triple(LocationMode.WhileUsing, false, false), + Triple(LocationMode.WhileUsing, true, true), + Triple(LocationMode.Always, false, false), + Triple(LocationMode.Always, true, true), + ).forEach { (savedMode, androidPermissionGranted, expected) -> + assertEquals( + "savedMode=$savedMode androidPermissionGranted=$androidPermissionGranted", + expected, + initialDeviceCapabilityEnabled( + savedCapabilityEnabled = savedMode != LocationMode.Off, + androidPermissionGranted = androidPermissionGranted, + ), + ) + } + } + + @Test + fun deviceCapabilityRowDistinguishesAndroidPermissionFromCapabilityOptIn() { assertEqualsCases( - "Not allowed" to cameraPermissionRowStatusText(capabilityEnabled = false, androidCameraPermissionGranted = false).resolveNativeText(), - "Off" to cameraPermissionRowStatusText(capabilityEnabled = false, androidCameraPermissionGranted = true).resolveNativeText(), - "Enabled" to cameraPermissionRowStatusText(capabilityEnabled = true, androidCameraPermissionGranted = true).resolveNativeText(), + "Not allowed" to deviceCapabilityRowStatusText(capabilityEnabled = false, androidPermissionGranted = false).resolveNativeText(), + "Off" to deviceCapabilityRowStatusText(capabilityEnabled = false, androidPermissionGranted = true).resolveNativeText(), + "Enabled" to deviceCapabilityRowStatusText(capabilityEnabled = true, androidPermissionGranted = true).resolveNativeText(), ) } @@ -207,10 +228,10 @@ class OnboardingFlowLogicTest { } @Test - fun cameraPermissionRowTogglesCapabilityWhenAndroidPermissionAlreadyGranted() { - assertNull(cameraCapabilityAfterRowTap(currentCapabilityEnabled = false, androidCameraPermissionGranted = false)) - assertTrue(cameraCapabilityAfterRowTap(currentCapabilityEnabled = false, androidCameraPermissionGranted = true)!!) - assertFalse(cameraCapabilityAfterRowTap(currentCapabilityEnabled = true, androidCameraPermissionGranted = true)!!) + fun deviceCapabilityRowTogglesOnlyWhenAndroidPermissionAlreadyGranted() { + assertNull(deviceCapabilityAfterRowTap(currentCapabilityEnabled = false, androidPermissionGranted = false)) + assertTrue(deviceCapabilityAfterRowTap(currentCapabilityEnabled = false, androidPermissionGranted = true)!!) + assertFalse(deviceCapabilityAfterRowTap(currentCapabilityEnabled = true, androidPermissionGranted = true)!!) } @Test