Skip to content
Draft
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
2 changes: 2 additions & 0 deletions Bedtime/Bedtime.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};
Expand Down Expand Up @@ -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";
};
Expand Down
6 changes: 6 additions & 0 deletions Bedtime/Bedtime/Bedtime-Bridging-Header.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//
// Bedtime-Bridging-Header.h
// Bedtime
//

#import "Utils/KVCSafeAccessor.h"
3 changes: 2 additions & 1 deletion Bedtime/Bedtime/Models/HealthKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Bedtime/Bedtime/Models/SleepData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions Bedtime/Bedtime/Models/SleepSampleDeduplicator.swift
Original file line number Diff line number Diff line change
@@ -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..<distinctCreationDates.count {
let gap = distinctCreationDates[index].timeIntervalSince(distinctCreationDates[index - 1])
if gap > bestGap {
bestGap = gap
bestGapIndex = index - 1
}
}
return distinctCreationDates[bestGapIndex].addingTimeInterval(bestGap / 2)
}
}
54 changes: 54 additions & 0 deletions Bedtime/Bedtime/Utils/HealthKitCreationDateReader.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
24 changes: 24 additions & 0 deletions Bedtime/Bedtime/Utils/KVCSafeAccessor.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

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
18 changes: 18 additions & 0 deletions Bedtime/Bedtime/Utils/KVCSafeAccessor.m
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down