Просмотр исходного кода

Fix HealthKitManager bug (#148)

* Added blood glucose simulator

* First Integration HealthKit

* Update GlucoseSimulatorSource.swift

* Update project.pbxproj

* Update Info.plist

* Working ...

* Working...

* Add Watch App

* Working...

* Add HealthKit integration (only blood glucose)

* Some fix

* Add localization

* Update GlucoseSimulatorSource.swift

* Delete ConfigOverride.xcconfig

* Some fix

* Update project.pbxproj

* Update project.pbxproj

* Update project.pbxproj

* Update project.pbxproj

* Some fix

* Update project.pbxproj

* Update HealthKit integration

* Update Localizable.strings

* Update HealthKit integration

* Some fix

* Fix Health integration

Исправлена ошибка не добавления глюкозы из Здоровья, дублирования данных, поступивших из Здоровья (FreeAPS отправлял их в Здоровье новой записью).

* Update FetchGlucoseManager.swift

* Update Localizable.strings

* Update Localizable.strings

* Fix Future publisher and others

* Update HealthKitManager.swift

* Update HealthKitManager.swift

* Fix adding BloodGlucose bug

* Update HealthKitManager.swift

* Update HealthKitManager

* Update HealthKitManager.swift

* Update HealthKitManager.swift

* Update HealthKitManager

* Update HealthKit integration

* Update FetchGlucoseManager.swift

* Update localization

* Update localization
Vasiliy Usov 4 лет назад
Родитель
Сommit
b03693b35d

+ 3 - 1
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -69,8 +69,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                 .eraseToAnyPublisher()
             }
             .sink { date, syncDate, glucose, glucoseFromHealth in
+                debug(.nightscout, "SyncDate is \(syncDate)")
                 let allGlucose = glucose + glucoseFromHealth
                 guard allGlucose.isNotEmpty else { return }
+
                 // Because of Spike dosn't respect a date query
                 let filteredByDate = allGlucose.filter { $0.dateString > syncDate }
                 let filtered = self.glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)
@@ -84,7 +86,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                 let glucoseForHealth = filteredByDate.filter { !glucoseFromHealth.contains($0) }
 
                 guard glucoseForHealth.isNotEmpty else { return }
-                self.healthKitManager.save(bloodGlucoses: glucoseForHealth, completion: nil)
+                self.healthKitManager.saveIfNeeded(bloodGlucoses: glucoseForHealth)
             }
             .store(in: &lifetime)
         timer.fire()

+ 3 - 0
FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings

@@ -907,6 +907,9 @@ Enact a temp Basal or a temp target */
 
 /* Show when have not permissions for writing to Health */
 "For write data to Apple Health you must give permissions in Settings > Health > Data Access" = "For write data to Apple Health you must give permissions in Settings > Health > Data Access";
+
+/* */
+"After you create or delete glucose records in the Health app, please open FreeAPS X to help us guaranteed transfer changed data" = "After you create or delete glucose records in the Health app, please open FreeAPS X to help us guaranteed transfer changed data";
 /* --------------------------------------------
 */
 /*

+ 3 - 0
FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings

@@ -903,6 +903,9 @@ Enact a temp Basal or a temp target */
 
 /* Show when have not permissions for writing to Health */
 "For write data to Apple Health you must give permissions in Settings > Health > Data Access" = "Чтобы записывать данные в Apple Health вам необходимо дать соответствующие разрешения, перейдя к меню Настройки > Здоровье > Доступ к данным";
