fix(android): add missing capability setup surfaces

This commit is contained in:
Ayaan Zaidi
2026-02-28 11:48:13 +05:30
committed by Ayaan Zaidi
parent 3899c89805
commit cd61edb0f3
6 changed files with 676 additions and 8 deletions

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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<String>()
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<String>()
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
}

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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)