diff --git a/health-services/PassiveDataCompose/README.md b/health-services/PassiveDataCompose/README.md index f2829577..8fcf34f1 100644 --- a/health-services/PassiveDataCompose/README.md +++ b/health-services/PassiveDataCompose/README.md @@ -31,6 +31,34 @@ To use synthetic data on emulators or physical devices running Wear OS 3, consult [the documentation](https://developer.android.com/health-and-fitness/guides/health-services/simulated-data#use_synthetic_data_on_wear_os_3) for synthetic data commands. +### Heart rate complication + +The sample also exposes heart rate to watch faces through a complication. Long press your watch +face, tap **Edit**, choose a complication slot and select **Heart rate** from the *Passive Data +Sample* app. + +`HeartRateComplicationService` builds its value from +[`PlatformHealthSources.heartRateBpm()`](https://developer.android.com/reference/androidx/wear/protolayout/expression/PlatformHealthSources), +wrapped in a `DynamicComplicationText`. The platform re-evaluates this expression roughly once a +second while the watch face is in interactive mode, so the displayed heart rate stays live without +the complication service being woken up at all. Because of that, `UPDATE_PERIOD_SECONDS` can be set +to a large value. + +Two things still come from passive data: + +* **Older devices.** Dynamic values require API 33 or above; below that the complication shows the + most recent measurement stored by `PassiveDataService`. +* **Fallback text.** `DynamicComplicationText` takes a fallback that is displayed whenever the + platform cannot evaluate the expression, and the last known passive measurement is a better + fallback than a placeholder. + +`PassiveDataService` therefore still calls +`ComplicationDataSourceUpdateRequester.requestUpdateAll()` when new data arrives, to keep those two +paths current. + +Note that the dynamic heart rate binding requires the app to hold `BODY_SENSORS` (or +`READ_HEART_RATE` on API 36 and above), which this sample already requests for passive data. + ## Troubleshooting ### App crashes with `java.lang.Exception: Not yet implemented` diff --git a/health-services/PassiveDataCompose/app/build.gradle.kts b/health-services/PassiveDataCompose/app/build.gradle.kts index 74ae4d52..709ed902 100644 --- a/health-services/PassiveDataCompose/app/build.gradle.kts +++ b/health-services/PassiveDataCompose/app/build.gradle.kts @@ -74,6 +74,12 @@ dependencies { // Health Services implementation(libs.androidx.health.services) + // Used to expose health data to watch face complications + implementation(libs.androidx.watchface.complications.datasource) + + // Platform health bindings, for complication values the system evaluates itself + implementation(libs.androidx.protolayout.expression) + // Used to bridge between Futures and coroutines implementation(libs.guava) implementation(libs.concurrent.futures) diff --git a/health-services/PassiveDataCompose/app/src/main/AndroidManifest.xml b/health-services/PassiveDataCompose/app/src/main/AndroidManifest.xml index 54dafaae..ef1e3723 100644 --- a/health-services/PassiveDataCompose/app/src/main/AndroidManifest.xml +++ b/health-services/PassiveDataCompose/app/src/main/AndroidManifest.xml @@ -49,6 +49,30 @@ + + + + + + + + + + + + diff --git a/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/complication/HeartRateComplicationService.kt b/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/complication/HeartRateComplicationService.kt new file mode 100644 index 00000000..ce59595d --- /dev/null +++ b/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/complication/HeartRateComplicationService.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.passivedatacompose.complication + +import android.app.PendingIntent +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.wear.protolayout.expression.PlatformHealthSources +import androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationText +import androidx.wear.watchface.complications.data.ComplicationType +import androidx.wear.watchface.complications.data.DynamicComplicationText +import androidx.wear.watchface.complications.data.MonochromaticImage +import androidx.wear.watchface.complications.data.NoDataComplicationData +import androidx.wear.watchface.complications.data.PlainComplicationText +import androidx.wear.watchface.complications.data.ShortTextComplicationData +import androidx.wear.watchface.complications.datasource.ComplicationRequest +import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService +import com.example.passivedatacompose.MainActivity +import com.example.passivedatacompose.R +import com.example.passivedatacompose.data.PassiveDataRepository +import kotlinx.coroutines.flow.first + +/** + * Surfaces heart rate on the watch face. + * + * Where possible the complication is backed by a platform binding: + * [PlatformHealthSources.heartRateBpm] produces a dynamic value that the system re-evaluates + * roughly once a second while the watch face is in interactive mode. The value therefore stays + * live without this service being woken up at all, which is both cheaper and far more responsive + * than returning a static number. + * + * On devices that predate dynamic values the complication falls back to the most recent + * measurement stored by [com.example.passivedatacompose.service.PassiveDataService], which also + * supplies the fallback text shown whenever the platform cannot evaluate the expression. + */ +class HeartRateComplicationService : SuspendingComplicationDataSourceService() { + private val repository by lazy { PassiveDataRepository(this) } + + override fun getPreviewData(type: ComplicationType): ComplicationData? = + if (type == ComplicationType.SHORT_TEXT) { + complicationData(PlainComplicationText.Builder(formatBpm(PREVIEW_HEART_RATE)).build()) + } else { + null + } + + override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { + if (request.complicationType != ComplicationType.SHORT_TEXT) return null + + // The repository reports 0.0 until the first passive measurement is received. + val latestHeartRate = repository.latestHeartRate.first() + + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> + complicationData(dynamicHeartRateText(latestHeartRate)) + + latestHeartRate > 0.0 -> + complicationData( + PlainComplicationText.Builder(formatBpm(latestHeartRate)).build() + ) + + // Letting the watch face draw its own placeholder is better than showing a stale or + // zero value. + else -> NoDataComplicationData() + } + } + + /** + * Heart rate as evaluated by the platform, with the last known passive measurement as the + * fallback for when the expression cannot be evaluated, for example in ambient mode. + * + * Requires this app to hold `BODY_SENSORS` (or `READ_HEART_RATE` on API 36 and above), which + * it already does in order to receive passive data. + */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun dynamicHeartRateText(latestHeartRate: Double): ComplicationText = + DynamicComplicationText( + PlatformHealthSources.heartRateBpm().asInt().format(), + if (latestHeartRate > 0.0) { + formatBpm(latestHeartRate) + } else { + getString(R.string.heart_rate_complication_placeholder) + } + ) + + private fun complicationData(text: ComplicationText): ComplicationData = + ShortTextComplicationData.Builder( + text = text, + contentDescription = PlainComplicationText.Builder( + getString(R.string.heart_rate_complication_description) + ).build() + ) + .setMonochromaticImage( + MonochromaticImage.Builder( + Icon.createWithResource(this, R.drawable.ic_heart) + ).build() + ) + .setTapAction(launchAppPendingIntent()) + .build() + + private fun formatBpm(heartRate: Double): String = heartRate.toInt().toString() + + private fun launchAppPendingIntent(): PendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + companion object { + private const val PREVIEW_HEART_RATE = 72.0 + } +} diff --git a/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/service/PassiveDataService.kt b/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/service/PassiveDataService.kt index d70866ae..a99d4911 100644 --- a/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/service/PassiveDataService.kt +++ b/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/service/PassiveDataService.kt @@ -15,9 +15,12 @@ */ package com.example.passivedatacompose.service +import android.content.ComponentName import androidx.health.services.client.PassiveListenerService import androidx.health.services.client.data.DataPointContainer import androidx.health.services.client.data.DataType +import androidx.wear.watchface.complications.datasource.ComplicationDataSourceUpdateRequester +import com.example.passivedatacompose.complication.HeartRateComplicationService import com.example.passivedatacompose.data.PassiveDataRepository import com.example.passivedatacompose.data.latestHeartRate import kotlinx.coroutines.runBlocking @@ -37,5 +40,15 @@ class PassiveDataService : PassiveListenerService() { repository.storeLatestHeartRate(it) } } + + // On API 33 and above the complication value is evaluated by the platform, but this keeps + // the fallback text, and the complication on older devices, up to date. + ComplicationDataSourceUpdateRequester.create( + context = this, + complicationDataSourceComponent = ComponentName( + this, + HeartRateComplicationService::class.java + ) + ).requestUpdateAll() } } diff --git a/health-services/PassiveDataCompose/app/src/main/res/drawable/ic_heart.xml b/health-services/PassiveDataCompose/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 00000000..dd96ce0e --- /dev/null +++ b/health-services/PassiveDataCompose/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,25 @@ + + + + diff --git a/health-services/PassiveDataCompose/app/src/main/res/values/strings.xml b/health-services/PassiveDataCompose/app/src/main/res/values/strings.xml index a3cca950..103819da 100644 --- a/health-services/PassiveDataCompose/app/src/main/res/values/strings.xml +++ b/health-services/PassiveDataCompose/app/src/main/res/values/strings.xml @@ -22,4 +22,8 @@ Heart Broken heart Heart Rate Switch Button + + Heart rate + Latest heart rate in beats per minute + -- diff --git a/health-services/PassiveDataCompose/gradle/libs.versions.toml b/health-services/PassiveDataCompose/gradle/libs.versions.toml index f23d7947..89340a0d 100644 --- a/health-services/PassiveDataCompose/gradle/libs.versions.toml +++ b/health-services/PassiveDataCompose/gradle/libs.versions.toml @@ -4,6 +4,8 @@ android-gradle-plugin = "8.7.3" androidx-activity = "1.10.1" androidx-datastore = "1.1.7" androidx-lifecycle = "2.9.0" +androidx-protolayout = "1.3.0" +androidx-watchface = "1.2.1" androidx-work = "2.10.1" compose = "1.7.8" compose-tooling = "1.8.2" @@ -22,6 +24,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } androidx-health-services = { module = "androidx.health:health-services-client", version.ref = "health-services" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-protolayout-expression = { module = "androidx.wear.protolayout:protolayout-expression", version.ref = "androidx-protolayout" } +androidx-watchface-complications-datasource = { module = "androidx.wear.watchface:watchface-complications-data-source-ktx", version.ref = "androidx-watchface" } androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" } androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }