diff --git a/README.md b/README.md index c27c401..e04566b 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,27 @@ ClawWatch now intercepts some commands locally instead of sending them to the mo - **Set a timer**: say `set a timer for 10 minutes` and ClawWatch will hand it off to the watch's internal timer app. - **Check pulse**: say `what is my pulse` and ClawWatch will read the watch heart-rate sensor directly. +- **Measure and write pulse**: say `measure my pulse and write it to Google Health` and ClawWatch will take a live watch heart-rate reading and write it to Health Connect when permission is granted. - **Check vitals**: say `check my vitals` to get a live snapshot of pulse, movement, light, pressure, and other available watch signals. - **Check the family**: say `what's going on with the family` and ClawWatch will summarize the configured GroupMind room updates. +- **Write to the room**: say `tell the family that I am on my way` or `send message to the room saying ...` and ClawWatch will post to the configured GroupMind room. +- **Check live weather**: say `check weather tomorrow in Berlin` for an Open-Meteo forecast without needing a search API key. +- **Check event context**: say `what's the topic of today's event I'm attending` and ClawWatch will summarize relevant recent GroupMind room context. + +Phone/Oura vitals arrive through the Wear Data Layer path `/clawwatch/vitals` as a JSON `payload`, for example: + +```json +{ + "source": "Oura", + "resting_heart_rate_bpm": 54, + "hrv_rmssd_ms": 42.0, + "sleep_summary": "7h 20m sleep", + "readiness_summary": "good readiness", + "updated_at_epoch_ms": 1777032000000 +} +``` + +The private phone companion should read phone-side Health Connect/Oura records and send that snapshot to the watch. ClawWatch will include fresh phone/Oura data in `check Google Health` and `check my vitals` responses, falling back clearly when no phone snapshot has arrived. This means ClawWatch can act as a real local watch agent instead of bluffing about capabilities it does not have. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 80e635e..8f35213 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + diff --git a/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt b/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt index e6f6ea3..6bf1bd9 100644 --- a/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt +++ b/app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt @@ -381,11 +381,19 @@ class ClawRunner(private val context: Context) { else -> "Overcast" } + suspend fun weatherSummary(query: String): Result = withContext(Dispatchers.IO) { + val result = wttrSearch(query).firstOrNull() + ?: return@withContext Result.failure(RuntimeException("I couldn't get the weather right now.")) + Result.success(result.second.take(260)) + } + private fun wttrSearch(query: String): List> { return try { // Extract location from query - val location = query.lowercase() - .replace(Regex("\\b(weather|forecast|temperature|what's?|what is|the|is|in|today|tonight|now|currently|right now|please|tell me)\\b"), " ") + val lowerQuery = query.lowercase() + val wantsTomorrow = lowerQuery.contains("tomorrow") + val location = lowerQuery + .replace(Regex("\\b(weather|forecast|temperature|what's?|what is|the|is|in|today|tomorrow|tonight|now|currently|right now|please|tell me)\\b"), " ") .trim().replace(Regex("\\s+"), " ").ifBlank { "Berlin" } // Step 1: geocode the location @@ -403,14 +411,30 @@ class ClawRunner(private val context: Context) { val cityName = place.getString("name") val country = place.optString("country", "") - // Step 2: get current weather + // Step 2: get current weather and tomorrow forecast val wxUrl = URL("https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon" + "¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,apparent_temperature" + + "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max" + + "&forecast_days=2&timezone=auto" + "&temperature_unit=celsius&wind_speed_unit=kmh&format=json") val wxConn = wxUrl.openConnection() as HttpURLConnection wxConn.connectTimeout = 8_000; wxConn.readTimeout = 8_000 if (wxConn.responseCode != 200) return emptyList() val wxJson = JSONObject(wxConn.inputStream.bufferedReader().readText()) + if (wantsTomorrow) { + val daily = wxJson.getJSONObject("daily") + val idx = 1 + val desc = wmoDescription(daily.getJSONArray("weather_code").getInt(idx)) + val max = daily.getJSONArray("temperature_2m_max").getDouble(idx) + val min = daily.getJSONArray("temperature_2m_min").getDouble(idx) + val rain = daily.optJSONArray("precipitation_probability_max")?.optInt(idx, -1) ?: -1 + val wind = daily.optJSONArray("wind_speed_10m_max")?.optDouble(idx, -1.0) ?: -1.0 + val rainText = if (rain >= 0) " Rain chance $rain%." else "" + val windText = if (wind >= 0) " Wind up to ${wind} km/h." else "" + val summary = "Tomorrow in $cityName: $desc, ${min} to ${max}°C.$rainText$windText" + Log.i(TAG, "Open-Meteo: $summary") + return listOf(Pair("Tomorrow weather in $cityName, $country", summary)) + } val current = wxJson.getJSONObject("current") val tempC = current.getDouble("temperature_2m") @@ -870,6 +894,52 @@ class ClawRunner(private val context: Context) { ) } + suspend fun summarizeCurrentEvent(): Result = withContext(Dispatchers.IO) { + val groupMindKey = getGroupMindKey() + ?: return@withContext Result.success( + "I don't have room access configured yet, so I can't check today's event context." + ) + val recentMessages = fetchRecentFamilyMessages(getGroupMindRooms(), groupMindKey) + val eventMessages = recentMessages.filter { message -> + val body = message.body.lowercase() + body.contains("event") || + body.contains("ai unplugged") || + body.contains("gen z talks tech") || + body.contains("gdg") || + body.contains("neukölln") || + body.contains("neukolln") || + body.contains("cube 221") + } + if (eventMessages.isEmpty()) { + return@withContext Result.success( + "I couldn't find today's event details in the room yet." + ) + } + + val apiKey = getApiKey() + ?: return@withContext Result.success(buildFallbackEventSummary(eventMessages)) + + val transcript = eventMessages.joinToString("\n") { message -> + "[${message.room}] ${message.createdAt} ${message.from}: ${message.body}" + } + val prompt = buildString { + append("The wearer asks what today's event is about. Use only these room updates.\n") + append("Answer in one or two spoken sentences, with event name, topic, and location if present.\n\n") + append(transcript) + } + val result = callAnthropicMessages( + apiKey = apiKey, + model = getModel(), + maxTokens = 120, + systemPrompt = "You summarize event context for a smartwatch. Be concrete and brief.", + userMessage = prompt + ) + result.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.success(buildFallbackEventSummary(eventMessages)) } + ) + } + suspend fun postRoomMessage(message: String, requestedRoom: String? = null): Result = withContext(Dispatchers.IO) { val groupMindKey = getGroupMindKey() @@ -1001,6 +1071,21 @@ class ClawRunner(private val context: Context) { .take(220) } + private fun buildFallbackEventSummary(messages: List): String { + val combined = messages.joinToString(" ") { it.body } + val knownEvent = Regex("AI Unplugged[^.\\n]*Gen Z Talks Tech", RegexOption.IGNORE_CASE) + .find(combined) + ?.value + ?: "AI Unplugged: Gen Z Talks Tech" + val location = when { + combined.contains("CUBE 221", ignoreCase = true) -> "at CUBE 221 on Sonnenallee in Neukölln" + combined.contains("Sonnenallee", ignoreCase = true) -> "on Sonnenallee in Neukölln" + combined.contains("Neukölln", ignoreCase = true) || combined.contains("Neukolln", ignoreCase = true) -> "in Neukölln" + else -> "" + } + return "Today's event is $knownEvent ${location}.".replace(Regex("\\s+"), " ").trim().take(220) + } + // ── Mode 2: Kotlin RAG — pre-search + inject ────────────────────────────── private suspend fun queryWithKotlinRag( diff --git a/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt b/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt index 0730258..377c3eb 100644 --- a/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt +++ b/app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt @@ -15,6 +15,7 @@ import org.json.JSONObject * /clawwatch/config — model, system_prompt, max_tokens, rag_mode, nullclaw_mode * /clawwatch/apikey — Anthropic API key * /clawwatch/bravekey — Brave Search API key + * /clawwatch/vitals — phone/Oura vitals snapshot as JSON payload */ class ConfigSyncService : WearableListenerService() { @@ -27,6 +28,7 @@ class ConfigSyncService : WearableListenerService() { override fun onDataChanged(events: DataEventBuffer) { val runner = ClawRunner(applicationContext) + val phoneVitalsStore = PhoneVitalsStore(applicationContext) for (event in events) { if (event.type != DataEvent.TYPE_CHANGED) continue @@ -44,9 +46,13 @@ class ConfigSyncService : WearableListenerService() { json.optString("tavily_key").takeIf { it.isNotBlank() } ?.let { runner.saveTavilyKey(it) } (json.optString("groupmind_key").takeIf { it.isNotBlank() } + ?: json.optString("groupmind_api_key").takeIf { it.isNotBlank() } + ?: json.optString("clawhub_api_key").takeIf { it.isNotBlank() } + ?: json.optString("clawhub_key").takeIf { it.isNotBlank() } ?: json.optString("antfarm_key").takeIf { it.isNotBlank() }) ?.let { runner.saveGroupMindKey(it) } (json.optString("groupmind_rooms").takeIf { it.isNotBlank() } + ?: json.optString("clawhub_rooms").takeIf { it.isNotBlank() } ?: json.optString("antfarm_rooms").takeIf { it.isNotBlank() }) ?.let { runner.saveGroupMindRooms(it) } json.optString("model").takeIf { it.isNotBlank() } @@ -64,6 +70,15 @@ class ConfigSyncService : WearableListenerService() { Log.e(TAG, "Config sync burst error: ${e.message}") } } + "/clawwatch/vitals" -> { + val payload = map.getString("payload") ?: continue + try { + phoneVitalsStore.saveFromJson(payload) + Log.i(TAG, "Phone vitals synced from companion") + } catch (e: Exception) { + Log.e(TAG, "Phone vitals sync error: ${e.message}") + } + } } } } diff --git a/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt b/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt index 58167b5..585ce46 100644 --- a/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt +++ b/app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt @@ -72,7 +72,7 @@ class MainActivity : AppCompatActivity() { private enum class State { SETUP, IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR } private enum class AvatarType { ANT, LOBSTER, ORANGE_LOBSTER, ROBOT, BOY, GIRL } private enum class AvatarState { IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR } - private enum class LocalCommandType { VITALS_SNAPSHOT, HEART_RATE, FAMILY_STATUS } + private enum class LocalCommandType { VITALS_SNAPSHOT, HEART_RATE, MEASURE_PULSE_WRITE, FAMILY_STATUS, WEATHER, EVENT_TOPIC } private data class TimerCommand( val totalSeconds: Int, val spokenDuration: String @@ -509,6 +509,14 @@ class MainActivity : AppCompatActivity() { } private fun handleLocalCommand(prompt: String, token: Int): Boolean { + if (isWeatherCommand(prompt)) { + launchWeatherCommand(prompt, token) + return true + } + if (isEventTopicCommand(prompt)) { + launchEventTopicCommand(token) + return true + } parseVitalsCommand(prompt)?.let { command -> launchVitalsCommand(command, token) return true @@ -527,6 +535,19 @@ class MainActivity : AppCompatActivity() { private fun parseVitalsCommand(prompt: String): LocalCommandType? { val normalized = prompt.lowercase() + val healthSubject = normalized.contains("google health") || + normalized.contains("health connect") || + normalized.contains("oura") || + normalized.contains("my health") + if (healthSubject) { + return LocalCommandType.VITALS_SNAPSHOT + } + if ( + (normalized.contains("measure") || normalized.contains("write") || normalized.contains("record")) && + (normalized.contains("pulse") || normalized.contains("heart rate") || normalized.contains("heartbeat")) + ) { + return LocalCommandType.MEASURE_PULSE_WRITE + } if ( normalized.contains("pulse") || normalized.contains("heart rate") || @@ -545,6 +566,29 @@ class MainActivity : AppCompatActivity() { return null } + private fun isWeatherCommand(prompt: String): Boolean { + val normalized = prompt.lowercase() + return normalized.contains("weather") || + normalized.contains("forecast") || + normalized.contains("temperature tomorrow") || + normalized.contains("rain tomorrow") + } + + private fun isEventTopicCommand(prompt: String): Boolean { + val normalized = prompt.lowercase() + val hasEvent = normalized.contains("event") || + normalized.contains("attending") || + normalized.contains("today's topic") || + normalized.contains("todays topic") + return hasEvent && ( + normalized.contains("topic") || + normalized.contains("what is") || + normalized.contains("what's") || + normalized.contains("where") || + normalized.contains("attending") + ) + } + private fun isFamilyStatusCommand(prompt: String): Boolean { val normalized = prompt.lowercase() val hasSubject = normalized.contains("family") || @@ -700,11 +744,19 @@ class MainActivity : AppCompatActivity() { private fun requiredVitalsPermissions(command: LocalCommandType): List { val permissions = mutableListOf() - if (command == LocalCommandType.HEART_RATE || command == LocalCommandType.VITALS_SNAPSHOT) { + if (command == LocalCommandType.HEART_RATE || + command == LocalCommandType.MEASURE_PULSE_WRITE || + command == LocalCommandType.VITALS_SNAPSHOT + ) { if (!hasPermission("android.permission.health.READ_HEART_RATE")) { permissions += "android.permission.health.READ_HEART_RATE" } } + if (command == LocalCommandType.MEASURE_PULSE_WRITE && + !hasPermission("android.permission.health.WRITE_HEART_RATE") + ) { + permissions += "android.permission.health.WRITE_HEART_RATE" + } if (command == LocalCommandType.VITALS_SNAPSHOT && !hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)) { permissions += Manifest.permission.ACTIVITY_RECOGNITION } @@ -735,6 +787,14 @@ class MainActivity : AppCompatActivity() { val response = when (command) { LocalCommandType.VITALS_SNAPSHOT -> buildVitalsSummary(snapshot, canReadHeartRate, canReadSteps) + LocalCommandType.MEASURE_PULSE_WRITE -> { + val bpm = vitalsReader.measurePulseAndWriteToHealthConnect() + if (bpm != null) { + "Your pulse is $bpm beats per minute. I wrote it to Health Connect." + } else { + "I couldn't get a live pulse reading right now." + } + } LocalCommandType.HEART_RATE -> { if (snapshot.heartRateBpm != null) { "Your last recorded pulse is ${snapshot.heartRateBpm} beats per minute." @@ -743,12 +803,40 @@ class MainActivity : AppCompatActivity() { } } LocalCommandType.FAMILY_STATUS -> "I couldn't check the family yet." + LocalCommandType.WEATHER -> "I couldn't check the weather yet." + LocalCommandType.EVENT_TOPIC -> "I couldn't check today's event yet." } binding.responseText.text = response speakLocalResponse(response, token) } } + private fun launchWeatherCommand(prompt: String, token: Int) { + queryJob?.cancel() + setState(State.SEARCHING) + setStatus("Checking weather…") + queryJob = lifecycleScope.launch { + val result = clawRunner.weatherSummary(prompt) + if (token != interactionToken) return@launch + val response = result.getOrElse { "I couldn't get the weather right now." } + binding.responseText.text = response + speakLocalResponse(response, token) + } + } + + private fun launchEventTopicCommand(token: Int) { + queryJob?.cancel() + setState(State.THINKING) + setStatus("Checking today's event…") + queryJob = lifecycleScope.launch { + val result = clawRunner.summarizeCurrentEvent() + if (token != interactionToken) return@launch + val response = result.getOrElse { "I couldn't check today's event right now." } + binding.responseText.text = response + speakLocalResponse(response, token) + } + } + private fun launchFamilyStatusCommand(token: Int) { queryJob?.cancel() setState(State.THINKING) @@ -813,6 +901,18 @@ class MainActivity : AppCompatActivity() { } else if (!canReadSteps) { details += "Step count is unavailable until you allow activity access." } + snapshot.phoneVitals?.takeIf { it.isFresh() }?.let { phoneVitals -> + val phoneDetails = mutableListOf() + phoneVitals.restingHeartRateBpm?.let { phoneDetails += "resting pulse $it" } + phoneVitals.hrvRmssdMs?.let { phoneDetails += "HRV ${it.toInt()} milliseconds" } + phoneVitals.sleepSummary?.let { phoneDetails += it } + phoneVitals.readinessSummary?.let { phoneDetails += it } + if (phoneDetails.isNotEmpty()) { + details += "${phoneVitals.source.replaceFirstChar { it.uppercase() }} phone data: ${phoneDetails.joinToString(", ")}." + } + } ?: run { + details += "No fresh phone or Oura vitals have arrived yet." + } details += describeRecoveryState(snapshot, snapshot.heartRateBpm) return details.joinToString(" ").take(320) } diff --git a/app/src/main/java/com/thinkoff/clawwatch/PhoneVitalsStore.kt b/app/src/main/java/com/thinkoff/clawwatch/PhoneVitalsStore.kt new file mode 100644 index 0000000..856d690 --- /dev/null +++ b/app/src/main/java/com/thinkoff/clawwatch/PhoneVitalsStore.kt @@ -0,0 +1,61 @@ +package com.thinkoff.clawwatch + +import android.content.Context +import org.json.JSONObject + +class PhoneVitalsStore(context: Context) { + data class Snapshot( + val source: String, + val restingHeartRateBpm: Int?, + val hrvRmssdMs: Double?, + val sleepSummary: String?, + val readinessSummary: String?, + val updatedAtEpochMs: Long + ) { + fun isFresh(nowMs: Long = System.currentTimeMillis()): Boolean = + updatedAtEpochMs > 0 && nowMs - updatedAtEpochMs < 12L * 60L * 60L * 1000L + } + + private val prefs = SecurePrefs.watch(context) + + fun saveFromJson(payload: String) { + val json = JSONObject(payload) + prefs.edit() + .putString(KEY_SOURCE, json.optString("source", "phone")) + .putInt(KEY_RESTING_HR, json.optIntOrNull("resting_heart_rate_bpm") ?: -1) + .putString(KEY_HRV, json.optDoubleOrNull("hrv_rmssd_ms")?.toString()) + .putString(KEY_SLEEP, json.optString("sleep_summary").ifBlank { null }) + .putString(KEY_READINESS, json.optString("readiness_summary").ifBlank { null }) + .putLong(KEY_UPDATED_AT, json.optLong("updated_at_epoch_ms", System.currentTimeMillis())) + .apply() + } + + fun latest(): Snapshot? { + val updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L) + if (updatedAt <= 0L) return null + val restingHr = prefs.getInt(KEY_RESTING_HR, -1).takeIf { it > 0 } + return Snapshot( + source = prefs.getString(KEY_SOURCE, "phone") ?: "phone", + restingHeartRateBpm = restingHr, + hrvRmssdMs = prefs.getString(KEY_HRV, null)?.toDoubleOrNull(), + sleepSummary = prefs.getString(KEY_SLEEP, null), + readinessSummary = prefs.getString(KEY_READINESS, null), + updatedAtEpochMs = updatedAt + ) + } + + private fun JSONObject.optIntOrNull(name: String): Int? = + if (has(name) && !isNull(name)) optInt(name) else null + + private fun JSONObject.optDoubleOrNull(name: String): Double? = + if (has(name) && !isNull(name)) optDouble(name) else null + + companion object { + private const val KEY_SOURCE = "phone_vitals_source" + private const val KEY_RESTING_HR = "phone_resting_heart_rate_bpm" + private const val KEY_HRV = "phone_hrv_rmssd_ms" + private const val KEY_SLEEP = "phone_sleep_summary" + private const val KEY_READINESS = "phone_readiness_summary" + private const val KEY_UPDATED_AT = "phone_vitals_updated_at_epoch_ms" + } +} diff --git a/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt b/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt index c9a6032..70e511f 100644 --- a/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt +++ b/app/src/main/java/com/thinkoff/clawwatch/VitalsReader.kt @@ -3,6 +3,7 @@ package com.thinkoff.clawwatch import android.content.Context import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.metadata.Metadata import androidx.health.connect.client.request.ReadRecordsRequest import androidx.health.connect.client.time.TimeRangeFilter import java.time.Instant @@ -29,7 +30,8 @@ class VitalsReader(private val context: Context) { val pressureHpa: Float?, val stepCount: Int?, val motionLevel: MotionLevel, - val batteryPercent: Int + val batteryPercent: Int, + val phoneVitals: PhoneVitalsStore.Snapshot? ) enum class MotionLevel { STILL, LIGHT, ACTIVE } @@ -81,10 +83,35 @@ class VitalsReader(private val context: Context) { pressureHpa = pressure, stepCount = steps, motionLevel = motionLevel, - batteryPercent = batteryPercent + batteryPercent = batteryPercent, + phoneVitals = PhoneVitalsStore(context).latest() ) } + suspend fun measurePulseAndWriteToHealthConnect(): Int? = withContext(Dispatchers.Default) { + val bpm = readHeartRateSensor(timeoutMs = 10_000L) ?: return@withContext null + try { + val sdkStatus = HealthConnectClient.getSdkStatus(context) + if (sdkStatus == HealthConnectClient.SDK_AVAILABLE) { + val now = Instant.now() + val record = HeartRateRecord( + startTime = now.minusSeconds(1), + startZoneOffset = null, + endTime = now, + endZoneOffset = null, + samples = listOf(HeartRateRecord.Sample(now, bpm.toLong())), + metadata = Metadata(recordingMethod = Metadata.RECORDING_METHOD_ACTIVELY_RECORDED) + ) + HealthConnectClient.getOrCreate(context).insertRecords(listOf(record)) + } else { + android.util.Log.w("VitalsReader", "Health Connect unavailable, pulse measured but not written (status=$sdkStatus).") + } + } catch (e: Exception) { + android.util.Log.w("VitalsReader", "Health Connect pulse write failed: ${e.message}") + } + bpm + } + private suspend fun readSingleValue(sensorType: Int, timeoutMs: Long): Float? = suspendCancellableCoroutine { cont -> val sensor = sensorManager.getDefaultSensor(sensorType) @@ -116,6 +143,42 @@ class VitalsReader(private val context: Context) { } } + private suspend fun readHeartRateSensor(timeoutMs: Long): Int? = + suspendCancellableCoroutine { cont -> + val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE) + if (sensor == null) { + cont.resume(null) + return@suspendCancellableCoroutine + } + + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val bpm = event.values.firstOrNull() + ?.takeIf { it in 30f..230f } + ?.roundToIntSafe() + if (bpm != null) { + sensorManager.unregisterListener(this) + cont.resume(bpm) + } + } + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } + + try { + sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + } catch (_: SecurityException) { + cont.resume(null) + return@suspendCancellableCoroutine + } + cont.invokeOnCancellation { sensorManager.unregisterListener(listener) } + + GlobalScope.launch(Dispatchers.Default) { + delay(timeoutMs) + sensorManager.unregisterListener(listener) + if (cont.isActive) cont.resume(null) + } + } + private suspend fun readMotionLevel(): MotionLevel = suspendCancellableCoroutine { cont -> val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)