Procházet zdrojové kódy

Merge branch 'dev' into feat/dev-eversense

Deniz Cengiz před 15 hodinami
rodič
revize
f1d2bc1408

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.7.0
-APP_DEV_VERSION = 0.7.0.19
+APP_DEV_VERSION = 0.7.0.22
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 24 - 0
Trio.xcodeproj/project.pbxproj

@@ -260,6 +260,7 @@
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
 		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
+		B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */; };
 		3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; };
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@@ -416,6 +417,7 @@
 		BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0582D66189700B95AED /* TestAssembly.swift */; };
 		BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */; };
 		BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */; };
+		B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; };
 		BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */; };
 		BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; };
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
@@ -1127,6 +1129,7 @@
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
+		B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = "<group>"; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
 		3E84DA3F2F48D96000033608 /* EversenseKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = EversenseKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -1277,6 +1280,7 @@
 		BD8FC0582D66189700B95AED /* TestAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssembly.swift; sourceTree = "<group>"; };
 		BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorTests.swift; sourceTree = "<group>"; };
+		B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = "<group>"; };
 		BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
@@ -2128,6 +2132,7 @@
 				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
 				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
+				B015AFE22E500000000D7351 /* BolusSafety */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				E592A37E2CEEC046009A472C /* ContactImage */,
 				F90692A8274B7A980037068D /* HealthKit */,
@@ -2744,6 +2749,7 @@
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
+				B015AFE62E500000000D7351 /* BolusSafetyTests */,
 				BD8FC0552D66187700B95AED /* CoreDataTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
@@ -2779,6 +2785,14 @@
 			path = IOB;
 			sourceTree = "<group>";
 		};
+		B015AFE22E500000000D7351 /* BolusSafety */ = {
+			isa = PBXGroup;
+			children = (
+				B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */,
+			);
+			path = BolusSafety;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3133,6 +3147,14 @@
 			path = BolusCalculatorTests;
 			sourceTree = "<group>";
 		};
+		B015AFE62E500000000D7351 /* BolusSafetyTests */ = {
+			isa = PBXGroup;
+			children = (
+				B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */,
+			);
+			path = BolusSafetyTests;
+			sourceTree = "<group>";
+		};
 		BDA25F1A2D26BCE800035F34 /* Views */ = {
 			isa = PBXGroup;
 			children = (
@@ -4567,6 +4589,7 @@
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				190EBCC829FF13AA00BA767D /* UserInterfaceSettingsStateModel.swift in Sources */,
 				3BF85FE32E427312000D7351 /* IOBService.swift in Sources */,
+				B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */,
 				DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */,
 				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
 				BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */,
@@ -4945,6 +4968,7 @@
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
+				B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */,
 				BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -29,5 +29,6 @@ final class ServiceAssembly: Assembly {
             }
         }
         container.register(IOBService.self) { r in BaseIOBService(resolver: r) }
+        container.register(BolusSafetyValidator.self) { r in BaseBolusSafetyValidator(resolver: r) }
     }
 }

+ 20 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -58322,6 +58322,26 @@
         }
       }
     },
