From da2dcdfdc6c75d928ab99a9c4f37c5ee52cfd4a3 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 12:37:24 +0300 Subject: [PATCH 1/2] fix(health): hold write-back while the phone is locked and skip rewriting unchanged batches --- .../StrandImport/HealthWriteback.swift | 27 +++++ .../HealthWritebackTests.swift | 15 +++ StrandiOS/Health/HealthKitBridge.swift | 98 +++++++++++++++++-- Tools/parity_dispositions.json | 24 +++++ Tools/parity_twin_map.json | 8 +- 5 files changed, 162 insertions(+), 10 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index fd8d5d0d3e..aeeb22558f 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -198,6 +198,33 @@ public enum HealthWriteback { /// seen the wearer awake; after `openNightMaxHoldSeconds` it is written regardless. public static func nightIsStillOpen(endTs: Int, newestHeartRateTs: Int, now: Int) -> Bool { newestHeartRateTs - endTs < openNightMarginSeconds && now - endTs < openNightMaxHoldSeconds + // MARK: - Skipping an unchanged rewrite + + /// How long an unchanged batch may go without being rewritten. The skip trusts that Health still holds + /// what was written; a daily rewrite restores anything removed there since (a user clearing NOOP's data + /// in the Health app, say) without paying for a rewrite on every sync. + public static let unchangedRewriteIntervalSeconds = 24 * 3_600 + + /// A fingerprint of a batch about to be written: one descriptor per sample, order-independent. FNV-1a + /// over the sorted descriptors, so it is stable across launches and devices, unlike `Hasher`. + public static func batchFingerprint(_ descriptors: [String]) -> String { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for descriptor in descriptors.sorted() { + for byte in descriptor.utf8 { hash = (hash ^ UInt64(byte)) &* 0x0000_0100_0000_01b3 } + hash = (hash ^ 0x0a) &* 0x0000_0100_0000_01b3 + } + return "\(descriptors.count):" + String(hash, radix: 16) + } + + /// Whether a write can be skipped: the batch is identical to the last one that saved, and that save is + /// recent enough to trust (`unchangedRewriteIntervalSeconds`). + /// + /// The write-back runs after every completed offload, about every 10 minutes while a strap is connected, + /// and each run deleted and re-saved fourteen days of sleep, vitals and workouts that had not changed. + public static func canSkipUnchangedWrite(fingerprint: String, lastFingerprint: String?, lastWrittenAt: Int?, + now: Int) -> Bool { + guard let lastFingerprint, let lastWrittenAt else { return false } + return lastFingerprint == fingerprint && now - lastWrittenAt < unchangedRewriteIntervalSeconds } /// The sleep key: `noop:sleep:`. diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index 8366fed45f..071ce972d9 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -353,5 +353,20 @@ final class HealthWritebackTests: XCTestCase { // Strap taken off to charge at wake: no newer heart rate arrives. XCTAssertTrue(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600 - 1)) XCTAssertFalse(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600)) + func testABatchFingerprintIgnoresOrderAndSeesEveryChange() { + let a = HealthWriteback.batchFingerprint(["k1|55|100", "k2|60|200"]) + XCTAssertEqual(a, HealthWriteback.batchFingerprint(["k2|60|200", "k1|55|100"])) + XCTAssertNotEqual(a, HealthWriteback.batchFingerprint(["k1|55|100", "k2|61|200"])) + XCTAssertNotEqual(a, HealthWriteback.batchFingerprint(["k1|55|100"])) + // Descriptor boundaries count: "ab"+"c" is not "a"+"bc". + XCTAssertNotEqual(HealthWriteback.batchFingerprint(["ab", "c"]), HealthWriteback.batchFingerprint(["a", "bc"])) + } + + func testAnUnchangedBatchIsSkippedOnlyWhileTheLastWriteIsRecent() { + let day = HealthWriteback.unchangedRewriteIntervalSeconds + XCTAssertTrue(HealthWriteback.canSkipUnchangedWrite(fingerprint: "f", lastFingerprint: "f", lastWrittenAt: 1_000, now: 1_000 + day - 1)) + XCTAssertFalse(HealthWriteback.canSkipUnchangedWrite(fingerprint: "f", lastFingerprint: "f", lastWrittenAt: 1_000, now: 1_000 + day)) + XCTAssertFalse(HealthWriteback.canSkipUnchangedWrite(fingerprint: "g", lastFingerprint: "f", lastWrittenAt: 1_000, now: 1_001)) + XCTAssertFalse(HealthWriteback.canSkipUnchangedWrite(fingerprint: "f", lastFingerprint: nil, lastWrittenAt: nil, now: 1_001)) } } diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index 8d3e0cb27b..a7f0927255 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -730,6 +730,14 @@ final class HealthKitBridge: ObservableObject { // No authorization is a successful no-op for a background task. The scheduler is cancelled by // its app-owned operation after observing this state, so it does not keep waking unnecessarily. guard auth == .authorized else { return true } + // Health refuses reads and writes while the phone is locked ("Protected health data is + // inaccessible"), and offloads keep completing overnight, so every locked run read fourteen days + // from the store only to fail. Owe the write instead and run it once when the phone is unlocked. + guard UIApplication.shared.isProtectedDataAvailable else { + writeBackOwedUntilUnlock = true + observeUnlockOnce() + return true + } guard !syncing else { writeBackPending = true return true @@ -747,6 +755,44 @@ final class HealthKitBridge: ObservableObject { } } + /// A write-back arrived while the phone was locked and has not run yet. + private var writeBackOwedUntilUnlock = false + private var unlockObserver: NSObjectProtocol? + + /// Run the owed write-back once protected data becomes available again. + private func observeUnlockOnce() { + guard unlockObserver == nil else { return } + unlockObserver = NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + if let observer = self.unlockObserver { NotificationCenter.default.removeObserver(observer) } + self.unlockObserver = nil + guard self.writeBackOwedUntilUnlock else { return } + self.writeBackOwedUntilUnlock = false + Task { await self.writeBackAfterNewData() } + } + } + } + + /// UserDefaults key for the last batch written of one kind (`HealthWriteback.batchFingerprint`). + private func writtenBatchKey(_ kind: String) -> String { "hkWrittenBatch.v1.\(kind)" } + + /// Whether this kind's batch is unchanged since it last saved (`HealthWriteback.canSkipUnchangedWrite`). + private func isUnchangedBatch(_ kind: String, fingerprint: String) -> Bool { + let last = UserDefaults.standard.dictionary(forKey: writtenBatchKey(kind)) + return HealthWriteback.canSkipUnchangedWrite( + fingerprint: fingerprint, lastFingerprint: last?["fingerprint"] as? String, + lastWrittenAt: last?["writtenAt"] as? Int, now: Int(Date().timeIntervalSince1970)) + } + + /// Record a batch that saved, so an identical one can be skipped (`isUnchangedBatch`). + private func recordWrittenBatch(_ kind: String, fingerprint: String) { + UserDefaults.standard.set(["fingerprint": fingerprint, "writtenAt": Int(Date().timeIntervalSince1970)], + forKey: writtenBatchKey(kind)) + } + /// Release the single-flight gate and service one coalesced fresh-data signal. Scheduling a new task /// (rather than recursing in the defer) guarantees the current pass has fully returned first. private func finishHealthPass() { @@ -967,6 +1013,10 @@ final class HealthKitBridge: ObservableObject { } } guard !candidates.isEmpty else { return } + let vitalsFingerprint = HealthWriteback.batchFingerprint(candidates.map { + "\($0.key)|\($0.sample.quantity)|\($0.sample.startDate.timeIntervalSince1970)" + }) + guard !isUnchangedBatch("vitals", fingerprint: vitalsFingerprint) else { return } // Delete any of OUR prior samples that carry the same metadata keys, then write the fresh // batch. Scoped to HKSource.default() so we never touch a sample written by another app @@ -982,6 +1032,7 @@ final class HealthKitBridge: ObservableObject { _ = try? await self.store.deleteObjects(of: type, predicate: pred) } try await self.store.save(candidates.map { $0.sample }) + recordWrittenBatch("vitals", fingerprint: vitalsFingerprint) } /// Write each BRIDGED NIGHT (#364) as one `.inBed` sample plus one category sample per stage @@ -1039,12 +1090,18 @@ final class HealthKitBridge: ObservableObject { } } guard !samples.isEmpty else { return } + let sleepFingerprint = HealthWriteback.batchFingerprint(samples.map { + "\($0.metadata?[HKMetadataKeyExternalUUID] ?? "")|\($0.value)|\($0.startDate.timeIntervalSince1970)" + + "|\($0.endDate.timeIntervalSince1970)" + } + keys.map { "key|\($0)" }) + guard !isUnchangedBatch("sleep", fingerprint: sleepFingerprint) else { return } let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ HKQuery.predicateForObjects(from: HKSource.default()), HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: keys), ]) _ = try? await store.deleteObjects(of: type, predicate: pred) try await store.save(samples) + recordWrittenBatch("sleep", fingerprint: sleepFingerprint) } /// UserDefaults key for the HR write cursor (the newest bucket ts we've written). Per-strap so a @@ -1070,18 +1127,34 @@ final class HealthKitBridge: ObservableObject { to: nowTs, bucketSeconds: 60)) ?? [] guard !buckets.isEmpty else { return } + // The 48 h behind the cursor was deleted and re-saved on every run (~2 880 samples every ~10 min + // while a strap is connected) to catch a late offload into it. Fingerprint that span as written; when + // it is unchanged, only the minutes past the cursor are new, and only they are deleted and saved. + // The newest minutes are left out of the fingerprint and always rewritten: the bucket at the cursor + // was usually still filling when it was saved, and its mean moves once the rest of its minute lands. + let settleSeconds = 5 * 60 + func tailFingerprint(through cursorTs: Int) -> String { + let settled = cursorTs - settleSeconds + return HealthWriteback.batchFingerprint(buckets.filter { $0.ts >= cursorTs - 48 * 3600 && $0.ts <= settled } + .map { "\($0.ts)|\($0.bpm)" }) + } + let tailUnchanged = cursor > 0 && isUnchangedBatch("heartRateTail", fingerprint: tailFingerprint(through: cursor)) + let writeFrom = tailUnchanged ? cursor - settleSeconds : windowStart + let toWrite = tailUnchanged ? buckets.filter { $0.ts > writeFrom } : buckets + guard !toWrite.isEmpty else { return } + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ HKQuery.predicateForObjects(from: HKSource.default()), - HKQuery.predicateForSamples(withStart: Date(timeIntervalSince1970: TimeInterval(windowStart)), + HKQuery.predicateForSamples(withStart: Date(timeIntervalSince1970: TimeInterval(writeFrom)), end: Date(timeIntervalSince1970: TimeInterval(nowTs) + 60), - options: []), + options: tailUnchanged ? [.strictStartDate] : []), ]) _ = try? await store.deleteObjects(of: type, predicate: pred) let unit = HKUnit.count().unitDivided(by: .minute()) var samples: [HKQuantitySample] = [] - samples.reserveCapacity(buckets.count) - for b in buckets { + samples.reserveCapacity(toWrite.count) + for b in toWrite { let start = Date(timeIntervalSince1970: TimeInterval(b.ts)) // Span the bucket, clamped so a bucket at the window edge can't end in the future // (HealthKit rejects future-dated samples). @@ -1094,7 +1167,7 @@ final class HealthKitBridge: ObservableObject { // transaction is oversized. Cursor only advances past what actually saved. var lastSaved = cursor var pending = samples[...] - var pendingTs = buckets.map(\.ts)[...] + var pendingTs = toWrite.map(\.ts)[...] while !pending.isEmpty { let chunk = Array(pending.prefix(5000)) let chunkTs = Array(pendingTs.prefix(5000)) @@ -1104,6 +1177,7 @@ final class HealthKitBridge: ObservableObject { lastSaved = max(lastSaved, chunkTs.last ?? lastSaved) UserDefaults.standard.set(lastSaved, forKey: hrWriteCursorKey) } + recordWrittenBatch("heartRateTail", fingerprint: tailFingerprint(through: lastSaved)) } /// Write strap-detected and manual workouts into Health via `HKWorkoutBuilder`, with an @@ -1202,6 +1276,14 @@ final class HealthKitBridge: ObservableObject { let rows = byKey.values.sorted { $0.startTs < $1.startTs } func key(_ row: WorkoutRow) -> String { HealthWriteback.appleHealthWorkoutKey(startTs: row.startTs) } + // Unchanged rows and a complete read: nothing to reconcile or rewrite. A failed or capped read never + // matches a recorded batch, so the orphan pass below still gets its complete window. + let readComplete = mineRead != nil && computedRead != nil + && mine.count < Self.workoutReadLimit && computed.count < Self.workoutReadLimit + let workoutsFingerprint = HealthWriteback.batchFingerprint(rows.map { + "\(key($0))|\($0.endTs)|\($0.sport)|\($0.energyKcal ?? -1)|\($0.distanceM ?? -1)" + } + ["window|\(fromTs / 86_400)|complete=\(readComplete)"]) + if readComplete, isUnchangedBatch("workouts", fingerprint: workoutsFingerprint) { return } // #2210: remove workouts we wrote that the store no longer holds. The delete below only ever // names the keys it is about to rewrite, so a row that LEFT the store keeps its Health copy for @@ -1220,7 +1302,10 @@ final class HealthKitBridge: ObservableObject { await deleteOrphanedWorkouts(fromTs: fromTs, toTs: toTs, keeping: Set(rows.map(key))) } - guard !rows.isEmpty else { return } + guard !rows.isEmpty else { + if readComplete { recordWrittenBatch("workouts", fingerprint: workoutsFingerprint) } + return + } let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ HKQuery.predicateForObjects(from: HKSource.default()), HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, @@ -1260,6 +1345,7 @@ final class HealthKitBridge: ObservableObject { throw error } } + if readComplete { recordWrittenBatch("workouts", fingerprint: workoutsFingerprint) } } /// Reverse of `sportName`: NOOP's sport label → the `HKWorkoutActivityType` written to Health. diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 3dcf98fd10..211099cbf4 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -24,6 +24,30 @@ "identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb", "platform": "swift", "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::unchangedRewriteIntervalSeconds", + "identity_sha256": "21b652991cbcccbade6fb8090505d865c3f0151babdfab3b216cd92c2d3b2f80", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to skip rewriting an unchanged batch; the Android Health Connect exporter does not call it yet (tracked in the PR)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::batchFingerprint/1#1", + "identity_sha256": "d7af38b439901b5da88eb4dd28f901538d156e1ccd91ccf099d7cd8b91b88e48", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to skip rewriting an unchanged batch; the Android Health Connect exporter does not call it yet (tracked in the PR)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::canSkipUnchangedWrite/4#1", + "identity_sha256": "85fc78a25cbd9987c5b651e223328b88e20549bc28c2be239c13a2c44ae572df", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to skip rewriting an unchanged batch; the Android Health Connect exporter does not call it yet (tracked in the PR)." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 53f3770c23..d9b1dca565 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,16 +19,16 @@ }, "authority": { "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4455, "sha256": "a07ca22b2e581e8b6feadae9bdb1e4d645d46196921796df3a2be6cdf8c2ef7b"}, + "functions": {"count": 4457, "sha256": "169b5dc9e03ad3747060b326337446f314338f56fc0df574e34de4978036191c"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, - "constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"}, + "constants": {"count": 1952, "sha256": "dd73d86a7513f0569c78488b435183beb26774cd9e57dcaab342ec32695bed09"}, "file_pairs": {"count": 68, "sha256": "414dbafb27e1e35cf65cff54f6ff780f102f009762c1dfb12d91b0980fe30854"}, "function_pairs": {"count": 176, "sha256": "e3a74634d5a9381cf6e3491df839dad5ab33ec6cee8f29eb77c47ba7974b2e76"}, "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, "constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"}, "unpaired_files": {"count": 384, "sha256": "17285ce29f015a373969bbcb13042b100a15d580825785f824680e0880b5d777"}, - "unpaired_functions": {"count": 4109, "sha256": "a3ec845a9b802edc70c7f018b6ecd56889022b7d95e9738c29dbe0fde6d4af5e"}, + "unpaired_functions": {"count": 4111, "sha256": "967eb61a4a0f7bc960221c40768049a1c3d811b679ebf88081a1c6b4afc312f8"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, - "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} + "unpaired_constants": {"count": 596, "sha256": "124e57b4039a968f34ea49eea2443f451477b1dc4cbc8d3be0221e22058549ea"} } } From 0fbebd0c185500abfa1ca61c973566a3a562b0fc Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Mon, 21 Sep 2026 13:35:06 +0300 Subject: [PATCH 2/2] health: restore two braces the rebase dropped; key the batch fingerprint per strap; fingerprint vitals by value The rebase onto #2294 dropped the closing brace of nightIsStillOpen and of its test, so StrandImport and both app builds failed. The written-batch key now carries the strap id like hrWriteCursorKey, and the vitals fingerprint uses the value each sample was built from (bit pattern, fixed unit per metric) instead of HKQuantity's description. The unlock observer is removed in deinit. --- .../StrandImport/HealthWriteback.swift | 2 ++ .../HealthWritebackTests.swift | 2 ++ StrandiOS/Health/HealthKitBridge.swift | 19 ++++++++++++++----- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index aeeb22558f..c7336a2e15 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -198,6 +198,8 @@ public enum HealthWriteback { /// seen the wearer awake; after `openNightMaxHoldSeconds` it is written regardless. public static func nightIsStillOpen(endTs: Int, newestHeartRateTs: Int, now: Int) -> Bool { newestHeartRateTs - endTs < openNightMarginSeconds && now - endTs < openNightMaxHoldSeconds + } + // MARK: - Skipping an unchanged rewrite /// How long an unchanged batch may go without being rewritten. The skip trusts that Health still holds diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index 071ce972d9..111b6e3ccb 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -353,6 +353,8 @@ final class HealthWritebackTests: XCTestCase { // Strap taken off to charge at wake: no newer heart rate arrives. XCTAssertTrue(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600 - 1)) XCTAssertFalse(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600)) + } + func testABatchFingerprintIgnoresOrderAndSeesEveryChange() { let a = HealthWriteback.batchFingerprint(["k1|55|100", "k2|60|200"]) XCTAssertEqual(a, HealthWriteback.batchFingerprint(["k2|60|200", "k1|55|100"])) diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index a7f0927255..d1c4b965d5 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -776,8 +776,15 @@ final class HealthKitBridge: ObservableObject { } } - /// UserDefaults key for the last batch written of one kind (`HealthWriteback.batchFingerprint`). - private func writtenBatchKey(_ kind: String) -> String { "hkWrittenBatch.v1.\(kind)" } + deinit { + // The observer only removes itself once it fires; a bridge released first would leave it registered. + if let unlockObserver { NotificationCenter.default.removeObserver(unlockObserver) } + } + + /// UserDefaults key for the last batch written of one kind (`HealthWriteback.batchFingerprint`). Per strap, + /// like `hrWriteCursorKey`: on a two-strap install the heart-rate cursor moves with the strap, and a + /// fingerprint shared across straps would describe a different strap's batch. + private func writtenBatchKey(_ kind: String) -> String { "hkWrittenBatch.v1.\(noopDeviceId).\(kind)" } /// Whether this kind's batch is unchanged since it last saved (`HealthWriteback.canSkipUnchangedWrite`). private func isUnchangedBatch(_ kind: String, fingerprint: String) -> Bool { @@ -968,7 +975,9 @@ final class HealthKitBridge: ObservableObject { for r in imported { byDay[r.day] = HealthExportMerge.merged(computed: byDay[r.day], imported: r) } let rows = byDay.keys.sorted().filter { !holdingDays.contains($0) }.map { byDay[$0]! } - struct Candidate { let type: HKQuantityType; let key: String; let sample: HKQuantitySample } + // `value` is the number the sample was built from, in its metric's fixed unit, so the fingerprint below + // does not depend on how HealthKit formats a quantity. + struct Candidate { let type: HKQuantityType; let key: String; let value: Double; let sample: HKQuantitySample } var candidates: [Candidate] = [] func add(_ id: HKQuantityTypeIdentifier, _ unit: HKUnit, _ value: Double, _ day: String, _ at: Date) { guard let type = HKQuantityType.quantityType(forIdentifier: id), @@ -984,7 +993,7 @@ final class HealthKitBridge: ObservableObject { start: at, end: at, metadata: [HKMetadataKeyExternalUUID: key] ) - candidates.append(Candidate(type: type, key: key, sample: sample)) + candidates.append(Candidate(type: type, key: key, value: value, sample: sample)) } for row in rows { @@ -1014,7 +1023,7 @@ final class HealthKitBridge: ObservableObject { } guard !candidates.isEmpty else { return } let vitalsFingerprint = HealthWriteback.batchFingerprint(candidates.map { - "\($0.key)|\($0.sample.quantity)|\($0.sample.startDate.timeIntervalSince1970)" + "\($0.key)|\($0.value.bitPattern)|\($0.sample.startDate.timeIntervalSince1970)" }) guard !isUnchangedBatch("vitals", fingerprint: vitalsFingerprint) else { return }