Browse Source

Add HealthKit integration (only blood glucose reading/writing) (#135)

* 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
Jon B.M 4 years ago
parent
commit
ccaa9107aa

+ 58 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -289,6 +289,9 @@
 		E00EEC0727368630002FF094 /* APSAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEC0127368630002FF094 /* APSAssembly.swift */; };
 		E00EEC0827368630002FF094 /* NetworkAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEC0227368630002FF094 /* NetworkAssembly.swift */; };
 		E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */; };
+		E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06B9119275B5EEA003C04B6 /* Array+Extension.swift */; };
+		E0CC2C5C275B9F0F00A7BC71 /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */; };
+		E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */; };
 		E13B7DAB2A435F57066AF02E /* TargetsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */; };
 		E25073BC86C11C3D6A42F5AC /* CalibrationsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47DFCE895C930F784EF11843 /* CalibrationsStateModel.swift */; };
 		E39E418C56A5A46B61D960EE /* ConfigEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5B4F8B4194BB7E260EF251 /* ConfigEditorStateModel.swift */; };
@@ -298,6 +301,11 @@
 		E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */; };
 		F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */; };
 		F5F7E6C1B7F098F59EB67EC5 /* TargetsEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */; };
+		F90692AA274B7AAE0037068D /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90692A9274B7AAE0037068D /* HealthKitManager.swift */; };
+		F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90692CE274B999A0037068D /* HealthKitDataFlow.swift */; };
+		F90692D1274B99B60037068D /* HealthKitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90692D0274B99B60037068D /* HealthKitProvider.swift */; };
+		F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90692D2274B9A130037068D /* AppleHealthKitRootView.swift */; };
+		F90692D6274B9A450037068D /* HealthKitStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90692D5274B9A450037068D /* HealthKitStateModel.swift */; };
 		FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */; };
 /* End PBXBuildFile section */
 
@@ -685,11 +693,19 @@
 		E00EEC0127368630002FF094 /* APSAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APSAssembly.swift; sourceTree = "<group>"; };
 		E00EEC0227368630002FF094 /* NetworkAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = "<group>"; };
 		E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSimulatorSource.swift; sourceTree = "<group>"; };
+		E06B9119275B5EEA003C04B6 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
+		E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; };
+		E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSample.swift; sourceTree = "<group>"; };
 		E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeStateModel.swift; sourceTree = "<group>"; };
 		E2EBA7C03C26FCC67E16D798 /* LibreConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreConfigProvider.swift; sourceTree = "<group>"; };
 		E625985B47742D498CB1681A /* NotificationsConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigProvider.swift; sourceTree = "<group>"; };
 		E68CDC1E5C438D1BEAD4CF24 /* LibreConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreConfigStateModel.swift; sourceTree = "<group>"; };
 		E9AAB83FB6C3B41EFD1846A0 /* AddTempTargetRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddTempTargetRootView.swift; sourceTree = "<group>"; };
+		F90692A9274B7AAE0037068D /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; };
+		F90692CE274B999A0037068D /* HealthKitDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitDataFlow.swift; sourceTree = "<group>"; };
+		F90692D0274B99B60037068D /* HealthKitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitProvider.swift; sourceTree = "<group>"; };
+		F90692D2274B9A130037068D /* AppleHealthKitRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleHealthKitRootView.swift; sourceTree = "<group>"; };
+		F90692D5274B9A450037068D /* HealthKitStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitStateModel.swift; sourceTree = "<group>"; };
 		FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorRootView.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
@@ -716,6 +732,7 @@
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				3818AA60274C26A300843DB3 /* Crypto.framework in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
+				E0CC2C5C275B9F0F00A7BC71 /* HealthKit.framework in Frameworks */,
 				3818AA62274C26A400843DB3 /* MinimedKit.framework in Frameworks */,
 				3818AA58274C26A300843DB3 /* LoopKit.framework in Frameworks */,
 			);
