Skip to content
Open
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
29 changes: 29 additions & 0 deletions Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:<startTs>`.
public static func appleHealthSleepKey(startTs: Int) -> String {
appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
111 changes: 103 additions & 8 deletions StrandiOS/Health/HealthKitBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
]
}
8 changes: 4 additions & 4 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
}
Loading