8000 feat(health): add bounded Apple Health context import by shroominic · Pull Request #71 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Draft
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
110 changes: 110 additions & 0 deletions docs/architecture/adr/0005-bounded-apple-health-context-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# ADR 0005: Use bounded anchored Apple Health context import

- Status: Accepted
- Date: 2026-08-24
- Owners: `@shroominic`

## Context

OpenGlucose needs optional local context for sleep, workouts, and heart rate.
An initial full HealthKit read can be large, repeat records on every retry, and
miss source deletions. Apple does not disclose whether read access was granted;
an empty read and an unavailable read can look the same to an app.

The source-aware identity and tombstone contract from ADR 0004 is the required
storage dependency. HealthKit UUIDs, source app/device metadata, and anchors
are restricted health metadata. They must not enter logs, exports, analytics,
or default UI.

## Decision

Use an iOS-only native `HKAnchoredObjectQuery` channel for these exact types:

- sleep analysis;
- workouts; and
- heart rate.

The user must enable the separate context-import toggle and then press
**Import now**. Each request has a rolling 30-day range. Dart rejects ranges
over 31 days, and the native channel repeats that bound before querying.
No background observer or automatic import is started.

The native channel returns a versioned, fail-closed payload. It maps each
HealthKit UUID to a typed Apple Health import identity, retains local-only
source provenance, and returns deletion UUIDs as tombstones. There is one
opaque anchor per type in a separate versioned import-state file. The iOS
directory, file, and transaction artifacts are backup-excluded before use. A
binary that does not recognize a future import-state schema fails closed and
does not rewrite it. This keeps the import cursor separate from the strict
sensor/glucose restricted-state schema and permits a safe app downgrade.
Draft builds that wrote an import cursor into the strict v3 file discard that
cursor during upgrade and repeat a bounded source read; source-aware upserts
make that replay safe. They do not migrate opaque anchors across the two state
contracts.

The app persists samples/tombstones and then purges only Apple Health records
that have fallen before the current 30-day predicate before it persists that
type's next anchor. This explicit source/type-scoped expiry handles the fact
that an anchored query with a moving date predicate cannot later report every
deletion for an aged-out record. If the purge or local state write fails, that
type's anchor does not advance; a later user-triggered sync safely repeats the
bounded work.

The UI distinguishes an unavailable device, a locked device, a retryable
failure, a read-access request, a ready state, and **No accessible data**. It
must not claim that a read permission was granted. Missing
`HKMetadataKeyWasUserEntered` is retained as an unknown recording method, not
assumed to be automatic.

## Alternatives considered

- Use Flutter's generic Health package for all reads: rejected because this
scope requires iOS anchored changes and deletion records.
- Read every HealthKit record without a date predicate: rejected because the
user action would be unbounded.
- Treat an empty response as permission denial: rejected because HealthKit
intentionally does not reveal read authorization.
- Add Health Connect, historical glucose import, overlays/charts, or source
precedence here: rejected as separate product and safety decisions.

## Consequences

- Repeated source records replace local rows by stable identity rather than
duplicating them.
- Returned source deletions remove visible imported rows through tombstones.
- On each successful sync, the importer expires source/type records that fall
before that sync's rolling 30-day cutoff without fabricating a source
tombstone. Turning the toggle off does not itself erase retained context.
- A full native page can require another explicit sync; the UI states this
conservatively instead of starting a hidden long-running import.
- Turning the toggle off stops future app reads. It does not delete retained
local imported context; verified deletion remains separate work.

## Follow-up controls

- Establish source-overlap and display policy before rendering imported context
behind glucose data.
- Decide retention beyond the current 30-day bounded window and complete local
deletion for imported context.
- Add reviewed background-delivery policy only after explicit user controls and
physical-device evidence.
- Evaluate Health Connect separately; it must not reuse iOS authorization or
anchor assumptions.

## Required physical-device evidence before broader rollout

This partial implementation is not evidence-complete until a real iPhone with
Health data records each case below without copying values or identifiers into
test logs or issue comments:

- opt-in and off behavior, including the native authorization sheet;
- an empty or unreadable read shown only as **No accessible data**;
- HealthKit unavailable, a locked device, and a retryable native error mapped
to their safe public states;
- sleep, workout, and heart-rate import with a source revision/update and a
returned deletion;
- a record that ages out of the 30-day predicate removed before its next anchor
advances, including an interrupted/retried sync; and
- backup exclusion for the import-state directory, primary file, and staging
artifacts, plus an update/downgrade/roll-forward check that preserves an
unknown future state file.
1 change: 1 addition & 0 deletions docs/architecture/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enforcement detail is already implemented.
| [0002](0002-local-first-health-data.md) | Accepted | Keep health data local by default and make movement explicit |
| [0003](0003-platform-release-model.md) | Accepted | Build source-bound mobile artifacts and release only through fail-closed platform lanes |
| [0004](0004-source-aware-import-identity.md) | Accepted | Keep imported health-record identity and provenance local for deterministic reconciliation |
| [0005](0005-bounded-apple-health-context-import.md) | Accepted | Read only user-enabled Apple Health context through bounded, anchored iOS queries |

## Adding an ADR

Expand Down
44 changes: 33 additions & 11 deletions docs/privacy/data-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,15 @@ The local health SQLite store can retain platform-owned import identities,
source application/package, source name/device, recording method, revision, and
deletion tombstones for deterministic reconciliation. These are restricted
health metadata. They are not included in the current archive export, default
timeline/UI, analytics, or production logs. Native HealthKit/Health Connect
reading and incremental sync are not implemented by this storage contract.
timeline/UI, analytics, or production logs. The iOS Apple Health context reader
uses this contract only after explicit opt-in and a user-triggered import. It
reads sleep, workouts, and heart rate in a rolling 30-day bounded query with
one opaque anchor per type in a dedicated versioned import-state file. On iOS,
that directory and its transaction artifacts are verified backup-excluded; an
unknown future import-state version fails closed without being rewritten.
Health Connect, background delivery,
historical glucose import, source-overlap policy, and context display remain
unimplemented.

Display-only preferences may remain in platform preferences. In an actual web
build, `PreferencesHealthStateStore` delegates to `shared_preferences`, whose
Expand Down Expand Up @@ -84,15 +91,30 @@ clearing one selected sensor is not equivalent to complete erasure.

## Sharing and export

The iOS app implements an opt-in, write-only Apple Health integration. It sends
glucose values and their timestamps as blood-glucose samples only after the
user enables the integration and taps **Sync now**; simulated/demo data is
blocked. It does not read HealthKit data. Apple Health's privacy, retention,
backup, and deletion behavior applies once a sample is written. The local
contiguous export watermark and last-sync time use the restricted state store,
not backup-eligible preferences. Because a HealthKit write and the local cursor
cannot commit atomically, an interrupted sync can write a duplicate sample on
retry. The UI discloses this at-least-once behavior.
The iOS app implements two separate, opt-in Apple Health integrations.
Glucose export sends glucose values and their timestamps as blood-glucose
samples only after the user enables it and taps **Sync now**; simulated/demo
data is blocked. Its local contiguous export watermark and last-sync time use
the restricted state store, not backup-eligible preferences. Because a
HealthKit write and the local cursor cannot commit atomically, an interrupted
sync can write a duplicate sample on retry. The UI discloses this at-least-once
behavior.

Apple Health context import reads only sleep, workouts, and heart rate after
the user enables that separate control and taps **Import now**. Each sync is a
bounded rolling 30-day read. It retains source identity/provenance and opaque
per-type anchors in a separate versioned, backup-excluded iOS import-state
file for repeat reconciliation, and applies returned source deletions locally.
Before an anchor advances, it removes only matching Apple Health records that
have aged out of the rolling predicate; it does not fabricate source deletion
records. Apple does not disclose read permission to the app, so the
UI says **No accessible data** when a query returns no records; it does not
claim that access was granted or denied. It does not import blood glucose,
start background delivery, or show raw imported values in the default UI.
Disabling the toggle stops future reads but does not erase retained context;
the verified complete-delete flow remains unimplemented. Apple Health's
privacy, retention, backup, and deletion behavior applies after data enters
that platform or app storage.

Archived sensor details provide an explicit single-file export through the
platform share sheet. A confirmation preview displays the sensor-session date
Expand Down
32 changes: 17 additions & 15 deletions docs/product-foundations-2026.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,12 @@ disconnect or replace.

## Apple Health import and overlays

The current integration exports glucose only. Import must not ship until the
schema retains HealthKit UUID, source bundle/name, device, recording method,
interval, and deletion state with unique constraints; the current methods named
`upsert` are append-only and would duplicate repeated imports.
The current first import ships only a narrow iOS context reader. It uses the
source-aware schema for HealthKit UUID, source bundle/name, device, recording
method, interval, and deletion state. Its upserts reconcile only records with
stable platform identities. The broader import plan below remains unshipped.