+    "Bolus blocked: a %@ U bolus would exceed max IOB (%@ U). Current IOB: %@ U." : {
+      "comment" : "The string that appears when a bolus is blocked because it would exceed the maximum IOB. The argument is the requested bolus amount, the second argument is the maximum IOB, and the third argument is the current IOB.",
+      "isCommentAutoGenerated" : true,
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Bolus blocked: a %1$@ U bolus would exceed max IOB (%2$@ U). Current IOB: %3$@ U."
+          }
+        }
+      }
+    },
+    "Bolus blocked: a significant bolus was delivered within the last %lld minutes." : {
+      "comment" : "The message displayed when a bolus is blocked because a significant bolus was delivered within the last 5 minutes.",
+      "isCommentAutoGenerated" : true
+    },
+    "Bolus blocked: current IOB is not available." : {
+      "comment" : "Message displayed when a bolus is blocked due to the IOB not being available.",
+      "isCommentAutoGenerated" : true
+    },
     "Bolus Calculator" : {
       "localizations" : {
         "bg" : {

+ 3 - 3
Trio/Sources/Models/TrioSettings.swift

@@ -3,14 +3,14 @@ import Foundation
 enum BolusShortcutLimit: String, JSON, CaseIterable, Identifiable {
     var id: String { rawValue }
     case notAllowed
-    case limitBolusMax
+    case limitWithSafetyChecks
 
     var displayName: String {
         switch self {
         case .notAllowed:
             return String(localized: "Not allowed")
-        case .limitBolusMax:
-            return String(localized: "Max bolus")
+        case .limitWithSafetyChecks:
+            return String(localized: "Limit with Safety Checks")
         }
     }
 }

+ 10 - 2
Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift

@@ -7,6 +7,13 @@ struct TherapySettingEditorView: View {
     var valueOptions: [Decimal]
     var validateOnDelete: (() -> Void)?
     var onItemAdded: (() -> Void)?
+    
+    private let basalFormatter: NumberFormatter = {
+        let numberFormatter = NumberFormatter()
+        numberFormatter.maximumFractionDigits = 3
+        numberFormatter.minimumFractionDigits = 2
+        return numberFormatter
+    }()
 
     @State private var selectedItemID: UUID?
     @Namespace var bottomID
@@ -280,10 +287,11 @@ struct TherapySettingEditorView: View {
         case .mmolL,
              .mmolLPerUnit:
             return decimalValue.formattedAsMmolL
+        case .unitPerHour:
+            return basalFormatter.string(from: decimalValue as NSNumber) ?? ""
         case .gramPerUnit,
              .mgdL,
-             .mgdLPerUnit,
-             .unitPerHour:
+             .mgdLPerUnit:
             return decimalValue.description
         }
     }

+ 2 - 2
Trio/Sources/Modules/ShortcutsConfig/ShortcutsConfigStateModel.swift

@@ -16,7 +16,7 @@ extension ShortcutsConfig {
             units = settingsManager.settings.units
 
             subscribeSetting(\.bolusShortcut, on: $maxBolusByShortcuts) {
-                maxBolusByShortcuts = ($0 == .notAllowed) ? .limitBolusMax : $0
+                maxBolusByShortcuts = ($0 == .notAllowed) ? .limitWithSafetyChecks : $0
                 allowBolusByShortcuts = ($0 != .notAllowed)
             }
 
@@ -29,7 +29,7 @@ extension ShortcutsConfig {
                         if let bs = self?.maxBolusByShortcuts {
                             self?.settingsManager.settings.bolusShortcut = bs
                         } else {
-                            self?.settingsManager.settings.bolusShortcut = .limitBolusMax
+                            self?.settingsManager.settings.bolusShortcut = .limitWithSafetyChecks
                         }
                     }
                 }

+ 106 - 0
Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift

@@ -0,0 +1,106 @@
+import CoreData
+import Foundation
+import Swinject
+
+/// Shared safety checks applied to any bolus command originating outside the main bolus UI
+/// (remote notifications, Shortcuts, etc.). Keeps validation logic consistent across call sites.
+protocol BolusSafetyValidator {
+    /// - Parameter lookbackStart: start of the window used for the recent-bolus 20% check.
+    ///   Defaults to `now - BolusSafetyEvaluator.recentBolusWindowMinutes`. Callers that know when the
+    ///   command was originally issued (e.g. APNS payload timestamp) should pass that instead so the
+    ///   check covers any bolus since the command was sent.
+    func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult
+    func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal
+}
+
+extension BolusSafetyValidator {
+    func validate(bolusAmount: Decimal) async throws -> BolusSafetyResult {
+        try await validate(bolusAmount: bolusAmount, lookbackStart: nil)
+    }
+}
+
+enum BolusSafetyResult: Equatable {
+    case allowed
+    case rejected(BolusSafetyRejection)
+}
+
+enum BolusSafetyRejection: Equatable {
+    case exceedsMaxBolus(maxBolus: Decimal)
+    case iobUnavailable
+    case exceedsMaxIOB(currentIOB: Decimal, maxIOB: Decimal)
+    case recentBolusWithinWindow(totalRecent: Decimal)
+}
+
+struct BolusSafetyInputs: Equatable {
+    let maxBolus: Decimal
+    let maxIOB: Decimal
+    let currentIOB: Decimal?
+    /// Sum of bolus amounts delivered within the recent-bolus window (see `BolusSafetyEvaluator.recentBolusWindowMinutes`).
+    let totalRecentBolus: Decimal
+}
+
+enum BolusSafetyEvaluator {
+    static let recentBolusThreshold: Decimal = 0.2
+    static let recentBolusWindowMinutes: Int = 6
+
+    static func evaluate(bolusAmount: Decimal, inputs: BolusSafetyInputs) -> BolusSafetyResult {
+        if bolusAmount > inputs.maxBolus {
+            return .rejected(.exceedsMaxBolus(maxBolus: inputs.maxBolus))
+        }
+        guard let currentIOB = inputs.currentIOB else {
+            return .rejected(.iobUnavailable)
+        }
+        if (currentIOB + bolusAmount) > inputs.maxIOB {
+            return .rejected(.exceedsMaxIOB(currentIOB: currentIOB, maxIOB: inputs.maxIOB))
+        }
+        if inputs.totalRecentBolus >= bolusAmount * recentBolusThreshold {
+            return .rejected(.recentBolusWithinWindow(totalRecent: inputs.totalRecentBolus))
+        }
+        return .allowed
+    }
+}
+
+final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var iobService: IOBService!
+
+    private let fetchContext: NSManagedObjectContext
+
+    init(resolver: Resolver) {
+        fetchContext = CoreDataStack.shared.newTaskContext()
+        injectServices(resolver)
+    }
+
+    func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult {
+        let windowStart = lookbackStart
+            ?? Date().addingTimeInterval(-Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60))
+        let inputs = BolusSafetyInputs(
+            maxBolus: settingsManager.pumpSettings.maxBolus,
+            maxIOB: settingsManager.preferences.maxIOB,
+            currentIOB: iobService.currentIOB,
+            totalRecentBolus: try await fetchTotalRecentBolusAmount(since: windowStart)
+        )
+        return BolusSafetyEvaluator.evaluate(bolusAmount: bolusAmount, inputs: inputs)
+    }
+
+    func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
+        let predicate = NSPredicate(
+            format: "type == %@ AND timestamp > %@",
+            PumpEventStored.EventType.bolus.rawValue,
+            date as NSDate
+        )
+        let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: fetchContext,
+            predicate: predicate,
+            key: "timestamp",
+            ascending: true,
+            fetchLimit: nil,
+            propertiesToFetch: ["bolus.amount"]
+        )
+        guard let bolusDictionaries = results as? [[String: Any]] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
+        return bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
+    }
+}

