Repository navigation
Description
Every identity NOOP writes into Apple Health embeds the active strap id. That id is not durable — it
changes when a strap is re-paired or identified by serial — and when it changes, everything previously
written becomes unreachable to the code that maintains it.
Where the id gets into the key
HealthKitBridge is constructed with noopDeviceId: model.deviceId (StrandiOS/App/StrandiOSApp.swift),
which is the active strap id, and stamps it into each external identity:
| Record | HKMetadataKeyExternalUUID |
Site |
|---|---|---|
| daily metrics | noop:<activeId>:<metric>:<day> |
HealthKitBridge.swift:749 |
| sleep | noop:<activeId>:sleep:<startTs> |
HealthKitBridge.swift:831 |
| workouts | noop:<activeId>:workout:<startTs> |
HealthKitBridge.swift:940 |
Why that strands records
Each of those three write paths reconciles the same way — delete first, then write:
HKQuery.predicateForObjects(from: HKSource.default()),
HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: keys),keys is always built from the id that is active now. So after the active id changes:
- records written under the old id no longer match the delete predicate — they are never selected;
- the write proceeds and adds fresh records under the new id;
- the old ones stay in Apple Health permanently, and the bridge cannot clean them up later because it no
longer knows the key they were written under.
The user sees duplicated days, sleeps and workouts in Apple Health after re-pairing a strap, with no way to
get rid of them from inside NOOP.
Not affected
The heart-rate export (HealthKitBridge.swift:886) deletes by date range plus HKSource.default()
rather than by external UUID, so it is immune to the id change and does not duplicate.
Android
Not affected. Its Health Connect clientRecordId carries no device id at all —
noop-workout-<startTs> (HealthConnectWriter.kt:318) — so a re-pair changes nothing about record
identity. Same structural asymmetry as #1501: Apple derives identity from a mutable id, Android does not.
Options
- Pin the key to the canonical id (
"my-whoop"), which is stable in every state. Smallest change;
still leaves a<id>segment that carries no information once it is constant. - Drop the id segment entirely —
noop:workout:<startTs>— matching what Android already does and has
had no trouble with. Cleanest, and makes the key mean what it is: a natural key.
Either way the records already stranded under an old id need a migration, and by construction the bridge
cannot find them by key. A one-off sweep by HKSource.default() plus date range — the way the heart-rate
path already deletes — would reach them, but it would also delete records written under the current key,
so it has to run before a re-write rather than beside one.
Scope
Split out of #1501. Related root cause — an active strap id treated as durable — but a separate fix: this
one lives entirely in the export identity and is untouched by any of the options in that issue, which all
concern where rows are stored in NOOP's own database.
Activity
health: track the ryanbr#1503 stranded-records sweep per-type, not as…
health: drop the active strap id from Apple Health external UUID keys
merge: bring in 120 upstream RyanBR commits (through 5cb1347)