Initial read categories:
Future read categories, outside the current partial implementation:

- sleep intervals and stages;
- workouts, active energy, exercise time, steps, and walking/running distance;
Expand All @@ -102,16 +102,18 @@ glucose line. Imported raw samples remain separate; display normalization uses
per-type source priority so overlapping Apple Watch, Whoop, Oura, and iPhone
records are not blindly summed.

The local storage contract now retains typed platform identity, source
provenance, revisions, and tombstones. It de-duplicates a source record only
when the platform supplies that stable identity; it does not yet read either
platform, request permission, retain sync anchors, or choose an overlap winner.
MVP can bounded-requery with the existing Flutter `health` package and upsert by
HealthKit UUID. Production incremental sync uses native
`HKAnchoredObjectQuery`, one persisted anchor per type, and handles deleted
objects. Permissions are progressive and requested only when the user enables
an overlay. iOS can make denied and empty reads indistinguishable, so the UI
reports `No accessible data` rather than claiming a permission outcome.
The local storage contract retains typed platform identity, source provenance,
revisions, and tombstones. The iOS context-import implementation now uses native
`HKAnchoredObjectQuery` for user-triggered, bounded sleep/workout/heart-rate
imports. It persists one anchor per type in a separate versioned,
backup-excluded import-state file only after matching local changes and
source/type-scoped rolling-window expiry are durable, and applies returned
deleted objects as tombstones.
The first UI is a dedicated opt-in control, not an overlay. iOS can make denied
and empty reads indistinguishable, so it reports `No accessible data` rather
than claiming a permission outcome. Health Connect, historical glucose import,
background delivery, source-overlap policy, and visual overlays remain future
work.

References:

Expand Down
4 changes: 4 additions & 0 deletions openhealth/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
C22A0F3121F3DC145DF706B6 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D855B9851A289C72801204A3 /* GlucoseLiveActivityAttributes.swift */; };
E14A0002F2D54BCB87610002 /* PrivacyStorageChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A0001F2D54BCB87610001 /* PrivacyStorageChannel.swift */; };
E13A0002F2D54BCB87610002 /* NativeRestrictedStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13A0001F2D54BCB87610001 /* NativeRestrictedStateStore.swift */; };
E15A0002F2D54BCB87610002 /* HealthKitContextImportChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15A0001F2D54BCB87610001 /* HealthKitContextImportChannel.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -110,6 +111,7 @@
E9A3F3866D70875832C57844 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/SwiftUI.framework; sourceTree = DEVELOPER_DIR; };
E14A0001F2D54BCB87610001 /* PrivacyStorageChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyStorageChannel.swift; sourceTree = "<group>"; };
E13A0001F2D54BCB87610001 /* NativeRestrictedStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeRestrictedStateStore.swift; sourceTree = "<group>"; };
E15A0001F2D54BCB87610001 /* HealthKitContextImportChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitContextImportChannel.swift; sourceTree = "<group>"; };
E13A0003F2D54BCB87610003 /* LiveActivity.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = LiveActivity.xcconfig; path = Flutter/LiveActivity.xcconfig; sourceTree = "<group>"; };
F1661D12E3A5B0B8F7EADF7F /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/ActivityKit.framework; sourceTree = DEVELOPER_DIR; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -244,6 +246,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
E14A0001F2D54BCB87610001 /* PrivacyStorageChannel.swift */,
E15A0001F2D54BCB87610001 /* HealthKitContextImportChannel.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
16C4FAEE38C24B8B8A121001 /* AidexBackgroundMonitor.swift */,
E13A0001F2D54BCB87610001 /* NativeRestrictedStateStore.swift */,
Expand Down Expand Up @@ -506,6 +509,7 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
E14A0002F2D54BCB87610002 /* PrivacyStorageChannel.swift in Sources */,
E15A0002F2D54BCB87610002 /* HealthKitContextImportChannel.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
16C4FAEF38C24B8B8A121001 /* AidexBackgroundMonitor.swift in Sources */,
Expand Down
4 changes: 4 additions & 0 deletions openhealth/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var privacyStorageChannel: PrivacyStorageChannel?
private var healthKitContextImportChannel: HealthKitContextImportChannel?

override func application(
_ application: UIApplication,
Expand Down Expand Up @@ -37,5 +38,8 @@ import UIKit
with: messenger
)
privacyStorageChannel = PrivacyStorageChannel(messenger: messenger)
healthKitContextImportChannel = HealthKitContextImportChannel(
messenger: messenger
)
}
}
Loading
0