+ 22 - 45
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -2,42 +2,22 @@ import Foundation
 import HealthKit
 
 extension TrioRemoteControl {
-    internal func handleBolusCommand(_ payload: CommandPayload) async throws {
+    func handleBolusCommand(_ payload: CommandPayload) async throws {
         guard let bolusAmount = payload.bolusAmount else {
             await logError("Command rejected: bolus amount is missing or invalid.", payload: payload)
             return
         }
 
-        let maxBolus = await TrioApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
-
-        if bolusAmount > maxBolus {
-            await logError(
-                "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
-                payload: payload
-            )
-            return
-        }
-
-        let maxIOB = settings.preferences.maxIOB
-        guard let currentIOB = iobService.currentIOB else {
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-        if (currentIOB + bolusAmount) > maxIOB {
-            await logError(
-                "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
-                payload: payload
-            )
-            return
-        }
-
-        let totalRecentBolusAmount =
-            try await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: payload.timestamp))
+        let validation = try await bolusSafetyValidator.validate(
+            bolusAmount: bolusAmount,
+            lookbackStart: Date(timeIntervalSince1970: payload.timestamp)
+        )
 
-        if totalRecentBolusAmount >= bolusAmount * 0.2 {
-            await logError(
-                "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
-                payload: payload
-            )
+        switch validation {
+        case .allowed:
+            break
+        case let .rejected(reason):
+            await logError(reason.remoteCommandMessage(bolusAmount: bolusAmount), payload: payload)
             return
         }
 
@@ -79,22 +59,19 @@ extension TrioRemoteControl {
                 }
             }
     }
+}
 
