From cd61edb0f3470294ac9abfcce6ebecafc278029a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 28 Feb 2026 11:48:13 +0530 Subject: [PATCH] fix(android): add missing capability setup surfaces --- .../android/node/InvokeCommandRegistry.kt | 3 + .../protocol/OpenClawProtocolConstants.kt | 3 + .../ai/openclaw/android/ui/OnboardingFlow.kt | 266 +++++++++++- .../ai/openclaw/android/ui/SettingsSheet.kt | 403 ++++++++++++++++++ .../android/node/InvokeCommandRegistryTest.kt | 6 + .../protocol/OpenClawProtocolConstantsTest.kt | 3 + 6 files changed, 676 insertions(+), 8 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt index 5e53a08a759..b8ec77bfca9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt @@ -61,6 +61,9 @@ object InvokeCommandRegistry { NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue), NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue), NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue), + NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue), + NodeCapabilitySpec(name = OpenClawCapability.System.rawValue), + NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue), NodeCapabilitySpec( name = OpenClawCapability.Camera.rawValue, availability = NodeCapabilityAvailability.CameraEnabled, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt index f5c14781d65..a2816e257fa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt @@ -8,6 +8,9 @@ enum class OpenClawCapability(val rawValue: String) { VoiceWake("voiceWake"), Location("location"), Device("device"), + Notifications("notifications"), + System("system"), + AppUpdate("appUpdate"), Photos("photos"), Contacts("contacts"), Calendar("calendar"), diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt index 4c9e064e6af..182b83f2639 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -2,8 +2,13 @@ package ai.openclaw.android.ui import android.Manifest import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorManager +import android.net.Uri import android.os.Build +import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -77,6 +82,7 @@ import androidx.core.content.ContextCompat import ai.openclaw.android.LocationMode import ai.openclaw.android.MainViewModel import ai.openclaw.android.R +import ai.openclaw.android.node.DeviceNotificationListenerService import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions @@ -205,51 +211,129 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { var attemptedConnect by rememberSaveable { mutableStateOf(false) } var enableDiscovery by rememberSaveable { mutableStateOf(true) } + var enableLocation by rememberSaveable { mutableStateOf(false) } var enableNotifications by rememberSaveable { mutableStateOf(true) } + var enableNotificationListener by rememberSaveable { mutableStateOf(false) } + var enableAppUpdates by rememberSaveable { mutableStateOf(false) } var enableMicrophone by rememberSaveable { mutableStateOf(false) } var enableCamera by rememberSaveable { mutableStateOf(false) } + var enablePhotos by rememberSaveable { mutableStateOf(false) } + var enableContacts by rememberSaveable { mutableStateOf(false) } + var enableCalendar by rememberSaveable { mutableStateOf(false) } + var enableMotion by rememberSaveable { mutableStateOf(false) } var enableSms by rememberSaveable { mutableStateOf(false) } val smsAvailable = remember(context) { context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } + val motionAvailable = + remember(context) { + hasMotionCapabilities(context) + } + val motionPermissionRequired = Build.VERSION.SDK_INT >= 29 + val photosPermission = + if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } val selectedPermissions = remember( context, enableDiscovery, + enableLocation, enableNotifications, enableMicrophone, enableCamera, + enablePhotos, + enableContacts, + enableCalendar, + enableMotion, enableSms, smsAvailable, + motionAvailable, + motionPermissionRequired, + photosPermission, ) { val requested = mutableListOf() if (enableDiscovery) { requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION } + if (enableLocation) { + requested += Manifest.permission.ACCESS_FINE_LOCATION + requested += Manifest.permission.ACCESS_COARSE_LOCATION + } if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO if (enableCamera) requested += Manifest.permission.CAMERA + if (enablePhotos) requested += photosPermission + if (enableContacts) { + requested += Manifest.permission.READ_CONTACTS + requested += Manifest.permission.WRITE_CONTACTS + } + if (enableCalendar) { + requested += Manifest.permission.READ_CALENDAR + requested += Manifest.permission.WRITE_CALENDAR + } + if (enableMotion && motionAvailable && motionPermissionRequired) { + requested += Manifest.permission.ACTIVITY_RECOGNITION + } if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS - requested.filterNot { isPermissionGranted(context, it) } + requested + .distinct() + .filterNot { isPermissionGranted(context, it) } } val enabledPermissionSummary = - remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) { + remember( + enableDiscovery, + enableLocation, + enableNotifications, + enableNotificationListener, + enableAppUpdates, + enableMicrophone, + enableCamera, + enablePhotos, + enableContacts, + enableCalendar, + enableMotion, + enableSms, + smsAvailable, + motionAvailable, + ) { val enabled = mutableListOf() if (enableDiscovery) enabled += "Gateway discovery" - if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications" + if (enableLocation) enabled += "Location" + if (enableNotifications) enabled += "Notifications" + if (enableNotificationListener) enabled += "Notification listener" + if (enableAppUpdates) enabled += "App updates" if (enableMicrophone) enabled += "Microphone" if (enableCamera) enabled += "Camera" + if (enablePhotos) enabled += "Photos" + if (enableContacts) enabled += "Contacts" + if (enableCalendar) enabled += "Calendar" + if (enableMotion && motionAvailable) enabled += "Motion" if (smsAvailable && enableSms) enabled += "SMS" if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") } + val proceedFromPermissions: () -> Unit = { + when { + enableNotificationListener && !isNotificationListenerEnabled(context) -> { + openNotificationListenerSettings(context) + } + enableAppUpdates && !canInstallUnknownApps(context) -> { + openUnknownAppSourcesSettings(context) + } + } + step = OnboardingStep.FinalCheck + } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - step = OnboardingStep.FinalCheck + proceedFromPermissions() } val qrScanLauncher = @@ -382,16 +466,32 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { OnboardingStep.Permissions -> PermissionsStep( enableDiscovery = enableDiscovery, + enableLocation = enableLocation, enableNotifications = enableNotifications, + enableNotificationListener = enableNotificationListener, + enableAppUpdates = enableAppUpdates, enableMicrophone = enableMicrophone, enableCamera = enableCamera, + enablePhotos = enablePhotos, + enableContacts = enableContacts, + enableCalendar = enableCalendar, + enableMotion = enableMotion, + motionAvailable = motionAvailable, + motionPermissionRequired = motionPermissionRequired, enableSms = enableSms, smsAvailable = smsAvailable, context = context, onDiscoveryChange = { enableDiscovery = it }, + onLocationChange = { enableLocation = it }, onNotificationsChange = { enableNotifications = it }, + onNotificationListenerChange = { enableNotificationListener = it }, + onAppUpdatesChange = { enableAppUpdates = it }, onMicrophoneChange = { enableMicrophone = it }, onCameraChange = { enableCamera = it }, + onPhotosChange = { enablePhotos = it }, + onContactsChange = { enableContacts = it }, + onCalendarChange = { enableCalendar = it }, + onMotionChange = { enableMotion = it }, onSmsChange = { enableSms = it }, ) OnboardingStep.FinalCheck -> @@ -504,9 +604,9 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { Button( onClick = { viewModel.setCameraEnabled(enableCamera) - viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off) + viewModel.setLocationMode(if (enableLocation) LocationMode.WhileUsing else LocationMode.Off) if (selectedPermissions.isEmpty()) { - step = OnboardingStep.FinalCheck + proceedFromPermissions() } else { permissionLauncher.launch(selectedPermissions.toTypedArray()) } @@ -1014,19 +1114,61 @@ private fun InlineDivider() { @Composable private fun PermissionsStep( enableDiscovery: Boolean, + enableLocation: Boolean, enableNotifications: Boolean, + enableNotificationListener: Boolean, + enableAppUpdates: Boolean, enableMicrophone: Boolean, enableCamera: Boolean, + enablePhotos: Boolean, + enableContacts: Boolean, + enableCalendar: Boolean, + enableMotion: Boolean, + motionAvailable: Boolean, + motionPermissionRequired: Boolean, enableSms: Boolean, smsAvailable: Boolean, context: Context, onDiscoveryChange: (Boolean) -> Unit, + onLocationChange: (Boolean) -> Unit, onNotificationsChange: (Boolean) -> Unit, + onNotificationListenerChange: (Boolean) -> Unit, + onAppUpdatesChange: (Boolean) -> Unit, onMicrophoneChange: (Boolean) -> Unit, onCameraChange: (Boolean) -> Unit, + onPhotosChange: (Boolean) -> Unit, + onContactsChange: (Boolean) -> Unit, + onCalendarChange: (Boolean) -> Unit, + onMotionChange: (Boolean) -> Unit, onSmsChange: (Boolean) -> Unit, ) { val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + val locationGranted = + isPermissionGranted(context, Manifest.permission.ACCESS_FINE_LOCATION) || + isPermissionGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION) + val photosPermission = + if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + val contactsGranted = + isPermissionGranted(context, Manifest.permission.READ_CONTACTS) && + isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS) + val calendarGranted = + isPermissionGranted(context, Manifest.permission.READ_CALENDAR) && + isPermissionGranted(context, Manifest.permission.WRITE_CALENDAR) + val motionGranted = + if (!motionAvailable) { + false + } else if (!motionPermissionRequired) { + true + } else { + isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION) + } + val notificationListenerGranted = isNotificationListenerEnabled(context) + val appUpdatesGranted = canInstallUnknownApps(context) + StepShell(title = "Permissions") { Text( "Enable only what you need now. You can change everything later in Settings.", @@ -1041,16 +1183,40 @@ private fun PermissionsStep( onCheckedChange = onDiscoveryChange, ) InlineDivider() + PermissionToggleRow( + title = "Location", + subtitle = "location.get (while app is open unless set to Always later)", + checked = enableLocation, + granted = locationGranted, + onCheckedChange = onLocationChange, + ) + InlineDivider() if (Build.VERSION.SDK_INT >= 33) { PermissionToggleRow( title = "Notifications", - subtitle = "Foreground service + alerts", + subtitle = "system.notify and foreground alerts", checked = enableNotifications, granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), onCheckedChange = onNotificationsChange, ) InlineDivider() } + PermissionToggleRow( + title = "Notification listener", + subtitle = "notifications.list and notifications.actions (opens Android Settings)", + checked = enableNotificationListener, + granted = notificationListenerGranted, + onCheckedChange = onNotificationListenerChange, + ) + InlineDivider() + PermissionToggleRow( + title = "App updates", + subtitle = "app.update install confirmation (opens Android Settings)", + checked = enableAppUpdates, + granted = appUpdatesGranted, + onCheckedChange = onAppUpdatesChange, + ) + InlineDivider() PermissionToggleRow( title = "Microphone", subtitle = "Voice tab transcription", @@ -1066,6 +1232,40 @@ private fun PermissionsStep( granted = isPermissionGranted(context, Manifest.permission.CAMERA), onCheckedChange = onCameraChange, ) + InlineDivider() + PermissionToggleRow( + title = "Photos", + subtitle = "photos.latest", + checked = enablePhotos, + granted = isPermissionGranted(context, photosPermission), + onCheckedChange = onPhotosChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Contacts", + subtitle = "contacts.search and contacts.add", + checked = enableContacts, + granted = contactsGranted, + onCheckedChange = onContactsChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Calendar", + subtitle = "calendar.events and calendar.add", + checked = enableCalendar, + granted = calendarGranted, + onCheckedChange = onCalendarChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Motion", + subtitle = "motion.activity and motion.pedometer", + checked = enableMotion, + granted = motionGranted, + onCheckedChange = onMotionChange, + enabled = motionAvailable, + statusOverride = if (!motionAvailable) "Unavailable on this device" else null, + ) if (smsAvailable) { InlineDivider() PermissionToggleRow( @@ -1086,6 +1286,8 @@ private fun PermissionToggleRow( subtitle: String, checked: Boolean, granted: Boolean, + enabled: Boolean = true, + statusOverride: String? = null, onCheckedChange: (Boolean) -> Unit, ) { Row( @@ -1097,7 +1299,7 @@ private fun PermissionToggleRow( Text(title, style = onboardingHeadlineStyle, color = onboardingText) Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) Text( - if (granted) "Granted" else "Not granted", + statusOverride ?: if (granted) "Granted" else "Not granted", style = onboardingCaption1Style, color = if (granted) onboardingSuccess else onboardingTextSecondary, ) @@ -1105,6 +1307,7 @@ private fun PermissionToggleRow( Switch( checked = checked, onCheckedChange = onCheckedChange, + enabled = enabled, colors = SwitchDefaults.colors( checkedTrackColor = onboardingAccent, @@ -1207,3 +1410,50 @@ private fun Bullet(text: String) { private fun isPermissionGranted(context: Context, permission: String): Boolean { return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } + +private fun isNotificationListenerEnabled(context: Context): Boolean { + return DeviceNotificationListenerService.isAccessEnabled(context) +} + +private fun canInstallUnknownApps(context: Context): Boolean { + if (Build.VERSION.SDK_INT < 26) return true + return context.packageManager.canRequestPackageInstalls() +} + +private fun openNotificationListenerSettings(context: Context) { + val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { + context.startActivity(intent) + }.getOrElse { + openAppSettings(context) + } +} + +private fun openUnknownAppSourcesSettings(context: Context) { + if (Build.VERSION.SDK_INT < 26) return + val intent = + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { + context.startActivity(intent) + }.getOrElse { + openAppSettings(context) + } +} + +private fun openAppSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) +} + +private fun hasMotionCapabilities(context: Context): Boolean { + val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false + return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || + sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index 6de3151a7f1..b20013f7d57 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -4,6 +4,8 @@ import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorManager import android.net.Uri import android.os.Build import android.provider.Settings @@ -66,6 +68,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import ai.openclaw.android.BuildConfig import ai.openclaw.android.LocationMode import ai.openclaw.android.MainViewModel +import ai.openclaw.android.node.DeviceNotificationListenerService @Composable fun SettingsSheet(viewModel: MainViewModel) { @@ -162,6 +165,91 @@ fun SettingsSheet(viewModel: MainViewModel) { remember { context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } + val photosPermission = + if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + val motionPermissionRequired = Build.VERSION.SDK_INT >= 29 + val motionAvailable = remember(context) { hasMotionCapabilities(context) } + + var notificationsPermissionGranted by + remember { + mutableStateOf(hasNotificationsPermission(context)) + } + val notificationsPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + notificationsPermissionGranted = granted + } + + var notificationListenerEnabled by + remember { + mutableStateOf(isNotificationListenerEnabled(context)) + } + + var photosPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, photosPermission) == + PackageManager.PERMISSION_GRANTED, + ) + } + val photosPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + photosPermissionGranted = granted + } + + var contactsPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == + PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == + PackageManager.PERMISSION_GRANTED, + ) + } + val contactsPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val readOk = perms[Manifest.permission.READ_CONTACTS] == true + val writeOk = perms[Manifest.permission.WRITE_CONTACTS] == true + contactsPermissionGranted = readOk && writeOk + } + + var calendarPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == + PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) == + PackageManager.PERMISSION_GRANTED, + ) + } + val calendarPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val readOk = perms[Manifest.permission.READ_CALENDAR] == true + val writeOk = perms[Manifest.permission.WRITE_CALENDAR] == true + calendarPermissionGranted = readOk && writeOk + } + + var motionPermissionGranted by + remember { + mutableStateOf( + !motionPermissionRequired || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == + PackageManager.PERMISSION_GRANTED, + ) + } + val motionPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + motionPermissionGranted = granted + } + + var appUpdateInstallEnabled by + remember { + mutableStateOf(canInstallUnknownApps(context)) + } + var smsPermissionGranted by remember { mutableStateOf( @@ -182,6 +270,26 @@ fun SettingsSheet(viewModel: MainViewModel) { micPermissionGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + notificationsPermissionGranted = hasNotificationsPermission(context) + notificationListenerEnabled = isNotificationListenerEnabled(context) + photosPermissionGranted = + ContextCompat.checkSelfPermission(context, photosPermission) == + PackageManager.PERMISSION_GRANTED + contactsPermissionGranted = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == + PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == + PackageManager.PERMISSION_GRANTED + calendarPermissionGranted = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == + PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) == + PackageManager.PERMISSION_GRANTED + motionPermissionGranted = + !motionPermissionRequired || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == + PackageManager.PERMISSION_GRANTED + appUpdateInstallEnabled = canInstallUnknownApps(context) smsPermissionGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED @@ -437,6 +545,254 @@ fun SettingsSheet(viewModel: MainViewModel) { item { HorizontalDivider(color = mobileBorder) } + // Notifications + item { + Text( + "NOTIFICATIONS", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + val buttonLabel = + if (notificationsPermissionGranted) { + "Manage" + } else { + "Grant" + } + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("System Notifications", style = mobileHeadline) }, + supportingContent = { + Text( + "Required for `system.notify` and Android foreground service alerts.", + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) { + openAppSettings(context) + } else { + notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) + } + }, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Notification Listener Access", style = mobileHeadline) }, + supportingContent = { + Text( + "Required for `notifications.list` and `notifications.actions`.", + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { openNotificationListenerSettings(context) }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (notificationListenerEnabled) "Manage" else "Enable", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + } + item { HorizontalDivider(color = mobileBorder) } + + // Data access + item { + Text( + "DATA ACCESS", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Photos Permission", style = mobileHeadline) }, + supportingContent = { + Text( + "Required for `photos.latest`.", + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (photosPermissionGranted) { + openAppSettings(context) + } else { + photosPermissionLauncher.launch(photosPermission) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (photosPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Contacts Permission", style = mobileHeadline) }, + supportingContent = { + Text( + "Required for `contacts.search` and `contacts.add`.", + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (contactsPermissionGranted) { + openAppSettings(context) + } else { + contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (contactsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Calendar Permission", style = mobileHeadline) }, + supportingContent = { + Text( + "Required for `calendar.events` and `calendar.add`.", + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (calendarPermissionGranted) { + openAppSettings(context) + } else { + calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (calendarPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + } + item { + val motionButtonLabel = + when { + !motionAvailable -> "Unavailable" + !motionPermissionRequired -> "Manage" + motionPermissionGranted -> "Manage" + else -> "Grant" + } + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Motion Permission", style = mobileHeadline) }, + supportingContent = { + Text( + if (!motionAvailable) { + "This device does not expose accelerometer or step-counter motion sensors." + } else { + "Required for `motion.activity` and `motion.pedometer`." + }, + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (!motionAvailable) return@Button + if (!motionPermissionRequired || motionPermissionGranted) { + openAppSettings(context) + } else { + motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION) + } + }, + enabled = motionAvailable, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) + } + }, + ) + } + item { HorizontalDivider(color = mobileBorder) } + + // System + item { + Text( + "SYSTEM", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Install App Updates", style = mobileHeadline) }, + supportingContent = { + Text( + "Enable install access for `app.update` package installs.", + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { openUnknownAppSourcesSettings(context) }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (appUpdateInstallEnabled) "Manage" else "Enable", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + } + item { HorizontalDivider(color = mobileBorder) } + // Location item { Text( @@ -603,3 +959,50 @@ private fun openAppSettings(context: Context) { ) context.startActivity(intent) } + +private fun openNotificationListenerSettings(context: Context) { + val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) + runCatching { + context.startActivity(intent) + }.getOrElse { + openAppSettings(context) + } +} + +private fun openUnknownAppSourcesSettings(context: Context) { + if (Build.VERSION.SDK_INT < 26) { + openAppSettings(context) + return + } + val intent = + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ) + runCatching { + context.startActivity(intent) + }.getOrElse { + openAppSettings(context) + } +} + +private fun hasNotificationsPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < 33) return true + return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED +} + +private fun isNotificationListenerEnabled(context: Context): Boolean { + return DeviceNotificationListenerService.isAccessEnabled(context) +} + +private fun canInstallUnknownApps(context: Context): Boolean { + if (Build.VERSION.SDK_INT < 26) return true + return context.packageManager.canRequestPackageInstalls() +} + +private fun hasMotionCapabilities(context: Context): Boolean { + val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false + return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || + sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt index d4cfe73b7ce..bd3dced03e5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt @@ -34,6 +34,9 @@ class InvokeCommandRegistryTest { assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue)) + assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue)) + assertTrue(capabilities.contains(OpenClawCapability.System.rawValue)) + assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue)) assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue)) assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue)) assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue)) @@ -62,6 +65,9 @@ class InvokeCommandRegistryTest { assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue)) + assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue)) + assertTrue(capabilities.contains(OpenClawCapability.System.rawValue)) + assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue)) assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue)) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt index 55230241c6a..cd1cf847101 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt @@ -29,6 +29,9 @@ class OpenClawProtocolConstantsTest { assertEquals("location", OpenClawCapability.Location.rawValue) assertEquals("sms", OpenClawCapability.Sms.rawValue) assertEquals("device", OpenClawCapability.Device.rawValue) + assertEquals("notifications", OpenClawCapability.Notifications.rawValue) + assertEquals("system", OpenClawCapability.System.rawValue) + assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue) assertEquals("photos", OpenClawCapability.Photos.rawValue) assertEquals("contacts", OpenClawCapability.Contacts.rawValue) assertEquals("calendar", OpenClawCapability.Calendar.rawValue)