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 },
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(

View File

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

View File

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

View File

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

View File

@@ -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<AccelerometerSample?> { 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)

View File

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

View File

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