-    private func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
-        let predicate = NSPredicate(
-            format: "type == %@ AND timestamp > %@",
-            PumpEventStored.EventType.bolus.rawValue,
-            date as NSDate
-        )
-        let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self, onContext: pumpHistoryFetchContext, predicate: predicate, key: "timestamp",
-            ascending: true, fetchLimit: nil, propertiesToFetch: ["bolus.amount"]
-        )
-        guard let bolusDictionaries = results as? [[String: Any]] else {
-            await logError("Failed to cast fetched bolus events. Fetched entities type: \(type(of: results))")
-            throw CoreDataError.fetchError(function: #function, file: #file)
+private extension BolusSafetyRejection {
+    func remoteCommandMessage(bolusAmount: Decimal) -> String {
+        switch self {
+        case let .exceedsMaxBolus(maxBolus):
+            return "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units)."
+        case .iobUnavailable:
+            return "Command rejected: current IOB is not available."
+        case let .exceedsMaxIOB(currentIOB, maxIOB):
+            return "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units."
+        case .recentBolusWithinWindow:
+            return "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent."
         }
-        let totalAmount = bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
-        return totalAmount
     }
 }

+ 1 - 3
Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift

@@ -10,15 +10,13 @@ class TrioRemoteControl: Injectable {
     @Injected() internal var nightscoutManager: NightscoutManager!
     @Injected() internal var overrideStorage: OverrideStorage!
     @Injected() internal var settings: SettingsManager!
-    @Injected() internal var iobService: IOBService!
+    @Injected() internal var bolusSafetyValidator: BolusSafetyValidator!
 
     private let timeWindow: TimeInterval = 600
 
-    internal let pumpHistoryFetchContext: NSManagedObjectContext
     internal let viewContext: NSManagedObjectContext
 
     private init() {
-        pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
         viewContext = CoreDataStack.shared.persistentContainer.viewContext
         injectServices(TrioApp.resolver)
     }

+ 20 - 3
Trio/Sources/Services/Telemetry/TelemetryClient.swift

@@ -1,4 +1,5 @@
 import Foundation
+import HealthKit
 import LoopKit
 import Swinject
 import UIKit
@@ -197,9 +198,25 @@ final class TelemetryClient: Injectable {
 
         payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
 
-        let useHealth = settings?.useAppleHealth ?? false
-        let healthAuthorized = healthKitManager?.hasGrantedFullWritePermissions ?? false
-        payload["appleHealthEnabled"] = useHealth && healthAuthorized
+        // Apple Health: report `enabled = true` as soon as *any* per-type write
+        // permission is granted, with the full per-type breakdown in
+        // `appleHealthWrites`.
+        let appleHealthSampleTypes: [(name: String, type: HKObjectType?)] = [
+            ("glucose", AppleHealthConfig.healthBGObject),
+            ("insulin", AppleHealthConfig.healthInsulinObject),
+            ("carbs", AppleHealthConfig.healthCarbObject),
+            ("fat", AppleHealthConfig.healthFatObject),
+            ("protein", AppleHealthConfig.healthProteinObject)
+        ]
+        var writePermissions: [String: Bool] = [:]
+        for (name, type) in appleHealthSampleTypes {
+            let granted = type.flatMap { healthKitManager?.checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false
+            writePermissions[name] = granted
+        }
+        payload["appleHealthEnabled"] = writePermissions.values.contains(true)
+        if !writePermissions.isEmpty {
+            payload["appleHealthWrites"] = writePermissions
+        }
 
         if let settings = settings {
             payload["closedLoop"] = settings.closedLoop

+ 1 - 0
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -17,6 +17,7 @@ import Swinject
     @Injected() var liveActivityManager: LiveActivityManager!
     @Injected() var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() var iobService: IOBService!
+    @Injected() var bolusSafetyValidator: BolusSafetyValidator!
 
     let resolver: Resolver
 

+ 37 - 10
Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift

@@ -4,25 +4,25 @@ import Foundation
 
 final class BolusIntentRequest: BaseIntentsRequest {
     func bolus(_ bolusAmount: Double) async throws -> String {
-        var bolusQuantity: Decimal = 0
         switch settingsManager.settings.bolusShortcut {
-        // Block boluses if they are disabled
         case .notAllowed:
             return String(
                 localized:
                 "Bolusing via Shortcuts is disabled in Trio settings."
             )
 
-        // Block any bolus attempted if it is larger than the max bolus in settings
-        case .limitBolusMax:
-            if Decimal(bolusAmount) > settingsManager.pumpSettings.maxBolus {
-                return String(
-                    localized:
-                    "The bolus cannot be larger than the pump setting max bolus (\(settingsManager.pumpSettings.maxBolus.description))."
+        case .limitWithSafetyChecks:
+            let requestedAmount = Decimal(bolusAmount)
+            let validation = try await bolusSafetyValidator.validate(bolusAmount: requestedAmount)
+
+            if case let .rejected(reason) = validation {
+                return reason.shortcutMessage(
+                    requestedAmount: requestedAmount,
+                    pumpMaxBolus: settingsManager.pumpSettings.maxBolus
                 )
-            } else {
-                bolusQuantity = apsManager.roundBolus(amount: Decimal(bolusAmount))
             }
+
+            let bolusQuantity = apsManager.roundBolus(amount: requestedAmount)
             await apsManager.enactBolus(amount: Double(bolusQuantity), isSMB: false, callback: nil)
             return String(
                 localized:
@@ -52,3 +52,30 @@ final class BolusIntentRequest: BaseIntentsRequest {
         }
     }
 }
+
+private extension BolusSafetyRejection {
+    func shortcutMessage(requestedAmount: Decimal, pumpMaxBolus: Decimal) -> String {
+        switch self {
+        case .exceedsMaxBolus:
+            return String(
+                localized:
+                "The bolus cannot be larger than the pump setting max bolus (\(pumpMaxBolus.description))."
+            )
+        case .iobUnavailable:
+            return String(
+                localized:
+                "Bolus blocked: current IOB is not available."
+            )
+        case let .exceedsMaxIOB(currentIOB, maxIOB):
+            return String(
+                localized:
+                "Bolus blocked: a \(requestedAmount.formatted()) U bolus would exceed max IOB (\(maxIOB.formatted()) U). Current IOB: \(currentIOB.formatted()) U."
+            )
+        case .recentBolusWithinWindow:
+            return String(
+                localized:
+                "Bolus blocked: a significant bolus was delivered within the last \(BolusSafetyEvaluator.recentBolusWindowMinutes) minutes."
+            )
+        }
+    }
+}

+ 113 - 0
TrioTests/BolusSafetyTests/BolusSafetyValidatorTests.swift

@@ -0,0 +1,113 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Bolus Safety Validator Tests") struct BolusSafetyValidatorTests: Injectable {
+    @Injected() var validator: BolusSafetyValidator!
+    let resolver = TrioApp().resolver
+
+    init() {
+        injectServices(resolver)
+    }
+
+    @Test("Validator resolves from the service container") func testValidatorResolves() {
+        #expect(validator != nil, "BolusSafetyValidator should be registered in ServiceAssembly")
+        #expect(validator is BaseBolusSafetyValidator, "Validator should be of type BaseBolusSafetyValidator")
+    }
+
+    @Test("Allows bolus when all inputs are within limits") func testAllowed() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 1,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Rejects when amount exceeds max bolus") func testExceedsMaxBolus() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 6, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxBolus(maxBolus: 5)))
+    }
+
+    @Test("Rejects when current IOB is unavailable") func testIOBUnavailable() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: nil,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 1, inputs: inputs)
+        #expect(result == .rejected(.iobUnavailable))
+    }
+
+    @Test("Rejects when amount would exceed max IOB") func testExceedsMaxIOB() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 5,
+            currentIOB: 3,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 2.5, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxIOB(currentIOB: 3, maxIOB: 5)))
+    }
+
+    @Test("Rejects when recent bolus totals >= 20% of requested amount") func testRecentBolusWithinWindow() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 1.0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs)
+        #expect(result == .rejected(.recentBolusWithinWindow(totalRecent: 1.0)))
+    }
+
+    @Test("Allows when recent bolus total is below 20% threshold") func testRecentBolusBelowThreshold() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0.99
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Max bolus check runs before IOB check") func testCheckOrdering() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: nil,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 6, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxBolus(maxBolus: 5)))
+    }
+
+    @Test("Equal-to-max-bolus is allowed") func testEqualsMaxBolus() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Current IOB plus amount equal to max IOB is allowed") func testEqualToMaxIOB() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 5,
+            currentIOB: 3,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 2, inputs: inputs) == .allowed)
+    }
+}