1 contributor
import Foundation
import HealthKit
struct ImportTimingBreakdown: Sendable, Equatable {
var fetchElapsedSeconds: TimeInterval = 0
var processingElapsedSeconds: TimeInterval = 0
var processingDeltaApplyElapsedSeconds: TimeInterval = 0
var processingRecordArchiveRebuildElapsedSeconds: TimeInterval = 0
var processingInitialRecordElapsedSeconds: TimeInterval = 0
var processingRecordArchiveFinalizeElapsedSeconds: TimeInterval = 0
var insertElapsedSeconds: TimeInterval = 0
var finalizeElapsedSeconds: TimeInterval = 0
var finalizeEventCountElapsedSeconds: TimeInterval = 0
var finalizeTypeSummaryElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateBucketLookupElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateCopyElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateDeleteElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateRebuildElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateInsertElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateOtherElapsedSeconds: TimeInterval = 0
var finalizeRunUpdateElapsedSeconds: TimeInterval = 0
var finalizeOtherElapsedSeconds: TimeInterval = 0
static let zero = ImportTimingBreakdown()
var accountedElapsedSeconds: TimeInterval {
fetchElapsedSeconds + processingElapsedSeconds + insertElapsedSeconds + finalizeElapsedSeconds
}
var processingAccountedElapsedSeconds: TimeInterval {
processingDeltaApplyElapsedSeconds
+ processingRecordArchiveRebuildElapsedSeconds
+ processingInitialRecordElapsedSeconds
+ processingRecordArchiveFinalizeElapsedSeconds
}
var processingOtherElapsedSeconds: TimeInterval {
max(0, processingElapsedSeconds - processingAccountedElapsedSeconds)
}
var finalizeAccountedElapsedSeconds: TimeInterval {
finalizeEventCountElapsedSeconds
+ finalizeTypeSummaryElapsedSeconds
+ finalizeDailyAggregateElapsedSeconds
+ finalizeRunUpdateElapsedSeconds
+ finalizeOtherElapsedSeconds
}
var finalizeDailyAggregateAccountedElapsedSeconds: TimeInterval {
finalizeDailyAggregateBucketLookupElapsedSeconds
+ finalizeDailyAggregateCopyElapsedSeconds
+ finalizeDailyAggregateDeleteElapsedSeconds
+ finalizeDailyAggregateRebuildElapsedSeconds
+ finalizeDailyAggregateInsertElapsedSeconds
+ finalizeDailyAggregateOtherElapsedSeconds
}
}
struct HealthKitAPICallResult: Sendable, Equatable {
enum Status: String, Sendable {
case complete
case failed
case timeout
case unknown
case unauthorized
case unsupported
}
let queryType: String
let status: Status
let elapsedSeconds: TimeInterval
let resultValue: String?
let errorCode: String?
let errorDomain: String?
let errorDescription: String?
let failureKind: String?
let cancellationReason: String?
init(
queryType: String,
status: Status,
elapsedSeconds: TimeInterval,
resultValue: String?,
errorCode: String? = nil,
errorDomain: String? = nil,
errorDescription: String? = nil,
failureKind: String? = nil,
cancellationReason: String? = nil
) {
self.queryType = queryType
self.status = status
self.elapsedSeconds = elapsedSeconds
self.resultValue = resultValue
self.errorCode = errorCode
self.errorDomain = errorDomain
self.errorDescription = errorDescription
self.failureKind = failureKind
self.cancellationReason = cancellationReason
}
var statusDescription: String {
switch status {
case .complete: return "complete"
case .failed: return "failed"
case .timeout: return "timeout"
case .unknown: return "unknown"
case .unauthorized: return "unauthorized"
case .unsupported: return "unsupported"
}
}
var indicatesProtectedDataInaccessible: Bool {
errorDomain == HKError.errorDomain
&& errorCode == "\(HKError.Code.errorDatabaseInaccessible.rawValue)"
}
}
@Observable
final class SnapshotFetchProgress {
struct YearlyCountProgress: Identifiable, Sendable, Equatable {
var id: Int { year }
let year: Int
let count: Int
let isApproximate: Bool
}
struct TypeProgress: Identifiable, Sendable, Equatable {
enum FetchStatus: Sendable, Equatable {
case pending
case fetching
case complete
case failed(String)
var icon: String {
switch self {
case .pending: return "circle"
case .fetching: return "arrow.triangle.2.circlepath"
case .complete: return "checkmark.circle.fill"
case .failed: return "xmark.circle.fill"
}
}
}
let id: String
let displayName: String
var status: FetchStatus = .pending
var quality: String = SnapshotQuality.loading.rawValue
var recordCount: Int = 0
var isUnsupported: Bool = false
var authorizationStatus: String = "unknown"
var earliestDate: Date?
var latestDate: Date?
var yearlyCounts: [YearlyCountProgress] = []
var apiCallDetails: [HealthKitAPICallResult] = []
var timeoutConfiguredSeconds: TimeInterval = 0
var totalElapsedSeconds: TimeInterval = 0
var timeoutMode: String = "default"
var lastSuccessfulElapsed: TimeInterval = 0
var learnedTimeout: TimeInterval = 0
var suggestedRetryTimeout: TimeInterval = 0
var timeoutCount: Int = 0
var successCount: Int = 0
var timingBreakdown: ImportTimingBreakdown = .zero
var captureMode: String = "unavailable"
var deltaEventCount: Int = 0
var blockProgress: String = ""
var blockElapsedSeconds: TimeInterval = 0
var blockSamplesPerSecond: Double = 0
}
let totalTypeCount: Int
var types: [TypeProgress] = []
var perTypeTimeoutSeconds: TimeInterval = 0
var maxConcurrentTypeFetches: Int = 0
var adaptiveTimeoutsEnabled: Bool = false
var previousSnapshotID: UUID?
var isChainStart: Bool?
var snapshotChecksum: String = ""
var monitoredTypeSetHash: String = ""
var monitoredRegistryVersion: Int?
private let displayNamesByID: [String: String]
var visibleTypes: [TypeProgress] { types }
var completedCount: Int { types.filter { $0.status == .complete }.count }
var failedCount: Int {
types.filter {
if case .failed = $0.status { return true }
return false
}.count
}
var totalRecords: Int {
types.reduce(0) { $0 + max($1.recordCount, 0) }
}
var aggregateTimingBreakdown: ImportTimingBreakdown {
types.reduce(.zero) { partial, type in
var combined = partial
combined.fetchElapsedSeconds += type.timingBreakdown.fetchElapsedSeconds
combined.processingElapsedSeconds += type.timingBreakdown.processingElapsedSeconds
combined.processingDeltaApplyElapsedSeconds += type.timingBreakdown.processingDeltaApplyElapsedSeconds
combined.processingRecordArchiveRebuildElapsedSeconds += type.timingBreakdown.processingRecordArchiveRebuildElapsedSeconds
combined.processingInitialRecordElapsedSeconds += type.timingBreakdown.processingInitialRecordElapsedSeconds
combined.processingRecordArchiveFinalizeElapsedSeconds += type.timingBreakdown.processingRecordArchiveFinalizeElapsedSeconds
combined.insertElapsedSeconds += type.timingBreakdown.insertElapsedSeconds
combined.finalizeElapsedSeconds += type.timingBreakdown.finalizeElapsedSeconds
combined.finalizeEventCountElapsedSeconds += type.timingBreakdown.finalizeEventCountElapsedSeconds
combined.finalizeTypeSummaryElapsedSeconds += type.timingBreakdown.finalizeTypeSummaryElapsedSeconds
combined.finalizeDailyAggregateElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateElapsedSeconds
combined.finalizeDailyAggregateBucketLookupElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateBucketLookupElapsedSeconds
combined.finalizeDailyAggregateCopyElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateCopyElapsedSeconds
combined.finalizeDailyAggregateDeleteElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateDeleteElapsedSeconds
combined.finalizeDailyAggregateRebuildElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateRebuildElapsedSeconds
combined.finalizeDailyAggregateInsertElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateInsertElapsedSeconds
combined.finalizeDailyAggregateOtherElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateOtherElapsedSeconds
combined.finalizeRunUpdateElapsedSeconds += type.timingBreakdown.finalizeRunUpdateElapsedSeconds
combined.finalizeOtherElapsedSeconds += type.timingBreakdown.finalizeOtherElapsedSeconds
return combined
}
}
init(monitoredTypes: [(id: String, displayName: String)]) {
self.totalTypeCount = monitoredTypes.count
self.displayNamesByID = Dictionary(uniqueKeysWithValues: monitoredTypes.map { ($0.id, $0.displayName) })
}
func updateConfiguration(
perTypeTimeoutSeconds: TimeInterval,
maxConcurrentTypeFetches: Int,
adaptiveTimeoutsEnabled: Bool
) {
self.perTypeTimeoutSeconds = perTypeTimeoutSeconds
self.maxConcurrentTypeFetches = maxConcurrentTypeFetches
self.adaptiveTimeoutsEnabled = adaptiveTimeoutsEnabled
}
func updateChainContext(
previousSnapshotID: UUID?,
isChainStart: Bool,
snapshotChecksum: String,
monitoredTypeSetHash: String,
monitoredRegistryVersion: Int
) {
self.previousSnapshotID = previousSnapshotID
self.isChainStart = isChainStart
self.snapshotChecksum = snapshotChecksum
self.monitoredTypeSetHash = monitoredTypeSetHash
self.monitoredRegistryVersion = monitoredRegistryVersion
}
func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
let index = visibleTypeIndex(for: id)
types[index].status = status
switch status {
case .complete, .failed, .pending:
types[index].blockProgress = ""
case .fetching:
break
}
if let recordCount {
types[index].recordCount = recordCount
}
}
func updateDetails(
_ id: String,
quality: String,
recordCount: Int,
isUnsupported: Bool,
authorizationStatus: String,
earliestDate: Date?,
latestDate: Date?,
yearlyCounts: [YearlyCountProgress],
apiCallDetails: [HealthKitAPICallResult],
timeoutConfiguredSeconds: TimeInterval,
totalElapsedSeconds: TimeInterval,
timeoutMode: String,
lastSuccessfulElapsed: TimeInterval,
learnedTimeout: TimeInterval,
suggestedRetryTimeout: TimeInterval,
timeoutCount: Int,
successCount: Int,
timingBreakdown: ImportTimingBreakdown,
captureMode: String = "unavailable",
deltaEventCount: Int = 0
) {
let index = visibleTypeIndex(for: id)
types[index].quality = quality
types[index].recordCount = recordCount
types[index].isUnsupported = isUnsupported
types[index].authorizationStatus = authorizationStatus
types[index].earliestDate = earliestDate
types[index].latestDate = latestDate
types[index].yearlyCounts = yearlyCounts
types[index].apiCallDetails = apiCallDetails
types[index].timeoutConfiguredSeconds = timeoutConfiguredSeconds
types[index].totalElapsedSeconds = totalElapsedSeconds
types[index].timeoutMode = timeoutMode
types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
types[index].learnedTimeout = learnedTimeout
types[index].suggestedRetryTimeout = suggestedRetryTimeout
types[index].timeoutCount = timeoutCount
types[index].successCount = successCount
types[index].timingBreakdown = timingBreakdown
types[index].captureMode = captureMode
types[index].deltaEventCount = deltaEventCount
}
func updateTimeoutProfile(
_ id: String,
timeoutMode: String,
lastSuccessfulElapsed: TimeInterval,
learnedTimeout: TimeInterval,
suggestedRetryTimeout: TimeInterval,
timeoutCount: Int,
successCount: Int
) {
let index = visibleTypeIndex(for: id)
types[index].timeoutMode = timeoutMode
types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
types[index].learnedTimeout = learnedTimeout
types[index].suggestedRetryTimeout = suggestedRetryTimeout
types[index].timeoutCount = timeoutCount
types[index].successCount = successCount
}
func updateBlockProgress(
_ id: String,
detail: String,
recordCount: Int? = nil,
elapsedSeconds: TimeInterval? = nil,
samplesPerSecond: Double? = nil
) {
let index = visibleTypeIndex(for: id)
types[index].blockProgress = detail
if let recordCount {
types[index].recordCount = recordCount
}
if let elapsedSeconds {
types[index].blockElapsedSeconds = elapsedSeconds
}
if let samplesPerSecond {
types[index].blockSamplesPerSecond = samplesPerSecond
}
}
func markUnavailable(_ id: String) {
let index = visibleTypeIndex(for: id)
types[index].status = .failed("Not authorized")
types[index].quality = SnapshotQuality.unauthorized.rawValue
types[index].recordCount = -1
types[index].authorizationStatus = "unavailable"
}
private func visibleTypeIndex(for id: String) -> Int {
if let index = types.firstIndex(where: { $0.id == id }) {
return index
}
types.insert(
TypeProgress(id: id, displayName: displayNamesByID[id] ?? id),
at: 0
)
return 0
}
}