@@ -122,6 +122,15 @@ final class TypeDistributionBin {
|
||
| 122 | 122 |
// Import uses a global anchored query per data type so follow-up snapshots fetch only |
| 123 | 123 |
// HealthKit deltas instead of scanning calendar blocks with fixed per-query latency. |
| 124 | 124 |
|
| 125 |
+// Interface updated 2026-05-14 — see AGENTS.md |
|
| 126 |
+// TypeCount.recordArchiveData is stored with SwiftData external storage. |
|
| 127 |
+// The archive remains a complete per-record forensic snapshot, but large blobs should |
|
| 128 |
+// not be kept inline with the TypeCount row because high-volume HealthKit stores can |
|
| 129 |
+// exceed device memory during import or detail inspection. |
|
| 130 |
+// Initial high-volume imports stream records directly into a compact binary archive |
|
| 131 |
+// instead of building a full in-memory dictionary/array; plist archives remain readable |
|
| 132 |
+// for backwards compatibility. |
|
| 133 |
+ |
|
| 125 | 134 |
// Models/DetectedAnomaly.swift |
| 126 | 135 |
enum AnomalyType: String, Codable {
|
| 127 | 136 |
case historicalInsertion = "historical_insertion" |
@@ -12,17 +12,187 @@ struct HealthRecordValue: Codable, Hashable, Identifiable, Sendable {
|
||
| 12 | 12 |
} |
| 13 | 13 |
|
| 14 | 14 |
enum HealthRecordArchive {
|
| 15 |
+ private static let compactMagic = Data([0x48, 0x50, 0x52, 0x41, 0x32]) // HPRA2 |
|
| 16 |
+ |
|
| 15 | 17 |
static func encode(_ values: [HealthRecordValue]) -> Data? {
|
| 16 |
- let encoder = PropertyListEncoder() |
|
| 17 |
- encoder.outputFormat = .binary |
|
| 18 |
- return try? encoder.encode(values) |
|
| 18 |
+ guard let typeIdentifier = values.first?.typeIdentifier else {
|
|
| 19 |
+ return encodeCompact(typeIdentifier: "", values: []) |
|
| 20 |
+ } |
|
| 21 |
+ return encodeCompact(typeIdentifier: typeIdentifier, values: values) |
|
| 19 | 22 |
} |
| 20 | 23 |
|
| 21 | 24 |
static func decode(_ data: Data) -> [HealthRecordValue]? {
|
| 22 |
- try? PropertyListDecoder().decode([HealthRecordValue].self, from: data) |
|
| 25 |
+ if let decoded = decodeCompact(data) {
|
|
| 26 |
+ return decoded |
|
| 27 |
+ } |
|
| 28 |
+ return try? PropertyListDecoder().decode([HealthRecordValue].self, from: data) |
|
| 23 | 29 |
} |
| 24 |
-} |
|
| 25 | 30 |
|
| 31 |
+ static func makeCompactWriter(typeIdentifier: String, estimatedRecordCount: Int = 0) -> CompactWriter {
|
|
| 32 |
+ CompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: estimatedRecordCount) |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ private static func encodeCompact(typeIdentifier: String, values: [HealthRecordValue]) -> Data? {
|
|
| 36 |
+ var writer = makeCompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: values.count) |
|
| 37 |
+ for value in values {
|
|
| 38 |
+ writer.append(value) |
|
| 39 |
+ } |
|
| 40 |
+ return writer.finalize() |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ struct CompactWriter {
|
|
| 44 |
+ private var data = Data() |
|
| 45 |
+ private var countOffset = 0 |
|
| 46 |
+ private var count: UInt64 = 0 |
|
| 47 |
+ |
|
| 48 |
+ init(typeIdentifier: String, estimatedRecordCount: Int = 0) {
|
|
| 49 |
+ data.reserveCapacity(max(256, estimatedRecordCount * 160)) |
|
| 50 |
+ data.append(compactMagic) |
|
| 51 |
+ appendString(typeIdentifier) |
|
| 52 |
+ countOffset = data.count |
|
| 53 |
+ appendUInt64(0) |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ mutating func append(_ value: HealthRecordValue) {
|
|
| 57 |
+ appendString(value.sampleUUIDHash) |
|
| 58 |
+ appendString(value.recordFingerprint) |
|
| 59 |
+ appendDouble(value.startDate.timeIntervalSinceReferenceDate) |
|
| 60 |
+ appendDouble(value.endDate.timeIntervalSinceReferenceDate) |
|
| 61 |
+ appendOptionalString(value.displayValue) |
|
| 62 |
+ count += 1 |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ mutating func finalize() -> Data {
|
|
| 66 |
+ var littleEndianCount = count.littleEndian |
|
| 67 |
+ data.replaceSubrange( |
|
| 68 |
+ countOffset..<countOffset + MemoryLayout<UInt64>.size, |
|
| 69 |
+ with: withUnsafeBytes(of: &littleEndianCount) { Array($0) }
|
|
| 70 |
+ ) |
|
| 71 |
+ return data |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ private mutating func appendString(_ value: String) {
|
|
| 75 |
+ let bytes = Array(value.utf8) |
|
| 76 |
+ appendUInt32(UInt32(bytes.count)) |
|
| 77 |
+ data.append(contentsOf: bytes) |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 80 |
+ private mutating func appendOptionalString(_ value: String?) {
|
|
| 81 |
+ guard let value else {
|
|
| 82 |
+ appendInt32(-1) |
|
| 83 |
+ return |
|
| 84 |
+ } |
|
| 85 |
+ let bytes = Array(value.utf8) |
|
| 86 |
+ appendInt32(Int32(bytes.count)) |
|
| 87 |
+ data.append(contentsOf: bytes) |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ private mutating func appendUInt32(_ value: UInt32) {
|
|
| 91 |
+ var littleEndian = value.littleEndian |
|
| 92 |
+ data.append(contentsOf: withUnsafeBytes(of: &littleEndian) { Array($0) })
|
|
| 93 |
+ } |
|
| 94 |
+ |
|
| 95 |
+ private mutating func appendInt32(_ value: Int32) {
|
|
| 96 |
+ var littleEndian = value.littleEndian |
|
| 97 |
+ data.append(contentsOf: withUnsafeBytes(of: &littleEndian) { Array($0) })
|
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ private mutating func appendUInt64(_ value: UInt64) {
|
|
| 101 |
+ var littleEndian = value.littleEndian |
|
| 102 |
+ data.append(contentsOf: withUnsafeBytes(of: &littleEndian) { Array($0) })
|
|
| 103 |
+ } |
|
| 104 |
+ |
|
| 105 |
+ private mutating func appendDouble(_ value: Double) {
|
|
| 106 |
+ appendUInt64(value.bitPattern) |
|
| 107 |
+ } |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ private static func decodeCompact(_ data: Data) -> [HealthRecordValue]? {
|
|
| 111 |
+ guard data.starts(with: compactMagic) else { return nil }
|
|
| 112 |
+ var reader = CompactReader(data: data, offset: compactMagic.count) |
|
| 113 |
+ guard let typeIdentifier = reader.readString(), |
|
| 114 |
+ let count = reader.readUInt64() else {
|
|
| 115 |
+ return nil |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ var values: [HealthRecordValue] = [] |
|
| 119 |
+ values.reserveCapacity(Int(min(count, UInt64(Int.max)))) |
|
| 120 |
+ for _ in 0..<count {
|
|
| 121 |
+ guard let sampleUUIDHash = reader.readString(), |
|
| 122 |
+ let recordFingerprint = reader.readString(), |
|
| 123 |
+ let startInterval = reader.readDouble(), |
|
| 124 |
+ let endInterval = reader.readDouble(), |
|
| 125 |
+ let displayValue = reader.readOptionalString() else {
|
|
| 126 |
+ return nil |
|
| 127 |
+ } |
|
| 128 |
+ values.append( |
|
| 129 |
+ HealthRecordValue( |
|
| 130 |
+ typeIdentifier: typeIdentifier, |
|
| 131 |
+ sampleUUIDHash: sampleUUIDHash, |
|
| 132 |
+ recordFingerprint: recordFingerprint, |
|
| 133 |
+ startDate: Date(timeIntervalSinceReferenceDate: startInterval), |
|
| 134 |
+ endDate: Date(timeIntervalSinceReferenceDate: endInterval), |
|
| 135 |
+ displayValue: displayValue |
|
| 136 |
+ ) |
|
| 137 |
+ ) |
|
| 138 |
+ } |
|
| 139 |
+ return values |
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ private struct CompactReader {
|
|
| 143 |
+ let data: Data |
|
| 144 |
+ var offset: Int |
|
| 145 |
+ |
|
| 146 |
+ mutating func readString() -> String? {
|
|
| 147 |
+ guard let length = readUInt32(), |
|
| 148 |
+ let bytes = readBytes(count: Int(length)) else {
|
|
| 149 |
+ return nil |
|
| 150 |
+ } |
|
| 151 |
+ return String(data: Data(bytes), encoding: .utf8) |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ mutating func readOptionalString() -> String?? {
|
|
| 155 |
+ guard let length = readInt32() else { return nil }
|
|
| 156 |
+ if length < 0 {
|
|
| 157 |
+ return .some(nil) |
|
| 158 |
+ } |
|
| 159 |
+ guard let bytes = readBytes(count: Int(length)) else { return nil }
|
|
| 160 |
+ return .some(String(data: Data(bytes), encoding: .utf8)) |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ mutating func readUInt32() -> UInt32? {
|
|
| 164 |
+ readFixedWidthInteger() |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 167 |
+ mutating func readInt32() -> Int32? {
|
|
| 168 |
+ readFixedWidthInteger() |
|
| 169 |
+ } |
|
| 170 |
+ |
|
| 171 |
+ mutating func readUInt64() -> UInt64? {
|
|
| 172 |
+ readFixedWidthInteger() |
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 175 |
+ mutating func readDouble() -> Double? {
|
|
| 176 |
+ guard let bitPattern: UInt64 = readFixedWidthInteger() else { return nil }
|
|
| 177 |
+ return Double(bitPattern: bitPattern) |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ private mutating func readBytes(count: Int) -> [UInt8]? {
|
|
| 181 |
+ guard count >= 0, offset + count <= data.count else { return nil }
|
|
| 182 |
+ let bytes = Array(data[offset..<offset + count]) |
|
| 183 |
+ offset += count |
|
| 184 |
+ return bytes |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ private mutating func readFixedWidthInteger<T: FixedWidthInteger>() -> T? {
|
|
| 188 |
+ let size = MemoryLayout<T>.size |
|
| 189 |
+ guard let bytes = readBytes(count: size) else { return nil }
|
|
| 190 |
+ return bytes.enumerated().reduce(T.zero) { result, item in
|
|
| 191 |
+ result | (T(item.element) << T(item.offset * 8)) |
|
| 192 |
+ } |
|
| 193 |
+ } |
|
| 194 |
+ } |
|
| 195 |
+} |
|
| 26 | 196 |
// Interface updated 2026-05-12 — see AGENTS.md |
| 27 | 197 |
@Model final class HealthRecord {
|
| 28 | 198 |
var id: UUID = UUID() |
@@ -1,7 +1,7 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
import SwiftData |
| 3 | 3 |
|
| 4 |
-// Interface updated 2026-05-12 — see AGENTS.md |
|
| 4 |
+// Interface updated 2026-05-14 — see AGENTS.md |
|
| 5 | 5 |
@Model final class TypeCount {
|
| 6 | 6 |
var id: UUID = UUID() |
| 7 | 7 |
var typeIdentifier: String = "" |
@@ -12,7 +12,7 @@ import SwiftData |
||
| 12 | 12 |
var latestDate: Date? |
| 13 | 13 |
var qualityRaw: String = SnapshotQuality.complete.rawValue |
| 14 | 14 |
var isUnsupported: Bool = false |
| 15 |
- var recordArchiveData: Data? |
|
| 15 |
+ @Attribute(.externalStorage) var recordArchiveData: Data? |
|
| 16 | 16 |
var snapshot: HealthSnapshot? |
| 17 | 17 |
@Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount) |
| 18 | 18 |
var yearlyCounts: [YearlyCount]? = [] |
@@ -2,6 +2,24 @@ import CryptoKit |
||
| 2 | 2 |
import Foundation |
| 3 | 3 |
|
| 4 | 4 |
enum HashService {
|
| 5 |
+ struct TypeHashBuilder {
|
|
| 6 |
+ private var hasher = SHA256() |
|
| 7 |
+ |
|
| 8 |
+ init(typeIdentifier: String) {
|
|
| 9 |
+ hasher.update(data: Data(typeIdentifier.utf8)) |
|
| 10 |
+ } |
|
| 11 |
+ |
|
| 12 |
+ mutating func append(recordFingerprint: String) {
|
|
| 13 |
+ hasher.update(data: Data("|".utf8))
|
|
| 14 |
+ hasher.update(data: Data(recordFingerprint.utf8)) |
|
| 15 |
+ } |
|
| 16 |
+ |
|
| 17 |
+ mutating func finalize() -> String {
|
|
| 18 |
+ let digest = hasher.finalize() |
|
| 19 |
+ return digest.map { String(format: "%02x", $0) }.joined()
|
|
| 20 |
+ } |
|
| 21 |
+ } |
|
| 22 |
+ |
|
| 5 | 23 |
private static let iso8601Formatter: ISO8601DateFormatter = {
|
| 6 | 24 |
let f = ISO8601DateFormatter() |
| 7 | 25 |
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] |
@@ -36,6 +54,14 @@ enum HashService {
|
||
| 36 | 54 |
return digest.map { String(format: "%02x", $0) }.joined()
|
| 37 | 55 |
} |
| 38 | 56 |
|
| 57 |
+ static func typeHashInRecordedOrder(typeIdentifier: String, recordFingerprints: [String]) -> String {
|
|
| 58 |
+ var builder = TypeHashBuilder(typeIdentifier: typeIdentifier) |
|
| 59 |
+ for fingerprint in recordFingerprints {
|
|
| 60 |
+ builder.append(recordFingerprint: fingerprint) |
|
| 61 |
+ } |
|
| 62 |
+ return builder.finalize() |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 39 | 65 |
static func sampleFingerprint( |
| 40 | 66 |
typeIdentifier: String, |
| 41 | 67 |
sampleUUID: String, |
@@ -96,21 +96,17 @@ final class HealthKitService {
|
||
| 96 | 96 |
snapshot.retryOfSnapshotID = retryOfSnapshotID |
| 97 | 97 |
snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier |
| 98 | 98 |
let previousSnapshot = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context) |
| 99 |
- // Fetch raw HealthKit data off the main actor, then assemble SwiftData models here |
|
| 100 |
- // on the main actor to prevent data races on managed object context. |
|
| 101 |
- let fetchResults = await fetchAllTypeCounts( |
|
| 99 |
+ // Fetch raw HealthKit data one type at a time, then assemble each SwiftData model |
|
| 100 |
+ // immediately so large per-type archives are not duplicated in an intermediate result array. |
|
| 101 |
+ let typeCounts = await fetchAllTypeCounts( |
|
| 102 | 102 |
for: active, |
| 103 | 103 |
context: context, |
| 104 |
+ snapshot: snapshot, |
|
| 104 | 105 |
previousSnapshot: previousSnapshot, |
| 105 | 106 |
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
| 106 | 107 |
timeoutMultiplier: timeoutMultiplier, |
| 107 | 108 |
progress: progress |
| 108 | 109 |
) |
| 109 |
- let typeCounts: [TypeCount] = fetchResults.map { result in
|
|
| 110 |
- let tc = result.makeTypeCount() |
|
| 111 |
- tc.snapshot = snapshot |
|
| 112 |
- return tc |
|
| 113 |
- } |
|
| 114 | 110 |
snapshot.typeCounts = typeCounts |
| 115 | 111 |
|
| 116 | 112 |
applyStickyUnavailableState( |
@@ -384,18 +380,19 @@ final class HealthKitService {
|
||
| 384 | 380 |
|
| 385 | 381 |
// MARK: - Per-type fetch pipeline |
| 386 | 382 |
|
| 387 |
- // Returns raw Sendable fetch results — no SwiftData model creation here. |
|
| 388 |
- // All TypeCount/YearlyCount objects must be created on the main actor in createSnapshot. |
|
| 389 | 383 |
// Fetches sequentially to prevent race conditions and resource exhaustion. |
| 384 |
+ @MainActor |
|
| 390 | 385 |
private func fetchAllTypeCounts( |
| 391 | 386 |
for active: [MonitoredType], |
| 392 | 387 |
context: ModelContext, |
| 388 |
+ snapshot: HealthSnapshot, |
|
| 393 | 389 |
previousSnapshot: HealthSnapshot?, |
| 394 | 390 |
adaptiveTimeoutsEnabled: Bool, |
| 395 | 391 |
timeoutMultiplier: Double, |
| 396 | 392 |
progress: SnapshotFetchProgress? = nil |
| 397 |
- ) async -> [TypeCountFetchResult] {
|
|
| 398 |
- var results: [TypeCountFetchResult] = [] |
|
| 393 |
+ ) async -> [TypeCount] {
|
|
| 394 |
+ var typeCounts: [TypeCount] = [] |
|
| 395 |
+ typeCounts.reserveCapacity(active.count) |
|
| 399 | 396 |
|
| 400 | 397 |
for monitoredType in active {
|
| 401 | 398 |
let profile = timeoutProfile(for: monitoredType, context: context) |
@@ -414,9 +411,11 @@ final class HealthKitService {
|
||
| 414 | 411 |
updateTimeoutProfile(profile, with: result, monitoredType: monitoredType) |
| 415 | 412 |
result.applyTimeoutProfile(profile) |
| 416 | 413 |
progress?.updateTimeoutProfile(from: profile, for: monitoredType.id) |
| 417 |
- results.append(result) |
|
| 414 |
+ let typeCount = result.makeTypeCount() |
|
| 415 |
+ typeCount.snapshot = snapshot |
|
| 416 |
+ typeCounts.append(typeCount) |
|
| 418 | 417 |
} |
| 419 |
- return results |
|
| 418 |
+ return typeCounts |
|
| 420 | 419 |
} |
| 421 | 420 |
|
| 422 | 421 |
private func fetchTypeCountData( |
@@ -574,16 +573,22 @@ final class HealthKitService {
|
||
| 574 | 573 |
) |
| 575 | 574 |
} |
| 576 | 575 |
|
| 577 |
- let contentHash = HashService.typeHash( |
|
| 576 |
+ let contentHash = distribution.contentHash ?? HashService.typeHash( |
|
| 578 | 577 |
typeIdentifier: monitoredType.id, |
| 579 | 578 |
recordFingerprints: distribution.records.map(\.recordFingerprint) |
| 580 | 579 |
) |
| 581 | 580 |
|
| 582 | 581 |
// YearlyCount uses Calendar.current; year attribution is local-time based. |
| 583 |
- var yearMap: [Int: Int] = [:] |
|
| 584 |
- for record in distribution.records {
|
|
| 585 |
- let year = Calendar.current.component(.year, from: record.startDate) |
|
| 586 |
- yearMap[year, default: 0] += 1 |
|
| 582 |
+ let yearMap: [Int: Int] |
|
| 583 |
+ if let cachedYearlyCounts = distribution.yearlyCounts {
|
|
| 584 |
+ yearMap = cachedYearlyCounts |
|
| 585 |
+ } else {
|
|
| 586 |
+ var computedYearMap: [Int: Int] = [:] |
|
| 587 |
+ for record in distribution.records {
|
|
| 588 |
+ let year = Calendar.current.component(.year, from: record.startDate) |
|
| 589 |
+ computedYearMap[year, default: 0] += 1 |
|
| 590 |
+ } |
|
| 591 |
+ yearMap = computedYearMap |
|
| 587 | 592 |
} |
| 588 | 593 |
let yearlyCounts = yearMap.map { year, yearCount in
|
| 589 | 594 |
TypeCountFetchResult.YearlyCountData( |
@@ -598,18 +603,7 @@ final class HealthKitService {
|
||
| 598 | 603 |
detail: "Preparing record archive", |
| 599 | 604 |
recordCount: distribution.totalCount |
| 600 | 605 |
) |
| 601 |
- let recordArchiveData = HealthRecordArchive.encode( |
|
| 602 |
- distribution.records.map {
|
|
| 603 |
- HealthRecordValue( |
|
| 604 |
- typeIdentifier: monitoredType.id, |
|
| 605 |
- sampleUUIDHash: $0.sampleUUIDHash, |
|
| 606 |
- recordFingerprint: $0.recordFingerprint, |
|
| 607 |
- startDate: $0.startDate, |
|
| 608 |
- endDate: $0.endDate, |
|
| 609 |
- displayValue: $0.displayValue |
|
| 610 |
- ) |
|
| 611 |
- } |
|
| 612 |
- ) |
|
| 606 |
+ let recordArchiveData = distribution.recordArchiveData ?? HealthRecordArchive.encode(distribution.records) |
|
| 613 | 607 |
|
| 614 | 608 |
return TypeCountFetchResult( |
| 615 | 609 |
typeIdentifier: monitoredType.id, |
@@ -649,29 +643,27 @@ final class HealthKitService {
|
||
| 649 | 643 |
progress: SnapshotFetchProgress? |
| 650 | 644 |
) async throws -> SampleDistribution {
|
| 651 | 645 |
var anchor = previousDistribution.globalAnchor |
| 652 |
- let seedRecords = anchor == nil ? [] : previousDistribution.records |
|
| 653 |
- var recordMap = Dictionary( |
|
| 654 |
- uniqueKeysWithValues: seedRecords.map {
|
|
| 655 |
- ( |
|
| 656 |
- $0.sampleUUIDHash, |
|
| 657 |
- SampleDistribution.Record( |
|
| 658 |
- sampleUUIDHash: $0.sampleUUIDHash, |
|
| 659 |
- recordFingerprint: $0.recordFingerprint, |
|
| 660 |
- startDate: $0.startDate, |
|
| 661 |
- endDate: $0.endDate, |
|
| 662 |
- displayValue: $0.displayValue |
|
| 663 |
- ) |
|
| 664 |
- ) |
|
| 665 |
- } |
|
| 666 |
- ) |
|
| 646 |
+ let startedFromAnchor = anchor != nil |
|
| 647 |
+ let estimatedPageCount = startedFromAnchor |
|
| 648 |
+ ? Self.estimatedPageCount(for: previousDistribution.count) |
|
| 649 |
+ : nil |
|
| 650 |
+ let progressStarted = Date() |
|
| 667 | 651 |
var pageNumber = 0 |
| 652 |
+ var processedEventCount = 0 |
|
| 653 |
+ var firstDeltaPage: SampleDistributionPage? |
|
| 668 | 654 |
|
| 669 |
- while true {
|
|
| 670 |
- pageNumber += 1 |
|
| 655 |
+ if anchor != nil {
|
|
| 656 |
+ pageNumber = 1 |
|
| 671 | 657 |
progress?.updateBlockProgress( |
| 672 | 658 |
typeIdentifier, |
| 673 |
- detail: anchor == nil ? "Import page \(pageNumber)" : "Delta page \(pageNumber)", |
|
| 674 |
- recordCount: recordMap.count |
|
| 659 |
+ detail: Self.pageProgressDetail( |
|
| 660 |
+ operation: "Delta", |
|
| 661 |
+ pageNumber: pageNumber, |
|
| 662 |
+ estimatedPageCount: estimatedPageCount |
|
| 663 |
+ ), |
|
| 664 |
+ recordCount: previousDistribution.count, |
|
| 665 |
+ elapsedSeconds: 0, |
|
| 666 |
+ samplesPerSecond: 0 |
|
| 675 | 667 |
) |
| 676 | 668 |
|
| 677 | 669 |
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
|
@@ -683,36 +675,129 @@ final class HealthKitService {
|
||
| 683 | 675 |
} |
| 684 | 676 |
anchor = page.anchor |
| 685 | 677 |
|
| 686 |
- for deletedObject in page.deletedObjects {
|
|
| 687 |
- recordMap.removeValue(forKey: HashService.sampleUUIDHash(deletedObject.uuid.uuidString)) |
|
| 678 |
+ if page.samples.isEmpty, page.deletedObjects.isEmpty, |
|
| 679 |
+ let unchanged = previousDistribution.unchangedDistribution( |
|
| 680 |
+ updatedAnchor: anchor, |
|
| 681 |
+ typeIdentifier: typeIdentifier, |
|
| 682 |
+ earliestDate: earliestDate, |
|
| 683 |
+ latestDate: latestDate |
|
| 684 |
+ ) {
|
|
| 685 |
+ progress?.updateBlockProgress( |
|
| 686 |
+ typeIdentifier, |
|
| 687 |
+ detail: "No HealthKit delta", |
|
| 688 |
+ recordCount: unchanged.totalCount, |
|
| 689 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted), |
|
| 690 |
+ samplesPerSecond: 0 |
|
| 691 |
+ ) |
|
| 692 |
+ return unchanged |
|
| 688 | 693 |
} |
| 689 | 694 |
|
| 690 |
- for sample in page.samples {
|
|
| 691 |
- let uuidHash = HashService.sampleUUIDHash(sample.uuid.uuidString) |
|
| 692 |
- recordMap[uuidHash] = SampleDistribution.Record( |
|
| 693 |
- sampleUUIDHash: uuidHash, |
|
| 694 |
- recordFingerprint: HashService.sampleFingerprint( |
|
| 695 |
- typeIdentifier: sampleType.identifier, |
|
| 696 |
- sampleUUID: sample.uuid.uuidString, |
|
| 697 |
- startDate: sample.startDate, |
|
| 698 |
- endDate: sample.endDate |
|
| 699 |
- ), |
|
| 700 |
- startDate: sample.startDate, |
|
| 701 |
- endDate: sample.endDate, |
|
| 702 |
- displayValue: Self.displayValue(for: sample) |
|
| 695 |
+ firstDeltaPage = page |
|
| 696 |
+ } |
|
| 697 |
+ |
|
| 698 |
+ if !startedFromAnchor {
|
|
| 699 |
+ return try await fetchInitialDistributionStreaming( |
|
| 700 |
+ for: sampleType, |
|
| 701 |
+ typeIdentifier: typeIdentifier, |
|
| 702 |
+ earliestDate: earliestDate, |
|
| 703 |
+ latestDate: latestDate, |
|
| 704 |
+ progressStarted: progressStarted, |
|
| 705 |
+ progress: progress |
|
| 706 |
+ ) |
|
| 707 |
+ } |
|
| 708 |
+ |
|
| 709 |
+ var recordMap = startedFromAnchor ? try previousDistribution.makeRecordMap() : [:] |
|
| 710 |
+ var shouldFetchNextPage = true |
|
| 711 |
+ |
|
| 712 |
+ if let firstDeltaPage {
|
|
| 713 |
+ applyDistributionPage(firstDeltaPage, sampleType: sampleType, to: &recordMap) |
|
| 714 |
+ processedEventCount += pageEventCount(firstDeltaPage) |
|
| 715 |
+ shouldFetchNextPage = firstDeltaPage.samples.count + firstDeltaPage.deletedObjects.count >= DistributionCaptureConfiguration.queryPageLimit |
|
| 716 |
+ progress?.updateBlockProgress( |
|
| 717 |
+ typeIdentifier, |
|
| 718 |
+ detail: Self.pageProgressDetail( |
|
| 719 |
+ operation: "Delta", |
|
| 720 |
+ pageNumber: pageNumber, |
|
| 721 |
+ estimatedPageCount: estimatedPageCount |
|
| 722 |
+ ), |
|
| 723 |
+ recordCount: recordMap.count, |
|
| 724 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted), |
|
| 725 |
+ samplesPerSecond: Self.samplesPerSecond( |
|
| 726 |
+ processedCount: processedEventCount, |
|
| 727 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted) |
|
| 703 | 728 |
) |
| 704 |
- } |
|
| 729 |
+ ) |
|
| 730 |
+ } |
|
| 705 | 731 |
|
| 706 |
- if page.samples.count + page.deletedObjects.count < DistributionCaptureConfiguration.queryPageLimit {
|
|
| 707 |
- break |
|
| 732 |
+ while shouldFetchNextPage {
|
|
| 733 |
+ pageNumber += 1 |
|
| 734 |
+ progress?.updateBlockProgress( |
|
| 735 |
+ typeIdentifier, |
|
| 736 |
+ detail: Self.pageProgressDetail( |
|
| 737 |
+ operation: anchor == nil ? "Import" : "Delta", |
|
| 738 |
+ pageNumber: pageNumber, |
|
| 739 |
+ estimatedPageCount: anchor == nil ? nil : estimatedPageCount |
|
| 740 |
+ ), |
|
| 741 |
+ recordCount: recordMap.count, |
|
| 742 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted), |
|
| 743 |
+ samplesPerSecond: Self.samplesPerSecond( |
|
| 744 |
+ processedCount: processedEventCount, |
|
| 745 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted) |
|
| 746 |
+ ) |
|
| 747 |
+ ) |
|
| 748 |
+ |
|
| 749 |
+ let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
|
|
| 750 |
+ try await self.fetchDistributionPage( |
|
| 751 |
+ for: sampleType, |
|
| 752 |
+ predicate: nil, |
|
| 753 |
+ anchor: anchor |
|
| 754 |
+ ) |
|
| 708 | 755 |
} |
| 756 |
+ anchor = page.anchor |
|
| 757 |
+ |
|
| 758 |
+ applyDistributionPage(page, sampleType: sampleType, to: &recordMap) |
|
| 759 |
+ processedEventCount += pageEventCount(page) |
|
| 760 |
+ shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= DistributionCaptureConfiguration.queryPageLimit |
|
| 761 |
+ progress?.updateBlockProgress( |
|
| 762 |
+ typeIdentifier, |
|
| 763 |
+ detail: Self.pageProgressDetail( |
|
| 764 |
+ operation: anchor == nil ? "Import" : "Delta", |
|
| 765 |
+ pageNumber: pageNumber, |
|
| 766 |
+ estimatedPageCount: anchor == nil ? nil : estimatedPageCount |
|
| 767 |
+ ), |
|
| 768 |
+ recordCount: recordMap.count, |
|
| 769 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted), |
|
| 770 |
+ samplesPerSecond: Self.samplesPerSecond( |
|
| 771 |
+ processedCount: processedEventCount, |
|
| 772 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted) |
|
| 773 |
+ ) |
|
| 774 |
+ ) |
|
| 709 | 775 |
} |
| 710 | 776 |
|
| 711 |
- let sortedRecords = Array(recordMap.values).sorted {
|
|
| 712 |
- if $0.startDate != $1.startDate {
|
|
| 713 |
- return $0.startDate < $1.startDate |
|
| 777 |
+ let sortedKeys = recordMap.keys.sorted {
|
|
| 778 |
+ guard let left = recordMap[$0], |
|
| 779 |
+ let right = recordMap[$1] else {
|
|
| 780 |
+ return $0 < $1 |
|
| 714 | 781 |
} |
| 715 |
- return $0.recordFingerprint < $1.recordFingerprint |
|
| 782 |
+ if left.startDate != right.startDate {
|
|
| 783 |
+ return left.startDate < right.startDate |
|
| 784 |
+ } |
|
| 785 |
+ return left.recordFingerprint < right.recordFingerprint |
|
| 786 |
+ } |
|
| 787 |
+ var sortedRecords: [HealthRecordValue] = [] |
|
| 788 |
+ sortedRecords.reserveCapacity(sortedKeys.count) |
|
| 789 |
+ for sampleUUIDHash in sortedKeys {
|
|
| 790 |
+ guard let record = recordMap[sampleUUIDHash] else { continue }
|
|
| 791 |
+ sortedRecords.append( |
|
| 792 |
+ HealthRecordValue( |
|
| 793 |
+ typeIdentifier: typeIdentifier, |
|
| 794 |
+ sampleUUIDHash: sampleUUIDHash, |
|
| 795 |
+ recordFingerprint: record.recordFingerprint, |
|
| 796 |
+ startDate: record.startDate, |
|
| 797 |
+ endDate: record.endDate, |
|
| 798 |
+ displayValue: record.displayValue |
|
| 799 |
+ ) |
|
| 800 |
+ ) |
|
| 716 | 801 |
} |
| 717 | 802 |
let contentHash = HashService.typeHash( |
| 718 | 803 |
typeIdentifier: typeIdentifier, |
@@ -726,7 +811,14 @@ final class HealthKitService {
|
||
| 726 | 811 |
) |
| 727 | 812 |
|
| 728 | 813 |
guard !sortedRecords.isEmpty || anchor != nil else {
|
| 729 |
- return SampleDistribution(totalCount: 0, bins: [], records: []) |
|
| 814 |
+ return SampleDistribution( |
|
| 815 |
+ totalCount: 0, |
|
| 816 |
+ bins: [], |
|
| 817 |
+ records: [], |
|
| 818 |
+ contentHash: nil, |
|
| 819 |
+ yearlyCounts: nil, |
|
| 820 |
+ recordArchiveData: nil |
|
| 821 |
+ ) |
|
| 730 | 822 |
} |
| 731 | 823 |
|
| 732 | 824 |
let binStart = earliestDate ?? sortedRecords.first?.startDate ?? previousDistribution.earliestRecordDate ?? Date() |
@@ -744,10 +836,205 @@ final class HealthKitService {
|
||
| 744 | 836 |
anchorData: anchor.flatMap(Self.archiveAnchor(_:)) |
| 745 | 837 |
) |
| 746 | 838 |
], |
| 747 |
- records: sortedRecords |
|
| 839 |
+ records: sortedRecords, |
|
| 840 |
+ contentHash: contentHash, |
|
| 841 |
+ yearlyCounts: nil, |
|
| 842 |
+ recordArchiveData: nil |
|
| 843 |
+ ) |
|
| 844 |
+ } |
|
| 845 |
+ |
|
| 846 |
+ private func fetchInitialDistributionStreaming( |
|
| 847 |
+ for sampleType: HKSampleType, |
|
| 848 |
+ typeIdentifier: String, |
|
| 849 |
+ earliestDate: Date?, |
|
| 850 |
+ latestDate: Date?, |
|
| 851 |
+ progressStarted: Date, |
|
| 852 |
+ progress: SnapshotFetchProgress? |
|
| 853 |
+ ) async throws -> SampleDistribution {
|
|
| 854 |
+ var anchor: HKQueryAnchor? |
|
| 855 |
+ var pageNumber = 0 |
|
| 856 |
+ var recordCount = 0 |
|
| 857 |
+ var processedEventCount = 0 |
|
| 858 |
+ var firstRecordDate: Date? |
|
| 859 |
+ var latestRecordDate: Date? |
|
| 860 |
+ var yearMap: [Int: Int] = [:] |
|
| 861 |
+ var archiveWriter = HealthRecordArchive.makeCompactWriter(typeIdentifier: typeIdentifier) |
|
| 862 |
+ var hashBuilder = HashService.TypeHashBuilder(typeIdentifier: typeIdentifier) |
|
| 863 |
+ var shouldFetchNextPage = true |
|
| 864 |
+ |
|
| 865 |
+ while shouldFetchNextPage {
|
|
| 866 |
+ pageNumber += 1 |
|
| 867 |
+ let elapsedBeforePage = Date().timeIntervalSince(progressStarted) |
|
| 868 |
+ progress?.updateBlockProgress( |
|
| 869 |
+ typeIdentifier, |
|
| 870 |
+ detail: Self.pageProgressDetail( |
|
| 871 |
+ operation: "Import", |
|
| 872 |
+ pageNumber: pageNumber, |
|
| 873 |
+ estimatedPageCount: nil |
|
| 874 |
+ ), |
|
| 875 |
+ recordCount: recordCount, |
|
| 876 |
+ elapsedSeconds: elapsedBeforePage, |
|
| 877 |
+ samplesPerSecond: Self.samplesPerSecond( |
|
| 878 |
+ processedCount: processedEventCount, |
|
| 879 |
+ elapsedSeconds: elapsedBeforePage |
|
| 880 |
+ ) |
|
| 881 |
+ ) |
|
| 882 |
+ |
|
| 883 |
+ let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
|
|
| 884 |
+ try await self.fetchDistributionPage( |
|
| 885 |
+ for: sampleType, |
|
| 886 |
+ predicate: nil, |
|
| 887 |
+ anchor: anchor |
|
| 888 |
+ ) |
|
| 889 |
+ } |
|
| 890 |
+ anchor = page.anchor |
|
| 891 |
+ |
|
| 892 |
+ for sample in page.samples {
|
|
| 893 |
+ let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: typeIdentifier) |
|
| 894 |
+ archiveWriter.append(value) |
|
| 895 |
+ hashBuilder.append(recordFingerprint: value.recordFingerprint) |
|
| 896 |
+ yearMap[Calendar.current.component(.year, from: value.startDate), default: 0] += 1 |
|
| 897 |
+ firstRecordDate = min(firstRecordDate ?? value.startDate, value.startDate) |
|
| 898 |
+ latestRecordDate = max(latestRecordDate ?? value.endDate, value.endDate) |
|
| 899 |
+ recordCount += 1 |
|
| 900 |
+ } |
|
| 901 |
+ |
|
| 902 |
+ processedEventCount += pageEventCount(page) |
|
| 903 |
+ shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= DistributionCaptureConfiguration.queryPageLimit |
|
| 904 |
+ let elapsedAfterPage = Date().timeIntervalSince(progressStarted) |
|
| 905 |
+ progress?.updateBlockProgress( |
|
| 906 |
+ typeIdentifier, |
|
| 907 |
+ detail: Self.pageProgressDetail( |
|
| 908 |
+ operation: "Import", |
|
| 909 |
+ pageNumber: pageNumber, |
|
| 910 |
+ estimatedPageCount: nil |
|
| 911 |
+ ), |
|
| 912 |
+ recordCount: recordCount, |
|
| 913 |
+ elapsedSeconds: elapsedAfterPage, |
|
| 914 |
+ samplesPerSecond: Self.samplesPerSecond( |
|
| 915 |
+ processedCount: processedEventCount, |
|
| 916 |
+ elapsedSeconds: elapsedAfterPage |
|
| 917 |
+ ) |
|
| 918 |
+ ) |
|
| 919 |
+ } |
|
| 920 |
+ |
|
| 921 |
+ let contentHash = hashBuilder.finalize() |
|
| 922 |
+ progress?.updateBlockProgress( |
|
| 923 |
+ typeIdentifier, |
|
| 924 |
+ detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages", |
|
| 925 |
+ recordCount: recordCount, |
|
| 926 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted), |
|
| 927 |
+ samplesPerSecond: Self.samplesPerSecond( |
|
| 928 |
+ processedCount: processedEventCount, |
|
| 929 |
+ elapsedSeconds: Date().timeIntervalSince(progressStarted) |
|
| 930 |
+ ) |
|
| 931 |
+ ) |
|
| 932 |
+ |
|
| 933 |
+ guard recordCount > 0 || anchor != nil else {
|
|
| 934 |
+ return SampleDistribution( |
|
| 935 |
+ totalCount: 0, |
|
| 936 |
+ bins: [], |
|
| 937 |
+ records: [], |
|
| 938 |
+ contentHash: nil, |
|
| 939 |
+ yearlyCounts: nil, |
|
| 940 |
+ recordArchiveData: nil |
|
| 941 |
+ ) |
|
| 942 |
+ } |
|
| 943 |
+ |
|
| 944 |
+ let binStart = earliestDate ?? firstRecordDate ?? Date() |
|
| 945 |
+ let rawBinEnd = latestDate ?? latestRecordDate ?? binStart |
|
| 946 |
+ let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1) |
|
| 947 |
+ |
|
| 948 |
+ return SampleDistribution( |
|
| 949 |
+ totalCount: recordCount, |
|
| 950 |
+ bins: [ |
|
| 951 |
+ SampleDistribution.Bin( |
|
| 952 |
+ start: binStart, |
|
| 953 |
+ end: binEnd, |
|
| 954 |
+ count: recordCount, |
|
| 955 |
+ contentHash: contentHash, |
|
| 956 |
+ anchorData: anchor.flatMap(Self.archiveAnchor(_:)) |
|
| 957 |
+ ) |
|
| 958 |
+ ], |
|
| 959 |
+ records: [], |
|
| 960 |
+ contentHash: contentHash, |
|
| 961 |
+ yearlyCounts: yearMap, |
|
| 962 |
+ recordArchiveData: archiveWriter.finalize() |
|
| 963 |
+ ) |
|
| 964 |
+ } |
|
| 965 |
+ |
|
| 966 |
+ private func applyDistributionPage( |
|
| 967 |
+ _ page: SampleDistributionPage, |
|
| 968 |
+ sampleType: HKSampleType, |
|
| 969 |
+ to recordMap: inout [String: SampleRecordPayload] |
|
| 970 |
+ ) {
|
|
| 971 |
+ for deletedObject in page.deletedObjects {
|
|
| 972 |
+ recordMap.removeValue(forKey: HashService.sampleUUIDHash(deletedObject.uuid.uuidString)) |
|
| 973 |
+ } |
|
| 974 |
+ |
|
| 975 |
+ for sample in page.samples {
|
|
| 976 |
+ let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: sampleType.identifier) |
|
| 977 |
+ let uuidHash = value.sampleUUIDHash |
|
| 978 |
+ recordMap[uuidHash] = SampleRecordPayload( |
|
| 979 |
+ recordFingerprint: value.recordFingerprint, |
|
| 980 |
+ startDate: value.startDate, |
|
| 981 |
+ endDate: value.endDate, |
|
| 982 |
+ displayValue: value.displayValue |
|
| 983 |
+ ) |
|
| 984 |
+ } |
|
| 985 |
+ } |
|
| 986 |
+ |
|
| 987 |
+ private static func recordValue( |
|
| 988 |
+ for sample: HKSample, |
|
| 989 |
+ sampleType: HKSampleType, |
|
| 990 |
+ typeIdentifier: String |
|
| 991 |
+ ) -> HealthRecordValue {
|
|
| 992 |
+ HealthRecordValue( |
|
| 993 |
+ typeIdentifier: typeIdentifier, |
|
| 994 |
+ sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString), |
|
| 995 |
+ recordFingerprint: HashService.sampleFingerprint( |
|
| 996 |
+ typeIdentifier: sampleType.identifier, |
|
| 997 |
+ sampleUUID: sample.uuid.uuidString, |
|
| 998 |
+ startDate: sample.startDate, |
|
| 999 |
+ endDate: sample.endDate |
|
| 1000 |
+ ), |
|
| 1001 |
+ startDate: sample.startDate, |
|
| 1002 |
+ endDate: sample.endDate, |
|
| 1003 |
+ displayValue: nil |
|
| 748 | 1004 |
) |
| 749 | 1005 |
} |
| 750 | 1006 |
|
| 1007 |
+ private func pageEventCount(_ page: SampleDistributionPage) -> Int {
|
|
| 1008 |
+ page.samples.count + page.deletedObjects.count |
|
| 1009 |
+ } |
|
| 1010 |
+ |
|
| 1011 |
+ private static func estimatedPageCount(for recordCount: Int) -> Int {
|
|
| 1012 |
+ let count = max(recordCount, 0) |
|
| 1013 |
+ let pageLimit = DistributionCaptureConfiguration.queryPageLimit |
|
| 1014 |
+ return max(1, (count + pageLimit - 1) / pageLimit) |
|
| 1015 |
+ } |
|
| 1016 |
+ |
|
| 1017 |
+ private static func samplesPerSecond(processedCount: Int, elapsedSeconds: TimeInterval) -> Double {
|
|
| 1018 |
+ guard elapsedSeconds > 0, processedCount > 0 else { return 0 }
|
|
| 1019 |
+ return Double(processedCount) / elapsedSeconds |
|
| 1020 |
+ } |
|
| 1021 |
+ |
|
| 1022 |
+ private static func pageProgressDetail( |
|
| 1023 |
+ operation: String, |
|
| 1024 |
+ pageNumber: Int, |
|
| 1025 |
+ estimatedPageCount: Int? |
|
| 1026 |
+ ) -> String {
|
|
| 1027 |
+ guard let estimatedPageCount else {
|
|
| 1028 |
+ return "\(operation) page \(pageNumber) (total unknown)" |
|
| 1029 |
+ } |
|
| 1030 |
+ |
|
| 1031 |
+ if pageNumber <= estimatedPageCount {
|
|
| 1032 |
+ return "\(operation) page \(pageNumber) of ~\(estimatedPageCount)" |
|
| 1033 |
+ } |
|
| 1034 |
+ |
|
| 1035 |
+ return "\(operation) page \(pageNumber) of ~\(estimatedPageCount)+" |
|
| 1036 |
+ } |
|
| 1037 |
+ |
|
| 751 | 1038 |
private func fetchDistributionPage( |
| 752 | 1039 |
for sampleType: HKSampleType, |
| 753 | 1040 |
predicate: NSPredicate?, |
@@ -941,9 +1228,10 @@ final class HealthKitService {
|
||
| 941 | 1228 |
|
| 942 | 1229 |
private func timeoutProfile(for monitoredType: MonitoredType, context: ModelContext) -> MetricTimeoutProfile {
|
| 943 | 1230 |
let identifier = monitoredType.id |
| 944 |
- let descriptor = FetchDescriptor<MetricTimeoutProfile>( |
|
| 1231 |
+ var descriptor = FetchDescriptor<MetricTimeoutProfile>( |
|
| 945 | 1232 |
predicate: #Predicate<MetricTimeoutProfile> { $0.metricIdentifier == identifier }
|
| 946 | 1233 |
) |
| 1234 |
+ descriptor.fetchLimit = 1 |
|
| 947 | 1235 |
if let existing = try? context.fetch(descriptor).first {
|
| 948 | 1236 |
existing.displayName = monitoredType.displayName |
| 949 | 1237 |
return existing |
@@ -1140,17 +1428,19 @@ final class HealthKitService {
|
||
| 1140 | 1428 |
// MARK: - Chain helpers |
| 1141 | 1429 |
|
| 1142 | 1430 |
private func findPreviousSnapshot(deviceID: String, excluding id: UUID, context: ModelContext) -> HealthSnapshot? {
|
| 1143 |
- let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 1431 |
+ var descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 1144 | 1432 |
predicate: #Predicate<HealthSnapshot> { $0.deviceID == deviceID && $0.id != id },
|
| 1145 | 1433 |
sortBy: [SortDescriptor(\.localSequenceNumber, order: .reverse)] |
| 1146 | 1434 |
) |
| 1435 |
+ descriptor.fetchLimit = 1 |
|
| 1147 | 1436 |
return try? context.fetch(descriptor).first |
| 1148 | 1437 |
} |
| 1149 | 1438 |
|
| 1150 | 1439 |
private func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
|
| 1151 |
- let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 1440 |
+ var descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 1152 | 1441 |
predicate: #Predicate<HealthSnapshot> { $0.id == id }
|
| 1153 | 1442 |
) |
| 1443 |
+ descriptor.fetchLimit = 1 |
|
| 1154 | 1444 |
return try? context.fetch(descriptor).first |
| 1155 | 1445 |
} |
| 1156 | 1446 |
|
@@ -1182,7 +1472,8 @@ final class HealthKitService {
|
||
| 1182 | 1472 |
} |
| 1183 | 1473 |
|
| 1184 | 1474 |
private func isStoreEmpty(context: ModelContext) -> Bool {
|
| 1185 |
- let descriptor = FetchDescriptor<HealthSnapshot>() |
|
| 1475 |
+ var descriptor = FetchDescriptor<HealthSnapshot>() |
|
| 1476 |
+ descriptor.fetchLimit = 1 |
|
| 1186 | 1477 |
return (try? context.fetch(descriptor).isEmpty) ?? true |
| 1187 | 1478 |
} |
| 1188 | 1479 |
|
@@ -1272,16 +1563,12 @@ private struct SampleDistribution: Sendable {
|
||
| 1272 | 1563 |
let contentHash: String |
| 1273 | 1564 |
let anchorData: Data? |
| 1274 | 1565 |
} |
| 1275 |
- struct Record: Sendable {
|
|
| 1276 |
- let sampleUUIDHash: String |
|
| 1277 |
- let recordFingerprint: String |
|
| 1278 |
- let startDate: Date |
|
| 1279 |
- let endDate: Date |
|
| 1280 |
- let displayValue: String? |
|
| 1281 |
- } |
|
| 1282 | 1566 |
let totalCount: Int |
| 1283 | 1567 |
let bins: [Bin] |
| 1284 |
- let records: [Record] |
|
| 1568 |
+ let records: [HealthRecordValue] |
|
| 1569 |
+ let contentHash: String? |
|
| 1570 |
+ let yearlyCounts: [Int: Int]? |
|
| 1571 |
+ let recordArchiveData: Data? |
|
| 1285 | 1572 |
} |
| 1286 | 1573 |
|
| 1287 | 1574 |
private struct SampleDistributionPage: Sendable {
|
@@ -1290,6 +1577,27 @@ private struct SampleDistributionPage: Sendable {
|
||
| 1290 | 1577 |
let anchor: HKQueryAnchor? |
| 1291 | 1578 |
} |
| 1292 | 1579 |
|
| 1580 |
+private struct SampleRecordPayload: Sendable {
|
|
| 1581 |
+ let recordFingerprint: String |
|
| 1582 |
+ let startDate: Date |
|
| 1583 |
+ let endDate: Date |
|
| 1584 |
+ let displayValue: String? |
|
| 1585 |
+} |
|
| 1586 |
+ |
|
| 1587 |
+private enum HealthRecordArchiveReadError: LocalizedError {
|
|
| 1588 |
+ case missingArchive(typeIdentifier: String, count: Int) |
|
| 1589 |
+ case decodeFailed(typeIdentifier: String) |
|
| 1590 |
+ |
|
| 1591 |
+ var errorDescription: String? {
|
|
| 1592 |
+ switch self {
|
|
| 1593 |
+ case .missingArchive(let typeIdentifier, let count): |
|
| 1594 |
+ return "Missing record archive for \(typeIdentifier) with \(count) records." |
|
| 1595 |
+ case .decodeFailed(let typeIdentifier): |
|
| 1596 |
+ return "Could not decode previous record archive for \(typeIdentifier)." |
|
| 1597 |
+ } |
|
| 1598 |
+ } |
|
| 1599 |
+} |
|
| 1600 |
+ |
|
| 1293 | 1601 |
private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked Sendable {
|
| 1294 | 1602 |
private let lock = NSLock() |
| 1295 | 1603 |
nonisolated(unsafe) private var continuation: CheckedContinuation<Value, Error>? |
@@ -1367,14 +1675,6 @@ private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked S |
||
| 1367 | 1675 |
} |
| 1368 | 1676 |
|
| 1369 | 1677 |
private struct PreviousDistributionState: Sendable {
|
| 1370 |
- struct Record: Sendable {
|
|
| 1371 |
- let sampleUUIDHash: String |
|
| 1372 |
- let recordFingerprint: String |
|
| 1373 |
- let startDate: Date |
|
| 1374 |
- let endDate: Date |
|
| 1375 |
- let displayValue: String? |
|
| 1376 |
- } |
|
| 1377 |
- |
|
| 1378 | 1678 |
struct Bin: Sendable {
|
| 1379 | 1679 |
let bucketStart: Date |
| 1380 | 1680 |
let bucketEnd: Date |
@@ -1386,7 +1686,13 @@ private struct PreviousDistributionState: Sendable {
|
||
| 1386 | 1686 |
} |
| 1387 | 1687 |
} |
| 1388 | 1688 |
|
| 1389 |
- let records: [Record] |
|
| 1689 |
+ let typeIdentifier: String |
|
| 1690 |
+ let count: Int |
|
| 1691 |
+ let contentHash: String |
|
| 1692 |
+ let earliestRecordDate: Date? |
|
| 1693 |
+ let latestRecordDate: Date? |
|
| 1694 |
+ let recordArchiveData: Data? |
|
| 1695 |
+ let yearlyCounts: [Int: Int] |
|
| 1390 | 1696 |
let bins: [Bin] |
| 1391 | 1697 |
|
| 1392 | 1698 |
var globalAnchor: HKQueryAnchor? {
|
@@ -1394,24 +1700,16 @@ private struct PreviousDistributionState: Sendable {
|
||
| 1394 | 1700 |
return bins[0].anchor |
| 1395 | 1701 |
} |
| 1396 | 1702 |
|
| 1397 |
- var earliestRecordDate: Date? {
|
|
| 1398 |
- records.map(\.startDate).min() |
|
| 1399 |
- } |
|
| 1400 |
- |
|
| 1401 |
- var latestRecordDate: Date? {
|
|
| 1402 |
- records.map(\.endDate).max() |
|
| 1403 |
- } |
|
| 1404 |
- |
|
| 1405 | 1703 |
init(typeCount: TypeCount?) {
|
| 1406 |
- self.records = (typeCount?.recordValues ?? []).map {
|
|
| 1407 |
- Record( |
|
| 1408 |
- sampleUUIDHash: $0.sampleUUIDHash, |
|
| 1409 |
- recordFingerprint: $0.recordFingerprint, |
|
| 1410 |
- startDate: $0.startDate, |
|
| 1411 |
- endDate: $0.endDate, |
|
| 1412 |
- displayValue: $0.displayValue |
|
| 1413 |
- ) |
|
| 1414 |
- } |
|
| 1704 |
+ self.typeIdentifier = typeCount?.typeIdentifier ?? "" |
|
| 1705 |
+ self.count = typeCount?.count ?? 0 |
|
| 1706 |
+ self.contentHash = typeCount?.contentHash ?? "" |
|
| 1707 |
+ self.earliestRecordDate = typeCount?.earliestDate |
|
| 1708 |
+ self.latestRecordDate = typeCount?.latestDate |
|
| 1709 |
+ self.recordArchiveData = typeCount?.recordArchiveData |
|
| 1710 |
+ self.yearlyCounts = Dictionary( |
|
| 1711 |
+ uniqueKeysWithValues: (typeCount?.yearlyCounts ?? []).map { ($0.year, $0.count) }
|
|
| 1712 |
+ ) |
|
| 1415 | 1713 |
self.bins = (typeCount?.distributionBins ?? []).map {
|
| 1416 | 1714 |
Bin( |
| 1417 | 1715 |
bucketStart: $0.bucketStart, |
@@ -1420,6 +1718,73 @@ private struct PreviousDistributionState: Sendable {
|
||
| 1420 | 1718 |
) |
| 1421 | 1719 |
} |
| 1422 | 1720 |
} |
| 1721 |
+ |
|
| 1722 |
+ func unchangedDistribution( |
|
| 1723 |
+ updatedAnchor: HKQueryAnchor?, |
|
| 1724 |
+ typeIdentifier fallbackTypeIdentifier: String, |
|
| 1725 |
+ earliestDate: Date?, |
|
| 1726 |
+ latestDate: Date? |
|
| 1727 |
+ ) -> SampleDistribution? {
|
|
| 1728 |
+ guard count >= 0, |
|
| 1729 |
+ count == 0 || recordArchiveData != nil, |
|
| 1730 |
+ count == 0 || !contentHash.isEmpty else {
|
|
| 1731 |
+ return nil |
|
| 1732 |
+ } |
|
| 1733 |
+ |
|
| 1734 |
+ let binStart = earliestDate ?? earliestRecordDate ?? Date() |
|
| 1735 |
+ let rawBinEnd = latestDate ?? latestRecordDate ?? binStart |
|
| 1736 |
+ let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1) |
|
| 1737 |
+ let effectiveTypeIdentifier = typeIdentifier.isEmpty ? fallbackTypeIdentifier : typeIdentifier |
|
| 1738 |
+ let effectiveContentHash = contentHash.isEmpty |
|
| 1739 |
+ ? HashService.typeHash(typeIdentifier: effectiveTypeIdentifier, recordFingerprints: []) |
|
| 1740 |
+ : contentHash |
|
| 1741 |
+ |
|
| 1742 |
+ return SampleDistribution( |
|
| 1743 |
+ totalCount: count, |
|
| 1744 |
+ bins: [ |
|
| 1745 |
+ SampleDistribution.Bin( |
|
| 1746 |
+ start: binStart, |
|
| 1747 |
+ end: binEnd, |
|
| 1748 |
+ count: count, |
|
| 1749 |
+ contentHash: effectiveContentHash, |
|
| 1750 |
+ anchorData: updatedAnchor.flatMap(Self.archiveAnchor(_:)) |
|
| 1751 |
+ ) |
|
| 1752 |
+ ], |
|
| 1753 |
+ records: [], |
|
| 1754 |
+ contentHash: effectiveContentHash, |
|
| 1755 |
+ yearlyCounts: yearlyCounts, |
|
| 1756 |
+ recordArchiveData: recordArchiveData |
|
| 1757 |
+ ) |
|
| 1758 |
+ } |
|
| 1759 |
+ |
|
| 1760 |
+ func makeRecordMap() throws -> [String: SampleRecordPayload] {
|
|
| 1761 |
+ guard let recordArchiveData else {
|
|
| 1762 |
+ if count > 0 {
|
|
| 1763 |
+ throw HealthRecordArchiveReadError.missingArchive(typeIdentifier: typeIdentifier, count: count) |
|
| 1764 |
+ } |
|
| 1765 |
+ return [:] |
|
| 1766 |
+ } |
|
| 1767 |
+ |
|
| 1768 |
+ guard let records = HealthRecordArchive.decode(recordArchiveData) else {
|
|
| 1769 |
+ throw HealthRecordArchiveReadError.decodeFailed(typeIdentifier: typeIdentifier) |
|
| 1770 |
+ } |
|
| 1771 |
+ |
|
| 1772 |
+ var recordMap: [String: SampleRecordPayload] = [:] |
|
| 1773 |
+ recordMap.reserveCapacity(records.count) |
|
| 1774 |
+ for record in records {
|
|
| 1775 |
+ recordMap[record.sampleUUIDHash] = SampleRecordPayload( |
|
| 1776 |
+ recordFingerprint: record.recordFingerprint, |
|
| 1777 |
+ startDate: record.startDate, |
|
| 1778 |
+ endDate: record.endDate, |
|
| 1779 |
+ displayValue: record.displayValue |
|
| 1780 |
+ ) |
|
| 1781 |
+ } |
|
| 1782 |
+ return recordMap |
|
| 1783 |
+ } |
|
| 1784 |
+ |
|
| 1785 |
+ private nonisolated static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
|
|
| 1786 |
+ try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true) |
|
| 1787 |
+ } |
|
| 1423 | 1788 |
} |
| 1424 | 1789 |
|
| 1425 | 1790 |
private struct TypeCountFetchResult: Sendable {
|
@@ -1575,7 +1940,7 @@ private extension SnapshotFetchProgress {
|
||
| 1575 | 1940 |
} |
| 1576 | 1941 |
|
| 1577 | 1942 |
private enum DistributionCaptureConfiguration {
|
| 1578 |
- static let queryPageLimit = 20_000 |
|
| 1943 |
+ static let queryPageLimit = 25_000 |
|
| 1579 | 1944 |
static let pageTimeoutSeconds: TimeInterval = 60 |
| 1580 | 1945 |
static let fullImportTimeoutSeconds: TimeInterval = HealthKitService.fullHistoryImportTimeoutSeconds |
| 1581 | 1946 |
} |
@@ -100,6 +100,8 @@ final class SnapshotFetchProgress {
|
||
| 100 | 100 |
var timeoutCount: Int = 0 |
| 101 | 101 |
var successCount: Int = 0 |
| 102 | 102 |
var blockProgress: String = "" |
| 103 |
+ var blockElapsedSeconds: TimeInterval = 0 |
|
| 104 |
+ var blockSamplesPerSecond: Double = 0 |
|
| 103 | 105 |
} |
| 104 | 106 |
|
| 105 | 107 |
let totalTypeCount: Int |
@@ -225,12 +227,24 @@ final class SnapshotFetchProgress {
|
||
| 225 | 227 |
types[index].successCount = successCount |
| 226 | 228 |
} |
| 227 | 229 |
|
| 228 |
- func updateBlockProgress(_ id: String, detail: String, recordCount: Int? = nil) {
|
|
| 230 |
+ func updateBlockProgress( |
|
| 231 |
+ _ id: String, |
|
| 232 |
+ detail: String, |
|
| 233 |
+ recordCount: Int? = nil, |
|
| 234 |
+ elapsedSeconds: TimeInterval? = nil, |
|
| 235 |
+ samplesPerSecond: Double? = nil |
|
| 236 |
+ ) {
|
|
| 229 | 237 |
let index = visibleTypeIndex(for: id) |
| 230 | 238 |
types[index].blockProgress = detail |
| 231 | 239 |
if let recordCount {
|
| 232 | 240 |
types[index].recordCount = recordCount |
| 233 | 241 |
} |
| 242 |
+ if let elapsedSeconds {
|
|
| 243 |
+ types[index].blockElapsedSeconds = elapsedSeconds |
|
| 244 |
+ } |
|
| 245 |
+ if let samplesPerSecond {
|
|
| 246 |
+ types[index].blockSamplesPerSecond = samplesPerSecond |
|
| 247 |
+ } |
|
| 234 | 248 |
} |
| 235 | 249 |
|
| 236 | 250 |
func markUnavailable(_ id: String) {
|
@@ -185,8 +185,12 @@ final class DashboardViewModel {
|
||
| 185 | 185 |
return |
| 186 | 186 |
} |
| 187 | 187 |
|
| 188 |
- let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
|
| 189 |
- let exists = allSnapshots.contains { $0.id == snapshot.id }
|
|
| 188 |
+ let snapshotID = snapshot.id |
|
| 189 |
+ var descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 190 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
|
|
| 191 |
+ ) |
|
| 192 |
+ descriptor.fetchLimit = 1 |
|
| 193 |
+ let exists = try !context.fetch(descriptor).isEmpty |
|
| 190 | 194 |
|
| 191 | 195 |
if !exists {
|
| 192 | 196 |
throw SnapshotCreationError.snapshotNotSaved |
@@ -326,8 +330,11 @@ final class DashboardViewModel {
|
||
| 326 | 330 |
ambiguousDisappearedMetrics = [] |
| 327 | 331 |
if let snapshotID = completedSnapshotID {
|
| 328 | 332 |
do {
|
| 329 |
- let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
|
| 330 |
- if let snapshot = allSnapshots.first(where: { $0.id == snapshotID }) {
|
|
| 333 |
+ var descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 334 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
|
|
| 335 |
+ ) |
|
| 336 |
+ descriptor.fetchLimit = 1 |
|
| 337 |
+ if let snapshot = try context.fetch(descriptor).first {
|
|
| 331 | 338 |
context.delete(snapshot) |
| 332 | 339 |
} |
| 333 | 340 |
} catch { }
|
@@ -407,6 +407,12 @@ struct DashboardView: View {
|
||
| 407 | 407 |
lines.append(" timeoutCount: \(type.timeoutCount)")
|
| 408 | 408 |
lines.append(" successCount: \(type.successCount)")
|
| 409 | 409 |
lines.append(" totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
|
| 410 |
+ if type.blockElapsedSeconds > 0 {
|
|
| 411 |
+ lines.append(" fetchProgressElapsed: \(formatDuration(type.blockElapsedSeconds))")
|
|
| 412 |
+ } |
|
| 413 |
+ if type.blockSamplesPerSecond > 0 {
|
|
| 414 |
+ lines.append(" fetchProgressRate: \(formatRate(type.blockSamplesPerSecond)) samples/s")
|
|
| 415 |
+ } |
|
| 410 | 416 |
if mode == .full || type.isUnsupported {
|
| 411 | 417 |
lines.append(" unsupported: \(type.isUnsupported ? "true" : "false")")
|
| 412 | 418 |
} |
@@ -805,22 +811,12 @@ struct DashboardView: View {
|
||
| 805 | 811 |
Text(type.blockProgress) |
| 806 | 812 |
.font(.caption2) |
| 807 | 813 |
.foregroundStyle(.secondary) |
| 808 |
- .lineLimit(1) |
|
| 814 |
+ .lineLimit(2) |
|
| 809 | 815 |
} |
| 810 | 816 |
} |
| 811 | 817 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 812 | 818 |
|
| 813 |
- if type.recordCount > 0 {
|
|
| 814 |
- Text("\(type.recordCount) records")
|
|
| 815 |
- .font(.caption2) |
|
| 816 |
- .foregroundStyle(.secondary) |
|
| 817 |
- .lineLimit(1) |
|
| 818 |
- } else if case .failed(let reason) = type.status, reason == "Not authorized" {
|
|
| 819 |
- Text("Unavailable")
|
|
| 820 |
- .font(.caption2) |
|
| 821 |
- .foregroundStyle(Color.warningAmber) |
|
| 822 |
- .lineLimit(1) |
|
| 823 |
- } |
|
| 819 |
+ fetchMetricStatsColumn(type) |
|
| 824 | 820 |
} |
| 825 | 821 |
.padding(.horizontal, 10) |
| 826 | 822 |
.padding(.vertical, 9) |
@@ -910,6 +906,45 @@ struct DashboardView: View {
|
||
| 910 | 906 |
.frame(maxWidth: .infinity) |
| 911 | 907 |
} |
| 912 | 908 |
|
| 909 |
+ @ViewBuilder |
|
| 910 |
+ private func fetchMetricStatsColumn(_ type: SnapshotFetchProgress.TypeProgress) -> some View {
|
|
| 911 |
+ if type.recordCount > 0 || type.blockElapsedSeconds > 0 || type.blockSamplesPerSecond > 0 {
|
|
| 912 |
+ VStack(alignment: .trailing, spacing: 2) {
|
|
| 913 |
+ if type.recordCount > 0 {
|
|
| 914 |
+ Text("\(type.recordCount) records")
|
|
| 915 |
+ } |
|
| 916 |
+ if type.blockElapsedSeconds > 0 {
|
|
| 917 |
+ Text(formatDuration(type.blockElapsedSeconds)) |
|
| 918 |
+ } |
|
| 919 |
+ if type.blockSamplesPerSecond > 0 {
|
|
| 920 |
+ Text("\(formatRate(type.blockSamplesPerSecond)) rec/s")
|
|
| 921 |
+ } |
|
| 922 |
+ } |
|
| 923 |
+ .font(.caption2) |
|
| 924 |
+ .foregroundStyle(.secondary) |
|
| 925 |
+ .lineLimit(1) |
|
| 926 |
+ .frame(minWidth: 82, alignment: .trailing) |
|
| 927 |
+ } else if case .failed(let reason) = type.status, reason == "Not authorized" {
|
|
| 928 |
+ Text("Unavailable")
|
|
| 929 |
+ .font(.caption2) |
|
| 930 |
+ .foregroundStyle(Color.warningAmber) |
|
| 931 |
+ .lineLimit(1) |
|
| 932 |
+ } |
|
| 933 |
+ } |
|
| 934 |
+ |
|
| 935 |
+ private func formatRate(_ value: Double) -> String {
|
|
| 936 |
+ if value >= 1_000 {
|
|
| 937 |
+ return value.formatted(.number.precision(.fractionLength(0)).grouping(.automatic)) |
|
| 938 |
+ } |
|
| 939 |
+ if value >= 100 {
|
|
| 940 |
+ return value.formatted(.number.precision(.fractionLength(0))) |
|
| 941 |
+ } |
|
| 942 |
+ if value >= 10 {
|
|
| 943 |
+ return value.formatted(.number.precision(.fractionLength(1))) |
|
| 944 |
+ } |
|
| 945 |
+ return value.formatted(.number.precision(.fractionLength(2))) |
|
| 946 |
+ } |
|
| 947 |
+ |
|
| 913 | 948 |
// MARK: - Progress Sheet |
| 914 | 949 |
|
| 915 | 950 |
private var progressSheet: some View {
|