HealthKitManager.swift 19 KB

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