@@ -820,6 +837,7 @@
 		3811DE0325C9D31700A708ED /* Modules */ = {
 			isa = PBXGroup;
 			children = (
+				F90692CD274B99850037068D /* HealthKit */,
 				6DC5D590658EF8B8DF94F9F5 /* AddCarbs */,
 				A9A4C88374496B3C89058A89 /* AddTempTarget */,
 				672F63EEAE27400625E14BAD /* AutotuneConfig */,
@@ -948,6 +966,7 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
+				F90692A8274B7A980037068D /* HealthKit */,
 				38E8754D275556E100975559 /* WatchManager */,
 				38E87406274F9AA500975559 /* UserNotifiactions */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
@@ -1081,6 +1100,7 @@
 		3818AA48274C267000843DB3 /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
+				E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */,
 				38E87402274F78C000975559 /* libswiftCoreNFC.tbd */,
 				38E873FD274F761800975559 /* CoreNFC.framework */,
 				3818AA70274C278200843DB3 /* LoopTestingKit.framework */,
@@ -1235,6 +1255,7 @@
 				38A0364125ED069400FCBB52 /* TempBasal.swift */,
 				3871F39B25ED892B0013ECB5 /* TempTarget.swift */,
 				3811DE8E25C9D80400A708ED /* User.swift */,
+				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
 			);
 			path = Models;
 			sourceTree = "<group>";
@@ -1261,6 +1282,7 @@
 				3811DE5525C9D4D500A708ED /* Publisher.swift */,
 				38E98A3625F5509500C0CED0 /* String+Extensions.swift */,
 				3811DEE325CA063400A708ED /* PropertyWrappers */,
+				E06B9119275B5EEA003C04B6 /* Array+Extension.swift */,
 			);
 			path = Helpers;
 			sourceTree = "<group>";
@@ -1767,6 +1789,33 @@
 			path = CGM;
 			sourceTree = "<group>";
 		};
+		F90692A8274B7A980037068D /* HealthKit */ = {
+			isa = PBXGroup;
+			children = (
+				F90692A9274B7AAE0037068D /* HealthKitManager.swift */,
+			);
+			path = HealthKit;
+			sourceTree = "<group>";
+		};
+		F90692CD274B99850037068D /* HealthKit */ = {
+			isa = PBXGroup;
+			children = (
+				F90692CE274B999A0037068D /* HealthKitDataFlow.swift */,
+				F90692D0274B99B60037068D /* HealthKitProvider.swift */,
+				F90692D5274B9A450037068D /* HealthKitStateModel.swift */,
+				F90692D4274B9A160037068D /* View */,
+			);
+			path = HealthKit;
+			sourceTree = "<group>";
+		};
+		F90692D4274B9A160037068D /* View */ = {
+			isa = PBXGroup;
+			children = (
+				F90692D2274B9A130037068D /* AppleHealthKitRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -2000,6 +2049,7 @@
 				38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */,
 				38D0B3B625EBE24900CB6E88 /* Battery.swift in Sources */,
 				38C4D33725E9A1A300D30B77 /* DispatchQueue+Extensions.swift in Sources */,
+				F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */,
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
@@ -2124,10 +2174,12 @@
 				2BE9A6FA20875F6F4F9CD461 /* PumpSettingsEditorProvider.swift in Sources */,
 				6B9625766B697D1C98E455A2 /* PumpSettingsEditorStateModel.swift in Sources */,
 				A0B8EC8CC5CD1DD237D1BCD2 /* PumpSettingsEditorRootView.swift in Sources */,
+				E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */,
 				38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */,
 				389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */,
 				38FEF3FE2738083E00574A46 /* CGMProvider.swift in Sources */,
 				38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */,
+				F90692D1274B99B60037068D /* HealthKitProvider.swift in Sources */,
 				385CEAC125F2EA52002D6D5B /* Announcement.swift in Sources */,
 				8B759CFCF47B392BB365C251 /* BasalProfileEditorDataFlow.swift in Sources */,
 				389442CB25F65F7100FA1F27 /* NightscoutTreatment.swift in Sources */,
@@ -2135,6 +2187,7 @@
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
 				385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */,
+				F90692AA274B7AAE0037068D /* HealthKitManager.swift in Sources */,
 				38887CCE25F5725200944304 /* IOBEntry.swift in Sources */,
 				38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */,
 				CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */,
@@ -2172,6 +2225,7 @@
 				D2165E9D78EFF692C1DED1C6 /* AddTempTargetDataFlow.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
 				5BFA1C2208114643B77F8CEB /* AddTempTargetProvider.swift in Sources */,
+				E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */,
 				919DBD08F13BAFB180DF6F47 /* AddTempTargetStateModel.swift in Sources */,
 				8BC2F5A29AD1ED08AC0EE013 /* AddTempTargetRootView.swift in Sources */,
 				38A00B1F25FC00F7006BC0B0 /* Autotune.swift in Sources */,
@@ -2182,9 +2236,11 @@
 				38569353270B5E350002C50D /* CGMRootView.swift in Sources */,
 				69A31254F2451C20361D172F /* BolusStateModel.swift in Sources */,
 				0CEA2EA070AB041AF3E3745B /* BolusRootView.swift in Sources */,
+				F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */,
 				3862CC1F273FDC9200BF832C /* CalibrationsChart.swift in Sources */,
 				711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */,
 				BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */,
+				F90692D6274B9A450037068D /* HealthKitStateModel.swift in Sources */,
 				C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */,
 				38E4453B274E411700EC9A94 /* Disk+VolumeInformation.swift in Sources */,
 				7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */,
@@ -2600,6 +2656,7 @@
 				INFOPLIST_KEY_CFBundleDisplayName = "$(APP_DISPLAY_NAME) WatchKit Extension";
 				INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).ComplicationController";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -2635,6 +2692,7 @@
 				INFOPLIST_KEY_CFBundleDisplayName = "$(APP_DISPLAY_NAME) WatchKit Extension";
 				INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).ComplicationController";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",

