diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index fd8d5d0d3e..c7336a2e15 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -200,6 +200,35 @@ public enum HealthWriteback { 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:`. public static func appleHealthSleepKey(startTs: Int) -> String { appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)") diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index 8366fed45f..111b6e3ccb 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -354,4 +354,21 @@ final class HealthWritebackTests: XCTestCase { 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..d1c4b965d5 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,51 @@ 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() } + } + } + } + + 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 { + 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() { @@ -922,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), @@ -938,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 { @@ -967,6 +1022,10 @@ final class HealthKitBridge: ObservableObject { } } guard !candidates.isEmpty else { return } + let vitalsFingerprint = HealthWriteback.batchFingerprint(candidates.map { + "\($0.key)|\($0.value.bitPattern)|\($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 +1041,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 +1099,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 +1136,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 +1176,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 +1186,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 +1285,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 +1311,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 +1354,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"} } }