- Sticky unavailable state: if previous TypeCount was unauthorized and current returns complete+0, force it back to unauthorized (applyStickyUnavailableState) - First-detection ambiguous disappearance: user resolution UI with three options (Treat as Unauthorized / Treat as Deleted / Cancel) - Status bar shows 'X metrics unavailable' instead of generic 'Incomplete snapshot' - Confirm authorization change updates TypeCount.quality to .unauthorized in snapshot - Changes vs Previous excludes non-complete metrics (SnapshotDiffService) - Auto-save known-unauthorized partial snapshots without reconfirmation; sheet stays open for analysis (no auto-dismiss) - Progress feed shows 'Unavailable' (amber) for unauthorized metrics - Re-authorization analysis uses last snapshot with values as baseline, not the intermediate unauthorized snapshot (DeltaService, AnomalyDetector, HealthKitService pipeline) - AnomalyDetector relaxed quality gate: only current snapshot must be complete; allows detection when previous was partial due to deauth
@@ -18,12 +18,10 @@ enum AnomalyDetector {
|
||
| 18 | 18 |
currentTypeCounts: [String: TypeCount], |
| 19 | 19 |
previousTypeCounts: [String: TypeCount] |
| 20 | 20 |
) -> DetectionResult {
|
| 21 |
- // Quality gate — suppresses ALL detection if either snapshot is not complete. |
|
| 22 |
- // This also covers deletion anomalies and the first authorization after full deny: |
|
| 23 |
- // if previous.snapshotQuality == .unauthorized, detection returns [] immediately. |
|
| 24 |
- // No additional check is needed; the quality gate is the complete suppression mechanism. |
|
| 25 |
- guard previous.snapshotQuality == SnapshotQuality.complete, |
|
| 26 |
- current.snapshotQuality == SnapshotQuality.complete else {
|
|
| 21 |
+ // Quality gate: current snapshot must be complete. |
|
| 22 |
+ // Previous may be partial due known unavailable metrics; per-type quality guards |
|
| 23 |
+ // below still prevent analysis on impaired comparisons. |
|
| 24 |
+ guard current.snapshotQuality == SnapshotQuality.complete else {
|
|
| 27 | 25 |
return DetectionResult(records: []) |
| 28 | 26 |
} |
| 29 | 27 |
|
@@ -98,7 +96,7 @@ enum AnomalyDetector {
|
||
| 98 | 96 |
type: .deletion, |
| 99 | 97 |
severity: severity, |
| 100 | 98 |
typeID: typeID, |
| 101 |
- message: "Deletion detected for \(typeDelta.displayName): \(countDelta) records" |
|
| 99 |
+ message: "Records disappeared for \(typeDelta.displayName): \(countDelta) records. Verify Health read authorization first; if access was intentionally revoked, confirm that decision. Otherwise, treat this as possible data deletion." |
|
| 102 | 100 |
) |
| 103 | 101 |
records.append(record) |
| 104 | 102 |
} |
@@ -39,10 +39,19 @@ enum DeltaService {
|
||
| 39 | 39 |
for typeID in allTypeIDs {
|
| 40 | 40 |
let prev = prevByID[typeID] |
| 41 | 41 |
let curr = currByID[typeID] |
| 42 |
- let td = buildTypeDelta( |
|
| 42 |
+ |
|
| 43 |
+ let effectivePrev = historicalBaselinePreviousTypeCount( |
|
| 43 | 44 |
typeID: typeID, |
| 44 | 45 |
prev: prev, |
| 45 | 46 |
curr: curr, |
| 47 |
+ previousSnapshot: previous, |
|
| 48 |
+ context: context |
|
| 49 |
+ ) ?? prev |
|
| 50 |
+ |
|
| 51 |
+ let td = buildTypeDelta( |
|
| 52 |
+ typeID: typeID, |
|
| 53 |
+ prev: effectivePrev, |
|
| 54 |
+ curr: curr, |
|
| 46 | 55 |
previous: previous, |
| 47 | 56 |
current: current |
| 48 | 57 |
) |
@@ -174,6 +183,62 @@ enum DeltaService {
|
||
| 174 | 183 |
return td |
| 175 | 184 |
} |
| 176 | 185 |
|
| 186 |
+ private static func historicalBaselinePreviousTypeCount( |
|
| 187 |
+ typeID: String, |
|
| 188 |
+ prev: TypeCount?, |
|
| 189 |
+ curr: TypeCount?, |
|
| 190 |
+ previousSnapshot: HealthSnapshot, |
|
| 191 |
+ context: ModelContext |
|
| 192 |
+ ) -> TypeCount? {
|
|
| 193 |
+ guard let prev, |
|
| 194 |
+ let curr, |
|
| 195 |
+ prev.quality == .unauthorized, |
|
| 196 |
+ curr.quality == .complete, |
|
| 197 |
+ curr.count > 0 else {
|
|
| 198 |
+ return nil |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ return findLastCompleteValuedTypeCount( |
|
| 202 |
+ typeID: typeID, |
|
| 203 |
+ before: previousSnapshot, |
|
| 204 |
+ context: context |
|
| 205 |
+ ) |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ private static func findLastCompleteValuedTypeCount( |
|
| 209 |
+ typeID: String, |
|
| 210 |
+ before snapshot: HealthSnapshot, |
|
| 211 |
+ context: ModelContext |
|
| 212 |
+ ) -> TypeCount? {
|
|
| 213 |
+ var visited: Set<UUID> = [] |
|
| 214 |
+ var cursorID = snapshot.previousSnapshotID |
|
| 215 |
+ |
|
| 216 |
+ while let snapshotID = cursorID, !visited.contains(snapshotID) {
|
|
| 217 |
+ visited.insert(snapshotID) |
|
| 218 |
+ |
|
| 219 |
+ guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
|
|
| 220 |
+ break |
|
| 221 |
+ } |
|
| 222 |
+ |
|
| 223 |
+ if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
|
|
| 224 |
+ candidate.quality == .complete, |
|
| 225 |
+ candidate.count > 0 {
|
|
| 226 |
+ return candidate |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 229 |
+ cursorID = historicalSnapshot.previousSnapshotID |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ return nil |
|
| 233 |
+ } |
|
| 234 |
+ |
|
| 235 |
+ private static func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
|
|
| 236 |
+ let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 237 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == id }
|
|
| 238 |
+ ) |
|
| 239 |
+ return try? context.fetch(descriptor).first |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 177 | 242 |
private static func assignReason( |
| 178 | 243 |
prevQuality: SnapshotQuality?, |
| 179 | 244 |
currQuality: SnapshotQuality?, |
@@ -76,6 +76,7 @@ final class HealthKitService {
|
||
| 76 | 76 |
triggerReason: String = "manual", |
| 77 | 77 |
retryOfSnapshotID: UUID? = nil, |
| 78 | 78 |
timeoutMultiplier: Double = 1, |
| 79 |
+ reviewAmbiguousCompleteDisappearedTypes: Bool = false, |
|
| 79 | 80 |
progress: SnapshotFetchProgress? = nil |
| 80 | 81 |
) async throws -> HealthSnapshot {
|
| 81 | 82 |
let active = Self.allTypes |
@@ -109,6 +110,12 @@ final class HealthKitService {
|
||
| 109 | 110 |
} |
| 110 | 111 |
snapshot.typeCounts = typeCounts |
| 111 | 112 |
|
| 113 |
+ applyStickyUnavailableState( |
|
| 114 |
+ snapshot: snapshot, |
|
| 115 |
+ typeCounts: typeCounts, |
|
| 116 |
+ context: context |
|
| 117 |
+ ) |
|
| 118 |
+ |
|
| 112 | 119 |
// Invariant assertions before save — debug asserts + release silent correction |
| 113 | 120 |
for tc in typeCounts {
|
| 114 | 121 |
let isComplete = tc.quality == SnapshotQuality.complete |
@@ -135,6 +142,12 @@ final class HealthKitService {
|
||
| 135 | 142 |
context: context |
| 136 | 143 |
) |
| 137 | 144 |
|
| 145 |
+ if snapshot.snapshotQuality == .complete, |
|
| 146 |
+ reviewAmbiguousCompleteDisappearedTypes, |
|
| 147 |
+ hasAmbiguousCompleteDisappearance(snapshot: snapshot, typeCounts: typeCounts, context: context) {
|
|
| 148 |
+ return snapshot |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 138 | 151 |
if snapshot.snapshotQuality == .complete {
|
| 139 | 152 |
try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context) |
| 140 | 153 |
} |
@@ -159,6 +172,24 @@ final class HealthKitService {
|
||
| 159 | 172 |
return snapshot |
| 160 | 173 |
} |
| 161 | 174 |
|
| 175 |
+ @MainActor |
|
| 176 |
+ func saveReviewedCompleteSnapshot(_ snapshot: HealthSnapshot, in context: ModelContext) async throws -> HealthSnapshot {
|
|
| 177 |
+ let typeCounts = snapshot.typeCounts ?? [] |
|
| 178 |
+ snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts) |
|
| 179 |
+ guard snapshot.snapshotQuality == .complete else {
|
|
| 180 |
+ return try await savePartialSnapshot(snapshot, in: context) |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ configureSnapshotMetadata( |
|
| 184 |
+ snapshot, |
|
| 185 |
+ typeCounts: typeCounts, |
|
| 186 |
+ intendedTypeIDs: typeCounts.map(\.typeIdentifier), |
|
| 187 |
+ context: context |
|
| 188 |
+ ) |
|
| 189 |
+ try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context) |
|
| 190 |
+ return snapshot |
|
| 191 |
+ } |
|
| 192 |
+ |
|
| 162 | 193 |
// MARK: - Snapshot persistence |
| 163 | 194 |
|
| 164 | 195 |
private func persistSnapshot( |
@@ -231,6 +262,62 @@ final class HealthKitService {
|
||
| 231 | 262 |
snapshot.appBuildVersion = appBuildVersion() |
| 232 | 263 |
} |
| 233 | 264 |
|
| 265 |
+ private func applyStickyUnavailableState( |
|
| 266 |
+ snapshot: HealthSnapshot, |
|
| 267 |
+ typeCounts: [TypeCount], |
|
| 268 |
+ context: ModelContext |
|
| 269 |
+ ) {
|
|
| 270 |
+ guard let previous = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context) else {
|
|
| 271 |
+ return |
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 274 |
+ let previousByType = Dictionary( |
|
| 275 |
+ uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 276 |
+ ) |
|
| 277 |
+ |
|
| 278 |
+ for current in typeCounts {
|
|
| 279 |
+ guard current.quality == .complete, |
|
| 280 |
+ current.count == 0, |
|
| 281 |
+ let previousType = previousByType[current.typeIdentifier], |
|
| 282 |
+ previousType.quality == .unauthorized else {
|
|
| 283 |
+ continue |
|
| 284 |
+ } |
|
| 285 |
+ |
|
| 286 |
+ current.count = -1 |
|
| 287 |
+ current.contentHash = "" |
|
| 288 |
+ current.earliestDate = nil |
|
| 289 |
+ current.latestDate = nil |
|
| 290 |
+ current.quality = .unauthorized |
|
| 291 |
+ current.yearlyCounts?.removeAll() |
|
| 292 |
+ } |
|
| 293 |
+ } |
|
| 294 |
+ |
|
| 295 |
+ private func hasAmbiguousCompleteDisappearance( |
|
| 296 |
+ snapshot: HealthSnapshot, |
|
| 297 |
+ typeCounts: [TypeCount], |
|
| 298 |
+ context: ModelContext |
|
| 299 |
+ ) -> Bool {
|
|
| 300 |
+ guard let previousID = snapshot.previousSnapshotID, |
|
| 301 |
+ let previous = fetchSnapshot(id: previousID, context: context) else {
|
|
| 302 |
+ return false |
|
| 303 |
+ } |
|
| 304 |
+ |
|
| 305 |
+ let previousByType = Dictionary( |
|
| 306 |
+ uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 307 |
+ ) |
|
| 308 |
+ |
|
| 309 |
+ return typeCounts.contains { current in
|
|
| 310 |
+ guard current.quality == .complete, |
|
| 311 |
+ current.count == 0, |
|
| 312 |
+ let previous = previousByType[current.typeIdentifier], |
|
| 313 |
+ previous.quality == .complete, |
|
| 314 |
+ previous.count > 0 else {
|
|
| 315 |
+ return false |
|
| 316 |
+ } |
|
| 317 |
+ return true |
|
| 318 |
+ } |
|
| 319 |
+ } |
|
| 320 |
+ |
|
| 234 | 321 |
// MARK: - Post-save pipeline |
| 235 | 322 |
|
| 236 | 323 |
private func runPostSavePipeline( |
@@ -249,10 +336,26 @@ final class HealthKitService {
|
||
| 249 | 336 |
|
| 250 | 337 |
// Build type count maps for AnomalyDetector (never access relationship properties directly) |
| 251 | 338 |
let currentTypeCounts = Dictionary(uniqueKeysWithValues: typeCounts.map { ($0.typeIdentifier, $0) })
|
| 252 |
- let previousTypeCounts = Dictionary( |
|
| 339 |
+ var previousTypeCounts = Dictionary( |
|
| 253 | 340 |
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
| 254 | 341 |
) |
| 255 | 342 |
|
| 343 |
+ for (typeID, currentType) in currentTypeCounts {
|
|
| 344 |
+ guard currentType.quality == .complete, |
|
| 345 |
+ currentType.count > 0, |
|
| 346 |
+ let immediatePreviousType = previousTypeCounts[typeID], |
|
| 347 |
+ immediatePreviousType.quality == .unauthorized, |
|
| 348 |
+ let historicalBaseline = findLastCompleteValuedTypeCount( |
|
| 349 |
+ typeID: typeID, |
|
| 350 |
+ before: previous, |
|
| 351 |
+ context: context |
|
| 352 |
+ ) else {
|
|
| 353 |
+ continue |
|
| 354 |
+ } |
|
| 355 |
+ |
|
| 356 |
+ previousTypeCounts[typeID] = historicalBaseline |
|
| 357 |
+ } |
|
| 358 |
+ |
|
| 256 | 359 |
let detection = AnomalyDetector.detect( |
| 257 | 360 |
delta: delta, |
| 258 | 361 |
current: snapshot, |
@@ -758,6 +861,40 @@ final class HealthKitService {
|
||
| 758 | 861 |
return try? context.fetch(descriptor).first |
| 759 | 862 |
} |
| 760 | 863 |
|
| 864 |
+ private func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
|
|
| 865 |
+ let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 866 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == id }
|
|
| 867 |
+ ) |
|
| 868 |
+ return try? context.fetch(descriptor).first |
|
| 869 |
+ } |
|
| 870 |
+ |
|
| 871 |
+ private func findLastCompleteValuedTypeCount( |
|
| 872 |
+ typeID: String, |
|
| 873 |
+ before snapshot: HealthSnapshot, |
|
| 874 |
+ context: ModelContext |
|
| 875 |
+ ) -> TypeCount? {
|
|
| 876 |
+ var visited: Set<UUID> = [] |
|
| 877 |
+ var cursorID = snapshot.previousSnapshotID |
|
| 878 |
+ |
|
| 879 |
+ while let snapshotID = cursorID, !visited.contains(snapshotID) {
|
|
| 880 |
+ visited.insert(snapshotID) |
|
| 881 |
+ |
|
| 882 |
+ guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
|
|
| 883 |
+ break |
|
| 884 |
+ } |
|
| 885 |
+ |
|
| 886 |
+ if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
|
|
| 887 |
+ candidate.quality == .complete, |
|
| 888 |
+ candidate.count > 0 {
|
|
| 889 |
+ return candidate |
|
| 890 |
+ } |
|
| 891 |
+ |
|
| 892 |
+ cursorID = historicalSnapshot.previousSnapshotID |
|
| 893 |
+ } |
|
| 894 |
+ |
|
| 895 |
+ return nil |
|
| 896 |
+ } |
|
| 897 |
+ |
|
| 761 | 898 |
private func isStoreEmpty(context: ModelContext) -> Bool {
|
| 762 | 899 |
let descriptor = FetchDescriptor<HealthSnapshot>() |
| 763 | 900 |
return (try? context.fetch(descriptor).isEmpty) ?? true |
@@ -44,9 +44,19 @@ final class SnapshotDiffService {
|
||
| 44 | 44 |
} |
| 45 | 45 |
|
| 46 | 46 |
func totalAbsoluteChange(current: HealthSnapshot, baseline: HealthSnapshot) -> Int {
|
| 47 |
- diff(current: current, baseline: baseline) |
|
| 48 |
- .filter { $0.previousTracked }
|
|
| 49 |
- .reduce(0) { $0 + abs($1.delta) }
|
|
| 47 |
+ let baselineMap = Dictionary( |
|
| 48 |
+ uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 49 |
+ ) |
|
| 50 |
+ |
|
| 51 |
+ return (current.typeCounts ?? []).reduce(0) { partial, currentType in
|
|
| 52 |
+ guard currentType.quality == .complete, |
|
| 53 |
+ let previousType = baselineMap[currentType.typeIdentifier], |
|
| 54 |
+ previousType.quality == .complete else {
|
|
| 55 |
+ return partial |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ return partial + abs(currentType.count - previousType.count) |
|
| 59 |
+ } |
|
| 50 | 60 |
} |
| 51 | 61 |
|
| 52 | 62 |
func apply(filter: DiffFilter, to diffs: [TypeDiff]) -> [TypeDiff] {
|
@@ -218,6 +218,14 @@ final class SnapshotFetchProgress {
|
||
| 218 | 218 |
types[index].successCount = successCount |
| 219 | 219 |
} |
| 220 | 220 |
|
| 221 |
+ func markUnavailable(_ id: String) {
|
|
| 222 |
+ let index = visibleTypeIndex(for: id) |
|
| 223 |
+ types[index].status = .failed("Not authorized")
|
|
| 224 |
+ types[index].quality = SnapshotQuality.unauthorized.rawValue |
|
| 225 |
+ types[index].recordCount = -1 |
|
| 226 |
+ types[index].authorizationStatus = "unavailable" |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 221 | 229 |
private func visibleTypeIndex(for id: String) -> Int {
|
| 222 | 230 |
if let index = types.firstIndex(where: { $0.id == id }) {
|
| 223 | 231 |
return index |
@@ -22,10 +22,12 @@ final class DashboardViewModel {
|
||
| 22 | 22 |
var completedSnapshotDeviceID: String? = nil |
| 23 | 23 |
var completedSnapshotTriggerReason: String? = nil |
| 24 | 24 |
var completedSnapshotRetryOfSnapshotID: UUID? = nil |
| 25 |
+ var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = [] |
|
| 25 | 26 |
|
| 26 | 27 |
private let healthKit = HealthKitService.shared |
| 27 | 28 |
private let diffService = SnapshotDiffService.shared |
| 28 | 29 |
private var pendingPartialSnapshot: HealthSnapshot? |
| 30 |
+ private var pendingAmbiguousSnapshot: HealthSnapshot? |
|
| 29 | 31 |
|
| 30 | 32 |
func requestAuthorization() async {
|
| 31 | 33 |
isRequestingAuth = true |
@@ -64,6 +66,8 @@ final class DashboardViewModel {
|
||
| 64 | 66 |
completedSnapshotTriggerReason = nil |
| 65 | 67 |
completedSnapshotRetryOfSnapshotID = nil |
| 66 | 68 |
pendingPartialSnapshot = nil |
| 69 |
+ pendingAmbiguousSnapshot = nil |
|
| 70 |
+ ambiguousDisappearedMetrics = [] |
|
| 67 | 71 |
snapshotProgressMessage = "" |
| 68 | 72 |
snapshotProgressDetail = "" |
| 69 | 73 |
canRetryWithPermissions = false |
@@ -92,6 +96,7 @@ final class DashboardViewModel {
|
||
| 92 | 96 |
triggerReason: triggerReason, |
| 93 | 97 |
retryOfSnapshotID: retryOfSnapshotID, |
| 94 | 98 |
timeoutMultiplier: timeoutMultiplier, |
| 99 |
+ reviewAmbiguousCompleteDisappearedTypes: triggerReason == "manual", |
|
| 95 | 100 |
progress: self.fetchProgress |
| 96 | 101 |
) |
| 97 | 102 |
} |
@@ -110,6 +115,8 @@ final class DashboardViewModel {
|
||
| 110 | 115 |
monitoredRegistryVersion: snapshot.monitoredRegistryVersion |
| 111 | 116 |
) |
| 112 | 117 |
|
| 118 |
+ reflectUnavailableMetricsInProgress(snapshot: snapshot) |
|
| 119 |
+ |
|
| 113 | 120 |
if snapshot.snapshotQuality != SnapshotQuality.complete {
|
| 114 | 121 |
pendingPartialSnapshot = snapshot |
| 115 | 122 |
|
@@ -124,6 +131,13 @@ final class DashboardViewModel {
|
||
| 124 | 131 |
snapshotProgressMessage = "Incomplete snapshot" |
| 125 | 132 |
permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore |
| 126 | 133 |
|
| 134 |
+ if shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot, failedCount: failedCount, context: context) {
|
|
| 135 |
+ snapshotProgressMessage = "Known unavailable metrics" |
|
| 136 |
+ snapshotProgressDetail = "Snapshot was auto-saved as partial because unavailable metrics were already confirmed in the previous snapshot." |
|
| 137 |
+ await savePartialSnapshot(context: context, keepSheetOpenForReview: true) |
|
| 138 |
+ return |
|
| 139 |
+ } |
|
| 140 |
+ |
|
| 127 | 141 |
if unauthorizedCount > 0 {
|
| 128 | 142 |
snapshotProgressMessage = "Missing permissions (\(unauthorizedCount) type\(unauthorizedCount == 1 ? "" : "s"))" |
| 129 | 143 |
let typesList = unauthorizedTypes.map { $0.displayName }.joined(separator: ", ")
|
@@ -157,6 +171,17 @@ final class DashboardViewModel {
|
||
| 157 | 171 |
return |
| 158 | 172 |
} |
| 159 | 173 |
|
| 174 |
+ let ambiguousMetrics = findAmbiguousDisappearedMetrics(snapshot: snapshot, context: context) |
|
| 175 |
+ if !ambiguousMetrics.isEmpty {
|
|
| 176 |
+ pendingAmbiguousSnapshot = snapshot |
|
| 177 |
+ ambiguousDisappearedMetrics = ambiguousMetrics |
|
| 178 |
+ snapshotProgress = .requiresResolution |
|
| 179 |
+ snapshotProgressMessage = "Metric access needs review" |
|
| 180 |
+ snapshotProgressDetail = "One or more metrics returned zero samples after previously having data. Choose how HealthProbe should classify this before saving the snapshot." |
|
| 181 |
+ showProgressSheet = true |
|
| 182 |
+ return |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 160 | 185 |
let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
| 161 | 186 |
let exists = allSnapshots.contains { $0.id == snapshot.id }
|
| 162 | 187 |
|
@@ -180,6 +205,42 @@ final class DashboardViewModel {
|
||
| 180 | 205 |
} |
| 181 | 206 |
} |
| 182 | 207 |
|
| 208 |
+ func treatAmbiguousMetricsAsUnauthorized(context: ModelContext) async {
|
|
| 209 |
+ guard let snapshot = pendingAmbiguousSnapshot else { return }
|
|
| 210 |
+ let ambiguousIDs = Set(ambiguousDisappearedMetrics.map(\.id)) |
|
| 211 |
+ let typeCounts = snapshot.typeCounts ?? [] |
|
| 212 |
+ |
|
| 213 |
+ for typeCount in typeCounts where ambiguousIDs.contains(typeCount.typeIdentifier) {
|
|
| 214 |
+ typeCount.count = -1 |
|
| 215 |
+ typeCount.contentHash = "" |
|
| 216 |
+ typeCount.earliestDate = nil |
|
| 217 |
+ typeCount.latestDate = nil |
|
| 218 |
+ typeCount.quality = .unauthorized |
|
| 219 |
+ typeCount.yearlyCounts?.removeAll() |
|
| 220 |
+ } |
|
| 221 |
+ snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: typeCounts) |
|
| 222 |
+ |
|
| 223 |
+ do {
|
|
| 224 |
+ let saved = try await healthKit.savePartialSnapshot(snapshot, in: context) |
|
| 225 |
+ finishSavedReviewedSnapshot(saved) |
|
| 226 |
+ } catch {
|
|
| 227 |
+ snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)" |
|
| 228 |
+ showProgressSheet = true |
|
| 229 |
+ } |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ func treatAmbiguousMetricsAsDeleted(context: ModelContext) async {
|
|
| 233 |
+ guard let snapshot = pendingAmbiguousSnapshot else { return }
|
|
| 234 |
+ |
|
| 235 |
+ do {
|
|
| 236 |
+ let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: context) |
|
| 237 |
+ finishSavedReviewedSnapshot(saved) |
|
| 238 |
+ } catch {
|
|
| 239 |
+ snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)" |
|
| 240 |
+ showProgressSheet = true |
|
| 241 |
+ } |
|
| 242 |
+ } |
|
| 243 |
+ |
|
| 183 | 244 |
func retryWithPermissions(context: ModelContext, selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
|
| 184 | 245 |
isRequestingAuth = true |
| 185 | 246 |
defer { isRequestingAuth = false }
|
@@ -214,17 +275,23 @@ final class DashboardViewModel {
|
||
| 214 | 275 |
) |
| 215 | 276 |
} |
| 216 | 277 |
|
| 217 |
- func savePartialSnapshot(context: ModelContext) async {
|
|
| 278 |
+ func savePartialSnapshot(context: ModelContext, keepSheetOpenForReview: Bool = false) async {
|
|
| 218 | 279 |
guard let snapshot = pendingPartialSnapshot else {
|
| 219 |
- fetchProgress = nil |
|
| 220 |
- showProgressSheet = false |
|
| 221 |
- snapshotProgress = .idle |
|
| 280 |
+ if !keepSheetOpenForReview {
|
|
| 281 |
+ fetchProgress = nil |
|
| 282 |
+ showProgressSheet = false |
|
| 283 |
+ snapshotProgress = .idle |
|
| 284 |
+ } |
|
| 222 | 285 |
return |
| 223 | 286 |
} |
| 224 | 287 |
|
| 225 | 288 |
do {
|
| 226 | 289 |
let saved = try await healthKit.savePartialSnapshot(snapshot, in: context) |
| 227 | 290 |
completedSnapshotID = saved.id |
| 291 |
+ completedSnapshotTimestamp = saved.timestamp |
|
| 292 |
+ completedSnapshotDeviceID = saved.deviceID |
|
| 293 |
+ completedSnapshotTriggerReason = saved.triggerReason |
|
| 294 |
+ completedSnapshotRetryOfSnapshotID = saved.retryOfSnapshotID |
|
| 228 | 295 |
pendingPartialSnapshot = nil |
| 229 | 296 |
fetchProgress?.updateChainContext( |
| 230 | 297 |
previousSnapshotID: saved.previousSnapshotID, |
@@ -239,6 +306,12 @@ final class DashboardViewModel {
|
||
| 239 | 306 |
return |
| 240 | 307 |
} |
| 241 | 308 |
|
| 309 |
+ if keepSheetOpenForReview {
|
|
| 310 |
+ snapshotProgress = .incomplete |
|
| 311 |
+ showProgressSheet = true |
|
| 312 |
+ return |
|
| 313 |
+ } |
|
| 314 |
+ |
|
| 242 | 315 |
fetchProgress = nil |
| 243 | 316 |
showProgressSheet = false |
| 244 | 317 |
snapshotProgress = .idle |
@@ -246,6 +319,8 @@ final class DashboardViewModel {
|
||
| 246 | 319 |
|
| 247 | 320 |
func discardSnapshot(context: ModelContext) async {
|
| 248 | 321 |
pendingPartialSnapshot = nil |
| 322 |
+ pendingAmbiguousSnapshot = nil |
|
| 323 |
+ ambiguousDisappearedMetrics = [] |
|
| 249 | 324 |
if let snapshotID = completedSnapshotID {
|
| 250 | 325 |
do {
|
| 251 | 326 |
let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
@@ -276,6 +351,90 @@ final class DashboardViewModel {
|
||
| 276 | 351 |
return result |
| 277 | 352 |
} |
| 278 | 353 |
} |
| 354 |
+ |
|
| 355 |
+ private func findAmbiguousDisappearedMetrics(snapshot: HealthSnapshot, context: ModelContext) -> [AmbiguousDisappearedMetric] {
|
|
| 356 |
+ guard let previousID = snapshot.previousSnapshotID else { return [] }
|
|
| 357 |
+ let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 358 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
|
|
| 359 |
+ ) |
|
| 360 |
+ guard let previous = try? context.fetch(descriptor).first else { return [] }
|
|
| 361 |
+ |
|
| 362 |
+ let previousByType = Dictionary( |
|
| 363 |
+ uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 364 |
+ ) |
|
| 365 |
+ |
|
| 366 |
+ return (snapshot.typeCounts ?? []).compactMap { current in
|
|
| 367 |
+ guard current.quality == .complete, |
|
| 368 |
+ current.count == 0, |
|
| 369 |
+ let previous = previousByType[current.typeIdentifier], |
|
| 370 |
+ previous.quality == .complete, |
|
| 371 |
+ previous.count > 0 else {
|
|
| 372 |
+ return nil |
|
| 373 |
+ } |
|
| 374 |
+ return AmbiguousDisappearedMetric( |
|
| 375 |
+ id: current.typeIdentifier, |
|
| 376 |
+ displayName: current.displayName, |
|
| 377 |
+ previousCount: previous.count |
|
| 378 |
+ ) |
|
| 379 |
+ }.sorted { $0.displayName < $1.displayName }
|
|
| 380 |
+ } |
|
| 381 |
+ |
|
| 382 |
+ private func reflectUnavailableMetricsInProgress(snapshot: HealthSnapshot) {
|
|
| 383 |
+ guard let fetchProgress else { return }
|
|
| 384 |
+ for typeCount in snapshot.typeCounts ?? [] where typeCount.quality == .unauthorized {
|
|
| 385 |
+ fetchProgress.markUnavailable(typeCount.typeIdentifier) |
|
| 386 |
+ } |
|
| 387 |
+ } |
|
| 388 |
+ |
|
| 389 |
+ private func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot, failedCount: Int, context: ModelContext) -> Bool {
|
|
| 390 |
+ guard failedCount == 0, |
|
| 391 |
+ let previousID = snapshot.previousSnapshotID else {
|
|
| 392 |
+ return false |
|
| 393 |
+ } |
|
| 394 |
+ |
|
| 395 |
+ let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
|
|
| 396 |
+ guard !currentUnauthorized.isEmpty else { return false }
|
|
| 397 |
+ |
|
| 398 |
+ let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 399 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
|
|
| 400 |
+ ) |
|
| 401 |
+ guard let previous = try? context.fetch(descriptor).first else {
|
|
| 402 |
+ return false |
|
| 403 |
+ } |
|
| 404 |
+ |
|
| 405 |
+ let previousByType = Dictionary( |
|
| 406 |
+ uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 407 |
+ ) |
|
| 408 |
+ |
|
| 409 |
+ return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
|
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ private func finishSavedReviewedSnapshot(_ snapshot: HealthSnapshot) {
|
|
| 413 |
+ completedSnapshotID = snapshot.id |
|
| 414 |
+ completedSnapshotTimestamp = snapshot.timestamp |
|
| 415 |
+ completedSnapshotDeviceID = snapshot.deviceID |
|
| 416 |
+ completedSnapshotTriggerReason = snapshot.triggerReason |
|
| 417 |
+ completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID |
|
| 418 |
+ pendingAmbiguousSnapshot = nil |
|
| 419 |
+ pendingPartialSnapshot = nil |
|
| 420 |
+ ambiguousDisappearedMetrics = [] |
|
| 421 |
+ fetchProgress?.updateChainContext( |
|
| 422 |
+ previousSnapshotID: snapshot.previousSnapshotID, |
|
| 423 |
+ isChainStart: snapshot.isChainStart, |
|
| 424 |
+ snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []), |
|
| 425 |
+ monitoredTypeSetHash: snapshot.monitoredTypeSetHash, |
|
| 426 |
+ monitoredRegistryVersion: snapshot.monitoredRegistryVersion |
|
| 427 |
+ ) |
|
| 428 |
+ fetchProgress = nil |
|
| 429 |
+ showProgressSheet = false |
|
| 430 |
+ snapshotProgress = .idle |
|
| 431 |
+ } |
|
| 432 |
+} |
|
| 433 |
+ |
|
| 434 |
+struct AmbiguousDisappearedMetric: Identifiable, Equatable {
|
|
| 435 |
+ let id: String |
|
| 436 |
+ let displayName: String |
|
| 437 |
+ let previousCount: Int |
|
| 279 | 438 |
} |
| 280 | 439 |
|
| 281 | 440 |
enum SnapshotProgress {
|
@@ -284,6 +443,7 @@ enum SnapshotProgress {
|
||
| 284 | 443 |
case processing |
| 285 | 444 |
case complete |
| 286 | 445 |
case incomplete |
| 446 |
+ case requiresResolution |
|
| 287 | 447 |
|
| 288 | 448 |
var message: String {
|
| 289 | 449 |
switch self {
|
@@ -292,12 +452,13 @@ enum SnapshotProgress {
|
||
| 292 | 452 |
case .processing: return "Processing snapshot..." |
| 293 | 453 |
case .complete: return "Snapshot created successfully!" |
| 294 | 454 |
case .incomplete: return "Snapshot created with issues" |
| 455 |
+ case .requiresResolution: return "Snapshot needs review" |
|
| 295 | 456 |
} |
| 296 | 457 |
} |
| 297 | 458 |
|
| 298 | 459 |
var isError: Bool {
|
| 299 | 460 |
switch self {
|
| 300 |
- case .incomplete: return true |
|
| 461 |
+ case .incomplete, .requiresResolution: return true |
|
| 301 | 462 |
default: return false |
| 302 | 463 |
} |
| 303 | 464 |
} |
@@ -35,6 +35,11 @@ struct DashboardView: View {
|
||
| 35 | 35 |
return latest.deviceName |
| 36 | 36 |
} |
| 37 | 37 |
|
| 38 |
+ private var latestUnavailableMetricCount: Int {
|
|
| 39 |
+ guard let latest else { return 0 }
|
|
| 40 |
+ return (latest.typeCounts ?? []).filter { $0.quality == .unauthorized }.count
|
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 38 | 43 |
var body: some View {
|
| 39 | 44 |
NavigationStack {
|
| 40 | 45 |
List {
|
@@ -110,6 +115,7 @@ struct DashboardView: View {
|
||
| 110 | 115 |
private func qualityLabel(progress: SnapshotFetchProgress) -> String {
|
| 111 | 116 |
switch viewModel.snapshotProgress {
|
| 112 | 117 |
case .complete: return "Complete" |
| 118 |
+ case .requiresResolution: return "Needs decision" |
|
| 113 | 119 |
case .incomplete: |
| 114 | 120 |
let hasUnauthorized = failedTypesList(in: progress).contains {
|
| 115 | 121 |
if case .failed(let r) = $0.status { return r == "Not authorized" }
|
@@ -123,6 +129,7 @@ struct DashboardView: View {
|
||
| 123 | 129 |
private func qualityColor(progress: SnapshotFetchProgress) -> Color {
|
| 124 | 130 |
switch viewModel.snapshotProgress {
|
| 125 | 131 |
case .complete: return .healthyGreen |
| 132 |
+ case .requiresResolution: return .warningAmber |
|
| 126 | 133 |
case .incomplete: return .warningAmber |
| 127 | 134 |
default: return .neutralGray |
| 128 | 135 |
} |
@@ -142,13 +149,21 @@ struct DashboardView: View {
|
||
| 142 | 149 |
} |
| 143 | 150 |
} |
| 144 | 151 |
|
| 145 |
-private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
|
| 152 |
+ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
|
| 146 | 153 |
let failed = failedTypesList(in: progress) |
| 147 | 154 |
var items: [String] = [] |
| 155 |
+ let unauthorized = failed.filter {
|
|
| 156 |
+ if case .failed(let r) = $0.status { return r == "Not authorized" }
|
|
| 157 |
+ return false |
|
| 158 |
+ } |
|
| 148 | 159 |
let timedOut = failed.filter {
|
| 149 | 160 |
if case .failed(let r) = $0.status { return r == "Timeout" }
|
| 150 | 161 |
return false |
| 151 | 162 |
} |
| 163 |
+ if !unauthorized.isEmpty {
|
|
| 164 |
+ items.append("Open iOS Settings, choose HealthProbe, and re-enable Health read access for the listed metrics.")
|
|
| 165 |
+ items.append("After restoring access, create a new snapshot before relying on checksum or anomaly results.")
|
|
| 166 |
+ } |
|
| 152 | 167 |
if !timedOut.isEmpty {
|
| 153 | 168 |
items.append("Timeout reflects HealthProbe's configured limit, not missing Health data or permission.")
|
| 154 | 169 |
} |
@@ -158,6 +173,21 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 158 | 173 |
return items |
| 159 | 174 |
} |
| 160 | 175 |
|
| 176 |
+ private func ambiguousDisappearanceNotes() -> [String] {
|
|
| 177 |
+ [ |
|
| 178 |
+ "All values disappeared for the listed metric. Apple API does not allow HealthProbe to verify Health read authorization status.", |
|
| 179 |
+ "Possible causes: the metric data was deleted, or Health read authorization was withdrawn." |
|
| 180 |
+ ] |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ private func ambiguousDisappearanceActions() -> [String] {
|
|
| 184 |
+ [ |
|
| 185 |
+ "Treat the metric as unauthorized.", |
|
| 186 |
+ "Treat the metric as totally deleted.", |
|
| 187 |
+ "Cancel saving this snapshot." |
|
| 188 |
+ ] |
|
| 189 |
+ } |
|
| 190 |
+ |
|
| 161 | 191 |
private func iso8601String(_ date: Date?) -> String {
|
| 162 | 192 |
guard let date else { return "none" }
|
| 163 | 193 |
return ISO8601DateFormatter().string(from: date) |
@@ -174,6 +204,8 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 174 | 204 |
return "complete_success" |
| 175 | 205 |
case .incomplete: |
| 176 | 206 |
return "partial_success" |
| 207 |
+ case .requiresResolution: |
|
| 208 |
+ return "requires_user_resolution" |
|
| 177 | 209 |
case .idle, .fetching, .processing: |
| 178 | 210 |
return "failed" |
| 179 | 211 |
} |
@@ -244,6 +276,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 244 | 276 |
switch viewModel.snapshotProgress {
|
| 245 | 277 |
case .complete: quality = "complete" |
| 246 | 278 |
case .incomplete: quality = "partial" |
| 279 |
+ case .requiresResolution: quality = "requires_resolution" |
|
| 247 | 280 |
default: quality = "unknown" |
| 248 | 281 |
} |
| 249 | 282 |
let duration = viewModel.fetchDurationSeconds.map { formatDuration($0) } ?? "unknown"
|
@@ -253,6 +286,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 253 | 286 |
let tsStr = fmt.string(from: timestamp) |
| 254 | 287 |
let degraded = degradedTypesList(in: progress) |
| 255 | 288 |
let isPartialSnapshot = viewModel.snapshotProgress == .incomplete |
| 289 |
+ let requiresResolution = viewModel.snapshotProgress == .requiresResolution |
|
| 256 | 290 |
let failedLines: String |
| 257 | 291 |
if degraded.isEmpty {
|
| 258 | 292 |
failedLines = "FAILED/DEGRADED METRICS: none" |
@@ -287,8 +321,10 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 287 | 321 |
lines.append("")
|
| 288 | 322 |
lines.append("INTERPRETATION_HINTS")
|
| 289 | 323 |
lines.append("Partial snapshot: \(isPartialSnapshot ? "true" : "false")")
|
| 324 |
+ lines.append("Requires user resolution: \(requiresResolution ? "true" : "false")")
|
|
| 290 | 325 |
lines.append("Failed metrics are excluded from checksum/anomaly detection")
|
| 291 | 326 |
lines.append("Do not infer deletion from partial snapshots")
|
| 327 |
+ lines.append("All-values-disappeared metrics require classification before saving")
|
|
| 292 | 328 |
lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
|
| 293 | 329 |
lines.append("")
|
| 294 | 330 |
lines.append("CONFIGURATION")
|
@@ -310,6 +346,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 310 | 346 |
lines.append("")
|
| 311 | 347 |
lines.append(failedLines) |
| 312 | 348 |
|
| 349 |
+ if requiresResolution {
|
|
| 350 |
+ lines.append("")
|
|
| 351 |
+ lines.append("AMBIGUOUS TOTAL DISAPPEARANCE")
|
|
| 352 |
+ for metric in viewModel.ambiguousDisappearedMetrics {
|
|
| 353 |
+ lines.append(" \(metric.displayName): previousCount=\(metric.previousCount), currentCount=0")
|
|
| 354 |
+ } |
|
| 355 |
+ lines.append(" note: Apple API does not expose read authorization status for this decision point")
|
|
| 356 |
+ } |
|
| 357 |
+ |
|
| 313 | 358 |
lines.append("")
|
| 314 | 359 |
lines.append("HEALTHKIT API RESULTS")
|
| 315 | 360 |
let orderedTypes = progress.types.sorted(by: { $0.displayName < $1.displayName })
|
@@ -423,7 +468,8 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 423 | 468 |
case .pending: return .gray |
| 424 | 469 |
case .fetching: return .blue |
| 425 | 470 |
case .complete: return .healthyGreen |
| 426 |
- case .failed: return .criticalRed |
|
| 471 |
+ case .failed(let reason): |
|
| 472 |
+ return reason == "Not authorized" ? .warningAmber : .criticalRed |
|
| 427 | 473 |
} |
| 428 | 474 |
} |
| 429 | 475 |
|
@@ -447,7 +493,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 447 | 493 |
|
| 448 | 494 |
private var shouldShowFetchProgress: Bool {
|
| 449 | 495 |
switch viewModel.snapshotProgress {
|
| 450 |
- case .fetching, .complete, .incomplete: |
|
| 496 |
+ case .fetching, .complete, .incomplete, .requiresResolution: |
|
| 451 | 497 |
return viewModel.fetchProgress != nil |
| 452 | 498 |
case .idle, .processing: |
| 453 | 499 |
return false |
@@ -456,7 +502,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 456 | 502 |
|
| 457 | 503 |
private var shouldShowFetchReport: Bool {
|
| 458 | 504 |
switch viewModel.snapshotProgress {
|
| 459 |
- case .complete, .incomplete: |
|
| 505 |
+ case .complete, .incomplete, .requiresResolution: |
|
| 460 | 506 |
return true |
| 461 | 507 |
case .idle, .fetching, .processing: |
| 462 | 508 |
return false |
@@ -465,14 +511,22 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 465 | 511 |
|
| 466 | 512 |
private func fetchReportView(_ progress: SnapshotFetchProgress) -> some View {
|
| 467 | 513 |
let isIncomplete = viewModel.snapshotProgress == .incomplete |
| 514 |
+ let requiresResolution = viewModel.snapshotProgress == .requiresResolution |
|
| 468 | 515 |
let failed = isIncomplete ? failedTypesList(in: progress) : [] |
| 469 | 516 |
let noteItems = isIncomplete ? remediationNoteItems(progress: progress) : [] |
| 517 |
+ let remediationTitle = hasAuthorizationFailures(progress) ? "WHAT TO DO" : "NOTES" |
|
| 470 | 518 |
return VStack(alignment: .leading, spacing: 20) {
|
| 471 | 519 |
reportSummarySection(progress) |
| 472 | 520 |
if !failed.isEmpty { reportIssuesSection(failed) }
|
| 473 |
- if !noteItems.isEmpty { reportRemediationSection(noteItems, title: "NOTES") }
|
|
| 521 |
+ if requiresResolution {
|
|
| 522 |
+ ambiguousDisappearanceSection() |
|
| 523 |
+ reportRemediationSection(ambiguousDisappearanceNotes(), title: "NOTES") |
|
| 524 |
+ reportRemediationSection(ambiguousDisappearanceActions(), title: "WHAT TO DO") |
|
| 525 |
+ } |
|
| 526 |
+ if !noteItems.isEmpty { reportRemediationSection(noteItems, title: remediationTitle) }
|
|
| 474 | 527 |
if isIncomplete, hasTimedOutMetrics(progress) { reportDecisionOverviewSection() }
|
| 475 | 528 |
if isIncomplete { snapshotResultActionsSection }
|
| 529 |
+ if requiresResolution { ambiguousResolutionActionsSection }
|
|
| 476 | 530 |
} |
| 477 | 531 |
} |
| 478 | 532 |
|
@@ -483,14 +537,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 483 | 537 |
.foregroundStyle(.secondary) |
| 484 | 538 |
|
| 485 | 539 |
let isComplete = viewModel.snapshotProgress == .complete |
| 540 |
+ let requiresResolution = viewModel.snapshotProgress == .requiresResolution |
|
| 486 | 541 |
HStack(spacing: 10) {
|
| 487 | 542 |
Image(systemName: isComplete ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") |
| 488 | 543 |
.font(.title3) |
| 489 | 544 |
.foregroundStyle(isComplete ? Color.healthyGreen : Color.warningAmber) |
| 490 | 545 |
VStack(alignment: .leading, spacing: 2) {
|
| 491 |
- Text(isComplete ? "Snapshot created successfully" : "Partial snapshot") |
|
| 546 |
+ Text(isComplete ? "Snapshot created successfully" : (requiresResolution ? "Snapshot needs review" : "Partial snapshot")) |
|
| 492 | 547 |
.font(.subheadline.weight(.semibold)) |
| 493 |
- Text(isComplete ? "All monitored metrics loaded" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed") |
|
| 548 |
+ Text(isComplete ? "All monitored metrics loaded" : (requiresResolution ? "\(viewModel.ambiguousDisappearedMetrics.count) metric\(viewModel.ambiguousDisappearedMetrics.count == 1 ? "" : "s") need classification" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed")) |
|
| 494 | 549 |
.font(.caption) |
| 495 | 550 |
.foregroundStyle(.secondary) |
| 496 | 551 |
} |
@@ -533,6 +588,39 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 533 | 588 |
} |
| 534 | 589 |
} |
| 535 | 590 |
|
| 591 |
+ private func ambiguousDisappearanceSection() -> some View {
|
|
| 592 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 593 |
+ Text("ISSUES")
|
|
| 594 |
+ .font(.caption.weight(.semibold)) |
|
| 595 |
+ .foregroundStyle(.secondary) |
|
| 596 |
+ |
|
| 597 |
+ Text("\(viewModel.ambiguousDisappearedMetrics.count) metric\(viewModel.ambiguousDisappearedMetrics.count == 1 ? "" : "s") returned zero samples")
|
|
| 598 |
+ .font(.subheadline.weight(.semibold)) |
|
| 599 |
+ .foregroundStyle(Color.warningAmber) |
|
| 600 |
+ |
|
| 601 |
+ VStack(spacing: 4) {
|
|
| 602 |
+ ForEach(viewModel.ambiguousDisappearedMetrics) { metric in
|
|
| 603 |
+ HStack(spacing: 8) {
|
|
| 604 |
+ Image(systemName: "questionmark.circle.fill") |
|
| 605 |
+ .foregroundStyle(Color.warningAmber) |
|
| 606 |
+ .font(.caption) |
|
| 607 |
+ .frame(width: 14) |
|
| 608 |
+ Text(metric.displayName) |
|
| 609 |
+ .font(.subheadline.weight(.semibold)) |
|
| 610 |
+ Spacer() |
|
| 611 |
+ Text("all values disappeared")
|
|
| 612 |
+ .font(.caption) |
|
| 613 |
+ .foregroundStyle(.secondary) |
|
| 614 |
+ } |
|
| 615 |
+ .padding(.horizontal, 12) |
|
| 616 |
+ .padding(.vertical, 10) |
|
| 617 |
+ .background(Color(.systemGray6)) |
|
| 618 |
+ .cornerRadius(8) |
|
| 619 |
+ } |
|
| 620 |
+ } |
|
| 621 |
+ } |
|
| 622 |
+ } |
|
| 623 |
+ |
|
| 536 | 624 |
private func reportIssuesSection(_ failed: [SnapshotFetchProgress.TypeProgress]) -> some View {
|
| 537 | 625 |
VStack(alignment: .leading, spacing: 6) {
|
| 538 | 626 |
Text("ISSUES")
|
@@ -693,6 +781,11 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 693 | 781 |
.font(.caption2) |
| 694 | 782 |
.foregroundStyle(.secondary) |
| 695 | 783 |
.lineLimit(1) |
| 784 |
+ } else if case .failed(let reason) = type.status, reason == "Not authorized" {
|
|
| 785 |
+ Text("Unavailable")
|
|
| 786 |
+ .font(.caption2) |
|
| 787 |
+ .foregroundStyle(Color.warningAmber) |
|
| 788 |
+ .lineLimit(1) |
|
| 696 | 789 |
} |
| 697 | 790 |
} |
| 698 | 791 |
.padding(.horizontal, 10) |
@@ -766,7 +859,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 766 | 859 |
Image(systemName: "checkmark.circle.fill") |
| 767 | 860 |
.font(.caption) |
| 768 | 861 |
.foregroundStyle(Color.healthyGreen) |
| 769 |
- } else if viewModel.snapshotProgress == .incomplete {
|
|
| 862 |
+ } else if viewModel.snapshotProgress == .incomplete || viewModel.snapshotProgress == .requiresResolution {
|
|
| 770 | 863 |
Image(systemName: "exclamationmark.circle.fill") |
| 771 | 864 |
.font(.caption) |
| 772 | 865 |
.foregroundStyle(Color.warningAmber) |
@@ -845,12 +938,12 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 845 | 938 |
} |
| 846 | 939 |
.presentationDetents(sheetDetents) |
| 847 | 940 |
.presentationDragIndicator(.visible) |
| 848 |
- .interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.isRequestingAuth) |
|
| 941 |
+ .interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.snapshotProgress == .requiresResolution || viewModel.isRequestingAuth) |
|
| 849 | 942 |
.onAppear {
|
| 850 | 943 |
snapshotSheetTab = .progress |
| 851 | 944 |
} |
| 852 | 945 |
.onChange(of: viewModel.snapshotProgress) { _, newProgress in
|
| 853 |
- if newProgress == .incomplete {
|
|
| 946 |
+ if newProgress == .incomplete || newProgress == .requiresResolution {
|
|
| 854 | 947 |
snapshotSheetTab = .report |
| 855 | 948 |
} else if newProgress == .fetching {
|
| 856 | 949 |
snapshotSheetTab = .progress |
@@ -860,28 +953,44 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 860 | 953 |
|
| 861 | 954 |
@ViewBuilder |
| 862 | 955 |
private var snapshotResultActionsSection: some View {
|
| 956 |
+ let hasAuthorizationIssue = hasAuthorizationFailures(viewModel.fetchProgress) |
|
| 957 |
+ let hasTimeoutIssue = hasTimedOutMetrics(viewModel.fetchProgress) |
|
| 958 |
+ |
|
| 863 | 959 |
HStack(spacing: 12) {
|
| 864 |
- Button {
|
|
| 865 |
- Task {
|
|
| 866 |
- if viewModel.canRetryWithPermissions {
|
|
| 867 |
- await viewModel.retryWithPermissions( |
|
| 868 |
- context: modelContext, |
|
| 869 |
- selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 870 |
- adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 871 |
- ) |
|
| 872 |
- } else {
|
|
| 873 |
- await viewModel.retryFailedMetricsWithExtendedTimeout( |
|
| 874 |
- context: modelContext, |
|
| 875 |
- selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 876 |
- adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 877 |
- ) |
|
| 960 |
+ if hasAuthorizationIssue && !viewModel.canRetryWithPermissions {
|
|
| 961 |
+ Button {
|
|
| 962 |
+ if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
| 963 |
+ UIApplication.shared.open(url) |
|
| 878 | 964 |
} |
| 965 |
+ } label: {
|
|
| 966 |
+ Text("Open Settings").frame(maxWidth: .infinity)
|
|
| 879 | 967 |
} |
| 880 |
- } label: {
|
|
| 881 |
- Text("Retry").frame(maxWidth: .infinity)
|
|
| 968 |
+ .buttonStyle(.borderedProminent) |
|
| 969 |
+ .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth) |
|
| 970 |
+ .accessibilityLabel("Open app settings to restore HealthKit read access")
|
|
| 971 |
+ } else if viewModel.canRetryWithPermissions || hasTimeoutIssue {
|
|
| 972 |
+ Button {
|
|
| 973 |
+ Task {
|
|
| 974 |
+ if viewModel.canRetryWithPermissions {
|
|
| 975 |
+ await viewModel.retryWithPermissions( |
|
| 976 |
+ context: modelContext, |
|
| 977 |
+ selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 978 |
+ adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 979 |
+ ) |
|
| 980 |
+ } else {
|
|
| 981 |
+ await viewModel.retryFailedMetricsWithExtendedTimeout( |
|
| 982 |
+ context: modelContext, |
|
| 983 |
+ selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 984 |
+ adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 985 |
+ ) |
|
| 986 |
+ } |
|
| 987 |
+ } |
|
| 988 |
+ } label: {
|
|
| 989 |
+ Text("Retry").frame(maxWidth: .infinity)
|
|
| 990 |
+ } |
|
| 991 |
+ .buttonStyle(.borderedProminent) |
|
| 992 |
+ .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth) |
|
| 882 | 993 |
} |
| 883 |
- .buttonStyle(.borderedProminent) |
|
| 884 |
- .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth) |
|
| 885 | 994 |
|
| 886 | 995 |
Button {
|
| 887 | 996 |
Task { await viewModel.savePartialSnapshot(context: modelContext) }
|
@@ -903,6 +1012,41 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 903 | 1012 |
} |
| 904 | 1013 |
} |
| 905 | 1014 |
|
| 1015 |
+ private var ambiguousResolutionActionsSection: some View {
|
|
| 1016 |
+ VStack(spacing: 10) {
|
|
| 1017 |
+ Button {
|
|
| 1018 |
+ Task { await viewModel.treatAmbiguousMetricsAsUnauthorized(context: modelContext) }
|
|
| 1019 |
+ } label: {
|
|
| 1020 |
+ Label("Treat as Unauthorized", systemImage: "lock.slash")
|
|
| 1021 |
+ .frame(maxWidth: .infinity) |
|
| 1022 |
+ } |
|
| 1023 |
+ .buttonStyle(.borderedProminent) |
|
| 1024 |
+ .disabled(viewModel.isCreatingSnapshot) |
|
| 1025 |
+ .accessibilityLabel("Treat disappeared metrics as unauthorized")
|
|
| 1026 |
+ |
|
| 1027 |
+ Button {
|
|
| 1028 |
+ Task { await viewModel.treatAmbiguousMetricsAsDeleted(context: modelContext) }
|
|
| 1029 |
+ } label: {
|
|
| 1030 |
+ Label("Treat as Deleted", systemImage: "trash")
|
|
| 1031 |
+ .frame(maxWidth: .infinity) |
|
| 1032 |
+ } |
|
| 1033 |
+ .buttonStyle(.bordered) |
|
| 1034 |
+ .disabled(viewModel.isCreatingSnapshot) |
|
| 1035 |
+ .accessibilityLabel("Treat disappeared metrics as deleted")
|
|
| 1036 |
+ |
|
| 1037 |
+ Button {
|
|
| 1038 |
+ Task { await viewModel.discardSnapshot(context: modelContext) }
|
|
| 1039 |
+ } label: {
|
|
| 1040 |
+ Label("Cancel Save", systemImage: "xmark")
|
|
| 1041 |
+ .foregroundStyle(Color.criticalRed) |
|
| 1042 |
+ .frame(maxWidth: .infinity) |
|
| 1043 |
+ } |
|
| 1044 |
+ .buttonStyle(.bordered) |
|
| 1045 |
+ .disabled(viewModel.isCreatingSnapshot) |
|
| 1046 |
+ .accessibilityLabel("Cancel saving this snapshot")
|
|
| 1047 |
+ } |
|
| 1048 |
+ } |
|
| 1049 |
+ |
|
| 906 | 1050 |
private var sheetDetents: Set<PresentationDetent> {
|
| 907 | 1051 |
[.large] |
| 908 | 1052 |
} |
@@ -921,9 +1065,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 921 | 1065 |
.foregroundStyle(.secondary) |
| 922 | 1066 |
} |
| 923 | 1067 |
if latest.snapshotQuality != SnapshotQuality.complete {
|
| 924 |
- Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
|
|
| 925 |
- .font(.caption) |
|
| 926 |
- .foregroundStyle(Color.warningAmber) |
|
| 1068 |
+ if latestUnavailableMetricCount > 0 {
|
|
| 1069 |
+ Label("\(latestUnavailableMetricCount) metric\(latestUnavailableMetricCount == 1 ? "" : "s") unavailable", systemImage: "exclamationmark.triangle")
|
|
| 1070 |
+ .font(.caption) |
|
| 1071 |
+ .foregroundStyle(Color.warningAmber) |
|
| 1072 |
+ } else {
|
|
| 1073 |
+ Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
|
|
| 1074 |
+ .font(.caption) |
|
| 1075 |
+ .foregroundStyle(Color.warningAmber) |
|
| 1076 |
+ } |
|
| 927 | 1077 |
} |
| 928 | 1078 |
} else {
|
| 929 | 1079 |
Label("No snapshots yet", systemImage: "camera.viewfinder")
|
@@ -983,7 +1133,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 983 | 1133 |
|
| 984 | 1134 |
private enum SnapshotSheetTab: String, CaseIterable, Identifiable {
|
| 985 | 1135 |
case progress = "Progress" |
| 986 |
- case report = "Report" |
|
| 1136 |
+ case report = "Result" |
|
| 987 | 1137 |
|
| 988 | 1138 |
var id: Self { self }
|
| 989 | 1139 |
} |
@@ -1108,11 +1258,43 @@ private struct DiagnosticReportSheet: View {
|
||
| 1108 | 1258 |
} |
| 1109 | 1259 |
|
| 1110 | 1260 |
private struct AnomalySummarySection: View {
|
| 1261 |
+ @Environment(\.modelContext) private var modelContext |
|
| 1111 | 1262 |
@Query(filter: #Predicate<AnomalyRecord> { !$0.isResolved })
|
| 1112 | 1263 |
private var unresolved: [AnomalyRecord] |
| 1113 | 1264 |
|
| 1114 | 1265 |
private var criticalCount: Int { unresolved.filter { $0.severityRaw == Severity.critical.rawValue }.count }
|
| 1115 | 1266 |
private var warningCount: Int { unresolved.filter { $0.severityRaw == Severity.warning.rawValue }.count }
|
| 1267 |
+ private var disappearedRecords: [AnomalyRecord] {
|
|
| 1268 |
+ unresolved |
|
| 1269 |
+ .filter { $0.anomalyType == .deletion }
|
|
| 1270 |
+ .sorted { $0.detectedAt > $1.detectedAt }
|
|
| 1271 |
+ } |
|
| 1272 |
+ |
|
| 1273 |
+ private func confirmAuthorizationChange(for anomaly: AnomalyRecord) {
|
|
| 1274 |
+ defer {
|
|
| 1275 |
+ anomaly.isResolved = true |
|
| 1276 |
+ try? modelContext.save() |
|
| 1277 |
+ } |
|
| 1278 |
+ |
|
| 1279 |
+ guard let typeIdentifier = anomaly.typeIdentifier else { return }
|
|
| 1280 |
+ let snapshotID = anomaly.snapshotID |
|
| 1281 |
+ |
|
| 1282 |
+ let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 1283 |
+ predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
|
|
| 1284 |
+ ) |
|
| 1285 |
+ guard let snapshot = try? modelContext.fetch(descriptor).first, |
|
| 1286 |
+ let typeCount = snapshot.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
|
|
| 1287 |
+ return |
|
| 1288 |
+ } |
|
| 1289 |
+ |
|
| 1290 |
+ typeCount.count = -1 |
|
| 1291 |
+ typeCount.contentHash = "" |
|
| 1292 |
+ typeCount.earliestDate = nil |
|
| 1293 |
+ typeCount.latestDate = nil |
|
| 1294 |
+ typeCount.quality = .unauthorized |
|
| 1295 |
+ typeCount.yearlyCounts?.removeAll() |
|
| 1296 |
+ snapshot.snapshotQuality = HealthKitService.shared.deriveSnapshotQuality(from: snapshot.typeCounts ?? []) |
|
| 1297 |
+ } |
|
| 1116 | 1298 |
|
| 1117 | 1299 |
var body: some View {
|
| 1118 | 1300 |
if !unresolved.isEmpty {
|
@@ -1127,6 +1309,29 @@ private struct AnomalySummarySection: View {
|
||
| 1127 | 1309 |
systemImage: "exclamationmark.triangle.fill") |
| 1128 | 1310 |
.foregroundStyle(Color.warningAmber) |
| 1129 | 1311 |
} |
| 1312 |
+ if !disappearedRecords.isEmpty {
|
|
| 1313 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 1314 |
+ Label("Records disappeared", systemImage: "eye.slash")
|
|
| 1315 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1316 |
+ .foregroundStyle(Color.criticalRed) |
|
| 1317 |
+ Text("Check Health read access for the affected metrics first. If you intentionally revoked access, confirm that here; otherwise investigate as possible data deletion.")
|
|
| 1318 |
+ .font(.caption) |
|
| 1319 |
+ .foregroundStyle(.secondary) |
|
| 1320 |
+ ForEach(disappearedRecords.prefix(3)) { anomaly in
|
|
| 1321 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 1322 |
+ Text(anomaly.message) |
|
| 1323 |
+ .font(.caption) |
|
| 1324 |
+ .foregroundStyle(.secondary) |
|
| 1325 |
+ Button("Confirm Authorization Change") {
|
|
| 1326 |
+ confirmAuthorizationChange(for: anomaly) |
|
| 1327 |
+ } |
|
| 1328 |
+ .buttonStyle(.bordered) |
|
| 1329 |
+ .controlSize(.small) |
|
| 1330 |
+ } |
|
| 1331 |
+ } |
|
| 1332 |
+ } |
|
| 1333 |
+ .padding(.vertical, 4) |
|
| 1334 |
+ } |
|
| 1130 | 1335 |
} |
| 1131 | 1336 |
} |
| 1132 | 1337 |
} |