|
|
@@ -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()
|
|
|
}
|
|
|
}
|