Skip to content

Hydration: import water from Apple Health and Health Connect (#949) - #974

Merged
ryanbr merged 5 commits into
mainfrom
hydration-health-import-949
Jul 30, 2026
Merged

Hydration: import water from Apple Health and Health Connect (#949)#974
ryanbr merged 5 commits into
mainfrom
hydration-health-import-949

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #949 (water half — caffeine is a separate follow-up, see the end).

Drinks logged in a hydration app or by a smart bottle had to be entered into NOOP a second time. Both platforms already read their health store, so this adds water to what they read. Stays on-device: a plain HealthKit / Health Connect read, no network.

The design decision that matters

Imported water lives in its own metricSeries row (hydrationImported) rather than being folded into the hand-logged total, because the two behave differently on write:

hand-logged (hydration) imported (hydrationImported)
write accumulates — each tap adds replaced with the health store's recomputed day sum
owner the user, editable here the app that logged it, not editable here

The replacement is what makes re-importing idempotent without tracking individual sample ids — HealthKit's HKStatisticsCollectionQuery and Health Connect's day bucket both hand back the whole day, so writing it wholesale means a second import of the same day stores the same number. Every day in the window is written, zeros included, so a drink deleted in the source app makes the figure go down here rather than being stranded forever.

Why that separation is load-bearing

A quick-add stores current + amount. Reading the combined figure there would copy every imported millilitre into the hand-logged row, and the next import would then add the imported water on top of that copy — one tap after an import would silently double the day, compounding on every tap after it. Android's remove was the sharper version of the same trap: with 200 ml logged and 500 ml imported, removing 100 would have computed 700−100=600 and stored that as the hand-logged total, inflating the day to 1100 instead of reducing it to 600.

So log / remove / set all read the manual row; only display reads the sum. hydrationTotal (display) and hydrationManualTotal (write) are now separate calls on both platforms.

Sources are SUMMED, not maxed

Steps and calories take the max across sources (#589) because a phone and a watch measure the same walk. Water is the opposite: a smart bottle and a manual entry are two different real glasses. Taking the max would silently drop whichever app logged less.

Per platform

  • iOS.dietaryWater as a cumulative day sum. NOOP's own samples are already excluded by the existing notNoopAuthored predicate, so a tap in NOOP can never return as an import. Adding a read type is safe for returning users: refreshAuthIfPreviouslyGranted resumes off legacyCoreWriteTypes (write types only), and HealthKit never exposes read status anyway.
  • AndroidHydrationRecord over its own 30-day window; the aggregate window is 10 years, which would mean ~3,650 zero-fill rows per import to back a screen showing today plus 7 bars. 30 days matches the iOS sync window exactly. Also declares READ_HYDRATION, without which Health Connect silently drops the runtime request (the Fix carried Charge source label #412 trap). Respects partial permissions (Home screen Icons #150) — skipped entirely when the water scope is not granted, rather than zeroing the window.
  • Both gate the write on the hydration toggle, which is opt-in and default OFF, so an import never quietly populates a feature the user turned off.

UI

The detail screen names the imported share instead of letting the day total move on its own with nothing on screen accounting for it. It is deliberately not editable there — "deleting" it would be a lie, since the next sync re-reads the same day and it would come straight back. Android's "Clear today" now clears only what NOOP owns, and is hidden when there is nothing of ours to clear.

Verification

  • 10 JVM unit tests (HydrationImportTest) over the two pure helpers: the same-day merge (the old associate { it.day to it.value } keeps only the last row — with a manual row and an imported row on the same day that silently halves every history bar), and the zero-fill / idempotency / clamping contract. All 10 also executed locally against the shipped function bodies.
  • Whole Android module compiles: error count unchanged from main apart from +2 unresolved R refs, which are the two new string resources my ad-hoc classpath has no generated R for. Both files confirmed analysed by deliberately injecting an error and watching it get reported.
  • Tools/i18n_audit.py --ci exit 0 — new string added to all 6 Android locales and both new Apple literals added to the catalog with 8 translations each.
  • Tools/doc_comment_lint.py exit 0. Tools/ suite 53/53.
  • Not hardware- or device-tested: no iPhone/Android device here, and app-target Swift has no CI. The Swift app target is unverified by compile — worth a local xcodebuild before merging.

Noticed while in here, NOT fixed (out of scope)

DistanceRecord is in READ_RECORDS, so PERMISSIONS requests READ_DISTANCE — but READ_DISTANCE is never declared in the manifest, which by the same #412 rule means Health Connect silently drops it and distance never imports. Pre-existing and unrelated to hydration; happy to send a one-line PR separately.

Follow-up: caffeine

Deliberately not here. NOOP already models caffeine, but DoseResponseEngine is explicit that the dose is a timing proxy, never mg. So the useful part of a HealthKit caffeine sample is its timestamp mapped onto the existing 0–3 time-of-day bucket — a different change against a different engine, and it should not ride along with a storage change.

Drinks logged in a dedicated hydration app or by a smart bottle had to be
entered into NOOP a second time. Both platforms already read their health
store; this adds water to what they read.

Imported water is kept in its OWN metricSeries row (`hydrationImported`)
rather than folded into the hand-logged total, because the two behave
differently on write: the hand-logged row ACCUMULATES (each tap adds to it)
while the imported row is REPLACED with the health store's recomputed day
sum. That replacement is what makes re-importing idempotent without tracking
individual sample ids, and it means a drink deleted in the source app makes
the figure go down here instead of being stranded forever. Every day in the
window is written, zeros included, so a deletion actually propagates.

Keeping them apart is also what keeps the write paths correct. A quick-add
stores `current + amount`, so reading the COMBINED figure there would copy
every imported millilitre into the hand-logged row, and the next import would
add the imported water on top of that copy — one tap after an import would
silently double the day and compound from there. `log`, `remove` and `set`
all read the manual row; only display reads the sum.

Both platforms sum water ACROSS sources rather than taking the max the way
steps and calories do (#589): a phone and a watch measure the same walk, but
a smart bottle and a manual entry are two different real glasses.

- iOS: `.dietaryWater` as a cumulative day sum, excluded from NOOP's own
  samples by the existing `notNoopAuthored` predicate.
- Android: `HydrationRecord` over its own 30-day window (the aggregate window
  is 10 years, which would mean ~3,650 zero-fill rows) plus the READ_HYDRATION
  manifest declaration, without which Health Connect silently drops the
  runtime request.
- Both gate the write on the hydration toggle, which is opt-in and default
  OFF, so an import never populates a feature the user turned off.
- The detail screen names the imported share instead of letting the day total
  move on its own, and it is not editable there — it belongs to the app that
  logged it. Android's "Clear today" now clears only what NOOP owns.

Tests: 10 JVM tests over the two pure helpers (the same-day merge that a
plain `associate {}` would silently halve, and the zero-fill/idempotency
contract). i18n audit and doc-comment lint pass.
Found re-reviewing the PR. Both platforms swallow a per-type read failure —
Android's readAll catches and logs, iOS's collect discards the error — which
is correct for the aggregate types, because a failed type simply contributes
nothing and no row is written for it. Its own comment says so: "a partial type
is simply absent, never corrupt."

That reasoning does not survive a write that REPLACES rather than adds. For
imported water, "read nothing" and "there is nothing" arrive as the same empty
result, and the zero-fill then writes 0 for every day in the window — so a
transient Health Connect hiccup or a HealthKit query error would erase up to
30 days of imported water and report a clean import.

Both readers now say whether they actually completed, and the write is gated
on it. readAll returns Boolean and collect returns Bool (@discardableResult,
so the accumulating callers are unchanged). Nothing is lost when the read does
fail: the health store is the source of truth and the next import restores it.

collect also stopped ignoring the error argument — a nil result with an error
was previously indistinguishable from an empty day range.
@ryanbr

ryanbr commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Re-review found one real defect in this PR, fixed in 0fd2def.

Both platforms swallow a per-type read failure — Android's readAll catches and logs, iOS's collect discarded the error. That is correct for the aggregate types, and the code says so: "a partial type is simply absent, never corrupt."

That reasoning does not survive a write that replaces rather than adds. For imported water, "read nothing" and "there is nothing" arrive as the same empty result, and the zero-fill then writes 0 for every day — so a transient Health Connect hiccup or a HealthKit query error would erase up to 30 days of imported water and report a clean import.

Both readers now report whether they actually completed, and the write is gated on it. Nothing is lost when a read does fail: the health store is the source of truth and the next import restores it.

Checks still green, including the APK build and the JVM tests.

ryanbr added 2 commits July 30, 2026 12:46
…949)

Without this the whole feature is dead for everyone who already connected —
which is most users. It reads as "you have no water logged", silently, forever.

iOS: HealthKit never reports read authorization, and requestAuthorization is
only called from the connect button, so a read type added in an update stays
notDetermined for anyone who granted access before it existed and its queries
just return empty. Store a fingerprint of the read set and re-request when it
changes. Quiet by construction: HealthKit presents the sheet only for types
still undetermined, so an unchanged set shows no UI and a returning user is
asked about exactly the new one. Stored only on success, so a failed request
is retried rather than swallowed.

Android: the same gap for the same reason. The import gate is
`granted.any { ... }` by design (#150 partial grants), so an existing user goes
straight to importing and READ_HYDRATION is never requested. Same fix — compare
a stored fingerprint of PERMISSIONS and route through the request once when the
set grows. Marked before launching so declining is remembered and this never
becomes a nag.

Found by asking what an existing user would actually see, rather than what a
fresh install does.
sync() is also driven by background observer wakes, where no permission sheet
can be presented. If such a call reported success without showing anything the
signature would be stored and the user would never be asked at all — the exact
silent failure the re-request exists to prevent. Gate on the app being active;
a background wake simply leaves it for the next foreground sync.
@ryanbr

ryanbr commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Final sweep found one more, and it was the biggest: the feature would have done nothing for existing users. Fixed in 9b00966 + 1882604.

HealthKit never reports read authorization, and requestAuthorization is only called from the connect button — so a read type added in an update stays notDetermined for everyone who granted access before it existed. Queries just return empty, forever, indistinguishable from "you have no water logged". Android had the identical gap for a different reason: the import gate is granted.any { … } by design (#150 partial grants), so an existing user goes straight to importing and READ_HYDRATION is never requested.

Both now compare a stored fingerprint of the requested set and ask once when it grows. Quiet by construction — HealthKit only presents the sheet for still-undetermined types, and the Android side marks before launching so declining is remembered rather than nagged.

Follow-up on that fix: the iOS re-request is gated on the app being active. sync() is also driven by background observer wakes, and spending the request there — where no sheet can present — risked storing the signature having shown nothing, which would have been the same silent failure one level down.

Also checked and clear: no widget reads hydration, availableKeys has no callers, and Metric Explorer does not cover the hydration source, so the new series never renders raw anywhere.

Unrelated observation while tracing this: HealthKitBridge.foregroundCatchUp() is dead code — its own comment says "hook in StrandiOSApp to call", and nothing ever did. Left alone.

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.

Feature request: Apple Health integration for hydration tracking

1 participant