mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(android): add motion activity and pedometer handlers
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal data class MotionActivityRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class MotionPedometerRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
)
|
||||
|
||||
internal data class MotionActivityRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val confidence: String,
|
||||
val isWalking: Boolean,
|
||||
val isRunning: Boolean,
|
||||
val isCycling: Boolean,
|
||||
val isAutomotive: Boolean,
|
||||
val isStationary: Boolean,
|
||||
val isUnknown: Boolean,
|
||||
)
|
||||
|
||||
internal data class PedometerRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val steps: Int?,
|
||||
val distanceMeters: Double?,
|
||||
val floorsAscended: Int?,
|
||||
val floorsDescended: Int?,
|
||||
)
|
||||
|
||||
internal interface MotionDataSource {
|
||||
fun isAvailable(context: Context): Boolean
|
||||
|
||||
fun hasPermission(context: Context): Boolean
|
||||
|
||||
suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord
|
||||
|
||||
suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord
|
||||
}
|
||||
|
||||
private object SystemMotionDataSource : MotionDataSource {
|
||||
override fun isAvailable(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
|
||||
}
|
||||
|
||||
override fun hasPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 29) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
|
||||
}
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
|
||||
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
|
||||
|
||||
val sample = readAccelerometerSample(sensorManager, accelerometer)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
|
||||
val end = Instant.now()
|
||||
val start = end.minusSeconds(2)
|
||||
val classification = classifyActivity(sample.averageDelta)
|
||||
return MotionActivityRecord(
|
||||
startISO = start.toString(),
|
||||
endISO = end.toString(),
|
||||
confidence = classifyConfidence(sample.samples, sample.averageDelta),
|
||||
isWalking = classification == "walking",
|
||||
isRunning = classification == "running",
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = classification == "stationary",
|
||||
isUnknown = classification == "unknown",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
|
||||
}
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
|
||||
val stepCounter = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
|
||||
|
||||
val steps = readStepCounter(sensorManager, stepCounter)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
|
||||
val bootMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||
return PedometerRecord(
|
||||
startISO = Instant.ofEpochMilli(max(0L, bootMs)).toString(),
|
||||
endISO = Instant.now().toString(),
|
||||
steps = steps,
|
||||
distanceMeters = null,
|
||||
floorsAscended = null,
|
||||
floorsDescended = null,
|
||||
)
|
||||
}
|
||||
|
||||
private data class AccelerometerSample(
|
||||
val samples: Int,
|
||||
val averageDelta: Double,
|
||||
)
|
||||
|
||||
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
|
||||
val sample =
|
||||
withTimeoutOrNull(1200L) {
|
||||
suspendCancellableCoroutine<Float?> { cont ->
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (resumed) return
|
||||
val value = event?.values?.firstOrNull()
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(value)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
sensorManager.unregisterListener(listener)
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
return sample?.toInt()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
private suspend fun readAccelerometerSample(
|
||||
sensorManager: SensorManager,
|
||||
sensor: Sensor,
|
||||
): AccelerometerSample? {
|
||||
val sample =
|
||||
withTimeoutOrNull(2200L) {
|
||||
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
|
||||
var count = 0
|
||||
var sumDelta = 0.0
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
val values = event?.values ?: return
|
||||
if (values.size < 3) return
|
||||
val magnitude =
|
||||
sqrt(
|
||||
values[0] * values[0] +
|
||||
values[1] * values[1] +
|
||||
values[2] * values[2],
|
||||
).toDouble()
|
||||
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
|
||||
count += 1
|
||||
if (count >= 20 && !resumed) {
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(
|
||||
AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = if (count == 0) 0.0 else sumDelta / count,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun classifyActivity(averageDelta: Double): String {
|
||||
return when {
|
||||
averageDelta <= 0.55 -> "stationary"
|
||||
averageDelta <= 1.80 -> "walking"
|
||||
averageDelta > 1.80 -> "running"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyConfidence(samples: Int, averageDelta: Double): String {
|
||||
if (samples < 6) return "low"
|
||||
if (samples >= 14 && averageDelta > 0.4) return "high"
|
||||
return "medium"
|
||||
}
|
||||
}
|
||||
|
||||
class MotionHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: MotionDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
|
||||
|
||||
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseActivityRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val activity = dataSource.activity(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"activities",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("startISO", JsonPrimitive(activity.startISO))
|
||||
put("endISO", JsonPrimitive(activity.endISO))
|
||||
put("confidence", JsonPrimitive(activity.confidence))
|
||||
put("isWalking", JsonPrimitive(activity.isWalking))
|
||||
put("isRunning", JsonPrimitive(activity.isRunning))
|
||||
put("isCycling", JsonPrimitive(activity.isCycling))
|
||||
put("isAutomotive", JsonPrimitive(activity.isAutomotive))
|
||||
put("isStationary", JsonPrimitive(activity.isStationary))
|
||||
put("isUnknown", JsonPrimitive(activity.isUnknown))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: ${err.message ?: "motion activity failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parsePedometerRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val payload = dataSource.pedometer(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("startISO", JsonPrimitive(payload.startISO))
|
||||
put("endISO", JsonPrimitive(payload.endISO))
|
||||
payload.steps?.let { put("steps", JsonPrimitive(it)) }
|
||||
payload.distanceMeters?.let { put("distanceMeters", JsonPrimitive(it)) }
|
||||
payload.floorsAscended?.let { put("floorsAscended", JsonPrimitive(it)) }
|
||||
payload.floorsDescended?.let { put("floorsDescended", JsonPrimitive(it)) }
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: ${err.message ?: "pedometer query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
|
||||
|
||||
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return MotionActivityRequest(startISO = null, endISO = null, limit = 200)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
|
||||
return MotionActivityRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePedometerRequest(paramsJson: String?): MotionPedometerRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return MotionPedometerRequest(startISO = null, endISO = null)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
return MotionPedometerRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
|
||||
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: MotionDataSource,
|
||||
): MotionHandler = MotionHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MotionHandlerTest {
|
||||
@Test
|
||||
fun handleMotionActivity_requiresPermission() =
|
||||
runTest {
|
||||
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = false))
|
||||
|
||||
val result = handler.handleMotionActivity(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("MOTION_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionActivity_rejectsInvalidJson() =
|
||||
runTest {
|
||||
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = true))
|
||||
|
||||
val result = handler.handleMotionActivity("[]")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionActivity_returnsActivityPayload() =
|
||||
runTest {
|
||||
val activity =
|
||||
MotionActivityRecord(
|
||||
startISO = "2026-02-28T10:00:00Z",
|
||||
endISO = "2026-02-28T10:00:02Z",
|
||||
confidence = "high",
|
||||
isWalking = true,
|
||||
isRunning = false,
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = false,
|
||||
isUnknown = false,
|
||||
)
|
||||
val handler =
|
||||
MotionHandler.forTesting(
|
||||
appContext(),
|
||||
FakeMotionDataSource(hasPermission = true, activityRecord = activity),
|
||||
)
|
||||
|
||||
val result = handler.handleMotionActivity(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val activities = payload.getValue("activities").jsonArray
|
||||
assertEquals(1, activities.size)
|
||||
assertEquals("high", activities.first().jsonObject.getValue("confidence").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionPedometer_mapsRangeUnsupportedError() =
|
||||
runTest {
|
||||
val handler =
|
||||
MotionHandler.forTesting(
|
||||
appContext(),
|
||||
FakeMotionDataSource(
|
||||
hasPermission = true,
|
||||
pedometerError = IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: not supported"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = handler.handleMotionPedometer("""{"startISO":"2026-02-01T00:00:00Z"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
|
||||
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeMotionDataSource(
|
||||
private val hasPermission: Boolean,
|
||||
private val available: Boolean = true,
|
||||
private val activityRecord: MotionActivityRecord =
|
||||
MotionActivityRecord(
|
||||
startISO = "2026-02-28T00:00:00Z",
|
||||
endISO = "2026-02-28T00:00:02Z",
|
||||
confidence = "medium",
|
||||
isWalking = false,
|
||||
isRunning = false,
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = true,
|
||||
isUnknown = false,
|
||||
),
|
||||
private val pedometerRecord: PedometerRecord =
|
||||
PedometerRecord(
|
||||
startISO = "2026-02-28T00:00:00Z",
|
||||
endISO = "2026-02-28T01:00:00Z",
|
||||
steps = 1234,
|
||||
distanceMeters = null,
|
||||
floorsAscended = null,
|
||||
floorsDescended = null,
|
||||
),
|
||||
private val activityError: Throwable? = null,
|
||||
private val pedometerError: Throwable? = null,
|
||||
) : MotionDataSource {
|
||||
override fun isAvailable(context: Context): Boolean = available
|
||||
|
||||
override fun hasPermission(context: Context): Boolean = hasPermission
|
||||
|
||||
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
|
||||
activityError?.let { throw it }
|
||||
return activityRecord
|
||||
}
|
||||
|
||||
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
|
||||
pedometerError?.let { throw it }
|
||||
return pedometerRecord
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user