Skip to content

Android: make the SpO₂-candidate display toggle take effect - #1595

Merged
ryanbr merged 3 commits into
mainfrom
fix/spo2-candidate-toggle-reactive
Aug 24, 2026
Merged

ryanbr merged 3 commits into
mainfrom
fix/spo2-candidate-toggle-reactive

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Found while reviewing #1590, which is the second consumer of this flow. Independent of that PR — no conflict, either order.

The bug

combine(recentDays, flowOf(NoopPrefs.spo2CandidateDisplay(appContext))) { days, toggleOn -> … }

flowOf emits once and completes, so combine holds that first value for the ViewModel's whole life. The toggle is read at construction and never again.

The symptom is a setting that does nothing:

you do what happens
turn "show SpO₂ candidate" off Key Metrics tile keeps showing strap estimates until the process restarts
turn it on nothing appears until the process restarts

Both directions, with nothing in the app to indicate the value in use is stale.

iOS reads PuffinExperiment.spo2CandidateDisplayEnabled per render and has never had the lag, so this was a live parity divergence too.

The fix

NoopPrefs.spo2CandidateDisplayFlow wraps the same key in a callbackFlow over OnSharedPreferenceChangeListener — the idiom already used by SettingsScreen (experiment keys) and NoopMotion — seeded with the current value and distinctUntilChanged so an unrelated write to the prefs file can't re-run the database union behind this flow.

Two details carried over from those existing listener sites rather than rediscovered:

  • A strong local reference. Android holds these listeners weakly; one kept alive only by the register call is collected and silently stops firing. SettingsScreen has a comment saying exactly this.
  • The null-key case. key is @Nullable on modern SDKs — it arrives null when the whole file is cleared, which reads as "everything changed". Required to compile, not merely defensive.

Named for the one key rather than made generic, so it sits beside the getter it mirrors and the two can't drift on the default. flowOf had no other caller in AppViewModel, so its import went with it.

Scope

The flowOf(NoopPrefs…) pattern appears exactly once in the codebase — this line. Not systemic, and now gone.

Verification, and one honest gap

  • compileFullDebugKotlin clean, full unit suite 4294 tests / 0 failures, assembleFullDebug clean, doc lint clean.
  • Not unit-tested. The listener needs a real SharedPreferences, and this module is deliberately Robolectric-free (AndroidDiagnosticsTest and friends note the same constraint), so there's no way to pin the re-emit without adding that dependency for one test. I'd rather say that than claim coverage that isn't there.
  • The behaviour needs a device: toggle it in Settings and the Blood Oxygen tile should change without restarting the app.

Android-only — iOS already reads the flag per render.

ryanbr added 3 commits August 25, 2026 01:24
`spo2CandidateByDay` combined against `flowOf(NoopPrefs.spo2CandidateDisplay(appContext))`.
`flowOf` emits once and completes, so `combine` held that first value for
the ViewModel's whole life — the toggle was read at construction and never
again.

The symptom is a setting that does nothing. Turn "show SpO2 candidate"
off and the Key Metrics tile keeps showing strap estimates until the
process restarts; turn it on and nothing appears until then. Both
directions, and no way to tell from inside the app that the value it is
using is stale.

iOS reads PuffinExperiment.spo2CandidateDisplayEnabled per render and has
never had the lag, so this was also a live parity divergence.

NoopPrefs.spo2CandidateDisplayFlow now wraps the same key in a
callbackFlow over OnSharedPreferenceChangeListener — the idiom already
used by SettingsScreen and NoopMotion — seeded with the current value and
distinctUntilChanged so an unrelated write to the prefs file cannot
re-run the query. Two details carried over from the existing listener
sites: a strong local reference, because Android holds these weakly and a
listener kept alive only by the register call is collected and silently
stops firing; and the null-key case, which arrives when the whole file is
cleared and reads as "everything changed".

Named for the one key rather than generic, so it sits beside the getter
it mirrors and the two cannot drift on the default. `flowOf` had no other
caller in the file and its import went with it.

NOT unit-tested, and honestly so: the listener needs a real
SharedPreferences and this module is deliberately Robolectric-free, so
there is no way to pin the re-emit without adding that dependency.
Verified by compile, the full unit suite (4294/0) and assembleFullDebug;
the behaviour itself needs a device — toggle it and watch the Blood
Oxygen tile change without a restart.
Re-review of my own commit. The flow read the current value and THEN
registered the listener, so a write landing between the two was missed
outright — and since nothing re-reads until the next change, the flow
would then serve a stale value indefinitely. That is precisely the
failure this function exists to remove, reintroduced in a smaller window.

Registering first makes the worst case a duplicate emit, which
`distinctUntilChanged` already drops.

Full suite 4294/0, assembleFullDebug clean.
Every other NoopPrefs accessor is a point read that returns before the
caller's Context can matter. This one captures it in a flow that lives as
long as something collects, so an Activity passed by a future caller
would be retained across a rotation.

The only caller today passes an application Context already; resolving it
inside means the function no longer depends on callers remembering to.

Full suite 4294/0, assembleFullDebug and doc lint clean.
@ryanbr
ryanbr merged commit 4097bda into main Aug 24, 2026
3 checks passed
@ryanbr
ryanbr deleted the fix/spo2-candidate-toggle-reactive branch August 24, 2026 13:41
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.

1 participant