+
+/* */
+"After you create or delete glucose records in the Health app, please open FreeAPS X to help us guaranteed transfer changed data" = "После ручного создания или удаления записей о глюкозы в программе Здоровье пожалуйста откройте FreeAPS X, чтобы помочь нам гарантированно загрузить измененные данные";
 /* --------------------------------------------
 
 

+ 4 - 2
FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift

@@ -28,10 +28,12 @@ extension AppleHealthKit {
                         return
                     }
 
-                    self.healthKitManager.enableBackgroundDelivery()
+                    debug(.service, "User set permission for HealthKitManager")
+
                     self.healthKitManager.createObserver()
+                    self.healthKitManager.enableBackgroundDelivery()
                     DispatchQueue.main.async {
-                        self.needShowInformationTextForSetPermissions = !self.healthKitManager.areAllowAllPermissions
+                        self.needShowInformationTextForSetPermissions = !self.healthKitManager.checkAvailabilitySaveBG()
                     }
                 }
             }

+ 9 - 0
FreeAPS/Sources/Modules/HealthKit/View/AppleHealthKitRootView.swift

@@ -10,12 +10,21 @@ extension AppleHealthKit {
             Form {
                 Section {
                     Toggle("Connect to Apple Health", isOn: $state.useAppleHealth)
+                    HStack {
+                        Image(systemName: "pencil.circle.fill")
+                        Text(
+                            "After you create or delete glucose records in the Health app, please open FreeAPS X to help us guaranteed transfer changed data"
+                        )
+                        .font(.caption)
+                    }
+                    .foregroundColor(Color.secondary)
                     if state.needShowInformationTextForSetPermissions {
                         HStack {
                             Image(systemName: "exclamationmark.circle.fill")
                             Text("For write data to Apple Health you must give permissions in Settings > Health > Data Access")
                                 .font(.caption)
                         }
+                        .foregroundColor(Color.secondary)
                     }
                 }
             }

+ 201 - 98
FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift

@@ -9,12 +9,13 @@ protocol HealthKitManager: GlucoseSource {
     /// Check all needed permissions
     /// Return false if one or more permissions are deny or not choosen
     var areAllowAllPermissions: Bool { get }
-    /// Check availability HealthKit on current device and user's permission of object
-    func isAvailableFor(object: HKObjectType) -> Bool
+    /// Check availability to save data of concrete type to Health store
+    func checkAvailabilitySave(objectTypeToHealthStore: HKObjectType) -> Bool
+    func checkAvailabilitySaveBG() -> Bool
     /// Requests user to give permissions on using HealthKit
     func requestPermission(completion: ((Bool, Error?) -> Void)?)
-    /// Save blood glucose data to HealthKit store
-    func save(bloodGlucoses: [BloodGlucose], completion: ((Result<Bool, Error>) -> Void)?)
+    /// Save blood glucose to Health store (dublicate of bg will ignore)
+    func saveIfNeeded(bloodGlucoses: [BloodGlucose])
     /// Create observer for data passing beetwen Health Store and FreeAPS
     func createObserver()
     /// Enable background delivering objects from Apple Health to FreeAPS
@@ -27,6 +28,9 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     @Injected() private var healthKitStore: HKHealthStore!
     @Injected() private var settingsManager: SettingsManager!
 
+    private let queue = DispatchQueue(label: "debugInfoQueue")
+    private var lock = NSLock(label: "helathKitExecureQueryLock")
+
     private enum Config {
         // unwraped HKObjects
         static var permissions: Set<HKSampleType> {
@@ -41,10 +45,33 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         // link to object in HealthKit
         static let healthBGObject = HKObjectType.quantityType(forIdentifier: .bloodGlucose)
 
-        static let frequencyBackgroundDeliveryBloodGlucoseFromHealth = HKUpdateFrequency(rawValue: 10)!
+        static let frequencyBackgroundDeliveryBloodGlucoseFromHealth = HKUpdateFrequency(rawValue: 1)!
+        // Meta-data key of FreeASPX data in HealthStore
+        static let freeAPSMetaKey = "fromFreeAPSX"
     }
 
-    private var newGlucose: [BloodGlucose] = []
+    // BG that will be return Publisher
+    @Persisted(key: "HealthKitManagerNewGlucose") private var newGlucose: [BloodGlucose] = []
+
+    // last anchor for HKAnchoredQuery
+    private var lastBloodGlucoseQueryAnchor: HKQueryAnchor! {
+        set {
+            guard let data = try? NSKeyedArchiver.archivedData(withRootObject: newValue as Any, requiringSecureCoding: false)
+            else {
+                persistedAnchor = Data()
+                return
+            }
+            persistedAnchor = data
+        }
+        get {
+            guard let result = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(persistedAnchor) as? HKQueryAnchor else {
+                return HKQueryAnchor(fromValue: 0)
+            }
+            return result
+        }
+    }
+
+    @Persisted(key: "HealthKitManagerAnchor") private var persistedAnchor = Data()
 
     var isAvailableOnCurrentDevice: Bool {
         HKHealthStore.isHealthDataAvailable()
@@ -62,20 +89,37 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         return result
     }
 
+    // NSPredicate, which use during load increment BG from Health store
+    private lazy var loadBGPredicate: NSPredicate = {
+        // loading only daily bg
+        let predicateByStartDate = HKQuery.predicateForSamples(
+            withStart: Date().addingTimeInterval(-1.days.timeInterval),
+            end: nil,
+            options: .strictStartDate
+        )
+
+        // loading only not FreeAPS bg
+        // this predicate dont influence on Deleted Objects, only on added
+        let predicateByMeta = HKQuery.predicateForObjects(
+            withMetadataKey: Config.freeAPSMetaKey,
+            operatorType: .notEqualTo,
+            value: 1
+        )
+
+        return NSCompoundPredicate(andPredicateWithSubpredicates: [predicateByStartDate, predicateByMeta])
+    }()
+
     init(resolver: Resolver) {
         injectServices(resolver)
-        guard isAvailableOnCurrentDevice, let bjObject = Config.healthBGObject else {
-            return
-        }
-        if isAvailableFor(object: bjObject) {
-            debug(.service, "Create HealthKit Observer for Blood Glucose")
-            createObserver()
-        }
+        guard isAvailableOnCurrentDevice,
+              Config.healthBGObject != nil else { return }
+        createObserver()
         enableBackgroundDelivery()
+        debug(.service, "HealthKitManager did create")
     }
 
-    func isAvailableFor(object: HKObjectType) -> Bool {
-        let status = healthKitStore.authorizationStatus(for: object)
+    func checkAvailabilitySave(objectTypeToHealthStore: HKObjectType) -> Bool {
+        let status = healthKitStore.authorizationStatus(for: objectTypeToHealthStore)
         switch status {
         case .sharingAuthorized:
             return true
@@ -84,6 +128,13 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         }
     }
 
+    func checkAvailabilitySaveBG() -> Bool {
+        guard let sampleType = Config.healthBGObject else {
+            return false
+        }
+        return checkAvailabilitySave(objectTypeToHealthStore: sampleType)
+    }
+
     func requestPermission(completion: ((Bool, Error?) -> Void)? = nil) {
         guard isAvailableOnCurrentDevice else {
             completion?(false, HKError.notAvailableOnCurrentDevice)
@@ -101,9 +152,12 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         }
     }
 
-    func save(bloodGlucoses: [BloodGlucose], completion: ((Result<Bool, Error>) -> Void)? = nil) {
+    func saveIfNeeded(bloodGlucoses: [BloodGlucose]) {
         guard settingsManager.settings.useAppleHealth,
-              bloodGlucoses.isNotEmpty else { return }
+              let sampleType = Config.healthBGObject,
+              checkAvailabilitySave(objectTypeToHealthStore: sampleType),
+              bloodGlucoses.isNotEmpty
+        else { return }
 
         for bgItem in bloodGlucoses {
             let bgQuantity = HKQuantity(
@@ -112,24 +166,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
             )
 
             let bgObjectSample = HKQuantitySample(
-                type: Config.healthBGObject!,
+                type: sampleType,
                 quantity: bgQuantity,
                 start: bgItem.dateString,
                 end: bgItem.dateString,
                 metadata: [
-                    "HKMetadataKeyExternalUUID": bgItem.id,
-                    "HKMetadataKeySyncIdentifier": bgItem.id,
-                    "HKMetadataKeySyncVersion": 1,
-                    "fromFreeAPSX": true
+                    HKMetadataKeyExternalUUID: bgItem.id,
+                    HKMetadataKeySyncIdentifier: bgItem.id,
+                    HKMetadataKeySyncVersion: 1,
+                    Config.freeAPSMetaKey: true
                 ]
             )
-
-            healthKitStore.save(bgObjectSample) { status, error in
-                guard error == nil else {
-                    completion?(Result.failure(error!))
-                    return
+            load(sampleFromHealth: sampleType, withID: bgItem.id) { [weak self] samples in
+                if samples.isEmpty {
+                    self?.healthKitStore.save(bgObjectSample) { _, _ in }
                 }
-                completion?(Result.success(status))
             }
         }
     }
@@ -148,38 +199,30 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         }
 
         let query = HKObserverQuery(sampleType: bgType, predicate: nil) { [unowned self] _, _, observerError in
-
-            if let _ = observerError {
+            debug(.service, "Execute HelathKit observer query for loading increment samples")
+            guard observerError == nil else {
+                warning(.service, "Error during execution of HelathKit Observer's query", error: observerError!)
                 return
             }
 
-            // loading only daily bg
-            let predicateByDate = HKQuery.predicateForSamples(
-                withStart: Date().addingTimeInterval(-1.days.timeInterval),
-                end: nil,
-                options: .strictStartDate
-            )
-            // loading only not FreeAPS bg
-            let predicateByMeta = HKQuery.predicateForObjects(
-                withMetadataKey: "fromFreeAPSX",
-                operatorType: .notEqualTo,
-                value: 1
-            )
-            let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicateByDate, predicateByMeta])
-
-            healthKitStore.execute(getQueryForDeletedBloodGlucose(sampleType: bgType, predicate: predicateByDate))
-            healthKitStore.execute(getQueryForAddedBloodGlucose(sampleType: bgType, predicate: compoundPredicate))
+            if let incrementQuery = getBloodGlucoseHKQuery(predicate: loadBGPredicate) {
+                debug(.service, "Create increment query")
+                healthKitStore.execute(incrementQuery)
+            }
         }
         healthKitStore.execute(query)
+        debug(.service, "Create Observer for Blood Glucose")
     }
 
     func enableBackgroundDelivery() {
-        guard settingsManager.settings.useAppleHealth else { return }
+        guard settingsManager.settings.useAppleHealth else {
+            healthKitStore.disableAllBackgroundDelivery { _, _ in }
+            return }
 
         guard let bgType = Config.healthBGObject else {
             warning(
                 .service,
-                "Can not create HealthKit Background Delivery, because unable to get the Blood Glucose type",
+                "Can not create background delivery, because unable to get the Blood Glucose type",
                 description: nil,
                 error: nil
             )
@@ -191,65 +234,90 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
             frequency: Config.frequencyBackgroundDeliveryBloodGlucoseFromHealth
         ) { status, e in
             guard e == nil else {
-                warning(.service, "Can not enable background delivery for Apple Health", description: nil, error: e)
+                warning(.service, "Can not enable background delivery", description: nil, error: e)
                 return
             }
-            debug(.service, "HealthKit background delivery status is \(status)")
+            debug(.service, "Background delivery status is \(status)")
         }
     }
 
-    private func getQueryForDeletedBloodGlucose(sampleType: HKQuantityType, predicate: NSPredicate) -> HKQuery {
-        let query = HKAnchoredObjectQuery(
-            type: sampleType,
+    /// Try to load samples from Health store with id and do some work
+    private func load(
+        sampleFromHealth sampleType: HKQuantityType,
+        withID id: String,
+        andDo completion: (([HKSample]) -> Void)?
+    ) {
+        let predicate = HKQuery.predicateForObjects(
+            withMetadataKey: HKMetadataKeySyncIdentifier,
+            operatorType: .equalTo,
+            value: id
+        )
+
+        let query = HKSampleQuery(
+            sampleType: sampleType,
             predicate: predicate,
-            anchor: nil,
-            limit: 1000
-        ) { [unowned self] _, _, deletedObjects, _, _ in
-            guard let samples = deletedObjects else {
+            limit: 1,
+            sortDescriptors: nil
+        ) { _, results, _ in
+
+            guard let samples = results as? [HKQuantitySample] else {
+                completion?([])
                 return
             }
 
-            DispatchQueue.global(qos: .utility).async {
-                let removingBGID = samples.map {
-                    $0.metadata?["HKMetadataKeySyncIdentifier"] as? String ?? $0.uuid.uuidString
-                }
-                glucoseStorage.removeGlucose(ids: removingBGID)
-                newGlucose = newGlucose.filter { !removingBGID.contains($0.id) }
-            }
+            completion?(samples)
         }
-        return query
+        healthKitStore.execute(query)
     }
 
-    private func getQueryForAddedBloodGlucose(sampleType: HKQuantityType, predicate: NSPredicate) -> HKQuery {
-        let query = HKSampleQuery(
-            sampleType: sampleType,
+    private func getBloodGlucoseHKQuery(predicate: NSPredicate) -> HKQuery? {
+        guard let sampleType = Config.healthBGObject else { return nil }
+
+        let query = HKAnchoredObjectQuery(
+            type: sampleType,
             predicate: predicate,
-            limit: Int(HKObjectQueryNoLimit),
-            sortDescriptors: nil
-        ) { [unowned self] _, results, _ in
+            anchor: lastBloodGlucoseQueryAnchor,
+            limit: HKObjectQueryNoLimit
+        ) { [unowned self] _, addedObjects, deletedObjects, anchor, _ in
+            queue.sync {
+                debug(.service, "AnchoredQuery did execute")
+            }
 
-            guard let samples = results as? [HKQuantitySample], samples.isNotEmpty else {
-                return
+            lastBloodGlucoseQueryAnchor = anchor
+
+            // Added objects
+            if let bgSamples = addedObjects as? [HKQuantitySample],
+               bgSamples.isNotEmpty
+            {
+                prepare(bloodGlucoseSamplesToPublisherFetch: bgSamples)
             }
 
-            let oldSamples: [HealthKitSample] = fileStorage
-                .retrieve(OpenAPS.HealthKit.downloadedGlucose, as: [HealthKitSample].self) ?? []
-
-            let newSamples = samples
-                .compactMap { sample -> HealthKitSample? in
-                    let fromFAX = sample.metadata?["fromFreeAPSX"] as? Bool ?? false
-                    guard !fromFAX else { return nil }
-                    return HealthKitSample(
-                        healthKitId: sample.uuid.uuidString,
-                        date: sample.startDate,
-                        glucose: Int(round(sample.quantity.doubleValue(for: .milligramsPerDeciliter)))
-                    )
-                }
-                .filter { !oldSamples.contains($0) }
+            // Deleted objects
+            if let deletedSamples = deletedObjects,
+               deletedSamples.isNotEmpty
+            {
+                delete(samplesFromLocalStorage: deletedSamples)
+            }
+        }
+        return query
+    }
 
-            guard newSamples.isNotEmpty else { return }
+    private func prepare(bloodGlucoseSamplesToPublisherFetch samples: [HKQuantitySample]) {
+        queue.sync {
+            debug(.service, "Start preparing samples: \(String(describing: samples))")
+        }
 
-            let newGlucose = newSamples.map { sample in
+        newGlucose += samples
+            .compactMap { sample -> HealthKitSample? in
+                let fromFAX = sample.metadata?[Config.freeAPSMetaKey] as? Bool ?? false
+                guard !fromFAX else { return nil }
+                return HealthKitSample(
+                    healthKitId: sample.uuid.uuidString,
+                    date: sample.startDate,
+                    glucose: Int(round(sample.quantity.doubleValue(for: .milligramsPerDeciliter)))
+                )
+            }
+            .map { sample in
                 BloodGlucose(
                     _id: sample.healthKitId,
                     sgv: sample.glucose,
@@ -263,22 +331,57 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
                     type: "sgv"
                 )
             }
+            .filter { $0.dateString >= Date().addingTimeInterval(-1.days.timeInterval) }
 
-            self.newGlucose = newGlucose
+        newGlucose = newGlucose.removeDublicates()
 
-            let savingSamples = (newSamples + oldSamples)
-                .removeDublicates()
-                .filter { $0.date >= Date().addingTimeInterval(-1.days.timeInterval) }
+        queue.sync {
+            debug(
+                .service,
+                "Current BloodGlucose.Type objects will be send from Publisher during fetch: \(String(describing: newGlucose))"
+            )
+        }
+    }
 
-            self.fileStorage.save(savingSamples, as: OpenAPS.HealthKit.downloadedGlucose)
+    private func delete(samplesFromLocalStorage deletedSamples: [HKDeletedObject]) {
+        queue.sync {
+            debug(.service, "Delete HealthKit objects: \(String(describing: deletedSamples))")
+        }
+        DispatchQueue.global(qos: .utility).async {
+            let removingBGID = deletedSamples.map {
+                $0.metadata?[HKMetadataKeySyncIdentifier] as? String ?? $0.uuid.uuidString
+            }
+            self.glucoseStorage.removeGlucose(ids: removingBGID)
+            self.newGlucose = self.newGlucose.filter { !removingBGID.contains($0.id) }
         }
-        return query
     }
 
     func fetch() -> AnyPublisher<[BloodGlucose], Never> {
-        guard settingsManager.settings.useAppleHealth else { return Just([]).eraseToAnyPublisher() }
-        let actualGlucose = newGlucose.filter { $0.dateString <= Date() }
-        newGlucose = newGlucose.filter { !actualGlucose.contains($0) }
+        queue.sync {
+            debug(.service, "Start fetching HealthKitManager")
+        }
+        guard settingsManager.settings.useAppleHealth else {
+            queue.sync {
+                debug(.service, "HealthKitManager cant return any data, because useAppleHealth option is disable")
+            }
+            return Just([]).eraseToAnyPublisher()
+        }
+
+        // Remove old BGs
+        newGlucose = newGlucose
+            .filter { $0.dateString >= Date().addingTimeInterval(-1.days.timeInterval) }
+        // Get actual BGs (beetwen Date() - 1 day and Date())
+        let actualGlucose = newGlucose
+            .filter { $0.dateString <= Date() }
+        // Update newGlucose
+        newGlucose = newGlucose
+            .filter { !actualGlucose.contains($0) }
+        queue.sync {
+            debug(.service, "Actual glucose is \(actualGlucose)")
+        }
+        queue.sync {
+            debug(.service, "Current state of newGlucose is \(newGlucose)")
+        }
         return Just(actualGlucose).eraseToAnyPublisher()
     }
 }