+ 6 - 0
FreeAPS/Resources/FreeAPS.entitlements

@@ -2,6 +2,12 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>com.apple.developer.healthkit</key>
+	<true/>
+	<key>com.apple.developer.healthkit.access</key>
+	<array/>
+	<key>com.apple.developer.healthkit.background-delivery</key>
+	<true/>
 	<key>com.apple.developer.nfc.readersession.formats</key>
 	<array>
 		<string>TAG</string>

+ 4 - 0
FreeAPS/Resources/Info.plist

@@ -61,6 +61,10 @@
 	<string>Calendar is used to create a new glucose events.</string>
 	<key>NSFaceIDUsageDescription</key>
 	<string>For authorized acces to bolus</string>
+	<key>NSHealthShareUsageDescription</key>
+	<string>Health App is used to store blood glucose data</string>
+	<key>NSHealthUpdateUsageDescription</key>
+	<string>Health App is used to store blood glucose data</string>
 	<key>UIApplicationSceneManifest</key>
 	<dict>
 		<key>UIApplicationSupportsMultipleScenes</key>

+ 2 - 0
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -12,6 +12,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @Injected() var apsManager: APSManager!
     @Injected() var settingsManager: SettingsManager!
     @Injected() var libreTransmitter: LibreTransmitterSource!
+    @Injected() var healthKitManager: HealthKitManager!
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
@@ -73,6 +74,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                     self.glucoseStorage.storeGlucose(filtered)
                     self.apsManager.heartbeat(date: date, force: false)
                     self.nightscoutManager.uploadGlucose()
+                    self.healthKitManager.save(bloodGlucoses: filtered, completion: nil)
                 }
             }
             .store(in: &lifetime)

+ 4 - 0
FreeAPS/Sources/APS/OpenAPS/Constants.swift

@@ -86,4 +86,8 @@ extension OpenAPS {
         static let tempTargetsPresets = "freeaps/temptargets_presets.json"
         static let calibrations = "freeaps/calibrations.json"
     }
+
+    enum HealthKit {
+        static let downloadedGlucose = "healthkit/downloaded-glucose.json"
+    }
 }

+ 36 - 0
FreeAPS/Sources/APS/Storage/GlucoseStorage.swift

