diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 87b4725f2d2..6ad35f02dfe 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -140,7 +140,8 @@ class NodeRuntime(context: Context) { cameraEnabled = { cameraEnabled.value }, locationMode = { locationMode.value }, voiceWakeMode = { VoiceWakeMode.Off }, - motionAvailable = { motionHandler.isAvailable() }, + motionActivityAvailable = { motionHandler.isActivityAvailable() }, + motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, smsAvailable = { sms.canSendSms() }, hasRecordAudioPermission = { hasRecordAudioPermission() }, manualTls = { manualTls.value }, @@ -165,7 +166,6 @@ class NodeRuntime(context: Context) { isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, - motionAvailable = { motionHandler.isAvailable() }, smsAvailable = { sms.canSendSms() }, debugBuild = { BuildConfig.DEBUG }, refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() }, @@ -175,6 +175,8 @@ class NodeRuntime(context: Context) { _canvasRehydrateErrorText.value = null }, onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, + motionActivityAvailable = { motionHandler.isActivityAvailable() }, + motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, ) data class GatewayTrustPrompt( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index 76858a64d5e..021c5fe2ce6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -15,7 +15,8 @@ class ConnectionManager( private val cameraEnabled: () -> Boolean, private val locationMode: () -> LocationMode, private val voiceWakeMode: () -> VoiceWakeMode, - private val motionAvailable: () -> Boolean, + private val motionActivityAvailable: () -> Boolean, + private val motionPedometerAvailable: () -> Boolean, private val smsAvailable: () -> Boolean, private val hasRecordAudioPermission: () -> Boolean, private val manualTls: () -> Boolean, @@ -79,7 +80,8 @@ class ConnectionManager( locationEnabled = locationMode() != LocationMode.Off, smsAvailable = smsAvailable(), voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(), - motionAvailable = motionAvailable(), + motionActivityAvailable = motionActivityAvailable(), + motionPedometerAvailable = motionPedometerAvailable(), debugBuild = BuildConfig.DEBUG, ) 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 8d677a63b82..5e53a08a759 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 @@ -20,7 +20,8 @@ data class NodeRuntimeFlags( val locationEnabled: Boolean, val smsAvailable: Boolean, val voiceWakeEnabled: Boolean, - val motionAvailable: Boolean, + val motionActivityAvailable: Boolean, + val motionPedometerAvailable: Boolean, val debugBuild: Boolean, ) @@ -29,7 +30,8 @@ enum class InvokeCommandAvailability { CameraEnabled, LocationEnabled, SmsAvailable, - MotionAvailable, + MotionActivityAvailable, + MotionPedometerAvailable, DebugBuild, } @@ -179,11 +181,11 @@ object InvokeCommandRegistry { ), InvokeCommandSpec( name = OpenClawMotionCommand.Activity.rawValue, - availability = InvokeCommandAvailability.MotionAvailable, + availability = InvokeCommandAvailability.MotionActivityAvailable, ), InvokeCommandSpec( name = OpenClawMotionCommand.Pedometer.rawValue, - availability = InvokeCommandAvailability.MotionAvailable, + availability = InvokeCommandAvailability.MotionPedometerAvailable, ), InvokeCommandSpec( name = OpenClawSmsCommand.Send.rawValue, @@ -213,7 +215,7 @@ object InvokeCommandRegistry { NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled - NodeCapabilityAvailability.MotionAvailable -> flags.motionAvailable + NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable } } .map { it.name } @@ -227,7 +229,8 @@ object InvokeCommandRegistry { InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable - InvokeCommandAvailability.MotionAvailable -> flags.motionAvailable + InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable + InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable 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 70d0c8433d2..8e6552edfbb 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 @@ -34,12 +34,13 @@ 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, private val onCanvasA2uiPush: () -> Unit, private val onCanvasA2uiReset: () -> Unit, + private val motionActivityAvailable: () -> Boolean, + private val motionPedometerAvailable: () -> Boolean, ) { suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { val spec = @@ -241,13 +242,22 @@ class InvokeDispatcher( message = "LOCATION_DISABLED: enable Location in Settings", ) } - InvokeCommandAvailability.MotionAvailable -> - if (motionAvailable()) { + InvokeCommandAvailability.MotionActivityAvailable -> + if (motionActivityAvailable()) { null } else { GatewaySession.InvokeResult.error( code = "MOTION_UNAVAILABLE", - message = "MOTION_UNAVAILABLE: motion sensors not available", + message = "MOTION_UNAVAILABLE: accelerometer not available", + ) + } + InvokeCommandAvailability.MotionPedometerAvailable -> + if (motionPedometerAvailable()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "PEDOMETER_UNAVAILABLE", + message = "PEDOMETER_UNAVAILABLE: step counter not available", ) } InvokeCommandAvailability.SmsAvailable -> diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt index 9e3c92981b3..d385b35f182 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/MotionHandler.kt @@ -24,6 +24,9 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.sqrt +private const val ACCELEROMETER_SAMPLE_TARGET = 20 +private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L + internal data class MotionActivityRequest( val startISO: String?, val endISO: String?, @@ -57,7 +60,11 @@ internal data class PedometerRecord( ) internal interface MotionDataSource { - fun isAvailable(context: Context): Boolean + fun isActivityAvailable(context: Context): Boolean + + fun isPedometerAvailable(context: Context): Boolean + + fun isAvailable(context: Context): Boolean = isActivityAvailable(context) || isPedometerAvailable(context) fun hasPermission(context: Context): Boolean @@ -67,11 +74,14 @@ internal interface MotionDataSource { } private object SystemMotionDataSource : MotionDataSource { - override fun isAvailable(context: Context): Boolean { + override fun isActivityAvailable(context: Context): Boolean { val sensorManager = context.getSystemService(SensorManager::class.java) - val hasAccelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null - val hasStepCounter = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null - return hasAccelerometer || hasStepCounter + return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + } + + override fun isPedometerAvailable(context: Context): Boolean { + val sensorManager = context.getSystemService(SensorManager::class.java) + return sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null } override fun hasPermission(context: Context): Boolean { @@ -169,7 +179,7 @@ private object SystemMotionDataSource : MotionDataSource { sensor: Sensor, ): AccelerometerSample? { val sample = - withTimeoutOrNull(2200L) { + withTimeoutOrNull(ACCELEROMETER_SAMPLE_TIMEOUT_MS) { suspendCancellableCoroutine { cont -> var count = 0 var sumDelta = 0.0 @@ -187,7 +197,7 @@ private object SystemMotionDataSource : MotionDataSource { ).toDouble() sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble()) count += 1 - if (count >= 20 && !resumed) { + if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) { resumed = true sensorManager.unregisterListener(this) cont.resume( @@ -318,6 +328,10 @@ class MotionHandler private constructor( fun isAvailable(): Boolean = dataSource.isAvailable(appContext) + fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext) + + fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext) + private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? { if (paramsJson.isNullOrBlank()) { return MotionActivityRequest(startISO = null, endISO = null, limit = 200) 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 8749e936f0b..d4cfe73b7ce 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 @@ -25,7 +25,8 @@ class InvokeCommandRegistryTest { locationEnabled = false, smsAvailable = false, voiceWakeEnabled = false, - motionAvailable = false, + motionActivityAvailable = false, + motionPedometerAvailable = false, debugBuild = false, ), ) @@ -52,7 +53,8 @@ class InvokeCommandRegistryTest { locationEnabled = true, smsAvailable = true, voiceWakeEnabled = true, - motionAvailable = true, + motionActivityAvailable = true, + motionPedometerAvailable = true, debugBuild = false, ), ) @@ -79,7 +81,8 @@ class InvokeCommandRegistryTest { locationEnabled = false, smsAvailable = false, voiceWakeEnabled = false, - motionAvailable = false, + motionActivityAvailable = false, + motionPedometerAvailable = false, debugBuild = false, ), ) @@ -117,7 +120,8 @@ class InvokeCommandRegistryTest { locationEnabled = true, smsAvailable = true, voiceWakeEnabled = false, - motionAvailable = true, + motionActivityAvailable = true, + motionPedometerAvailable = true, debugBuild = true, ), ) @@ -145,4 +149,23 @@ class InvokeCommandRegistryTest { assertTrue(commands.contains("debug.ed25519")) assertTrue(commands.contains("app.update")) } + + @Test + fun advertisedCommands_onlyIncludesSupportedMotionCommands() { + val commands = + InvokeCommandRegistry.advertisedCommands( + NodeRuntimeFlags( + cameraEnabled = false, + locationEnabled = false, + smsAvailable = false, + voiceWakeEnabled = false, + motionActivityAvailable = true, + motionPedometerAvailable = false, + debugBuild = false, + ), + ) + + assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue)) + assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue)) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt index 7f5d1e2c1fe..1a0fb0c0bd6 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt @@ -92,7 +92,8 @@ class MotionHandlerTest { private class FakeMotionDataSource( private val hasPermission: Boolean, - private val available: Boolean = true, + private val activityAvailable: Boolean = true, + private val pedometerAvailable: Boolean = true, private val activityRecord: MotionActivityRecord = MotionActivityRecord( startISO = "2026-02-28T00:00:00Z", @@ -117,7 +118,9 @@ private class FakeMotionDataSource( private val activityError: Throwable? = null, private val pedometerError: Throwable? = null, ) : MotionDataSource { - override fun isAvailable(context: Context): Boolean = available + override fun isActivityAvailable(context: Context): Boolean = activityAvailable + + override fun isPedometerAvailable(context: Context): Boolean = pedometerAvailable override fun hasPermission(context: Context): Boolean = hasPermission