@@ -2,17 +2,7 @@ |
||
| 2 | 2 |
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 |
- <key>aps-environment</key> |
|
| 6 |
- <string>development</string> |
|
| 7 | 5 |
<key>com.apple.developer.healthkit</key> |
| 8 | 6 |
<true/> |
| 9 |
- <key>com.apple.developer.icloud-container-identifiers</key> |
|
| 10 |
- <array> |
|
| 11 |
- <string>iCloud.ro.xdev.HealthProbe</string> |
|
| 12 |
- </array> |
|
| 13 |
- <key>com.apple.developer.icloud-services</key> |
|
| 14 |
- <array> |
|
| 15 |
- <string>CloudKit</string> |
|
| 16 |
- </array> |
|
| 17 | 7 |
</dict> |
| 18 | 8 |
</plist> |
@@ -22,16 +22,20 @@ struct HealthProbeApp: App {
|
||
| 22 | 22 |
.modelContainer(sharedModelContainer) |
| 23 | 23 |
} |
| 24 | 24 |
|
| 25 |
- // Completely wipes all SwiftData stores and recreates fresh directory structure. |
|
| 26 |
- // This prevents schema corruption from legacy data by starting completely clean. |
|
| 25 |
+ // Wipes all SwiftData stores and CoreData-CloudKit caches so that schema-mismatched |
|
| 26 |
+ // records are not re-imported from CloudKit after the store is deleted. |
|
| 27 | 27 |
private static func destroyAllStoresAndRecreate() {
|
| 28 |
+ let fm = FileManager.default |
|
| 28 | 29 |
let appSupportURL = URL.applicationSupportDirectory |
| 29 | 30 |
|
| 30 |
- // Remove the entire Application Support directory |
|
| 31 |
- try? FileManager.default.removeItem(at: appSupportURL) |
|
| 31 |
+ // Remove the entire Application Support directory (store files, WAL, SHM) |
|
| 32 |
+ try? fm.removeItem(at: appSupportURL) |
|
| 33 |
+ try? fm.createDirectory(at: appSupportURL, withIntermediateDirectories: true) |
|
| 32 | 34 |
|
| 33 |
- // Recreate it fresh |
|
| 34 |
- try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true) |
|
| 35 |
+ // Also wipe CoreData-CloudKit transaction log metadata so stale remote records |
|
| 36 |
+ // are not re-applied to the fresh store on the next launch. |
|
| 37 |
+ let ckMetadataURL = appSupportURL.appending(path: "com.apple.coredata.cloudkit", directoryHint: .isDirectory) |
|
| 38 |
+ try? fm.removeItem(at: ckMetadataURL) |
|
| 35 | 39 |
} |
| 36 | 40 |
|
| 37 | 41 |
// Two separate ModelConfiguration instances: |
@@ -46,7 +50,7 @@ struct HealthProbeApp: App {
|
||
| 46 | 50 |
let fullSchema = Schema([ |
| 47 | 51 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, |
| 48 | 52 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 49 |
- OperationLog.self, DeviceProfile.self, |
|
| 53 |
+ OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self, |
|
| 50 | 54 |
]) |
| 51 | 55 |
|
| 52 | 56 |
#if DEBUG |
@@ -63,7 +67,7 @@ struct HealthProbeApp: App {
|
||
| 63 | 67 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, |
| 64 | 68 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 65 | 69 |
]) |
| 66 |
- let localModels = Schema([OperationLog.self, DeviceProfile.self]) |
|
| 70 |
+ let localModels = Schema([OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self]) |
|
| 67 | 71 |
|
| 68 | 72 |
let cloudConfig = ModelConfiguration( |
| 69 | 73 |
"cloud", |
@@ -4,9 +4,5 @@ |
||
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>NSHealthShareUsageDescription</key> |
| 6 | 6 |
<string>HealthProbe reads record counts from your Health data to audit integrity and detect anomalies such as silent deletions or unexpected insertions. No health values are read, stored, or shared outside this device.</string> |
| 7 |
- <key>UIBackgroundModes</key> |
|
| 8 |
- <array> |
|
| 9 |
- <string>remote-notification</string> |
|
| 10 |
- </array> |
|
| 11 | 7 |
</dict> |
| 12 | 8 |
</plist> |
@@ -11,9 +11,10 @@ import SwiftData |
||
| 11 | 11 |
var previousSnapshotID: UUID? |
| 12 | 12 |
var isChainStart: Bool = false |
| 13 | 13 |
var recoveredDeviceID: Bool = false |
| 14 |
- var snapshotQuality: SnapshotQuality = SnapshotQuality.complete |
|
| 14 |
+ var snapshotQualityRaw: String = SnapshotQuality.complete.rawValue |
|
| 15 | 15 |
var anomalyFlagsJSON: String = "[]" |
| 16 | 16 |
var triggerReason: String = "manual" |
| 17 |
+ var retryOfSnapshotID: UUID? |
|
| 17 | 18 |
var isPostRestore: Bool = false |
| 18 | 19 |
var isPostRestoreInferred: Bool = false |
| 19 | 20 |
var isPostRestoreSuppressedDeltaID: UUID? |
@@ -36,6 +37,11 @@ import SwiftData |
||
| 36 | 37 |
} |
| 37 | 38 |
|
| 38 | 39 |
extension HealthSnapshot {
|
| 40 |
+ var snapshotQuality: SnapshotQuality {
|
|
| 41 |
+ get { SnapshotQuality(rawValue: snapshotQualityRaw) ?? .complete }
|
|
| 42 |
+ set { snapshotQualityRaw = newValue.rawValue }
|
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 39 | 45 |
var anomalyFlags: [String] {
|
| 40 | 46 |
get { (try? JSONDecoder().decode([String].self, from: Data(anomalyFlagsJSON.utf8))) ?? [] }
|
| 41 | 47 |
set { anomalyFlagsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
|
@@ -0,0 +1,140 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+import SwiftData |
|
| 3 |
+ |
|
| 4 |
+@Model final class MetricTimeoutProfile {
|
|
| 5 |
+ static let defaultInitialTimeout: TimeInterval = 15 |
|
| 6 |
+ static let maximumTimeout: TimeInterval = 120 |
|
| 7 |
+ private static let sampleLimit = 30 |
|
| 8 |
+ private static let p95MinimumSampleCount = 5 |
|
| 9 |
+ |
|
| 10 |
+ var id: UUID = UUID() |
|
| 11 |
+ var metricIdentifier: String = "" |
|
| 12 |
+ var displayName: String = "" |
|
| 13 |
+ var lastSuccessfulElapsed: TimeInterval = 0 |
|
| 14 |
+ var averageSuccessfulElapsed: TimeInterval = 0 |
|
| 15 |
+ var p95SuccessfulElapsed: TimeInterval = 0 |
|
| 16 |
+ var lastTimeoutElapsed: TimeInterval = 0 |
|
| 17 |
+ var timeoutCount: Int = 0 |
|
| 18 |
+ var successCount: Int = 0 |
|
| 19 |
+ var lastUpdatedAt: Date = Date.distantPast |
|
| 20 |
+ var currentTimeout: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout |
|
| 21 |
+ var isManuallyOverridden: Bool = false |
|
| 22 |
+ var manualTimeoutValue: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout |
|
| 23 |
+ var successfulElapsedSamplesJSON: String = "[]" |
|
| 24 |
+ |
|
| 25 |
+ init(metricIdentifier: String, displayName: String) {
|
|
| 26 |
+ self.id = UUID() |
|
| 27 |
+ self.metricIdentifier = metricIdentifier |
|
| 28 |
+ self.displayName = displayName |
|
| 29 |
+ self.currentTimeout = Self.defaultInitialTimeout |
|
| 30 |
+ self.manualTimeoutValue = Self.defaultInitialTimeout |
|
| 31 |
+ } |
|
| 32 |
+} |
|
| 33 |
+ |
|
| 34 |
+extension MetricTimeoutProfile {
|
|
| 35 |
+ var effectiveTimeout: TimeInterval {
|
|
| 36 |
+ clamp(isManuallyOverridden ? manualTimeoutValue : currentTimeout) |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ var timeoutMode: String {
|
|
| 40 |
+ if isManuallyOverridden { return "manual" }
|
|
| 41 |
+ return successCount == 0 && timeoutCount == 0 ? "default" : "adaptive" |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ var hasP95: Bool {
|
|
| 45 |
+ successSamples.count >= Self.p95MinimumSampleCount |
|
| 46 |
+ } |
|
| 47 |
+ |
|
| 48 |
+ var p95SuccessfulElapsedIfAvailable: TimeInterval? {
|
|
| 49 |
+ hasP95 ? p95SuccessfulElapsed : nil |
|
| 50 |
+ } |
|
| 51 |
+ |
|
| 52 |
+ var suggestedRetryTimeout: TimeInterval {
|
|
| 53 |
+ clamp(max(effectiveTimeout * 2, lastSuccessfulElapsed * 2, Self.defaultInitialTimeout)) |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ func recordSuccess(elapsed: TimeInterval, displayName: String) {
|
|
| 57 |
+ self.displayName = displayName |
|
| 58 |
+ lastSuccessfulElapsed = elapsed |
|
| 59 |
+ successCount += 1 |
|
| 60 |
+ lastUpdatedAt = Date() |
|
| 61 |
+ |
|
| 62 |
+ var samples = successSamples |
|
| 63 |
+ samples.append(elapsed) |
|
| 64 |
+ if samples.count > Self.sampleLimit {
|
|
| 65 |
+ samples.removeFirst(samples.count - Self.sampleLimit) |
|
| 66 |
+ } |
|
| 67 |
+ successSamples = samples |
|
| 68 |
+ |
|
| 69 |
+ averageSuccessfulElapsed = samples.reduce(0, +) / Double(samples.count) |
|
| 70 |
+ p95SuccessfulElapsed = Self.percentile95(samples) |
|
| 71 |
+ currentTimeout = automaticTimeout(from: samples) |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ func recordTimeout(elapsed: TimeInterval, displayName: String) {
|
|
| 75 |
+ self.displayName = displayName |
|
| 76 |
+ lastTimeoutElapsed = elapsed |
|
| 77 |
+ timeoutCount += 1 |
|
| 78 |
+ lastUpdatedAt = Date() |
|
| 79 |
+ |
|
| 80 |
+ guard !isManuallyOverridden else { return }
|
|
| 81 |
+ let progressive = max(currentTimeout * 1.5, elapsed * 2, Self.defaultInitialTimeout) |
|
| 82 |
+ currentTimeout = clamp(progressive) |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ func resetLearning(displayName: String? = nil) {
|
|
| 86 |
+ if let displayName { self.displayName = displayName }
|
|
| 87 |
+ lastSuccessfulElapsed = 0 |
|
| 88 |
+ averageSuccessfulElapsed = 0 |
|
| 89 |
+ p95SuccessfulElapsed = 0 |
|
| 90 |
+ lastTimeoutElapsed = 0 |
|
| 91 |
+ timeoutCount = 0 |
|
| 92 |
+ successCount = 0 |
|
| 93 |
+ lastUpdatedAt = Date() |
|
| 94 |
+ currentTimeout = Self.defaultInitialTimeout |
|
| 95 |
+ isManuallyOverridden = false |
|
| 96 |
+ manualTimeoutValue = Self.defaultInitialTimeout |
|
| 97 |
+ successfulElapsedSamplesJSON = "[]" |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ func setManualTimeout(_ value: TimeInterval) {
|
|
| 101 |
+ manualTimeoutValue = clamp(value) |
|
| 102 |
+ isManuallyOverridden = true |
|
| 103 |
+ lastUpdatedAt = Date() |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ func returnToAutomatic() {
|
|
| 107 |
+ isManuallyOverridden = false |
|
| 108 |
+ lastUpdatedAt = Date() |
|
| 109 |
+ } |
|
| 110 |
+ |
|
| 111 |
+ private var successSamples: [TimeInterval] {
|
|
| 112 |
+ get {
|
|
| 113 |
+ guard let data = successfulElapsedSamplesJSON.data(using: .utf8), |
|
| 114 |
+ let values = try? JSONDecoder().decode([TimeInterval].self, from: data) else {
|
|
| 115 |
+ return [] |
|
| 116 |
+ } |
|
| 117 |
+ return values |
|
| 118 |
+ } |
|
| 119 |
+ set {
|
|
| 120 |
+ successfulElapsedSamplesJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" |
|
| 121 |
+ } |
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ private func automaticTimeout(from samples: [TimeInterval]) -> TimeInterval {
|
|
| 125 |
+ let basis = samples.count >= Self.p95MinimumSampleCount ? Self.percentile95(samples) : samples.max() ?? 0 |
|
| 126 |
+ return clamp(max(Self.defaultInitialTimeout, basis * 2, currentTimeout * 0.9)) |
|
| 127 |
+ } |
|
| 128 |
+ |
|
| 129 |
+ private func clamp(_ value: TimeInterval) -> TimeInterval {
|
|
| 130 |
+ min(max(value, Self.defaultInitialTimeout), Self.maximumTimeout) |
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 133 |
+ private static func percentile95(_ samples: [TimeInterval]) -> TimeInterval {
|
|
| 134 |
+ guard !samples.isEmpty else { return 0 }
|
|
| 135 |
+ let sorted = samples.sorted() |
|
| 136 |
+ let rawIndex = Int(ceil(Double(sorted.count) * 0.95)) - 1 |
|
| 137 |
+ let index = min(max(rawIndex, 0), sorted.count - 1) |
|
| 138 |
+ return sorted[index] |
|
| 139 |
+ } |
|
| 140 |
+} |
|
@@ -6,10 +6,10 @@ import SwiftData |
||
| 6 | 6 |
var typeIdentifier: String = "" |
| 7 | 7 |
var displayName: String = "" |
| 8 | 8 |
var count: Int = 0 |
| 9 |
- var hash: String = "" |
|
| 9 |
+ var contentHash: String = "" |
|
| 10 | 10 |
var earliestDate: Date? |
| 11 | 11 |
var latestDate: Date? |
| 12 |
- var quality: SnapshotQuality = SnapshotQuality.complete |
|
| 12 |
+ var qualityRaw: String = SnapshotQuality.complete.rawValue |
|
| 13 | 13 |
var isUnsupported: Bool = false |
| 14 | 14 |
var snapshot: HealthSnapshot? |
| 15 | 15 |
@Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount) |
@@ -20,6 +20,13 @@ import SwiftData |
||
| 20 | 20 |
self.typeIdentifier = typeIdentifier |
| 21 | 21 |
self.displayName = displayName |
| 22 | 22 |
self.count = count |
| 23 |
- self.quality = quality |
|
| 23 |
+ self.qualityRaw = quality.rawValue |
|
| 24 |
+ } |
|
| 25 |
+} |
|
| 26 |
+ |
|
| 27 |
+extension TypeCount {
|
|
| 28 |
+ var quality: SnapshotQuality {
|
|
| 29 |
+ get { SnapshotQuality(rawValue: qualityRaw) ?? .complete }
|
|
| 30 |
+ set { qualityRaw = newValue.rawValue }
|
|
| 24 | 31 |
} |
| 25 | 32 |
} |
@@ -123,8 +123,8 @@ enum DeltaService {
|
||
| 123 | 123 |
|
| 124 | 124 |
let prevCount = prev?.count ?? 0 |
| 125 | 125 |
let currCount = curr?.count ?? 0 |
| 126 |
- let prevHash = prev?.hash ?? "" |
|
| 127 |
- let currHash = curr?.hash ?? "" |
|
| 126 |
+ let prevHash = prev?.contentHash ?? "" |
|
| 127 |
+ let currHash = curr?.contentHash ?? "" |
|
| 128 | 128 |
|
| 129 | 129 |
if let prev, let curr {
|
| 130 | 130 |
// Type present in both snapshots |
@@ -26,14 +26,13 @@ enum HashService {
|
||
| 26 | 26 |
} |
| 27 | 27 |
|
| 28 | 28 |
// Per-snapshot: sort TypeCounts by typeIdentifier, SHA256 of concatenated type hashes. |
| 29 |
- // Filter criterion: quality == .complete — do NOT use hash != "" as a proxy. |
|
| 30 |
- // A TypeCount with quality = .failed but hash = "nonEmpty" (e.g. from a prior bug) must |
|
| 31 |
- // be excluded. Filtering on hash != "" would include it, producing a non-deterministic checksum. |
|
| 29 |
+ // Filter criterion: quality == .complete; do not use contentHash != "" as a proxy. |
|
| 30 |
+ // A TypeCount with quality = .failed but contentHash = "nonEmpty" must be excluded. |
|
| 32 | 31 |
static func snapshotChecksum(typeCounts: [TypeCount]) -> String {
|
| 33 | 32 |
let completeHashes = typeCounts |
| 34 | 33 |
.filter { $0.quality == .complete }
|
| 35 | 34 |
.sorted { $0.typeIdentifier < $1.typeIdentifier }
|
| 36 |
- .map { $0.hash }
|
|
| 35 |
+ .map { $0.contentHash }
|
|
| 37 | 36 |
.joined() |
| 38 | 37 |
let digest = SHA256.hash(data: Data(completeHashes.utf8)) |
| 39 | 38 |
return digest.map { String(format: "%02x", $0) }.joined()
|
@@ -15,7 +15,7 @@ enum TypeCategory: String, CaseIterable {
|
||
| 15 | 15 |
case body = "Body" |
| 16 | 16 |
} |
| 17 | 17 |
|
| 18 |
-struct MonitoredType: Identifiable {
|
|
| 18 |
+struct MonitoredType: Identifiable, @unchecked Sendable {
|
|
| 19 | 19 |
let id: String |
| 20 | 20 |
let displayName: String |
| 21 | 21 |
let category: TypeCategory |
@@ -29,19 +29,29 @@ final class HealthKitService {
|
||
| 29 | 29 |
|
| 30 | 30 |
static let allTypes: [MonitoredType] = buildAllTypes() |
| 31 | 31 |
|
| 32 |
- // 15s budget covers distribution + earliestDate + latestDate combined — not 15s each. |
|
| 33 |
- private static let perTypeTimeoutSeconds: TimeInterval = 15 |
|
| 32 |
+ static let defaultInitialTimeoutSeconds: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout |
|
| 33 |
+ static let maximumTimeoutSeconds: TimeInterval = MetricTimeoutProfile.maximumTimeout |
|
| 34 | 34 |
// Prevents 3N simultaneous HK queries from exhausting resources at N=20 types. |
| 35 |
- private static let maxConcurrentTypeFetches = 6 |
|
| 35 |
+ static let maxConcurrentTypeFetches = 6 |
|
| 36 | 36 |
|
| 37 | 37 |
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
| 38 | 38 |
|
| 39 |
+ var hasRequestedPermissionsBefore: Bool {
|
|
| 40 |
+ get {
|
|
| 41 |
+ UserDefaults.standard.bool(forKey: "healthkit.permissions.requested") |
|
| 42 |
+ } |
|
| 43 |
+ set {
|
|
| 44 |
+ UserDefaults.standard.set(newValue, forKey: "healthkit.permissions.requested") |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ |
|
| 39 | 48 |
// MARK: - Authorization |
| 40 | 49 |
|
| 41 | 50 |
func requestAuthorization() async throws {
|
| 42 | 51 |
guard isAvailable else { return }
|
| 43 | 52 |
let readTypes = Set(Self.allTypes.compactMap { $0.objectType })
|
| 44 | 53 |
try await store.requestAuthorization(toShare: [], read: readTypes) |
| 54 |
+ hasRequestedPermissionsBefore = true |
|
| 45 | 55 |
} |
| 46 | 56 |
|
| 47 | 57 |
// MARK: - Snapshot creation |
@@ -50,7 +60,11 @@ final class HealthKitService {
|
||
| 50 | 60 |
func createSnapshot( |
| 51 | 61 |
in context: ModelContext, |
| 52 | 62 |
selectedTypeIDs: Set<String>, |
| 53 |
- onTypeCompleted: ((TypeCount) -> Void)? = nil |
|
| 63 |
+ adaptiveTimeoutsEnabled: Bool, |
|
| 64 |
+ triggerReason: String = "manual", |
|
| 65 |
+ retryOfSnapshotID: UUID? = nil, |
|
| 66 |
+ timeoutMultiplier: Double = 1, |
|
| 67 |
+ progress: SnapshotFetchProgress? = nil |
|
| 54 | 68 |
) async throws -> HealthSnapshot {
|
| 55 | 69 |
let active = Self.allTypes.filter { selectedTypeIDs.contains($0.id) }
|
| 56 | 70 |
let deviceResolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: isStoreEmpty(context: context)) |
@@ -62,11 +76,29 @@ final class HealthKitService {
|
||
| 62 | 76 |
deviceID: deviceResolution.id |
| 63 | 77 |
) |
| 64 | 78 |
snapshot.recoveredDeviceID = deviceResolution.isRecovered |
| 65 |
- snapshot.triggerReason = "manual" |
|
| 79 |
+ snapshot.triggerReason = triggerReason |
|
| 80 |
+ snapshot.retryOfSnapshotID = retryOfSnapshotID |
|
| 66 | 81 |
snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier |
| 67 | 82 |
context.insert(snapshot) |
| 68 | 83 |
|
| 69 |
- let typeCounts = await fetchAllTypeCounts(for: active, snapshot: snapshot, onTypeCompleted: onTypeCompleted) |
|
| 84 |
+ // Fetch raw HealthKit data off the main actor, then assemble SwiftData models here |
|
| 85 |
+ // on the main actor to prevent data races on managed object context. |
|
| 86 |
+ let fetchResults = await fetchAllTypeCounts( |
|
| 87 |
+ for: active, |
|
| 88 |
+ context: context, |
|
| 89 |
+ adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
|
| 90 |
+ timeoutMultiplier: timeoutMultiplier, |
|
| 91 |
+ progress: progress |
|
| 92 |
+ ) |
|
| 93 |
+ let typeCounts: [TypeCount] = fetchResults.map { result in
|
|
| 94 |
+ let tc = result.makeTypeCount() |
|
| 95 |
+ context.insert(tc) |
|
| 96 |
+ for yearlyCount in tc.yearlyCounts ?? [] {
|
|
| 97 |
+ context.insert(yearlyCount) |
|
| 98 |
+ } |
|
| 99 |
+ tc.snapshot = snapshot |
|
| 100 |
+ return tc |
|
| 101 |
+ } |
|
| 70 | 102 |
|
| 71 | 103 |
// Invariant assertions before save — debug asserts + release silent correction |
| 72 | 104 |
for tc in typeCounts {
|
@@ -182,147 +214,205 @@ final class HealthKitService {
|
||
| 182 | 214 |
|
| 183 | 215 |
// MARK: - Per-type fetch pipeline |
| 184 | 216 |
|
| 217 |
+ // Returns raw Sendable fetch results — no SwiftData model creation here. |
|
| 218 |
+ // All TypeCount/YearlyCount objects must be created on the main actor in createSnapshot. |
|
| 219 |
+ // Fetches sequentially to prevent race conditions and resource exhaustion. |
|
| 185 | 220 |
private func fetchAllTypeCounts( |
| 186 | 221 |
for active: [MonitoredType], |
| 187 |
- snapshot: HealthSnapshot, |
|
| 188 |
- onTypeCompleted: ((TypeCount) -> Void)? = nil |
|
| 189 |
- ) async -> [TypeCount] {
|
|
| 190 |
- var results: [TypeCount] = [] |
|
| 191 |
- |
|
| 192 |
- // Fetch in batches to cap concurrent HK queries |
|
| 193 |
- let batches = stride(from: 0, to: active.count, by: Self.maxConcurrentTypeFetches).map {
|
|
| 194 |
- Array(active[$0..<min($0 + Self.maxConcurrentTypeFetches, active.count)]) |
|
| 195 |
- } |
|
| 196 |
- |
|
| 197 |
- for batch in batches {
|
|
| 198 |
- await withTaskGroup(of: TypeCount.self) { group in
|
|
| 199 |
- for monitoredType in batch {
|
|
| 200 |
- group.addTask { [weak self] in
|
|
| 201 |
- guard let self else {
|
|
| 202 |
- return self?.makeFailedTypeCount(monitoredType) ?? TypeCount( |
|
| 203 |
- typeIdentifier: monitoredType.id, |
|
| 204 |
- displayName: monitoredType.displayName, |
|
| 205 |
- count: -1, |
|
| 206 |
- quality: SnapshotQuality.failed |
|
| 207 |
- ) |
|
| 208 |
- } |
|
| 209 |
- return await self.fetchTypeCount(for: monitoredType) |
|
| 210 |
- } |
|
| 211 |
- } |
|
| 212 |
- for await tc in group {
|
|
| 213 |
- tc.snapshot = snapshot |
|
| 214 |
- snapshot.typeCounts?.append(tc) |
|
| 215 |
- onTypeCompleted?(tc) |
|
| 216 |
- results.append(tc) |
|
| 217 |
- } |
|
| 218 |
- } |
|
| 222 |
+ context: ModelContext, |
|
| 223 |
+ adaptiveTimeoutsEnabled: Bool, |
|
| 224 |
+ timeoutMultiplier: Double, |
|
| 225 |
+ progress: SnapshotFetchProgress? = nil |
|
| 226 |
+ ) async -> [TypeCountFetchResult] {
|
|
| 227 |
+ var results: [TypeCountFetchResult] = [] |
|
| 228 |
+ |
|
| 229 |
+ for monitoredType in active {
|
|
| 230 |
+ let profile = timeoutProfile(for: monitoredType, context: context) |
|
| 231 |
+ let timeout = timeoutForFetch( |
|
| 232 |
+ profile: profile, |
|
| 233 |
+ adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
|
| 234 |
+ timeoutMultiplier: timeoutMultiplier |
|
| 235 |
+ ) |
|
| 236 |
+ var result = await fetchTypeCountData( |
|
| 237 |
+ for: monitoredType, |
|
| 238 |
+ timeoutProfile: profile, |
|
| 239 |
+ timeoutSeconds: timeout, |
|
| 240 |
+ progress: progress |
|
| 241 |
+ ) |
|
| 242 |
+ updateTimeoutProfile(profile, with: result, monitoredType: monitoredType) |
|
| 243 |
+ result.applyTimeoutProfile(profile) |
|
| 244 |
+ progress?.updateTimeoutProfile(from: profile, for: monitoredType.id) |
|
| 245 |
+ results.append(result) |
|
| 219 | 246 |
} |
| 220 | 247 |
return results |
| 221 | 248 |
} |
| 222 | 249 |
|
| 223 |
- private func fetchTypeCount(for monitoredType: MonitoredType) async -> TypeCount {
|
|
| 250 |
+ private func fetchTypeCountData( |
|
| 251 |
+ for monitoredType: MonitoredType, |
|
| 252 |
+ timeoutProfile: MetricTimeoutProfile, |
|
| 253 |
+ timeoutSeconds: TimeInterval, |
|
| 254 |
+ progress: SnapshotFetchProgress? = nil |
|
| 255 |
+ ) async -> TypeCountFetchResult {
|
|
| 256 |
+ let started = Date() |
|
| 257 |
+ progress?.updateStatus(monitoredType.id, status: .fetching) |
|
| 258 |
+ |
|
| 224 | 259 |
// Unsupported type: HKObjectType factory returned nil for this identifier |
| 225 | 260 |
guard let objectType = monitoredType.objectType, |
| 226 | 261 |
let sampleType = objectType as? HKSampleType else {
|
| 227 |
- let tc = TypeCount( |
|
| 262 |
+ var result = TypeCountFetchResult( |
|
| 228 | 263 |
typeIdentifier: monitoredType.id, |
| 229 | 264 |
displayName: monitoredType.displayName, |
| 230 | 265 |
count: -1, |
| 231 |
- quality: SnapshotQuality.failed |
|
| 266 |
+ contentHash: "", |
|
| 267 |
+ earliestDate: nil, |
|
| 268 |
+ latestDate: nil, |
|
| 269 |
+ quality: SnapshotQuality.failed, |
|
| 270 |
+ diagnosticQuality: HealthKitAPICallResult.Status.unsupported.rawValue, |
|
| 271 |
+ isUnsupported: true, |
|
| 272 |
+ authorizationStatus: "unavailable", |
|
| 273 |
+ apiCalls: Self.placeholderAPICalls(status: .unsupported, message: "HealthKit type is not available on this OS or device"), |
|
| 274 |
+ yearlyCounts: [] |
|
| 232 | 275 |
) |
| 233 |
- tc.isUnsupported = true |
|
| 234 |
- return tc |
|
| 276 |
+ result.totalElapsedSeconds = Date().timeIntervalSince(started) |
|
| 277 |
+ result.timeoutConfiguredSeconds = timeoutSeconds |
|
| 278 |
+ result.applyTimeoutProfile(timeoutProfile) |
|
| 279 |
+ progress?.updateDetails(from: result) |
|
| 280 |
+ progress?.updateStatus(monitoredType.id, status: .failed("Unsupported"))
|
|
| 281 |
+ return result |
|
| 235 | 282 |
} |
| 236 | 283 |
|
| 237 |
- // Check authorization status before querying — if denied, fail immediately |
|
| 238 |
- // (HealthKit queries for denied types might succeed with 0 data, appearing complete) |
|
| 239 |
- if store.authorizationStatus(for: sampleType) == .sharingDenied {
|
|
| 240 |
- return TypeCount( |
|
| 241 |
- typeIdentifier: monitoredType.id, |
|
| 242 |
- displayName: monitoredType.displayName, |
|
| 243 |
- count: -1, |
|
| 244 |
- quality: SnapshotQuality.unauthorized |
|
| 245 |
- ) |
|
| 284 |
+ var result = await fetchTypeCountDataFromHK(monitoredType: monitoredType, sampleType: sampleType, timeoutSeconds: timeoutSeconds) |
|
| 285 |
+ result.totalElapsedSeconds = Date().timeIntervalSince(started) |
|
| 286 |
+ result.timeoutConfiguredSeconds = timeoutSeconds |
|
| 287 |
+ result.applyTimeoutProfile(timeoutProfile) |
|
| 288 |
+ progress?.updateDetails(from: result) |
|
| 289 |
+ |
|
| 290 |
+ if result.diagnosticQuality == SnapshotQuality.complete.rawValue {
|
|
| 291 |
+ progress?.updateStatus(monitoredType.id, status: .complete, recordCount: max(result.count, 0)) |
|
| 292 |
+ } else if result.apiCalls.contains(where: { $0.status == .timeout }) {
|
|
| 293 |
+ progress?.updateStatus(monitoredType.id, status: .failed("Timeout"))
|
|
| 294 |
+ } else if result.diagnosticQuality == SnapshotQuality.unauthorized.rawValue {
|
|
| 295 |
+ progress?.updateStatus(monitoredType.id, status: .failed("Not authorized"))
|
|
| 296 |
+ } else {
|
|
| 297 |
+ progress?.updateStatus(monitoredType.id, status: .failed("Failed"))
|
|
| 246 | 298 |
} |
| 247 | 299 |
|
| 248 |
- // 15s budget covers distribution + earliestDate + latestDate combined — not 15s each. |
|
| 249 |
- do {
|
|
| 250 |
- return try await withTimeout(seconds: Self.perTypeTimeoutSeconds) {
|
|
| 251 |
- await self.fetchTypeCountFromHK(monitoredType: monitoredType, sampleType: sampleType) |
|
| 252 |
- } |
|
| 253 |
- } catch {
|
|
| 254 |
- let isAuthDenied = (error as? HKError)?.code == .errorAuthorizationDenied |
|
| 255 |
- return TypeCount( |
|
| 300 |
+ return result |
|
| 301 |
+ } |
|
| 302 |
+ |
|
| 303 |
+ private func fetchTypeCountDataFromHK(monitoredType: MonitoredType, sampleType: HKSampleType, timeoutSeconds: TimeInterval) async -> TypeCountFetchResult {
|
|
| 304 |
+ let deadline = Date().addingTimeInterval(timeoutSeconds) |
|
| 305 |
+ let distributionResult = await measureAPICall( |
|
| 306 |
+ queryType: "distribution", |
|
| 307 |
+ timeoutSeconds: max(0, deadline.timeIntervalSinceNow) |
|
| 308 |
+ ) {
|
|
| 309 |
+ try await self.fetchDistribution(for: sampleType) |
|
| 310 |
+ } resultDescription: { distribution in
|
|
| 311 |
+ "\(distribution.totalCount) samples" |
|
| 312 |
+ } |
|
| 313 |
+ |
|
| 314 |
+ guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
|
|
| 315 |
+ let status = distributionResult.apiCall.status |
|
| 316 |
+ let quality = diagnosticQuality(for: status) |
|
| 317 |
+ return TypeCountFetchResult( |
|
| 256 | 318 |
typeIdentifier: monitoredType.id, |
| 257 | 319 |
displayName: monitoredType.displayName, |
| 258 | 320 |
count: -1, |
| 259 |
- quality: isAuthDenied ? SnapshotQuality.unauthorized : SnapshotQuality.failed |
|
| 321 |
+ contentHash: "", |
|
| 322 |
+ earliestDate: nil, |
|
| 323 |
+ latestDate: nil, |
|
| 324 |
+ quality: snapshotQuality(for: status), |
|
| 325 |
+ diagnosticQuality: quality, |
|
| 326 |
+ isUnsupported: false, |
|
| 327 |
+ authorizationStatus: authorizationStatus(from: [distributionResult.apiCall]), |
|
| 328 |
+ apiCalls: [ |
|
| 329 |
+ distributionResult.apiCall, |
|
| 330 |
+ Self.placeholderAPICall(queryType: "earliest_sample", status: .unknown, message: "query not run"), |
|
| 331 |
+ Self.placeholderAPICall(queryType: "latest_sample", status: .unknown, message: "query not run") |
|
| 332 |
+ ], |
|
| 333 |
+ yearlyCounts: [] |
|
| 260 | 334 |
) |
| 261 | 335 |
} |
| 262 |
- } |
|
| 263 | 336 |
|
| 264 |
- private func fetchTypeCountFromHK(monitoredType: MonitoredType, sampleType: HKSampleType) async -> TypeCount {
|
|
| 265 |
- do {
|
|
| 266 |
- let distribution = try await fetchDistribution(for: sampleType) |
|
| 267 |
- |
|
| 268 |
- // Both date queries share the same 15s budget via withTimeout in the caller. |
|
| 269 |
- // If either date query fails, both are set to nil (no partial date results). |
|
| 270 |
- async let earliestTask = fetchEarliestDate(for: sampleType) |
|
| 271 |
- async let latestTask = fetchLatestDate(for: sampleType) |
|
| 272 |
- let (earliest, latest) = try await (earliestTask, latestTask) |
|
| 273 |
- |
|
| 274 |
- let tc = TypeCount( |
|
| 337 |
+ // Both date queries share the same 15s budget with the distribution query. |
|
| 338 |
+ let remainingSeconds = max(0, deadline.timeIntervalSinceNow) |
|
| 339 |
+ async let earliestTask = measureAPICall( |
|
| 340 |
+ queryType: "earliest_sample", |
|
| 341 |
+ timeoutSeconds: remainingSeconds |
|
| 342 |
+ ) {
|
|
| 343 |
+ try await self.fetchEarliestDate(for: sampleType) |
|
| 344 |
+ } resultDescription: { date in
|
|
| 345 |
+ Self.iso8601String(for: date) |
|
| 346 |
+ } |
|
| 347 |
+ async let latestTask = measureAPICall( |
|
| 348 |
+ queryType: "latest_sample", |
|
| 349 |
+ timeoutSeconds: remainingSeconds |
|
| 350 |
+ ) {
|
|
| 351 |
+ try await self.fetchLatestDate(for: sampleType) |
|
| 352 |
+ } resultDescription: { date in
|
|
| 353 |
+ Self.iso8601String(for: date) |
|
| 354 |
+ } |
|
| 355 |
+ let earliestResult = await earliestTask |
|
| 356 |
+ let latestResult = await latestTask |
|
| 357 |
+ let apiCalls = [distributionResult.apiCall, earliestResult.apiCall, latestResult.apiCall] |
|
| 358 |
+ |
|
| 359 |
+ guard earliestResult.apiCall.status == .complete, latestResult.apiCall.status == .complete else {
|
|
| 360 |
+ let status = firstImpairedStatus(in: apiCalls) |
|
| 361 |
+ let quality = diagnosticQuality(for: status) |
|
| 362 |
+ return TypeCountFetchResult( |
|
| 275 | 363 |
typeIdentifier: monitoredType.id, |
| 276 | 364 |
displayName: monitoredType.displayName, |
| 277 |
- count: distribution.totalCount, |
|
| 278 |
- quality: SnapshotQuality.complete |
|
| 279 |
- ) |
|
| 280 |
- tc.earliestDate = earliest |
|
| 281 |
- tc.latestDate = latest |
|
| 282 |
- tc.hash = HashService.typeHash( |
|
| 283 |
- typeIdentifier: monitoredType.id, |
|
| 284 |
- totalCount: distribution.totalCount, |
|
| 285 |
- earliestDate: earliest, |
|
| 286 |
- latestDate: latest |
|
| 365 |
+ count: -1, |
|
| 366 |
+ contentHash: "", |
|
| 367 |
+ earliestDate: earliestResult.value ?? nil, |
|
| 368 |
+ latestDate: latestResult.value ?? nil, |
|
| 369 |
+ quality: snapshotQuality(for: status), |
|
| 370 |
+ diagnosticQuality: quality, |
|
| 371 |
+ isUnsupported: false, |
|
| 372 |
+ authorizationStatus: authorizationStatus(from: apiCalls), |
|
| 373 |
+ apiCalls: apiCalls, |
|
| 374 |
+ yearlyCounts: [] |
|
| 287 | 375 |
) |
| 376 |
+ } |
|
| 288 | 377 |
|
| 289 |
- // YearlyCount — group distribution bins by year |
|
| 290 |
- // YearlyCount uses Calendar.current — year attribution is local-time based. |
|
| 291 |
- let isApprox = DistributionCaptureConfiguration.bucketComponent != .day |
|
| 292 |
- var yearMap: [Int: Int] = [:] |
|
| 293 |
- for bin in distribution.bins {
|
|
| 294 |
- let year = Calendar.current.component(.year, from: bin.start) |
|
| 295 |
- yearMap[year, default: 0] += bin.count |
|
| 296 |
- } |
|
| 297 |
- for (year, yearCount) in yearMap {
|
|
| 298 |
- let yc = YearlyCount( |
|
| 299 |
- year: year, |
|
| 300 |
- count: yearCount, |
|
| 301 |
- typeIdentifier: monitoredType.id, |
|
| 302 |
- isApproximate: isApprox |
|
| 303 |
- ) |
|
| 304 |
- yc.typeCount = tc |
|
| 305 |
- tc.yearlyCounts?.append(yc) |
|
| 306 |
- } |
|
| 378 |
+ let earliest = earliestResult.value ?? nil |
|
| 379 |
+ let latest = latestResult.value ?? nil |
|
| 380 |
+ let contentHash = HashService.typeHash( |
|
| 381 |
+ typeIdentifier: monitoredType.id, |
|
| 382 |
+ totalCount: distribution.totalCount, |
|
| 383 |
+ earliestDate: earliest, |
|
| 384 |
+ latestDate: latest |
|
| 385 |
+ ) |
|
| 307 | 386 |
|
| 308 |
- return tc |
|
| 309 |
- } catch {
|
|
| 310 |
- let isAuthDenied = (error as? HKError)?.code == .errorAuthorizationDenied |
|
| 311 |
- return TypeCount( |
|
| 387 |
+ // YearlyCount uses Calendar.current; year attribution is local-time based. |
|
| 388 |
+ let isApprox = DistributionCaptureConfiguration.bucketComponent != .day |
|
| 389 |
+ var yearMap: [Int: Int] = [:] |
|
| 390 |
+ for bin in distribution.bins {
|
|
| 391 |
+ let year = Calendar.current.component(.year, from: bin.start) |
|
| 392 |
+ yearMap[year, default: 0] += bin.count |
|
| 393 |
+ } |
|
| 394 |
+ let yearlyCounts = yearMap.map { year, yearCount in
|
|
| 395 |
+ TypeCountFetchResult.YearlyCountData( |
|
| 396 |
+ year: year, |
|
| 397 |
+ count: yearCount, |
|
| 312 | 398 |
typeIdentifier: monitoredType.id, |
| 313 |
- displayName: monitoredType.displayName, |
|
| 314 |
- count: -1, |
|
| 315 |
- quality: isAuthDenied ? SnapshotQuality.unauthorized : SnapshotQuality.failed |
|
| 399 |
+ isApproximate: isApprox |
|
| 316 | 400 |
) |
| 317 | 401 |
} |
| 318 |
- } |
|
| 319 | 402 |
|
| 320 |
- private func makeFailedTypeCount(_ monitoredType: MonitoredType) -> TypeCount {
|
|
| 321 |
- TypeCount( |
|
| 403 |
+ return TypeCountFetchResult( |
|
| 322 | 404 |
typeIdentifier: monitoredType.id, |
| 323 | 405 |
displayName: monitoredType.displayName, |
| 324 |
- count: -1, |
|
| 325 |
- quality: SnapshotQuality.failed |
|
| 406 |
+ count: distribution.totalCount, |
|
| 407 |
+ contentHash: contentHash, |
|
| 408 |
+ earliestDate: earliest, |
|
| 409 |
+ latestDate: latest, |
|
| 410 |
+ quality: SnapshotQuality.complete, |
|
| 411 |
+ diagnosticQuality: HealthKitAPICallResult.Status.complete.rawValue, |
|
| 412 |
+ isUnsupported: false, |
|
| 413 |
+ authorizationStatus: authorizationStatus(from: apiCalls), |
|
| 414 |
+ apiCalls: apiCalls, |
|
| 415 |
+ yearlyCounts: yearlyCounts |
|
| 326 | 416 |
) |
| 327 | 417 |
} |
| 328 | 418 |
|
@@ -397,6 +487,194 @@ final class HealthKitService {
|
||
| 397 | 487 |
} |
| 398 | 488 |
} |
| 399 | 489 |
|
| 490 |
+ // MARK: - Timeout profiles |
|
| 491 |
+ |
|
| 492 |
+ private func timeoutProfile(for monitoredType: MonitoredType, context: ModelContext) -> MetricTimeoutProfile {
|
|
| 493 |
+ let identifier = monitoredType.id |
|
| 494 |
+ let descriptor = FetchDescriptor<MetricTimeoutProfile>( |
|
| 495 |
+ predicate: #Predicate<MetricTimeoutProfile> { $0.metricIdentifier == identifier }
|
|
| 496 |
+ ) |
|
| 497 |
+ if let existing = try? context.fetch(descriptor).first {
|
|
| 498 |
+ existing.displayName = monitoredType.displayName |
|
| 499 |
+ return existing |
|
| 500 |
+ } |
|
| 501 |
+ |
|
| 502 |
+ let profile = MetricTimeoutProfile(metricIdentifier: monitoredType.id, displayName: monitoredType.displayName) |
|
| 503 |
+ context.insert(profile) |
|
| 504 |
+ return profile |
|
| 505 |
+ } |
|
| 506 |
+ |
|
| 507 |
+ private func timeoutForFetch( |
|
| 508 |
+ profile: MetricTimeoutProfile, |
|
| 509 |
+ adaptiveTimeoutsEnabled: Bool, |
|
| 510 |
+ timeoutMultiplier: Double |
|
| 511 |
+ ) -> TimeInterval {
|
|
| 512 |
+ let base = adaptiveTimeoutsEnabled ? profile.effectiveTimeout : Self.defaultInitialTimeoutSeconds |
|
| 513 |
+ return min(max(base * timeoutMultiplier, Self.defaultInitialTimeoutSeconds), Self.maximumTimeoutSeconds) |
|
| 514 |
+ } |
|
| 515 |
+ |
|
| 516 |
+ private func updateTimeoutProfile( |
|
| 517 |
+ _ profile: MetricTimeoutProfile, |
|
| 518 |
+ with result: TypeCountFetchResult, |
|
| 519 |
+ monitoredType: MonitoredType |
|
| 520 |
+ ) {
|
|
| 521 |
+ if result.quality == .complete {
|
|
| 522 |
+ profile.recordSuccess(elapsed: result.totalElapsedSeconds, displayName: monitoredType.displayName) |
|
| 523 |
+ } else if result.apiCalls.contains(where: { $0.status == .timeout }) {
|
|
| 524 |
+ profile.recordTimeout(elapsed: result.totalElapsedSeconds, displayName: monitoredType.displayName) |
|
| 525 |
+ } else {
|
|
| 526 |
+ profile.displayName = monitoredType.displayName |
|
| 527 |
+ } |
|
| 528 |
+ } |
|
| 529 |
+ |
|
| 530 |
+ // MARK: - Query diagnostics |
|
| 531 |
+ |
|
| 532 |
+ private struct APICallMeasurement<Value: Sendable>: Sendable {
|
|
| 533 |
+ let value: Value? |
|
| 534 |
+ let apiCall: HealthKitAPICallResult |
|
| 535 |
+ } |
|
| 536 |
+ |
|
| 537 |
+ private func measureAPICall<Value: Sendable>( |
|
| 538 |
+ queryType: String, |
|
| 539 |
+ timeoutSeconds: TimeInterval, |
|
| 540 |
+ operation: @escaping @Sendable () async throws -> Value, |
|
| 541 |
+ resultDescription: @escaping @Sendable (Value) -> String |
|
| 542 |
+ ) async -> APICallMeasurement<Value> {
|
|
| 543 |
+ let started = Date() |
|
| 544 |
+ do {
|
|
| 545 |
+ guard timeoutSeconds > 0 else { throw CancellationError() }
|
|
| 546 |
+ let value = try await withTimeout(seconds: timeoutSeconds, operation: operation) |
|
| 547 |
+ return APICallMeasurement( |
|
| 548 |
+ value: value, |
|
| 549 |
+ apiCall: HealthKitAPICallResult( |
|
| 550 |
+ queryType: queryType, |
|
| 551 |
+ status: .complete, |
|
| 552 |
+ elapsedSeconds: Date().timeIntervalSince(started), |
|
| 553 |
+ resultValue: resultDescription(value) |
|
| 554 |
+ ) |
|
| 555 |
+ ) |
|
| 556 |
+ } catch {
|
|
| 557 |
+ let failure = Self.apiFailure(for: error) |
|
| 558 |
+ let nsError = error as NSError |
|
| 559 |
+ return APICallMeasurement( |
|
| 560 |
+ value: nil, |
|
| 561 |
+ apiCall: HealthKitAPICallResult( |
|
| 562 |
+ queryType: queryType, |
|
| 563 |
+ status: failure.status, |
|
| 564 |
+ elapsedSeconds: Date().timeIntervalSince(started), |
|
| 565 |
+ resultValue: nil, |
|
| 566 |
+ errorCode: failure.errorCode ?? "\(nsError.code)", |
|
| 567 |
+ errorDomain: failure.errorDomain ?? nsError.domain, |
|
| 568 |
+ errorDescription: failure.errorDescription ?? nsError.localizedDescription, |
|
| 569 |
+ failureKind: failure.failureKind, |
|
| 570 |
+ cancellationReason: failure.cancellationReason |
|
| 571 |
+ ) |
|
| 572 |
+ ) |
|
| 573 |
+ } |
|
| 574 |
+ } |
|
| 575 |
+ |
|
| 576 |
+ private static func apiFailure(for error: Error) -> ( |
|
| 577 |
+ status: HealthKitAPICallResult.Status, |
|
| 578 |
+ failureKind: String, |
|
| 579 |
+ errorDomain: String?, |
|
| 580 |
+ errorCode: String?, |
|
| 581 |
+ errorDescription: String?, |
|
| 582 |
+ cancellationReason: String? |
|
| 583 |
+ ) {
|
|
| 584 |
+ if error is CancellationError {
|
|
| 585 |
+ return ( |
|
| 586 |
+ .timeout, |
|
| 587 |
+ "internal cancellation", |
|
| 588 |
+ "HealthProbe", |
|
| 589 |
+ "perTypeTimeout", |
|
| 590 |
+ "HealthProbe cancelled this query after the per-type timeout", |
|
| 591 |
+ "per-type timeout reached" |
|
| 592 |
+ ) |
|
| 593 |
+ } |
|
| 594 |
+ |
|
| 595 |
+ let nsError = error as NSError |
|
| 596 |
+ if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut {
|
|
| 597 |
+ return (.timeout, "timeout", nil, nil, nil, nil) |
|
| 598 |
+ } |
|
| 599 |
+ |
|
| 600 |
+ if (error as? HKError)?.code == .errorAuthorizationDenied {
|
|
| 601 |
+ return (.unauthorized, "HealthKit error", nil, nil, nil, nil) |
|
| 602 |
+ } |
|
| 603 |
+ |
|
| 604 |
+ return (.failed, "HealthKit error", nil, nil, nil, nil) |
|
| 605 |
+ } |
|
| 606 |
+ |
|
| 607 |
+ private func authorizationStatus(from apiCalls: [HealthKitAPICallResult]) -> String {
|
|
| 608 |
+ if apiCalls.contains(where: { $0.status == .complete }) {
|
|
| 609 |
+ return "granted" |
|
| 610 |
+ } |
|
| 611 |
+ if apiCalls.contains(where: { $0.status == .unauthorized }) {
|
|
| 612 |
+ return "denied" |
|
| 613 |
+ } |
|
| 614 |
+ return HealthKitAPICallResult.Status.unknown.rawValue |
|
| 615 |
+ } |
|
| 616 |
+ |
|
| 617 |
+ private func snapshotQuality(for status: HealthKitAPICallResult.Status) -> SnapshotQuality {
|
|
| 618 |
+ status == .unauthorized ? .unauthorized : .failed |
|
| 619 |
+ } |
|
| 620 |
+ |
|
| 621 |
+ private func diagnosticQuality(for status: HealthKitAPICallResult.Status) -> String {
|
|
| 622 |
+ switch status {
|
|
| 623 |
+ case .complete: |
|
| 624 |
+ return HealthKitAPICallResult.Status.complete.rawValue |
|
| 625 |
+ case .unauthorized: |
|
| 626 |
+ return HealthKitAPICallResult.Status.unauthorized.rawValue |
|
| 627 |
+ case .unsupported: |
|
| 628 |
+ return HealthKitAPICallResult.Status.unsupported.rawValue |
|
| 629 |
+ case .timeout: |
|
| 630 |
+ return SnapshotQuality.failed.rawValue |
|
| 631 |
+ case .failed: |
|
| 632 |
+ return HealthKitAPICallResult.Status.failed.rawValue |
|
| 633 |
+ case .unknown: |
|
| 634 |
+ return HealthKitAPICallResult.Status.unknown.rawValue |
|
| 635 |
+ } |
|
| 636 |
+ } |
|
| 637 |
+ |
|
| 638 |
+ private func firstImpairedStatus(in apiCalls: [HealthKitAPICallResult]) -> HealthKitAPICallResult.Status {
|
|
| 639 |
+ let statuses = apiCalls.map(\.status) |
|
| 640 |
+ if statuses.contains(.unauthorized) { return .unauthorized }
|
|
| 641 |
+ if statuses.contains(.timeout) { return .timeout }
|
|
| 642 |
+ if statuses.contains(.unsupported) { return .unsupported }
|
|
| 643 |
+ if statuses.contains(.failed) { return .failed }
|
|
| 644 |
+ return .unknown |
|
| 645 |
+ } |
|
| 646 |
+ |
|
| 647 |
+ private static func placeholderAPICalls(status: HealthKitAPICallResult.Status, message: String) -> [HealthKitAPICallResult] {
|
|
| 648 |
+ [ |
|
| 649 |
+ placeholderAPICall(queryType: "distribution", status: status, message: message), |
|
| 650 |
+ placeholderAPICall(queryType: "earliest_sample", status: status, message: message), |
|
| 651 |
+ placeholderAPICall(queryType: "latest_sample", status: status, message: message) |
|
| 652 |
+ ] |
|
| 653 |
+ } |
|
| 654 |
+ |
|
| 655 |
+ private static func placeholderAPICall( |
|
| 656 |
+ queryType: String, |
|
| 657 |
+ status: HealthKitAPICallResult.Status, |
|
| 658 |
+ message: String |
|
| 659 |
+ ) -> HealthKitAPICallResult {
|
|
| 660 |
+ HealthKitAPICallResult( |
|
| 661 |
+ queryType: queryType, |
|
| 662 |
+ status: status, |
|
| 663 |
+ elapsedSeconds: 0, |
|
| 664 |
+ resultValue: nil, |
|
| 665 |
+ errorCode: "none", |
|
| 666 |
+ errorDomain: "none", |
|
| 667 |
+ errorDescription: message, |
|
| 668 |
+ failureKind: status == .unknown ? "not run" : status.rawValue, |
|
| 669 |
+ cancellationReason: "none" |
|
| 670 |
+ ) |
|
| 671 |
+ } |
|
| 672 |
+ |
|
| 673 |
+ private static func iso8601String(for date: Date?) -> String {
|
|
| 674 |
+ guard let date else { return "none" }
|
|
| 675 |
+ return ISO8601DateFormatter().string(from: date) |
|
| 676 |
+ } |
|
| 677 |
+ |
|
| 400 | 678 |
// MARK: - Quality aggregation |
| 401 | 679 |
|
| 402 | 680 |
func deriveSnapshotQuality(from typeCounts: [TypeCount]) -> SnapshotQuality {
|
@@ -497,8 +775,8 @@ final class HealthKitService {
|
||
| 497 | 775 |
} |
| 498 | 776 |
} |
| 499 | 777 |
|
| 500 |
-private struct SampleDistribution {
|
|
| 501 |
- struct Bin {
|
|
| 778 |
+private struct SampleDistribution: Sendable {
|
|
| 779 |
+ struct Bin: Sendable {
|
|
| 502 | 780 |
let start: Date |
| 503 | 781 |
let end: Date |
| 504 | 782 |
let count: Int |
@@ -507,6 +785,113 @@ private struct SampleDistribution {
|
||
| 507 | 785 |
let bins: [Bin] |
| 508 | 786 |
} |
| 509 | 787 |
|
| 788 |
+private struct TypeCountFetchResult: Sendable {
|
|
| 789 |
+ struct YearlyCountData: Sendable {
|
|
| 790 |
+ let year: Int |
|
| 791 |
+ let count: Int |
|
| 792 |
+ let typeIdentifier: String |
|
| 793 |
+ let isApproximate: Bool |
|
| 794 |
+ } |
|
| 795 |
+ |
|
| 796 |
+ let typeIdentifier: String |
|
| 797 |
+ let displayName: String |
|
| 798 |
+ let count: Int |
|
| 799 |
+ let contentHash: String |
|
| 800 |
+ let earliestDate: Date? |
|
| 801 |
+ let latestDate: Date? |
|
| 802 |
+ let quality: SnapshotQuality |
|
| 803 |
+ let diagnosticQuality: String |
|
| 804 |
+ let isUnsupported: Bool |
|
| 805 |
+ let authorizationStatus: String |
|
| 806 |
+ let apiCalls: [HealthKitAPICallResult] |
|
| 807 |
+ let yearlyCounts: [YearlyCountData] |
|
| 808 |
+ var timeoutConfiguredSeconds: TimeInterval = 0 |
|
| 809 |
+ var totalElapsedSeconds: TimeInterval = 0 |
|
| 810 |
+ var timeoutMode: String = "default" |
|
| 811 |
+ var lastSuccessfulElapsed: TimeInterval = 0 |
|
| 812 |
+ var learnedTimeout: TimeInterval = 0 |
|
| 813 |
+ var suggestedRetryTimeout: TimeInterval = 0 |
|
| 814 |
+ var timeoutCount: Int = 0 |
|
| 815 |
+ var successCount: Int = 0 |
|
| 816 |
+ |
|
| 817 |
+ mutating func applyTimeoutProfile(_ profile: MetricTimeoutProfile) {
|
|
| 818 |
+ timeoutMode = profile.timeoutMode |
|
| 819 |
+ lastSuccessfulElapsed = profile.lastSuccessfulElapsed |
|
| 820 |
+ learnedTimeout = profile.effectiveTimeout |
|
| 821 |
+ suggestedRetryTimeout = profile.suggestedRetryTimeout |
|
| 822 |
+ timeoutCount = profile.timeoutCount |
|
| 823 |
+ successCount = profile.successCount |
|
| 824 |
+ } |
|
| 825 |
+ |
|
| 826 |
+ @MainActor |
|
| 827 |
+ func makeTypeCount() -> TypeCount {
|
|
| 828 |
+ let typeCount = TypeCount( |
|
| 829 |
+ typeIdentifier: typeIdentifier, |
|
| 830 |
+ displayName: displayName, |
|
| 831 |
+ count: count, |
|
| 832 |
+ quality: quality |
|
| 833 |
+ ) |
|
| 834 |
+ typeCount.contentHash = contentHash |
|
| 835 |
+ typeCount.earliestDate = earliestDate |
|
| 836 |
+ typeCount.latestDate = latestDate |
|
| 837 |
+ typeCount.isUnsupported = isUnsupported |
|
| 838 |
+ |
|
| 839 |
+ for yearlyCountData in yearlyCounts {
|
|
| 840 |
+ let yearlyCount = YearlyCount( |
|
| 841 |
+ year: yearlyCountData.year, |
|
| 842 |
+ count: yearlyCountData.count, |
|
| 843 |
+ typeIdentifier: yearlyCountData.typeIdentifier, |
|
| 844 |
+ isApproximate: yearlyCountData.isApproximate |
|
| 845 |
+ ) |
|
| 846 |
+ typeCount.yearlyCounts?.append(yearlyCount) |
|
| 847 |
+ } |
|
| 848 |
+ |
|
| 849 |
+ return typeCount |
|
| 850 |
+ } |
|
| 851 |
+} |
|
| 852 |
+ |
|
| 853 |
+private extension SnapshotFetchProgress {
|
|
| 854 |
+ func updateDetails(from result: TypeCountFetchResult) {
|
|
| 855 |
+ updateDetails( |
|
| 856 |
+ result.typeIdentifier, |
|
| 857 |
+ quality: result.diagnosticQuality, |
|
| 858 |
+ recordCount: result.count, |
|
| 859 |
+ isUnsupported: result.isUnsupported, |
|
| 860 |
+ authorizationStatus: result.authorizationStatus, |
|
| 861 |
+ earliestDate: result.earliestDate, |
|
| 862 |
+ latestDate: result.latestDate, |
|
| 863 |
+ yearlyCounts: result.yearlyCounts.map {
|
|
| 864 |
+ SnapshotFetchProgress.YearlyCountProgress( |
|
| 865 |
+ year: $0.year, |
|
| 866 |
+ count: $0.count, |
|
| 867 |
+ isApproximate: $0.isApproximate |
|
| 868 |
+ ) |
|
| 869 |
+ }, |
|
| 870 |
+ apiCallDetails: result.apiCalls, |
|
| 871 |
+ timeoutConfiguredSeconds: result.timeoutConfiguredSeconds, |
|
| 872 |
+ totalElapsedSeconds: result.totalElapsedSeconds, |
|
| 873 |
+ timeoutMode: result.timeoutMode, |
|
| 874 |
+ lastSuccessfulElapsed: result.lastSuccessfulElapsed, |
|
| 875 |
+ learnedTimeout: result.learnedTimeout, |
|
| 876 |
+ suggestedRetryTimeout: result.suggestedRetryTimeout, |
|
| 877 |
+ timeoutCount: result.timeoutCount, |
|
| 878 |
+ successCount: result.successCount |
|
| 879 |
+ ) |
|
| 880 |
+ } |
|
| 881 |
+ |
|
| 882 |
+ func updateTimeoutProfile(from profile: MetricTimeoutProfile, for typeID: String) {
|
|
| 883 |
+ updateTimeoutProfile( |
|
| 884 |
+ typeID, |
|
| 885 |
+ timeoutMode: profile.timeoutMode, |
|
| 886 |
+ lastSuccessfulElapsed: profile.lastSuccessfulElapsed, |
|
| 887 |
+ learnedTimeout: profile.effectiveTimeout, |
|
| 888 |
+ suggestedRetryTimeout: profile.suggestedRetryTimeout, |
|
| 889 |
+ timeoutCount: profile.timeoutCount, |
|
| 890 |
+ successCount: profile.successCount |
|
| 891 |
+ ) |
|
| 892 |
+ } |
|
| 893 |
+} |
|
| 894 |
+ |
|
| 510 | 895 |
private enum DistributionCaptureConfiguration {
|
| 511 | 896 |
static let bucketComponent: Calendar.Component = .day |
| 512 | 897 |
static let bucketStep = 1 |
@@ -102,10 +102,10 @@ final class ObserverService {
|
||
| 102 | 102 |
do {
|
| 103 | 103 |
let snapshot = try await HealthKitService.shared.createSnapshot( |
| 104 | 104 |
in: context, |
| 105 |
- selectedTypeIDs: selectedTypeIDs |
|
| 105 |
+ selectedTypeIDs: selectedTypeIDs, |
|
| 106 |
+ adaptiveTimeoutsEnabled: true, |
|
| 107 |
+ triggerReason: "observerCallback" |
|
| 106 | 108 |
) |
| 107 |
- snapshot.triggerReason = "observerCallback" |
|
| 108 |
- try context.save() |
|
| 109 | 109 |
logger.info("ObserverService: observer-triggered snapshot created \(snapshot.id)")
|
| 110 | 110 |
} catch {
|
| 111 | 111 |
logger.error("ObserverService: failed to create snapshot — \(error)")
|
@@ -33,8 +33,8 @@ final class AppSettings {
|
||
| 33 | 33 |
let ids = try? JSONDecoder().decode([String].self, from: data) {
|
| 34 | 34 |
selectedDeviceIDs = Set(ids) |
| 35 | 35 |
} else {
|
| 36 |
- let currentID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id |
|
| 37 |
- selectedDeviceIDs = [currentID] |
|
| 36 |
+ let currentID = UIDevice.current.identifierForVendor?.uuidString ?? "" |
|
| 37 |
+ selectedDeviceIDs = currentID.isEmpty ? [] : [currentID] |
|
| 38 | 38 |
} |
| 39 | 39 |
|
| 40 | 40 |
if UserDefaults.standard.object(forKey: Self.adaptiveTimeoutsEnabledKey) == nil {
|
@@ -0,0 +1,219 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+ |
|
| 3 |
+struct HealthKitAPICallResult: Sendable, Equatable {
|
|
| 4 |
+ enum Status: String, Sendable {
|
|
| 5 |
+ case complete |
|
| 6 |
+ case failed |
|
| 7 |
+ case timeout |
|
| 8 |
+ case unknown |
|
| 9 |
+ case unauthorized |
|
| 10 |
+ case unsupported |
|
| 11 |
+ } |
|
| 12 |
+ |
|
| 13 |
+ let queryType: String |
|
| 14 |
+ let status: Status |
|
| 15 |
+ let elapsedSeconds: TimeInterval |
|
| 16 |
+ let resultValue: String? |
|
| 17 |
+ let errorCode: String? |
|
| 18 |
+ let errorDomain: String? |
|
| 19 |
+ let errorDescription: String? |
|
| 20 |
+ let failureKind: String? |
|
| 21 |
+ let cancellationReason: String? |
|
| 22 |
+ |
|
| 23 |
+ init( |
|
| 24 |
+ queryType: String, |
|
| 25 |
+ status: Status, |
|
| 26 |
+ elapsedSeconds: TimeInterval, |
|
| 27 |
+ resultValue: String?, |
|
| 28 |
+ errorCode: String? = nil, |
|
| 29 |
+ errorDomain: String? = nil, |
|
| 30 |
+ errorDescription: String? = nil, |
|
| 31 |
+ failureKind: String? = nil, |
|
| 32 |
+ cancellationReason: String? = nil |
|
| 33 |
+ ) {
|
|
| 34 |
+ self.queryType = queryType |
|
| 35 |
+ self.status = status |
|
| 36 |
+ self.elapsedSeconds = elapsedSeconds |
|
| 37 |
+ self.resultValue = resultValue |
|
| 38 |
+ self.errorCode = errorCode |
|
| 39 |
+ self.errorDomain = errorDomain |
|
| 40 |
+ self.errorDescription = errorDescription |
|
| 41 |
+ self.failureKind = failureKind |
|
| 42 |
+ self.cancellationReason = cancellationReason |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ var statusDescription: String {
|
|
| 46 |
+ switch status {
|
|
| 47 |
+ case .complete: return "complete" |
|
| 48 |
+ case .failed: return "failed" |
|
| 49 |
+ case .timeout: return "timeout" |
|
| 50 |
+ case .unknown: return "unknown" |
|
| 51 |
+ case .unauthorized: return "unauthorized" |
|
| 52 |
+ case .unsupported: return "unsupported" |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+} |
|
| 56 |
+ |
|
| 57 |
+@Observable |
|
| 58 |
+final class SnapshotFetchProgress {
|
|
| 59 |
+ struct YearlyCountProgress: Identifiable, Sendable, Equatable {
|
|
| 60 |
+ var id: Int { year }
|
|
| 61 |
+ let year: Int |
|
| 62 |
+ let count: Int |
|
| 63 |
+ let isApproximate: Bool |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ struct TypeProgress: Identifiable, Sendable, Equatable {
|
|
| 67 |
+ enum FetchStatus: Sendable, Equatable {
|
|
| 68 |
+ case pending |
|
| 69 |
+ case fetching |
|
| 70 |
+ case complete |
|
| 71 |
+ case failed(String) |
|
| 72 |
+ |
|
| 73 |
+ var icon: String {
|
|
| 74 |
+ switch self {
|
|
| 75 |
+ case .pending: return "circle" |
|
| 76 |
+ case .fetching: return "arrow.triangle.2.circlepath" |
|
| 77 |
+ case .complete: return "checkmark.circle.fill" |
|
| 78 |
+ case .failed: return "xmark.circle.fill" |
|
| 79 |
+ } |
|
| 80 |
+ } |
|
| 81 |
+ } |
|
| 82 |
+ |
|
| 83 |
+ let id: String |
|
| 84 |
+ let displayName: String |
|
| 85 |
+ var status: FetchStatus = .pending |
|
| 86 |
+ var quality: String = SnapshotQuality.loading.rawValue |
|
| 87 |
+ var recordCount: Int = 0 |
|
| 88 |
+ var isUnsupported: Bool = false |
|
| 89 |
+ var authorizationStatus: String = "unknown" |
|
| 90 |
+ var earliestDate: Date? |
|
| 91 |
+ var latestDate: Date? |
|
| 92 |
+ var yearlyCounts: [YearlyCountProgress] = [] |
|
| 93 |
+ var apiCallDetails: [HealthKitAPICallResult] = [] |
|
| 94 |
+ var timeoutConfiguredSeconds: TimeInterval = 0 |
|
| 95 |
+ var totalElapsedSeconds: TimeInterval = 0 |
|
| 96 |
+ var timeoutMode: String = "default" |
|
| 97 |
+ var lastSuccessfulElapsed: TimeInterval = 0 |
|
| 98 |
+ var learnedTimeout: TimeInterval = 0 |
|
| 99 |
+ var suggestedRetryTimeout: TimeInterval = 0 |
|
| 100 |
+ var timeoutCount: Int = 0 |
|
| 101 |
+ var successCount: Int = 0 |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ var types: [TypeProgress] |
|
| 105 |
+ var perTypeTimeoutSeconds: TimeInterval = 0 |
|
| 106 |
+ var maxConcurrentTypeFetches: Int = 0 |
|
| 107 |
+ var adaptiveTimeoutsEnabled: Bool = false |
|
| 108 |
+ var previousSnapshotID: UUID? |
|
| 109 |
+ var isChainStart: Bool? |
|
| 110 |
+ var snapshotChecksum: String = "" |
|
| 111 |
+ var monitoredTypeSetHash: String = "" |
|
| 112 |
+ var monitoredRegistryVersion: Int? |
|
| 113 |
+ |
|
| 114 |
+ var visibleTypes: [TypeProgress] { types }
|
|
| 115 |
+ var completedCount: Int { types.filter { $0.status == .complete }.count }
|
|
| 116 |
+ var failedCount: Int {
|
|
| 117 |
+ types.filter {
|
|
| 118 |
+ if case .failed = $0.status { return true }
|
|
| 119 |
+ return false |
|
| 120 |
+ }.count |
|
| 121 |
+ } |
|
| 122 |
+ var totalRecords: Int {
|
|
| 123 |
+ types.reduce(0) { $0 + max($1.recordCount, 0) }
|
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ init(monitoredTypes: [(id: String, displayName: String)]) {
|
|
| 127 |
+ self.types = monitoredTypes.map {
|
|
| 128 |
+ TypeProgress(id: $0.id, displayName: $0.displayName) |
|
| 129 |
+ } |
|
| 130 |
+ } |
|
| 131 |
+ |
|
| 132 |
+ func updateConfiguration( |
|
| 133 |
+ perTypeTimeoutSeconds: TimeInterval, |
|
| 134 |
+ maxConcurrentTypeFetches: Int, |
|
| 135 |
+ adaptiveTimeoutsEnabled: Bool |
|
| 136 |
+ ) {
|
|
| 137 |
+ self.perTypeTimeoutSeconds = perTypeTimeoutSeconds |
|
| 138 |
+ self.maxConcurrentTypeFetches = maxConcurrentTypeFetches |
|
| 139 |
+ self.adaptiveTimeoutsEnabled = adaptiveTimeoutsEnabled |
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ func updateChainContext( |
|
| 143 |
+ previousSnapshotID: UUID?, |
|
| 144 |
+ isChainStart: Bool, |
|
| 145 |
+ snapshotChecksum: String, |
|
| 146 |
+ monitoredTypeSetHash: String, |
|
| 147 |
+ monitoredRegistryVersion: Int |
|
| 148 |
+ ) {
|
|
| 149 |
+ self.previousSnapshotID = previousSnapshotID |
|
| 150 |
+ self.isChainStart = isChainStart |
|
| 151 |
+ self.snapshotChecksum = snapshotChecksum |
|
| 152 |
+ self.monitoredTypeSetHash = monitoredTypeSetHash |
|
| 153 |
+ self.monitoredRegistryVersion = monitoredRegistryVersion |
|
| 154 |
+ } |
|
| 155 |
+ |
|
| 156 |
+ func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
|
|
| 157 |
+ guard let index = types.firstIndex(where: { $0.id == id }) else { return }
|
|
| 158 |
+ types[index].status = status |
|
| 159 |
+ if let recordCount {
|
|
| 160 |
+ types[index].recordCount = recordCount |
|
| 161 |
+ } |
|
| 162 |
+ } |
|
| 163 |
+ |
|
| 164 |
+ func updateDetails( |
|
| 165 |
+ _ id: String, |
|
| 166 |
+ quality: String, |
|
| 167 |
+ recordCount: Int, |
|
| 168 |
+ isUnsupported: Bool, |
|
| 169 |
+ authorizationStatus: String, |
|
| 170 |
+ earliestDate: Date?, |
|
| 171 |
+ latestDate: Date?, |
|
| 172 |
+ yearlyCounts: [YearlyCountProgress], |
|
| 173 |
+ apiCallDetails: [HealthKitAPICallResult], |
|
| 174 |
+ timeoutConfiguredSeconds: TimeInterval, |
|
| 175 |
+ totalElapsedSeconds: TimeInterval, |
|
| 176 |
+ timeoutMode: String, |
|
| 177 |
+ lastSuccessfulElapsed: TimeInterval, |
|
| 178 |
+ learnedTimeout: TimeInterval, |
|
| 179 |
+ suggestedRetryTimeout: TimeInterval, |
|
| 180 |
+ timeoutCount: Int, |
|
| 181 |
+ successCount: Int |
|
| 182 |
+ ) {
|
|
| 183 |
+ guard let index = types.firstIndex(where: { $0.id == id }) else { return }
|
|
| 184 |
+ types[index].quality = quality |
|
| 185 |
+ types[index].recordCount = recordCount |
|
| 186 |
+ types[index].isUnsupported = isUnsupported |
|
| 187 |
+ types[index].authorizationStatus = authorizationStatus |
|
| 188 |
+ types[index].earliestDate = earliestDate |
|
| 189 |
+ types[index].latestDate = latestDate |
|
| 190 |
+ types[index].yearlyCounts = yearlyCounts |
|
| 191 |
+ types[index].apiCallDetails = apiCallDetails |
|
| 192 |
+ types[index].timeoutConfiguredSeconds = timeoutConfiguredSeconds |
|
| 193 |
+ types[index].totalElapsedSeconds = totalElapsedSeconds |
|
| 194 |
+ types[index].timeoutMode = timeoutMode |
|
| 195 |
+ types[index].lastSuccessfulElapsed = lastSuccessfulElapsed |
|
| 196 |
+ types[index].learnedTimeout = learnedTimeout |
|
| 197 |
+ types[index].suggestedRetryTimeout = suggestedRetryTimeout |
|
| 198 |
+ types[index].timeoutCount = timeoutCount |
|
| 199 |
+ types[index].successCount = successCount |
|
| 200 |
+ } |
|
| 201 |
+ |
|
| 202 |
+ func updateTimeoutProfile( |
|
| 203 |
+ _ id: String, |
|
| 204 |
+ timeoutMode: String, |
|
| 205 |
+ lastSuccessfulElapsed: TimeInterval, |
|
| 206 |
+ learnedTimeout: TimeInterval, |
|
| 207 |
+ suggestedRetryTimeout: TimeInterval, |
|
| 208 |
+ timeoutCount: Int, |
|
| 209 |
+ successCount: Int |
|
| 210 |
+ ) {
|
|
| 211 |
+ guard let index = types.firstIndex(where: { $0.id == id }) else { return }
|
|
| 212 |
+ types[index].timeoutMode = timeoutMode |
|
| 213 |
+ types[index].lastSuccessfulElapsed = lastSuccessfulElapsed |
|
| 214 |
+ types[index].learnedTimeout = learnedTimeout |
|
| 215 |
+ types[index].suggestedRetryTimeout = suggestedRetryTimeout |
|
| 216 |
+ types[index].timeoutCount = timeoutCount |
|
| 217 |
+ types[index].successCount = successCount |
|
| 218 |
+ } |
|
| 219 |
+} |
|
@@ -84,7 +84,7 @@ enum SnapshotPDFExporter {
|
||
| 84 | 84 |
} |
| 85 | 85 |
|
| 86 | 86 |
/// Generates PDF using only CoreGraphics + CoreText. Safe to call off the main thread. |
| 87 |
- nonisolated static func generatePDF(from data: SnapshotReportData) -> Data {
|
|
| 87 |
+ static func generatePDF(from data: SnapshotReportData) -> Data {
|
|
| 88 | 88 |
let pageSize = CGSize(width: 595.2, height: 841.8) |
| 89 | 89 |
let pdfData = NSMutableData() |
| 90 | 90 |
guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
|
@@ -1,29 +1,27 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
import SwiftData |
| 3 | 3 |
|
| 4 |
-struct TypeProgressEntry: Identifiable {
|
|
| 5 |
- let id: String |
|
| 6 |
- let displayName: String |
|
| 7 |
- var status: Status |
|
| 8 |
- |
|
| 9 |
- enum Status {
|
|
| 10 |
- case pending |
|
| 11 |
- case complete(Int) // record count |
|
| 12 |
- case unauthorized |
|
| 13 |
- case failed(String) // error reason |
|
| 14 |
- } |
|
| 15 |
-} |
|
| 16 |
- |
|
| 17 | 4 |
@Observable |
| 18 | 5 |
final class DashboardViewModel {
|
| 19 | 6 |
var isRequestingAuth = false |
| 20 | 7 |
var isCreatingSnapshot = false |
| 21 | 8 |
var authError: String? |
| 22 | 9 |
var snapshotError: String? |
| 23 |
- var fetchProgress: [TypeProgressEntry] = [] |
|
| 10 |
+ var snapshotProgress: SnapshotProgress = .idle |
|
| 11 |
+ var snapshotProgressMessage: String = "" |
|
| 12 |
+ var snapshotProgressDetail: String = "" |
|
| 24 | 13 |
var showProgressSheet = false |
| 25 |
- var fetchStartTime: Date? |
|
| 26 |
- var lastSnapshotQuality: SnapshotQuality? = nil |
|
| 14 |
+ var canRetryWithPermissions = false |
|
| 15 |
+ var permissionsAlreadyRequested = false |
|
| 16 |
+ var fetchProgress: SnapshotFetchProgress? |
|
| 17 |
+ var fetchDurationSeconds: TimeInterval? = nil |
|
| 18 |
+ var fetchStartDate: Date? = nil |
|
| 19 |
+ var operationID: UUID? = nil |
|
| 20 |
+ var completedSnapshotID: UUID? = nil |
|
| 21 |
+ var completedSnapshotTimestamp: Date? = nil |
|
| 22 |
+ var completedSnapshotDeviceID: String? = nil |
|
| 23 |
+ var completedSnapshotTriggerReason: String? = nil |
|
| 24 |
+ var completedSnapshotRetryOfSnapshotID: UUID? = nil |
|
| 27 | 25 |
|
| 28 | 26 |
private let healthKit = HealthKitService.shared |
| 29 | 27 |
private let diffService = SnapshotDiffService.shared |
@@ -39,56 +37,246 @@ final class DashboardViewModel {
|
||
| 39 | 37 |
} |
| 40 | 38 |
} |
| 41 | 39 |
|
| 42 |
- func createSnapshot(context: ModelContext, selectedTypeIDs: Set<String>) async {
|
|
| 40 |
+ func createSnapshot( |
|
| 41 |
+ context: ModelContext, |
|
| 42 |
+ selectedTypeIDs: Set<String>, |
|
| 43 |
+ adaptiveTimeoutsEnabled: Bool, |
|
| 44 |
+ triggerReason: String = "manual", |
|
| 45 |
+ retryOfSnapshotID: UUID? = nil, |
|
| 46 |
+ timeoutMultiplier: Double = 1 |
|
| 47 |
+ ) async {
|
|
| 48 |
+ guard !selectedTypeIDs.isEmpty else {
|
|
| 49 |
+ snapshotError = "No health data types selected. Please select types to monitor in Settings." |
|
| 50 |
+ return |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 43 | 53 |
isCreatingSnapshot = true |
| 44 | 54 |
snapshotError = nil |
| 55 |
+ snapshotProgress = .fetching |
|
| 45 | 56 |
showProgressSheet = true |
| 46 |
- fetchStartTime = Date() |
|
| 57 |
+ fetchStartDate = Date() |
|
| 58 |
+ fetchDurationSeconds = nil |
|
| 59 |
+ operationID = UUID() |
|
| 60 |
+ completedSnapshotID = nil |
|
| 61 |
+ completedSnapshotTimestamp = nil |
|
| 62 |
+ completedSnapshotDeviceID = nil |
|
| 63 |
+ completedSnapshotTriggerReason = nil |
|
| 64 |
+ completedSnapshotRetryOfSnapshotID = nil |
|
| 65 |
+ snapshotProgressMessage = "" |
|
| 66 |
+ snapshotProgressDetail = "" |
|
| 67 |
+ canRetryWithPermissions = false |
|
| 68 |
+ permissionsAlreadyRequested = false |
|
| 47 | 69 |
|
| 48 | 70 |
let monitoredTypes = HealthKitService.allTypes |
| 49 | 71 |
.filter { selectedTypeIDs.contains($0.id) }
|
| 50 |
- .sorted { $0.displayName < $1.displayName }
|
|
| 51 |
- |
|
| 52 |
- fetchProgress = monitoredTypes.map { type in
|
|
| 53 |
- TypeProgressEntry(id: type.id, displayName: type.displayName, status: .pending) |
|
| 54 |
- } |
|
| 72 |
+ .map { (id: $0.id, displayName: $0.displayName) }
|
|
| 73 |
+ fetchProgress = SnapshotFetchProgress(monitoredTypes: monitoredTypes) |
|
| 74 |
+ fetchProgress?.updateConfiguration( |
|
| 75 |
+ perTypeTimeoutSeconds: HealthKitService.defaultInitialTimeoutSeconds, |
|
| 76 |
+ maxConcurrentTypeFetches: HealthKitService.maxConcurrentTypeFetches, |
|
| 77 |
+ adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled |
|
| 78 |
+ ) |
|
| 55 | 79 |
|
| 56 | 80 |
defer { isCreatingSnapshot = false }
|
| 57 | 81 |
|
| 58 | 82 |
do {
|
| 59 |
- _ = try await healthKit.createSnapshot( |
|
| 60 |
- in: context, |
|
| 61 |
- selectedTypeIDs: selectedTypeIDs, |
|
| 62 |
- onTypeCompleted: { [weak self] typeCount in
|
|
| 63 |
- Task { @MainActor in
|
|
| 64 |
- self?.updateProgress(for: typeCount) |
|
| 83 |
+ let operationTimeout = max(120, Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30) |
|
| 84 |
+ let snapshot = try await withTimeout(seconds: operationTimeout) {
|
|
| 85 |
+ try await self.healthKit.createSnapshot( |
|
| 86 |
+ in: context, |
|
| 87 |
+ selectedTypeIDs: selectedTypeIDs, |
|
| 88 |
+ adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
|
| 89 |
+ triggerReason: triggerReason, |
|
| 90 |
+ retryOfSnapshotID: retryOfSnapshotID, |
|
| 91 |
+ timeoutMultiplier: timeoutMultiplier, |
|
| 92 |
+ progress: self.fetchProgress |
|
| 93 |
+ ) |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
|
| 97 |
+ let exists = allSnapshots.contains { $0.id == snapshot.id }
|
|
| 98 |
+ |
|
| 99 |
+ if !exists {
|
|
| 100 |
+ throw SnapshotCreationError.snapshotNotSaved |
|
| 101 |
+ } |
|
| 102 |
+ |
|
| 103 |
+ fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
|
|
| 104 |
+ completedSnapshotID = snapshot.id |
|
| 105 |
+ completedSnapshotTimestamp = snapshot.timestamp |
|
| 106 |
+ completedSnapshotDeviceID = snapshot.deviceID |
|
| 107 |
+ completedSnapshotTriggerReason = snapshot.triggerReason |
|
| 108 |
+ completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID |
|
| 109 |
+ fetchProgress?.updateChainContext( |
|
| 110 |
+ previousSnapshotID: snapshot.previousSnapshotID, |
|
| 111 |
+ isChainStart: snapshot.isChainStart, |
|
| 112 |
+ snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []), |
|
| 113 |
+ monitoredTypeSetHash: snapshot.monitoredTypeSetHash, |
|
| 114 |
+ monitoredRegistryVersion: snapshot.monitoredRegistryVersion |
|
| 115 |
+ ) |
|
| 116 |
+ |
|
| 117 |
+ if snapshot.snapshotQuality != SnapshotQuality.complete {
|
|
| 118 |
+ let typeCounts = snapshot.typeCounts ?? [] |
|
| 119 |
+ let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
|
|
| 120 |
+ let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count
|
|
| 121 |
+ |
|
| 122 |
+ let failedTypes = typeCounts.filter { $0.quality == SnapshotQuality.failed }
|
|
| 123 |
+ let unauthorizedTypes = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }
|
|
| 124 |
+ |
|
| 125 |
+ snapshotProgress = .incomplete |
|
| 126 |
+ snapshotProgressMessage = "Incomplete snapshot" |
|
| 127 |
+ permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore |
|
| 128 |
+ |
|
| 129 |
+ if unauthorizedCount > 0 {
|
|
| 130 |
+ snapshotProgressMessage = "Missing permissions (\(unauthorizedCount) type\(unauthorizedCount == 1 ? "" : "s"))" |
|
| 131 |
+ let typesList = unauthorizedTypes.map { $0.displayName }.joined(separator: ", ")
|
|
| 132 |
+ |
|
| 133 |
+ if permissionsAlreadyRequested {
|
|
| 134 |
+ snapshotProgressDetail = """ |
|
| 135 |
+ Not authorized: \(typesList) |
|
| 136 |
+ |
|
| 137 |
+ You need to grant access to health data. Open Settings and enable HealthKit access for the types you want to monitor. |
|
| 138 |
+ """ |
|
| 139 |
+ canRetryWithPermissions = false |
|
| 140 |
+ } else {
|
|
| 141 |
+ snapshotProgressDetail = "Some health data types are not authorized. You can request permissions and try again." |
|
| 142 |
+ canRetryWithPermissions = true |
|
| 65 | 143 |
} |
| 144 |
+ } else if failedCount > 0 {
|
|
| 145 |
+ snapshotProgressMessage = "Data load failed (\(failedCount) type\(failedCount == 1 ? "" : "s"))" |
|
| 146 |
+ let typesList = failedTypes.map { $0.displayName }.joined(separator: ", ")
|
|
| 147 |
+ snapshotProgressDetail = """ |
|
| 148 |
+ Failed to load: \(typesList) |
|
| 149 |
+ |
|
| 150 |
+ This may indicate a temporary issue. Try again or check HealthKit availability. |
|
| 151 |
+ """ |
|
| 152 |
+ canRetryWithPermissions = false |
|
| 153 |
+ } else {
|
|
| 154 |
+ snapshotProgressDetail = "Snapshot created with quality: \(snapshot.snapshotQuality.rawValue). Some data may be incomplete." |
|
| 155 |
+ canRetryWithPermissions = false |
|
| 66 | 156 |
} |
| 67 |
- ) |
|
| 68 |
- lastSnapshotQuality = .complete |
|
| 157 |
+ |
|
| 158 |
+ showProgressSheet = true |
|
| 159 |
+ return |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ snapshotProgress = .complete |
|
| 163 |
+ } catch is CancellationError {
|
|
| 164 |
+ snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available." |
|
| 165 |
+ snapshotProgress = .idle |
|
| 166 |
+ showProgressSheet = true |
|
| 167 |
+ } catch let error as SnapshotCreationError {
|
|
| 168 |
+ snapshotError = error.message |
|
| 169 |
+ snapshotProgress = .idle |
|
| 170 |
+ showProgressSheet = true |
|
| 69 | 171 |
} catch {
|
| 70 |
- snapshotError = error.localizedDescription |
|
| 71 |
- lastSnapshotQuality = .failed |
|
| 172 |
+ snapshotError = "Failed to create snapshot: \(error.localizedDescription)" |
|
| 173 |
+ snapshotProgress = .idle |
|
| 174 |
+ showProgressSheet = true |
|
| 72 | 175 |
} |
| 73 | 176 |
} |
| 74 | 177 |
|
| 75 |
- @MainActor |
|
| 76 |
- private func updateProgress(for typeCount: TypeCount) {
|
|
| 77 |
- if let index = fetchProgress.firstIndex(where: { $0.id == typeCount.typeIdentifier }) {
|
|
| 78 |
- let status: TypeProgressEntry.Status |
|
| 79 |
- switch typeCount.quality {
|
|
| 80 |
- case .complete: |
|
| 81 |
- status = .complete(typeCount.count) |
|
| 82 |
- case .unauthorized: |
|
| 83 |
- status = .unauthorized |
|
| 84 |
- case .failed, .partial, .loading: |
|
| 85 |
- status = .failed("Failed")
|
|
| 86 |
- } |
|
| 87 |
- fetchProgress[index].status = status |
|
| 178 |
+ func retryWithPermissions(context: ModelContext, selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
|
|
| 179 |
+ isRequestingAuth = true |
|
| 180 |
+ defer { isRequestingAuth = false }
|
|
| 181 |
+ do {
|
|
| 182 |
+ try await healthKit.requestAuthorization() |
|
| 183 |
+ await createSnapshot(context: context, selectedTypeIDs: selectedTypeIDs, adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled) |
|
| 184 |
+ } catch {
|
|
| 185 |
+ snapshotError = "Failed to request permissions: \(error.localizedDescription)" |
|
| 186 |
+ snapshotProgress = .idle |
|
| 187 |
+ showProgressSheet = false |
|
| 188 |
+ } |
|
| 189 |
+ } |
|
| 190 |
+ |
|
| 191 |
+ func retryFailedMetricsWithExtendedTimeout(context: ModelContext) async {
|
|
| 192 |
+ guard let progress = fetchProgress else { return }
|
|
| 193 |
+ let retryTypeIDs = Set(progress.types.compactMap { type -> String? in
|
|
| 194 |
+ guard case .failed(let reason) = type.status else { return nil }
|
|
| 195 |
+ return reason == "Timeout" ? type.id : nil |
|
| 196 |
+ }) |
|
| 197 |
+ guard !retryTypeIDs.isEmpty else { return }
|
|
| 198 |
+ |
|
| 199 |
+ await createSnapshot( |
|
| 200 |
+ context: context, |
|
| 201 |
+ selectedTypeIDs: retryTypeIDs, |
|
| 202 |
+ adaptiveTimeoutsEnabled: true, |
|
| 203 |
+ triggerReason: "retryFailedMetrics", |
|
| 204 |
+ retryOfSnapshotID: completedSnapshotID, |
|
| 205 |
+ timeoutMultiplier: 2 |
|
| 206 |
+ ) |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ func acceptPartialSnapshot() {
|
|
| 210 |
+ fetchProgress = nil |
|
| 211 |
+ showProgressSheet = false |
|
| 212 |
+ snapshotProgress = .idle |
|
| 213 |
+ } |
|
| 214 |
+ |
|
| 215 |
+ func discardSnapshot(context: ModelContext) async {
|
|
| 216 |
+ if let snapshotID = completedSnapshotID {
|
|
| 217 |
+ do {
|
|
| 218 |
+ let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
|
| 219 |
+ if let snapshot = allSnapshots.first(where: { $0.id == snapshotID }) {
|
|
| 220 |
+ context.delete(snapshot) |
|
| 221 |
+ } |
|
| 222 |
+ } catch { }
|
|
| 88 | 223 |
} |
| 224 |
+ completedSnapshotID = nil |
|
| 225 |
+ fetchProgress = nil |
|
| 226 |
+ showProgressSheet = false |
|
| 227 |
+ snapshotProgress = .idle |
|
| 89 | 228 |
} |
| 90 | 229 |
|
| 91 | 230 |
func totalChanges(latest: HealthSnapshot, previous: HealthSnapshot) -> Int {
|
| 92 | 231 |
diffService.totalAbsoluteChange(current: latest, baseline: previous) |
| 93 | 232 |
} |
| 233 |
+ |
|
| 234 |
+ private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
|
|
| 235 |
+ try await withThrowingTaskGroup(of: T.self) { group in
|
|
| 236 |
+ group.addTask { try await operation() }
|
|
| 237 |
+ group.addTask {
|
|
| 238 |
+ try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) |
|
| 239 |
+ throw CancellationError() |
|
| 240 |
+ } |
|
| 241 |
+ let result = try await group.next()! |
|
| 242 |
+ group.cancelAll() |
|
| 243 |
+ return result |
|
| 244 |
+ } |
|
| 245 |
+ } |
|
| 246 |
+} |
|
| 247 |
+ |
|
| 248 |
+enum SnapshotProgress {
|
|
| 249 |
+ case idle |
|
| 250 |
+ case fetching |
|
| 251 |
+ case processing |
|
| 252 |
+ case complete |
|
| 253 |
+ case incomplete |
|
| 254 |
+ |
|
| 255 |
+ var message: String {
|
|
| 256 |
+ switch self {
|
|
| 257 |
+ case .idle: return "" |
|
| 258 |
+ case .fetching: return "Fetching health data..." |
|
| 259 |
+ case .processing: return "Processing snapshot..." |
|
| 260 |
+ case .complete: return "Snapshot created successfully!" |
|
| 261 |
+ case .incomplete: return "Snapshot created with issues" |
|
| 262 |
+ } |
|
| 263 |
+ } |
|
| 264 |
+ |
|
| 265 |
+ var isError: Bool {
|
|
| 266 |
+ switch self {
|
|
| 267 |
+ case .incomplete: return true |
|
| 268 |
+ default: return false |
|
| 269 |
+ } |
|
| 270 |
+ } |
|
| 271 |
+} |
|
| 272 |
+ |
|
| 273 |
+enum SnapshotCreationError: Error {
|
|
| 274 |
+ case snapshotNotSaved |
|
| 275 |
+ |
|
| 276 |
+ var message: String {
|
|
| 277 |
+ switch self {
|
|
| 278 |
+ case .snapshotNotSaved: |
|
| 279 |
+ return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section." |
|
| 280 |
+ } |
|
| 281 |
+ } |
|
| 94 | 282 |
} |
@@ -8,11 +8,14 @@ struct DashboardView: View {
|
||
| 8 | 8 |
@Environment(AppSettings.self) private var appSettings |
| 9 | 9 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var snapshots: [HealthSnapshot] |
| 10 | 10 |
@State private var viewModel = DashboardViewModel() |
| 11 |
+ @State private var didAutoRequestPermissions = false |
|
| 12 |
+ @State private var snapshotSheetTab: SnapshotSheetTab = .progress |
|
| 13 |
+ @State private var expandedIssueIDs: Set<String> = [] |
|
| 11 | 14 |
|
| 12 | 15 |
init() {
|
| 13 |
- let id = UIDevice.current.identifierForVendor?.uuidString ?? "" |
|
| 16 |
+ let deviceID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id |
|
| 14 | 17 |
_snapshots = Query( |
| 15 |
- filter: #Predicate<HealthSnapshot> { $0.deviceID == id },
|
|
| 18 |
+ filter: #Predicate<HealthSnapshot> { $0.deviceID == deviceID },
|
|
| 16 | 19 |
sort: \HealthSnapshot.timestamp, |
| 17 | 20 |
order: .reverse |
| 18 | 21 |
) |
@@ -38,10 +41,856 @@ struct DashboardView: View {
|
||
| 38 | 41 |
.navigationTitle("HealthProbe")
|
| 39 | 42 |
} |
| 40 | 43 |
.sheet(isPresented: $viewModel.showProgressSheet) {
|
| 41 |
- SnapshotProgressSheet(viewModel: viewModel) |
|
| 44 |
+ progressSheet |
|
| 45 |
+ } |
|
| 46 |
+ .task {
|
|
| 47 |
+ if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
|
|
| 48 |
+ didAutoRequestPermissions = true |
|
| 49 |
+ await viewModel.requestAuthorization() |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ // MARK: - Helpers |
|
| 55 |
+ |
|
| 56 |
+ // MARK: - Report helpers |
|
| 57 |
+ |
|
| 58 |
+ private func failedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
|
|
| 59 |
+ progress.visibleTypes.filter {
|
|
| 60 |
+ if case .failed = $0.status { return true }
|
|
| 61 |
+ return false |
|
| 62 |
+ } |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ private func timedOutMetricNames(_ progress: SnapshotFetchProgress?) -> [String] {
|
|
| 66 |
+ progress?.types.compactMap { type in
|
|
| 67 |
+ if case .failed(let r) = type.status, r == "Timeout" { return type.displayName }
|
|
| 68 |
+ return nil |
|
| 69 |
+ } ?? [] |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ private func hasTimedOutMetrics(_ progress: SnapshotFetchProgress?) -> Bool {
|
|
| 73 |
+ progress?.types.contains {
|
|
| 74 |
+ if case .failed(let reason) = $0.status { return reason == "Timeout" }
|
|
| 75 |
+ return false |
|
| 76 |
+ } ?? false |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ private func hasAuthorizationFailures(_ progress: SnapshotFetchProgress?) -> Bool {
|
|
| 80 |
+ progress?.types.contains {
|
|
| 81 |
+ if case .failed(let reason) = $0.status { return reason == "Not authorized" }
|
|
| 82 |
+ return false |
|
| 83 |
+ } ?? false |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ private func degradedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
|
|
| 87 |
+ progress.types.filter { type in
|
|
| 88 |
+ let hasAllExpectedCalls = Set(type.apiCallDetails.map(\.queryType)) == Set(["distribution", "earliest_sample", "latest_sample"]) |
|
| 89 |
+ let allCallsComplete = type.apiCallDetails.allSatisfy { $0.status == .complete }
|
|
| 90 |
+ return type.quality != "complete" || !hasAllExpectedCalls || !allCallsComplete |
|
| 91 |
+ } |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ private func formatDuration(_ seconds: TimeInterval) -> String {
|
|
| 95 |
+ if seconds < 60 { return String(format: "%.1fs", seconds) }
|
|
| 96 |
+ return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s" |
|
| 97 |
+ } |
|
| 98 |
+ |
|
| 99 |
+ private func qualityLabel(progress: SnapshotFetchProgress) -> String {
|
|
| 100 |
+ switch viewModel.snapshotProgress {
|
|
| 101 |
+ case .complete: return "Complete" |
|
| 102 |
+ case .incomplete: |
|
| 103 |
+ let hasUnauthorized = failedTypesList(in: progress).contains {
|
|
| 104 |
+ if case .failed(let r) = $0.status { return r == "Not authorized" }
|
|
| 105 |
+ return false |
|
| 106 |
+ } |
|
| 107 |
+ return hasUnauthorized ? "Partial (unauthorized)" : "Partial" |
|
| 108 |
+ default: return "—" |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ private func qualityColor(progress: SnapshotFetchProgress) -> Color {
|
|
| 113 |
+ switch viewModel.snapshotProgress {
|
|
| 114 |
+ case .complete: return .healthyGreen |
|
| 115 |
+ case .incomplete: return .warningAmber |
|
| 116 |
+ default: return .neutralGray |
|
| 117 |
+ } |
|
| 118 |
+ } |
|
| 119 |
+ |
|
| 120 |
+ private func failureReason(_ type: SnapshotFetchProgress.TypeProgress) -> String {
|
|
| 121 |
+ if case .failed(let r) = type.status { return r.isEmpty ? "Failed" : r }
|
|
| 122 |
+ return "Unknown" |
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ private func failureImpact(_ reason: String) -> String {
|
|
| 126 |
+ switch reason {
|
|
| 127 |
+ case "Not authorized": return "Excluded from checksum and anomaly detection" |
|
| 128 |
+ case "Timeout": return "Data unavailable — type skipped this run" |
|
| 129 |
+ case "Unsupported": return "Not supported on this device or OS version" |
|
| 130 |
+ default: return "Data unavailable" |
|
| 131 |
+ } |
|
| 132 |
+ } |
|
| 133 |
+ |
|
| 134 |
+private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
|
| 135 |
+ let failed = failedTypesList(in: progress) |
|
| 136 |
+ var items: [String] = [] |
|
| 137 |
+ let timedOut = failed.filter {
|
|
| 138 |
+ if case .failed(let r) = $0.status { return r == "Timeout" }
|
|
| 139 |
+ return false |
|
| 140 |
+ } |
|
| 141 |
+ if !timedOut.isEmpty {
|
|
| 142 |
+ items.append("Timeout reflects HealthProbe's configured limit, not missing Health data or permission.")
|
|
| 143 |
+ } |
|
| 144 |
+ if viewModel.snapshotProgress == .incomplete {
|
|
| 145 |
+ items.append("Partial snapshots are excluded from anomaly detection. A complete snapshot is needed to resume integrity checks.")
|
|
| 146 |
+ } |
|
| 147 |
+ return items |
|
| 148 |
+ } |
|
| 149 |
+ |
|
| 150 |
+ private func iso8601String(_ date: Date?) -> String {
|
|
| 151 |
+ guard let date else { return "none" }
|
|
| 152 |
+ return ISO8601DateFormatter().string(from: date) |
|
| 153 |
+ } |
|
| 154 |
+ |
|
| 155 |
+ private enum DiagnosticReportMode {
|
|
| 156 |
+ case compact |
|
| 157 |
+ case full |
|
| 158 |
+ } |
|
| 159 |
+ |
|
| 160 |
+ private func operationResultValue() -> String {
|
|
| 161 |
+ switch viewModel.snapshotProgress {
|
|
| 162 |
+ case .complete: |
|
| 163 |
+ return "complete_success" |
|
| 164 |
+ case .incomplete: |
|
| 165 |
+ return "partial_success" |
|
| 166 |
+ case .idle, .fetching, .processing: |
|
| 167 |
+ return "failed" |
|
| 168 |
+ } |
|
| 169 |
+ } |
|
| 170 |
+ |
|
| 171 |
+ private func normalizedAuthorization(for type: SnapshotFetchProgress.TypeProgress) -> (status: String, source: String) {
|
|
| 172 |
+ let hasSuccessfulQuery = type.apiCallDetails.contains { $0.status == .complete }
|
|
| 173 |
+ let normalized = type.authorizationStatus.lowercased() |
|
| 174 |
+ |
|
| 175 |
+ if hasSuccessfulQuery {
|
|
| 176 |
+ return ("granted", "inferredFromSuccessfulQuery")
|
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 179 |
+ if normalized == "denied" {
|
|
| 180 |
+ return ("denied", "inferredFromUnauthorizedQuery")
|
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ if normalized == "notdetermined" {
|
|
| 184 |
+ return ("notDetermined", "reported")
|
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ if normalized == "granted" {
|
|
| 188 |
+ return ("granted", "reported")
|
|
| 189 |
+ } |
|
| 190 |
+ |
|
| 191 |
+ // When real HK authorization status is unavailable, report unknown+unavailable. |
|
| 192 |
+ return ("unknown", "unavailable")
|
|
| 193 |
+ } |
|
| 194 |
+ |
|
| 195 |
+ private func metricDateString(_ date: Date?, queryType: String, calls: [HealthKitAPICallResult]) -> String {
|
|
| 196 |
+ if let date { return iso8601String(date) }
|
|
| 197 |
+ guard let call = calls.first(where: { $0.queryType == queryType }) else { return "unknown" }
|
|
| 198 |
+ return call.status == .complete ? "none" : "unknown" |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ private func yearlyCountsString(_ counts: [SnapshotFetchProgress.YearlyCountProgress]) -> String {
|
|
| 202 |
+ guard !counts.isEmpty else { return "none" }
|
|
| 203 |
+ return counts |
|
| 204 |
+ .sorted { $0.year < $1.year }
|
|
| 205 |
+ .map { "\($0.year)=\($0.count)\($0.isApproximate ? " approximate" : "")" }
|
|
| 206 |
+ .joined(separator: ", ") |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ private func callResultLabel(_ call: HealthKitAPICallResult) -> String {
|
|
| 210 |
+ if let result = call.resultValue, !result.isEmpty {
|
|
| 211 |
+ return result |
|
| 212 |
+ } |
|
| 213 |
+ return call.status == .complete ? "none" : "unknown" |
|
| 214 |
+ } |
|
| 215 |
+ |
|
| 216 |
+ private func shouldPrintCallErrorFields(_ call: HealthKitAPICallResult) -> Bool {
|
|
| 217 |
+ switch call.status {
|
|
| 218 |
+ case .failed, .timeout, .unknown, .unauthorized: |
|
| 219 |
+ return true |
|
| 220 |
+ case .complete, .unsupported: |
|
| 221 |
+ return false |
|
| 42 | 222 |
} |
| 43 | 223 |
} |
| 44 | 224 |
|
| 225 |
+ private func buildDiagnosticText(_ progress: SnapshotFetchProgress, mode: DiagnosticReportMode = .full) -> String {
|
|
| 226 |
+ let snapshotID = viewModel.completedSnapshotID.map { $0.uuidString } ?? "unknown"
|
|
| 227 |
+ let deviceID = viewModel.completedSnapshotDeviceID ?? "unknown" |
|
| 228 |
+ let timestamp = viewModel.completedSnapshotTimestamp ?? Date() |
|
| 229 |
+ let operationID = viewModel.operationID?.uuidString ?? "unknown" |
|
| 230 |
+ let reportGeneratedAt = iso8601String(Date()) |
|
| 231 |
+ let trigger = viewModel.completedSnapshotTriggerReason ?? "manual" |
|
| 232 |
+ let quality: String |
|
| 233 |
+ switch viewModel.snapshotProgress {
|
|
| 234 |
+ case .complete: quality = "complete" |
|
| 235 |
+ case .incomplete: quality = "partial" |
|
| 236 |
+ default: quality = "unknown" |
|
| 237 |
+ } |
|
| 238 |
+ let duration = viewModel.fetchDurationSeconds.map { formatDuration($0) } ?? "unknown"
|
|
| 239 |
+ let fmt = DateFormatter() |
|
| 240 |
+ fmt.dateFormat = "yyyy-MM-dd HH:mm:ss" |
|
| 241 |
+ fmt.locale = Locale(identifier: "en_US_POSIX") |
|
| 242 |
+ let tsStr = fmt.string(from: timestamp) |
|
| 243 |
+ let degraded = degradedTypesList(in: progress) |
|
| 244 |
+ let isPartialSnapshot = viewModel.snapshotProgress == .incomplete |
|
| 245 |
+ let failedLines: String |
|
| 246 |
+ if degraded.isEmpty {
|
|
| 247 |
+ failedLines = "FAILED/DEGRADED METRICS: none" |
|
| 248 |
+ } else {
|
|
| 249 |
+ failedLines = (["FAILED/DEGRADED METRICS"] + degraded |
|
| 250 |
+ .map { " \($0.displayName): \($0.quality)" })
|
|
| 251 |
+ .joined(separator: "\n") |
|
| 252 |
+ } |
|
| 253 |
+ |
|
| 254 |
+ var lines: [String] = [] |
|
| 255 |
+ lines.append("BEGIN HEALTHPROBE REPORT")
|
|
| 256 |
+ lines.append("")
|
|
| 257 |
+ lines.append("OPERATION METADATA")
|
|
| 258 |
+ lines.append("operationType: \(trigger == "retryFailedMetrics" ? "retryFailedMetrics" : "createSnapshot")")
|
|
| 259 |
+ lines.append("operationID: \(operationID)")
|
|
| 260 |
+ lines.append("operationResult: \(operationResultValue())")
|
|
| 261 |
+ lines.append("primaryObjectType: snapshot")
|
|
| 262 |
+ lines.append("primaryObjectID: \(snapshotID)")
|
|
| 263 |
+ lines.append("reportSchemaVersion: 1")
|
|
| 264 |
+ lines.append("reportGeneratedAt: \(reportGeneratedAt)")
|
|
| 265 |
+ lines.append("")
|
|
| 266 |
+ lines.append("OPERATION SUMMARY")
|
|
| 267 |
+ lines.append("Snapshot: \(snapshotID)")
|
|
| 268 |
+ lines.append("Device: \(deviceID)")
|
|
| 269 |
+ lines.append("Timestamp: \(tsStr)")
|
|
| 270 |
+ lines.append("Quality: \(quality)")
|
|
| 271 |
+ lines.append("Duration: \(duration)")
|
|
| 272 |
+ lines.append("Trigger: \(trigger)")
|
|
| 273 |
+ if let retryOfSnapshotID = viewModel.completedSnapshotRetryOfSnapshotID {
|
|
| 274 |
+ lines.append("Retry of: \(retryOfSnapshotID.uuidString)")
|
|
| 275 |
+ } |
|
| 276 |
+ lines.append("")
|
|
| 277 |
+ lines.append("INTERPRETATION_HINTS")
|
|
| 278 |
+ lines.append("Partial snapshot: \(isPartialSnapshot ? "true" : "false")")
|
|
| 279 |
+ lines.append("Failed metrics are excluded from checksum/anomaly detection")
|
|
| 280 |
+ lines.append("Do not infer deletion from partial snapshots")
|
|
| 281 |
+ lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
|
|
| 282 |
+ lines.append("")
|
|
| 283 |
+ lines.append("CONFIGURATION")
|
|
| 284 |
+ lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
|
|
| 285 |
+ lines.append("defaultInitialTimeout: \(formatDuration(MetricTimeoutProfile.defaultInitialTimeout))")
|
|
| 286 |
+ lines.append("maximumTimeout: \(formatDuration(MetricTimeoutProfile.maximumTimeout))")
|
|
| 287 |
+ lines.append("maxConcurrentTypeFetches: \(progress.maxConcurrentTypeFetches)")
|
|
| 288 |
+ lines.append("")
|
|
| 289 |
+ lines.append("CHAIN/INTEGRITY CONTEXT")
|
|
| 290 |
+ lines.append("previousSnapshotID: \(progress.previousSnapshotID.map { $0.uuidString } ?? "none")")
|
|
| 291 |
+ lines.append("isChainStart: \(progress.isChainStart.map { $0 ? "true" : "false" } ?? "unknown")")
|
|
| 292 |
+ lines.append("snapshotChecksum: \(progress.snapshotChecksum)")
|
|
| 293 |
+ lines.append("monitoredTypeSetHash: \(progress.monitoredTypeSetHash)")
|
|
| 294 |
+ lines.append("monitoredRegistryVersion: \(progress.monitoredRegistryVersion.map(String.init) ?? "unknown")")
|
|
| 295 |
+ lines.append("")
|
|
| 296 |
+ lines.append("STATISTICS")
|
|
| 297 |
+ lines.append("Records: \(progress.totalRecords)")
|
|
| 298 |
+ lines.append("Types: \(progress.types.count) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
|
|
| 299 |
+ lines.append("")
|
|
| 300 |
+ lines.append(failedLines) |
|
| 301 |
+ |
|
| 302 |
+ lines.append("")
|
|
| 303 |
+ lines.append("HEALTHKIT API RESULTS")
|
|
| 304 |
+ let orderedTypes = progress.types.sorted(by: { $0.displayName < $1.displayName })
|
|
| 305 |
+ let degradedIDs = Set(degraded.map(\.id)) |
|
| 306 |
+ let reportTypes: [SnapshotFetchProgress.TypeProgress] |
|
| 307 |
+ switch mode {
|
|
| 308 |
+ case .compact: |
|
| 309 |
+ reportTypes = orderedTypes.filter { degradedIDs.contains($0.id) }
|
|
| 310 |
+ case .full: |
|
| 311 |
+ reportTypes = orderedTypes |
|
| 312 |
+ } |
|
| 313 |
+ |
|
| 314 |
+ for type in reportTypes {
|
|
| 315 |
+ lines.append("")
|
|
| 316 |
+ lines.append("\(type.displayName):")
|
|
| 317 |
+ lines.append(" identifier: \(type.id)")
|
|
| 318 |
+ lines.append(" quality: \(type.quality)")
|
|
| 319 |
+ lines.append(" count: \(type.recordCount)")
|
|
| 320 |
+ lines.append(" timeoutMode: \(type.timeoutMode)")
|
|
| 321 |
+ lines.append(" timeoutConfigured: \(formatDuration(type.timeoutConfiguredSeconds))")
|
|
| 322 |
+ lines.append(" lastSuccessfulElapsed: \(type.lastSuccessfulElapsed > 0 ? formatDuration(type.lastSuccessfulElapsed) : "none")")
|
|
| 323 |
+ lines.append(" learnedTimeout: \(type.learnedTimeout > 0 ? formatDuration(type.learnedTimeout) : "none")")
|
|
| 324 |
+ lines.append(" suggestedRetryTimeout: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : "none")")
|
|
| 325 |
+ lines.append(" timeoutCount: \(type.timeoutCount)")
|
|
| 326 |
+ lines.append(" successCount: \(type.successCount)")
|
|
| 327 |
+ lines.append(" totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
|
|
| 328 |
+ if mode == .full || type.isUnsupported {
|
|
| 329 |
+ lines.append(" unsupported: \(type.isUnsupported ? "true" : "false")")
|
|
| 330 |
+ } |
|
| 331 |
+ let auth = normalizedAuthorization(for: type) |
|
| 332 |
+ lines.append(" authorizationStatus: \(auth.status)")
|
|
| 333 |
+ lines.append(" authorizationStatusSource: \(auth.source)")
|
|
| 334 |
+ lines.append(" earliestDate: \(metricDateString(type.earliestDate, queryType: "earliest_sample", calls: type.apiCallDetails))")
|
|
| 335 |
+ lines.append(" latestDate: \(metricDateString(type.latestDate, queryType: "latest_sample", calls: type.apiCallDetails))")
|
|
| 336 |
+ lines.append(" yearlyCounts: \(yearlyCountsString(type.yearlyCounts))")
|
|
| 337 |
+ if type.recordCount == 0 {
|
|
| 338 |
+ let zeroReason = type.quality == "complete" ? "complete query with zero records" : "\(type.authorizationStatus) authorization/query state" |
|
| 339 |
+ lines.append(" zeroCountContext: \(zeroReason)")
|
|
| 340 |
+ } |
|
| 341 |
+ if type.apiCallDetails.contains(where: { $0.status == .timeout }) {
|
|
| 342 |
+ lines.append(" interpretation: timeout policy failure, not HealthKit denial")
|
|
| 343 |
+ lines.append(" action: Retry with extended timeout")
|
|
| 344 |
+ } |
|
| 345 |
+ |
|
| 346 |
+ lines.append(" apiCalls:")
|
|
| 347 |
+ for queryType in ["distribution", "earliest_sample", "latest_sample"] {
|
|
| 348 |
+ if let call = type.apiCallDetails.first(where: { $0.queryType == queryType }) {
|
|
| 349 |
+ lines.append(" \(queryType):")
|
|
| 350 |
+ lines.append(" status: \(call.statusDescription)")
|
|
| 351 |
+ lines.append(" elapsed: \(String(format: "%.2f", call.elapsedSeconds))s")
|
|
| 352 |
+ if mode == .full || call.status != .complete {
|
|
| 353 |
+ lines.append(" result: \(callResultLabel(call))")
|
|
| 354 |
+ } |
|
| 355 |
+ if shouldPrintCallErrorFields(call) {
|
|
| 356 |
+ if let failureKind = call.failureKind, !failureKind.isEmpty, failureKind != "none" {
|
|
| 357 |
+ lines.append(" failureKind: \(failureKind)")
|
|
| 358 |
+ } |
|
| 359 |
+ if let cancellationReason = call.cancellationReason, !cancellationReason.isEmpty, cancellationReason != "none" {
|
|
| 360 |
+ lines.append(" cancellationReason: \(cancellationReason)")
|
|
| 361 |
+ } |
|
| 362 |
+ if let errorDomain = call.errorDomain, !errorDomain.isEmpty, errorDomain != "none" {
|
|
| 363 |
+ lines.append(" errorDomain: \(errorDomain)")
|
|
| 364 |
+ } |
|
| 365 |
+ if let errorCode = call.errorCode, !errorCode.isEmpty, errorCode != "none" {
|
|
| 366 |
+ lines.append(" errorCode: \(errorCode)")
|
|
| 367 |
+ } |
|
| 368 |
+ if let errorMessage = call.errorDescription, !errorMessage.isEmpty, errorMessage != "none" {
|
|
| 369 |
+ lines.append(" errorMessage: \(errorMessage)")
|
|
| 370 |
+ } |
|
| 371 |
+ } |
|
| 372 |
+ } else {
|
|
| 373 |
+ lines.append(" \(queryType):")
|
|
| 374 |
+ lines.append(" status: unknown")
|
|
| 375 |
+ lines.append(" elapsed: 0.00s")
|
|
| 376 |
+ if mode == .full {
|
|
| 377 |
+ lines.append(" result: unknown")
|
|
| 378 |
+ lines.append(" failureKind: not_run")
|
|
| 379 |
+ } |
|
| 380 |
+ } |
|
| 381 |
+ } |
|
| 382 |
+ } |
|
| 383 |
+ |
|
| 384 |
+ lines.append("")
|
|
| 385 |
+ lines.append("DEVICE/APP CONTEXT")
|
|
| 386 |
+ lines.append("OS: \(UIDevice.current.systemVersion)")
|
|
| 387 |
+ lines.append("App Version: \(Bundle.main.appVersion)")
|
|
| 388 |
+ lines.append("")
|
|
| 389 |
+ lines.append("END HEALTHPROBE REPORT")
|
|
| 390 |
+ |
|
| 391 |
+ return lines.joined(separator: "\n") |
|
| 392 |
+ } |
|
| 393 |
+ |
|
| 394 |
+ private func plainTextStatus(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> String {
|
|
| 395 |
+ switch status {
|
|
| 396 |
+ case .pending: return "Pending" |
|
| 397 |
+ case .fetching: return "Fetching" |
|
| 398 |
+ case .complete: return "Complete" |
|
| 399 |
+ case .failed(let message): return message.isEmpty ? "Failed" : "Failed (\(message))" |
|
| 400 |
+ } |
|
| 401 |
+ } |
|
| 402 |
+ |
|
| 403 |
+ private func fetchProgressSummary(_ progress: SnapshotFetchProgress) -> String {
|
|
| 404 |
+ if progress.failedCount > 0 {
|
|
| 405 |
+ return "\(progress.completedCount)/\(progress.types.count) fetched - \(progress.failedCount) failed" |
|
| 406 |
+ } |
|
| 407 |
+ return "\(progress.completedCount)/\(progress.types.count) fetched" |
|
| 408 |
+ } |
|
| 409 |
+ |
|
| 410 |
+ private func colorForStatus(_ status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
|
|
| 411 |
+ switch status {
|
|
| 412 |
+ case .pending: return .gray |
|
| 413 |
+ case .fetching: return .blue |
|
| 414 |
+ case .complete: return .healthyGreen |
|
| 415 |
+ case .failed: return .criticalRed |
|
| 416 |
+ } |
|
| 417 |
+ } |
|
| 418 |
+ |
|
| 419 |
+ private func rowBackground(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
|
|
| 420 |
+ switch status {
|
|
| 421 |
+ case .fetching: |
|
| 422 |
+ return .blue.opacity(0.1) |
|
| 423 |
+ default: |
|
| 424 |
+ return Color(.systemGray6) |
|
| 425 |
+ } |
|
| 426 |
+ } |
|
| 427 |
+ |
|
| 428 |
+ private func rowBorder(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
|
|
| 429 |
+ switch status {
|
|
| 430 |
+ case .fetching: |
|
| 431 |
+ return .blue.opacity(0.3) |
|
| 432 |
+ default: |
|
| 433 |
+ return .clear |
|
| 434 |
+ } |
|
| 435 |
+ } |
|
| 436 |
+ |
|
| 437 |
+ private var shouldShowFetchProgress: Bool {
|
|
| 438 |
+ switch viewModel.snapshotProgress {
|
|
| 439 |
+ case .fetching, .complete, .incomplete: |
|
| 440 |
+ return viewModel.fetchProgress != nil |
|
| 441 |
+ case .idle, .processing: |
|
| 442 |
+ return false |
|
| 443 |
+ } |
|
| 444 |
+ } |
|
| 445 |
+ |
|
| 446 |
+ private var shouldShowFetchReport: Bool {
|
|
| 447 |
+ switch viewModel.snapshotProgress {
|
|
| 448 |
+ case .complete, .incomplete: |
|
| 449 |
+ return true |
|
| 450 |
+ case .idle, .fetching, .processing: |
|
| 451 |
+ return false |
|
| 452 |
+ } |
|
| 453 |
+ } |
|
| 454 |
+ |
|
| 455 |
+ private func fetchReportView(_ progress: SnapshotFetchProgress) -> some View {
|
|
| 456 |
+ let isIncomplete = viewModel.snapshotProgress == .incomplete |
|
| 457 |
+ let failed = isIncomplete ? failedTypesList(in: progress) : [] |
|
| 458 |
+ let noteItems = isIncomplete ? remediationNoteItems(progress: progress) : [] |
|
| 459 |
+ return VStack(alignment: .leading, spacing: 20) {
|
|
| 460 |
+ reportSummarySection(progress) |
|
| 461 |
+ if !failed.isEmpty { reportIssuesSection(failed) }
|
|
| 462 |
+ if !noteItems.isEmpty { reportRemediationSection(noteItems, title: "NOTES") }
|
|
| 463 |
+ reportDiagnosticBlock(progress) |
|
| 464 |
+ if isIncomplete, hasTimedOutMetrics(progress) { reportDecisionOverviewSection() }
|
|
| 465 |
+ if isIncomplete { snapshotResultActionsSection }
|
|
| 466 |
+ } |
|
| 467 |
+ } |
|
| 468 |
+ |
|
| 469 |
+ private func reportSummarySection(_ progress: SnapshotFetchProgress) -> some View {
|
|
| 470 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 471 |
+ Text("SUMMARY")
|
|
| 472 |
+ .font(.caption.weight(.semibold)) |
|
| 473 |
+ .foregroundStyle(.secondary) |
|
| 474 |
+ |
|
| 475 |
+ let isComplete = viewModel.snapshotProgress == .complete |
|
| 476 |
+ HStack(spacing: 10) {
|
|
| 477 |
+ Image(systemName: isComplete ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") |
|
| 478 |
+ .font(.title3) |
|
| 479 |
+ .foregroundStyle(isComplete ? Color.healthyGreen : Color.warningAmber) |
|
| 480 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 481 |
+ Text(isComplete ? "Snapshot created successfully" : "Partial snapshot") |
|
| 482 |
+ .font(.subheadline.weight(.semibold)) |
|
| 483 |
+ Text(isComplete ? "All monitored metrics loaded" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed") |
|
| 484 |
+ .font(.caption) |
|
| 485 |
+ .foregroundStyle(.secondary) |
|
| 486 |
+ } |
|
| 487 |
+ Spacer() |
|
| 488 |
+ } |
|
| 489 |
+ .padding(.horizontal, 12) |
|
| 490 |
+ .padding(.vertical, 10) |
|
| 491 |
+ .background(isComplete ? Color.healthyGreen.opacity(0.08) : Color.warningAmber.opacity(0.08)) |
|
| 492 |
+ .cornerRadius(8) |
|
| 493 |
+ |
|
| 494 |
+ VStack(spacing: 0) {
|
|
| 495 |
+ ReportRow(label: "Types processed", value: "\(progress.types.count)") |
|
| 496 |
+ Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
|
| 497 |
+ ReportRow(label: "Successful", value: "\(progress.completedCount)", valueColor: .healthyGreen) |
|
| 498 |
+ if progress.failedCount > 0 {
|
|
| 499 |
+ Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
|
| 500 |
+ ReportRow(label: "Failed", value: "\(progress.failedCount)", valueColor: .criticalRed) |
|
| 501 |
+ } |
|
| 502 |
+ Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
|
| 503 |
+ ReportRow(label: "Records fetched", value: "\(progress.totalRecords)") |
|
| 504 |
+ Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
|
| 505 |
+ ReportRow( |
|
| 506 |
+ label: "Quality", |
|
| 507 |
+ value: qualityLabel(progress: progress), |
|
| 508 |
+ valueColor: qualityColor(progress: progress) |
|
| 509 |
+ ) |
|
| 510 |
+ if let secs = viewModel.fetchDurationSeconds {
|
|
| 511 |
+ Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
|
| 512 |
+ ReportRow(label: "Duration", value: formatDuration(secs)) |
|
| 513 |
+ } |
|
| 514 |
+ Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
|
| 515 |
+ ReportRow(label: "Trigger", value: viewModel.completedSnapshotTriggerReason ?? "manual") |
|
| 516 |
+ } |
|
| 517 |
+ .background(Color(.systemGray6)) |
|
| 518 |
+ .cornerRadius(10) |
|
| 519 |
+ } |
|
| 520 |
+ } |
|
| 521 |
+ |
|
| 522 |
+ private func reportIssuesSection(_ failed: [SnapshotFetchProgress.TypeProgress]) -> some View {
|
|
| 523 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 524 |
+ Text("ISSUES")
|
|
| 525 |
+ .font(.caption.weight(.semibold)) |
|
| 526 |
+ .foregroundStyle(.secondary) |
|
| 527 |
+ |
|
| 528 |
+ Text("\(failed.count) metric\(failed.count == 1 ? "" : "s") failed")
|
|
| 529 |
+ .font(.subheadline.weight(.semibold)) |
|
| 530 |
+ .foregroundStyle(Color.criticalRed) |
|
| 531 |
+ |
|
| 532 |
+ VStack(spacing: 4) {
|
|
| 533 |
+ ForEach(failed) { type in
|
|
| 534 |
+ let reason = failureReason(type) |
|
| 535 |
+ let isExpanded = expandedIssueIDs.contains(type.id) |
|
| 536 |
+ VStack(alignment: .leading, spacing: 0) {
|
|
| 537 |
+ // Collapsed row (always visible) |
|
| 538 |
+ Button {
|
|
| 539 |
+ withAnimation(.easeInOut(duration: 0.2)) {
|
|
| 540 |
+ if isExpanded {
|
|
| 541 |
+ expandedIssueIDs.remove(type.id) |
|
| 542 |
+ } else {
|
|
| 543 |
+ expandedIssueIDs.insert(type.id) |
|
| 544 |
+ } |
|
| 545 |
+ } |
|
| 546 |
+ } label: {
|
|
| 547 |
+ HStack(spacing: 8) {
|
|
| 548 |
+ Image(systemName: reason == "Not authorized" |
|
| 549 |
+ ? "exclamationmark.triangle.fill" : "xmark.circle.fill") |
|
| 550 |
+ .foregroundStyle(reason == "Not authorized" |
|
| 551 |
+ ? Color.warningAmber : Color.criticalRed) |
|
| 552 |
+ .font(.caption) |
|
| 553 |
+ .frame(width: 14) |
|
| 554 |
+ Text(type.displayName) |
|
| 555 |
+ .font(.subheadline.weight(.semibold)) |
|
| 556 |
+ Text("— \(reason)")
|
|
| 557 |
+ .font(.subheadline) |
|
| 558 |
+ .foregroundStyle(.secondary) |
|
| 559 |
+ Spacer() |
|
| 560 |
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
|
| 561 |
+ .font(.caption2.weight(.semibold)) |
|
| 562 |
+ .foregroundStyle(.tertiary) |
|
| 563 |
+ } |
|
| 564 |
+ .padding(.horizontal, 12) |
|
| 565 |
+ .padding(.vertical, 10) |
|
| 566 |
+ .contentShape(Rectangle()) |
|
| 567 |
+ } |
|
| 568 |
+ .buttonStyle(.plain) |
|
| 569 |
+ |
|
| 570 |
+ // Expanded detail (on tap) |
|
| 571 |
+ if isExpanded {
|
|
| 572 |
+ Divider().padding(.horizontal, 12) |
|
| 573 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 574 |
+ Text("Reason: \(reason)").font(.caption).foregroundStyle(.secondary)
|
|
| 575 |
+ Text("Impact: \(failureImpact(reason))").font(.caption).foregroundStyle(.secondary)
|
|
| 576 |
+ if reason == "Timeout" {
|
|
| 577 |
+ Text("Configured: \(formatDuration(type.timeoutConfiguredSeconds)) \(type.timeoutMode)")
|
|
| 578 |
+ .font(.caption).foregroundStyle(.secondary) |
|
| 579 |
+ if type.learnedTimeout > 0 {
|
|
| 580 |
+ Text("Observed needed: ~\(formatDuration(type.learnedTimeout))")
|
|
| 581 |
+ .font(.caption).foregroundStyle(.secondary) |
|
| 582 |
+ } |
|
| 583 |
+ Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(MetricTimeoutProfile.maximumTimeout))")
|
|
| 584 |
+ .font(.caption).foregroundStyle(.secondary) |
|
| 585 |
+ } |
|
| 586 |
+ } |
|
| 587 |
+ .padding(.horizontal, 12) |
|
| 588 |
+ .padding(.vertical, 8) |
|
| 589 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 590 |
+ } |
|
| 591 |
+ } |
|
| 592 |
+ .background(Color(.systemGray6)) |
|
| 593 |
+ .cornerRadius(8) |
|
| 594 |
+ } |
|
| 595 |
+ } |
|
| 596 |
+ } |
|
| 597 |
+ } |
|
| 598 |
+ |
|
| 599 |
+ private func reportDiagnosticBlock(_ progress: SnapshotFetchProgress) -> some View {
|
|
| 600 |
+ let previewText = buildDiagnosticText(progress, mode: .compact) |
|
| 601 |
+ let fullText = buildDiagnosticText(progress, mode: .full) |
|
| 602 |
+ return CollapsibleDiagnosticBlock(previewText: previewText, fullText: fullText) |
|
| 603 |
+ } |
|
| 604 |
+ |
|
| 605 |
+ private func reportDecisionOverviewSection() -> some View {
|
|
| 606 |
+ reportRemediationSection( |
|
| 607 |
+ [ |
|
| 608 |
+ "Retry failed metric with extended timeout.", |
|
| 609 |
+ "Accept snapshot if partial data is sufficient.", |
|
| 610 |
+ "Discard snapshot and start a new one.", |
|
| 611 |
+ "Disable this metric in Settings if it consistently times out." |
|
| 612 |
+ ], |
|
| 613 |
+ title: "WHAT YOU CAN DO" |
|
| 614 |
+ ) |
|
| 615 |
+ } |
|
| 616 |
+ |
|
| 617 |
+ private func reportRemediationSection(_ items: [String], title: String = "WHAT TO DO") -> some View {
|
|
| 618 |
+ let isNotes = title == "NOTES" |
|
| 619 |
+ return VStack(alignment: .leading, spacing: 6) {
|
|
| 620 |
+ Text(title) |
|
| 621 |
+ .font(isNotes ? .caption : .caption.weight(.semibold)) |
|
| 622 |
+ .foregroundStyle(isNotes ? Color(.tertiaryLabel) : .secondary) |
|
| 623 |
+ VStack(alignment: .leading, spacing: isNotes ? 4 : 8) {
|
|
| 624 |
+ ForEach(Array(items.enumerated()), id: \.offset) { index, item in
|
|
| 625 |
+ HStack(alignment: .top, spacing: 8) {
|
|
| 626 |
+ if isNotes {
|
|
| 627 |
+ Text("·")
|
|
| 628 |
+ .font(.caption2) |
|
| 629 |
+ .foregroundStyle(Color(.tertiaryLabel)) |
|
| 630 |
+ .frame(width: 12, alignment: .trailing) |
|
| 631 |
+ } else {
|
|
| 632 |
+ Text("\(index + 1).")
|
|
| 633 |
+ .font(.caption.weight(.semibold)) |
|
| 634 |
+ .foregroundStyle(.secondary) |
|
| 635 |
+ .frame(width: 16, alignment: .trailing) |
|
| 636 |
+ } |
|
| 637 |
+ Text(item) |
|
| 638 |
+ .font(isNotes ? .caption2 : .caption) |
|
| 639 |
+ .foregroundStyle(isNotes ? Color(.secondaryLabel) : .primary) |
|
| 640 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 641 |
+ } |
|
| 642 |
+ } |
|
| 643 |
+ } |
|
| 644 |
+ .padding(12) |
|
| 645 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 646 |
+ .background(Color(.systemGray6)) |
|
| 647 |
+ .cornerRadius(8) |
|
| 648 |
+ } |
|
| 649 |
+ } |
|
| 650 |
+ |
|
| 651 |
+ private func progressFeedView(_ progress: SnapshotFetchProgress) -> some View {
|
|
| 652 |
+ VStack(spacing: 12) {
|
|
| 653 |
+ HStack {
|
|
| 654 |
+ Text(fetchProgressSummary(progress)) |
|
| 655 |
+ .font(.caption.weight(.semibold)) |
|
| 656 |
+ .foregroundStyle(.secondary) |
|
| 657 |
+ Spacer() |
|
| 658 |
+ Text("\(progress.totalRecords) records")
|
|
| 659 |
+ .font(.caption) |
|
| 660 |
+ .foregroundStyle(.secondary) |
|
| 661 |
+ } |
|
| 662 |
+ |
|
| 663 |
+ ScrollView {
|
|
| 664 |
+ VStack(spacing: 8) {
|
|
| 665 |
+ ForEach(progress.visibleTypes) { type in
|
|
| 666 |
+ HStack(spacing: 12) {
|
|
| 667 |
+ if case .fetching = type.status {
|
|
| 668 |
+ ProgressView() |
|
| 669 |
+ .scaleEffect(0.8, anchor: .center) |
|
| 670 |
+ .frame(width: 20) |
|
| 671 |
+ } else {
|
|
| 672 |
+ Image(systemName: type.status.icon) |
|
| 673 |
+ .font(.subheadline.weight(.semibold)) |
|
| 674 |
+ .frame(width: 20) |
|
| 675 |
+ .foregroundStyle(colorForStatus(type.status)) |
|
| 676 |
+ } |
|
| 677 |
+ |
|
| 678 |
+ Text(type.displayName) |
|
| 679 |
+ .font(.caption) |
|
| 680 |
+ .lineLimit(1) |
|
| 681 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 682 |
+ |
|
| 683 |
+ if type.recordCount > 0 {
|
|
| 684 |
+ Text("\(type.recordCount) records")
|
|
| 685 |
+ .font(.caption2) |
|
| 686 |
+ .foregroundStyle(.secondary) |
|
| 687 |
+ .lineLimit(1) |
|
| 688 |
+ } |
|
| 689 |
+ } |
|
| 690 |
+ .padding(.horizontal, 10) |
|
| 691 |
+ .padding(.vertical, 9) |
|
| 692 |
+ .background(rowBackground(for: type.status)) |
|
| 693 |
+ .cornerRadius(6) |
|
| 694 |
+ .overlay {
|
|
| 695 |
+ RoundedRectangle(cornerRadius: 6) |
|
| 696 |
+ .stroke(rowBorder(for: type.status), lineWidth: 1) |
|
| 697 |
+ } |
|
| 698 |
+ } |
|
| 699 |
+ } |
|
| 700 |
+ .frame(maxWidth: .infinity) |
|
| 701 |
+ } |
|
| 702 |
+ } |
|
| 703 |
+ .frame(maxHeight: .infinity) |
|
| 704 |
+ } |
|
| 705 |
+ |
|
| 706 |
+ private var pendingReportView: some View {
|
|
| 707 |
+ VStack(spacing: 12) {
|
|
| 708 |
+ Image(systemName: "doc.text.magnifyingglass") |
|
| 709 |
+ .font(.system(size: 36)) |
|
| 710 |
+ .foregroundStyle(.secondary) |
|
| 711 |
+ Text("Report will appear when the snapshot finishes.")
|
|
| 712 |
+ .font(.caption) |
|
| 713 |
+ .foregroundStyle(.secondary) |
|
| 714 |
+ .multilineTextAlignment(.center) |
|
| 715 |
+ } |
|
| 716 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity) |
|
| 717 |
+ } |
|
| 718 |
+ |
|
| 719 |
+ private var snapshotSheetTabPicker: some View {
|
|
| 720 |
+ HStack(spacing: 2) {
|
|
| 721 |
+ ForEach(SnapshotSheetTab.allCases) { tab in
|
|
| 722 |
+ let isSelected = snapshotSheetTab == tab |
|
| 723 |
+ let isDisabled = tab == .report && !shouldShowFetchReport |
|
| 724 |
+ |
|
| 725 |
+ Button {
|
|
| 726 |
+ snapshotSheetTab = tab |
|
| 727 |
+ } label: {
|
|
| 728 |
+ Text(tab.rawValue) |
|
| 729 |
+ .font(.caption.weight(.semibold)) |
|
| 730 |
+ .frame(maxWidth: .infinity) |
|
| 731 |
+ .padding(.vertical, 8) |
|
| 732 |
+ .foregroundStyle(isDisabled ? .tertiary : (isSelected ? .primary : .secondary)) |
|
| 733 |
+ .background(isSelected ? Color(.systemBackground) : Color.clear) |
|
| 734 |
+ .cornerRadius(6) |
|
| 735 |
+ } |
|
| 736 |
+ .buttonStyle(.plain) |
|
| 737 |
+ .disabled(isDisabled) |
|
| 738 |
+ .accessibilityLabel(tab.rawValue) |
|
| 739 |
+ } |
|
| 740 |
+ } |
|
| 741 |
+ .padding(2) |
|
| 742 |
+ .background(Color(.systemGray5)) |
|
| 743 |
+ .cornerRadius(8) |
|
| 744 |
+ } |
|
| 745 |
+ |
|
| 746 |
+ private var sheetOperationHeader: some View {
|
|
| 747 |
+ VStack(alignment: .center, spacing: 4) {
|
|
| 748 |
+ HStack(spacing: 8) {
|
|
| 749 |
+ Image(systemName: "camera.viewfinder") |
|
| 750 |
+ .font(.system(size: 20, weight: .semibold)) |
|
| 751 |
+ .foregroundStyle(Color(.secondaryLabel)) |
|
| 752 |
+ .frame(width: 22) |
|
| 753 |
+ |
|
| 754 |
+ HStack(spacing: 4) {
|
|
| 755 |
+ Text("Health data snapshot").font(.headline)
|
|
| 756 |
+ |
|
| 757 |
+ if viewModel.snapshotProgress == .complete {
|
|
| 758 |
+ Image(systemName: "checkmark.circle.fill") |
|
| 759 |
+ .font(.caption) |
|
| 760 |
+ .foregroundStyle(Color.healthyGreen) |
|
| 761 |
+ } else if viewModel.snapshotProgress == .incomplete {
|
|
| 762 |
+ Image(systemName: "exclamationmark.circle.fill") |
|
| 763 |
+ .font(.caption) |
|
| 764 |
+ .foregroundStyle(Color.warningAmber) |
|
| 765 |
+ } |
|
| 766 |
+ } |
|
| 767 |
+ } |
|
| 768 |
+ |
|
| 769 |
+ if let progress = viewModel.fetchProgress {
|
|
| 770 |
+ Text("\(progress.types.count) metrics")
|
|
| 771 |
+ .font(.caption) |
|
| 772 |
+ .foregroundStyle(.secondary) |
|
| 773 |
+ } |
|
| 774 |
+ } |
|
| 775 |
+ .frame(maxWidth: .infinity) |
|
| 776 |
+ } |
|
| 777 |
+ |
|
| 778 |
+ // MARK: - Progress Sheet |
|
| 779 |
+ |
|
| 780 |
+ private var progressSheet: some View {
|
|
| 781 |
+ VStack(spacing: 16) {
|
|
| 782 |
+ sheetOperationHeader |
|
| 783 |
+ .frame(maxWidth: .infinity, alignment: .center) |
|
| 784 |
+ |
|
| 785 |
+ if let progress = viewModel.fetchProgress, shouldShowFetchProgress {
|
|
| 786 |
+ VStack(spacing: 12) {
|
|
| 787 |
+ snapshotSheetTabPicker |
|
| 788 |
+ |
|
| 789 |
+ Group {
|
|
| 790 |
+ switch snapshotSheetTab {
|
|
| 791 |
+ case .progress: |
|
| 792 |
+ progressFeedView(progress) |
|
| 793 |
+ case .report: |
|
| 794 |
+ if shouldShowFetchReport {
|
|
| 795 |
+ ScrollView {
|
|
| 796 |
+ fetchReportView(progress) |
|
| 797 |
+ } |
|
| 798 |
+ } else {
|
|
| 799 |
+ pendingReportView |
|
| 800 |
+ } |
|
| 801 |
+ } |
|
| 802 |
+ } |
|
| 803 |
+ .frame(maxHeight: .infinity) |
|
| 804 |
+ } |
|
| 805 |
+ .frame(maxHeight: .infinity) |
|
| 806 |
+ } |
|
| 807 |
+ |
|
| 808 |
+ if viewModel.snapshotProgress == .idle, let errorMsg = viewModel.snapshotError, !errorMsg.isEmpty {
|
|
| 809 |
+ VStack(spacing: 12) {
|
|
| 810 |
+ HStack {
|
|
| 811 |
+ Text("Error details")
|
|
| 812 |
+ .font(.caption.weight(.semibold)) |
|
| 813 |
+ Spacer() |
|
| 814 |
+ Button {
|
|
| 815 |
+ UIPasteboard.general.string = errorMsg |
|
| 816 |
+ } label: {
|
|
| 817 |
+ Image(systemName: "doc.on.doc") |
|
| 818 |
+ .font(.caption) |
|
| 819 |
+ } |
|
| 820 |
+ .accessibilityLabel("Copy error details to clipboard")
|
|
| 821 |
+ } |
|
| 822 |
+ .foregroundStyle(.secondary) |
|
| 823 |
+ |
|
| 824 |
+ Text(errorMsg) |
|
| 825 |
+ .font(.caption) |
|
| 826 |
+ .textSelection(.enabled) |
|
| 827 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 828 |
+ .padding(12) |
|
| 829 |
+ .background(Color(.systemGray6)) |
|
| 830 |
+ .cornerRadius(8) |
|
| 831 |
+ } |
|
| 832 |
+ } |
|
| 833 |
+ } |
|
| 834 |
+ .padding(24) |
|
| 835 |
+ .presentationDetents(sheetDetents) |
|
| 836 |
+ .presentationDragIndicator(.visible) |
|
| 837 |
+ .interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.isRequestingAuth) |
|
| 838 |
+ .onAppear {
|
|
| 839 |
+ snapshotSheetTab = .progress |
|
| 840 |
+ } |
|
| 841 |
+ .onChange(of: viewModel.snapshotProgress) { _, newProgress in
|
|
| 842 |
+ if newProgress == .incomplete {
|
|
| 843 |
+ snapshotSheetTab = .report |
|
| 844 |
+ } else if newProgress == .fetching {
|
|
| 845 |
+ snapshotSheetTab = .progress |
|
| 846 |
+ } |
|
| 847 |
+ } |
|
| 848 |
+ } |
|
| 849 |
+ |
|
| 850 |
+ @ViewBuilder |
|
| 851 |
+ private var snapshotResultActionsSection: some View {
|
|
| 852 |
+ HStack(spacing: 12) {
|
|
| 853 |
+ Button {
|
|
| 854 |
+ Task {
|
|
| 855 |
+ if viewModel.canRetryWithPermissions {
|
|
| 856 |
+ await viewModel.retryWithPermissions( |
|
| 857 |
+ context: modelContext, |
|
| 858 |
+ selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 859 |
+ adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 860 |
+ ) |
|
| 861 |
+ } else {
|
|
| 862 |
+ await viewModel.retryFailedMetricsWithExtendedTimeout(context: modelContext) |
|
| 863 |
+ } |
|
| 864 |
+ } |
|
| 865 |
+ } label: {
|
|
| 866 |
+ Text("Retry").frame(maxWidth: .infinity)
|
|
| 867 |
+ } |
|
| 868 |
+ .buttonStyle(.borderedProminent) |
|
| 869 |
+ .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth) |
|
| 870 |
+ |
|
| 871 |
+ Button {
|
|
| 872 |
+ viewModel.acceptPartialSnapshot() |
|
| 873 |
+ } label: {
|
|
| 874 |
+ Text("Accept").frame(maxWidth: .infinity)
|
|
| 875 |
+ } |
|
| 876 |
+ .buttonStyle(.bordered) |
|
| 877 |
+ .accessibilityLabel("Accept partial snapshot")
|
|
| 878 |
+ |
|
| 879 |
+ Button {
|
|
| 880 |
+ Task { await viewModel.discardSnapshot(context: modelContext) }
|
|
| 881 |
+ } label: {
|
|
| 882 |
+ Text("Discard").foregroundStyle(Color.criticalRed).frame(maxWidth: .infinity)
|
|
| 883 |
+ } |
|
| 884 |
+ .buttonStyle(.bordered) |
|
| 885 |
+ .disabled(viewModel.isCreatingSnapshot) |
|
| 886 |
+ .accessibilityLabel("Discard snapshot")
|
|
| 887 |
+ } |
|
| 888 |
+ } |
|
| 889 |
+ |
|
| 890 |
+ private var sheetDetents: Set<PresentationDetent> {
|
|
| 891 |
+ [.large] |
|
| 892 |
+ } |
|
| 893 |
+ |
|
| 45 | 894 |
// MARK: - Sections |
| 46 | 895 |
|
| 47 | 896 |
private var statusSection: some View {
|
@@ -103,15 +952,12 @@ struct DashboardView: View {
|
||
| 103 | 952 |
Task {
|
| 104 | 953 |
await viewModel.createSnapshot( |
| 105 | 954 |
context: modelContext, |
| 106 |
- selectedTypeIDs: appSettings.selectedTypeIDs |
|
| 955 |
+ selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 956 |
+ adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 107 | 957 |
) |
| 108 | 958 |
} |
| 109 | 959 |
} label: {
|
| 110 |
- HStack {
|
|
| 111 |
- Label("Create Snapshot", systemImage: "camera.viewfinder")
|
|
| 112 |
- Spacer() |
|
| 113 |
- if viewModel.isCreatingSnapshot { ProgressView() }
|
|
| 114 |
- } |
|
| 960 |
+ Label("Create Snapshot", systemImage: "camera.viewfinder")
|
|
| 115 | 961 |
} |
| 116 | 962 |
.disabled(viewModel.isCreatingSnapshot) |
| 117 | 963 |
.accessibilityLabel("Create a new data snapshot")
|
@@ -119,6 +965,13 @@ struct DashboardView: View {
|
||
| 119 | 965 |
} |
| 120 | 966 |
} |
| 121 | 967 |
|
| 968 |
+private enum SnapshotSheetTab: String, CaseIterable, Identifiable {
|
|
| 969 |
+ case progress = "Progress" |
|
| 970 |
+ case report = "Report" |
|
| 971 |
+ |
|
| 972 |
+ var id: Self { self }
|
|
| 973 |
+} |
|
| 974 |
+ |
|
| 122 | 975 |
// Avoids LabeledContent's TableRowContent ambiguity in List/Section contexts. |
| 123 | 976 |
private struct InfoRow<Content: View>: View {
|
| 124 | 977 |
let label: String |
@@ -133,6 +986,57 @@ private struct InfoRow<Content: View>: View {
|
||
| 133 | 986 |
} |
| 134 | 987 |
} |
| 135 | 988 |
|
| 989 |
+private struct ReportRow: View {
|
|
| 990 |
+ let label: String |
|
| 991 |
+ let value: String |
|
| 992 |
+ var valueColor: Color = .primary |
|
| 993 |
+ |
|
| 994 |
+ var body: some View {
|
|
| 995 |
+ HStack {
|
|
| 996 |
+ Text(label) |
|
| 997 |
+ .font(.subheadline) |
|
| 998 |
+ Spacer() |
|
| 999 |
+ Text(value) |
|
| 1000 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1001 |
+ .foregroundStyle(valueColor) |
|
| 1002 |
+ } |
|
| 1003 |
+ .padding(.horizontal, 12) |
|
| 1004 |
+ .padding(.vertical, 10) |
|
| 1005 |
+ } |
|
| 1006 |
+} |
|
| 1007 |
+ |
|
| 1008 |
+private struct CollapsibleDiagnosticBlock: View {
|
|
| 1009 |
+ let previewText: String |
|
| 1010 |
+ let fullText: String |
|
| 1011 |
+ @State private var isExpanded = false |
|
| 1012 |
+ |
|
| 1013 |
+ var body: some View {
|
|
| 1014 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 1015 |
+ Button {
|
|
| 1016 |
+ withAnimation(.snappy) {
|
|
| 1017 |
+ isExpanded.toggle() |
|
| 1018 |
+ } |
|
| 1019 |
+ } label: {
|
|
| 1020 |
+ HStack {
|
|
| 1021 |
+ Label("Diagnostics", systemImage: "doc.text.magnifyingglass")
|
|
| 1022 |
+ Spacer() |
|
| 1023 |
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
|
| 1024 |
+ .font(.caption.weight(.semibold)) |
|
| 1025 |
+ } |
|
| 1026 |
+ } |
|
| 1027 |
+ .buttonStyle(.plain) |
|
| 1028 |
+ |
|
| 1029 |
+ Text(isExpanded ? fullText : previewText) |
|
| 1030 |
+ .font(.system(.caption, design: .monospaced)) |
|
| 1031 |
+ .textSelection(.enabled) |
|
| 1032 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 1033 |
+ .padding(10) |
|
| 1034 |
+ .background(Color(.secondarySystemGroupedBackground)) |
|
| 1035 |
+ .clipShape(RoundedRectangle(cornerRadius: 8)) |
|
| 1036 |
+ } |
|
| 1037 |
+ } |
|
| 1038 |
+} |
|
| 1039 |
+ |
|
| 136 | 1040 |
private struct AnomalySummarySection: View {
|
| 137 | 1041 |
@Query(filter: #Predicate<AnomalyRecord> { !$0.isResolved })
|
| 138 | 1042 |
private var unresolved: [AnomalyRecord] |
@@ -158,6 +1062,18 @@ private struct AnomalySummarySection: View {
|
||
| 158 | 1062 |
} |
| 159 | 1063 |
} |
| 160 | 1064 |
|
| 1065 |
+extension Bundle {
|
|
| 1066 |
+ var appVersion: String {
|
|
| 1067 |
+ guard let version = infoDictionary?["CFBundleShortVersionString"] as? String else {
|
|
| 1068 |
+ return "unknown" |
|
| 1069 |
+ } |
|
| 1070 |
+ guard let build = infoDictionary?["CFBundleVersion"] as? String else {
|
|
| 1071 |
+ return version |
|
| 1072 |
+ } |
|
| 1073 |
+ return "\(version)(\(build))" |
|
| 1074 |
+ } |
|
| 1075 |
+} |
|
| 1076 |
+ |
|
| 161 | 1077 |
#Preview {
|
| 162 | 1078 |
DashboardView() |
| 163 | 1079 |
.modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self], inMemory: true) |
@@ -1,358 +0,0 @@ |
||
| 1 |
-import SwiftUI |
|
| 2 |
- |
|
| 3 |
-struct SnapshotProgressSheet: View {
|
|
| 4 |
- @Bindable var viewModel: DashboardViewModel |
|
| 5 |
- @State private var selectedTab: Tab = .progress |
|
| 6 |
- |
|
| 7 |
- enum Tab { case progress, report }
|
|
| 8 |
- |
|
| 9 |
- var body: some View {
|
|
| 10 |
- NavigationStack {
|
|
| 11 |
- VStack(spacing: 12) {
|
|
| 12 |
- // Header |
|
| 13 |
- HStack {
|
|
| 14 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 15 |
- Text("Health data snapshot")
|
|
| 16 |
- .font(.headline) |
|
| 17 |
- Text("\(viewModel.fetchProgress.count) metrics")
|
|
| 18 |
- .font(.caption) |
|
| 19 |
- .foregroundStyle(.secondary) |
|
| 20 |
- } |
|
| 21 |
- Spacer() |
|
| 22 |
- Image(systemName: statusIcon) |
|
| 23 |
- .font(.title3) |
|
| 24 |
- .foregroundStyle(statusColor) |
|
| 25 |
- } |
|
| 26 |
- .padding(.horizontal) |
|
| 27 |
- |
|
| 28 |
- // Tab picker |
|
| 29 |
- Picker("View", selection: $selectedTab) {
|
|
| 30 |
- Text("Progress").tag(Tab.progress)
|
|
| 31 |
- Text("Report").tag(Tab.report)
|
|
| 32 |
- } |
|
| 33 |
- .pickerStyle(.segmented) |
|
| 34 |
- .padding(.horizontal) |
|
| 35 |
- |
|
| 36 |
- // Content |
|
| 37 |
- Group {
|
|
| 38 |
- switch selectedTab {
|
|
| 39 |
- case .progress: |
|
| 40 |
- ProgressTabView(entries: viewModel.fetchProgress, isComplete: !viewModel.isCreatingSnapshot) |
|
| 41 |
- case .report: |
|
| 42 |
- ReportTabView(entries: viewModel.fetchProgress, duration: duration) |
|
| 43 |
- } |
|
| 44 |
- } |
|
| 45 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 46 |
- } |
|
| 47 |
- .padding(.vertical, 12) |
|
| 48 |
- } |
|
| 49 |
- } |
|
| 50 |
- |
|
| 51 |
- private var statusIcon: String {
|
|
| 52 |
- let unauthorized = viewModel.fetchProgress.filter { if case .unauthorized = $0.status { return true }; return false }
|
|
| 53 |
- let failed = viewModel.fetchProgress.filter { if case .failed = $0.status { return true }; return false }
|
|
| 54 |
- |
|
| 55 |
- if viewModel.isCreatingSnapshot {
|
|
| 56 |
- return "hourglass.circle" |
|
| 57 |
- } else if unauthorized.isEmpty && failed.isEmpty {
|
|
| 58 |
- return "checkmark.circle.fill" |
|
| 59 |
- } else {
|
|
| 60 |
- return "exclamationmark.circle.fill" |
|
| 61 |
- } |
|
| 62 |
- } |
|
| 63 |
- |
|
| 64 |
- private var statusColor: Color {
|
|
| 65 |
- let unauthorized = viewModel.fetchProgress.filter { if case .unauthorized = $0.status { return true }; return false }
|
|
| 66 |
- let failed = viewModel.fetchProgress.filter { if case .failed = $0.status { return true }; return false }
|
|
| 67 |
- |
|
| 68 |
- if viewModel.isCreatingSnapshot {
|
|
| 69 |
- return .blue |
|
| 70 |
- } else if unauthorized.isEmpty && failed.isEmpty {
|
|
| 71 |
- return Color.healthyGreen |
|
| 72 |
- } else if unauthorized.isEmpty {
|
|
| 73 |
- return Color.criticalRed |
|
| 74 |
- } else {
|
|
| 75 |
- return Color.warningAmber |
|
| 76 |
- } |
|
| 77 |
- } |
|
| 78 |
- |
|
| 79 |
- private var duration: TimeInterval? {
|
|
| 80 |
- guard let start = viewModel.fetchStartTime else { return nil }
|
|
| 81 |
- return Date().timeIntervalSince(start) |
|
| 82 |
- } |
|
| 83 |
-} |
|
| 84 |
- |
|
| 85 |
-// MARK: - Progress Tab |
|
| 86 |
-private struct ProgressTabView: View {
|
|
| 87 |
- let entries: [TypeProgressEntry] |
|
| 88 |
- let isComplete: Bool |
|
| 89 |
- |
|
| 90 |
- var body: some View {
|
|
| 91 |
- List {
|
|
| 92 |
- ForEach(entries) { entry in
|
|
| 93 |
- HStack(spacing: 12) {
|
|
| 94 |
- icon(for: entry.status) |
|
| 95 |
- .frame(width: 20) |
|
| 96 |
- |
|
| 97 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 98 |
- Text(entry.displayName) |
|
| 99 |
- .font(.subheadline) |
|
| 100 |
- statusText(for: entry.status) |
|
| 101 |
- .font(.caption) |
|
| 102 |
- .foregroundStyle(.secondary) |
|
| 103 |
- } |
|
| 104 |
- |
|
| 105 |
- Spacer() |
|
| 106 |
- } |
|
| 107 |
- .padding(.vertical, 4) |
|
| 108 |
- } |
|
| 109 |
- } |
|
| 110 |
- .listStyle(.plain) |
|
| 111 |
- } |
|
| 112 |
- |
|
| 113 |
- private func icon(for status: TypeProgressEntry.Status) -> some View {
|
|
| 114 |
- Group {
|
|
| 115 |
- switch status {
|
|
| 116 |
- case .pending: |
|
| 117 |
- Image(systemName: "circle") |
|
| 118 |
- .foregroundStyle(.gray) |
|
| 119 |
- case .complete: |
|
| 120 |
- Image(systemName: "checkmark.circle.fill") |
|
| 121 |
- .foregroundStyle(Color.healthyGreen) |
|
| 122 |
- case .unauthorized: |
|
| 123 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 124 |
- .foregroundStyle(Color.warningAmber) |
|
| 125 |
- case .failed: |
|
| 126 |
- Image(systemName: "xmark.circle.fill") |
|
| 127 |
- .foregroundStyle(Color.criticalRed) |
|
| 128 |
- } |
|
| 129 |
- } |
|
| 130 |
- } |
|
| 131 |
- |
|
| 132 |
- private func statusText(for status: TypeProgressEntry.Status) -> some View {
|
|
| 133 |
- Group {
|
|
| 134 |
- switch status {
|
|
| 135 |
- case .pending: |
|
| 136 |
- Text("Pending")
|
|
| 137 |
- case .complete(let count): |
|
| 138 |
- Text("\(count) records")
|
|
| 139 |
- case .unauthorized: |
|
| 140 |
- Text("Not authorized")
|
|
| 141 |
- case .failed(let reason): |
|
| 142 |
- Text(reason) |
|
| 143 |
- } |
|
| 144 |
- } |
|
| 145 |
- } |
|
| 146 |
-} |
|
| 147 |
- |
|
| 148 |
-// MARK: - Report Tab |
|
| 149 |
-private struct ReportTabView: View {
|
|
| 150 |
- let entries: [TypeProgressEntry] |
|
| 151 |
- let duration: TimeInterval? |
|
| 152 |
- |
|
| 153 |
- var body: some View {
|
|
| 154 |
- ScrollView {
|
|
| 155 |
- VStack(alignment: .leading, spacing: 16) {
|
|
| 156 |
- // Summary |
|
| 157 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 158 |
- Text("SUMMARY")
|
|
| 159 |
- .font(.caption.weight(.semibold)) |
|
| 160 |
- .foregroundStyle(.secondary) |
|
| 161 |
- |
|
| 162 |
- let successful = entries.filter { if case .complete = $0.status { return true }; return false }
|
|
| 163 |
- let unauthorized = entries.filter { if case .unauthorized = $0.status { return true }; return false }
|
|
| 164 |
- let failed = entries.filter { if case .failed = $0.status { return true }; return false }
|
|
| 165 |
- |
|
| 166 |
- HStack {
|
|
| 167 |
- Text("Successful: \(successful.count)")
|
|
| 168 |
- Spacer() |
|
| 169 |
- Text("\(successful.count)")
|
|
| 170 |
- .fontWeight(.semibold) |
|
| 171 |
- .foregroundStyle(Color.healthyGreen) |
|
| 172 |
- } |
|
| 173 |
- .font(.caption) |
|
| 174 |
- |
|
| 175 |
- if !unauthorized.isEmpty {
|
|
| 176 |
- HStack {
|
|
| 177 |
- Text("Not authorized: \(unauthorized.count)")
|
|
| 178 |
- Spacer() |
|
| 179 |
- Text("\(unauthorized.count)")
|
|
| 180 |
- .fontWeight(.semibold) |
|
| 181 |
- .foregroundStyle(Color.warningAmber) |
|
| 182 |
- } |
|
| 183 |
- .font(.caption) |
|
| 184 |
- } |
|
| 185 |
- |
|
| 186 |
- if !failed.isEmpty {
|
|
| 187 |
- HStack {
|
|
| 188 |
- Text("Failed: \(failed.count)")
|
|
| 189 |
- Spacer() |
|
| 190 |
- Text("\(failed.count)")
|
|
| 191 |
- .fontWeight(.semibold) |
|
| 192 |
- .foregroundStyle(Color.criticalRed) |
|
| 193 |
- } |
|
| 194 |
- .font(.caption) |
|
| 195 |
- } |
|
| 196 |
- |
|
| 197 |
- if let duration {
|
|
| 198 |
- HStack {
|
|
| 199 |
- Text("Duration:")
|
|
| 200 |
- Spacer() |
|
| 201 |
- Text(formatDuration(duration)) |
|
| 202 |
- .fontWeight(.semibold) |
|
| 203 |
- .foregroundStyle(.secondary) |
|
| 204 |
- } |
|
| 205 |
- .font(.caption) |
|
| 206 |
- } |
|
| 207 |
- } |
|
| 208 |
- .padding(12) |
|
| 209 |
- .background(Color(.systemGray6)) |
|
| 210 |
- .cornerRadius(8) |
|
| 211 |
- |
|
| 212 |
- // Unauthorized metrics advice |
|
| 213 |
- let unauthorized = entries.filter { if case .unauthorized = $0.status { return true }; return false }
|
|
| 214 |
- if !unauthorized.isEmpty {
|
|
| 215 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 216 |
- Label("Permission required", systemImage: "exclamationmark.circle.fill")
|
|
| 217 |
- .font(.subheadline.weight(.semibold)) |
|
| 218 |
- .foregroundStyle(Color.warningAmber) |
|
| 219 |
- |
|
| 220 |
- Text("\(unauthorized.count) metric\(unauthorized.count == 1 ? "" : "s") excluded — enable in Health app")
|
|
| 221 |
- .font(.caption) |
|
| 222 |
- .foregroundStyle(.secondary) |
|
| 223 |
- |
|
| 224 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 225 |
- ForEach(unauthorized) { entry in
|
|
| 226 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 227 |
- Text(entry.displayName) |
|
| 228 |
- .font(.caption.weight(.semibold)) |
|
| 229 |
- Text("Health app → Settings → Privacy → Health → HealthProbe")
|
|
| 230 |
- .font(.caption2) |
|
| 231 |
- .foregroundStyle(.secondary) |
|
| 232 |
- } |
|
| 233 |
- .padding(8) |
|
| 234 |
- .background(Color(.systemGray6)) |
|
| 235 |
- .cornerRadius(6) |
|
| 236 |
- } |
|
| 237 |
- } |
|
| 238 |
- } |
|
| 239 |
- } |
|
| 240 |
- |
|
| 241 |
- // Failed metrics |
|
| 242 |
- let failed = entries.filter { if case .failed = $0.status { return true }; return false }
|
|
| 243 |
- if !failed.isEmpty {
|
|
| 244 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 245 |
- Label("Errors", systemImage: "xmark.circle.fill")
|
|
| 246 |
- .font(.subheadline.weight(.semibold)) |
|
| 247 |
- .foregroundStyle(Color.criticalRed) |
|
| 248 |
- |
|
| 249 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 250 |
- ForEach(failed) { entry in
|
|
| 251 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 252 |
- Text(entry.displayName) |
|
| 253 |
- .font(.caption.weight(.semibold)) |
|
| 254 |
- if case .failed(let reason) = entry.status {
|
|
| 255 |
- Text(reason) |
|
| 256 |
- .font(.caption2) |
|
| 257 |
- .foregroundStyle(.secondary) |
|
| 258 |
- } |
|
| 259 |
- } |
|
| 260 |
- .padding(8) |
|
| 261 |
- .background(Color(.systemGray6)) |
|
| 262 |
- .cornerRadius(6) |
|
| 263 |
- } |
|
| 264 |
- } |
|
| 265 |
- } |
|
| 266 |
- } |
|
| 267 |
- |
|
| 268 |
- // All metrics list |
|
| 269 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 270 |
- Text("ALL METRICS")
|
|
| 271 |
- .font(.caption.weight(.semibold)) |
|
| 272 |
- .foregroundStyle(.secondary) |
|
| 273 |
- |
|
| 274 |
- VStack(spacing: 6) {
|
|
| 275 |
- ForEach(entries.sorted { $0.displayName < $1.displayName }) { entry in
|
|
| 276 |
- HStack(spacing: 8) {
|
|
| 277 |
- icon(for: entry.status) |
|
| 278 |
- .frame(width: 14) |
|
| 279 |
- |
|
| 280 |
- Text(entry.displayName) |
|
| 281 |
- .font(.caption) |
|
| 282 |
- |
|
| 283 |
- Spacer() |
|
| 284 |
- |
|
| 285 |
- statusLabel(for: entry.status) |
|
| 286 |
- .font(.caption2) |
|
| 287 |
- .foregroundStyle(.secondary) |
|
| 288 |
- } |
|
| 289 |
- .padding(.vertical, 6) |
|
| 290 |
- .padding(.horizontal, 10) |
|
| 291 |
- .background(Color(.systemGray6)) |
|
| 292 |
- .cornerRadius(6) |
|
| 293 |
- } |
|
| 294 |
- } |
|
| 295 |
- } |
|
| 296 |
- } |
|
| 297 |
- .padding(12) |
|
| 298 |
- } |
|
| 299 |
- } |
|
| 300 |
- |
|
| 301 |
- private func icon(for status: TypeProgressEntry.Status) -> some View {
|
|
| 302 |
- Group {
|
|
| 303 |
- switch status {
|
|
| 304 |
- case .complete: |
|
| 305 |
- Image(systemName: "checkmark.circle.fill") |
|
| 306 |
- .foregroundStyle(Color.healthyGreen) |
|
| 307 |
- case .unauthorized: |
|
| 308 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 309 |
- .foregroundStyle(Color.warningAmber) |
|
| 310 |
- case .failed: |
|
| 311 |
- Image(systemName: "xmark.circle.fill") |
|
| 312 |
- .foregroundStyle(Color.criticalRed) |
|
| 313 |
- case .pending: |
|
| 314 |
- Image(systemName: "circle") |
|
| 315 |
- .foregroundStyle(.gray) |
|
| 316 |
- } |
|
| 317 |
- } |
|
| 318 |
- } |
|
| 319 |
- |
|
| 320 |
- private func statusLabel(for status: TypeProgressEntry.Status) -> some View {
|
|
| 321 |
- Group {
|
|
| 322 |
- switch status {
|
|
| 323 |
- case .complete(let count): |
|
| 324 |
- Text("\(count) records")
|
|
| 325 |
- case .unauthorized: |
|
| 326 |
- Text("Not authorized")
|
|
| 327 |
- case .failed(let reason): |
|
| 328 |
- Text(reason) |
|
| 329 |
- case .pending: |
|
| 330 |
- Text("Pending")
|
|
| 331 |
- } |
|
| 332 |
- } |
|
| 333 |
- } |
|
| 334 |
- |
|
| 335 |
- private func formatDuration(_ seconds: TimeInterval) -> String {
|
|
| 336 |
- if seconds < 60 {
|
|
| 337 |
- return String(format: "%.1fs", seconds) |
|
| 338 |
- } else {
|
|
| 339 |
- let minutes = Int(seconds) / 60 |
|
| 340 |
- let secs = Int(seconds) % 60 |
|
| 341 |
- return String(format: "%dm %ds", minutes, secs) |
|
| 342 |
- } |
|
| 343 |
- } |
|
| 344 |
-} |
|
| 345 |
- |
|
| 346 |
-#Preview {
|
|
| 347 |
- @Previewable @State var viewModel = DashboardViewModel() |
|
| 348 |
- |
|
| 349 |
- SnapshotProgressSheet(viewModel: viewModel) |
|
| 350 |
- .onAppear {
|
|
| 351 |
- viewModel.fetchProgress = [ |
|
| 352 |
- TypeProgressEntry(id: "steps", displayName: "Steps", status: .complete(1234)), |
|
| 353 |
- TypeProgressEntry(id: "activeEnergy", displayName: "Active Energy", status: .unauthorized), |
|
| 354 |
- TypeProgressEntry(id: "heartRate", displayName: "Heart Rate", status: .complete(5678)), |
|
| 355 |
- TypeProgressEntry(id: "sleep", displayName: "Sleep", status: .failed("Timeout")),
|
|
| 356 |
- ] |
|
| 357 |
- } |
|
| 358 |
-} |
|
@@ -8,6 +8,7 @@ struct SettingsView: View {
|
||
| 8 | 8 |
@Environment(AppSettings.self) private var appSettings |
| 9 | 9 |
@Query private var snapshots: [HealthSnapshot] |
| 10 | 10 |
@Query private var deviceProfiles: [DeviceProfile] |
| 11 |
+ @Query(sort: \MetricTimeoutProfile.displayName) private var timeoutProfiles: [MetricTimeoutProfile] |
|
| 11 | 12 |
@AppStorage("checkFrequencyHours") private var checkFrequencyHours: Int = 6
|
| 12 | 13 |
@State private var showDeleteConfirm = false |
| 13 | 14 |
|
@@ -24,12 +25,16 @@ struct SettingsView: View {
|
||
| 24 | 25 |
List {
|
| 25 | 26 |
deviceSection |
| 26 | 27 |
monitoringSection |
| 28 |
+ timeoutCalibrationSection |
|
| 27 | 29 |
typeSelectionSections |
| 28 | 30 |
dataSection |
| 29 | 31 |
aboutSection |
| 30 | 32 |
} |
| 31 | 33 |
.navigationTitle("Settings")
|
| 32 |
- .onAppear { ensureCurrentDeviceProfile() }
|
|
| 34 |
+ .onAppear {
|
|
| 35 |
+ ensureCurrentDeviceProfile() |
|
| 36 |
+ ensureTimeoutProfiles() |
|
| 37 |
+ } |
|
| 33 | 38 |
.confirmationDialog( |
| 34 | 39 |
"Delete All Audit Data", |
| 35 | 40 |
isPresented: $showDeleteConfirm, |
@@ -98,6 +103,36 @@ struct SettingsView: View {
|
||
| 98 | 103 |
} |
| 99 | 104 |
} |
| 100 | 105 |
|
| 106 |
+ private var timeoutCalibrationSection: some View {
|
|
| 107 |
+ Section("Timeout Calibration") {
|
|
| 108 |
+ Toggle("Adaptive Timeouts", isOn: Binding(
|
|
| 109 |
+ get: { appSettings.adaptiveTimeoutsEnabled },
|
|
| 110 |
+ set: { appSettings.adaptiveTimeoutsEnabled = $0 }
|
|
| 111 |
+ )) |
|
| 112 |
+ |
|
| 113 |
+ InfoRow(label: "Default Initial Timeout") {
|
|
| 114 |
+ Text(formatDuration(MetricTimeoutProfile.defaultInitialTimeout)) |
|
| 115 |
+ .foregroundStyle(.secondary) |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ InfoRow(label: "Maximum Timeout") {
|
|
| 119 |
+ Text(formatDuration(MetricTimeoutProfile.maximumTimeout)) |
|
| 120 |
+ .foregroundStyle(.secondary) |
|
| 121 |
+ } |
|
| 122 |
+ |
|
| 123 |
+ ForEach(timeoutProfiles) { profile in
|
|
| 124 |
+ TimeoutProfileRow(profile: profile) |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ Button(role: .destructive) {
|
|
| 128 |
+ resetAllTimeoutProfiles() |
|
| 129 |
+ } label: {
|
|
| 130 |
+ Label("Reset All Learned Timeouts", systemImage: "arrow.counterclockwise")
|
|
| 131 |
+ } |
|
| 132 |
+ .disabled(timeoutProfiles.isEmpty) |
|
| 133 |
+ } |
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 101 | 136 |
@ViewBuilder |
| 102 | 137 |
private var typeSelectionSections: some View {
|
| 103 | 138 |
ForEach(TypeCategory.allCases, id: \.self) { category in
|
@@ -144,6 +179,21 @@ struct SettingsView: View {
|
||
| 144 | 179 |
modelContext.insert(DeviceProfile(deviceID: currentDeviceID)) |
| 145 | 180 |
} |
| 146 | 181 |
|
| 182 |
+ private func ensureTimeoutProfiles() {
|
|
| 183 |
+ let existingIDs = Set(timeoutProfiles.map(\.metricIdentifier)) |
|
| 184 |
+ for type in HealthKitService.allTypes where !existingIDs.contains(type.id) {
|
|
| 185 |
+ modelContext.insert(MetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName)) |
|
| 186 |
+ } |
|
| 187 |
+ try? modelContext.save() |
|
| 188 |
+ } |
|
| 189 |
+ |
|
| 190 |
+ private func resetAllTimeoutProfiles() {
|
|
| 191 |
+ for profile in timeoutProfiles {
|
|
| 192 |
+ profile.resetLearning() |
|
| 193 |
+ } |
|
| 194 |
+ try? modelContext.save() |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 147 | 197 |
private func deleteAllData() {
|
| 148 | 198 |
for snapshot in snapshots { modelContext.delete(snapshot) }
|
| 149 | 199 |
try? modelContext.save() |
@@ -167,6 +217,91 @@ private struct TypeToggleRow: View {
|
||
| 167 | 217 |
} |
| 168 | 218 |
} |
| 169 | 219 |
|
| 220 |
+private struct TimeoutProfileRow: View {
|
|
| 221 |
+ @Bindable var profile: MetricTimeoutProfile |
|
| 222 |
+ |
|
| 223 |
+ var body: some View {
|
|
| 224 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 225 |
+ HStack(alignment: .firstTextBaseline) {
|
|
| 226 |
+ Text(profile.displayName) |
|
| 227 |
+ .font(.subheadline.weight(.semibold)) |
|
| 228 |
+ Spacer() |
|
| 229 |
+ Text(formatDuration(profile.effectiveTimeout)) |
|
| 230 |
+ .font(.subheadline.weight(.semibold)) |
|
| 231 |
+ .foregroundStyle(.secondary) |
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ HStack {
|
|
| 235 |
+ Text(profile.timeoutMode.capitalized) |
|
| 236 |
+ Spacer() |
|
| 237 |
+ Text("Success \(profile.successCount)")
|
|
| 238 |
+ Text("Timeouts \(profile.timeoutCount)")
|
|
| 239 |
+ } |
|
| 240 |
+ .font(.caption) |
|
| 241 |
+ .foregroundStyle(.secondary) |
|
| 242 |
+ |
|
| 243 |
+ HStack {
|
|
| 244 |
+ Text("Last success")
|
|
| 245 |
+ Spacer() |
|
| 246 |
+ Text(profile.lastSuccessfulElapsed > 0 ? formatDuration(profile.lastSuccessfulElapsed) : "none") |
|
| 247 |
+ } |
|
| 248 |
+ .font(.caption) |
|
| 249 |
+ .foregroundStyle(.secondary) |
|
| 250 |
+ |
|
| 251 |
+ HStack {
|
|
| 252 |
+ Text("Last timeout")
|
|
| 253 |
+ Spacer() |
|
| 254 |
+ Text(profile.lastTimeoutElapsed > 0 ? formatDuration(profile.lastTimeoutElapsed) : "none") |
|
| 255 |
+ } |
|
| 256 |
+ .font(.caption) |
|
| 257 |
+ .foregroundStyle(.secondary) |
|
| 258 |
+ |
|
| 259 |
+ HStack {
|
|
| 260 |
+ Text("p95 success")
|
|
| 261 |
+ Spacer() |
|
| 262 |
+ Text(profile.p95SuccessfulElapsedIfAvailable.map(formatDuration) ?? "collecting") |
|
| 263 |
+ } |
|
| 264 |
+ .font(.caption) |
|
| 265 |
+ .foregroundStyle(.secondary) |
|
| 266 |
+ |
|
| 267 |
+ if profile.isManuallyOverridden {
|
|
| 268 |
+ Stepper( |
|
| 269 |
+ "Manual \(formatDuration(profile.manualTimeoutValue))", |
|
| 270 |
+ value: $profile.manualTimeoutValue, |
|
| 271 |
+ in: MetricTimeoutProfile.defaultInitialTimeout...MetricTimeoutProfile.maximumTimeout, |
|
| 272 |
+ step: 5 |
|
| 273 |
+ ) |
|
| 274 |
+ .font(.caption) |
|
| 275 |
+ .onChange(of: profile.manualTimeoutValue) { _, newValue in
|
|
| 276 |
+ profile.setManualTimeout(newValue) |
|
| 277 |
+ } |
|
| 278 |
+ } |
|
| 279 |
+ |
|
| 280 |
+ HStack {
|
|
| 281 |
+ Button("Set Manual") {
|
|
| 282 |
+ profile.setManualTimeout(profile.effectiveTimeout) |
|
| 283 |
+ } |
|
| 284 |
+ .buttonStyle(.borderless) |
|
| 285 |
+ |
|
| 286 |
+ Button("Automatic") {
|
|
| 287 |
+ profile.returnToAutomatic() |
|
| 288 |
+ } |
|
| 289 |
+ .buttonStyle(.borderless) |
|
| 290 |
+ .disabled(!profile.isManuallyOverridden) |
|
| 291 |
+ |
|
| 292 |
+ Spacer() |
|
| 293 |
+ |
|
| 294 |
+ Button("Reset", role: .destructive) {
|
|
| 295 |
+ profile.resetLearning() |
|
| 296 |
+ } |
|
| 297 |
+ .buttonStyle(.borderless) |
|
| 298 |
+ } |
|
| 299 |
+ .font(.caption) |
|
| 300 |
+ } |
|
| 301 |
+ .padding(.vertical, 4) |
|
| 302 |
+ } |
|
| 303 |
+} |
|
| 304 |
+ |
|
| 170 | 305 |
private struct InfoRow<Content: View>: View {
|
| 171 | 306 |
let label: String |
| 172 | 307 |
@ViewBuilder let content: () -> Content |
@@ -183,8 +318,14 @@ private extension Bundle {
|
||
| 183 | 318 |
} |
| 184 | 319 |
} |
| 185 | 320 |
|
| 321 |
+private func formatDuration(_ seconds: TimeInterval) -> String {
|
|
| 322 |
+ if seconds <= 0 { return "none" }
|
|
| 323 |
+ if seconds < 60 { return String(format: "%.0fs", seconds) }
|
|
| 324 |
+ return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s" |
|
| 325 |
+} |
|
| 326 |
+ |
|
| 186 | 327 |
#Preview {
|
| 187 | 328 |
SettingsView() |
| 188 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self], inMemory: true) |
|
| 329 |
+ .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self, MetricTimeoutProfile.self], inMemory: true) |
|
| 189 | 330 |
.environment(AppSettings()) |
| 190 | 331 |
} |
@@ -118,18 +118,16 @@ struct SnapshotDetailView: View {
|
||
| 118 | 118 |
profile: profile |
| 119 | 119 |
) |
| 120 | 120 |
let timestamp = snapshot.timestamp |
| 121 |
- Task.detached(priority: .userInitiated) {
|
|
| 121 |
+ Task(priority: .userInitiated) {
|
|
| 122 | 122 |
let pdfData = SnapshotPDFExporter.generatePDF(from: reportData) |
| 123 | 123 |
let formatter = DateFormatter() |
| 124 | 124 |
formatter.dateFormat = "yyyy-MM-dd-HH-mm" |
| 125 | 125 |
let name = "HealthProbe-Snapshot-\(formatter.string(from: timestamp)).pdf" |
| 126 | 126 |
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) |
| 127 | 127 |
try? pdfData.write(to: url) |
| 128 |
- await MainActor.run {
|
|
| 129 |
- isExporting = false |
|
| 130 |
- pdfExportURL = url |
|
| 131 |
- showShareSheet = true |
|
| 132 |
- } |
|
| 128 |
+ isExporting = false |
|
| 129 |
+ pdfExportURL = url |
|
| 130 |
+ showShareSheet = true |
|
| 133 | 131 |
} |
| 134 | 132 |
} |
| 135 | 133 |
|
@@ -22,9 +22,9 @@ struct SnapshotsView: View {
|
||
| 22 | 22 |
} |
| 23 | 23 |
|
| 24 | 24 |
private var knownDevices: [DeviceEntry] {
|
| 25 |
- let currentID = UIDevice.current.identifierForVendor?.uuidString ?? "" |
|
| 25 |
+ let currentID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id |
|
| 26 | 26 |
var ids = Set(allSnapshots.map { $0.deviceID })
|
| 27 |
- if !currentID.isEmpty { ids.insert(currentID) }
|
|
| 27 |
+ ids.insert(currentID) |
|
| 28 | 28 |
return ids.map { id in
|
| 29 | 29 |
let profile = profileMap[id] |
| 30 | 30 |
let name: String |
@@ -33,7 +33,8 @@ struct SnapshotsView: View {
|
||
| 33 | 33 |
else { name = "Unknown Device" }
|
| 34 | 34 |
let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray |
| 35 | 35 |
return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID) |
| 36 |
- }.sorted {
|
|
| 36 |
+ } |
|
| 37 |
+ .sorted {
|
|
| 37 | 38 |
if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
|
| 38 | 39 |
return $0.displayName < $1.displayName |
| 39 | 40 |
} |