From 5171df4d57ec0492e3060818a572db53065bf2ac Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Wed, 16 Sep 2026 07:47:47 +0000 Subject: [PATCH 1/2] Add a heart rate complication to the PassiveDataCompose sample Passive data is a natural fit for watch face complications, but there is currently no sample showing how to connect the two. This adds a `SHORT_TEXT` complication that surfaces the latest passive heart rate measurement. The complication deliberately does not talk to Health Services itself: a complication data source is short-lived and `onComplicationRequest()` must return quickly, so `HeartRateComplicationService` only reads the value already cached in `PassiveDataRepository`. `PassiveDataService` calls `ComplicationDataSourceUpdateRequester.requestUpdateAll()` when new data arrives, so the complication refreshes as data comes in rather than relying on the periodic manifest update. --- health-services/PassiveDataCompose/README.md | 14 +++ .../PassiveDataCompose/app/build.gradle.kts | 3 + .../app/src/main/AndroidManifest.xml | 24 +++++ .../HeartRateComplicationService.kt | 92 +++++++++++++++++++ .../service/PassiveDataService.kt | 13 +++ .../app/src/main/res/drawable/ic_heart.xml | 25 +++++ .../app/src/main/res/values/strings.xml | 3 + .../gradle/libs.versions.toml | 2 + 8 files changed, 176 insertions(+) create mode 100644 health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/complication/HeartRateComplicationService.kt create mode 100644 health-services/PassiveDataCompose/app/src/main/res/drawable/ic_heart.xml diff --git a/health-services/PassiveDataCompose/README.md b/health-services/PassiveDataCompose/README.md index f2829577..3bd7c185 100644 --- a/health-services/PassiveDataCompose/README.md +++ b/health-services/PassiveDataCompose/README.md @@ -31,6 +31,20 @@ 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 the latest measurement 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` extends `SuspendingComplicationDataSourceService` and only reads the +value already stored in `PassiveDataRepository`: a complication data source is short-lived and must +not start a sensor session of its own. `PassiveDataService` calls +`ComplicationDataSourceUpdateRequester.requestUpdateAll()` whenever new passive data arrives, so the +complication refreshes as data comes in rather than waiting for the periodic update declared in the +manifest. Until the first measurement is received the data source returns `NoDataComplicationData`, +letting the watch face draw its own placeholder. + ## 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..94e5e535 100644 --- a/health-services/PassiveDataCompose/app/build.gradle.kts +++ b/health-services/PassiveDataCompose/app/build.gradle.kts @@ -74,6 +74,9 @@ dependencies { // Health Services implementation(libs.androidx.health.services) + // Used to expose health data to watch face complications + implementation(libs.androidx.watchface.complications.datasource) + // 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..dbb1ef4a 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..556e72f4 --- /dev/null +++ b/health-services/PassiveDataCompose/app/src/main/java/com/example/passivedatacompose/complication/HeartRateComplicationService.kt @@ -0,0 +1,92 @@ +/* + * 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 androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationType +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 the latest passive heart rate measurement on the watch face. + * + * A complication data source is short-lived: [onComplicationRequest] must return quickly and must + * not start a sensor session. Instead, this reads the value most recently stored by + * [com.example.passivedatacompose.service.PassiveDataService], which owns the passive registration + * and asks the system to refresh this complication whenever new data arrives. + */ +class HeartRateComplicationService : SuspendingComplicationDataSourceService() { + private val repository by lazy { PassiveDataRepository(this) } + + override fun getPreviewData(type: ComplicationType): ComplicationData? = + if (type == ComplicationType.SHORT_TEXT) { + heartRateComplicationData(PREVIEW_HEART_RATE) + } else { + null + } + + override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { + if (request.complicationType != ComplicationType.SHORT_TEXT) return null + + val heartRate = repository.latestHeartRate.first() + + // The repository reports 0.0 until the first measurement is received. Returning + // NoDataComplicationData lets the watch face draw its own placeholder instead of a + // misleading value. + return if (heartRate > 0.0) { + heartRateComplicationData(heartRate) + } else { + NoDataComplicationData() + } + } + + private fun heartRateComplicationData(heartRate: Double): ComplicationData = + ShortTextComplicationData.Builder( + text = PlainComplicationText.Builder(heartRate.toInt().toString()).build(), + 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 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..43951209 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) } } + + // Pushing updates as data arrives keeps the complication fresh without the watch face + // having to poll for it. + 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..50aca997 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,7 @@ 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..72277008 100644 --- a/health-services/PassiveDataCompose/gradle/libs.versions.toml +++ b/health-services/PassiveDataCompose/gradle/libs.versions.toml @@ -4,6 +4,7 @@ android-gradle-plugin = "8.7.3" androidx-activity = "1.10.1" androidx-datastore = "1.1.7" androidx-lifecycle = "2.9.0" +androidx-watchface = "1.2.1" androidx-work = "2.10.1" compose = "1.7.8" compose-tooling = "1.8.2" @@ -22,6 +23,7 @@ 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-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" } From 2fb7b90818a54e86a3ea8bf0dbac354a831c4bba Mon Sep 17 00:00:00 2001 From: Chiara Chiappini Date: Wed, 16 Sep 2026 11:43:35 +0000 Subject: [PATCH 2/2] Use PlatformHealthSources for the heart rate complication Per review feedback, prefer a platform binding over a value the data source computes. `PlatformHealthSources.heartRateBpm()` is wrapped in a `DynamicComplicationText`, so the system re-evaluates the heart rate about once a second while the watch face is interactive, without waking the complication service. `UPDATE_PERIOD_SECONDS` is raised to an hour accordingly. Passive data is still used for two things: the complication on API levels below 33, where dynamic values are unavailable, and the fallback text shown when the platform cannot evaluate the expression. --- health-services/PassiveDataCompose/README.md | 36 +++++++--- .../PassiveDataCompose/app/build.gradle.kts | 3 + .../app/src/main/AndroidManifest.xml | 6 +- .../HeartRateComplicationService.kt | 68 ++++++++++++++----- .../service/PassiveDataService.kt | 4 +- .../app/src/main/res/values/strings.xml | 1 + .../gradle/libs.versions.toml | 2 + 7 files changed, 88 insertions(+), 32 deletions(-) diff --git a/health-services/PassiveDataCompose/README.md b/health-services/PassiveDataCompose/README.md index 3bd7c185..8fcf34f1 100644 --- a/health-services/PassiveDataCompose/README.md +++ b/health-services/PassiveDataCompose/README.md @@ -33,17 +33,31 @@ for synthetic data commands. ### Heart rate complication -The sample also exposes the latest measurement 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` extends `SuspendingComplicationDataSourceService` and only reads the -value already stored in `PassiveDataRepository`: a complication data source is short-lived and must -not start a sensor session of its own. `PassiveDataService` calls -`ComplicationDataSourceUpdateRequester.requestUpdateAll()` whenever new passive data arrives, so the -complication refreshes as data comes in rather than waiting for the periodic update declared in the -manifest. Until the first measurement is received the data source returns `NoDataComplicationData`, -letting the watch face draw its own placeholder. +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 diff --git a/health-services/PassiveDataCompose/app/build.gradle.kts b/health-services/PassiveDataCompose/app/build.gradle.kts index 94e5e535..709ed902 100644 --- a/health-services/PassiveDataCompose/app/build.gradle.kts +++ b/health-services/PassiveDataCompose/app/build.gradle.kts @@ -77,6 +77,9 @@ dependencies { // 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 dbb1ef4a..ef1e3723 100644 --- a/health-services/PassiveDataCompose/app/src/main/AndroidManifest.xml +++ b/health-services/PassiveDataCompose/app/src/main/AndroidManifest.xml @@ -65,12 +65,12 @@ android:value="SHORT_TEXT" /> + android:value="3600" /> 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 index 556e72f4..ce59595d 100644 --- 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 @@ -18,8 +18,13 @@ 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 @@ -32,19 +37,24 @@ import com.example.passivedatacompose.data.PassiveDataRepository import kotlinx.coroutines.flow.first /** - * Surfaces the latest passive heart rate measurement on the watch face. + * Surfaces heart rate on the watch face. * - * A complication data source is short-lived: [onComplicationRequest] must return quickly and must - * not start a sensor session. Instead, this reads the value most recently stored by - * [com.example.passivedatacompose.service.PassiveDataService], which owns the passive registration - * and asks the system to refresh this complication whenever new data arrives. + * 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) { - heartRateComplicationData(PREVIEW_HEART_RATE) + complicationData(PlainComplicationText.Builder(formatBpm(PREVIEW_HEART_RATE)).build()) } else { null } @@ -52,21 +62,45 @@ class HeartRateComplicationService : SuspendingComplicationDataSourceService() { override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { if (request.complicationType != ComplicationType.SHORT_TEXT) return null - val heartRate = repository.latestHeartRate.first() + // The repository reports 0.0 until the first passive measurement is received. + val latestHeartRate = repository.latestHeartRate.first() - // The repository reports 0.0 until the first measurement is received. Returning - // NoDataComplicationData lets the watch face draw its own placeholder instead of a - // misleading value. - return if (heartRate > 0.0) { - heartRateComplicationData(heartRate) - } else { - NoDataComplicationData() + 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() } } - private fun heartRateComplicationData(heartRate: Double): ComplicationData = + /** + * 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 = PlainComplicationText.Builder(heartRate.toInt().toString()).build(), + text = text, contentDescription = PlainComplicationText.Builder( getString(R.string.heart_rate_complication_description) ).build() @@ -79,6 +113,8 @@ class HeartRateComplicationService : SuspendingComplicationDataSourceService() { .setTapAction(launchAppPendingIntent()) .build() + private fun formatBpm(heartRate: Double): String = heartRate.toInt().toString() + private fun launchAppPendingIntent(): PendingIntent = PendingIntent.getActivity( this, 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 43951209..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 @@ -41,8 +41,8 @@ class PassiveDataService : PassiveListenerService() { } } - // Pushing updates as data arrives keeps the complication fresh without the watch face - // having to poll for 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( 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 50aca997..103819da 100644 --- a/health-services/PassiveDataCompose/app/src/main/res/values/strings.xml +++ b/health-services/PassiveDataCompose/app/src/main/res/values/strings.xml @@ -25,4 +25,5 @@ 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 72277008..89340a0d 100644 --- a/health-services/PassiveDataCompose/gradle/libs.versions.toml +++ b/health-services/PassiveDataCompose/gradle/libs.versions.toml @@ -4,6 +4,7 @@ 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" @@ -23,6 +24,7 @@ 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" }