fix(android): stabilize motion sampling and gate pedometer command

This commit is contained in:
Ayaan Zaidi
2026-02-28 09:24:23 +05:30
committed by Ayaan Zaidi
parent 18e7938dfd
commit 1bc9da8f9e
7 changed files with 84 additions and 27 deletions

View File

@@ -140,7 +140,8 @@ class NodeRuntime(context: Context) {
cameraEnabled = { cameraEnabled.value }, cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value }, locationMode = { locationMode.value },
voiceWakeMode = { VoiceWakeMode.Off }, voiceWakeMode = { VoiceWakeMode.Off },
motionAvailable = { motionHandler.isAvailable() }, motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
smsAvailable = { sms.canSendSms() }, smsAvailable = { sms.canSendSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() }, hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value }, manualTls = { manualTls.value },
@@ -165,7 +166,6 @@ class NodeRuntime(context: Context) {
isForeground = { _isForeground.value }, isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value }, cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off }, locationEnabled = { locationMode.value != LocationMode.Off },
motionAvailable = { motionHandler.isAvailable() },
smsAvailable = { sms.canSendSms() }, smsAvailable = { sms.canSendSms() },
debugBuild = { BuildConfig.DEBUG }, debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() }, refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
@@ -175,6 +175,8 @@ class NodeRuntime(context: Context) {
_canvasRehydrateErrorText.value = null _canvasRehydrateErrorText.value = null
}, },
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
) )
data class GatewayTrustPrompt( data class GatewayTrustPrompt(

View File

@@ -15,7 +15,8 @@ class ConnectionManager(
private val cameraEnabled: () -> Boolean, private val cameraEnabled: () -> Boolean,
private val locationMode: () -> LocationMode, private val locationMode: () -> LocationMode,
private val voiceWakeMode: () -> VoiceWakeMode, private val voiceWakeMode: () -> VoiceWakeMode,
private val motionAvailable: () -> Boolean, private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean, private val smsAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean, private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean, private val manualTls: () -> Boolean,
@@ -79,7 +80,8 @@ class ConnectionManager(
locationEnabled = locationMode() != LocationMode.Off, locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(), smsAvailable = smsAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(), voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionAvailable = motionAvailable(), motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),
debugBuild = BuildConfig.DEBUG, debugBuild = BuildConfig.DEBUG,
) )

View File

@@ -20,7 +20,8 @@ data class NodeRuntimeFlags(
val locationEnabled: Boolean, val locationEnabled: Boolean,
val smsAvailable: Boolean, val smsAvailable: Boolean,
val voiceWakeEnabled: Boolean, val voiceWakeEnabled: Boolean,
val motionAvailable: Boolean, val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
val debugBuild: Boolean, val debugBuild: Boolean,
) )
@@ -29,7 +30,8 @@ enum class InvokeCommandAvailability {
CameraEnabled, CameraEnabled,
LocationEnabled, LocationEnabled,
SmsAvailable, SmsAvailable,
MotionAvailable, MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild, DebugBuild,
} }
@@ -179,11 +181,11 @@ object InvokeCommandRegistry {
), ),
InvokeCommandSpec( InvokeCommandSpec(
name = OpenClawMotionCommand.Activity.rawValue, name = OpenClawMotionCommand.Activity.rawValue,
availability = InvokeCommandAvailability.MotionAvailable, availability = InvokeCommandAvailability.MotionActivityAvailable,
), ),
InvokeCommandSpec( InvokeCommandSpec(
name = OpenClawMotionCommand.Pedometer.rawValue, name = OpenClawMotionCommand.Pedometer.rawValue,
availability = InvokeCommandAvailability.MotionAvailable, availability = InvokeCommandAvailability.MotionPedometerAvailable,
), ),
InvokeCommandSpec( InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue, name = OpenClawSmsCommand.Send.rawValue,
@@ -213,7 +215,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionAvailable NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
} }
} }
.map { it.name } .map { it.name }
@@ -227,7 +229,8 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
InvokeCommandAvailability.MotionAvailable -> flags.motionAvailable InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild InvokeCommandAvailability.DebugBuild -> flags.debugBuild
} }
} }

View File

