diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index d873cad..5d17895 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -157,6 +157,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -194,6 +195,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Bedtime/Bedtime-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Bedtime/Bedtime/Bedtime-Bridging-Header.h b/Bedtime/Bedtime/Bedtime-Bridging-Header.h new file mode 100644 index 0000000..f85d05c --- /dev/null +++ b/Bedtime/Bedtime/Bedtime-Bridging-Header.h @@ -0,0 +1,6 @@ +// +// Bedtime-Bridging-Header.h +// Bedtime +// + +#import "Utils/KVCSafeAccessor.h" diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 1af4dc3..9f677f4 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -254,7 +254,8 @@ class HealthKitManager: ObservableObject { } private func processSleepSamples(_ samples: [HKCategorySample]) { - let allSessions = samples.compactMap { SleepSession(sample: $0) } + let dedupedSamples = SleepSampleDeduplicator.deduplicate(samples) + let allSessions = dedupedSamples.compactMap { SleepSession(sample: $0) } self.allSleepSessions = Dictionary(grouping: allSessions) { $0.dateForGrouping } let includedSessions = allSessions.filter { diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index e1688ee..0269f59 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -29,6 +29,12 @@ struct SleepSession { } var dateForGrouping: Date { + Self.dateForGrouping(startDate: startDate, duration: duration) + } + + /// Exposed statically so other code that buckets raw samples into "nights" (e.g. duplicate + /// filtering, before `SleepSession`s exist yet) can use the exact same rule. + static func dateForGrouping(startDate: Date, duration: TimeInterval) -> Date { let midpoint = startDate.addingTimeInterval(duration / 2) let shiftedMidpoint = midpoint.addingTimeInterval(TimeInterval(6 * 60 * 60)) return Calendar.current.startOfDay(for: shiftedMidpoint) diff --git a/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift b/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift new file mode 100644 index 0000000..0bd0fe4 --- /dev/null +++ b/Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift @@ -0,0 +1,107 @@ +// +// SleepSampleDeduplicator.swift +// Bedtime +// +// Some sources (notably Oura) have a "double sync" bug: syncing a night, then falling back +// asleep and syncing again, re-writes the same stretch of stage samples a second time instead +// of only writing the newly-recorded tail. Left alone, that inflates every duration total this +// app computes (Sleep Bank balance, per-night totals, source comparisons) by roughly 2x for +// the affected night. +// +// HealthKit can only ever delete samples an app wrote itself, and these duplicates always come +// from the other source (Oura), never Bedger -- so there's no way to actually remove them from +// HealthKit. Instead, this filters the older, superseded sync out of Bedger's own calculations, +// using the same signal a person would use if they went looking in the Health app: "Date +// Added." See `HealthKitCreationDateReader`. +// + +import Foundation +import HealthKit + +enum SleepSampleDeduplicator { + /// Removes the "old", superseded sync from any same-source, same-night duplicate re-sync in + /// `samples`, so summed durations aren't inflated by data a source wrote twice. Preserves + /// `samples`' original relative order — callers fetch (and downstream code like + /// `SleepDayGroup`/`LastNightCard` reads `.first`/`.last` off) samples sorted newest-first, + /// so this only ever removes elements, never reorders them. + static func deduplicate(_ samples: [HKCategorySample]) -> [HKCategorySample] { + struct Key: Hashable { + let night: Date + let bundleID: String + } + + let grouped = Dictionary(grouping: samples) { sample in + Key( + night: SleepSession.dateForGrouping(startDate: sample.startDate, duration: sample.endDate.timeIntervalSince(sample.startDate)), + bundleID: sample.sourceRevision.source.bundleIdentifier + ) + } + + let toDropIDs = Set(grouped.values.flatMap(samplesToDrop).map(\.uuid)) + guard !toDropIDs.isEmpty else { return samples } + return samples.filter { !toDropIDs.contains($0.uuid) } + } + + /// Resolves one (night, source) group and returns just the samples that should be + /// discarded. Only touches groups that show the structural signature of a duplicate + /// re-sync -- same-source samples overlapping in time, which never happens within a single + /// healthy sync since stage segments are sequential -- and only when "Date Added" data is + /// rich enough to place a meaningful split. Otherwise drops nothing: we never guess at + /// removing data without a real signal to justify it. + private static func samplesToDrop(_ samples: [HKCategorySample]) -> [HKCategorySample] { + guard samples.count > 1, hasOverlap(samples) else { return [] } + + let creationDatesByID = Dictionary(uniqueKeysWithValues: samples.map { + ($0.uuid, HealthKitCreationDateReader.creationDate(for: $0)) + }) + let knownCreationDates = creationDatesByID.values.compactMap { $0 } + let distinctCreationDates = Array(Set(knownCreationDates)).sorted() + + // Need at least two distinct sync times, known for at least half the group, to place a + // meaningful divider between "old sync" and "new sync." + guard distinctCreationDates.count >= 2, knownCreationDates.count * 2 >= samples.count else { return [] } + + guard let cutoff = suggestedCutoff(distinctCreationDates) else { return [] } + + // Samples with no readable creation date are always kept -- only ever drop what we can + // positively identify as belonging to the older sync. + return samples.filter { sample in + // Dictionary lookup returns `Date??`: the outer optional is always populated here + // (every sample was inserted above), the inner one is `nil` when the creation date + // itself couldn't be read. + guard case .some(.some(let creationDate)) = creationDatesByID[sample.uuid] else { return false } + return creationDate < cutoff + } + } + + /// True when at least two samples from this source overlap in time -- back-to-back sleep + /// stages from a single sync never do, so this is specific to duplicate re-syncs. + private static func hasOverlap(_ samples: [HKCategorySample]) -> Bool { + let sorted = samples.sorted { $0.startDate < $1.startDate } + guard sorted.count > 1 else { return false } + var runningEnd = sorted[0].endDate + for sample in sorted.dropFirst() { + if sample.startDate < runningEnd { return true } + runningEnd = max(runningEnd, sample.endDate) + } + return false + } + + /// The midpoint of the largest gap between consecutive distinct creation timestamps -- i.e. + /// the boundary between two sync batches. `distinctCreationDates` must already be sorted + /// ascending and have at least two entries. + private static func suggestedCutoff(_ distinctCreationDates: [Date]) -> Date? { + guard distinctCreationDates.count > 1 else { return nil } + + var bestGapIndex = 0 + var bestGap: TimeInterval = -1 + for index in 1.. bestGap { + bestGap = gap + bestGapIndex = index - 1 + } + } + return distinctCreationDates[bestGapIndex].addingTimeInterval(bestGap / 2) + } +} diff --git a/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift new file mode 100644 index 0000000..70b50cc --- /dev/null +++ b/Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift @@ -0,0 +1,54 @@ +// +// HealthKitCreationDateReader.swift +// Bedtime +// +// Reads a HealthKit sample's "Date Added" timestamp — the value shown in the iOS Health app's +// sample detail screen, but never exposed by any public `HKSample` API (`startDate`/`endDate` +// describe when the *event* happened, not when HealthKit received it). +// + +import Foundation +import HealthKit + +/// HealthKit stores "Date Added" internally as `creationTimestamp`, readable only via +/// undocumented Key-Value Coding. This is exactly what `SleepSampleDeduplicator` needs: when a +/// source (e.g. Oura) re-syncs a night it already wrote, the resulting samples have overlapping +/// start/end times but a distinct, later creation timestamp — the only signal that tells the two +/// syncs apart. +/// +/// This is unsupported API: Apple could rename or remove the underlying property in a future OS +/// release. `KVCSafeAccessor` guards the read with an Objective-C `@try`/`@catch` so an +/// unexpected undefined-key exception degrades to `nil` (duplicate filtering simply becomes +/// unavailable for that sample, rather than guessing) instead of crashing the app. +enum HealthKitCreationDateReader { + private static let key = "creationTimestamp" + + static func creationDate(for sample: HKSample) -> Date? { + guard let raw = KVCSafeAccessor.safeValue(key, for: sample) else { return nil } + + // On every OS version observed so far this KVC read returns a boxed `NSNumber`, not + // an `NSDate` — internally it's a raw `_creationTimestamp` double, sitting right + // alongside `_startTimestamp`/`_endTimestamp` on `HKObject`. Still, handle a future + // `NSDate`-boxed representation too, since nothing here is documented. + if let date = raw as? Date { + return date + } + guard let interval = (raw as? NSNumber)?.doubleValue else { return nil } + + // That raw double sits in the same units as `_startTimestamp`/`_endTimestamp` — i.e. + // seconds since Foundation's reference date (2001-01-01), not the Unix epoch. Confirm + // via a plausibility check (should land near "now") rather than assuming, in case a + // future OS version switches conventions. + let now = Date() + let plausibleWindow = now.addingTimeInterval(-86400 * 400)...now.addingTimeInterval(86400) + let referenceDateCandidate = Date(timeIntervalSinceReferenceDate: interval) + if plausibleWindow.contains(referenceDateCandidate) { + return referenceDateCandidate + } + let unixEpochCandidate = Date(timeIntervalSince1970: interval) + if plausibleWindow.contains(unixEpochCandidate) { + return unixEpochCandidate + } + return nil + } +} diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.h b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h new file mode 100644 index 0000000..2afc9e7 --- /dev/null +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.h @@ -0,0 +1,24 @@ +// +// KVCSafeAccessor.h +// Bedtime +// +// Guards reads of undocumented Key-Value Coding properties (e.g. HKSample's private +// "creationTimestamp") against crashing if the key is ever removed or renamed. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Swift's `NSObject.value(forKey:)` does not catch Objective-C exceptions, so an undefined +/// KVC key raises `NSUndefinedKeyException` and crashes the app. This trampoline exists purely +/// to make undocumented/private KVC reads safe to attempt speculatively. +@interface KVCSafeAccessor : NSObject + +/// Returns `[object valueForKey:key]`, or `nil` if that raises any exception (e.g. the key is +/// undefined) instead of crashing. ++ (nullable id)safeValue:(NSString *)key forObject:(id)object NS_SWIFT_NAME(safeValue(_:for:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Bedtime/Bedtime/Utils/KVCSafeAccessor.m b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m new file mode 100644 index 0000000..f213dad --- /dev/null +++ b/Bedtime/Bedtime/Utils/KVCSafeAccessor.m @@ -0,0 +1,18 @@ +// +// KVCSafeAccessor.m +// Bedtime +// + +#import "KVCSafeAccessor.h" + +@implementation KVCSafeAccessor + ++ (nullable id)safeValue:(NSString *)key forObject:(id)object { + @try { + return [object valueForKey:key]; + } @catch (NSException *exception) { + return nil; + } +} + +@end diff --git a/README.md b/README.md index 4e54444..101eb9e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - View your recent sleep sessions from HealthKit - Track sleep duration over time - Visual representation of your sleep patterns +- Automatically filters out duplicate data from sources that occasionally re-sync a night + they already wrote (e.g. Oura, after falling back asleep and syncing again), so a + double-synced night doesn't inflate sleep totals. Detects the older, superseded sync using + the same "Date Added" signal the Health app shows, and excludes it from Bedger's own + calculations. Since HealthKit only lets an app delete data it wrote itself, this can't + delete anything from HealthKit — see `SleepSampleDeduplicator` ### ⚙️ Customizable Settings - Set your personal sleep goal (6-12 hours, in 15 minute steps)