- Add KeychainService for encrypted token storage (auth, refresh, health JWT, API key) - Remove hardcoded email/password from HealthAPIService, store in Keychain - Move all tokens from UserDefaults to Keychain - API key sent via X-API-Key header instead of URL query parameter - Replace force unwrap URL(string:)! with guard let + throws - Fix force unwrap Calendar.date() in HealthKitService - Mark HealthKitService @MainActor for thread-safe @Published - Use withTaskGroup for parallel habit log fetching in TrackerView - Check notification permission before scheduling reminders - Add input validation (title max 200 chars) - Add privacy policy and terms links in Settings - Update CLAUDE.md with security section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
379 lines
16 KiB
Swift
379 lines
16 KiB
Swift
import HealthKit
|
||
import Foundation
|
||
|
||
@MainActor
|
||
class HealthKitService: ObservableObject {
|
||
let healthStore = HKHealthStore()
|
||
@Published var isSyncing = false
|
||
|
||
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
||
|
||
private let typesToRead: Set<HKObjectType> = [
|
||
HKQuantityType(.heartRate),
|
||
HKQuantityType(.restingHeartRate),
|
||
HKQuantityType(.heartRateVariabilitySDNN),
|
||
HKQuantityType(.stepCount),
|
||
HKQuantityType(.activeEnergyBurned),
|
||
HKQuantityType(.oxygenSaturation),
|
||
HKQuantityType(.distanceWalkingRunning),
|
||
HKQuantityType(.respiratoryRate),
|
||
HKCategoryType(.sleepAnalysis),
|
||
]
|
||
|
||
func requestAuthorization() async throws {
|
||
guard isAvailable else { throw HealthKitError.notAvailable }
|
||
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
|
||
}
|
||
|
||
func checkAuthorization(for type: HKObjectType) -> Bool {
|
||
healthStore.authorizationStatus(for: type) == .sharingAuthorized
|
||
}
|
||
|
||
// MARK: - Collect All Metrics
|
||
|
||
func collectAllMetrics() async -> [[String: Any]] {
|
||
let now = Date()
|
||
let startOfDay = Calendar.current.startOfDay(for: now)
|
||
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
|
||
|
||
let dateFormatter = DateFormatter()
|
||
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
|
||
|
||
let dateStr = dateFormatter.string(from: startOfDay)
|
||
|
||
var metrics: [[String: Any]] = []
|
||
|
||
// Step Count
|
||
let steps = await fetchCumulativeSum(.stepCount, unit: .count(), predicate: predicate)
|
||
if steps > 0 {
|
||
metrics.append([
|
||
"name": "step_count",
|
||
"units": "count",
|
||
"data": [["date": dateStr, "qty": steps]]
|
||
])
|
||
}
|
||
|
||
// Resting Heart Rate
|
||
let rhr = await fetchLatestQuantity(.restingHeartRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||
if rhr > 0 {
|
||
metrics.append([
|
||
"name": "resting_heart_rate",
|
||
"units": "bpm",
|
||
"data": [["date": dateStr, "qty": rhr]]
|
||
])
|
||
}
|
||
|
||
// HRV
|
||
let hrvValues = await fetchAllSamples(.heartRateVariabilitySDNN, unit: .secondUnit(with: .milli), predicate: predicate)
|
||
if !hrvValues.isEmpty {
|
||
let hrvData = hrvValues.map { sample -> [String: Any] in
|
||
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: .secondUnit(with: .milli))]
|
||
}
|
||
metrics.append([
|
||
"name": "heart_rate_variability",
|
||
"units": "ms",
|
||
"data": hrvData
|
||
])
|
||
}
|
||
|
||
// Heart Rate
|
||
let hrValues = await fetchAllSamples(.heartRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||
if !hrValues.isEmpty {
|
||
let hrData = hrValues.map { sample -> [String: Any] in
|
||
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
|
||
}
|
||
metrics.append([
|
||
"name": "heart_rate",
|
||
"units": "bpm",
|
||
"data": hrData
|
||
])
|
||
}
|
||
|
||
// Active Energy (convert kcal to kJ)
|
||
let energy = await fetchCumulativeSum(.activeEnergyBurned, unit: .kilocalorie(), predicate: predicate)
|
||
if energy > 0 {
|
||
let kJ = energy * 4.184
|
||
metrics.append([
|
||
"name": "active_energy",
|
||
"units": "kJ",
|
||
"data": [["date": dateStr, "qty": kJ]]
|
||
])
|
||
}
|
||
|
||
// Blood Oxygen
|
||
let spo2Values = await fetchAllSamples(.oxygenSaturation, unit: .percent(), predicate: predicate)
|
||
if !spo2Values.isEmpty {
|
||
let spo2Data = spo2Values.map { sample -> [String: Any] in
|
||
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: .percent()) * 100]
|
||
}
|
||
metrics.append([
|
||
"name": "blood_oxygen_saturation",
|
||
"units": "%",
|
||
"data": spo2Data
|
||
])
|
||
}
|
||
|
||
// Walking + Running Distance
|
||
let distance = await fetchCumulativeSum(.distanceWalkingRunning, unit: .meter(), predicate: predicate)
|
||
if distance > 0 {
|
||
metrics.append([
|
||
"name": "walking_running_distance",
|
||
"units": "m",
|
||
"data": [["date": dateStr, "qty": distance]]
|
||
])
|
||
}
|
||
|
||
// Respiratory Rate (дыхание)
|
||
let rrValues = await fetchAllSamples(.respiratoryRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||
if !rrValues.isEmpty {
|
||
let rrData = rrValues.map { sample -> [String: Any] in
|
||
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
|
||
}
|
||
metrics.append([
|
||
"name": "respiratory_rate",
|
||
"units": "breaths/min",
|
||
"data": rrData
|
||
])
|
||
}
|
||
|
||
// Sleep Analysis — расширенный диапазон (последние 24 ча<EFBFBD><EFBFBD>а, чтобы захватить ночной сон)
|
||
let sleepData = await fetchSleepData(dateFormatter: dateFormatter)
|
||
if !sleepData.isEmpty {
|
||
metrics.append([
|
||
"name": "sleep_analysis",
|
||
"units": "hr",
|
||
"data": sleepData
|
||
])
|
||
}
|
||
|
||
return metrics
|
||
}
|
||
|
||
// MARK: - Sleep Data Collection
|
||
|
||
private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] {
|
||
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
||
|
||
// Берём последние 24 часа, чтобы захватить ночной сон
|
||
let now = Date()
|
||
guard let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now) else { return [] }
|
||
let sleepPredicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
|
||
|
||
return await withCheckedContinuation { cont in
|
||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||
let query = HKSampleQuery(sampleType: sleepType, predicate: sleepPredicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
|
||
guard let samples = samples as? [HKCategorySample], !samples.isEmpty else {
|
||
cont.resume(returning: [])
|
||
return
|
||
}
|
||
|
||
// Считаем время по каждой стадии сна в часах
|
||
var deepSeconds = 0.0
|
||
var remSeconds = 0.0
|
||
var coreSeconds = 0.0
|
||
var awakeSeconds = 0.0
|
||
var asleepSeconds = 0.0
|
||
var inBedStart: Date?
|
||
var inBedEnd: Date?
|
||
|
||
for sample in samples {
|
||
let duration = sample.endDate.timeIntervalSince(sample.startDate)
|
||
|
||
switch sample.value {
|
||
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
||
if inBedStart == nil || sample.startDate < inBedStart! { inBedStart = sample.startDate }
|
||
if inBedEnd == nil || sample.endDate > inBedEnd! { inBedEnd = sample.endDate }
|
||
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
|
||
deepSeconds += duration
|
||
asleepSeconds += duration
|
||
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
||
remSeconds += duration
|
||
asleepSeconds += duration
|
||
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
|
||
coreSeconds += duration
|
||
asleepSeconds += duration
|
||
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
|
||
asleepSeconds += duration
|
||
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
||
awakeSeconds += duration
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
// Если нет inBed, берём из asleep-сэмплов
|
||
if inBedStart == nil {
|
||
let asleepSamples = samples.filter { $0.value != HKCategoryValueSleepAnalysis.awake.rawValue && $0.value != HKCategoryValueSleepAnalysis.inBed.rawValue }
|
||
inBedStart = asleepSamples.first?.startDate
|
||
inBedEnd = asleepSamples.last?.endDate
|
||
}
|
||
|
||
let totalSleep = asleepSeconds / 3600.0
|
||
guard totalSleep > 0 else {
|
||
cont.resume(returning: [])
|
||
return
|
||
}
|
||
|
||
let startOfDay = Calendar.current.startOfDay(for: now)
|
||
|
||
// Формат, который ожидает health-webhook API
|
||
var entry: [String: Any] = [
|
||
"date": dateFormatter.string(from: startOfDay),
|
||
"totalSleep": round(totalSleep * 1000) / 1000,
|
||
"deep": round((deepSeconds / 3600.0) * 1000) / 1000,
|
||
"rem": round((remSeconds / 3600.0) * 1000) / 1000,
|
||
"core": round((coreSeconds / 3600.0) * 1000) / 1000,
|
||
"awake": round((awakeSeconds / 3600.0) * 1000) / 1000,
|
||
"asleep": round(totalSleep * 1000) / 1000,
|
||
"source": "Apple Watch"
|
||
]
|
||
|
||
if let start = inBedStart {
|
||
entry["inBedStart"] = dateFormatter.string(from: start)
|
||
entry["sleepStart"] = dateFormatter.string(from: start)
|
||
}
|
||
if let end = inBedEnd {
|
||
entry["inBedEnd"] = dateFormatter.string(from: end)
|
||
entry["sleepEnd"] = dateFormatter.string(from: end)
|
||
}
|
||
|
||
cont.resume(returning: [entry])
|
||
}
|
||
self.healthStore.execute(query)
|
||
}
|
||
}
|
||
|
||
// MARK: - Send to Server
|
||
|
||
func syncToServer(apiKey: String) async throws {
|
||
isSyncing = true
|
||
defer { isSyncing = false }
|
||
|
||
guard isAvailable else {
|
||
throw HealthKitError.notAvailable
|
||
}
|
||
|
||
try await requestAuthorization()
|
||
|
||
let metrics = await collectAllMetrics()
|
||
guard !metrics.isEmpty else {
|
||
throw HealthKitError.noData
|
||
}
|
||
|
||
let payload: [String: Any] = [
|
||
"data": [
|
||
"metrics": metrics
|
||
]
|
||
]
|
||
|
||
let jsonData = try JSONSerialization.data(withJSONObject: payload)
|
||
|
||
guard let url = URL(string: "\(HealthAPIService.shared.baseURL)/api/health") else {
|
||
throw HealthKitError.invalidURL
|
||
}
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
||
request.httpBody = jsonData
|
||
request.timeoutInterval = 30
|
||
|
||
let (data, response) = try await URLSession.shared.data(for: request)
|
||
guard let httpResponse = response as? HTTPURLResponse,
|
||
(200...299).contains(httpResponse.statusCode) else {
|
||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||
let body = String(data: data, encoding: .utf8) ?? ""
|
||
throw HealthKitError.serverError(code, body)
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private func fetchCumulativeSum(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> Double {
|
||
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 }
|
||
return await withCheckedContinuation { cont in
|
||
let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
|
||
cont.resume(returning: result?.sumQuantity()?.doubleValue(for: unit) ?? 0)
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
}
|
||
|
||
private func fetchLatestQuantity(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> Double {
|
||
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 }
|
||
return await withCheckedContinuation { cont in
|
||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
||
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 1, sortDescriptors: [sort]) { _, samples, _ in
|
||
let value = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: unit) ?? 0
|
||
cont.resume(returning: value)
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
}
|
||
|
||
private func fetchAllSamples(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> [HKQuantitySample] {
|
||
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return [] }
|
||
return await withCheckedContinuation { cont in
|
||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
|
||
cont.resume(returning: (samples as? [HKQuantitySample]) ?? [])
|
||
}
|
||
healthStore.execute(query)
|
||
}
|
||
}
|
||
|
||
// MARK: - Sleep Segments (timeline)
|
||
|
||
func fetchSleepSegments() async -> [SleepSegment] {
|
||
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
||
let now = Date()
|
||
guard let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now) else { return [] }
|
||
let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
|
||
|
||
return await withCheckedContinuation { cont in
|
||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
|
||
guard let samples = samples as? [HKCategorySample] else {
|
||
cont.resume(returning: [])
|
||
return
|
||
}
|
||
|
||
let segments: [SleepSegment] = samples.compactMap { sample in
|
||
let phase: SleepPhaseType?
|
||
switch sample.value {
|
||
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: phase = .deep
|
||
case HKCategoryValueSleepAnalysis.asleepREM.rawValue: phase = .rem
|
||
case HKCategoryValueSleepAnalysis.asleepCore.rawValue: phase = .core
|
||
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: phase = .core
|
||
case HKCategoryValueSleepAnalysis.awake.rawValue: phase = .awake
|
||
default: phase = nil
|
||
}
|
||
guard let p = phase else { return nil }
|
||
return SleepSegment(phase: p, start: sample.startDate, end: sample.endDate)
|
||
}
|
||
cont.resume(returning: segments)
|
||
}
|
||
self.healthStore.execute(query)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Errors
|
||
|
||
enum HealthKitError: Error, LocalizedError {
|
||
case notAvailable
|
||
case noData
|
||
case invalidURL
|
||
case serverError(Int, String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .notAvailable: return "HealthKit недоступен на этом устройстве"
|
||
case .noData: return "Нет данных HealthKit за сегодня. Убедитесь, что Apple Watch синхронизированы"
|
||
case .invalidURL: return "Неверный URL сервера"
|
||
case .serverError(let code, let body): return "Ошибка сервера (\(code)): \(body.prefix(100))"
|
||
}
|
||
}
|
||
}
|