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