diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 3d0b27f39e6..f5e20fd5a97 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -15,6 +15,15 @@
+
+
+
+
+
+
+
Boolean,
private val locationMode: () -> LocationMode,
private val voiceWakeMode: () -> VoiceWakeMode,
+ private val motionAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
@@ -78,6 +79,7 @@ class ConnectionManager(
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
+ motionAvailable = motionAvailable(),
debugBuild = BuildConfig.DEBUG,
)
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt
index 603713954e0..a091b6f211b 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt
@@ -130,6 +130,24 @@ class DeviceHandler(
private fun permissionsPayloadJson(): String {
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
+ val photosGranted =
+ if (Build.VERSION.SDK_INT >= 33) {
+ hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
+ } else {
+ hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ val motionGranted =
+ if (Build.VERSION.SDK_INT >= 29) {
+ hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
+ } else {
+ true
+ }
+ val notificationsGranted =
+ if (Build.VERSION.SDK_INT >= 33) {
+ hasPermission(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ true
+ }
return buildJsonObject {
put(
"permissions",
@@ -178,6 +196,41 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
+ put(
+ "notifications",
+ permissionStateJson(
+ granted = notificationsGranted,
+ promptableWhenDenied = true,
+ ),
+ )
+ put(
+ "photos",
+ permissionStateJson(
+ granted = photosGranted,
+ promptableWhenDenied = true,
+ ),
+ )
+ put(
+ "contacts",
+ permissionStateJson(
+ granted = hasPermission(Manifest.permission.READ_CONTACTS),
+ promptableWhenDenied = true,
+ ),
+ )
+ put(
+ "calendar",
+ permissionStateJson(
+ granted = hasPermission(Manifest.permission.READ_CALENDAR),
+ promptableWhenDenied = true,
+ ),
+ )
+ put(
+ "motion",
+ permissionStateJson(
+ granted = motionGranted,
+ promptableWhenDenied = Build.VERSION.SDK_INT >= 29,
+ ),
+ )
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
put(
"screenCapture",
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 757db106475..8d677a63b82 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
@@ -1,20 +1,26 @@
package ai.openclaw.android.node
+import ai.openclaw.android.protocol.OpenClawCalendarCommand
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawCapability
+import ai.openclaw.android.protocol.OpenClawContactsCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
+import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
+import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
+import ai.openclaw.android.protocol.OpenClawSystemCommand
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
val smsAvailable: Boolean,
val voiceWakeEnabled: Boolean,
+ val motionAvailable: Boolean,
val debugBuild: Boolean,
)
@@ -23,6 +29,7 @@ enum class InvokeCommandAvailability {
CameraEnabled,
LocationEnabled,
SmsAvailable,
+ MotionAvailable,
DebugBuild,
}
@@ -32,6 +39,7 @@ enum class NodeCapabilityAvailability {
LocationEnabled,
SmsAvailable,
VoiceWakeEnabled,
+ MotionAvailable,
}
data class NodeCapabilitySpec(
@@ -67,6 +75,13 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Location.rawValue,
availability = NodeCapabilityAvailability.LocationEnabled,
),
+ NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
+ NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
+ NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
+ NodeCapabilitySpec(
+ name = OpenClawCapability.Motion.rawValue,
+ availability = NodeCapabilityAvailability.MotionAvailable,
+ ),
)
val all: List =
@@ -107,6 +122,9 @@ object InvokeCommandRegistry {
name = OpenClawScreenCommand.Record.rawValue,
requiresForeground = true,
),
+ InvokeCommandSpec(
+ name = OpenClawSystemCommand.Notify.rawValue,
+ ),
InvokeCommandSpec(
name = OpenClawCameraCommand.List.rawValue,
requiresForeground = true,
@@ -144,6 +162,29 @@ object InvokeCommandRegistry {
InvokeCommandSpec(
name = OpenClawNotificationsCommand.Actions.rawValue,
),
+ InvokeCommandSpec(
+ name = OpenClawPhotosCommand.Latest.rawValue,
+ ),
+ InvokeCommandSpec(
+ name = OpenClawContactsCommand.Search.rawValue,
+ ),
+ InvokeCommandSpec(
+ name = OpenClawContactsCommand.Add.rawValue,
+ ),
+ InvokeCommandSpec(
+ name = OpenClawCalendarCommand.Events.rawValue,
+ ),
+ InvokeCommandSpec(
+ name = OpenClawCalendarCommand.Add.rawValue,
+ ),
+ InvokeCommandSpec(
+ name = OpenClawMotionCommand.Activity.rawValue,
+ availability = InvokeCommandAvailability.MotionAvailable,
+ ),
+ InvokeCommandSpec(
+ name = OpenClawMotionCommand.Pedometer.rawValue,
+ availability = InvokeCommandAvailability.MotionAvailable,
+ ),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
@@ -172,6 +213,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
+ NodeCapabilityAvailability.MotionAvailable -> flags.motionAvailable
}
}
.map { it.name }
@@ -185,6 +227,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
+ InvokeCommandAvailability.MotionAvailable -> flags.motionAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
}
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
index e843ce6ea20..70d0c8433d2 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
@@ -1,14 +1,19 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.GatewaySession
+import ai.openclaw.android.protocol.OpenClawCalendarCommand
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.android.protocol.OpenClawCanvasCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
+import ai.openclaw.android.protocol.OpenClawContactsCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
+import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
+import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
+import ai.openclaw.android.protocol.OpenClawSystemCommand
class InvokeDispatcher(
private val canvas: CanvasController,
@@ -16,6 +21,11 @@ class InvokeDispatcher(
private val locationHandler: LocationHandler,
private val deviceHandler: DeviceHandler,
private val notificationsHandler: NotificationsHandler,
+ private val systemHandler: SystemHandler,
+ private val photosHandler: PhotosHandler,
+ private val contactsHandler: ContactsHandler,
+ private val calendarHandler: CalendarHandler,
+ private val motionHandler: MotionHandler,
private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
@@ -24,6 +34,7 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
+ private val motionAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
@@ -130,6 +141,24 @@ class InvokeDispatcher(
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
OpenClawNotificationsCommand.Actions.rawValue -> notificationsHandler.handleNotificationsActions(paramsJson)
+ // System command
+ OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
+
+ // Photos command
+ OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
+
+ // Contacts command
+ OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
+ OpenClawContactsCommand.Add.rawValue -> contactsHandler.handleContactsAdd(paramsJson)
+
+ // Calendar command
+ OpenClawCalendarCommand.Events.rawValue -> calendarHandler.handleCalendarEvents(paramsJson)
+ OpenClawCalendarCommand.Add.rawValue -> calendarHandler.handleCalendarAdd(paramsJson)
+
+ // Motion command
+ OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
+ OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
+
// Screen command
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
@@ -212,6 +241,15 @@ class InvokeDispatcher(
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
+ InvokeCommandAvailability.MotionAvailable ->
+ if (motionAvailable()) {
+ null
+ } else {
+ GatewaySession.InvokeResult.error(
+ code = "MOTION_UNAVAILABLE",
+ message = "MOTION_UNAVAILABLE: motion sensors not available",
+ )
+ }
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
null
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 7e1a3d6538d..f5c14781d65 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,10 @@ enum class OpenClawCapability(val rawValue: String) {
VoiceWake("voiceWake"),
Location("location"),
Device("device"),
+ Photos("photos"),
+ Contacts("contacts"),
+ Calendar("calendar"),
+ Motion("motion"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@@ -93,3 +97,51 @@ enum class OpenClawNotificationsCommand(val rawValue: String) {
const val NamespacePrefix: String = "notifications."
}
}
+
+enum class OpenClawSystemCommand(val rawValue: String) {
+ Notify("system.notify"),
+ ;
+
+ companion object {
+ const val NamespacePrefix: String = "system."
+ }
+}
+
+enum class OpenClawPhotosCommand(val rawValue: String) {
+ Latest("photos.latest"),
+ ;
+
+ companion object {
+ const val NamespacePrefix: String = "photos."
+ }
+}
+
+enum class OpenClawContactsCommand(val rawValue: String) {
+ Search("contacts.search"),
+ Add("contacts.add"),
+ ;
+
+ companion object {
+ const val NamespacePrefix: String = "contacts."
+ }
+}
+
+enum class OpenClawCalendarCommand(val rawValue: String) {
+ Events("calendar.events"),
+ Add("calendar.add"),
+ ;
+
+ companion object {
+ const val NamespacePrefix: String = "calendar."
+ }
+}
+
+enum class OpenClawMotionCommand(val rawValue: String) {
+ Activity("motion.activity"),
+ Pedometer("motion.pedometer"),
+ ;
+
+ companion object {
+ const val NamespacePrefix: String = "motion."
+ }
+}
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt
index c3c97ee15cd..6232b0c9e11 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt
@@ -90,6 +90,11 @@ class DeviceHandlerTest {
"backgroundLocation",
"sms",
"notificationListener",
+ "notifications",
+ "photos",
+ "contacts",
+ "calendar",
+ "motion",
"screenCapture",
)
for (key in expected) {
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 39b47190e24..8749e936f0b 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
@@ -1,11 +1,16 @@
package ai.openclaw.android.node
+import ai.openclaw.android.protocol.OpenClawCalendarCommand
import ai.openclaw.android.protocol.OpenClawCameraCommand
import ai.openclaw.android.protocol.OpenClawCapability
+import ai.openclaw.android.protocol.OpenClawContactsCommand
import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
+import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
+import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
+import ai.openclaw.android.protocol.OpenClawSystemCommand
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -20,6 +25,7 @@ class InvokeCommandRegistryTest {
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
+ motionAvailable = false,
debugBuild = false,
),
)
@@ -31,6 +37,10 @@ class InvokeCommandRegistryTest {
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
+ assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
}
@Test
@@ -42,6 +52,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = true,
+ motionAvailable = true,
debugBuild = false,
),
)
@@ -53,6 +64,10 @@ class InvokeCommandRegistryTest {
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
+ assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
}
@Test
@@ -64,6 +79,7 @@ class InvokeCommandRegistryTest {
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
+ motionAvailable = false,
debugBuild = false,
),
)
@@ -78,6 +94,14 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
+ assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
+ assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
+ assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
+ assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
+ assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
+ assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
+ assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
+ assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(commands.contains("debug.logs"))
assertFalse(commands.contains("debug.ed25519"))
@@ -93,6 +117,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = false,
+ motionAvailable = true,
debugBuild = true,
),
)
@@ -107,6 +132,14 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
+ assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
+ assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
+ assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
+ assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
+ assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
+ assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
+ assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
+ assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(commands.contains("debug.logs"))
assertTrue(commands.contains("debug.ed25519"))
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 2bbf59193ee..55230241c6a 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,10 @@ class OpenClawProtocolConstantsTest {
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
assertEquals("device", OpenClawCapability.Device.rawValue)
+ assertEquals("photos", OpenClawCapability.Photos.rawValue)
+ assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
+ assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
+ assertEquals("motion", OpenClawCapability.Motion.rawValue)
}
@Test
@@ -56,4 +60,32 @@ class OpenClawProtocolConstantsTest {
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
}
+
+ @Test
+ fun systemCommandsUseStableStrings() {
+ assertEquals("system.notify", OpenClawSystemCommand.Notify.rawValue)
+ }
+
+ @Test
+ fun photosCommandsUseStableStrings() {
+ assertEquals("photos.latest", OpenClawPhotosCommand.Latest.rawValue)
+ }
+
+ @Test
+ fun contactsCommandsUseStableStrings() {
+ assertEquals("contacts.search", OpenClawContactsCommand.Search.rawValue)
+ assertEquals("contacts.add", OpenClawContactsCommand.Add.rawValue)
+ }
+
+ @Test
+ fun calendarCommandsUseStableStrings() {
+ assertEquals("calendar.events", OpenClawCalendarCommand.Events.rawValue)
+ assertEquals("calendar.add", OpenClawCalendarCommand.Add.rawValue)
+ }
+
+ @Test
+ fun motionCommandsUseStableStrings() {
+ assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
+ assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
+ }
}