@@ -34,12 +34,13 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean, private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean, private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean, private val locationEnabled: () -> Boolean,
private val motionAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean, private val smsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean, private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean, private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit, private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit, private val onCanvasA2uiReset: () -> Unit,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
) { ) {
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
val spec = val spec =
@@ -241,13 +242,22 @@ class InvokeDispatcher(
message = "LOCATION_DISABLED: enable Location in Settings", message = "LOCATION_DISABLED: enable Location in Settings",
) )
} }
InvokeCommandAvailability.MotionAvailable -> InvokeCommandAvailability.MotionActivityAvailable ->
if (motionAvailable()) { if (motionActivityAvailable()) {
null null
} else { } else {
GatewaySession.InvokeResult.error( GatewaySession.InvokeResult.error(
code = "MOTION_UNAVAILABLE", 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 -> InvokeCommandAvailability.SmsAvailable ->

View File

@@ -24,6 +24,9 @@ import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.sqrt import kotlin.math.sqrt
private const val ACCELEROMETER_SAMPLE_TARGET = 20
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
internal data class MotionActivityRequest( internal data class MotionActivityRequest(
val startISO: String?, val startISO: String?,
val endISO: String?, val endISO: String?,
@@ -57,7 +60,11 @@ internal data class PedometerRecord(
) )
internal interface MotionDataSource { 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 fun hasPermission(context: Context): Boolean
@@ -67,11 +74,14 @@ internal interface MotionDataSource {
} }
private object SystemMotionDataSource : 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 sensorManager = context.getSystemService(SensorManager::class.java)
val hasAccelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
val hasStepCounter = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null }
return hasAccelerometer || hasStepCounter
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 { override fun hasPermission(context: Context): Boolean {
@@ -169,7 +179,7 @@ private object SystemMotionDataSource : MotionDataSource {
sensor: Sensor, sensor: Sensor,
): AccelerometerSample? { ): AccelerometerSample? {
val sample = val sample =
withTimeoutOrNull(2200L) { withTimeoutOrNull(ACCELEROMETER_SAMPLE_TIMEOUT_MS) {
suspendCancellableCoroutine<AccelerometerSample?> { cont -> suspendCancellableCoroutine<AccelerometerSample?> { cont ->
var count = 0 var count = 0
var sumDelta = 0.0 var sumDelta = 0.0
@@ -187,7 +197,7 @@ private object SystemMotionDataSource : MotionDataSource {
).toDouble() ).toDouble()
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble()) sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1 count += 1
if (count >= 20 && !resumed) { if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
resumed = true resumed = true
sensorManager.unregisterListener(this) sensorManager.unregisterListener(this)
cont.resume( cont.resume(
@@ -318,6 +328,10 @@ class MotionHandler private constructor(
fun isAvailable(): Boolean = dataSource.isAvailable(appContext) fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? { private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
if (paramsJson.isNullOrBlank()) { if (paramsJson.isNullOrBlank()) {
return MotionActivityRequest(startISO = null, endISO = null, limit = 200) return MotionActivityRequest(startISO = null, endISO = null, limit = 200)

View File

@@ -25,7 +25,8 @@ class InvokeCommandRegistryTest {
locationEnabled = false, locationEnabled = false,
smsAvailable = false, smsAvailable = false,
voiceWakeEnabled = false, voiceWakeEnabled = false,
motionAvailable = false, motionActivityAvailable = false,
motionPedometerAvailable = false,
debugBuild = false, debugBuild = false,
), ),
) )
@@ -52,7 +53,8 @@ class InvokeCommandRegistryTest {
locationEnabled = true, locationEnabled = true,
smsAvailable = true, smsAvailable = true,
voiceWakeEnabled = true, voiceWakeEnabled = true,
motionAvailable = true, motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = false, debugBuild = false,
), ),
) )
@@ -79,7 +81,8 @@ class InvokeCommandRegistryTest {
locationEnabled = false, locationEnabled = false,
smsAvailable = false, smsAvailable = false,
voiceWakeEnabled = false, voiceWakeEnabled = false,
motionAvailable = false, motionActivityAvailable = false,
motionPedometerAvailable = false,
debugBuild = false, debugBuild = false,
), ),
) )
@@ -117,7 +120,8 @@ class InvokeCommandRegistryTest {
locationEnabled = true, locationEnabled = true,
smsAvailable = true, smsAvailable = true,
voiceWakeEnabled = false, voiceWakeEnabled = false,
motionAvailable = true, motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true, debugBuild = true,
), ),
) )
@@ -145,4 +149,23 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains("debug.ed25519")) assertTrue(commands.contains("debug.ed25519"))
assertTrue(commands.contains("app.update")) 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))
}
} }

View File

@@ -92,7 +92,8 @@ class MotionHandlerTest {
private class FakeMotionDataSource( private class FakeMotionDataSource(
private val hasPermission: Boolean, private val hasPermission: Boolean,
private val available: Boolean = true, private val activityAvailable: Boolean = true,
private val pedometerAvailable: Boolean = true,
private val activityRecord: MotionActivityRecord = private val activityRecord: MotionActivityRecord =
MotionActivityRecord( MotionActivityRecord(
startISO = "2026-02-28T00:00:00Z", startISO = "2026-02-28T00:00:00Z",
@@ -117,7 +118,9 @@ private class FakeMotionDataSource(
private val activityError: Throwable? = null, private val activityError: Throwable? = null,
private val pedometerError: Throwable? = null, private val pedometerError: Throwable? = null,
) : MotionDataSource { ) : 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 override fun hasPermission(context: Context): Boolean = hasPermission