@@ -4,6 +4,8 @@ import Swinject
 
 protocol GlucoseStorage {
     func storeGlucose(_ glucose: [BloodGlucose])
+    func removeGlucose(byID id: String)
+    func removeGlucose(byIDCollection ids: [String])
     func recent() -> [BloodGlucose]
     func syncDate() -> Date
     func filterTooFrequentGlucose(_ glucose: [BloodGlucose], at: Date) -> [BloodGlucose]
@@ -48,6 +50,40 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    func removeGlucose(byIDCollection ids: [String]) {
+        processQueue.sync {
+            let file = OpenAPS.Monitor.glucose
+            self.storage.transaction { storage in
+                let BGInStorage = storage.retrieve(file, as: [BloodGlucose].self)
+                let filteredBG = BGInStorage?.filter { !ids.contains($0.id) } ?? []
+                storage.save(filteredBG, as: file)
+
+                DispatchQueue.main.async {
+                    self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                        $0.glucoseDidUpdate(filteredBG.reversed())
+                    }
+                }
+            }
+        }
+    }
+
+    func removeGlucose(byID id: String) {
+        processQueue.sync {
+            let file = OpenAPS.Monitor.glucose
+            self.storage.transaction { storage in
+                let BGInStorage = storage.retrieve(file, as: [BloodGlucose].self)
+                let filteredBG = BGInStorage?.filter { $0.id != id } ?? []
+                storage.save(filteredBG, as: file)
+
+                DispatchQueue.main.async {
+                    self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                        $0.glucoseDidUpdate(filteredBG.reversed())
+                    }
+                }
+            }
+        }
+    }
+
     func syncDate() -> Date {
         guard let events = storage.retrieve(OpenAPS.Monitor.glucose, as: [BloodGlucose].self),
               let recent = events.first

+ 1 - 0
FreeAPS/Sources/Application/FreeAPSApp.swift

@@ -38,6 +38,7 @@ import Swinject
         _ = resolver.resolve(CalendarManager.self)!
         _ = resolver.resolve(UserNotificationsManager.self)!
         _ = resolver.resolve(WatchManager.self)!
+        _ = resolver.resolve(HealthKitManager.self)!
     }
 
     init() {

+ 3 - 0
FreeAPS/Sources/Assemblies/ServiceAssembly.swift

@@ -1,4 +1,5 @@
 import Foundation
+import HealthKit
 import Swinject
 
 final class ServiceAssembly: Assembly {
@@ -14,6 +15,8 @@ final class ServiceAssembly: Assembly {
             return reporter
         }
         container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
+        container.register(HKHealthStore.self) { _ in HKHealthStore() }
+        container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
     }

+ 11 - 0
FreeAPS/Sources/Helpers/Array+Extension.swift

@@ -0,0 +1,11 @@
+extension Array where Element: Hashable {
+    func removeDublicates() -> Self {
+        var result = Self()
+        for item in self {
+            if !result.contains(item) {
+                result.append(item)
+            }
+        }
+        return result
+    }
+}

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

@@ -977,3 +977,12 @@ Enact a temp Basal or a temp target */
 
 /* "Noisy CGM Target Multiplier" */
 "Defaults to 1.3. Increase target by this amount when looping off raw/noisy CGM data" = "По умолчанию 1,3. Увеличивает цель на эту величину, когда цикл отключается из за шума сенсора";
+
+/* */
+"Apple Health" = "Apple Health";
+
+/* */
+"Connect to Apple Health" = "Подключить к Apple Health";
+
+/* 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 вам необходимо дать соответствующие разрешения, перейдя к меню Настройки > Здоровье > Доступ к данным";

+ 15 - 0
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -14,6 +14,10 @@ struct FreeAPSSettings: JSON, Equatable {
     var cgm: CGMType = .nightscout
     var uploadGlucose: Bool = false
     var useCalendar: Bool = false
+    // Apple Health Integration
+    var useAppleHealth: Bool = false
+    var needShowInformationTextForSetPermissions: Bool = false
+    // ---
     var glucoseBadge: Bool = false
     var glucoseNotificationsAlways: Bool = false
     var useAlarmSound: Bool = false
@@ -81,6 +85,17 @@ extension FreeAPSSettings: Decodable {
             settings.useCalendar = useCalendar
         }
 
+        if let useAppleHealth = try? container.decode(Bool.self, forKey: .useAppleHealth) {
+            settings.useAppleHealth = useAppleHealth
+        }
+
+        if let needShowInformationTextForSetPermissions = try? container.decode(
+            Bool.self,
+            forKey: .needShowInformationTextForSetPermissions
+        ) {
+            settings.needShowInformationTextForSetPermissions = needShowInformationTextForSetPermissions
+        }
+
         if let glucoseBadge = try? container.decode(Bool.self, forKey: .glucoseBadge) {
             settings.glucoseBadge = glucoseBadge
         }

+ 19 - 0
FreeAPS/Sources/Models/HealthKitSample.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+struct HealthKitSample: JSON, Hashable, Equatable {
+    var healthKitId: String
+    var date: Date
+    var glucose: Int
+
+    static func == (lhs: HealthKitSample, rhs: HealthKitSample) -> Bool {
+        lhs.healthKitId == rhs.healthKitId
+    }
+}
+
+extension HealthKitSample {
+    private enum CodingKeys: String, CodingKey {
+        case healthKitId = "healthkit_id"
+        case date
+        case glucose
+    }
+}

+ 5 - 0
FreeAPS/Sources/Modules/HealthKit/HealthKitDataFlow.swift

@@ -0,0 +1,5 @@
+enum AppleHealthKit {
+    enum Config {}
+}
+
+protocol AppleHealthKitProvider: Provider {}

+ 3 - 0
FreeAPS/Sources/Modules/HealthKit/HealthKitProvider.swift

@@ -0,0 +1,3 @@
+extension AppleHealthKit {
+    final class Provider: BaseProvider, AppleHealthKitProvider {}
+}

+ 44 - 0
FreeAPS/Sources/Modules/HealthKit/HealthKitStateModel.swift

@@ -0,0 +1,44 @@
+import Combine
+import SwiftUI
+
+extension AppleHealthKit {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var healthKitManager: HealthKitManager!
+
+        @Published var useAppleHealth = false
+        @Published var needShowInformationTextForSetPermissions = false
+
+        override func subscribe() {
+            useAppleHealth = settingsManager.settings.useAppleHealth
+            needShowInformationTextForSetPermissions = settingsManager.settings.needShowInformationTextForSetPermissions
+
+            subscribeSetting(\.needShowInformationTextForSetPermissions, on: $needShowInformationTextForSetPermissions) { _ in }
+
+            $useAppleHealth
+                .removeDuplicates()
+                .sink { [weak self] value in
+                    guard let self = self else { return }
+                    guard value else {
+                        self.settingsManager.settings.useAppleHealth = false
+                        self.needShowInformationTextForSetPermissions = false
+                        return
+                    }
+
+                    self.healthKitManager.requestPermission { status, error in
+                        guard error == nil else {
+                            return
+                        }
+                        self.settingsManager.settings.useAppleHealth = status
+                        self.healthKitManager.enableBackgroundDelivery()
+                        self.healthKitManager.createObserver()
+                        DispatchQueue.main.async {
+                            if !self.healthKitManager.areAllowAllPermissions {
+                                self.needShowInformationTextForSetPermissions = true
+                            }
+                        }
+                    }
+                }
+                .store(in: &lifetime)
+        }
+    }
+}

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

@@ -0,0 +1,27 @@
+import SwiftUI
+import Swinject
+
+extension AppleHealthKit {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+
+        var body: some View {
+            Form {
+                Section {
+                    Toggle("Connect to Apple Health", isOn: $state.useAppleHealth)
+                    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)
+                        }
+                    }
+                }
+            }
+            .onAppear(perform: configureView)
+            .navigationTitle("Apple Health")
+            .navigationBarTitleDisplayMode(.automatic)
+        }
+    }
+}

+ 3 - 0
FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -20,6 +20,7 @@ extension Settings {
                 Section(header: Text("Services")) {
                     Text("Nightscout").navigationLink(to: .nighscoutConfig, from: self)
                     Text("CGM").navigationLink(to: .cgm, from: self)
+                    Text("Apple Health").navigationLink(to: .healthkit, from: self)
                     Text("Notifications").navigationLink(to: .notificationsConfig, from: self)
                 }
 
@@ -82,6 +83,8 @@ extension Settings {
                         }
 
                         Group {
+                            Text("HealthKit")
+                                .navigationLink(to: .configEditor(file: OpenAPS.HealthKit.downloadedGlucose), from: self)
                             Text("Target presets")
                                 .navigationLink(to: .configEditor(file: OpenAPS.FreeAPS.tempTargetsPresets), from: self)
                             Text("Calibrations")

+ 3 - 0
FreeAPS/Sources/Router/Screen.swift

@@ -21,6 +21,7 @@ enum Screen: Identifiable, Hashable {
     case autotuneConfig
     case dataTable
     case cgm
+    case healthkit
     case libreConfig
     case calibrations
     case notificationsConfig
@@ -70,6 +71,8 @@ extension Screen {
             DataTable.RootView(resolver: resolver)
         case .cgm:
             CGM.RootView(resolver: resolver)
+        case .healthkit:
+            AppleHealthKit.RootView(resolver: resolver)
         case .libreConfig:
             LibreConfig.RootView(resolver: resolver)
         case .calibrations:

+ 274 - 0
FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift

@@ -0,0 +1,274 @@
+import Foundation
+import HealthKit
+import Swinject
+
+protocol HealthKitManager {
+    /// Check availability HealthKit on current device and user's permissions
+    var isAvailableOnCurrentDevice: Bool { get }
+    /// 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
+    /// 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)?)
+    /// Create observer for data passing beetwen Health Store and FreeAPS
+    func createObserver()
+    /// Enable background delivering objects from Apple Health to FreeAPS
+    func enableBackgroundDelivery()
+}
+
+final class BaseHealthKitManager: HealthKitManager, Injectable {
+    @Injected() private var fileStorage: FileStorage!
+    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var healthKitStore: HKHealthStore!
+
+    private enum Config {
+        // unwraped HKObjects
+        static var permissions: Set<HKSampleType> {
+            var result: Set<HKSampleType> = []
+            for permission in optionalPermissions {
+                result.insert(permission!)
+            }
+            return result
+        }
+
+        static let optionalPermissions = Set([Config.healthBGObject])
+        // link to object in HealthKit
+        static let healthBGObject = HKObjectType.quantityType(forIdentifier: .bloodGlucose)
+
+        static let frequencyBackgroundDeliveryBloodGlucoseFromHealth = HKUpdateFrequency(rawValue: 10)!
+    }
+
+    var isAvailableOnCurrentDevice: Bool {
+        HKHealthStore.isHealthDataAvailable()
+    }
+
+    var areAllowAllPermissions: Bool {
+        var result = true
+        Config.permissions.forEach { permission in
+            if [HKAuthorizationStatus.sharingDenied, HKAuthorizationStatus.notDetermined]
+                .contains(healthKitStore.authorizationStatus(for: permission))
+            {
+                result = false
+            }
+        }
+        return result
+    }
+
+    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()
+        }
+        enableBackgroundDelivery()
+    }
+
+    func isAvailableFor(object: HKObjectType) -> Bool {
+        let status = healthKitStore.authorizationStatus(for: object)
+        switch status {
+        case .sharingAuthorized:
+            return true
+        default:
+            return false
+        }
+    }
+
+    func requestPermission(completion: ((Bool, Error?) -> Void)? = nil) {
+        guard isAvailableOnCurrentDevice else {
+            completion?(false, HKError.notAvailableOnCurrentDevice)
+            return
+        }
+        for permission in Config.optionalPermissions {
+            guard permission != nil else {
+                completion?(false, HKError.dataNotAvailable)
+                return
+            }
+        }
+
+        healthKitStore.requestAuthorization(toShare: Config.permissions, read: Config.permissions) { status, error in
+            completion?(status, error)
+        }
+    }
+
+    func save(bloodGlucoses: [BloodGlucose], completion: ((Result<Bool, Error>) -> Void)? = nil) {
+        for bgItem in bloodGlucoses {
+            let bgQuantity = HKQuantity(
+                unit: .milligramsPerDeciliter,
+                doubleValue: Double(bgItem.glucose!)
+            )
+
+            let bgObjectSample = HKQuantitySample(
+                type: Config.healthBGObject!,
+                quantity: bgQuantity,
+                start: bgItem.dateString,
+                end: bgItem.dateString,
+                metadata: [
+                    "HKMetadataKeyExternalUUID": bgItem.id,
+                    "HKMetadataKeySyncIdentifier": bgItem.id,
+                    "HKMetadataKeySyncVersion": 1,
+                    "fromFreeAPSX": true
+                ]
+            )
+
+            healthKitStore.save(bgObjectSample) { status, error in
+                guard error == nil else {
+                    completion?(Result.failure(error!))
+                    return
+                }
+                completion?(Result.success(status))
+            }
+        }
+    }
+
+    func createObserver() {
+        guard let bgType = Config.healthBGObject else {
+            warning(
+                .service,
+                "Can not create HealthKit Observer, because unable to get the Blood Glucose type",
+                description: nil,
+                error: nil
+            )
+            return
+        }
+
+        let query = HKObserverQuery(sampleType: bgType, predicate: nil) { [unowned self] _, _, observerError in
+
+            if let _ = observerError {
+                return
+            }
+
+            // loading only daily bg
+            let predicate = HKQuery.predicateForSamples(
+                withStart: Date().addingTimeInterval(-1.days.timeInterval),
+                end: nil,
+                options: .strictStartDate
+            )
+
+            healthKitStore.execute(getQueryForDeletedBloodGlucose(sampleType: bgType, predicate: predicate))
+            healthKitStore.execute(getQueryForAddedBloodGlucose(sampleType: bgType, predicate: predicate))
+        }
+        healthKitStore.execute(query)
+    }
+
+    func enableBackgroundDelivery() {
+        guard let bgType = Config.healthBGObject else {
+            warning(
+                .service,
+                "Can not create HealthKit Background Delivery, because unable to get the Blood Glucose type",
+                description: nil,
+                error: nil
+            )
+            return
+        }
+
+        healthKitStore.enableBackgroundDelivery(
+            for: bgType,
+            frequency: Config.frequencyBackgroundDeliveryBloodGlucoseFromHealth
+        ) { status, e in
+            guard e == nil else {
+                warning(.service, "Can not enable background delivery for Apple Health", description: nil, error: e)
+                return
+            }
+            debug(.service, "HealthKit background delivery status is \(status)")
+        }
+    }
+
+    private func getQueryForDeletedBloodGlucose(sampleType: HKQuantityType, predicate: NSPredicate) -> HKQuery {
+        let query = HKAnchoredObjectQuery(
+            type: sampleType,
+            predicate: predicate,
+            anchor: nil,
+            limit: 1000
+        ) { [unowned self] _, _, deletedObjects, _, _ in
+            guard let samples = deletedObjects else {
+                return
+            }
+
+            DispatchQueue.global(qos: .utility).async {
+                var removingBGID = [String]()
+                samples.forEach {
+                    if let idString = $0.metadata?["HKMetadataKeySyncIdentifier"] as? String {
+                        removingBGID.append(idString)
+                    } else {
+                        removingBGID.append($0.uuid.uuidString)
+                    }
+                }
+                glucoseStorage.removeGlucose(byIDCollection: removingBGID)
+            }
+        }
+        return query
+    }
+
+    private func getQueryForAddedBloodGlucose(sampleType: HKQuantityType, predicate: NSPredicate) -> HKQuery {
+        let query = HKSampleQuery(
+            sampleType: sampleType,
+            predicate: predicate,
+            limit: Int(HKObjectQueryNoLimit),
+            sortDescriptors: nil
+        ) { [unowned self] _, results, _ in
+
+            guard let samples = results as? [HKQuantitySample] else {
+                return
+            }
+
+            let oldSamples: [HealthKitSample] = fileStorage
+                .retrieve(OpenAPS.HealthKit.downloadedGlucose, as: [HealthKitSample].self) ?? []
+
+            var newSamples = [HealthKitSample]()
+            for sample in samples {
+                if sample.wasUserEntered {
+                    newSamples.append(HealthKitSample(
+                        healthKitId: sample.uuid.uuidString,
+                        date: sample.startDate,
+                        glucose: Int(round(sample.quantity.doubleValue(for: .milligramsPerDeciliter)))
+                    ))
+                }
+            }
+
+            newSamples = newSamples
+                .filter { !oldSamples.contains($0) }
+
+            newSamples.forEach({ sample in
+                let glucose = BloodGlucose(
+                    _id: sample.healthKitId,
+                    sgv: sample.glucose,
+                    direction: nil,
+                    date: Decimal(Int(sample.date.timeIntervalSince1970) * 1000),
+                    dateString: sample.date,
+                    unfiltered: nil,
+                    filtered: nil,
+                    noise: nil,
+                    glucose: sample.glucose,
+                    type: "sgv"
+                )
+                glucoseStorage.storeGlucose([glucose])
+            })
+
+            let savingSamples = (newSamples + oldSamples)
+                .removeDublicates()
+                .filter { $0.date >= Date().addingTimeInterval(-1.days.timeInterval) }
+
+            self.fileStorage.save(savingSamples, as: OpenAPS.HealthKit.downloadedGlucose)
+        }
+        return query
+    }
+}
+
+enum HealthKitPermissionRequestStatus {
+    case needRequest
+    case didRequest
+}
+
+enum HKError: Error {
+    // HealthKit work only iPhone (not on iPad)
+    case notAvailableOnCurrentDevice
+    // Some data can be not available on current iOS-device
+    case dataNotAvailable
+}