diff --git a/FreeAPS.xcodeproj/project.pbxproj b/FreeAPS.xcodeproj/project.pbxproj index 166e418f4..c58f79557 100644 --- a/FreeAPS.xcodeproj/project.pbxproj +++ b/FreeAPS.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 1D845DF2E3324130E1D95E67 /* DataTableProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60744C3E9BB3652895C908CC /* DataTableProvider.swift */; }; 23888883D4EA091C88480FF2 /* BolusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19984D62EFC0035A9E9644D /* BolusProvider.swift */; }; 28089E07169488CF6DCC2A31 /* AddCarbsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FC1CFD647CF34508AF9A3B /* AddCarbsRootView.swift */; }; + 29AC4F68277F8A2100766404 /* CarbSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AC4F67277F8A2100766404 /* CarbSource.swift */; }; 2BE9A6FA20875F6F4F9CD461 /* PumpSettingsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97F14812C1AFED3621165A5 /* PumpSettingsEditorProvider.swift */; }; 3083261C4B268E353F36CD0B /* AutotuneConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DCCCCE633F5E98E41B0CD3C /* AutotuneConfigDataFlow.swift */; }; 3171D2818C7C72CD1584BB5E /* NotificationsConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2C6489D29ECCCAD78E0721 /* NotificationsConfigStateModel.swift */; }; @@ -439,6 +440,8 @@ 212E8BFE6D66EE65AA26A114 /* CalibrationsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CalibrationsProvider.swift; sourceTree = ""; }; 223EC0494F55A91E3EA69EF4 /* BolusStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusStateModel.swift; sourceTree = ""; }; 22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigRootView.swift; sourceTree = ""; }; + 29AC4F65277F48C100766404 /* ConfigOverride.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ConfigOverride.xcconfig; sourceTree = ""; }; + 29AC4F67277F8A2100766404 /* CarbSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSource.swift; sourceTree = ""; }; 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigRootView.swift; sourceTree = ""; }; 2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigDataFlow.swift; sourceTree = ""; }; 3260468377DA9DB4DEE9AF6D /* NotificationsConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigDataFlow.swift; sourceTree = ""; }; @@ -480,7 +483,6 @@ 3811DEE725CA063400A708ED /* PersistedProperty.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; 3811DF0125CA9FEA00A708ED /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; 3811DF0F25CAAAE200A708ED /* APSManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APSManager.swift; sourceTree = ""; }; - 3818AA42274BBC1100843DB3 /* ConfigOverride.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ConfigOverride.xcconfig; sourceTree = ""; }; 3818AA45274C229000843DB3 /* LibreTransmitter */ = {isa = PBXFileReference; lastKnownFileType = folder; name = LibreTransmitter; path = Dependencies/LibreTransmitter; sourceTree = ""; }; 3818AA49274C267000843DB3 /* CGMBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA4C274C26A300843DB3 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1200,7 +1202,7 @@ isa = PBXGroup; children = ( 38F3783A2613555C009DB701 /* Config.xcconfig */, - 3818AA42274BBC1100843DB3 /* ConfigOverride.xcconfig */, + 29AC4F65277F48C100766404 /* ConfigOverride.xcconfig */, 388E595A25AD948C0019842D /* FreeAPS */, 38FCF3EE25E9028E0078B0D1 /* FreeAPSTests */, 3818AA44274C229000843DB3 /* Packages */, @@ -1310,6 +1312,7 @@ 38A0363A25ECF07E00FCBB52 /* GlucoseStorage.swift */, 38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */, 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */, + 29AC4F67277F8A2100766404 /* CarbSource.swift */, ); path = Storage; sourceTree = ""; @@ -2182,6 +2185,7 @@ 38E98A2525F52C9300C0CED0 /* IssueReporter.swift in Sources */, 3811DEB025C9D88300A708ED /* BaseKeychain.swift in Sources */, 3811DE4325C9D4A100A708ED /* SettingsProvider.swift in Sources */, + 29AC4F68277F8A2100766404 /* CarbSource.swift in Sources */, 45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */, 3871F38725ED661C0013ECB5 /* Suggestion.swift in Sources */, 38C4D33A25E9A1ED00D30B77 /* NSObject+AssociatedValues.swift in Sources */, @@ -2553,7 +2557,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; + DEVELOPMENT_TEAM = 4W28235M63; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = FreeAPS/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -2589,7 +2593,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; + DEVELOPMENT_TEAM = 4W28235M63; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = FreeAPS/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -2625,7 +2629,7 @@ CODE_SIGN_ENTITLEMENTS = FreeAPSWatch/FreeAPSWatch.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(BUILD_VERSION)"; - DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; + DEVELOPMENT_TEAM = 4W28235M63; GENERATE_INFOPLIST_FILE = YES; IBSC_MODULE = FreeAPSWatch_WatchKit_Extension; INFOPLIST_KEY_CFBundleDisplayName = "$(APP_DISPLAY_NAME)"; @@ -2655,7 +2659,7 @@ CODE_SIGN_ENTITLEMENTS = FreeAPSWatch/FreeAPSWatch.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(BUILD_VERSION)"; - DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; + DEVELOPMENT_TEAM = 4W28235M63; GENERATE_INFOPLIST_FILE = YES; IBSC_MODULE = FreeAPSWatch_WatchKit_Extension; INFOPLIST_KEY_CFBundleDisplayName = "$(APP_DISPLAY_NAME)"; @@ -2684,7 +2688,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(BUILD_VERSION)"; DEVELOPMENT_ASSET_PATHS = "\"FreeAPSWatch WatchKit Extension/Preview Content\""; - DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; + DEVELOPMENT_TEAM = 4W28235M63; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "FreeAPSWatch WatchKit Extension/Info.plist"; @@ -2720,7 +2724,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(BUILD_VERSION)"; DEVELOPMENT_ASSET_PATHS = "\"FreeAPSWatch WatchKit Extension/Preview Content\""; - DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; + DEVELOPMENT_TEAM = 4W28235M63; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "FreeAPSWatch WatchKit Extension/Info.plist"; @@ -2750,7 +2754,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)"; + DEVELOPMENT_TEAM = 4W28235M63; INFOPLIST_FILE = FreeAPSTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( @@ -2771,7 +2775,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)"; + DEVELOPMENT_TEAM = 4W28235M63; INFOPLIST_FILE = FreeAPSTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/FreeAPS/Sources/APS/FetchTreatmentsManager.swift b/FreeAPS/Sources/APS/FetchTreatmentsManager.swift index 81be5a6b9..7457871a2 100644 --- a/FreeAPS/Sources/APS/FetchTreatmentsManager.swift +++ b/FreeAPS/Sources/APS/FetchTreatmentsManager.swift @@ -10,6 +10,7 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable { @Injected() var nightscoutManager: NightscoutManager! @Injected() var tempTargetsStorage: TempTargetsStorage! @Injected() var carbsStorage: CarbsStorage! + @Injected() var healthKitManager: HealthKitManager! private var lifetime = Lifetime() private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval) @@ -22,19 +23,29 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable { private func subscribe() { timer.publisher .receive(on: processQueue) - .flatMap { _ -> AnyPublisher<([CarbsEntry], [TempTarget]), Never> in + .flatMap { _ -> AnyPublisher<([CarbsEntry], [TempTarget], [CarbsEntry]), Never> in debug(.nightscout, "FetchTreatmentsManager heartbeat") debug(.nightscout, "Start fetching carbs and temptargets") - return Publishers.CombineLatest( + return Publishers.CombineLatest3( self.nightscoutManager.fetchCarbs(), - self.nightscoutManager.fetchTempTargets() + self.nightscoutManager.fetchTempTargets(), + self.healthKitManager.fetchCarbs() ).eraseToAnyPublisher() } - .sink { carbs, targets in - let filteredCarbs = carbs.filter { !($0.enteredBy?.contains(CarbsEntry.manual) ?? false) } + .sink { carbs, targets, carbsFromHealth in + let since = self.carbsStorage.syncDate() + let nsCarbs = carbs.filter { !($0.enteredBy?.contains(CarbsEntry.manual) ?? false) } + + let allCarbs = nsCarbs + carbsFromHealth + let filteredCarbs = allCarbs.filter { $0.createdAt > since } + let carbsForHealth = allCarbs.filter { !carbsFromHealth.contains($0) } + if filteredCarbs.isNotEmpty { self.carbsStorage.storeCarbs(filteredCarbs) } + if carbsForHealth.isNotEmpty { + self.healthKitManager.saveIfNeeded(carbs: filteredCarbs) + } let filteredTargets = targets.filter { !($0.enteredBy?.contains(TempTarget.manual) ?? false) } if filteredTargets.isNotEmpty { self.tempTargetsStorage.storeTempTargets(filteredTargets) diff --git a/FreeAPS/Sources/APS/Storage/CarbSource.swift b/FreeAPS/Sources/APS/Storage/CarbSource.swift new file mode 100644 index 000000000..48c2c51c3 --- /dev/null +++ b/FreeAPS/Sources/APS/Storage/CarbSource.swift @@ -0,0 +1,5 @@ +import Combine + +protocol CarbSource { + func fetchCarbs() -> AnyPublisher<[CarbsEntry], Never> +} diff --git a/FreeAPS/Sources/APS/Storage/CarbsStorage.swift b/FreeAPS/Sources/APS/Storage/CarbsStorage.swift index 16c23f10d..77d62da05 100644 --- a/FreeAPS/Sources/APS/Storage/CarbsStorage.swift +++ b/FreeAPS/Sources/APS/Storage/CarbsStorage.swift @@ -32,10 +32,17 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { uniqEvents = storage.retrieve(file, as: [CarbsEntry].self)? .filter { $0.createdAt.addingTimeInterval(1.days.timeInterval) > Date() } .sorted { $0.createdAt > $1.createdAt } ?? [] + debug( + .service, + "Storing file carbs: \(String(describing: uniqEvents))" + ) storage.save(Array(uniqEvents), as: file) } - broadcaster.notify(CarbsObserver.self, on: processQueue) { - $0.carbsDidUpdate(uniqEvents) + + DispatchQueue.main.async { + self.broadcaster.notify(CarbsObserver.self, on: .main) { + $0.carbsDidUpdate(uniqEvents) + } } } } diff --git a/FreeAPS/Sources/Models/CarbsEntry.swift b/FreeAPS/Sources/Models/CarbsEntry.swift index dbcbc237f..f22a705bc 100644 --- a/FreeAPS/Sources/Models/CarbsEntry.swift +++ b/FreeAPS/Sources/Models/CarbsEntry.swift @@ -1,11 +1,13 @@ import Foundation struct CarbsEntry: JSON, Equatable, Hashable { + let id: String let createdAt: Date let carbs: Decimal let enteredBy: String? static let manual = "freeaps-x" + static let healthKit = "healthkit" static func == (lhs: CarbsEntry, rhs: CarbsEntry) -> Bool { lhs.createdAt == rhs.createdAt @@ -18,6 +20,7 @@ struct CarbsEntry: JSON, Equatable, Hashable { extension CarbsEntry { private enum CodingKeys: String, CodingKey { + case id case createdAt = "created_at" case carbs case enteredBy diff --git a/FreeAPS/Sources/Models/HealthKitSample.swift b/FreeAPS/Sources/Models/HealthKitSample.swift index 58b3f6845..57368f7df 100644 --- a/FreeAPS/Sources/Models/HealthKitSample.swift +++ b/FreeAPS/Sources/Models/HealthKitSample.swift @@ -3,7 +3,8 @@ import Foundation struct HealthKitSample: JSON, Hashable, Equatable { var healthKitId: String var date: Date - var glucose: Int + var glucose: Int? + var carb: Decimal? static func == (lhs: HealthKitSample, rhs: HealthKitSample) -> Bool { lhs.healthKitId == rhs.healthKitId @@ -15,5 +16,6 @@ extension HealthKitSample { case healthKitId = "healthkit_id" case date case glucose + case carb } } diff --git a/FreeAPS/Sources/Modules/AddCarbs/AddCarbsStateModel.swift b/FreeAPS/Sources/Modules/AddCarbs/AddCarbsStateModel.swift index 7bac8146c..dd07239e3 100644 --- a/FreeAPS/Sources/Modules/AddCarbs/AddCarbsStateModel.swift +++ b/FreeAPS/Sources/Modules/AddCarbs/AddCarbsStateModel.swift @@ -4,6 +4,8 @@ extension AddCarbs { final class StateModel: BaseStateModel { @Injected() var carbsStorage: CarbsStorage! @Injected() var apsManager: APSManager! + @Injected() var healthKitManager: HealthKitManager! + @Published var ID: String = UUID().uuidString @Published var carbs: Decimal = 0 @Published var date = Date() @Published var carbsRequired: Decimal? @@ -18,9 +20,11 @@ extension AddCarbs { return } - carbsStorage.storeCarbs([ - CarbsEntry(createdAt: date, carbs: carbs, enteredBy: CarbsEntry.manual) - ]) + let carbArray = [ + CarbsEntry(id: ID, createdAt: date, carbs: carbs, enteredBy: CarbsEntry.manual) + ] + carbsStorage.storeCarbs(carbArray) + healthKitManager.saveIfNeeded(carbs: carbArray) if settingsManager.settings.skipBolusScreenAfterCarbs { apsManager.determineBasalSync() diff --git a/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift b/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift index 134031c3b..66027250f 100644 --- a/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift +++ b/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift @@ -18,11 +18,16 @@ extension DataTable { } func carbs() -> [CarbsEntry] { - carbsStorage.recent() + carbsStorage.recent().sorted { $0.createdAt > $1.createdAt } } func deleteCarbs(at date: Date) { + guard let healthCarb = carbsStorage.recent().first(where: { $0.createdAt == date }) else { + debug(.service, "Failed to delete carbs") + return + } nightscoutManager.deleteCarbs(at: date) + healthkitManager.deleteCarb(syncID: healthCarb.id) } func glucose() -> [BloodGlucose] { @@ -31,7 +36,7 @@ extension DataTable { func deleteGlucose(id: String) { glucoseStorage.removeGlucose(ids: [id]) - healthkitManager.deleteGlucise(syncID: id) + healthkitManager.deleteGlucose(syncID: id) } } } diff --git a/FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift b/FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift index 1c5bb6cfc..3bcea073e 100644 --- a/FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift +++ b/FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift @@ -63,7 +63,7 @@ extension DataTable { private var glucoseList: some View { List { ForEach(state.glucose) { item in - gluciseView(item) + glucoseView(item) }.onDelete(perform: deleteGlucose) } } @@ -103,7 +103,7 @@ extension DataTable { } } - @ViewBuilder private func gluciseView(_ item: Glucose) -> some View { + @ViewBuilder private func glucoseView(_ item: Glucose) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(dateFormatter.string(from: item.glucose.dateString)) diff --git a/FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift b/FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift index fa23644b2..fd5018b13 100644 --- a/FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift +++ b/FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift @@ -25,7 +25,10 @@ extension AppleHealthKit { self.healthKitManager.requestPermission { ok, error in DispatchQueue.main.async { - self.needShowInformationTextForSetPermissions = !self.healthKitManager.checkAvailabilitySaveBG() + self.needShowInformationTextForSetPermissions = !( + self.healthKitManager.checkAvailabilitySaveBG() && self + .healthKitManager.checkAvailabilitySaveCarb() + ) } guard ok, error == nil else { @@ -33,9 +36,10 @@ extension AppleHealthKit { return } - debug(.service, "Permission granted HealthKitManager") + debug(.service, "Permission granted HealthKitManager") - self.healthKitManager.createObserver() + self.healthKitManager.createGlucoseObserver() + self.healthKitManager.createCarbObserver() self.healthKitManager.enableBackgroundDelivery() } } diff --git a/FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift b/FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift index 869ea4df1..5781fe7b6 100644 --- a/FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift +++ b/FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift @@ -3,46 +3,59 @@ import Foundation import HealthKit import Swinject -protocol HealthKitManager: GlucoseSource { +protocol HealthKitManager: GlucoseSource, CarbSource { /// Check all needed permissions /// Return false if one or more permissions are deny or not choosen var areAllowAllPermissions: Bool { get } /// Check availability to save data of BG type to Health store func checkAvailabilitySaveBG() -> Bool + /// Check availability to save data of Carb type to Health store + func checkAvailabilitySaveCarb() -> Bool /// Requests user to give permissions on using HealthKit func requestPermission(completion: ((Bool, Error?) -> Void)?) /// Save blood glucose to Health store (dublicate of bg will ignore) func saveIfNeeded(bloodGlucose: [BloodGlucose]) - /// Create observer for data passing beetwen Health Store and FreeAPS - func createObserver() + /// Save carb to Health store (duplicates will be ignored) + func saveIfNeeded(carbs: [CarbsEntry]) + /// Create observer for glucose data passing beetwen Health Store and FreeAPS + func createGlucoseObserver() + /// Create observer for carb data passing beetwen Health Store and FreeAPS + func createCarbObserver() /// Enable background delivering objects from Apple Health to FreeAPS func enableBackgroundDelivery() /// Delete glucose with syncID - func deleteGlucise(syncID: String) + func deleteGlucose(syncID: String) + /// Delete carb at specified date + func deleteCarb(syncID: String) } final class BaseHealthKitManager: HealthKitManager, Injectable { private enum Config { // unwraped HKObjects - static var permissions: Set { Set([healthBGObject].compactMap { $0 }) } + static var permissions: Set { Set(healthObjects.compactMap { $0 }) } - // link to object in HealthKit - static let healthBGObject = HKObjectType.quantityType(forIdentifier: .bloodGlucose) + // link to objects in HealthKit + static let healthObjects = [ + HKObjectType.quantityType(forIdentifier: .bloodGlucose), + HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates) + ] // Meta-data key of FreeASPX data in HealthStore static let freeAPSMetaKey = "fromFreeAPSX" } - @Injected() private var glucoseStorage: GlucoseStorage! @Injected() private var healthKitStore: HKHealthStore! @Injected() private var settingsManager: SettingsManager! - private let processQueue = DispatchQueue(label: "BaseHealthKitManager.processQueue") + private let glucoseProcessQueue = DispatchQueue(label: "BaseHealthKitManager.glucoseProcessQueue") + private let carbProcessQueue = DispatchQueue(label: "BaseHealthKitManager.carbProcessQueue") private var lifetime = Lifetime() // BG that will be return Publisher @SyncAccess @Persisted(key: "BaseHealthKitManager.newGlucose") private var newGlucose: [BloodGlucose] = [] + @SyncAccess @Persisted(key: "BaseHealthKitManager.newCarb") private var newCarb: [CarbsEntry] = [] + // last anchor for HKAnchoredQuery private var lastBloodGlucoseQueryAnchor: HKQueryAnchor? { set { @@ -54,6 +67,16 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { } } + private var lastCarbQueryAnchor: HKQueryAnchor? { + set { + persistedAnchor = try? NSKeyedArchiver.archivedData(withRootObject: newValue as Any, requiringSecureCoding: false) + } + get { + guard let data = persistedAnchor else { return nil } + return try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? HKQueryAnchor + } + } + @Persisted(key: "HealthKitManagerAnchor") private var persistedAnchor: Data? = nil var isAvailableOnCurrentDevice: Bool { @@ -66,16 +89,16 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { .isEmpty } - // NSPredicate, which use during load increment BG from Health store - private var loadBGPredicate: NSPredicate { - // loading only daily bg + // NSPredicate, which use during load increment values from Health store + private var loadValuePredicate: NSPredicate { + // loading only daily values let predicateByStartDate = HKQuery.predicateForSamples( withStart: Date().addingTimeInterval(-1.days.timeInterval), end: nil, options: .strictStartDate ) - // loading only not FreeAPS bg + // loading only not FreeAPS values // this predicate dont influence on Deleted Objects, only on added let predicateByMeta = HKQuery.predicateForObjects( withMetadataKey: Config.freeAPSMetaKey, @@ -89,8 +112,9 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { init(resolver: Resolver) { injectServices(resolver) guard isAvailableOnCurrentDevice, - Config.healthBGObject != nil else { return } - createObserver() + !Config.healthObjects.isEmpty else { return } + createGlucoseObserver() + createCarbObserver() enableBackgroundDelivery() debug(.service, "HealthKitManager did create") } @@ -100,7 +124,11 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { } func checkAvailabilitySaveBG() -> Bool { - Config.healthBGObject.map { checkAvailabilitySave(objectTypeToHealthStore: $0) } ?? false + Config.healthObjects[0].map { checkAvailabilitySave(objectTypeToHealthStore: $0) } ?? false + } + + func checkAvailabilitySaveCarb() -> Bool { + Config.healthObjects[1].map { checkAvailabilitySave(objectTypeToHealthStore: $0) } ?? false } func requestPermission(completion: ((Bool, Error?) -> Void)? = nil) { @@ -120,7 +148,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { func saveIfNeeded(bloodGlucose: [BloodGlucose]) { guard settingsManager.settings.useAppleHealth, - let sampleType = Config.healthBGObject, + let sampleType = Config.healthObjects[0], checkAvailabilitySave(objectTypeToHealthStore: sampleType), bloodGlucose.isNotEmpty else { return } @@ -148,20 +176,55 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { } loadSamplesFromHealth(sampleType: sampleType, withIDs: bloodGlucose.map(\.id)) - .receive(on: processQueue) + .receive(on: glucoseProcessQueue) .sink(receiveValue: save) .store(in: &lifetime) } - func createObserver() { + func saveIfNeeded(carbs: [CarbsEntry]) { + guard settingsManager.settings.useAppleHealth, + let sampleType = Config.healthObjects[1], + checkAvailabilitySave(objectTypeToHealthStore: sampleType), + carbs.isNotEmpty + else { return } + + func save(samples: [HKSample]) { + let sampleIDs = samples.compactMap(\.syncIdentifier) + let samplesToSave = carbs + .filter { !sampleIDs.contains($0.id) } + .map { + HKQuantitySample( + type: sampleType, + quantity: HKQuantity(unit: .gram(), doubleValue: Double($0.carbs)), + start: $0.createdAt, + end: $0.createdAt, + metadata: [ + HKMetadataKeyExternalUUID: $0.id, + HKMetadataKeySyncIdentifier: $0.id, + HKMetadataKeySyncVersion: 1, + Config.freeAPSMetaKey: true + ] + ) + } + + healthKitStore.save(samplesToSave) { _, _ in } + } + + loadSamplesFromHealth(sampleType: sampleType, withIDs: carbs.map(\.id)) + .receive(on: carbProcessQueue) + .sink(receiveValue: save) + .store(in: &lifetime) + } + + func createGlucoseObserver() { guard settingsManager.settings.useAppleHealth else { return } - guard let bgType = Config.healthBGObject else { + guard let bgType = Config.healthObjects[0] else { warning(.service, "Can not create HealthKit Observer, because unable to get the Blood Glucose type") return } - let query = HKObserverQuery(sampleType: bgType, predicate: nil) { [weak self] _, _, observerError in + let glucoseQuery = HKObserverQuery(sampleType: bgType, predicate: nil) { [weak self] _, _, observerError in guard let self = self else { return } debug(.service, "Execute HelathKit observer query for loading increment samples") guard observerError == nil else { @@ -169,21 +232,48 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { return } - if let incrementQuery = self.getBloodGlucoseHKQuery(predicate: self.loadBGPredicate) { + if let incrementQuery = self.getBloodGlucoseHKQuery(predicate: self.loadValuePredicate) { debug(.service, "Create increment query") self.healthKitStore.execute(incrementQuery) } } - healthKitStore.execute(query) + + healthKitStore.execute(glucoseQuery) debug(.service, "Create Observer for Blood Glucose") } + func createCarbObserver() { + guard settingsManager.settings.useAppleHealth else { return } + + guard let carbType = Config.healthObjects[1] else { + warning(.service, "Can not create HealthKit Observer, because unable to get the Carb type") + return + } + + let carbQuery = HKObserverQuery(sampleType: carbType, predicate: nil) { [weak self] _, _, observerError in + guard let self = self else { return } + 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 + } + + if let incrementQuery = self.getCarbHKQuery(predicate: self.loadValuePredicate) { + debug(.service, "Create carb increment query") + self.healthKitStore.execute(incrementQuery) + } + } + + healthKitStore.execute(carbQuery) + debug(.service, "Create Observer for Carbohydrate") + } + func enableBackgroundDelivery() { guard settingsManager.settings.useAppleHealth else { healthKitStore.disableAllBackgroundDelivery { _, _ in } return } - guard let bgType = Config.healthBGObject else { + guard let bgType = Config.healthObjects[0] else { warning( .service, "Can not create background delivery, because unable to get the Blood Glucose type" @@ -191,12 +281,28 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { return } + guard let carbType = Config.healthObjects[1] else { + warning( + .service, + "Can not create background delivery, because unable to get the Carb type" + ) + return + } + healthKitStore.enableBackgroundDelivery(for: bgType, frequency: .immediate) { status, error in guard error == nil else { - warning(.service, "Can not enable background delivery", error: error) + warning(.service, "Can not enable bg background delivery", error: error) return } - debug(.service, "Background delivery status is \(status)") + debug(.service, "Background delivery bg status is \(status)") + } + + healthKitStore.enableBackgroundDelivery(for: carbType, frequency: .immediate) { status, error in + guard error == nil else { + warning(.service, "Can not enable carb background delivery", error: error) + return + } + debug(.service, "Background delivery carb status is \(status)") } } @@ -224,7 +330,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { } private func getBloodGlucoseHKQuery(predicate: NSPredicate) -> HKQuery? { - guard let sampleType = Config.healthBGObject else { return nil } + guard let sampleType = Config.healthObjects[0] else { return nil } let query = HKAnchoredObjectQuery( type: sampleType, @@ -233,8 +339,8 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { limit: HKObjectQueryNoLimit ) { [weak self] _, addedObjects, _, anchor, _ in guard let self = self else { return } - self.processQueue.async { - debug(.service, "AnchoredQuery did execute") + self.glucoseProcessQueue.async { + debug(.service, "AnchoredQuery for glucose did execute") self.lastBloodGlucoseQueryAnchor = anchor @@ -242,15 +348,42 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { if let bgSamples = addedObjects as? [HKQuantitySample], bgSamples.isNotEmpty { - self.prepareSamplesToPublisherFetch(bgSamples) + self.prepareBGSamplesToPublisherFetch(bgSamples) + } + } + } + return query + } + + private func getCarbHKQuery(predicate: NSPredicate) -> HKQuery? { + guard let sampleType = Config.healthObjects[1] else { return nil } + + let query = HKAnchoredObjectQuery( + type: sampleType, + predicate: predicate, + anchor: lastCarbQueryAnchor, + limit: HKObjectQueryNoLimit + ) { [weak self] _, addedObjects, _, anchor, _ in + guard let self = self else { return } + self.carbProcessQueue.async { + debug(.service, "AnchoredQuery for carbs did execute") + + self.lastCarbQueryAnchor = anchor + + // Added objects + debug(.service, "getCarbHKQuery: \(String(describing: addedObjects))") + if let carbSamples = addedObjects as? [HKQuantitySample], + carbSamples.isNotEmpty + { + self.prepareCarbSamplesToPublisherFetch(carbSamples) } } } return query } - private func prepareSamplesToPublisherFetch(_ samples: [HKQuantitySample]) { - dispatchPrecondition(condition: .onQueue(processQueue)) + private func prepareBGSamplesToPublisherFetch(_ samples: [HKQuantitySample]) { + dispatchPrecondition(condition: .onQueue(glucoseProcessQueue)) debug(.service, "Start preparing samples: \(String(describing: samples))") newGlucose += samples @@ -260,7 +393,8 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { return HealthKitSample( healthKitId: sample.uuid.uuidString, date: sample.startDate, - glucose: Int(round(sample.quantity.doubleValue(for: .milligramsPerDeciliter))) + glucose: Int(round(sample.quantity.doubleValue(for: .milligramsPerDeciliter))), + carb: nil ) } .map { sample in @@ -287,6 +421,39 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { ) } + private func prepareCarbSamplesToPublisherFetch(_ samples: [HKQuantitySample]) { + dispatchPrecondition(condition: .onQueue(carbProcessQueue)) + debug(.service, "Start preparing samples: \(String(describing: samples))") + + newCarb += 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: nil, + carb: sample.quantity.doubleValue(for: .gram()).decimal + ) + } + .map { sample in + CarbsEntry( + id: sample.healthKitId, + createdAt: sample.date, + carbs: sample.carb as? Decimal ?? 0.0, + enteredBy: CarbsEntry.healthKit + ) + } + .filter { $0.createdAt >= Date().addingTimeInterval(-1.days.timeInterval) } + + newCarb = newCarb.removeDublicates() + + debug( + .service, + "Current Carb.Type objects will be send from Publisher during fetch: \(String(describing: newCarb))" + ) + } + func fetch() -> AnyPublisher<[BloodGlucose], Never> { Future { [weak self] promise in guard let self = self else { @@ -294,10 +461,10 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { return } - self.processQueue.async { - debug(.service, "Start fetching HealthKitManager") + self.glucoseProcessQueue.async { + debug(.service, "Start fetching HealthKitManager Glucose") guard self.settingsManager.settings.useAppleHealth else { - debug(.service, "HealthKitManager cant return any data, because useAppleHealth option is disable") + debug(.service, "HealthKitManager cant return any glucose data, because useAppleHealth option is disable") promise(.success([])) return } @@ -322,13 +489,70 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { .eraseToAnyPublisher() } - func deleteGlucise(syncID: String) { + func fetchCarbs() -> AnyPublisher<[CarbsEntry], Never> { + Future { [weak self] promise in + guard let self = self else { + promise(.success([])) + return + } + + self.carbProcessQueue.async { + debug(.service, "Start fetching HealthKitManager Carbs") + guard self.settingsManager.settings.useAppleHealth else { + debug(.service, "HealthKitManager cant return any carb data, because useAppleHealth option is disable") + promise(.success([])) + return + } + + debug(.service, "Old state of newCarb is \(self.newCarb)") + + // Remove old carbs + self.newCarb = self.newCarb + .filter { $0.createdAt >= Date().addingTimeInterval(-1.days.timeInterval) } + // Get actual carbs (beetwen Date() - 1 day and Date()) + let actualCarb = self.newCarb + .filter { $0.createdAt <= Date() } + // Update newCarb + self.newCarb = self.newCarb + .filter { !actualCarb.contains($0) } + + debug(.service, "Actual carb is \(actualCarb)") + + debug(.service, "Current state of newCarb is \(self.newCarb)") + + promise(.success(actualCarb)) + } + } + .eraseToAnyPublisher() + } + + func deleteGlucose(syncID: String) { + guard settingsManager.settings.useAppleHealth, + let sampleType = Config.healthObjects[0], + checkAvailabilitySave(objectTypeToHealthStore: sampleType) + else { return } + + glucoseProcessQueue.async { + let predicate = HKQuery.predicateForObjects( + withMetadataKey: HKMetadataKeySyncIdentifier, + operatorType: .equalTo, + value: syncID + ) + + self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in + guard let error = error else { return } + warning(.service, "Cannot delete sample with syncID: \(syncID)", error: error) + } + } + } + + func deleteCarb(syncID: String) { guard settingsManager.settings.useAppleHealth, - let sampleType = Config.healthBGObject, + let sampleType = Config.healthObjects[1], checkAvailabilitySave(objectTypeToHealthStore: sampleType) else { return } - processQueue.async { + carbProcessQueue.async { let predicate = HKQuery.predicateForObjects( withMetadataKey: HKMetadataKeySyncIdentifier, operatorType: .equalTo, diff --git a/FreeAPS/Sources/Services/Network/NightscoutManager.swift b/FreeAPS/Sources/Services/Network/NightscoutManager.swift index c6b20767c..f40cd2d78 100644 --- a/FreeAPS/Sources/Services/Network/NightscoutManager.swift +++ b/FreeAPS/Sources/Services/Network/NightscoutManager.swift @@ -3,7 +3,7 @@ import Foundation import Swinject import UIKit -protocol NightscoutManager: GlucoseSource { +protocol NightscoutManager: GlucoseSource, CarbSource { func fetchGlucose(since date: Date) -> AnyPublisher<[BloodGlucose], Never> func fetchCarbs() -> AnyPublisher<[CarbsEntry], Never> func fetchTempTargets() -> AnyPublisher<[TempTarget], Never> diff --git a/FreeAPS/Sources/Services/WatchManager/WatchManager.swift b/FreeAPS/Sources/Services/WatchManager/WatchManager.swift index 6ef13a5b7..22a89f4c3 100644 --- a/FreeAPS/Sources/Services/WatchManager/WatchManager.swift +++ b/FreeAPS/Sources/Services/WatchManager/WatchManager.swift @@ -16,6 +16,7 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable { @Injected() private var storage: FileStorage! @Injected() private var carbsStorage: CarbsStorage! @Injected() private var tempTargetsStorage: TempTargetsStorage! + @Injected() var healthKitManager: HealthKitManager! private var lifetime = Lifetime() @@ -216,9 +217,11 @@ extension BaseWatchManager: WCSessionDelegate { debug(.service, "WCSession got message with reply handler: \(message)") if let carbs = message["carbs"] as? Double, carbs > 0 { - carbsStorage.storeCarbs([ - CarbsEntry(createdAt: Date(), carbs: Decimal(carbs), enteredBy: CarbsEntry.manual) - ]) + let carbArray = [ + CarbsEntry(id: UUID().uuidString, createdAt: Date(), carbs: Decimal(carbs), enteredBy: CarbsEntry.manual) + ] + carbsStorage.storeCarbs(carbArray) + healthKitManager.saveIfNeeded(carbs: carbArray) if settingsManager.settings.skipBolusScreenAfterCarbs { apsManager.determineBasalSync()