Files
pulse-mobile/PulseHealth/Services/HealthKitService.swift
Daniil Klimov 44c759c190 fix: security hardening — Keychain, no hardcoded creds, safe URLs
- 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>
2026-04-06 14:11:10 +03:00

379 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))"
}
}
}