Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions health-services/PassiveDataCompose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions health-services/PassiveDataCompose/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@
</intent-filter>
</receiver>

<!-- Exposes the latest passive heart rate measurement to watch faces. -->
<service
android:name=".complication.HeartRateComplicationService"
android:exported="true"
android:icon="@drawable/ic_heart"
android:label="@string/heart_rate_complication_label"
android:permission="com.google.android.wearable.permission.BIND_COMPLICATION_PROVIDER">
<intent-filter>
<action android:name="android.support.wearable.complications.ACTION_COMPLICATION_UPDATE_REQUEST" />
</intent-filter>

<meta-data
android:name="android.support.wearable.complications.SUPPORTED_TYPES"
android:value="SHORT_TEXT" />

<!--
The value is evaluated by the platform (and pushed by PassiveDataService on older
devices), so the complication does not need frequent polling.
-->
<meta-data
android:name="android.support.wearable.complications.UPDATE_PERIOD_SECONDS"
android:value="3600" />
</service>

</application>
<queries>
<package android:name="com.google.android.wearable.healthservices" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@
<string name="heart_description">Heart</string>
<string name="broken_heart_description">Broken heart</string>
<string name="heart_rate_switch_button">Heart Rate Switch Button</string>

<string name="heart_rate_complication_label">Heart rate</string>
<string name="heart_rate_complication_description">Latest heart rate in beats per minute</string>
<string name="heart_rate_complication_placeholder">--</string>
</resources>
4 changes: 4 additions & 0 deletions health-services/PassiveDataCompose/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down
Loading