Skip to content

Add a heart rate complication to the PassiveDataCompose sample - #350

Merged
kul3r4 merged 2 commits into
android:mainfrom
kul3r4:passive-data-complication
Sep 16, 2026
Merged

kul3r4 merged 2 commits into
android:mainfrom
kul3r4:passive-data-complication

Conversation

@kul3r4

@kul3r4 kul3r4 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds a heart rate complication to the PassiveDataCompose sample, so heart rate can be shown directly on a watch face.

  • complication/HeartRateComplicationService.kt — a SuspendingComplicationDataSourceService providing SHORT_TEXT, backed by PlatformHealthSources.heartRateBpm()
  • PassiveDataService calls ComplicationDataSourceUpdateRequester.requestUpdateAll() when new data arrives, which keeps the fallback text and the pre-API-33 path fresh
  • Manifest declaration, a monochromatic heart drawable, three strings, and a README section
  • New dependencies: androidx.wear.watchface:watchface-complications-data-source-ktx:1.2.1 and androidx.wear.protolayout:protolayout-expression:1.3.0

Why

Passive data and complications are a natural pairing, but there's currently no sample connecting them, and the correct wiring isn't obvious.

The complication value comes from a platform binding, not from the data source. onComplicationRequest() returns a DynamicComplicationText wrapping PlatformHealthSources.heartRateBpm().asInt().format(). The platform re-evaluates that expression roughly once a second while the watch face is in interactive mode, so the displayed value stays live without waking up the complication data source at all. That's both cheaper and far more responsive than returning a static number and relying on service wakeups. Because of this, UPDATE_PERIOD_SECONDS is now just a safety net (raised to 3600) rather than the refresh mechanism.

See Leveraging platform bindings for high-frequency data in Building complication data sources on Wear OS.

Passive data is still used, for exactly two reasons:

  1. Fallback text. DynamicComplicationText needs a fallback for when the platform can't evaluate the expression. The last known passive measurement is a much better fallback than a -- placeholder.
  2. Devices below API 33, which have no dynamic values. There the complication shows the cached passive measurement, and returns NoDataComplicationData until the first measurement arrives so the watch face draws its own placeholder instead of 0.

PlatformHealthSources.heartRateBpm() requires BODY_SENSORS (or READ_HEART_RATE on API 36+), which the sample already holds in order to receive passive data.

Testing

  • ./gradlew :app:assembleDebug passes.
  • Complication appears in the watch face complication picker under Passive Data Sample and updates live on the watch face.

Note for maintainers

./gradlew spotlessCheck currently fails on main (before this change) with:

Caused by: java.lang.IllegalStateException: Ktlint versions < 0.48.0 not supported!
	at com.diffplug.spotless.glue.ktlint.KtlintFormatterFunc.<init>(KtlintFormatterFunc.java:55)

The sample pins ktlint = "0.46.1" while using Spotless 6.25.0, which requires ktlint ≥ 0.48.0. I've matched the surrounding code style by hand; happy to bump the ktlint version in this PR or a separate one if you'd like.

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.
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.
@kul3r4

kul3r4 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

The complication now gets its value from the platform binding rather than from the data source:

DynamicComplicationText(
    PlatformHealthSources.heartRateBpm().asInt().format(),
    fallbackText
)

So the value is re-evaluated by the platform (~1 Hz in interactive mode) with no wakeups of HeartRateComplicationService at all. UPDATE_PERIOD_SECONDS went from 300 → 3600, since it's now only a safety net rather than the refresh mechanism.

I kept the passive-data path, but only in a supporting role:

  • it supplies the fallback text for DynamicComplicationText (last known measurement reads much better than a -- placeholder), and
  • it's the value shown on API < 33, where dynamic values aren't available.

That's also why ComplicationDataSourceUpdateRequester.requestUpdateAll() stays in PassiveDataService — it keeps those two paths current. Happy to drop the pre-33 branch entirely if you'd rather the sample stay minimal.

The README section has been rewritten to lead with the platform binding and links to the Leveraging platform bindings for high-frequency data section of the blog post.

@kul3r4
kul3r4 requested a review from garanj September 16, 2026 11:55
@kul3r4
kul3r4 merged commit 7183ab9 into android:main Sep 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants