HealthKitManager.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import HealthKit
  5. import LoopKit
  6. import LoopKitUI
  7. import Swinject
  8. protocol HealthKitManager {
  9. /// Check all needed permissions
  10. /// Return false if one or more permissions are deny or not choosen
  11. var hasGrantedFullWritePermissions: Bool { get }
  12. /// Check availability to save data of BG type to Health store
  13. func hasGlucoseWritePermission() -> Bool
  14. /// Requests user to give permissions on using HealthKit
  15. func requestPermission() async throws -> Bool
  16. /// Checks whether permissions are granted for Trio to write to Health
  17. func checkWriteToHealthPermissions(objectTypeToHealthStore: HKObjectType) -> Bool
  18. /// Save blood glucose to Health store
  19. func uploadGlucose() async
  20. /// Save carbs to Health store
  21. func uploadCarbs() async
  22. /// Save Insulin to Health store
  23. func uploadInsulin() async
  24. /// Delete glucose with syncID
  25. func deleteGlucose(syncID: String) async
  26. /// delete carbs with syncID
  27. func deleteMealData(byID id: String, sampleType: HKSampleType) async
  28. /// delete insulin with syncID
  29. func deleteInsulin(syncID: String) async
  30. }
  31. public enum AppleHealthConfig {
  32. // unwraped HKObjects
  33. static var writePermissions: Set<HKSampleType> {
  34. Set([healthBGObject, healthCarbObject, healthFatObject, healthProteinObject, healthInsulinObject].compactMap { $0 }) }
  35. // link to object in HealthKit
  36. static let healthBGObject = HKObjectType.quantityType(forIdentifier: .bloodGlucose)
  37. static let healthCarbObject = HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
  38. static let healthFatObject = HKObjectType.quantityType(forIdentifier: .dietaryFatTotal)
  39. static let healthProteinObject = HKObjectType.quantityType(forIdentifier: .dietaryProtein)
  40. static let healthInsulinObject = HKObjectType.quantityType(forIdentifier: .insulinDelivery)
  41. // MetaDataKey of Trio data in HealthStore
  42. static let TrioMetaDataKey = "TrioMetaDataKey"
  43. }
  44. final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsStoredDelegate, PumpHistoryDelegate {
  45. @Injected() private var glucoseStorage: GlucoseStorage!
  46. @Injected() private var healthKitStore: HKHealthStore!
  47. @Injected() private var settingsManager: SettingsManager!
  48. @Injected() private var broadcaster: Broadcaster!
  49. @Injected() var carbsStorage: CarbsStorage!
  50. @Injected() var pumpHistoryStorage: PumpHistoryStorage!
  51. private var backgroundContext = CoreDataStack.shared.newTaskContext()
  52. func carbsStorageHasUpdatedCarbs(_: BaseCarbsStorage) {
  53. Task.detached { [weak self] in
  54. guard let self = self else { return }
  55. await self.uploadCarbs()
  56. }
  57. }
  58. func pumpHistoryHasUpdated(_: BasePumpHistoryStorage) {
  59. Task.detached { [weak self] in
  60. guard let self = self else { return }
  61. await self.uploadInsulin()
  62. }
  63. }
  64. var isAvailableOnCurrentDevice: Bool {
  65. HKHealthStore.isHealthDataAvailable()
  66. }
  67. init(resolver: Resolver) {
  68. injectServices(resolver)
  69. guard isAvailableOnCurrentDevice,
  70. AppleHealthConfig.healthBGObject != nil else { return }
  71. carbsStorage.delegate = self
  72. pumpHistoryStorage.delegate = self
  73. debug(.service, "HealthKitManager did create")
  74. }
  75. func checkWriteToHealthPermissions(objectTypeToHealthStore: HKObjectType) -> Bool {
  76. healthKitStore.authorizationStatus(for: objectTypeToHealthStore) == .sharingAuthorized
  77. }
  78. var hasGrantedFullWritePermissions: Bool {
  79. Set(AppleHealthConfig.writePermissions.map { healthKitStore.authorizationStatus(for: $0) })
  80. .intersection([.sharingDenied, .notDetermined])
  81. .isEmpty
  82. }
  83. func hasGlucoseWritePermission() -> Bool {
  84. AppleHealthConfig.healthBGObject.map { checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false
  85. }
  86. func requestPermission() async throws -> Bool {
  87. guard isAvailableOnCurrentDevice else {
  88. throw HKError.notAvailableOnCurrentDevice
  89. }
  90. return try await withCheckedThrowingContinuation { continuation in
  91. healthKitStore.requestAuthorization(
  92. toShare: AppleHealthConfig.writePermissions,
  93. read: nil
  94. ) { status, error in
  95. if let error = error {
  96. continuation.resume(throwing: error)
  97. } else {
  98. continuation.resume(returning: status)
  99. }
  100. }
  101. }
  102. }
  103. // Glucose Upload
  104. func uploadGlucose() async {
  105. await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToHealth())
  106. await uploadGlucose(glucoseStorage.getManualGlucoseNotYetUploadedToHealth())
  107. }
  108. func uploadGlucose(_ glucose: [BloodGlucose]) async {
  109. guard settingsManager.settings.useAppleHealth,
  110. let sampleType = AppleHealthConfig.healthBGObject,
  111. checkWriteToHealthPermissions(objectTypeToHealthStore: sampleType),
  112. glucose.isNotEmpty
  113. else { return }
  114. do {
  115. // Create HealthKit samples from all the passed glucose values
  116. let glucoseSamples = glucose.compactMap { glucoseSample -> HKQuantitySample? in
  117. guard let glucoseValue = glucoseSample.glucose else { return nil }
  118. return HKQuantitySample(
  119. type: sampleType,
  120. quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(glucoseValue)),
  121. start: glucoseSample.dateString,
  122. end: glucoseSample.dateString,
  123. metadata: [
  124. HKMetadataKeyExternalUUID: glucoseSample.id,
  125. HKMetadataKeySyncIdentifier: glucoseSample.id,
  126. HKMetadataKeySyncVersion: 1,
  127. AppleHealthConfig.TrioMetaDataKey: UUID().uuidString
  128. ]
  129. )
  130. }
  131. guard glucoseSamples.isNotEmpty else {
  132. debug(.service, "No glucose samples available for upload.")
  133. return
  134. }
  135. // Attempt to save the blood glucose samples to Apple Health
  136. try await healthKitStore.save(glucoseSamples)
  137. debug(.service, "Successfully stored \(glucoseSamples.count) blood glucose samples in HealthKit.")
  138. // After successful upload, update the isUploadedToHealth flag in Core Data
  139. await updateGlucoseAsUploaded(glucose)
  140. } catch {
  141. debug(.service, "Failed to upload glucose samples to HealthKit: \(error.localizedDescription)")
  142. }
  143. }
  144. private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
  145. await backgroundContext.perform {
  146. let ids = glucose.map(\.id) as NSArray
  147. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  148. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  149. do {
  150. let results = try self.backgroundContext.fetch(fetchRequest)
  151. for result in results {
  152. result.isUploadedToHealth = true
  153. }
  154. guard self.backgroundContext.hasChanges else { return }
  155. try self.backgroundContext.save()
  156. } catch let error as NSError {
  157. debugPrint(
  158. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
  159. )
  160. }
  161. }
  162. }
  163. // Carbs Upload
  164. func uploadCarbs() async {
  165. await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToHealth())
  166. }
  167. func uploadCarbs(_ carbs: [CarbsEntry]) async {
  168. guard settingsManager.settings.useAppleHealth,
  169. let carbSampleType = AppleHealthConfig.healthCarbObject,
  170. let fatSampleType = AppleHealthConfig.healthFatObject,
  171. let proteinSampleType = AppleHealthConfig.healthProteinObject,
  172. checkWriteToHealthPermissions(objectTypeToHealthStore: carbSampleType),
  173. carbs.isNotEmpty
  174. else { return }
  175. do {
  176. var samples: [HKQuantitySample] = []
  177. // Create HealthKit samples for carbs, fat, and protein
  178. for allSamples in carbs {
  179. guard let id = allSamples.id else { continue }
  180. let fpuID = allSamples.fpuID ?? id
  181. let startDate = allSamples.actualDate ?? Date()
  182. // Carbs Sample
  183. let carbValue = allSamples.carbs
  184. let carbSample = HKQuantitySample(
  185. type: carbSampleType,
  186. quantity: HKQuantity(unit: .gram(), doubleValue: Double(carbValue)),
  187. start: startDate,
  188. end: startDate,
  189. metadata: [
  190. HKMetadataKeyExternalUUID: id,
  191. HKMetadataKeySyncIdentifier: id,
  192. HKMetadataKeySyncVersion: 1,
  193. AppleHealthConfig.TrioMetaDataKey: UUID().uuidString
  194. ]
  195. )
  196. samples.append(carbSample)
  197. // Fat Sample (if available)
  198. if let fatValue = allSamples.fat {
  199. let fatSample = HKQuantitySample(
  200. type: fatSampleType,
  201. quantity: HKQuantity(unit: .gram(), doubleValue: Double(fatValue)),
  202. start: startDate,
  203. end: startDate,
  204. metadata: [
  205. HKMetadataKeyExternalUUID: fpuID,
  206. HKMetadataKeySyncIdentifier: fpuID,
  207. HKMetadataKeySyncVersion: 1,
  208. AppleHealthConfig.TrioMetaDataKey: UUID().uuidString
  209. ]
  210. )
  211. samples.append(fatSample)
  212. }
  213. // Protein Sample (if available)
  214. if let proteinValue = allSamples.protein {
  215. let proteinSample = HKQuantitySample(
  216. type: proteinSampleType,
  217. quantity: HKQuantity(unit: .gram(), doubleValue: Double(proteinValue)),
  218. start: startDate,
  219. end: startDate,
  220. metadata: [
  221. HKMetadataKeyExternalUUID: fpuID,
  222. HKMetadataKeySyncIdentifier: fpuID,
  223. HKMetadataKeySyncVersion: 1,
  224. AppleHealthConfig.TrioMetaDataKey: UUID().uuidString
  225. ]
  226. )
  227. samples.append(proteinSample)
  228. }
  229. }
  230. // Attempt to save the samples to Apple Health
  231. guard samples.isNotEmpty else {
  232. debug(.service, "No samples available for upload.")
  233. return
  234. }
  235. try await healthKitStore.save(samples)
  236. debug(.service, "Successfully stored \(samples.count) carb samples in HealthKit.")
  237. // After successful upload, update the isUploadedToHealth flag in Core Data
  238. await updateCarbsAsUploaded(carbs)
  239. } catch {
  240. debug(.service, "Failed to upload carb samples to HealthKit: \(error.localizedDescription)")
  241. }
  242. }
  243. private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
  244. await backgroundContext.perform {
  245. let ids = carbs.map(\.id) as NSArray
  246. let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
  247. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  248. do {
  249. let results = try self.backgroundContext.fetch(fetchRequest)
  250. for result in results {
  251. result.isUploadedToHealth = true
  252. }
  253. guard self.backgroundContext.hasChanges else { return }
  254. try self.backgroundContext.save()
  255. } catch let error as NSError {
  256. debugPrint(
  257. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
  258. )
  259. }
  260. }
  261. }
  262. // Insulin Upload
  263. func uploadInsulin() async {
  264. await uploadInsulin(pumpHistoryStorage.getPumpHistoryNotYetUploadedToHealth())
  265. }
  266. func uploadInsulin(_ insulin: [PumpHistoryEvent]) async {
  267. guard settingsManager.settings.useAppleHealth,
  268. let sampleType = AppleHealthConfig.healthInsulinObject,
  269. checkWriteToHealthPermissions(objectTypeToHealthStore: sampleType),
  270. insulin.isNotEmpty
  271. else { return }
  272. do {
  273. let insulinSamples = insulin.compactMap { insulinSample -> HKQuantitySample? in
  274. guard let insulinValue = insulinSample.amount else { return nil }
  275. // Determine the insulin delivery reason (bolus or basal)
  276. let deliveryReason: HKInsulinDeliveryReason
  277. switch insulinSample.type {
  278. case .bolus:
  279. deliveryReason = .bolus
  280. case .tempBasal:
  281. deliveryReason = .basal
  282. default:
  283. // Skip other types
  284. /// If deliveryReason is nil, the compactMap will filter this sample out preventing a crash
  285. return nil
  286. }
  287. return HKQuantitySample(
  288. type: sampleType,
  289. quantity: HKQuantity(unit: .internationalUnit(), doubleValue: Double(insulinValue)),
  290. start: insulinSample.timestamp,
  291. end: insulinSample.timestamp,
  292. metadata: [
  293. HKMetadataKeyExternalUUID: insulinSample.id,
  294. HKMetadataKeySyncIdentifier: insulinSample.id,
  295. HKMetadataKeySyncVersion: 1,
  296. HKMetadataKeyInsulinDeliveryReason: deliveryReason.rawValue,
  297. AppleHealthConfig.TrioMetaDataKey: UUID().uuidString
  298. ]
  299. )
  300. }
  301. guard insulinSamples.isNotEmpty else {
  302. debug(.service, "No insulin samples available for upload.")
  303. return
  304. }
  305. // Attempt to save the insulin samples to Apple Health
  306. try await healthKitStore.save(insulinSamples)
  307. debug(.service, "Successfully stored \(insulinSamples.count) insulin samples in HealthKit.")
  308. // After successful upload, update the isUploadedToHealth flag in Core Data
  309. await updateInsulinAsUploaded(insulin)
  310. } catch {
  311. debug(.service, "Failed to upload insulin samples to HealthKit: \(error.localizedDescription)")
  312. }
  313. }
  314. private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
  315. await backgroundContext.perform {
  316. let ids = insulin.map(\.id) as NSArray
  317. let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
  318. fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
  319. do {
  320. let results = try self.backgroundContext.fetch(fetchRequest)
  321. for result in results {
  322. result.isUploadedToHealth = true
  323. }
  324. guard self.backgroundContext.hasChanges else { return }
  325. try self.backgroundContext.save()
  326. } catch let error as NSError {
  327. debugPrint(
  328. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
  329. )
  330. }
  331. }
  332. }
  333. // Delete Glucose/Carbs/Insulin
  334. func deleteGlucose(syncID: String) async {
  335. guard settingsManager.settings.useAppleHealth,
  336. let sampleType = AppleHealthConfig.healthBGObject,
  337. checkWriteToHealthPermissions(objectTypeToHealthStore: sampleType)
  338. else { return }
  339. let predicate = HKQuery.predicateForObjects(
  340. withMetadataKey: HKMetadataKeySyncIdentifier,
  341. operatorType: .equalTo,
  342. value: syncID
  343. )
  344. do {
  345. try await deleteObjects(of: sampleType, predicate: predicate)
  346. debug(.service, "Successfully deleted glucose sample with syncID: \(syncID)")
  347. } catch {
  348. warning(.service, "Failed to delete glucose sample with syncID: \(syncID)", error: error)
  349. }
  350. }
  351. func deleteMealData(byID id: String, sampleType: HKSampleType) async {
  352. guard settingsManager.settings.useAppleHealth else { return }
  353. let predicate = HKQuery.predicateForObjects(
  354. withMetadataKey: HKMetadataKeySyncIdentifier,
  355. operatorType: .equalTo,
  356. value: id
  357. )
  358. do {
  359. try await deleteObjects(of: sampleType, predicate: predicate)
  360. debug(.service, "Successfully deleted \(sampleType) with syncID: \(id)")
  361. } catch {
  362. warning(.service, "Failed to delete carbs sample with syncID: \(id)", error: error)
  363. }
  364. }
  365. func deleteInsulin(syncID: String) async {
  366. guard settingsManager.settings.useAppleHealth,
  367. let sampleType = AppleHealthConfig.healthInsulinObject,
  368. checkWriteToHealthPermissions(objectTypeToHealthStore: sampleType)
  369. else {
  370. debug(.service, "HealthKit permissions are not available for insulin deletion.")
  371. return
  372. }
  373. let predicate = HKQuery.predicateForObjects(
  374. withMetadataKey: HKMetadataKeySyncIdentifier,
  375. operatorType: .equalTo,
  376. value: syncID
  377. )
  378. do {
  379. try await deleteObjects(of: sampleType, predicate: predicate)
  380. debug(.service, "Successfully deleted insulin sample with syncID: \(syncID)")
  381. } catch {
  382. warning(.service, "Failed to delete insulin sample with syncID: \(syncID)", error: error)
  383. }
  384. }
  385. private func deleteObjects(of sampleType: HKSampleType, predicate: NSPredicate) async throws {
  386. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  387. healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { success, _, error in
  388. if let error = error {
  389. continuation.resume(throwing: error)
  390. } else if success {
  391. continuation.resume(returning: ())
  392. }
  393. }
  394. }
  395. }
  396. }
  397. enum HealthKitPermissionRequestStatus {
  398. case needRequest
  399. case didRequest
  400. }
  401. enum HKError: Error {
  402. // HealthKit work only iPhone (not on iPad)
  403. case notAvailableOnCurrentDevice
  404. // Some data can be not available on current iOS-device
  405. case dataNotAvailable
  406. }
  407. private struct InsulinBolus {
  408. var id: String
  409. var amount: Decimal
  410. var date: Date
  411. }
  412. private struct InsulinBasal {
  413. var id: String
  414. var amount: Decimal
  415. var startDelivery: Date
  416. var endDelivery: Date
  417. }