diff --git a/.gitmodules b/.gitmodules index 79da6ecc30..86d45762a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "LoopKit"] path = LoopKit - url = https://github.com/loopandlearn/LoopKit.git - branch = trio-next-dev + url = https://github.com/mountrcg/LoopKit.git + branch = sync/trio-next-dev-through-e6b922d7 [submodule "CGMBLEKit"] path = CGMBLEKit url = https://github.com/loopandlearn/CGMBLEKit.git @@ -36,8 +36,8 @@ branch = trio-next-dev [submodule "MedtrumKit"] path = MedtrumKit - url = https://github.com/loopandlearn/MedtrumKit - branch = trio-next-dev + url = https://github.com/mountrcg/MedtrumKit.git + branch = feat/next-dev-connection-status-activation-ux [submodule "OmnipodKit"] path = OmnipodKit url = https://github.com/loopandlearn/OmnipodKit diff --git a/Config.xcconfig b/Config.xcconfig index 6795681025..ddac9e11c9 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -9,7 +9,7 @@ DEVELOPER_TEAM = ##TEAM_ID## // Typically this is not modified unless you want to create a separate (unique) app using your ID // It must include $(DEVELOPMENT_TEAM) // Tai uses .tai at the end instead of .trio -BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).tai +BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio // Danger zone - do not modify these unless you know what you are doing @@ -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 = 1.0.2 -APP_DEV_VERSION = 1.0.2.66 +APP_DEV_VERSION = 1.0.2.71 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = autoISF 3.01 diff --git a/LibreLoop b/LibreLoop index dbe4f9cdd1..0c90cd9a05 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit dbe4f9cdd165b0c7b50be7b583b5b6c32d97af8d +Subproject commit 0c90cd9a05353ed60642100e0bf1f7c1ec05f76a diff --git a/LoopKit b/LoopKit index babddf0453..40a5110810 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit babddf04536c04c6852ee7c76a1905164ee9893d +Subproject commit 40a5110810e87d3da83e796c2d23fc83ca77badd diff --git a/MedtrumKit b/MedtrumKit index eee17b0fc4..b007ee8554 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit eee17b0fc4768fb9d9dedd9cb857d010c668b564 +Subproject commit b007ee8554f4d73306fb739ede26eac1061601a8 diff --git a/SyaiKit b/SyaiKit index ef0aed3420..d46674c540 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit ef0aed34209e0842dc6ced360c85bfc677cfb8ab +Subproject commit d46674c5405d16418b55709d2e7464cd95d92412 diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index bfaa49b0b4..826292ca81 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1 +1,2 @@ -based on Trio dev 1.0.0.5 +based on Trio dev 1.0.2.71 +do M1 medtrum with old Loopkit diff --git a/Trio Watch App Extension/Views/AcknowledgementPendingView.swift b/Trio Watch App Extension/Views/AcknowledgementPendingView.swift index 22e951844e..b3218c35a3 100644 --- a/Trio Watch App Extension/Views/AcknowledgementPendingView.swift +++ b/Trio Watch App Extension/Views/AcknowledgementPendingView.swift @@ -43,17 +43,8 @@ struct AcknowledgementPendingView: View { .navigationBarBackButtonHidden(true) .toolbar(.hidden) .background(trioBackgroundColor) - .onChange(of: state.showCommsAnimation) { oldValue, newValue in - if newValue && !oldValue { - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - // If after 5 seconds there is still no acknowledgement banner, return to root - if !state.showAcknowledgmentBanner { - // Navigate back to the root - navigationPath.removeLast(navigationPath.count) - } - } - } - } + // WatchState times out a missing answer by showing a banner (beginAwaitingAcknowledgment), so every + // request ends here. A timer in this view never started: the flag is already true when the view appears. .onChange(of: state.showAcknowledgmentBanner) { _, newValue in if !newValue { // Navigate back to the root when acknowledgment banner disappears diff --git a/Trio Watch App Extension/Views/BolusConfirmationView.swift b/Trio Watch App Extension/Views/BolusConfirmationView.swift index 39b08c4ba2..e839a8f31f 100644 --- a/Trio Watch App Extension/Views/BolusConfirmationView.swift +++ b/Trio Watch App Extension/Views/BolusConfirmationView.swift @@ -52,11 +52,7 @@ struct BolusConfirmationView: View { Spacer() Button("Cancel") { - if state.carbsAmount > 0 { - state.carbsAmount = 0 // reset carbs in state - } - bolusAmount = 0 // reset bolus in state - confirmationProgress = 0 // reset auth progress + state.clearEntry() navigationPath.removeLast(navigationPath.count) } .buttonStyle(.bordered) @@ -80,13 +76,8 @@ struct BolusConfirmationView: View { WKInterfaceDevice.current().play(.success) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if state.carbsAmount > 0 { - state.sendCarbsRequest(state.carbsAmount, Date()) - state.carbsAmount = 0 // reset carbs in state - } - state.sendBolusRequest(Decimal(bolusAmount)) - bolusAmount = 0 // reset bolus in state - confirmationProgress = 0 // reset auth progress + // Sends carbs and bolus, or refuses an entry older than 2 min; either way the entry is cleared + state.sendConfirmedEntry(bolusAmount: bolusAmount) navigationPath.append(NavigationDestinations.acknowledgmentPending) } } else if newValue > 0 { diff --git a/Trio Watch App Extension/Views/BolusInputView.swift b/Trio Watch App Extension/Views/BolusInputView.swift index fc176a5842..54dca746d1 100644 --- a/Trio Watch App Extension/Views/BolusInputView.swift +++ b/Trio Watch App Extension/Views/BolusInputView.swift @@ -122,8 +122,7 @@ struct BolusInputView: View { Button(actionButtonLabel) { if isCarbsOnly { - state.sendCarbsRequest(state.carbsAmount) - state.carbsAmount = 0 // reset carbs in state + state.sendCarbsEntry(state.carbsAmount) navigationPath.append(NavigationDestinations.acknowledgmentPending) } else { state.bolusAmount = min(bolusAmount, effectiveBolusLimit) @@ -156,7 +155,10 @@ struct BolusInputView: View { .clipShape(Circle()) } } + // The entry's 2 min clock restarts whenever the amount changes, including a recommendation landing + .onChange(of: bolusAmount) { state.markEntryEdited() } .onAppear { + state.markEntryEdited() // Set initial bolus amount to recommended value // Only do this if user has not updated amount previously, e.g., when navigating to next and then back to this view if bolusAmount == 0 { diff --git a/Trio Watch App Extension/Views/CarbsInputView.swift b/Trio Watch App Extension/Views/CarbsInputView.swift index bc77bd3a37..97850e6966 100644 --- a/Trio Watch App Extension/Views/CarbsInputView.swift +++ b/Trio Watch App Extension/Views/CarbsInputView.swift @@ -94,7 +94,7 @@ struct CarbsInputView: View { state.carbsAmount = Int(min(carbsAmount, effectiveCarbsLimit)) navigationPath.append(NavigationDestinations.bolusInput) } else { - state.sendCarbsRequest(Int(min(carbsAmount, effectiveCarbsLimit))) + state.sendCarbsEntry(Int(min(carbsAmount, effectiveCarbsLimit))) navigationPath.append(NavigationDestinations.acknowledgmentPending) } } @@ -103,6 +103,9 @@ struct CarbsInputView: View { .disabled(!(carbsAmount > 0.0) || carbsAmount > effectiveCarbsLimit) } .background(trioBackgroundColor) + // The entry's 2 min clock restarts whenever the user touches it + .onAppear { state.markEntryEdited() } + .onChange(of: carbsAmount) { state.markEntryEdited() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Image("premeal") diff --git a/Trio Watch App Extension/Views/TrioMainWatchView.swift b/Trio Watch App Extension/Views/TrioMainWatchView.swift index f8803e3d91..71a0917625 100644 --- a/Trio Watch App Extension/Views/TrioMainWatchView.swift +++ b/Trio Watch App Extension/Views/TrioMainWatchView.swift @@ -3,6 +3,7 @@ import SwiftUI import WatchKit struct TrioMainWatchView: View { + @Environment(\.scenePhase) private var scenePhase @State private var state = WatchState() // misc @@ -233,6 +234,15 @@ struct TrioMainWatchView: View { if newPath.isEmpty { // Reset conditional view navigation when returning to root view continueToBolus = false + // Back on the main screen, so no entry is in progress + state.clearEntry() + } + } + .onChange(of: scenePhase) { _, newPhase in + // An entry left for more than 2 min (wrist down, app in the background) is + // cleared and the input screens are closed, so a stale amount can't be confirmed + if newPhase == .active, state.expireStaleEntry() { + navigationPath = NavigationPath() } } } diff --git a/Trio Watch App Extension/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index 30799a2c88..fc8b348a6e 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -4,27 +4,50 @@ import WatchConnectivity // MARK: - Send Data to Phone extension WatchState { + /// Sends the entry confirmed with the crown: its carbs, if any, then the bolus. + /// Refuses an entry older than `entryLifetime` and clears it instead. + func sendConfirmedEntry(bolusAmount amount: Double, at now: Date = Date()) { + guard refuseIfExpired(at: now) == false else { return } + + let carbs = carbsAmount + clearEntry() + if carbs > 0 { + sendCarbsRequest(carbs, now) + } + sendBolusRequest(Decimal(amount)) + } + + /// Sends a carbs-only entry ("Log Carbs"). Refuses an entry older than `entryLifetime` and clears it instead. + func sendCarbsEntry(_ amount: Int, at now: Date = Date()) { + guard refuseIfExpired(at: now) == false else { return } + + clearEntry() + sendCarbsRequest(amount, now) + } + + private func refuseIfExpired(at now: Date) -> Bool { + guard !isEntryFresh(at: now) else { return false } + WatchLogger.shared.warning(.treatments, "Entry older than \(Int(Self.entryLifetime / 60)) min not sent") + clearEntry() + handleRequestNotSent(Self.entryExpiredMessage) + return true + } + /// Sends a bolus insulin request to the paired iPhone /// - Parameters: /// - amount: The insulin amount to be delivered func sendBolusRequest(_ amount: Decimal) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.bolus, "Bolus request not sent, phone unreachable") + guard amount > 0 else { + WatchLogger.shared.warning(.bolus, "Bolus of \(amount) U not sent, amount must be above 0") + handleRequestNotSent(Self.invalidBolusAmountMessage) return } - WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") - - let message: [String: Any] = [ - WatchMessageKeys.bolus: amount - ] + guard let session = reachableSession(.bolus, notSentLog: "Bolus request not sent, phone unreachable") else { return } - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.bolus, "Sending bolus request failed", error: error) - } + WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") - // Display pending communication animation - showCommsAnimation = true + send([WatchMessageKeys.bolus: amount], on: session, .bolus, failureLog: "Sending bolus request failed") } /// Sends a carbohydrate entry request to the paired iPhone @@ -32,10 +55,7 @@ extension WatchState { /// - amount: The amount of carbs in grams /// - date: The timestamp for the carb entry (defaults to current time) func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Carbs request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Carbs request not sent, phone unreachable") else { return } WatchLogger.shared.info(.treatments, "Carbs of \(amount) g requested for \(date)") @@ -43,99 +63,91 @@ extension WatchState { WatchMessageKeys.carbs: amount, WatchMessageKeys.date: date.timeIntervalSince1970 ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending carbs request failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send(message, on: session, .treatments, failureLog: "Sending carbs request failed") } /// Sends a request to cancel the current override preset to the paired iPhone func sendCancelOverrideRequest() { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Override cancel not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Override cancel not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Override cancel requested") - let message: [String: Any] = [ - WatchMessageKeys.cancelOverride: true - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending override cancel failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send([WatchMessageKeys.cancelOverride: true], on: session, .treatments, failureLog: "Sending override cancel failed") } /// Sends a request to activate an override preset to the paired iPhone /// - Parameter presetName: The name of the override preset to activate func sendActivateOverrideRequest(presetName: String) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Override request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Override request not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Override preset \(presetName) requested") - let message: [String: Any] = [ - WatchMessageKeys.activateOverride: presetName - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending override request failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send( + [WatchMessageKeys.activateOverride: presetName], + on: session, + .treatments, + failureLog: "Sending override request failed" + ) } /// Sends a request to cancel the current temporary target to the paired iPhone func sendCancelTempTargetRequest() { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Temp target cancel not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Temp target cancel not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Temp target cancel requested") - let message: [String: Any] = [ - WatchMessageKeys.cancelTempTarget: true - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending temp target cancel failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send( + [WatchMessageKeys.cancelTempTarget: true], + on: session, + .treatments, + failureLog: "Sending temp target cancel failed" + ) } /// Sends a request to activate a temporary target preset to the paired iPhone /// - Parameter presetName: The name of the temporary target preset to activate func sendActivateTempTargetRequest(presetName: String) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Temp target request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Temp target request not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Temp target preset \(presetName) requested") - let message: [String: Any] = [ - WatchMessageKeys.activateTempTarget: presetName - ] + send( + [WatchMessageKeys.activateTempTarget: presetName], + on: session, + .treatments, + failureLog: "Sending temp target request failed" + ) + } - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending temp target request failed", error: error) + /// The session when the phone can take a request; otherwise tells the user nothing was sent + private func reachableSession(_ category: WatchLogEntry.Category, notSentLog: String) -> WCSession? { + guard let session = session, session.isReachable else { + WatchLogger.shared.warning(category, notSentLog) + handleRequestNotSent(Self.phoneUnreachableMessage) + return nil } + return session + } + + /// Sends a treatment request and waits for the phone's acknowledgment + private func send( + _ message: [String: Any], + on session: WCSession, + _ category: WatchLogEntry.Category, + failureLog: String + ) { + beginAwaitingAcknowledgment() - // Display pending communication animation - showCommsAnimation = true + session.sendMessage(message, replyHandler: nil) { error in + WatchLogger.shared.error(category, failureLog, error: error) + DispatchQueue.main.async { + self.handleMissingAcknowledgment() + } + } } /// Sends a request to calculate a bolus recommendation based on the current carbs amount diff --git a/Trio Watch App Extension/WatchState.swift b/Trio Watch App Extension/WatchState.swift index 25558fb583..30e223cf79 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -36,6 +36,14 @@ import WatchConnectivity var proteinAmount: Int = 0 var bolusAmount: Double = 0.0 var confirmationProgress: Double = 0.0 + /// Last time the user opened or changed a carbs/bolus entry; nil when no entry is in progress + @ObservationIgnored private(set) var entryEditedAt: Date? + /// An entry left alone for longer than this is cleared and never sent + static let entryLifetime: TimeInterval = 2 * 60 + static let entryExpiredMessage = String( + localized: "Entry expired after 2 minutes. Nothing was sent. Please enter it again.", + comment: "Watch message when a carbs or bolus entry was too old to send" + ) // Safety limits var maxBolus: Decimal = 10 @@ -61,6 +69,22 @@ import WatchConnectivity var acknowledgementStatus: AcknowledgementStatus = .pending var acknowledgmentMessage: String = "" var shouldNavigateToRoot: Bool = true + @ObservationIgnored private var acknowledgmentTimeoutWorkItem: DispatchWorkItem? + + /// Long enough for a pump to accept a bolus, short enough that the user isn't left guessing + static let acknowledgmentTimeout: TimeInterval = 15 + static let noAnswerMessage = String( + localized: "No answer from iPhone. Check Trio on your iPhone before trying again.", + comment: "Watch message when the phone did not confirm a request" + ) + static let phoneUnreachableMessage = String( + localized: "iPhone not reachable. Nothing was sent.", + comment: "Watch message when a request could not be sent to the phone" + ) + static let invalidBolusAmountMessage = String( + localized: "Error! Bolus amount must be greater than 0 U.", + comment: "Error message when a bolus of zero or less is requested" + ) // Bolus calculation progress var showBolusCalculationProgress: Bool = false @@ -107,9 +131,72 @@ import WatchConnectivity } } + // MARK: - Entry lifetime + + /// Starts or restarts the entry's clock. Call when an input screen appears and on every amount change. + func markEntryEdited(at now: Date = Date()) { + entryEditedAt = now + } + + /// True only for an entry changed within `entryLifetime`; an entry with no recorded edit is never fresh + func isEntryFresh(at now: Date = Date()) -> Bool { + guard let entryEditedAt else { return false } + return now.timeIntervalSince(entryEditedAt) <= Self.entryLifetime + } + + /// Ends the entry: clears amounts, the recommendation and the confirmation progress + func clearEntry() { + bolusAmount = 0 + carbsAmount = 0 + fatAmount = 0 + proteinAmount = 0 + recommendedBolus = 0 + confirmationProgress = 0 + entryEditedAt = nil + } + + /// Clears an entry older than `entryLifetime`. Returns true when it did, so the caller can + /// leave the input screens. + @discardableResult func expireStaleEntry(at now: Date = Date()) -> Bool { + guard entryEditedAt != nil, !isEntryFresh(at: now) else { return false } + WatchLogger.shared.info(.treatments, "Entry older than \(Int(Self.entryLifetime / 60)) min cleared") + clearEntry() + return true + } + // MARK: – Handle Acknowledgement Messages FROM Phone + /// Shows "Processing…" and starts the clock for the phone's answer + func beginAwaitingAcknowledgment(timeout: TimeInterval = WatchState.acknowledgmentTimeout) { + acknowledgmentTimeoutWorkItem?.cancel() + acknowledgementStatus = .pending + showCommsAnimation = true + + let workItem = DispatchWorkItem { [weak self] in + self?.handleMissingAcknowledgment() + } + acknowledgmentTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: workItem) + } + + /// No answer arrived in time, or sending reported an error. Sending can report an error and + /// still deliver the request, so this tells the user to check the phone rather than claiming + /// the request failed. + func handleMissingAcknowledgment() { + guard showCommsAnimation else { return } + WatchLogger.shared.warning(.treatments, "No answer from the phone for the last request") + handleAcknowledgment(success: false, message: Self.noAnswerMessage) + } + + /// The request was refused on the watch and never sent + func handleRequestNotSent(_ message: String) { + handleAcknowledgment(success: false, message: message) + } + func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) { + acknowledgmentTimeoutWorkItem?.cancel() + acknowledgmentTimeoutWorkItem = nil + if success { acknowledgementStatus = .success acknowledgmentMessage = message @@ -251,21 +338,22 @@ import WatchConnectivity /// Called when the reachability status of the paired iPhone changes /// Updates the local reachability status func sessionReachabilityDidChange(_ session: WCSession) { + let isReachable = session.isReachable DispatchQueue.main.async { - if WatchLogger.shared.shouldLog("reachability", state: "\(session.isReachable)") { - WatchLogger.shared.debug(.connectivity, "Phone reachable: \(session.isReachable)") - } - - if session.isReachable { - self.forceConditionalWatchStateUpdate() + self.handleReachabilityChange(isReachable: isReachable) + } + } - // reset input amounts - self.bolusAmount = 0 - self.carbsAmount = 0 + /// Reachability flips on every wrist raise, so this must leave entries in progress alone: + /// clearing `bolusAmount` here turned a confirmed bolus into a 0 U request. + /// The main view resets the entry fields when it appears. + func handleReachabilityChange(isReachable: Bool) { + if WatchLogger.shared.shouldLog("reachability", state: "\(isReachable)") { + WatchLogger.shared.debug(.connectivity, "Phone reachable: \(isReachable)") + } - // reset auth progress - self.confirmationProgress = 0 - } + if isReachable { + forceConditionalWatchStateUpdate() } } diff --git a/Trio Watch App Tests/Unit Tests.swift b/Trio Watch App Tests/Unit Tests.swift index 0d0cde0eb4..996dfb5a53 100644 --- a/Trio Watch App Tests/Unit Tests.swift +++ b/Trio Watch App Tests/Unit Tests.swift @@ -1,11 +1,15 @@ -import SwiftUICore +import SwiftUI import Testing @testable import Trio_Watch_App -import XCTest -@Suite("Watch App Tests") final class TrioWatchAppTests { +@Suite("Watch App Tests", .serialized) @MainActor final class TrioWatchAppTests { var watchState = WatchState() + /// Lets main-queue work scheduled by WatchState (asyncAfter, async) run + private func waitForMainQueue(_ seconds: Double) async throws { + try await Task.sleep(for: .milliseconds(Int(seconds * 1000))) + } + // MARK: - Color Conversion Tests @Test("Hex string to color conversion") func testHexStringToColor() throws { @@ -40,61 +44,233 @@ import XCTest @Test("Carb limits have correct default values") func testCarbLimits() throws { #expect(watchState.maxCarbs == Decimal(250)) - #expect(watchState.maxCOB == Decimal(120)) } - @Test("Bolus cancellation resets all related values") func testBolusCancellation() throws { - // Given - watchState.bolusProgress = 0.5 - watchState.activeBolusAmount = 5.0 - watchState.isBolusCanceled = false + @Test("Acknowledgment states transition correctly") func testAcknowledgmentStates() async throws { + // Given - Initial state + #expect(watchState.acknowledgementStatus == .pending) + #expect(!watchState.showAcknowledgmentBanner) - // When - watchState.sendCancelBolusRequest() + // When/Then - Success acknowledgment + watchState.handleAcknowledgment(success: true, message: "Success") + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Success") + try await waitForMainQueue(0.5) + #expect(watchState.showAcknowledgmentBanner) - // Then - #expect(watchState.isBolusCanceled) - #expect(watchState.bolusProgress == 0) - #expect(watchState.activeBolusAmount == 0) + // When/Then - Failure acknowledgment + watchState.handleAcknowledgment(success: false, message: "Error") + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == "Error") } - @Test("Meal bolus combo state transitions work correctly") func testMealBolusComboState() throws { - // Given - Initial state - #expect(!watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // MARK: - Reachability must not touch entries in progress - // When - Setup meal bolus combo - watchState.carbsAmount = 30 + @Test("Phone reconnecting keeps the bolus being confirmed") func testReconnectKeepsBolusEntry() throws { + // Given - the user is on the confirm screen, partway through dialing the crown watchState.bolusAmount = 3.0 + watchState.carbsAmount = 20 + watchState.confirmationProgress = 0.5 + + // When - raising the wrist reconnects the phone + watchState.handleReachabilityChange(isReachable: false) + watchState.handleReachabilityChange(isReachable: true) + + // Then - the confirmed amount is still the one the user entered, not 0 U + #expect(watchState.bolusAmount == 3.0) + #expect(watchState.carbsAmount == 20) + #expect(watchState.confirmationProgress == 0.5) + } - // Then - Test state transitions - watchState.handleAcknowledgment(success: true, message: "Saving carbs...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // MARK: - Bolus requests that must not be sent - watchState.handleAcknowledgment(success: true, message: "Enacting bolus...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .enactingBolus) + @Test("Zero bolus is refused on the watch", arguments: [ + Decimal(0), + Decimal(-1) + ]) func testZeroBolusRefused(amount: Decimal) async throws { + watchState.sendBolusRequest(amount) - watchState.handleAcknowledgment(success: true, message: "Carbs and bolus logged successfully", isFinal: true) - #expect(!watchState.isMealBolusCombo) + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.invalidBolusAmountMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) + #expect(watchState.showAcknowledgmentBanner) } - @Test("Acknowledgment states transition correctly") func testAcknowledgmentStates() throws { - // Given - Initial state - #expect(watchState.acknowledgementStatus == .pending) - #expect(!watchState.showAcknowledgmentBanner) + @Test("Bolus with the phone unreachable says nothing was sent") func testUnreachableBolus() async throws { + watchState.session = nil - // When/Then - Success acknowledgment - watchState.handleAcknowledgment(success: true, message: "Success") - #expect(watchState.acknowledgementStatus == .success) + watchState.sendBolusRequest(3) + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) #expect(watchState.showAcknowledgmentBanner) - #expect(watchState.acknowledgmentMessage == "Success") + } - // When/Then - Failure acknowledgment - watchState.handleAcknowledgment(success: false, message: "Error") + @Test("Other treatment requests with the phone unreachable say nothing was sent") func testUnreachableTreatments() throws { + watchState.session = nil + let requests: [() -> Void] = [ + { self.watchState.sendCarbsRequest(20) }, + { self.watchState.sendCancelOverrideRequest() }, + { self.watchState.sendActivateOverrideRequest(presetName: "Exercise") }, + { self.watchState.sendCancelTempTargetRequest() }, + { self.watchState.sendActivateTempTargetRequest(presetName: "Eating soon") } + ] + + for request in requests { + watchState.acknowledgementStatus = .pending + watchState.acknowledgmentMessage = "" + request() + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + } + } + + // MARK: - Waiting for the phone's answer + + @Test("Processing ends with a check-your-phone message when the phone never answers") func testAcknowledgmentTimeout() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 0.1) + #expect(watchState.showCommsAnimation) + #expect(watchState.acknowledgementStatus == .pending) + + try await waitForMainQueue(0.6) + + #expect(!watchState.showCommsAnimation) #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.noAnswerMessage) #expect(watchState.showAcknowledgmentBanner) - #expect(watchState.acknowledgmentMessage == "Error") + } + + @Test("An answer from the phone cancels the timeout") func testAcknowledgmentCancelsTimeout() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 0.1) + + watchState.handleAcknowledgment(success: true, message: "Bolus enacted successfully.") + try await waitForMainQueue(0.6) + + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") + } + + @Test("A send error ends processing with the check-your-phone message") func testSendErrorEndsProcessing() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 60) + + // What the sendMessage error handler does + watchState.handleMissingAcknowledgment() + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.noAnswerMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) + } + + @Test("A late timeout does not overwrite an answer already shown") func testMissingAcknowledgmentAfterAnswer() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 60) + watchState.handleAcknowledgment(success: true, message: "Bolus enacted successfully.") + try await waitForMainQueue(0.3) + + watchState.handleMissingAcknowledgment() + + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") + } + + // MARK: - Entries expire after 2 minutes + + private let entryStart = Date(timeIntervalSince1970: 1_800_000_000) + + /// An entry on the confirm screen: 20 g carbs and 3 U, last edited at `entryStart` + private func enterMealBolus() { + watchState.carbsAmount = 20 + watchState.bolusAmount = 3.0 + watchState.confirmationProgress = 0.5 + watchState.markEntryEdited(at: entryStart) + } + + private func expectEntryCleared() { + #expect(watchState.bolusAmount == 0) + #expect(watchState.carbsAmount == 0) + #expect(watchState.confirmationProgress == 0) + #expect(watchState.entryEditedAt == nil) + } + + @Test("An entry is fresh for 2 minutes after its last edit") func testEntryFreshness() throws { + #expect(!watchState.isEntryFresh(at: entryStart), "no entry has been edited yet") + + watchState.markEntryEdited(at: entryStart) + + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(119))) + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(120))) + #expect(!watchState.isEntryFresh(at: entryStart.addingTimeInterval(121))) + + // Editing again restarts the clock + watchState.markEntryEdited(at: entryStart.addingTimeInterval(100)) + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(200))) + } + + @Test("Confirming an entry older than 2 minutes sends nothing and clears it") + func testExpiredEntryNotSent() throws { + enterMealBolus() + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart.addingTimeInterval(121)) + + // The expired message, not "phone unreachable", shows nothing was attempted + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Confirming an entry with no recorded edit sends nothing") func testUneditedEntryNotSent() throws { + watchState.bolusAmount = 3.0 + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart) + + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Confirming a fresh entry sends it and clears it") func testFreshEntrySent() throws { + enterMealBolus() + // No phone in tests: reaching the "unreachable" refusal proves the send was attempted + watchState.session = nil + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart.addingTimeInterval(60)) + + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + expectEntryCleared() + } + + @Test("Logging carbs older than 2 minutes sends nothing") func testExpiredCarbsNotSent() throws { + watchState.markEntryEdited(at: entryStart) + + watchState.sendCarbsEntry(20, at: entryStart.addingTimeInterval(121)) + + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Logging fresh carbs sends them") func testFreshCarbsSent() throws { + watchState.markEntryEdited(at: entryStart) + watchState.session = nil + + watchState.sendCarbsEntry(20, at: entryStart.addingTimeInterval(60)) + + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + expectEntryCleared() + } + + @Test("Returning to the app clears an entry older than 2 minutes") func testStaleEntryExpiresOnReturn() throws { + enterMealBolus() + + #expect(!watchState.expireStaleEntry(at: entryStart.addingTimeInterval(120))) + #expect(watchState.bolusAmount == 3.0) + + #expect(watchState.expireStaleEntry(at: entryStart.addingTimeInterval(121))) + expectEntryCleared() + } + + @Test("Returning to the app with no entry in progress changes nothing") func testNoEntryOnReturn() throws { + #expect(!watchState.expireStaleEntry(at: entryStart)) } } diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index c1892503cc..fadf53abe2 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -228,8 +228,6 @@ 3B4BA76B2D8DBD690069D5B8 /* CGMBLEKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */; }; 3B4BA76D2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA76E2D8DBD690069D5B8 /* DanaKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */; }; - 3B4BA76F2D8DBD690069D5B8 /* DanaKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */; }; 3B4BA7712D8DBD690069D5B8 /* G7SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA7722D8DBD690069D5B8 /* G7SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75F2D8DBD690069D5B8 /* G7SensorKitUI.framework */; }; @@ -238,16 +236,6 @@ 3B4BA7752D8DBD690069D5B8 /* LibreTransmitter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7602D8DBD690069D5B8 /* LibreTransmitter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */; }; 3B4BA7772D8DBD690069D5B8 /* LibreTransmitterUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */; }; - 3B4BA7792D8DBD690069D5B8 /* MinimedKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA77A2D8DBD690069D5B8 /* MinimedKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */; }; - 3B4BA77B2D8DBD690069D5B8 /* MinimedKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7822D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */; }; - 3B4BA7832D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7842D8DBD690069D5B8 /* RileyLinkKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */; }; - 3B4BA7852D8DBD690069D5B8 /* RileyLinkKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7862D8DBD690069D5B8 /* RileyLinkKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */; }; - 3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA78A2D8DC0EC0069D5B8 /* ShareClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE398D1A297D69A900DF218F /* ShareClient.framework */; }; 3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE398D1A297D69A900DF218F /* ShareClient.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA78C2D8DC0EC0069D5B8 /* ShareClientUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE79502D29980E4D00FA576E /* ShareClientUI.framework */; }; @@ -518,9 +506,8 @@ B015AFF52E600000000D7351 /* AdjustmentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */; }; B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */; }; B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; }; + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */; }; B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; }; - B6E925132EB3932A0076D719 /* OmnipodKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; }; - B6E925142EB3932A0076D719 /* OmnipodKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = B958F1B62BA0711600484851 /* MKRingProgressView */; }; BA00D96F7B2FF169A06FB530 /* CGMSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C018D1680307A31C9ED7120 /* CGMSettingsStateModel.swift */; }; BB5227A51D9D4377A1A70BA6 /* TempTargetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8F3B0E159844FD8746DFC0 /* TempTargetData.swift */; }; @@ -861,16 +848,6 @@ CE7CA3582A064E2F004BE681 /* ListStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA3572A064E2F004BE681 /* ListStateView.swift */; }; CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02428E867BA00473A9C /* AlertStorage.swift */; }; CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */; }; - 9A3C0DE10000000000000B01 /* WatchCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A01 /* WatchCommand.swift */; }; - 9A3C0DE10000000000000B02 /* WatchCommandRequestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A02 /* WatchCommandRequestCache.swift */; }; - 9A3C0DE10000000000000B03 /* WatchCommandActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A03 /* WatchCommandActions.swift */; }; - 9A3C0DE10000000000000B04 /* WatchCommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A04 /* WatchCommandProcessor.swift */; }; - 9A3C0DE10000000000000B05 /* GarminCommandRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A05 /* GarminCommandRouter.swift */; }; - 9A3C0DE10000000000000B06 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A06 /* WatchCommandTestDoubles.swift */; }; - 9A3C0DE10000000000000B07 /* WatchCommandRequestCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A07 /* WatchCommandRequestCacheTests.swift */; }; - 9A3C0DE10000000000000B08 /* WatchCommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A08 /* WatchCommandProcessorTests.swift */; }; - 9A3C0DE10000000000000B09 /* GarminCommandRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A09 /* GarminCommandRouterTests.swift */; }; - 9A3C0DE10000000000000B0A /* WatchCommandIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A0A /* WatchCommandIntegrationTests.swift */; }; CE94598029E9E3BD0047C9C6 /* WatchConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */; }; CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94598129E9E3D30047C9C6 /* WatchConfigProvider.swift */; }; CE94598429E9E3E60047C9C6 /* WatchConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94598329E9E3E60047C9C6 /* WatchConfigStateModel.swift */; }; @@ -1222,6 +1199,20 @@ 2A3D41ADE8834E8485A9504D /* PumpStatusSuspendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2DC7144FFD427B96B8A2B8 /* PumpStatusSuspendTests.swift */; }; B52DBBF355407A732FDCE1DE /* ReportClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12ECFA954DA8F662969E3223 /* ReportClientTests.swift */; }; 99506C3B9CC23E2F925009D3 /* ReportProblemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C3B7105DC451594DF96299 /* ReportProblemTests.swift */; }; + 9A3C4FC24B2DFEEB95911EDE /* WatchCommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */; }; + 9A3CD75136E1A66C2F49240C /* WatchCommandActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */; }; + 9A3CB4CA0FB381954EA4BA94 /* WatchCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */; }; + 9A3C151EF9FCECCF212ED730 /* WatchPresets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */; }; + 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */; }; + 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */; }; + 9A3CCDA32C874DE6AB45F00D /* WatchCommandIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */; }; + 9A3C1EE99DC965899EC25C4F /* WatchCommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */; }; + 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */; }; + 9A3C0ECD1617389255D168CF /* WatchCommandRequestCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */; }; + 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */; }; + 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */; }; + 9A3CF45C4133651CC90484F5 /* GarminMessageRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */; }; + 9A3C034686E6B4AC8CE5E7D8 /* GarminManagerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1275,28 +1266,21 @@ 3B4BA7732D8DBD690069D5B8 /* G7SensorKitUI.framework in Embed Frameworks */, 3B4BA76D2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Embed Frameworks */, 3B4BA76B2D8DBD690069D5B8 /* CGMBLEKit.framework in Embed Frameworks */, - 3B4BA7792D8DBD690069D5B8 /* MinimedKit.framework in Embed Frameworks */, CE95BF5C2BA770C300DC3DE3 /* LoopKit.framework in Embed Frameworks */, 3B4BA7712D8DBD690069D5B8 /* G7SensorKit.framework in Embed Frameworks */, 3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */, CEB434FE28B90B8C00B70274 /* SwiftCharts in Embed Frameworks */, - 3B4BA76F2D8DBD690069D5B8 /* DanaKit.framework in Embed Frameworks */, - 3B4BA7852D8DBD690069D5B8 /* RileyLinkKit.framework in Embed Frameworks */, 3B4BA7752D8DBD690069D5B8 /* LibreTransmitter.framework in Embed Frameworks */, 3B4BA7772D8DBD690069D5B8 /* LibreTransmitterUI.framework in Embed Frameworks */, 5E12FE0E2EBC0000000000C1 /* LibreLoop.framework in Embed Frameworks */, 5E12FE0E2EBC0000000000C2 /* LibreLoopUI.framework in Embed Frameworks */, - 3B4BA77B2D8DBD690069D5B8 /* MinimedKitUI.framework in Embed Frameworks */, - 3B4BA7832D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Embed Frameworks */, CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */, CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */, 3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */, 3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */, - 3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */, 3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */, 3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */, CE95BF602BA7715800DC3DE3 /* MockKit.framework in Embed Frameworks */, - B6E925142EB3932A0076D719 /* OmnipodKit.framework in Embed Frameworks */, 3E5308A63002BC970075629E /* AccuChekKit.framework in Embed Frameworks */, CE95BF5E2BA770C300DC3DE3 /* LoopKitUI.framework in Embed Frameworks */, ); @@ -1437,8 +1421,6 @@ 3818AA4D274C26A300843DB3 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA4E274C26A300843DB3 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA4F274C26A300843DB3 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3818AA51274C26A300843DB3 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3818AA52274C26A300843DB3 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA70274C278200843DB3 /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopTestingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 38192E03261B82FA0094D973 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; 38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTreatmentsManager.swift; sourceTree = ""; }; @@ -1568,16 +1550,10 @@ 3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpHistoryEvent+Duplicates.swift"; sourceTree = ""; }; 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DanaKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA75F2D8DBD690069D5B8 /* G7SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7602D8DBD690069D5B8 /* LibreTransmitter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreTransmitter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreTransmitterUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B506FD82E635304000740B9 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = ""; }; @@ -1837,13 +1813,13 @@ B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentManagerTests.swift; sourceTree = ""; }; B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = ""; }; B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = ""; }; + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnactBolusAmountTests.swift; sourceTree = ""; }; B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = ""; }; B3919BBB515547118D684CF2 /* AlgorithmSuggestedBolusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AlgorithmSuggestedBolusTests.swift; sourceTree = ""; }; B3919BBB515547118D684CE2 /* DosingModeClampTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DosingModeClampTests.swift; sourceTree = ""; }; B3919BBB515547118D684CD2 /* LoopIndicatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoopIndicatorTests.swift; sourceTree = ""; }; B3919BBB515547118D684CC2 /* MaxIOBSuggestionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MaxIOBSuggestionTests.swift; sourceTree = ""; }; B3919BBB515547118D684CB2 /* DosingModeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DosingModeMigrationTests.swift; sourceTree = ""; }; - B6E925122EB3932A0076D719 /* OmnipodKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmnipodKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B9B5C0607505A38F256BF99A /* CGMSettingsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMSettingsDataFlow.swift; sourceTree = ""; }; BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorDataFlow.swift; sourceTree = ""; }; BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressOverlay.swift; sourceTree = ""; }; @@ -2191,16 +2167,6 @@ CE7CA3572A064E2F004BE681 /* ListStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateView.swift; sourceTree = ""; }; CE82E02428E867BA00473A9C /* AlertStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStorage.swift; sourceTree = ""; }; CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManager.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A01 /* WatchCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommand.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A02 /* WatchCommandRequestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCache.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A03 /* WatchCommandActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandActions.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A04 /* WatchCommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessor.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A05 /* GarminCommandRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminCommandRouter.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A06 /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A07 /* WatchCommandRequestCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCacheTests.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A08 /* WatchCommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessorTests.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A09 /* GarminCommandRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminCommandRouterTests.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A0A /* WatchCommandIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandIntegrationTests.swift; sourceTree = ""; }; CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigDataFlow.swift; sourceTree = ""; }; CE94598129E9E3D30047C9C6 /* WatchConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigProvider.swift; sourceTree = ""; }; CE94598329E9E3E60047C9C6 /* WatchConfigStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigStateModel.swift; sourceTree = ""; }; @@ -2213,8 +2179,6 @@ CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; CEB434E428B8FF5D00B70274 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopUIColorPalette+Default.swift"; sourceTree = ""; }; - CEC751D529D88262006E9D24 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - CEC751D729D88262006E9D24 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsProvider.swift; sourceTree = ""; }; CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsRootView.swift; sourceTree = ""; }; CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsChart.swift; sourceTree = ""; }; @@ -2535,6 +2499,20 @@ AE2DC7144FFD427B96B8A2B8 /* PumpStatusSuspendTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpStatusSuspendTests.swift; sourceTree = ""; }; 12ECFA954DA8F662969E3223 /* ReportClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportClientTests.swift; sourceTree = ""; }; 70C3B7105DC451594DF96299 /* ReportProblemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemTests.swift; sourceTree = ""; }; + 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessor.swift; sourceTree = ""; }; + 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandActions.swift; sourceTree = ""; }; + 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommand.swift; sourceTree = ""; }; + 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPresets.swift; sourceTree = ""; }; + 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCache.swift; sourceTree = ""; }; + 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipeline.swift; sourceTree = ""; }; + 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandIntegrationTests.swift; sourceTree = ""; }; + 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessorTests.swift; sourceTree = ""; }; + 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipelineTests.swift; sourceTree = ""; }; + 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCacheTests.swift; sourceTree = ""; }; + 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; + 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouter.swift; sourceTree = ""; }; + 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouterTests.swift; sourceTree = ""; }; + 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManagerIntegrationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -2555,21 +2533,15 @@ 5E12FE0E2EBC0000000000B1 /* LibreLoop.framework in Frameworks */, 5E12FE0E2EBC0000000000B2 /* LibreLoopUI.framework in Frameworks */, 3B4BA78C2D8DC0EC0069D5B8 /* ShareClientUI.framework in Frameworks */, - 3B4BA77A2D8DBD690069D5B8 /* MinimedKitUI.framework in Frameworks */, 3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */, 5E12FE112EBC0000000000B2 /* RoundWhiteDiscKit in Frameworks */, - 3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */, 3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */, 3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */, 3B4BA7902D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Frameworks */, 3B4BA76A2D8DBD690069D5B8 /* CGMBLEKit.framework in Frameworks */, - 3B4BA7822D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Frameworks */, - 3B4BA7842D8DBD690069D5B8 /* RileyLinkKit.framework in Frameworks */, - 3B4BA7862D8DBD690069D5B8 /* RileyLinkKitUI.framework in Frameworks */, 38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */, 38E87401274F77E400975559 /* CoreNFC.framework in Frameworks */, 3B4BA78A2D8DC0EC0069D5B8 /* ShareClient.framework in Frameworks */, - B6E925132EB3932A0076D719 /* OmnipodKit.framework in Frameworks */, 3E5308A53002BC970075629E /* AccuChekKit.framework in Frameworks */, 3811DE1025C9D37700A708ED /* Swinject in Frameworks */, 3B4BA78E2D8DC0EC0069D5B8 /* TidepoolServiceKit.framework in Frameworks */, @@ -2580,7 +2552,6 @@ CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */, 38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */, 3833B46D26012030003021B3 /* Algorithms in Frameworks */, - 3B4BA76E2D8DBD690069D5B8 /* DanaKit.framework in Frameworks */, 3B47C6102DA0A28F00B0E5EF /* FirebaseCrashlytics in Frameworks */, CEB434FD28B90B7C00B70274 /* SwiftCharts in Frameworks */, CE95BF5F2BA7715800DC3DE3 /* MockKit.framework in Frameworks */, @@ -2635,6 +2606,33 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9A3C6118713FEDFA9AD437CA /* WatchCommandTests */ = { + isa = PBXGroup; + children = ( + 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */, + 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */, + 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */, + 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */, + 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */, + 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */, + 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */, + ); + path = WatchCommandTests; + sourceTree = ""; + }; + 9A3CC598A206D8D945713F97 /* WatchCommand */ = { + isa = PBXGroup; + children = ( + 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */, + 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */, + 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */, + 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */, + 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */, + 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */, + ); + path = WatchCommand; + sourceTree = ""; + }; 08A79EA89BFA498F80F4D6FE /* QuickPickTreatmentsConfig */ = { isa = PBXGroup; children = ( @@ -3064,7 +3062,7 @@ DD7E1E30000000000000000A /* Telemetry */, 3811DEA525C9D88300A708ED /* UnlockManager */, 38E87406274F9AA500975559 /* UserNotifications */, - 9A3C0DE10000000000000C01 /* WatchCommand */, + 9A3CC598A206D8D945713F97 /* WatchCommand */, 38E8754D275556E100975559 /* WatchManager */, FCF410FF2012BCB1A08D4F48 /* ProfileScheduleFirer.swift */, 196DA87A7331DB0D3420E81B /* AppAttest */, @@ -3289,30 +3287,21 @@ 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */, 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */, 3E5308A43002BC970075629E /* AccuChekKit.framework */, - B6E925122EB3932A0076D719 /* OmnipodKit.framework */, 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */, 3E84DA3F2F48D96000033608 /* EversenseKit.framework */, 3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */, 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */, 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */, 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */, - 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */, 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */, 3B4BA75F2D8DBD690069D5B8 /* G7SensorKitUI.framework */, 3B4BA7602D8DBD690069D5B8 /* LibreTransmitter.framework */, 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */, 5E12FE0E2EBC0000000000A1 /* LibreLoop.framework */, 5E12FE0E2EBC0000000000A2 /* LibreLoopUI.framework */, - 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */, - 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */, - 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */, - 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */, - 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */, CE95BF492BA5CED700DC3DE3 /* LoopKit.framework */, CE95BF4A2BA5CED700DC3DE3 /* LoopKitUI.framework */, CE51DD1B2A01970800F163F7 /* ConnectIQ 2.xcframework */, - CEC751D529D88262006E9D24 /* MinimedKit.framework */, - CEC751D729D88262006E9D24 /* MinimedKitUI.framework */, CE79502D29980E4D00FA576E /* ShareClientUI.framework */, CE79502929980C9F00FA576E /* G7SensorKitUI.framework */, CE79502729980C9600FA576E /* CGMBLEKitUI.framework */, @@ -3327,8 +3316,6 @@ 3818AA4D274C26A300843DB3 /* LoopKitUI.framework */, 3818AA4E274C26A300843DB3 /* MockKit.framework */, 3818AA4F274C26A300843DB3 /* MockKitUI.framework */, - 3818AA51274C26A300843DB3 /* MinimedKit.framework */, - 3818AA52274C26A300843DB3 /* MinimedKitUI.framework */, 3818AA49274C267000843DB3 /* CGMBLEKit.framework */, 6B1A8D012B14D88B00E76752 /* UniformTypeIdentifiers.framework */, 6B1A8D182B14D91600E76752 /* WidgetKit.framework */, @@ -3703,7 +3690,7 @@ isa = PBXGroup; children = ( BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */, - 9A3C0DE10000000000000A05 /* GarminCommandRouter.swift */, + 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */, CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */, ); path = WatchManager; @@ -3754,7 +3741,7 @@ B015AFF62E600000000D7351 /* AdjustmentsTests */, B015AFE62E500000000D7351 /* BolusSafetyTests */, BD8FC0552D66187700B95AED /* CoreDataTests */, - 9A3C0DE10000000000000C02 /* WatchCommandTests */, + 9A3C6118713FEDFA9AD437CA /* WatchCommandTests */, 38FCF3F125E9028E0078B0D1 /* Info.plist */, CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */, CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */, @@ -4348,29 +4335,6 @@ path = AdjustmentsTests; sourceTree = ""; }; - 9A3C0DE10000000000000C01 /* WatchCommand */ = { - isa = PBXGroup; - children = ( - 9A3C0DE10000000000000A01 /* WatchCommand.swift */, - 9A3C0DE10000000000000A02 /* WatchCommandRequestCache.swift */, - 9A3C0DE10000000000000A03 /* WatchCommandActions.swift */, - 9A3C0DE10000000000000A04 /* WatchCommandProcessor.swift */, - ); - path = WatchCommand; - sourceTree = ""; - }; - 9A3C0DE10000000000000C02 /* WatchCommandTests */ = { - isa = PBXGroup; - children = ( - 9A3C0DE10000000000000A06 /* WatchCommandTestDoubles.swift */, - 9A3C0DE10000000000000A07 /* WatchCommandRequestCacheTests.swift */, - 9A3C0DE10000000000000A08 /* WatchCommandProcessorTests.swift */, - 9A3C0DE10000000000000A09 /* GarminCommandRouterTests.swift */, - 9A3C0DE10000000000000A0A /* WatchCommandIntegrationTests.swift */, - ); - path = WatchCommandTests; - sourceTree = ""; - }; B015AFE22E500000000D7351 /* BolusSafety */ = { isa = PBXGroup; children = ( @@ -4383,6 +4347,7 @@ isa = PBXGroup; children = ( B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */, + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */, ); path = BolusSafetyTests; sourceTree = ""; @@ -6294,11 +6259,13 @@ E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */, DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */, CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */, - 9A3C0DE10000000000000B01 /* WatchCommand.swift in Sources */, - 9A3C0DE10000000000000B02 /* WatchCommandRequestCache.swift in Sources */, - 9A3C0DE10000000000000B03 /* WatchCommandActions.swift in Sources */, - 9A3C0DE10000000000000B04 /* WatchCommandProcessor.swift in Sources */, - 9A3C0DE10000000000000B05 /* GarminCommandRouter.swift in Sources */, + 9A3C4FC24B2DFEEB95911EDE /* WatchCommandProcessor.swift in Sources */, + 9A3CD75136E1A66C2F49240C /* WatchCommandActions.swift in Sources */, + 9A3CB4CA0FB381954EA4BA94 /* WatchCommand.swift in Sources */, + 9A3C151EF9FCECCF212ED730 /* WatchPresets.swift in Sources */, + 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */, + 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */, + 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */, 3883583425EEB38000E024B2 /* PumpSettings.swift in Sources */, 38DAB280260CBB7F00F74C1A /* PumpView.swift in Sources */, DDD1631C2C4C697400CD525A /* AddOverrideForm.swift in Sources */, @@ -7168,6 +7135,7 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */, 9A3C0DE10000000000000B06 /* WatchCommandTestDoubles.swift in Sources */, 9A3C0DE10000000000000B07 /* WatchCommandRequestCacheTests.swift in Sources */, 9A3C0DE10000000000000B08 /* WatchCommandProcessorTests.swift in Sources */, diff --git a/Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json b/Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 0000000000..4a275a4edd --- /dev/null +++ b/Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "6ee3a6c0-b8a4-4bee-939b-8cada959fd38", + "targets" : [ + { + "id" : "F7FE623E-2EB9-4C2F-95CB-AA0A1692FA10", + "name" : "Tai" + } + ] +} \ No newline at end of file diff --git a/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme b/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme index cec3d6c604..66a19df58e 100644 --- a/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme +++ b/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme @@ -181,26 +181,6 @@ ReferencedContainer = "container:LoopKit/LoopKit.xcodeproj"> - - - - - - - - - - - - - - - - diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index 93a3e6ec7b..bdcee05b81 100644 --- a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -295,7 +295,7 @@ "location" : "https://github.com/tidepool-org/TidepoolKit", "state" : { "branch" : "dev", - "revision" : "290bd0431c55e8d0e0e542b163de13b1f07b77d9" + "revision" : "0f127debeecf5b3b378f7d7100c8e3d7e2d99615" } } ], diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index fbf2be848f..cde91e811e 100644 --- a/Trio/Resources/InfoPlist.xcstrings +++ b/Trio/Resources/InfoPlist.xcstrings @@ -1,282 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "CFBundleDisplayName" : { - "comment" : "Bundle display name", - "extractionState" : "extracted_with_value", - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "da" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - } - } - }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "da" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - } - } - }, "NFCReaderUsageDescription" : { "comment" : "Privacy - NFC Scan Usage Description", "extractionState" : "extracted_with_value", @@ -1105,6 +829,18 @@ } } }, + "NSCameraUsageDescription" : { + "comment" : "Privacy - Camera Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera is used to scan the code on your Dexcom sensor packaging." + } + } + } + }, "NSContactsUsageDescription" : { "comment" : "Privacy - Contacts Usage Description", "localizations" : { @@ -1660,133 +1396,133 @@ "localizations" : { "bg" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "cs" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "da" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "de" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "" + "value" : "autoISF 3.01" } }, "es" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "fr" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "he" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "it" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "ko" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "nb-NO" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "nl" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "pl" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "pt-PT" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "ro" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "ru" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "sv" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "tr" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "uk" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "vi" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } } diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 9645009b80..584a066220 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -688,7 +688,10 @@ final class BaseAPSManager: APSManager, Injectable { } func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async { - if amount <= 0 { + // Answer every caller: a silent return here left the watch waiting on "Processing…" forever + guard amount > 0 else { + warning(.bolus, "Bolus of \(amount) U not enacted, amount must be above 0") + callback?(false, Self.invalidBolusAmountMessage) return } @@ -715,6 +718,12 @@ final class BaseAPSManager: APSManager, Injectable { units: adjustPumpedVolumeToConcentration(amount).deliverable ) + guard roundedAmount > 0 else { + warning(.bolus, "Bolus of \(amount) U not enacted, rounds to 0 U on this pump") + callback?(false, Self.invalidBolusAmountMessage) + return + } + debug(.bolus, "Enacting bolus \(roundedAmount) U, \(isSMB ? "automatic" : "manual")") do { @@ -783,6 +792,11 @@ final class BaseAPSManager: APSManager, Injectable { } } + static let invalidBolusAmountMessage = String( + localized: "Error! Bolus amount must be greater than 0 U.", + comment: "Error message when a bolus of zero or less is requested" + ) + func cancelBolus(_ callback: ((Bool, String) -> Void)?) async { guard let pump = pumpManager, pump.status.pumpStatus.bolusing else { return } debug(.bolus, "Cancelling bolus") diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index a8fc3177a5..0485838869 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -1,14 +1,11 @@ import Combine import CoreData -import DanaKit import Foundation import LoopKit import LoopKitUI import MedtrumKit -import MinimedKit import MockKit import MockKitUI -import OmnipodKit import ShareClient import SwiftDate import Swinject @@ -154,13 +151,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { pumpActivatedAtDate.send(medtrumPump.state.patchActivatedAt) } } - if let omni = pumpManager as? OmniPumpManager { - guard let endTime = omni.state.podState?.expiresAt else { - pumpExpiresAtDate.send(nil) - return - } - pumpExpiresAtDate.send(endTime) - } if let simulatorPump = pumpManager as? MockPumpManager { pumpDisplayState.value = PumpDisplayState(name: simulatorPump.localizedTitle, image: simulatorPump.smallImage) pumpName.send(simulatorPump.localizedTitle) @@ -537,22 +527,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } - if let omni = pumpManager as? OmniPumpManager { - broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { - $0.pumpReservoirDidChange(omni.currentReservoir) - } - - guard let endTime = omni.state.podState?.expiresAt else { - pumpExpiresAtDate.send(nil) - return - } - pumpExpiresAtDate.send(endTime) - - if let startTime = omni.state.podState?.activatedAt { - storage.save(startTime, as: OpenAPS.Monitor.podAge) - } - } - if let simulatorPump = pumpManager as? MockPumpManager { broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { $0.pumpReservoirDidChange(Decimal(simulatorPump.state.reservoirUnitsRemaining)) @@ -717,15 +691,6 @@ extension PumpManager { case let medtrum as MedtrumPumpManager: // MedtrumKit keeps the last patch's reservoir until a new patch is activated return medtrum.state.patchSessionEnded ? nil : Decimal(medtrum.state.reservoir) - case let omni as OmniPumpManager: - guard let podState = omni.state.podState else { return nil } - guard let level = podState.lastInsulinMeasurements?.reservoirLevel, level <= 50 else { return 0xDEAD_BEEF } - return Decimal(level) - case let dana as DanaKitPumpManager: - // pumpTime is set on the first sync, after the same connection has read the reservoir - return dana.state.pumpTime == nil ? nil : Decimal(dana.state.reservoirLevel) - case let minimed as MinimedPumpManager: - return minimed.state.lastReservoirReading.map { Decimal($0.units) } case let mock as MockPumpManager: return Decimal(mock.state.reservoirUnitsRemaining) default: diff --git a/Trio/Sources/APS/Devices/DeviceCatalog.swift b/Trio/Sources/APS/Devices/DeviceCatalog.swift index 15d0f8d131..392ee01926 100644 --- a/Trio/Sources/APS/Devices/DeviceCatalog.swift +++ b/Trio/Sources/APS/Devices/DeviceCatalog.swift @@ -3,7 +3,6 @@ import AccuChekKit import CGMBLEKit import CGMBLEKitUI -import DanaKit import EversenseKit import Foundation import G7SensorKit @@ -16,12 +15,9 @@ import LoopKit import LoopKitUI import MachO import MedtrumKit -import MinimedKit -import MinimedKitUI import MockKit import MockKitUI import ObjectiveC -import OmnipodKit import SyaiKit import SyaiKitUI import UIKit @@ -415,27 +411,6 @@ extension DeviceCatalog { ] static let pumps: [PumpCatalogEntry] = [ - PumpCatalogEntry( - OmniPumpManager.self, - id: "Omni", - manufacturer: .insulet, - name: "Omnipod", - supportedModels: ["Classic", "DASH", "5"], - icon: .managerBundle(asset: "Pod"), - legacyIdentifierPrefixes: ["Omni"], - basalCapability: BasalRateCapability(supportedRates: OmniPumpManager.onboardingSupportedBasalRates), - reportsRewindEvents: false - ), - PumpCatalogEntry( - MinimedPumpManager.self, - id: "Minimed500", - manufacturer: .medtronic, - name: "MiniMed", - supportedModels: ["x15", "x22", "x23", "x54"], - icon: .uiBundle(identifier: "org.loopkit.MinimedKitUI", asset: "5xx Small Outline"), - basalCapability: BasalRateCapability(supportedRates: MinimedPumpManager.onboardingSupportedBasalRates), - reportsRewindEvents: true - ), PumpCatalogEntry( MedtrumPumpManager.self, id: "Medtrum", @@ -446,16 +421,6 @@ extension DeviceCatalog { basalCapability: BasalRateCapability(supportedRates: MedtrumPumpManager.onboardingSupportedBasalRates), reportsRewindEvents: false ), - PumpCatalogEntry( - DanaKitPumpManager.self, - id: "Dana", - manufacturer: .sooil, - name: "Dana", - supportedModels: ["DanaRS", "Dana-i"], - icon: .managerBundle(asset: "danars"), - basalCapability: BasalRateCapability(supportedRates: DanaKitPumpManager.onboardingSupportedBasalRates), - reportsRewindEvents: true - ), PumpCatalogEntry( MockPumpManager.self, id: "MockPumpManager", diff --git a/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift b/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift index 31d53c02d5..8734fe6d93 100644 --- a/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift +++ b/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift @@ -1,29 +1,13 @@ import Foundation import LoopKit -import RileyLinkBLEKit -import RileyLinkKit extension UserDefaults { private enum Key: String { case legacyPumpManagerRawValue = "com.rileylink.PumpManagerRawValue" - case rileyLinkConnectionManagerState = "com.rileylink.RileyLinkConnectionManagerState" case legacyPumpManagerState = "com.loopkit.Loop.PumpManagerState" case legacyCGMManagerState = "com.loopkit.Loop.CGMManagerState" } - var rileyLinkConnectionManagerState: RileyLinkConnectionState? { - get { - guard let rawValue = dictionary(forKey: Key.rileyLinkConnectionManagerState.rawValue) - else { - return nil - } - return RileyLinkConnectionState(rawValue: rawValue) - } - set { - set(newValue?.rawValue, forKey: Key.rileyLinkConnectionManagerState.rawValue) - } - } - var legacyPumpManagerRawValue: PumpManager.RawValue? { dictionary(forKey: Key.legacyPumpManagerRawValue.rawValue) } diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 6552bdaf5a..54dae0ab2a 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -744,7 +744,6 @@ extension BaseFetchGlucoseManager { return } - // Convert GlucoseStored to BloodGlucose array let bloodGlucoseArray: [BloodGlucose] = glucoseReadings.compactMap { stored -> BloodGlucose? in guard let date = stored.date else { return nil } @@ -778,7 +777,6 @@ extension BaseFetchGlucoseManager { var ukf = UnscentedKalmanFilter() let smoothed = ukf.smooth(bloodGlucoseArray) - // Store smoothed values back to CoreData for (idx, bloodGlucose) in smoothed.enumerated() where idx < glucoseReadings.count { if let smoothedValue = bloodGlucose.glucose { diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index 19f39e8e5c..ac4856bedf 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -35,6 +35,14 @@ final class ServiceAssembly: Assembly { .inObjectScope(.container) container.register(AdjustmentManager.self) { r in BaseAdjustmentManager(resolver: r) } .inObjectScope(.container) + container.register(WatchCommandAuthorization.self) { _ in WatchCommandAuthorization() } + .inObjectScope(.container) + container.register(WatchPresetsProvider.self) { r in BaseWatchPresetsProvider(resolver: r) } + // one instance, so its request-ID cache sees every watch request + container.register(WatchCommandPipeline.self) { r in + WatchCommandPipeline(authorization: r.resolve(WatchCommandAuthorization.self)!) + } + .inObjectScope(.container) container.register(WatchCommandProcessor.self) { r in BaseWatchCommandProcessor(resolver: r) } .inObjectScope(.container) container.register(TrioRemoteControl.self) { r in TrioRemoteControl(resolver: r) } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 2323913e85..a8e21b9891 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1238,6 +1238,9 @@ }, "This build of Trio can't send reports. Please share your logs on Discord or Facebook instead." : { + }, + "Tidepool Login Required" : { + }, "Title (optional)" : { @@ -1250,6 +1253,9 @@ }, "Up to %lld screenshots or photos." : { + }, + "Uploads to Tidepool are paused. Tap to log in." : { + }, "You've sent several reports today. Please try again tomorrow." : { @@ -131900,6 +131906,9 @@ } } }, + "Entry expired after 2 minutes. Nothing was sent. Please enter it again." : { + "comment" : "Watch message when a carbs or bolus entry was too old to send" + }, "Error" : { "comment" : "Error title", "localizations" : { @@ -132559,6 +132568,9 @@ } } }, + "Error! Bolus amount must be greater than 0 U." : { + "comment" : "Error message when a bolus of zero or less is requested" + }, "Error! Bolus cancellation failed with error: %@" : { "comment" : "Error message for canceling a bolus", "localizations" : { @@ -176363,6 +176375,9 @@ } } }, + "iPhone not reachable. Nothing was sent." : { + "comment" : "Watch message when a request could not be sent to the phone" + }, "is running" : { "localizations" : { "bg" : { @@ -211050,6 +211065,9 @@ } } }, + "No answer from iPhone. Check Trio on your iPhone before trying again." : { + "comment" : "Watch message when the phone did not confirm a request" + }, "No Bolus Data" : { "localizations" : { "bg" : { diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 547acb8fd0..865952d64e 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -466,7 +466,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { )) ?? [] let newestFirst = files .filter { $0.pathExtension == "zip" } - .map { ($0, (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)) } + .map { ($0, try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) } .sorted { ($0.1 ?? .distantPast, $0.0.lastPathComponent) > ($1.1 ?? .distantPast, $1.0.lastPathComponent) } for (file, _) in newestFirst.dropFirst(Self.legacyZipRetention) { try? FileManager.default.removeItem(at: file) diff --git a/Trio/Sources/Models/GarminWatchSettings.swift b/Trio/Sources/Models/GarminWatchSettings.swift index 240bca4d61..607cf6530a 100644 --- a/Trio/Sources/Models/GarminWatchSettings.swift +++ b/Trio/Sources/Models/GarminWatchSettings.swift @@ -84,9 +84,44 @@ enum GarminWatchface: String, JSON, CaseIterable, Identifiable, Codable, Hashabl } } +// MARK: - Garmin Command App + +/// Connect IQ watch app that sends commands next to a Trio or Swissalpine watchface. +enum GarminWatchControls { + static let displayName = "Trio-WatchControls" + static let appUUID = UUID(uuidString: "d504737e-8315-48d4-b07e-4c421ffb6f54") +} + +/// The one watch app allowed to send commands; with both command apps installed, both would otherwise prompt. +/// Raw values are the `owner` the watch reads from the command app notice. +enum GarminCommandApp: String { + case complication + case watchControls + case none + + /// The watchface selection decides, since Complication then runs in the watchface slot. + init(_ settings: GarminWatchSettings) { + if settings.watchface == .complication { + self = .complication + } else { + self = settings.isCommandControlEnabled ? .watchControls : .none + } + } + + var appUUID: UUID? { + switch self { + case .complication: return GarminWatchface.complication.watchfaceUUID + case .watchControls: return GarminWatchControls.appUUID + case .none: return nil + } + } + + static let appUUIDs = Set([GarminWatchface.complication.watchfaceUUID, GarminWatchControls.appUUID].compactMap { $0 }) +} + // MARK: - Garmin Datafield Setting -/// Defines the available Garmin datafields with their associated UUIDs. +/// Defines the available Garmin datafield apps with their associated UUIDs. enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashable { var id: String { rawValue } @@ -126,26 +161,15 @@ enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashabl } } - /// The datafields the user can pick from. `.none` is not offered as a choice any more: - /// "no datafield" is expressed by an empty selection. - static var selectableCases: [GarminDatafield] { - allCases.filter { $0 != .none } - } - - /// Maximum number of datafields that can receive data at the same time. - /// Every selected datafield is registered as its own Connect IQ app and gets the - /// same broadcast payload, so this caps how many apps a single update fans out to. - static let maxSelectionCount = 4 - - /// Normalizes a selection read from settings: drops `.none`, removes duplicates - /// and enforces `maxSelectionCount`, preserving the original order. - static func sanitizedSelection(_ datafields: [GarminDatafield]) -> [GarminDatafield] { - var selection = [GarminDatafield]() - for datafield in datafields where datafield != .none && !selection.contains(datafield) { - selection.append(datafield) - if selection.count == maxSelectionCount { break } - } - return selection + /// Resolves a datafield list persisted by multi-datafield builds to a single selection. + /// Unknown raw values are skipped; returns nil when the list holds nothing recognizable, + /// so the caller can fall back to the legacy singular key or the default. + static func migratedSelection(from rawValues: [String]) -> GarminDatafield? { + // An explicit empty list was the "no datafield" choice + if rawValues.isEmpty { return GarminDatafield.none } + let known = rawValues.compactMap(GarminDatafield.init(rawValue:)) + guard !known.isEmpty else { return nil } + return known.first { $0 != .none } ?? GarminDatafield.none } } @@ -155,9 +179,8 @@ enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashabl /// Both watchfaces use the same settings: primaryAttributeChoice and secondaryAttributeChoice. struct GarminWatchSettings: Codable, Hashable { var watchface: GarminWatchface = .trio - /// Datafields that receive data, in selection order. Empty means no datafield is used. - /// Never holds more than `GarminDatafield.maxSelectionCount` entries. - var datafields: [GarminDatafield] = [.trio] + /// Single datafield that receives data; `.none` means no datafield is used. + var datafield: GarminDatafield = .trio var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr var isWatchfaceDataEnabled: Bool = false @@ -165,32 +188,29 @@ struct GarminWatchSettings: Codable, Hashable { var isCommandControlEnabled: Bool = false /// Allows bolus and meal+bolus commands; only effective while `isCommandControlEnabled` is on. var isBolusCommandEnabled: Bool = false - - /// Adds or removes a datafield from the selection, ignoring additions beyond the maximum. - mutating func toggleDatafield(_ datafield: GarminDatafield) { - if let index = datafields.firstIndex(of: datafield) { - datafields.remove(at: index) - } else if datafields.count < GarminDatafield.maxSelectionCount { - datafields.append(datafield) - } - } - - /// Whether the given datafield can still be added to the selection. - func canSelectDatafield(_ datafield: GarminDatafield) -> Bool { - datafields.contains(datafield) || datafields.count < GarminDatafield.maxSelectionCount - } } extension GarminWatchSettings { + /// Multi-datafield builds persisted a plural `datafields` array; it has no backing property, + /// so it decodes through its own container rather than widening the Codable `CodingKeys`. + private enum LegacyCodingKeys: String, CodingKey { + case datafields + } + /// Keys missing from settings written by older versions keep their defaults, so commands stay off. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let legacyContainer = try decoder.container(keyedBy: LegacyCodingKeys.self) self.init() if let watchface = try? container.decode(GarminWatchface.self, forKey: .watchface) { self.watchface = watchface } - if let datafields = try? container.decode([GarminDatafield].self, forKey: .datafields) { - self.datafields = GarminDatafield.sanitizedSelection(datafields) + if let rawDatafields = try? legacyContainer.decode([String].self, forKey: .datafields), + let migrated = GarminDatafield.migratedSelection(from: rawDatafields) + { + datafield = migrated + } else if let datafield = try? container.decode(GarminDatafield.self, forKey: .datafield) { + self.datafield = datafield } if let primaryAttributeChoice = try? container .decode(GarminPrimaryAttributeChoice.self, forKey: .primaryAttributeChoice) diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index 5a3ddc88e7..8725c54d31 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -102,9 +102,8 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Selected Garmin watchface (Trio or SwissAlpine) var garminWatchface: GarminWatchface = .trio - /// Selected Garmin datafields, in selection order. Empty means no datafield is used. - /// Replaces the single `garminDatafield` setting; see the decoder for the migration. - var garminDatafields: [GarminDatafield] = [] + /// Selected Garmin datafield; `.none` means no datafield is used + var garminDatafield: GarminDatafield = .none /// Primary attribute choice for Garmin display (COB, ISF, or Sensitivity Ratio) var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob @@ -126,7 +125,7 @@ struct TrioSettings: JSON, Equatable, Encodable { get { GarminWatchSettings( watchface: garminWatchface, - datafields: garminDatafields, + datafield: garminDatafield, primaryAttributeChoice: primaryAttributeChoice, secondaryAttributeChoice: secondaryAttributeChoice, isWatchfaceDataEnabled: isWatchfaceDataEnabled, @@ -136,7 +135,7 @@ struct TrioSettings: JSON, Equatable, Encodable { } set { garminWatchface = newValue.watchface - garminDatafields = newValue.datafields + garminDatafield = newValue.datafield primaryAttributeChoice = newValue.primaryAttributeChoice secondaryAttributeChoice = newValue.secondaryAttributeChoice isWatchfaceDataEnabled = newValue.isWatchfaceDataEnabled @@ -149,8 +148,8 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Keys that are no longer stored properties of `TrioSettings`, but still have to be read /// from settings files written by older versions. private enum LegacyGarminCodingKeys: String, CodingKey { - /// Superseded by `garminDatafields`, which holds multiple datafields - case garminDatafield + /// Written by multi-datafield builds; migrated to the single `garminDatafield` + case garminDatafields } /// Used to decode settings keys that no longer have a matching stored property (e.g. renamed keys kept for migration). @@ -444,13 +443,13 @@ extension TrioSettings: Decodable { settings.garminWatchface = garminWatchface } - if let garminDatafields = try? container.decode([GarminDatafield].self, forKey: .garminDatafields) { - settings.garminDatafields = GarminDatafield.sanitizedSelection(garminDatafields) - } else if let legacyContainer = try? decoder.container(keyedBy: LegacyGarminCodingKeys.self), - let garminDatafield = try? legacyContainer.decode(GarminDatafield.self, forKey: .garminDatafield) + if let legacyContainer = try? decoder.container(keyedBy: LegacyGarminCodingKeys.self), + let rawDatafields = try? legacyContainer.decode([String].self, forKey: .garminDatafields), + let migrated = GarminDatafield.migratedSelection(from: rawDatafields) { - // Settings written before multiple datafields were supported stored a single choice - settings.garminDatafields = garminDatafield == .none ? [] : [garminDatafield] + settings.garminDatafield = migrated + } else if let garminDatafield = try? container.decode(GarminDatafield.self, forKey: .garminDatafield) { + settings.garminDatafield = garminDatafield } if let primaryAttributeChoice = try? container diff --git a/Trio/Sources/Models/WatchMessageKeys.swift b/Trio/Sources/Models/WatchMessageKeys.swift index 78de9a6b3d..586398818d 100644 --- a/Trio/Sources/Models/WatchMessageKeys.swift +++ b/Trio/Sources/Models/WatchMessageKeys.swift @@ -76,4 +76,6 @@ enum WatchMessageKeys { static let presetIsEnabled = "isEnabled" static let isCommandControlEnabled = "isCommandControlEnabled" static let isBolusCommandEnabled = "isBolusCommandEnabled" + static let isCommandAppActive = "active" + static let commandApp = "owner" } diff --git a/Trio/Sources/Modules/Export/ExportStateModel.swift b/Trio/Sources/Modules/Export/ExportStateModel.swift index 740f3d0d5a..a762ac5e05 100644 --- a/Trio/Sources/Modules/Export/ExportStateModel.swift +++ b/Trio/Sources/Modules/Export/ExportStateModel.swift @@ -1308,7 +1308,6 @@ extension Export { } } - for preset in presetData { if let target = preset.target { let targetValue = trioSettings.units == .mgdL ? target : target.asMmolL @@ -1351,7 +1350,6 @@ extension Export { // Override Presets (from Core Data) do { - // Ensure Core Data is fully initialized - wait longer on first run var retryCount = 0 var overridePresetIDs: [NSManagedObjectID] = [] @@ -1431,7 +1429,6 @@ extension Export { } } - for preset in presetData { addSetting( category: presetsCategory, diff --git a/Trio/Sources/Modules/Export/View/ExportRootView.swift b/Trio/Sources/Modules/Export/View/ExportRootView.swift index 940fec2f00..66025ca408 100644 --- a/Trio/Sources/Modules/Export/View/ExportRootView.swift +++ b/Trio/Sources/Modules/Export/View/ExportRootView.swift @@ -188,8 +188,13 @@ extension Export { state.isExporting = false } } catch let attributesError { - error(.settings, "Reading settings export file attributes failed", error: attributesError) - exportErrorMessage = "Could not verify file attributes: \(attributesError.localizedDescription)" + error( + .settings, + "Reading settings export file attributes failed", + error: attributesError + ) + exportErrorMessage = + "Could not verify file attributes: \(attributesError.localizedDescription)" showExportError = true // Stop spinner on error state.isExporting = false diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index 873a315882..4f60f48ebc 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -23,6 +23,7 @@ extension Home { @ObservationIgnored @Injected() var pluginCGMManager: PluginManager! @ObservationIgnored @Injected() var fetchGlucoseManager: FetchGlucoseManager! @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager! + @ObservationIgnored @Injected() var tidepoolManager: TidepoolManager! @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage! @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage! @ObservationIgnored @Injected() var carbsStorage: CarbsStorage! @@ -139,6 +140,7 @@ extension Home { var pumpStatusHighlightMessage: String? var pumpStatusBadgeImage: UIImage? var pumpStatusBadgeColor: Color? + var tidepoolLoginRequired = false var cgmAvailable: Bool = false var cgmDisplayState: CgmDisplayState? var cgmProgressHighlight: DeviceLifecycleProgress? @@ -694,6 +696,11 @@ extension Home { .weakAssign(to: \.lastLoopDate, on: self) .store(in: &lifetime) + tidepoolManager.tidepoolLoginRequired + .receive(on: DispatchQueue.main) + .weakAssign(to: \.tidepoolLoginRequired, on: self) + .store(in: &lifetime) + apsManager.pumpName .receive(on: DispatchQueue.main) .weakAssign(to: \.pumpName, on: self) diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index f7f9cc4aa5..3ce09fe7f3 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -216,6 +216,7 @@ extension Home.RootView { pumpTimeMismatch: state.pumpStatusBadgeImage != nil, lastGlucoseDate: state.glucoseFromPersistence.last?.date, maxIOB: state.maxIOB, + tidepoolLoginRequired: state.tidepoolLoginRequired, hasOverride: overrideString != nil, hasTempTarget: tempTargetString != nil, hasTempProfile: activeProfile.first?.expiresAt != nil, @@ -386,6 +387,16 @@ extension Home.RootView { openMaxIOBSetting() } .transition(.blurReplace) + case .tidepoolLoginRequired: + panelBanner( + systemImage: "icloud.slash.fill", + title: String(localized: "Tidepool Login Required"), + subtitle: String(localized: "Uploads to Tidepool are paused. Tap to log in."), + tint: .orange + ) { + openTidepoolSetting() + } + .transition(.blurReplace) case .whatsNew: panelBanner( systemImage: "sparkles", @@ -446,6 +457,11 @@ extension Home.RootView { )) } + func openTidepoolSetting() { + selectedTab = 4 + settingsPath.append(SearchResultTarget(screen: .tidepoolConfig, scrollLabel: "Tidepool Integration".localized)) + } + func statsDistributionBar(_ segments: [(color: Color, fraction: CGFloat)]) -> some View { GeometryReader { g in let spacing: CGFloat = 2 diff --git a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift index 949e8a2345..a8c414b4aa 100644 --- a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift +++ b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift @@ -17,6 +17,7 @@ enum MultiUsePanelState: Equatable { case pumpTimeMismatch case cgmStale case maxIOBZero + case tidepoolLoginRequired case adjustments(ActiveAdjustment) case whatsNew case dosingModeLimited(DosingMode) @@ -31,6 +32,7 @@ enum MultiUsePanelState: Equatable { pumpTimeMismatch: Bool, lastGlucoseDate: Date?, maxIOB: Decimal, + tidepoolLoginRequired: Bool, hasOverride: Bool, hasTempTarget: Bool, hasTempProfile: Bool, @@ -47,6 +49,8 @@ enum MultiUsePanelState: Equatable { return .dosingModeLimited(dosingMode) } if maxIOB <= 0 { return .maxIOBZero } + // Uploads only, so it yields to every dosing-related warning above. + if tidepoolLoginRequired { return .tidepoolLoginRequired } if hasOverride, hasTempTarget { return .adjustments(.dual) } if hasOverride { return .adjustments(.override) } if hasTempTarget { return .adjustments(.tempTarget) } diff --git a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift index 43e4df60a2..8c555db919 100644 --- a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift +++ b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift @@ -1,11 +1,8 @@ import Combine -import DanaKit import Foundation import LoopKit import MedtrumKit -import MinimedKit import Observation -import OmnipodKit import SwiftUI /// Model that holds the data collected during onboarding. diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift index 8126bccdb4..1374db92cc 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift @@ -2,7 +2,6 @@ import Combine import HealthKit import LoopKit import LoopKitUI -import RileyLinkBLEKit extension PumpConfig { final class Provider: BaseProvider, PumpConfigProvider { diff --git a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift index 0f9e0fc9dd..4d75cdbc58 100644 --- a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift +++ b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift @@ -30,12 +30,14 @@ struct TidepoolStartView: BaseView { state.setupTidepool.toggle() } label: { + let isAuthenticated = !state.provider.tidepoolManager.tidepoolLoginRequired.value HStack { - Text("Connected to Tidepool").font(.title3) + (isAuthenticated ? Text("Connected to Tidepool") : Text("Tidepool Login Required")) + .font(.title3) ZStack { Image(systemName: "network") - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green).font(.caption2) + Image(systemName: isAuthenticated ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(isAuthenticated ? .green : .red).font(.caption2) .offset(x: 9, y: 6) } .accessibilityHidden(true) diff --git a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift index 08ce143c33..e8c747f84f 100644 --- a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift +++ b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift @@ -164,33 +164,20 @@ struct WatchConfigGarminAppConfigView: View { Section( header: Text("Datafield Settings"), content: { - ForEach(GarminDatafield.selectableCases) { selection in - Button( - action: { - state.toggleDatafield(selection) - }, - label: { - HStack { - Text(selection.displayName) - .foregroundStyle( - state.canSelectDatafield(selection) ? Color.primary : Color.secondary - ) - Spacer() - if state.garminSettings.datafields.contains(selection) { - Image(systemName: "checkmark") - .foregroundStyle(Color.accentColor) - } - } + VStack { + Picker( + selection: $state.garminSettings.datafield, + label: Text("Datafield Selection").multilineTextAlignment(.leading) + ) { + ForEach(GarminDatafield.allCases) { selection in + Text(selection.displayName).tag(selection) } - ) - .buttonStyle(BorderlessButtonStyle()) - .disabled(!state.canSelectDatafield(selection)) - } + } + .padding(.top) - VStack { HStack(alignment: .center) { Text( - "Choose which datafields to support. Up to four can receive data at the same time, or select none at all. Can be used independently of watchface selection." + "Choose which datafield to support. Can be used independently of watchface selection." ) .font(.footnote) .foregroundColor(.secondary) @@ -334,17 +321,55 @@ struct WatchConfigGarminAppConfigView: View { sheetTitle: String(localized: "Help", comment: "Help sheet title") ) } + .sheet(isPresented: $shouldDisplayCommandsHint) { + SettingInputHintView( + hintDetent: $hintDetent, + shouldDisplayHint: $shouldDisplayCommandsHint, + hintLabel: String(localized: "Enable Watch Commands"), + hintText: VStack(alignment: .leading, spacing: 10) { + Text("Default: OFF").bold() + Text( + "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." + ) + Text( + "Commands are only accepted from one Garmin app: Trio-Complication when it is selected as watchface, otherwise Trio-WatchControls. Changing the watchface switches the command app right away and cancels commands still pending." + ) + Text( + "Commands must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." + ) + }, + sheetTitle: String(localized: "Help", comment: "Help sheet title") + ) + } + .sheet(isPresented: $shouldDisplayBolusCommandsHint) { + SettingInputHintView( + hintDetent: $hintDetent, + shouldDisplayHint: $shouldDisplayBolusCommandsHint, + hintLabel: String(localized: "Allow Bolus Commands"), + hintText: VStack(alignment: .leading, spacing: 10) { + Text("Default: OFF").bold() + Text( + "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." + ) + Text( + "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." + ) + Text("Only enable this if you keep your watch locked and under your control.").bold() + }, + sheetTitle: String(localized: "Help", comment: "Help sheet title") + ) + } .sheet(isPresented: $shouldDisplayHint4) { SettingInputHintView( hintDetent: $hintDetent, shouldDisplayHint: $shouldDisplayHint4, hintLabel: "Choose Garmin Datafield", hintText: Text( - "Choose which datafields on your Garmin device you wish to provide data for. Tap an entry to select or deselect it; up to four datafields can receive data at the same time. The datafields can be used independently from the watchface selection.\n\n" + + "Choose which datafield on your Garmin device you wish to provide data for. The datafield can be used independently from the watchface selection.\n\n" + "• Trio – The original Trio datafield, developed by Pierre.\n" + "• Swissalpine – Originally developed for AAPS, adapted to work with Trio.\n" + "• Loop Graph – Shows the glucose history and the predicted glucose envelope.\n\n" + - "Deselect all datafields if you don't want to use one, or want to preserve battery while not exercising." + "Select 'None' if you don't want to use a datafield, or want to preserve battery while not exercising." ), sheetTitle: String(localized: "Help", comment: "Help sheet title") ) diff --git a/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift b/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift index 8effaf51d5..8e8062afcc 100644 --- a/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift +++ b/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift @@ -41,17 +41,6 @@ extension WatchConfig { garmin.updateDeviceList(devices) } - /// Adds or removes a datafield from the selection. - /// Additions beyond `GarminDatafield.maxSelectionCount` are ignored. - func toggleDatafield(_ datafield: GarminDatafield) { - garminSettings.toggleDatafield(datafield) - } - - /// Whether the given datafield can still be added to the selection - func canSelectDatafield(_ datafield: GarminDatafield) -> Bool { - garminSettings.canSelectDatafield(datafield) - } - /// Handles watchface selection changes by automatically disabling data transmission /// to allow the user to switch watchfaces on their Garmin device without data conflicts func handleWatchfaceChange() { diff --git a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift index 3bde436dea..e36ade0e1e 100644 --- a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift +++ b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift @@ -81,9 +81,8 @@ protocol AdjustmentManager { @discardableResult func cancelTempTarget(source: AdjustmentSource, waitForUpload: Bool) async throws -> AdjustmentOutcome - // Guarded entry points for callers whose permission can be withdrawn while they wait for the - // serializer, i.e. watch commands. `authorize` runs inside the transaction, before anything is - // read or written; whatever it throws is rethrown with nothing saved and no side effects. + // for callers whose permission can be withdrawn while queued (watch commands): `authorize` runs + // inside the transaction before any read or write, and whatever it throws leaves nothing saved @discardableResult func activateOverride( _ preset: AdjustmentRef, diff --git a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift index 629a010569..9cb6bf61bd 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift @@ -13,7 +13,7 @@ import LoopKit enum AlertCatalogRegistry { static let entries: [Alert.CatalogEntry] = omniEntries + minimedEntries + danaEntries + medtrumEntries + libreLoopEntries + g7Entries - + syaiEntries + trioAlgorithmEntries + + syaiEntries + trioAlgorithmEntries + tidepoolEntries static func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? { if let exact = entries.first(where: { $0.identifier == identifier }) { @@ -350,6 +350,21 @@ private extension AlertCatalogRegistry { ] } +// MARK: - Tidepool + +private extension AlertCatalogRegistry { + static let tidepoolEntries: [Alert.CatalogEntry] = [ + addEntry( + "TidepoolService", + "authentication-needed", + .active, + "Tidepool Service Authorization", + "Services", + .tidepoolLoginRequired + ) + ] +} + // MARK: - Helpers private extension AlertCatalogRegistry { diff --git a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift index d4cd35709a..6524377c1c 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift @@ -43,6 +43,9 @@ extension Alert { case notLooping case algorithmError case glucoseDataStale + // Service concepts. Surface through `TrioAlertManager` via + // `TidepoolManager.issueAlert(_:)`. + case tidepoolLoginRequired /// Plugin-unique alarm that doesn't merge with anything else. case unspecified @@ -80,6 +83,7 @@ extension Alert { case .notLooping: return String(localized: "Trio Not Looping") case .algorithmError: return String(localized: "Algorithm Error") case .glucoseDataStale: return String(localized: "Glucose Data Stale") + case .tidepoolLoginRequired: return String(localized: "Tidepool Login Required") case .unspecified: return "" } } diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 1214b1eadf..ea87f5ec10 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -13,6 +13,7 @@ protocol TidepoolManager { func addTidepoolService(service: Service) func getTidepoolServiceUI() -> ServiceUI? func getTidepoolPluginHost() -> PluginHost? + var tidepoolLoginRequired: CurrentValueSubject { get } func uploadCarbs() async func deleteCarbs(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) func uploadInsulin() async @@ -32,6 +33,13 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { @Injected() private var apsManager: APSManager! @Injected() private var settingsManager: SettingsManager! @Injected() private var notificationCenter: NotificationCenter! + @Injected() private var trioAlertManager: TrioAlertManager! + + /// Raised by `TidepoolService` when its login session is cleared + static let authenticationAlertIdentifier = LoopKit.Alert.Identifier( + managerIdentifier: TidepoolService.serviceIdentifier, + alertIdentifier: "authentication-needed" + ) // Lazy access to avoid circular dependency (TidepoolManager ↔ FetchGlucoseManager) private var resolver: Resolver? @@ -70,6 +78,8 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { let viewContext = CoreDataStack.shared.persistentContainer.viewContext private var subscriptions = Set() + private var sessionSubscription: AnyCancellable? + let tidepoolLoginRequired = CurrentValueSubject(false) // MARK: - Upload triggers @@ -130,6 +140,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { }() @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue? + @PersistedProperty(key: "TidepoolLoginRequired") var persistedLoginRequired: Bool? init(resolver: Resolver) { self.resolver = resolver @@ -175,15 +186,35 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { broadcaster.register(PreferencesObserver.self, observer: self) } + /// Persisted because `TidepoolService` can't be restored without a session, so the flag must outlive a relaunch + private func setLoginRequired(_ required: Bool) { + persistedLoginRequired = required ? true : nil + tidepoolLoginRequired.send(required) + } + /// Loads the Tidepool service from saved state fileprivate func loadTidepoolManager() { + tidepoolLoginRequired.send(persistedLoginRequired ?? false) if let rawTidepoolManager = rawTidepoolManager { tidepoolService = tidepoolServiceFromRaw(rawTidepoolManager) tidepoolService?.serviceDelegate = self tidepoolService?.stateDelegate = self + observeTidepoolSession() } } + /// Tracks whether the Tidepool service lost its login; retracts the re-login alert once it is back + private func observeTidepoolSession() { + sessionSubscription = (tidepoolService as? TidepoolService)?.$session + .sink { [weak self] session in + guard let self else { return } + self.setLoginRequired(session == nil) + if session != nil { + self.trioAlertManager.retractAlert(identifier: Self.authenticationAlertIdentifier) + } + } + } + /// Returns the Tidepool service UI if available func getTidepoolServiceUI() -> ServiceUI? { tidepoolService as? ServiceUI @@ -197,6 +228,9 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { /// Adds a Tidepool service func addTidepoolService(service: Service) { tidepoolService = service as? RemoteDataService + tidepoolService?.serviceDelegate = self + tidepoolService?.stateDelegate = self + observeTidepoolSession() } /// Loads the Tidepool service from raw stored data @@ -261,9 +295,13 @@ extension BaseTidepoolManager: ServiceDelegate { return semanticVersion } - func issueAlert(_: LoopKit.Alert) {} + func issueAlert(_ alert: LoopKit.Alert) { + trioAlertManager.issueAlert(alert) + } - func retractAlert(identifier _: LoopKit.Alert.Identifier) {} + func retractAlert(identifier: LoopKit.Alert.Identifier) { + trioAlertManager.retractAlert(identifier: identifier) + } func enactRemoteOverride(name _: String, durationTime _: TimeInterval?, remoteAddress _: String) async throws {} @@ -877,7 +915,12 @@ extension BaseTidepoolManager: StatefulPluggableDelegate { func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {} func pluginWantsDeletion(_: LoopKit.StatefulPluggable) { + // deleteService() logs out right after, which would raise the re-login alert + tidepoolService?.serviceDelegate = nil tidepoolService = nil + sessionSubscription = nil + setLoginRequired(false) + trioAlertManager.retractAlert(identifier: Self.authenticationAlertIdentifier) } } diff --git a/Trio/Sources/Services/WatchCommand/WatchCommand.swift b/Trio/Sources/Services/WatchCommand/WatchCommand.swift index ce1a9273ac..930b5b38b0 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommand.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommand.swift @@ -1,7 +1,7 @@ import Foundation /// A state-changing command sent from a watch, already parsed and type-checked by its transport. -enum WatchCommand: Equatable { +enum WatchCommand: Hashable { case bolus(Decimal) case carbs(Int) /// Carbs are stored before the bolus runs, so a rejected bolus still leaves the meal logged. @@ -43,18 +43,7 @@ enum WatchCommand: Equatable { return nil } } -} -struct WatchCommandRequest: Equatable { - let requestID: UUID - /// Watch app that sent the command; request IDs are only unique per app. - let appUUID: UUID - /// When the watch issued the command. - let date: Date - let command: WatchCommand -} - -extension WatchCommand { var isInsulin: Bool { bolusAmount != nil } /// Commands whose success changes which preset is active on the watch's preset list. @@ -71,35 +60,17 @@ extension WatchCommand { } } -/// Terminal answer to a watch command, sent back to the requesting app only. -struct WatchCommandResult: Equatable { - let acknowledged: Bool - let ackCode: BaseWatchManager.AcknowledgmentCode - /// Short, user-facing text. Never carries amounts or internal error details. - let message: String - - /// Something may have changed on the phone, so the watch needs fresh state. - var refreshesState: Bool { - acknowledged || ackCode == .partialFailure - } - - static func success(_ ackCode: BaseWatchManager.AcknowledgmentCode, _ message: String) -> WatchCommandResult { - WatchCommandResult(acknowledged: true, ackCode: ackCode, message: message) - } - - static func failure(_ message: String) -> WatchCommandResult { - WatchCommandResult(acknowledged: false, ackCode: .genericFailure, message: message) - } -} - -struct WatchCommandPresets: Equatable { - let overridePresets: [OverridePresetWatch] - let tempTargetPresets: [TempTargetPresetWatch] - var capabilities = WatchCommandCapabilities() +struct WatchCommandRequest: Equatable { + let requestID: UUID + /// Request IDs are only unique per watch app. + let appUUID: UUID + /// When the watch issued the command. + let date: Date + let command: WatchCommand } -/// Switches and limits the watch uses to offer only what the phone would accept; the phone still -/// enforces every one of them. The defaults describe a phone that accepts nothing. +/// Switches and limits the watch uses to offer only what the phone accepts; the phone still enforces +/// each of them. The defaults describe a phone that accepts nothing. struct WatchCommandCapabilities: Equatable { var isCommandControlEnabled = false var isBolusCommandEnabled = false @@ -107,36 +78,3 @@ struct WatchCommandCapabilities: Equatable { var maxCarbs: Decimal = 0 var bolusIncrement: Decimal = 0 } - -/// An adjustment preset as the watch names it, plus the reference that activates exactly that row. -/// Every configured preset is listed and can be activated, whether it is running or not. -struct WatchPresetEntry: Equatable { - let name: String - /// Whether the preset is running right now (`enabled` on the stored row); it is sent to the - /// watch as the v1 `isEnabled` key and never restricts activation. - let isActive: Bool - let ref: AdjustmentRef -} - -/// Stable, content-free labels for command errors: Core Data and adjustment errors can carry -/// treatment values, preset names or store paths in their descriptions. -enum WatchCommandErrorCategory { - static func name(for error: Error) -> String { - switch error { - case let adjustmentError as AdjustmentError: - switch adjustmentError { - case .presetNotFound: return "presetNotFound" - case .nothingActive: return "nothingActive" - case .persistenceFailed: return "adjustmentPersistence" - } - case is CoreDataError: - return "coreData" - case is CancellationError: - return "cancelled" - default: - let nsError = error as NSError - // a Cocoa code identifies Core Data failures without their user info - return nsError.domain == NSCocoaErrorDomain ? "cocoa(\(nsError.code))" : "unexpected" - } - } -} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift b/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift index f0cb71a923..3eb156eff4 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift @@ -1,25 +1,20 @@ -import CoreData import Foundation import Swinject -/// Pump, Core Data and preset side effects of watch commands, kept behind a protocol so the -/// processor's validation and sequencing can be tested without a pump. +/// Pump and Core Data side effects of watch commands, behind a protocol so validation and +/// sequencing can be tested without a pump. protocol WatchCommandActions { - /// Throws unless the carb row was actually saved. `authorize` runs inside the save transaction - /// before the row is inserted; whatever it throws is rethrown with nothing written. + /// Throws unless the carb row was saved. `authorize` runs inside the save transaction; whatever + /// it throws is rethrown with nothing written. func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws - /// Returns once the pump accepted or refused the bolus, without waiting for the follow-up loop run. - /// Throws only what `authorize` threw, run right before issuance; nothing reached the pump then. + /// Returns once the pump accepted or refused the bolus. Throws only what `authorize` threw, + /// right before issuance; nothing reached the pump then. func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool - func overridePresets() async throws -> [WatchPresetEntry] - func tempTargetPresets() async throws -> [WatchPresetEntry] } final class BaseWatchCommandActions: WatchCommandActions, Injectable { @Injected() private var apsManager: APSManager! @Injected() private var carbsStorage: CarbsStorage! - @Injected() private var overrideStorage: OverrideStorage! - @Injected() private var tempTargetStorage: TempTargetsStorage! init(resolver: Resolver) { injectServices(resolver) @@ -41,12 +36,10 @@ final class BaseWatchCommandActions: WatchCommandActions, Injectable { try await carbsStorage.storeVerifiedCarbs(entry, authorize: authorize) } - /// `authorize` runs in the task that issues the request, immediately before `APSManager.enactBolus` - /// and with nothing awaited in between, so a revocation or expiry cannot land between the final - /// check and issuance. The task lets the ack return before the determination `enactBolus` runs - /// after a manual bolus. + /// `authorize` runs in the task that issues the request, right before `APSManager.enactBolus` with + /// nothing awaited in between; the task lets the ack return before the follow-up loop run. func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool { - // enactBolus returns without calling back for non-positive amounts, which would never resume + // enactBolus never calls back for non-positive amounts, which would never resume guard units > 0, let apsManager = self.apsManager else { return false } return try await withCheckedThrowingContinuation { continuation in Task { @@ -62,32 +55,4 @@ final class BaseWatchCommandActions: WatchCommandActions, Injectable { } } } - - func overridePresets() async throws -> [WatchPresetEntry] { - let ids = try await overrideStorage.fetchForOverridePresets() - let context = CoreDataStack.shared.newTaskContext() - context.name = "watchCommandOverridePresets" - let rows: [OverrideStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) - - return await context.perform { - rows.compactMap { row in - guard let name = row.name, let id = row.id else { return nil } - return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id)) - } - } - } - - func tempTargetPresets() async throws -> [WatchPresetEntry] { - let ids = try await tempTargetStorage.fetchForTempTargetPresets() - let context = CoreDataStack.shared.newTaskContext() - context.name = "watchCommandTempTargetPresets" - let rows: [TempTargetStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) - - return await context.perform { - rows.compactMap { row in - guard let name = row.name, let id = row.id else { return nil } - return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id.uuidString)) - } - } - } } diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift new file mode 100644 index 0000000000..c7476c3fd9 --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift @@ -0,0 +1,223 @@ +import CoreData +import Foundation + +/// Terminal answer to a watch command, sent back to the requesting app only. +struct WatchCommandResult: Equatable { + let acknowledged: Bool + let ackCode: BaseWatchManager.AcknowledgmentCode + /// Short, user-facing text; never carries amounts or internal error details. + let message: String + + /// Something may have changed on the phone, so the watch needs fresh state. + var refreshesState: Bool { + acknowledged || ackCode == .partialFailure + } + + static func success(_ ackCode: BaseWatchManager.AcknowledgmentCode, _ message: String) -> WatchCommandResult { + WatchCommandResult(acknowledged: true, ackCode: ackCode, message: message) + } + + static func failure(_ message: String) -> WatchCommandResult { + WatchCommandResult(acknowledged: false, ackCode: .genericFailure, message: message) + } +} + +/// Thrown by a final check right before a side effect writes or issues anything; nothing was written. +struct WatchCommandRejection: Error { + let result: WatchCommandResult +} + +/// Revocation epoch for watch commands; a command admitted under an older epoch fails its next check. +final class WatchCommandAuthorization: @unchecked Sendable { + private let lock = NSLock() + private var epoch: UInt64 = 0 + + var current: UInt64 { lock.withLock { epoch } } + + /// Call on the writing queue before a permission-withdrawing setting is stored; settings + /// notifications are async and latest-value only, so they miss an off-then-on sequence. + func revoke() { + lock.withLock { epoch += 1 } + } +} + +/// Writes watch command log lines at their level and keeps the caller's location; tests replace `write`. +struct WatchCommandLogger: Sendable { + enum Level: Sendable { + case debug + case info + case warning + } + + var write: @Sendable(Level, String, String, String, UInt) -> Void + + static let app = WatchCommandLogger { level, message, file, function, line in + switch level { + case .debug: Trio.debug(.watch, message, file: file, function: function, line: line) + case .info: Trio.info(.watch, message, file: file, function: function, line: line) + case .warning: Trio.warning(.watch, message, file: file, function: function, line: line) + } + } + + func debug(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) { + write(.debug, message, file, function, line) + } + + func info(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) { + write(.info, message, file, function, line) + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) { + write(.warning, message, file, function, line) + } +} + +/// The checks an admitted command re-runs at every side-effect boundary. +struct WatchCommandContext: Sendable { + let issuedAt: Date + let name: String + + fileprivate let grant: UInt64 + fileprivate let authorization: WatchCommandAuthorization + fileprivate let gate: @Sendable() -> WatchCommandResult? + fileprivate let now: @Sendable() -> Date + fileprivate let log: WatchCommandLogger + + /// Expiry is opt-in after admission: only waits that can outlast the window need it again. + func rejection(checkingExpiry: Bool = false) -> WatchCommandResult? { + if checkingExpiry, let expired = expiryRejection() { + return expired + } + guard authorization.current == grant else { + log.info("Watch \(name) command refused, command settings changed while it was pending") + return .failure(String(localized: "Watch command settings changed.", comment: "Watch command ack")) + } + return gate() + } + + /// Run synchronously in the side effect's last serialized hop, with nothing awaited before the + /// write or pump request, so a revocation cannot land between check and effect. + func finalCheck(checkingExpiry: Bool = false) -> @Sendable() throws -> Void { + { [self] in + if let rejection = rejection(checkingExpiry: checkingExpiry) { + throw WatchCommandRejection(result: rejection) + } + } + } + + private func expiryRejection() -> WatchCommandResult? { + let age = now().timeIntervalSince(issuedAt) + guard age <= WatchCommandPipeline.maximumCommandAge, age >= -WatchCommandPipeline.maximumCommandLead else { + log.info("Watch \(name) command refused, sent outside the 2 minute window") + return .failure(String(localized: "Command expired. Please try again.", comment: "Watch command ack")) + } + return nil + } +} + +/// Runs state-changing watch commands for any transport: at most once per app and request ID, +/// inside a freshness window, and revocable while pending. Transports parse; features execute. +final class WatchCommandPipeline { + /// Commands arrive within seconds; two minutes only keeps a late or replayed one from running. + static let maximumCommandAge: TimeInterval = 2 * 60 + /// Clock skew tolerance for watches that run slightly ahead of the phone. + static let maximumCommandLead: TimeInterval = 60 + + private let authorization: WatchCommandAuthorization + private let cache: WatchCommandRequestCache + private let now: @Sendable() -> Date + private let log: WatchCommandLogger + + init( + authorization: WatchCommandAuthorization, + cache: WatchCommandRequestCache = WatchCommandRequestCache(), + now: @escaping @Sendable() -> Date = { Date() }, + log: WatchCommandLogger = .app + ) { + self.authorization = authorization + self.cache = cache + self.now = now + self.log = log + } + + /// `command` identifies the request's content, so a reused request ID is caught; `gate` holds + /// the feature switches and is re-read with every check. + func run( + requestID: UUID, + appUUID: UUID, + issuedAt: Date, + command: AnyHashable, + name: String, + gate: @escaping @Sendable() -> WatchCommandResult? = { nil }, + execute: (WatchCommandContext) async -> WatchCommandResult + ) async -> WatchCommandResult { + let key = WatchCommandRequestCache.Key(appUUID: appUUID, requestID: requestID) + // read before admission, so a revocation while queued is still seen + let grant = authorization.current + + let ticket: WatchCommandRequestCache.Ticket + switch await cache.admit(key, command: command, now: now()) { + case let .accepted(admitted): + ticket = admitted + case let .completed(result): + log.debug("Watch \(name) command repeated, sending the earlier result again") + return result + case .inProgress: + log.debug("Watch \(name) command repeated while the first one is still running") + return WatchCommandResult( + acknowledged: false, + ackCode: .inProgress, + message: String(localized: "Request is already in progress.", comment: "Watch command ack") + ) + case .conflict: + log.warning("Watch \(name) command refused, its request ID was already used for another command") + return .failure(String(localized: "Request ID was already used.", comment: "Watch command ack")) + case .full: + log.warning("Watch \(name) command refused, too many commands are running") + return .failure(String(localized: "Too many requests in progress. Please try again.", comment: "Watch command ack")) + } + + let context = WatchCommandContext( + issuedAt: issuedAt, + name: name, + grant: grant, + authorization: authorization, + gate: gate, + now: now, + log: log + ) + let result: WatchCommandResult + if let rejection = context.rejection(checkingExpiry: true) { + result = rejection + } else { + result = await execute(context) + } + await cache.complete(ticket, result: result) + + log.info("Watch \(name) command \(result.acknowledged ? "done" : "not done") (\(result.ackCode.rawValue))") + return result + } +} + +/// Stable, content-free labels for errors: Core Data and adjustment errors can carry treatment values, +/// preset names or store paths. +enum WatchCommandErrorCategory { + static func name(for error: Error) -> String { + switch error { + case let adjustmentError as AdjustmentError: + switch adjustmentError { + case .presetNotFound: return "presetNotFound" + case .nothingActive: return "nothingActive" + case .persistenceFailed: return "adjustmentPersistence" + } + case is CoreDataError: + return "coreData" + case is CancellationError: + return "cancelled" + default: + let nsError = error as NSError + // the Cocoa code identifies Core Data failures without their user info + return nsError.domain == NSCocoaErrorDomain ? "cocoa(\(nsError.code))" : "unexpected" + } + } +} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift index fa38e796a8..a100c34794 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift @@ -1,54 +1,40 @@ import Foundation import Swinject -/// Validates and executes watch commands independent of the transport that delivered them. +/// Validates and executes watch commands through `WatchCommandPipeline`, independent of the transport. protocol WatchCommandProcessor { - /// Runs `request` at most once per app and request ID and returns its terminal result; - /// a duplicate gets the cached result back without executing again. + /// Runs `request` at most once per app and request ID and returns its terminal result. func process(_ request: WatchCommandRequest) async -> WatchCommandResult - /// Override and temp target presets, sorted by display name, with the current command switches - /// and limits. - func presets() async throws -> WatchCommandPresets + /// Read at call time, so a switch turned off is reported off in the very next reply. + func capabilities() -> WatchCommandCapabilities } -/// Revocation epoch for watch commands; a command admitted under an older epoch was revoked. -/// -/// `BaseSettingsManager` advances it synchronously from the settings setter, on whatever queue -/// writes the settings, before a Garmin command setting that turns off is stored. The settings -/// notification is asynchronous and carries only the latest value, so an off-then-on sequence -/// that lands before the main queue drains would otherwise revoke nothing. -final class WatchCommandAuthorization { - private let lock = NSLock() - private var epoch: UInt64 = 0 - - var current: UInt64 { lock.withLock { epoch } } - - /// Call before `new` is stored. Turning either setting off always revokes, even when it is - /// switched back on right after; turning one on never does. +extension WatchCommandAuthorization { + /// Call before `new` is stored. Turning either Garmin command switch off, or a change of command app, + /// always revokes, even when it is undone right after; turning a switch on never does. func settingsWillChange(from old: TrioSettings, to new: TrioSettings) { let masterTurnedOff = old.isGarminCommandControlEnabled && !new.isGarminCommandControlEnabled let bolusTurnedOff = old.isGarminBolusCommandEnabled && !new.isGarminBolusCommandEnabled - guard masterTurnedOff || bolusTurnedOff else { return } - lock.withLock { epoch += 1 } - info(.watch, "Garmin commands switched off, pending commands revoked") + let oldCommandApp = GarminCommandApp(old.garminSettings) + // with no command app before, nothing could be pending + let commandAppChanged = oldCommandApp != .none && oldCommandApp != GarminCommandApp(new.garminSettings) + if masterTurnedOff || bolusTurnedOff { + revoke() + info(.watch, "Garmin command setting turned off, pending watch commands revoked") + } else if commandAppChanged { + revoke() + info(.watch, "Garmin command app changed, pending watch commands revoked") + } } } -/// Thrown by the final check a side effect runs right before it writes or issues anything; -/// nothing was saved or sent to the pump. -struct WatchCommandRejection: Error { - let result: WatchCommandResult -} - -/// Admits one watch insulin command at a time across all request IDs, so each validation sees the -/// previous command's delivery. Waiters suspend on a continuation and never hold the actor. +/// Admits one watch insulin command at a time, so each validation sees the previous command's delivery. +/// Waiters suspend on a continuation and never hold the actor. /// -/// Phase 1 policy, deliberately stricter than the shared 20% recent-bolus threshold: once a watch -/// bolus was handed to `APSManager`, the next watch insulin command is rejected for -/// `BolusSafetyEvaluator.recentBolusWindowMinutes`, even when the pump or status check refused it, -/// since pump history may not show it yet and a refusal can still mean partial delivery. Only a -/// final-check rejection, which never reached `APSManager`, withdraws the record. +/// Stricter than the shared 20% recent-bolus check: after a watch bolus reached `APSManager`, the next +/// one is refused for `BolusSafetyEvaluator.recentBolusWindowMinutes`, since pump history may lag and a +/// refusal can still mean partial delivery. actor WatchCommandInsulinLane { private var isOccupied = false private var waiters: [CheckedContinuation] = [] @@ -84,35 +70,27 @@ actor WatchCommandInsulinLane { } } -/// Garmin is the only caller in phase 1, so the command gates read the Garmin settings. +/// Garmin is the only transport sending commands, so the gates read the Garmin settings. final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { - /// The watch talks to the phone directly over Bluetooth (ConnectIQ SDK) and a command arrives within - /// seconds, so two minutes only keeps a late or replayed command from executing long after it was confirmed. - static let maximumCommandAge: TimeInterval = 2 * 60 - /// Clock skew tolerance for watches that run slightly ahead of the phone. - static let maximumCommandLead: TimeInterval = 60 - @Injected() private var settingsManager: SettingsManager! @Injected() private var bolusSafetyValidator: BolusSafetyValidator! + @Injected() private var pipeline: WatchCommandPipeline! @Injected() private var adjustmentManager: AdjustmentManager! - @Injected() private var authorization: WatchCommandAuthorization! + @Injected() private var presetsProvider: WatchPresetsProvider! private let actions: WatchCommandActions - private let cache: WatchCommandRequestCache private let insulinLane: WatchCommandInsulinLane private let now: () -> Date - private let log: (LogLevel, String) -> Void + private let log: WatchCommandLogger init( resolver: Resolver, actions: WatchCommandActions? = nil, - cache: WatchCommandRequestCache = WatchCommandRequestCache(), insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), now: @escaping () -> Date = Date.init, - log: @escaping (LogLevel, String) -> Void = { TrioLogger.log($0, .watch, $1, file: #file, function: #function, line: #line) } + log: WatchCommandLogger = .app ) { self.actions = actions ?? BaseWatchCommandActions(resolver: resolver) - self.cache = cache self.insulinLane = insulinLane self.now = now self.log = log @@ -120,57 +98,19 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } func process(_ request: WatchCommandRequest) async -> WatchCommandResult { - let key = WatchCommandRequestCache.Key(appUUID: request.appUUID, requestID: request.requestID) - let name = request.command.name - let grant = authorization.current - - let ticket: WatchCommandRequestCache.Ticket - switch await cache.admit(key, command: request.command, now: now()) { - case let .accepted(admitted): - ticket = admitted - case let .completed(result): - log(.debug, "Garmin duplicate \(name) request, returning cached result") - return result - case .inProgress: - log(.info, "Garmin duplicate \(name) request still in progress, rejected") - return WatchCommandResult( - acknowledged: false, - ackCode: .inProgress, - message: String(localized: "Request is already in progress.", comment: "Watch command ack") - ) - case .conflict: - log(.warning, "Garmin request ID reused for a different command (\(name)), rejected") - return .failure(String(localized: "Request ID was already used.", comment: "Watch command ack")) - case .full: - log(.warning, "Garmin sent too many \(name) requests in progress, rejected") - return .failure(String(localized: "Too many requests in progress. Please try again.", comment: "Watch command ack")) + await pipeline.run( + requestID: request.requestID, + appUUID: request.appUUID, + issuedAt: request.date, + command: request.command, + name: request.command.name, + gate: { [self] in gateRejection(for: request.command, from: request.appUUID) } + ) { context in + await execute(request.command, context: context) } - - let result = await execute(request, grant: grant) - await cache.complete(ticket, result: result) - - if result.acknowledged { - log(.info, "Garmin \(name) acknowledged with \(result.ackCode.rawValue)") - } else { - log(.warning, "Garmin \(name) failed with \(result.ackCode.rawValue)") - } - return result - } - - func presets() async throws -> WatchCommandPresets { - async let overrides = actions.overridePresets() - async let tempTargets = actions.tempTargetPresets() - - return try await WatchCommandPresets( - overridePresets: sortedByName(overrides).map { OverridePresetWatch(name: $0.name, isEnabled: $0.isActive) }, - tempTargetPresets: sortedByName(tempTargets).map { TempTargetPresetWatch(name: $0.name, isEnabled: $0.isActive) }, - capabilities: capabilities() - ) } - /// Same sources as the Apple Watch state, read at call time so a switch turned off is reported - /// off in the very next reply. - private func capabilities() -> WatchCommandCapabilities { + func capabilities() -> WatchCommandCapabilities { let settings = settingsManager.settings return WatchCommandCapabilities( isCommandControlEnabled: settings.isGarminCommandControlEnabled, @@ -183,99 +123,61 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { // MARK: - Validation - private func execute(_ request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = rejection(for: request, grant: grant) { + private func execute(_ command: WatchCommand, context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = amountRejection(for: command) { return rejection } - switch request.command { + switch command { case let .bolus(units): - return await bolus(units, request: request, grant: grant) + return await bolus(units, context: context) case let .carbs(grams): - return await carbs(grams, request: request, grant: grant) + return await carbs(grams, context: context) case let .mealBolus(grams, units): - return await mealBolus(carbs: grams, bolus: units, request: request, grant: grant) + return await mealBolus(carbs: grams, bolus: units, context: context) case let .activateOverride(name): - return await activateOverride(named: name, request: request, grant: grant) + return await activateOverride(named: name, context: context) case .cancelOverride: - return await cancelOverride(request: request, grant: grant) + return await cancelOverride(context: context) case let .activateTempTarget(name): - return await activateTempTarget(named: name, request: request, grant: grant) + return await activateTempTarget(named: name, context: context) case .cancelTempTarget: - return await cancelTempTarget(request: request, grant: grant) + return await cancelTempTarget(context: context) } } - /// Checks that need no side effects: freshness, feature gates and amount limits. - private func rejection(for request: WatchCommandRequest, grant: UInt64) -> WatchCommandResult? { - if let rejection = expiryRejection(for: request) ?? authorizationRejection(for: request, grant: grant) { - return rejection - } - - if let units = request.command.bolusAmount, units <= 0 { - return .failure(String(localized: "Invalid bolus amount.", comment: "Watch command ack")) - } - - if let grams = request.command.carbsAmount { - guard grams > 0 else { - return .failure(String(localized: "Invalid carbs amount.", comment: "Watch command ack")) - } - guard Decimal(grams) <= settingsManager.settings.maxCarbs else { - log(.info, "Garmin \(request.command.name) rejected, carbs above max carbs") - return .failure(String(localized: "Carbs exceed the maximum allowed.", comment: "Watch command ack")) - } - } - - return nil - } - - /// Re-checked for insulin after every wait: queueing in the lane or waiting for validation can - /// outlast the window the request was admitted in. - private func expiryRejection(for request: WatchCommandRequest) -> WatchCommandResult? { - let age = now().timeIntervalSince(request.date) - guard age <= Self.maximumCommandAge, age >= -Self.maximumCommandLead else { - log(.warning, "Garmin \(request.command.name) timestamp outside the freshness window, rejected") - return .failure(String(localized: "Command expired. Please try again.", comment: "Watch command ack")) - } - return nil - } - - /// Re-read at every side-effect boundary: settings can change while a command is suspended. - /// - /// Settings are read before the epoch. The epoch advances before a switch-off is stored, so - /// a check that still sees the setting on after an off-then-on sequence also sees the new epoch. - private func authorizationRejection(for request: WatchCommandRequest, grant: UInt64) -> WatchCommandResult? { - let name = request.command.name + /// Re-read at every check: settings can change while a command is suspended. + private func gateRejection(for command: WatchCommand, from appUUID: UUID) -> WatchCommandResult? { let garminSettings = settingsManager.settings.garminSettings - guard authorization.current == grant else { - log(.info, "Garmin \(name) rejected, command settings changed while pending") - return .failure(String(localized: "Watch command settings changed.", comment: "Watch command ack")) - } - guard garminSettings.isCommandControlEnabled else { - log(.info, "Garmin \(name) rejected, watch commands disabled") + log.info("Garmin \(command.name) command refused, watch commands are off") return .failure(String(localized: "Watch commands are disabled.", comment: "Watch command ack")) } - guard !request.command.isInsulin || garminSettings.isBolusCommandEnabled else { - log(.info, "Garmin \(name) rejected, bolus commands disabled") + guard GarminCommandApp(garminSettings).appUUID == appUUID else { + log.info("Garmin \(command.name) command refused, sent by an app that is not the command app") + return .failure(String(localized: "Commands are handled by another watch app.", comment: "Watch command ack")) + } + guard !command.isInsulin || garminSettings.isBolusCommandEnabled else { + log.info("Garmin \(command.name) command refused, bolus commands are off") return .failure(String(localized: "Bolus from watch is disabled.", comment: "Watch command ack")) } return nil } - /// The linearization point of a command: side effects run this synchronously in their last - /// serialized hop, inside the Core Data transaction or in the task that calls - /// `APSManager.enactBolus`, with nothing awaited between the check and the write or issuance. - /// Insulin re-checks the freshness window as well. - private func finalCheck(for request: WatchCommandRequest, grant: UInt64) -> @Sendable() throws -> Void { - { [self] in - if request.command.isInsulin, let rejection = expiryRejection(for: request) { - throw WatchCommandRejection(result: rejection) + private func amountRejection(for command: WatchCommand) -> WatchCommandResult? { + if let units = command.bolusAmount, units <= 0 { + return .failure(String(localized: "Invalid bolus amount.", comment: "Watch command ack")) + } + if let grams = command.carbsAmount { + guard grams > 0 else { + return .failure(String(localized: "Invalid carbs amount.", comment: "Watch command ack")) } - if let rejection = authorizationRejection(for: request, grant: grant) { - throw WatchCommandRejection(result: rejection) + guard Decimal(grams) <= settingsManager.settings.maxCarbs else { + log.info("Garmin \(command.name) command refused, carbs above max carbs") + return .failure(String(localized: "Carbs exceed the maximum allowed.", comment: "Watch command ack")) } } + return nil } // MARK: - Treatments @@ -288,8 +190,8 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { case failed } - private func bolus(_ units: Decimal, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - switch await deliverBolus(units, request: request, grant: grant) { + private func bolus(_ units: Decimal, context: WatchCommandContext) async -> WatchCommandResult { + switch await deliverBolus(units, context: context) { case .delivered: return .success(.genericSuccess, String(localized: "Bolus started.", comment: "Watch command ack")) case let .rejected(message): @@ -299,40 +201,39 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } } - private func carbs(_ grams: Int, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { + private func carbs(_ grams: Int, context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { return rejection } do { - try await actions.storeCarbs(grams, date: request.date, authorize: finalCheck(for: request, grant: grant)) + try await actions.storeCarbs(grams, date: context.issuedAt, authorize: context.finalCheck()) return .success(.carbsLogged, String(localized: "Carbs logged.", comment: "Watch command ack")) } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log(.warning, "Garmin carbs could not be stored (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Storing carbs from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not log carbs.", comment: "Watch command ack")) } } - private func mealBolus( - carbs grams: Int, - bolus units: Decimal, - request: WatchCommandRequest, - grant: UInt64 - ) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { + private func mealBolus(carbs grams: Int, bolus units: Decimal, context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { return rejection } do { - try await actions.storeCarbs(grams, date: request.date, authorize: finalCheck(for: request, grant: grant)) + // a meal command is insulin, so its carbs also re-check the freshness window + try await actions.storeCarbs(grams, date: context.issuedAt, authorize: context.finalCheck(checkingExpiry: true)) } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log(.warning, "Garmin meal carbs could not be stored (\(WatchCommandErrorCategory.name(for: error)))") + log + .warning( + "Storing meal carbs from Garmin failed, no bolus delivered (\(WatchCommandErrorCategory.name(for: error)))" + ) return .failure(String(localized: "Could not log carbs. No bolus was delivered.", comment: "Watch command ack")) } - switch await deliverBolus(units, request: request, grant: grant) { + switch await deliverBolus(units, context: context) { case .delivered: return .success(.genericSuccess, String(localized: "Carbs logged and bolus started.", comment: "Watch command ack")) case .rejected: @@ -353,51 +254,51 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } } - private func deliverBolus(_ units: Decimal, request: WatchCommandRequest, grant: UInt64) async -> BolusOutcome { + private func deliverBolus(_ units: Decimal, context: WatchCommandContext) async -> BolusOutcome { await insulinLane.enter() - let outcome = await deliverBolusInLane(units, request: request, grant: grant) + let outcome = await deliverBolusInLane(units, context: context) await insulinLane.leave() return outcome } - /// Only runs while holding the insulin lane; everything is re-checked because the wait for the - /// lane can take as long as another command's validation and pump request. - private func deliverBolusInLane(_ units: Decimal, request: WatchCommandRequest, grant: UInt64) async -> BolusOutcome { - if let rejection = expiryRejection(for: request) ?? authorizationRejection(for: request, grant: grant) { + /// Everything is re-checked: the wait for the lane can last as long as another command's + /// validation and pump request. + private func deliverBolusInLane(_ units: Decimal, context: WatchCommandContext) async -> BolusOutcome { + if let rejection = context.rejection(checkingExpiry: true) { return .rejected(rejection.message) } let recentWindow = Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60) let previousEnactment = await insulinLane.lastEnactment if let previousEnactment, now().timeIntervalSince(previousEnactment) < recentWindow { - log(.info, "Garmin bolus rejected, previous watch bolus may not be in pump history yet") + log.info("Garmin bolus refused, previous watch bolus may not be in pump history yet") return .rejected(BolusSafetyRejection.recentBolusWithinWindow(totalRecent: 0).watchMessage) } // cover every bolus since the watch sent the command, and never less than the standard window - let lookbackStart = min(request.date, now().addingTimeInterval(-recentWindow)) + let lookbackStart = min(context.issuedAt, now().addingTimeInterval(-recentWindow)) do { switch try await bolusSafetyValidator.validate(bolusAmount: units, lookbackStart: lookbackStart) { case .allowed: break case let .rejected(reason): - log(.info, "Garmin bolus rejected by safety check (\(reason.logName))") + log.info("Garmin bolus refused by safety check (\(reason.logName))") return .rejected(reason.watchMessage) } } catch { - log(.warning, "Garmin bolus safety check failed (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Garmin bolus safety check failed, no bolus delivered (\(WatchCommandErrorCategory.name(for: error)))") return .rejected(String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) } // validation awaited, so the request may have expired or been revoked meanwhile - if let rejection = expiryRejection(for: request) ?? authorizationRejection(for: request, grant: grant) { + if let rejection = context.rejection(checkingExpiry: true) { return .rejected(rejection.message) } // recorded before the request, since a pump error can still mean partial delivery await insulinLane.recordEnactment(at: now()) do { - return try await actions.enactBolus(units, authorize: finalCheck(for: request, grant: grant)) ? .delivered : .failed + return try await actions.enactBolus(units, authorize: context.finalCheck(checkingExpiry: true)) ? .delivered : .failed } catch { // the final check stopped it before `APSManager`, so nothing reached the pump await insulinLane.restoreEnactment(previousEnactment) @@ -405,109 +306,91 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { return .rejected(message ?? String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) } } +} +extension BaseWatchCommandProcessor { // MARK: - Adjustments - private func activateOverride(named name: String, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { + private func activateOverride(named name: String, context: WatchCommandContext) async -> WatchCommandResult { do { - let presets = try await actions.overridePresets() + let presets = try await presetsProvider.overridePresets() guard let preset = uniquePreset(named: name, in: presets) else { - log(.warning, "Garmin override preset missing or ambiguous") + log.info("Garmin override command refused, preset missing or ambiguous") return .failure(String(localized: "Override preset not found.", comment: "Watch command ack")) } - if let rejection = authorizationRejection(for: request, grant: grant) { + if let rejection = context.rejection() { return rejection } - try await adjustmentManager.activateOverride( - preset.ref, - source: .watch, - authorize: finalCheck(for: request, grant: grant) - ) + try await adjustmentManager.activateOverride(preset.ref, source: .watch, authorize: context.finalCheck()) return .success(.overrideStarted, String(localized: "Override started.", comment: "Watch command ack")) } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log(.warning, "Garmin override activation failed (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Starting override from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not start override.", comment: "Watch command ack")) } } - private func cancelOverride(request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { + private func cancelOverride(context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { return rejection } do { - try await adjustmentManager.cancelOverride(source: .watch, authorize: finalCheck(for: request, grant: grant)) + try await adjustmentManager.cancelOverride(source: .watch, authorize: context.finalCheck()) } catch let rejection as WatchCommandRejection { return rejection.result } catch AdjustmentError.nothingActive { // cancelling an inactive override already has the requested outcome } catch { - log(.warning, "Garmin override cancellation failed (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Stopping override from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not stop override.", comment: "Watch command ack")) } return .success(.overrideStopped, String(localized: "Override stopped.", comment: "Watch command ack")) } - private func activateTempTarget(named name: String, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { + private func activateTempTarget(named name: String, context: WatchCommandContext) async -> WatchCommandResult { do { - let presets = try await actions.tempTargetPresets() + let presets = try await presetsProvider.tempTargetPresets() guard let preset = uniquePreset(named: name, in: presets) else { - log(.warning, "Garmin temp target preset missing or ambiguous") + log.info("Garmin temp target command refused, preset missing or ambiguous") return .failure(String(localized: "Temp target preset not found.", comment: "Watch command ack")) } - if let rejection = authorizationRejection(for: request, grant: grant) { + if let rejection = context.rejection() { return rejection } - try await adjustmentManager.activateTempTarget( - preset.ref, - source: .watch, - authorize: finalCheck(for: request, grant: grant) - ) + try await adjustmentManager.activateTempTarget(preset.ref, source: .watch, authorize: context.finalCheck()) return .success(.tempTargetStarted, String(localized: "Temp target started.", comment: "Watch command ack")) } catch let rejection as WatchCommandRejection { return rejection.result } catch { - log(.warning, "Garmin temp target activation failed (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Starting temp target from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not start temp target.", comment: "Watch command ack")) } } - private func cancelTempTarget(request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { + private func cancelTempTarget(context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { return rejection } do { - try await adjustmentManager.cancelTempTarget(source: .watch, authorize: finalCheck(for: request, grant: grant)) + try await adjustmentManager.cancelTempTarget(source: .watch, authorize: context.finalCheck()) } catch let rejection as WatchCommandRejection { return rejection.result } catch AdjustmentError.nothingActive { // cancelling an inactive temp target already has the requested outcome } catch { - log(.warning, "Garmin temp target cancellation failed (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Stopping temp target from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") return .failure(String(localized: "Could not stop temp target.", comment: "Watch command ack")) } return .success(.tempTargetStopped, String(localized: "Temp target stopped.", comment: "Watch command ack")) } - /// Exact, case-sensitive match that ignores whether the preset is running; unlike - /// `AdjustmentManager`'s trimmed name lookup, two presets that only differ in whitespace must - /// not let the watch start the wrong one. + /// Exact, case-sensitive match whether or not the preset runs; unlike `AdjustmentManager`'s trimmed + /// lookup, two presets that only differ in whitespace must not let the watch start the wrong one. private func uniquePreset(named name: String, in presets: [WatchPresetEntry]) -> WatchPresetEntry? { let matches = presets.filter { $0.name == name } return matches.count == 1 ? matches.first : nil } - - private func sortedByName(_ presets: [WatchPresetEntry]) -> [WatchPresetEntry] { - presets.sorted { - switch $0.name.localizedStandardCompare($1.name) { - case .orderedAscending: return true - case .orderedDescending: return false - // tie-break on raw names so equal display names keep a stable order - case .orderedSame: return $0.name < $1.name - } - } - } } private extension BolusSafetyRejection { diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift b/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift index aac154d534..d378733342 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift @@ -1,9 +1,7 @@ import Foundation /// Remembers recent watch request IDs so a relayed or retried command executes at most once. -/// -/// In memory only: after an app restart the freshness window and bolus safety checks are what -/// stop a replay. +/// In memory only: after an app restart the freshness window is what stops a replay. actor WatchCommandRequestCache { struct Key: Hashable { let appUUID: UUID @@ -33,7 +31,7 @@ actor WatchCommandRequestCache { } private struct Entry { - let command: WatchCommand + let command: AnyHashable var state: State let insertedAt: Date var lastUsed: UInt64 @@ -44,7 +42,9 @@ actor WatchCommandRequestCache { } } + /// Must exceed the distinct requests one freshness window can bring; an evicted entry's repeat would run again. static let defaultCapacity = 256 + /// Must outlast the freshness window (2 min plus 60 s lead); longer so a late relayed repeat gets its real result. static let defaultLifetime: TimeInterval = 15 * 60 private let capacity: Int @@ -63,7 +63,7 @@ actor WatchCommandRequestCache { var count: Int { entries.count } /// Reserves `key` before anything executes, or reports why it must not run now. - func admit(_ key: Key, command: WatchCommand, now: Date) -> Admission { + func admit(_ key: Key, command: AnyHashable, now: Date) -> Admission { evictExpired(now: now) useCounter += 1 diff --git a/Trio/Sources/Services/WatchCommand/WatchPresets.swift b/Trio/Sources/Services/WatchCommand/WatchPresets.swift new file mode 100644 index 0000000000..465856a37e --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchPresets.swift @@ -0,0 +1,85 @@ +import CoreData +import Foundation +import Swinject + +/// An adjustment preset as the watch names it, plus the reference that activates exactly that row. +struct WatchPresetEntry: Equatable { + let name: String + /// `enabled` on the stored row: whether the preset runs now, never whether it may be started. + let isActive: Bool + let ref: AdjustmentRef +} + +/// The preset lists a watch shows, sorted by display name. +struct WatchPresets: Equatable { + let overridePresets: [OverridePresetWatch] + let tempTargetPresets: [TempTargetPresetWatch] +} + +/// Every configured override and temp target preset, running or not. +protocol WatchPresetsProvider { + func overridePresets() async throws -> [WatchPresetEntry] + func tempTargetPresets() async throws -> [WatchPresetEntry] +} + +extension WatchPresetsProvider { + func presets() async throws -> WatchPresets { + async let overrides = overridePresets() + async let tempTargets = tempTargetPresets() + + return try await WatchPresets( + overridePresets: overrides.sortedByName.map { OverridePresetWatch(name: $0.name, isEnabled: $0.isActive) }, + tempTargetPresets: tempTargets.sortedByName.map { TempTargetPresetWatch(name: $0.name, isEnabled: $0.isActive) } + ) + } +} + +private extension Array where Element == WatchPresetEntry { + var sortedByName: [WatchPresetEntry] { + sorted { + switch $0.name.localizedStandardCompare($1.name) { + case .orderedAscending: return true + case .orderedDescending: return false + // equal display names keep a stable order across replies + case .orderedSame: return $0.name < $1.name + } + } + } +} + +final class BaseWatchPresetsProvider: WatchPresetsProvider, Injectable { + @Injected() private var overrideStorage: OverrideStorage! + @Injected() private var tempTargetStorage: TempTargetsStorage! + + init(resolver: Resolver) { + injectServices(resolver) + } + + func overridePresets() async throws -> [WatchPresetEntry] { + let ids = try await overrideStorage.fetchForOverridePresets() + let context = CoreDataStack.shared.newTaskContext() + context.name = "watchOverridePresets" + let rows: [OverrideStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) + + return await context.perform { + rows.compactMap { row in + guard let name = row.name, let id = row.id else { return nil } + return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id)) + } + } + } + + func tempTargetPresets() async throws -> [WatchPresetEntry] { + let ids = try await tempTargetStorage.fetchForTempTargetPresets() + let context = CoreDataStack.shared.newTaskContext() + context.name = "watchTempTargetPresets" + let rows: [TempTargetStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) + + return await context.perform { + rows.compactMap { row in + guard let name = row.name, let id = row.id else { return nil } + return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id.uuidString)) + } + } + } +} diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index c13ed8c825..8b0d22c95f 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -1168,8 +1168,8 @@ extension BaseWatchManager { case genericFailure = "failure" /// Meal logged but its bolus was not delivered. case partialFailure = "partial_failure" - /// Duplicate of a request that is still executing; that request's own ack follows. Watches - /// branch on this code, never on the localized message. + /// A duplicate of a request that is still running; that request's own ack follows. + /// Not a failure, although `acknowledged` is false. case inProgress = "in_progress" } } diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 9707c2239c..c55ae5ee5c 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -29,12 +29,11 @@ protocol GarminManager { // MARK: - Connect IQ Client -/// The Connect IQ calls that register apps and reach them, behind a protocol so message targeting -/// and retries can be verified without a paired watch. +/// The Connect IQ calls that register and reach watch apps, behind a protocol so delivery can be tested +/// without a watch. There is deliberately no app-status call: each one is a BLE round trip to the watch. protocol GarminConnectIQClient: AnyObject { func registerDevice(_ device: IQDevice, delegate: IQDeviceEventDelegate) func registerApp(_ app: IQApp, delegate: IQAppMessageDelegate) - func appStatus(of app: IQApp, completion: @escaping (IQAppStatus?) -> Void) func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) } @@ -47,10 +46,6 @@ extension ConnectIQ: GarminConnectIQClient { register(forAppMessages: app, delegate: delegate) } - func appStatus(of app: IQApp, completion: @escaping (IQAppStatus?) -> Void) { - getAppStatus(app, completion: completion) - } - func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) { sendMessage(message, to: app, progress: { _, _ in }, completion: completion) } @@ -88,8 +83,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { @Injected() private var iobService: IOBService! @Injected() private var trioAlertManager: TrioAlertManager! - - /// Validates and executes watch commands; shared so its request-ID cache sees every request. + @Injected() private var presetsProvider: WatchPresetsProvider! @Injected() private var watchCommandProcessor: WatchCommandProcessor! /// Revoked by `BaseSettingsManager` itself; read here only to refresh the watch after a revocation. @@ -110,9 +104,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Wait before the single resend of a failed message. private let sendRetryDelay: TimeInterval + private let broadcastDebounce: DispatchQueue.SchedulerTimeType.Stride + /// Keeps references to watch apps (both watchface & data field) for each registered device. private var watchApps: [IQApp] = [] + /// The command app Trio-WatchControls was last told about, so a change still reaches it while it is + /// deregistered. Nil until the first registration: the selection cannot change while Trio is not running. + private var announcedCommandApp: GarminCommandApp? + /// A set of Combine cancellables for managing the lifecycle of various subscriptions. private var cancellables = Set() @@ -156,17 +156,32 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { debug(.watch, message) } - // MARK: - Device Ready State (SDK 1.8+) + // MARK: - Delivery + + /// What an app is still owed after its send failed for good; latest state wins. Main queue only. + private struct OwedDelivery { + var stateHash: Int? + var pushesPresets = false + var announcesCommandApp = false + } + + /// The send result is the readiness signal: readiness never gates a send, because a missed + /// characteristics callback would otherwise block delivery until the next reconnect. + private var owedDeliveries: [String: OwedDelivery] = [:] + + /// Apps that answered `AppNotFound`, skipped until re-registration or a message from them. + private var absentApps: Set = [] - /// Tracks which devices have completed characteristic discovery and are ready for communication. - /// In SDK 1.8+, `deviceStatusChanged: connected` does NOT mean the device is ready. - /// We must wait for `deviceCharacteristicsDiscovered:` before sending messages. - private var readyDevices: Set = [] + /// The only send gate, owned by the SDK's device status callbacks. + private var disconnectedDevices: Set = [] + + /// Latest broadcast payload, redelivered to apps that are owed state. + private var lastBroadcast: (payload: Any, hash: Int)? // MARK: - Deduplication - /// Hash of last sent data to prevent duplicate broadcasts - private var lastSentDataHash: Int? + /// Hash of the last state each app confirmed receiving, keyed by `deliveryKey(for:)`. + private var lastSentDataHashes: [String: Int] = [:] /// Hash of last prepared data to skip redundant preparation private var lastPreparedDataHash: Int? @@ -189,11 +204,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Track previous Garmin settings to detect what specifically changed private var previousGarminSettings = GarminWatchSettings() - /// Max carbs at the last settings notification; the complication caps its carbs picker with it. + /// Max carbs at the last settings notification; the watch caps its carbs picker with it. private var previousMaxCarbs: Decimal = 0 - /// Revocation epoch at the last settings notification; a newer one means a command setting was - /// switched off since, even if the notification's settings show it back on. + /// A newer epoch means a command switch went off since, even if it now shows on again. private var lastSeenCommandRevocation: UInt64 = 0 /// Pending settings update task - waits for user to finish making changes @@ -240,15 +254,18 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// - connectIQClient: Replaces the Connect IQ SDK for registration and sending. /// - presetChanges: Replaces the Core Data preset change signal. /// - sendRetryDelay: Wait before resending a failed message. + /// - broadcastDebounce: Quiet time before a state broadcast goes out. init( resolver: Resolver, connectIQClient: GarminConnectIQClient? = nil, presetChanges: AnyPublisher? = nil, - sendRetryDelay: TimeInterval = 2 + sendRetryDelay: TimeInterval = 2, + broadcastDebounce: DispatchQueue.SchedulerTimeType.Stride = .seconds(2) ) { router = resolver.resolve(Router.self)! self.connectIQClient = connectIQClient ?? ConnectIQ.sharedInstance() self.sendRetryDelay = sendRetryDelay + self.broadcastDebounce = broadcastDebounce super.init() injectServices(resolver) @@ -276,9 +293,9 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { .share() .eraseToAnyPublisher() - // Glucose updates - start 20s fallback timer + // Glucose updates - start 10s fallback timer // When loop is working: determination arrives within ~5s, cancels timer, sends complete data - // When loop is slow/failing: timer fires after 20s, sends glucose with stale loop data + // When loop is slow/failing: timer fires after 10s, sends glucose with stale loop data // This ensures watch gets fresh glucose even if loop doesn't complete glucoseStorage.updatePublisher .receive(on: timerQueue) @@ -305,15 +322,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { settingsManager.settings.garminSettings.watchface } - /// Returns the currently configured Garmin datafields from settings. - /// Empty when the user has not selected any datafield. - private var currentDatafields: [GarminDatafield] { - settingsManager.settings.garminSettings.datafields + /// The only watch app whose commands run, and the only one that gets the preset list. + private var currentCommandApp: GarminCommandApp { + GarminCommandApp(settingsManager.settings.garminSettings) } - /// Returns the configured datafield registered under the given app UUID, if any - private func datafield(for uuid: UUID) -> GarminDatafield? { - currentDatafields.first { $0.datafieldUUID == uuid } + /// Returns the currently configured Garmin datafield from settings + private var currentDatafield: GarminDatafield { + settingsManager.settings.garminSettings.datafield } /// Returns whether watchface data transmission is enabled in settings @@ -334,7 +350,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private var needsHistoricalGlucoseData: Bool { currentWatchface == .swissalpine || currentWatchface == .complication - || currentDatafields.contains(.loopgraph) + || currentDatafield == .loopgraph } /// Returns the display name for an app UUID (watchface or datafield). @@ -342,8 +358,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func appDisplayName(for uuid: UUID) -> String { if uuid == currentWatchface.watchfaceUUID { return "watchface:\(currentWatchface.displayName)" - } else if let datafield = datafield(for: uuid) { - return "datafield:\(datafield.displayName)" + } else if uuid == currentDatafield.datafieldUUID { + return "datafield:\(currentDatafield.displayName)" + } else if uuid == GarminWatchControls.appUUID { + return "watch app:\(GarminWatchControls.displayName)" } else { return "unknown app" } @@ -355,8 +373,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func appDetailedName(for uuid: UUID) -> String { if uuid == currentWatchface.watchfaceUUID { return "watchface:\(currentWatchface.displayName) (\(uuid.uuidString))" - } else if let datafield = datafield(for: uuid) { - return "datafield:\(datafield.displayName) (\(uuid.uuidString))" + } else if uuid == currentDatafield.datafieldUUID { + return "datafield:\(currentDatafield.displayName) (\(uuid.uuidString))" + } else if uuid == GarminWatchControls.appUUID { + return "watch app:\(GarminWatchControls.displayName) (\(uuid.uuidString))" } else { return "unknown app (\(uuid.uuidString))" } @@ -377,7 +397,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { (presetChanges ?? storedPresetChanges)? .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [weak self] in - self?.pushPresetsToComplications(triggeredBy: "PresetStorage") + self?.pushPresetsToCommandApp(triggeredBy: "PresetStorage") } .store(in: &subscriptions) @@ -447,7 +467,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func handleIOBUpdate() { guard !devices.isEmpty else { return } - // Cancel any existing fallback timer (restart the 20s window) + // Cancel any existing fallback timer (restart the 10s window) pendingGlucoseFallback?.cancel() // Create new fallback task @@ -852,18 +872,17 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { // MARK: - Device & App Registration /// Registers the given devices for ConnectIQ events (device status changes) and watch app messages. - /// It also creates and registers watch apps (watchface + data field) for each device. + /// It also creates and registers watch apps (watchface, data field and Trio-WatchControls) for each device. /// - Parameter devices: The devices to register. private func registerDevices(_ devices: [IQDevice]) { + let previousApps = watchApps watchApps.removeAll() - // Reset broadcast hash so newly registered apps receive data + // Reset broadcast hashes so newly registered apps receive data // Without this, hash deduplication could skip sending to new apps if data unchanged - lastSentDataHash = nil - - // Note: Do NOT clear readyDevices here. The device ready state is based on BLE - // characteristic discovery, which only happens on new connections. Re-registering - // for app messages doesn't affect the underlying BLE connection state. + lastSentDataHashes.removeAll() + // re-registration is the retry permission for apps that answered AppNotFound + absentApps.removeAll() for device in devices { connectIQClient?.registerDevice(device, delegate: self) @@ -880,16 +899,49 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { debugGarmin("Garmin watchface data disabled, watchface not registered") } - // Always register every configured datafield - all of them receive the same payload - for datafield in currentDatafields { - guard let datafieldUUID = datafield.datafieldUUID, - let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device) - else { continue } - + // Always register datafield (if configured) + if let datafieldUUID = currentDatafield.datafieldUUID, + let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device) + { debugGarmin("Registered Garmin \(appDetailedName(for: datafieldUUID))") watchApps.append(datafieldApp) connectIQClient?.registerApp(datafieldApp, delegate: self) } + + if currentCommandApp == .watchControls, + let watchControlsUUID = GarminWatchControls.appUUID, + let watchControlsApp = IQApp(uuid: watchControlsUUID, store: UUID(), device: device) + { + debugGarmin("Registered Garmin \(appDetailedName(for: watchControlsUUID))") + watchApps.append(watchControlsApp) + connectIQClient?.registerApp(watchControlsApp, delegate: self) + } + } + + let registeredKeys = Set(watchApps.compactMap(deliveryKey(for:))) + owedDeliveries = owedDeliveries.filter { registeredKeys.contains($0.key) } + + let commandAppChanged = announcedCommandApp != nil && announcedCommandApp != currentCommandApp + announcedCommandApp = currentCommandApp + + for app in watchApps where app.uuid.map(GarminCommandApp.appUUIDs.contains) == true { + sendCommandAppNotice(to: app, isActive: true) + } + + // Trio-WatchControls is registered only while it is the command app, so a change between the other + // two owners never reaches it through `previousApps`: going from commands off straight to + // Complication would leave it saying commands are off in Trio. While inactive its glance neither + // sends nor polls, so nothing on the watch would correct that. + var standDowns = previousApps.filter { $0.uuid.map(GarminCommandApp.appUUIDs.contains) == true } + if commandAppChanged, currentCommandApp != .watchControls, let watchControlsUUID = GarminWatchControls.appUUID { + standDowns += devices.compactMap { IQApp(uuid: watchControlsUUID, store: UUID(), device: $0) } + } + + var noticedKeys = Set() + for app in standDowns { + guard let key = deliveryKey(for: app), !registeredKeys.contains(key), noticedKeys.insert(key).inserted + else { continue } + sendCommandAppNotice(to: app, isActive: false) } } @@ -956,7 +1008,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Subscribes to watch state updates with debouncing private func subscribeToWatchState() { watchStateSubject - .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .debounce(for: broadcastDebounce, scheduler: DispatchQueue.main) .sink { [weak self] data in self?.broadcastWatchStateData(data) } @@ -974,7 +1026,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { deviceSelectionPromise = nil } - /// Broadcasts watch state data to all registered apps + /// Sends the state once to every registered app that has not confirmed this exact state yet. private func broadcastWatchStateData(_ data: Data) { // Deduplicate: Use stable content-based hash (sorted JSON bytes) let currentHash: Int @@ -987,43 +1039,156 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { currentHash = data.count // Fallback } - if currentHash == lastSentDataHash { - if debugWatchState { - debug(.watch, "Garmin broadcast skipped, data unchanged") - } - return - } - guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) else { warning(.watch, "Garmin watch state is not valid JSON") return } + lastBroadcast = (jsonObject, currentHash) - watchApps.forEach { app in - guard let appUUID = app.uuid else { - debug(.watch, "Skipped Garmin app without UUID") - return - } - let appName = self.appDisplayName(for: appUUID) + for app in watchApps { + guard let key = deliveryKey(for: app), let device = app.device else { continue } + let appName = appDisplayName(for: app.uuid) - // Check if device is ready (SDK 1.8+ requirement) - guard let device = app.device, readyDevices.contains(device.uuid) else { - debugGarmin("Skipped Garmin \(appName), device not ready") - return + if lastSentDataHashes[key] == currentHash, owedDeliveries[key]?.stateHash == nil { + if debugWatchState { + debug(.watch, "Garmin \(appName) skipped, data unchanged") + } + continue } + guard isReachable(key: key, device: device, appName: appName) else { + recordOwed(for: key, stateHash: currentHash) + continue + } + debugGarmin("Sending state to Garmin \(appName)") + sendMessage(jsonObject, to: app, appName: appName, stateHash: currentHash) + } + } + + // MARK: - Delivery - connectIQClient?.appStatus(of: app) { [weak self] status in - guard status?.isInstalled == true else { - debug(.watch, "Garmin \(appName) not installed") - return + private func deliveryKey(for app: IQApp) -> String? { + guard let appUUID = app.uuid, let deviceUUID = app.device?.uuid else { return nil } + return "\(appUUID.uuidString)-\(deviceUUID.uuidString)" + } + + /// SDK completions are not documented to arrive on main; delivery state lives there. + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + /// Skipping keeps whatever the app is owed for the next trigger. + private func isReachable(key: String, device: IQDevice, appName: String) -> Bool { + if absentApps.contains(key) { + debugGarmin("Garmin \(appName) skipped, app not installed") + return false + } + if disconnectedDevices.contains(device.uuid) { + debugGarmin("Garmin \(appName) skipped, device disconnected") + return false + } + return true + } + + /// Acks and other replies are never owed; the watch expires an unanswered command itself. + private func recordOwed(for key: String, stateHash: Int? = nil, presets: Bool = false, commandApp: Bool = false) { + guard stateHash != nil || presets || commandApp else { return } + var owed = owedDeliveries[key] ?? OwedDelivery() + if let stateHash { + owed.stateHash = stateHash + } + owed.pushesPresets = owed.pushesPresets || presets + owed.announcesCommandApp = owed.announcesCommandApp || commandApp + owedDeliveries[key] = owed + } + + /// Any delivered message proves the channel, so what the app is still owed goes out now. + private func flushOwed(to app: IQApp, key: String, appName: String) { + guard let owed = owedDeliveries.removeValue(forKey: key) else { return } + if owed.stateHash != nil, let broadcast = lastBroadcast, lastSentDataHashes[key] != broadcast.hash { + debugGarmin("Sending owed state to Garmin \(appName)") + sendMessage(broadcast.payload, to: app, appName: appName, stateHash: broadcast.hash) + } + if owed.pushesPresets { + pushPresets(to: [app], triggeredBy: "OwedDelivery") + } + if owed.announcesCommandApp { + sendCommandAppNotice(to: app, isActive: true) + } + } + + /// Redelivers on the triggers that occur anyway (broadcast, reconnect, inbound message) instead of timers. + private func flushOwed(on device: IQDevice) { + for app in watchApps where app.device?.uuid == device.uuid { + guard let key = deliveryKey(for: app), !absentApps.contains(key) else { continue } + flushOwed(to: app, key: key, appName: appDisplayName(for: app.uuid)) + } + } + + /// Main queue only. + private func pushPresetsToCommandApp(triggeredBy trigger: String) { + guard let commandAppUUID = currentCommandApp.appUUID else { return } + pushPresets(to: watchApps.filter { $0.uuid == commandAppUUID }, triggeredBy: trigger) + } + + /// Read by both command apps, Trio-WatchControls and Complication. Only the active notice is owed: + /// an app told to stand down that missed it is told again as soon as it sends anything. + private func sendCommandAppNotice(to app: IQApp, isActive: Bool) { + guard let key = deliveryKey(for: app), let device = app.device else { return } + let appName = appDisplayName(for: app.uuid) + let notice = GarminMessageEnvelope.commandAppNotice(isActive: isActive, commandApp: currentCommandApp) + guard !isActive || isReachable(key: key, device: device, appName: appName) else { + recordOwed(for: key, commandApp: true) + return + } + debug(.watch, "Sending command app notice to Garmin \(appName), \(isActive ? "active" : "inactive")") + sendMessage(notice, to: app, appName: appName, announcesCommandApp: isActive) + } + + /// Targeted like a reply, so it never touches the state broadcast or its content hash. + private func pushPresets(to targets: [IQApp], triggeredBy trigger: String) { + guard !targets.isEmpty else { return } + + Task { + do { + let response = try await GarminMessageEnvelope.presetsResponse( + loadPresets(), + capabilities: self.watchCommandProcessor.capabilities() + ) + onMain { [weak self] in + guard let self else { return } + for app in targets { + guard let key = self.deliveryKey(for: app), let device = app.device else { continue } + let appName = self.appDisplayName(for: app.uuid) + guard self.isReachable(key: key, device: device, appName: appName) else { + self.recordOwed(for: key, presets: true) + continue + } + self.sendTargetedResponse(response, to: app, appName: appName, isPresets: true) + } } - self?.debugGarmin("Sending to Garmin \(appName)") - self?.sendMessage(jsonObject as Any, to: app, appName: appName) + } catch { + warning( + .watch, + "Loading presets for the Garmin push after \(trigger) failed (\(WatchCommandErrorCategory.name(for: error)))" + ) } } + } - // Update last sent hash after initiating send - lastSentDataHash = currentHash + private func loadPresets() async throws -> WatchPresets { + try await presetsProvider.presets() + } + + /// Answers one app directly, never through `broadcastWatchStateData`, whose content hash would + /// otherwise suppress the next state update. + private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String, isPresets: Bool = false) { + let kind = response[WatchMessageKeys.request] as? String ?? "response" + debug(.watch, "Sending \(kind) to Garmin \(appName)") + sendMessage(response, to: app, appName: appName, isPresets: isPresets) } // MARK: - GarminManager Conformance @@ -1059,36 +1224,55 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { // MARK: - Helper: Sending Messages - /// Sends a message to a given IQApp with optional progress and completion callbacks. - /// Retries once after a short delay if the first attempt fails (SDK may need time after re-registration). - /// - Parameters: - /// - msg: The data to send to the watch app. - /// - app: The `IQApp` instance representing the watchface or data field. - /// - appName: The display name of the app for logging. - /// - isRetry: Whether this is a retry attempt (to prevent infinite retries). - private func sendMessage(_ msg: Any, to app: IQApp, appName: String, isRetry: Bool = false) { - connectIQClient?.send( - msg, - to: app, - completion: { [weak self] result in + /// Sends with one resend after `sendRetryDelay` for transient failures; a final failure leaves the + /// state or preset list owed. `AppNotFound` is never resent: the app is provably not installed. + private func sendMessage( + _ msg: Any, + to app: IQApp, + appName: String, + stateHash: Int? = nil, + isPresets: Bool = false, + announcesCommandApp: Bool = false, + isRetry: Bool = false + ) { + connectIQClient?.send(msg, to: app) { [weak self] result in + self?.onMain { + guard let self, let key = self.deliveryKey(for: app) else { return } switch result { case .success: debug(.watch, "Sent to Garmin \(appName)") + self.absentApps.remove(key) + if let stateHash { + self.lastSentDataHashes[key] = stateHash + } + self.flushOwed(to: app, key: key, appName: appName) + case .failure_AppNotFound: + debug(.watch, "Garmin \(appName) not installed, skipped until re-registered or heard from") + self.absentApps.insert(key) + self.recordOwed(for: key, stateHash: stateHash, presets: isPresets, commandApp: announcesCommandApp) default: + let reason = NSStringFromSendMessageResult(result) ?? "\(result.rawValue)" if isRetry { - error(.watch, "Sending to Garmin \(appName) failed after retry") + error(.watch, "Sending to Garmin \(appName) failed after retry (\(reason))") + self.recordOwed(for: key, stateHash: stateHash, presets: isPresets, commandApp: announcesCommandApp) } else { - let retryDelay = self?.sendRetryDelay ?? 2 - warning(.watch, "Sending to Garmin \(appName) failed, retrying in \(retryDelay) s") - // Retry after delay - SDK may need time after re-registration - DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { + warning(.watch, "Sending to Garmin \(appName) failed, retrying (\(reason))") + DispatchQueue.main.asyncAfter(deadline: .now() + self.sendRetryDelay) { [weak self] in self?.debugGarmin("Retrying send to Garmin \(appName)") - self?.sendMessage(msg, to: app, appName: appName, isRetry: true) + self?.sendMessage( + msg, + to: app, + appName: appName, + stateHash: stateHash, + isPresets: isPresets, + announcesCommandApp: announcesCommandApp, + isRetry: true + ) } } } } - ) + } } } @@ -1126,8 +1310,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // MARK: - IQDeviceEventDelegate /// Called whenever the status of a registered Garmin device changes (e.g., connected, not found, etc.). - /// Note: In SDK 1.8+, `connected` does NOT mean ready for communication. - /// Wait for `deviceCharacteristicsDiscovered:` before sending messages. + /// Only a lost connection gates sends; nothing here costs a round trip to the watch. /// - Parameters: /// - device: The device whose status has changed. /// - status: The new status for the device. @@ -1136,18 +1319,19 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM switch status { case .invalidDevice: warning(.watch, "Garmin device invalid") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .bluetoothNotReady: info(.watch, "Garmin device unavailable, Bluetooth not ready") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .notFound: info(.watch, "Garmin device not found") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .notConnected: info(.watch, "Garmin device disconnected") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .connected: debug(.watch, "Garmin device connected, waiting for characteristics") + disconnectedDevices.remove(device.uuid) @unknown default: debug(.watch, "Garmin device status unknown (\(status.rawValue))") } @@ -1158,7 +1342,8 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM /// - Parameter device: The device whose characteristics have been discovered. func deviceCharacteristicsDiscovered(_ device: IQDevice) { info(.watch, "Garmin device connected and ready") - readyDevices.insert(device.uuid) + disconnectedDevices.remove(device.uuid) + flushOwed(on: device) // Trigger a data send now that device is ready // This ensures newly connected devices get data promptly @@ -1167,9 +1352,9 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // MARK: - IQAppMessageDelegate - /// Called when a message arrives from a Garmin watch app (watchface, datafield or complication app). - /// Status requests refresh the broadcast state; preset requests and commands are answered only - /// to the requesting app. + /// Called when a message arrives from a Garmin watch app (watchface, datafield or command app). + /// Status requests refresh the broadcast state; preset requests and commands are answered only to + /// the requesting app. /// - Parameters: /// - message: The message content from the watch app. /// - app: The watch app sending the message. @@ -1179,15 +1364,39 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM return } let appName = appDisplayName(for: appUUID) - let isRegistered = watchApps.contains { $0.uuid == appUUID && $0.device?.uuid == app.device?.uuid } - let router = GarminCommandRouter(processor: watchCommandProcessor) debugGarmin("Message received from Garmin \(appName)") + let registered = watchApps.first { $0.uuid == appUUID && $0.device?.uuid == app.device?.uuid } + + // a message from the app proves the channel, so it gets what it is owed right away + if let registered, let key = deliveryKey(for: registered) { + onMain { [weak self] in + self?.absentApps.remove(key) + self?.flushOwed(to: registered, key: key, appName: appName) + } + } else if GarminCommandApp.appUUIDs.contains(appUUID), devices.contains(where: { $0.uuid == app.device?.uuid }) { + // it still acts as the command app, so it missed the notice to stand down + onMain { [weak self] in + self?.sendCommandAppNotice(to: app, isActive: false) + } + } + + let router = GarminMessageRouter(processor: watchCommandProcessor, loadPresets: { [weak self] in + guard let self else { throw CancellationError() } + return try await self.loadPresets() + }) Task { - let outcome = await router.route(message, from: appUUID, isRegistered: isRegistered, appName: appName) + let outcome = await router.route( + message, + from: appUUID, + isRegistered: registered != nil, + // only on a selected device: any other watch must not reach the processor + isCommandApp: GarminCommandApp.appUUIDs.contains(appUUID) && devices.contains { $0.uuid == app.device?.uuid }, + appName: appName + ) if let reply = outcome.reply { - DispatchQueue.main.async { [weak self] in + onMain { [weak self] in self?.sendTargetedResponse(reply, to: app, appName: appName) } } @@ -1196,42 +1405,12 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM triggerWatchStateUpdate(triggeredBy: trigger) } if outcome.pushesPresets { - DispatchQueue.main.async { [weak self] in - self?.pushPresetsToComplications(triggeredBy: "WatchCommand") - } - } - } - } - - /// Sends the preset list to every registered Complication app on every device, targeted like an - /// ack so it never touches the state broadcast or its content hash. Call on the main queue. - private func pushPresetsToComplications(triggeredBy trigger: String) { - let complicationUUID = GarminWatchface.complication.watchfaceUUID - let targets = watchApps.filter { $0.uuid != nil && $0.uuid == complicationUUID } - guard !targets.isEmpty else { return } - - Task { - do { - let response = try await GarminCommandEnvelope.presetsResponse(watchCommandProcessor.presets()) - DispatchQueue.main.async { [weak self] in - guard let self else { return } - for app in targets { - self.sendTargetedResponse(response, to: app, appName: self.appDisplayName(for: app.uuid)) - } + onMain { [weak self] in + self?.pushPresetsToCommandApp(triggeredBy: "WatchCommand") } - } catch { - warning(.watch, "Garmin presets for \(trigger) push could not be loaded (\(WatchCommandErrorCategory.name(for: error)))") } } } - - /// Answers one app directly. Never routed through `broadcastWatchStateData`, whose content hash - /// would otherwise suppress the next state update or leak command data to other apps. - private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String) { - let kind = response[WatchMessageKeys.request] as? String ?? "response" - debugGarmin("Sending \(kind) to Garmin \(appName)") - sendMessage(response, to: app, appName: appName) - } } extension BaseGarminManager: SettingsObserver { @@ -1247,7 +1426,7 @@ extension BaseGarminManager: SettingsObserver { let unitsChanged = currentUnits != units let colorSchemeChanged = currentColorScheme != glucoseColorScheme let watchfaceChanged = currentGarminSettings.watchface != previousGarminSettings.watchface - let datafieldChanged = currentGarminSettings.datafields != previousGarminSettings.datafields + let datafieldChanged = currentGarminSettings.datafield != previousGarminSettings.datafield let watchfaceDataEnabledChanged = currentGarminSettings.isWatchfaceDataEnabled != previousGarminSettings .isWatchfaceDataEnabled let displayAttributesChanged = currentGarminSettings.primaryAttributeChoice != previousGarminSettings @@ -1261,13 +1440,14 @@ extension BaseGarminManager: SettingsObserver { lastSeenCommandRevocation = commandRevocation let maxCarbsChanged = settingsManager.settings.maxCarbs != previousMaxCarbs previousMaxCarbs = settingsManager.settings.maxCarbs + let commandAppChanged = GarminCommandApp(currentGarminSettings) != GarminCommandApp(previousGarminSettings) // Update stored values units = currentUnits glucoseColorScheme = currentColorScheme - // Re-register devices only if watchface/datafield configuration changed - if watchfaceChanged || datafieldChanged || watchfaceDataEnabledChanged { + // Re-register devices only if the set of watch apps changed + if watchfaceChanged || datafieldChanged || watchfaceDataEnabledChanged || commandAppChanged { debugGarmin("Garmin watchface or data field changed, re-registering devices") if !devices.isEmpty { registerDevices(devices) @@ -1285,7 +1465,8 @@ extension BaseGarminManager: SettingsObserver { debug(.watch, "Garmin watchface data enabled, sending update") } triggerWatchStateUpdate(triggeredBy: "Settings") - } else if commandSettingsChanged { + } else if commandSettingsChanged || commandAppChanged { + // a newly selected command app shows nothing until its first state triggerWatchStateUpdate(triggeredBy: "CommandSettings") } else if unitsChanged || displayAttributesChanged || colorSchemeChanged { // Throttle other settings changes in case user makes multiple changes @@ -1295,10 +1476,10 @@ extension BaseGarminManager: SettingsObserver { sendSettingsUpdateThrottled() } - // the complication builds its menu and pickers from these, so it gets them without asking; - // max bolus and bolus increment live outside TrioSettings and reach it on the next app open - if commandSettingsChanged || maxCarbsChanged { - pushPresetsToComplications(triggeredBy: "CommandSettings") + // the complication builds its menu and pickers from these; max bolus and bolus increment live + // outside TrioSettings and reach it with the next preset request + if commandSettingsChanged || maxCarbsChanged || commandAppChanged { + pushPresetsToCommandApp(triggeredBy: "CommandSettings") } // Store current Garmin settings for next comparison diff --git a/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift similarity index 78% rename from Trio/Sources/Services/WatchManager/GarminCommandRouter.swift rename to Trio/Sources/Services/WatchManager/GarminMessageRouter.swift index ec7ec350e7..7cd12dfe16 100644 --- a/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift +++ b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift @@ -12,8 +12,8 @@ enum GarminInboundMessage: Equatable { case invalid(requestID: String?) } -/// Garmin v1 wire contract: parsing inbound envelopes and building the targeted responses. -enum GarminCommandEnvelope { +/// Garmin v1 wire contract: parsing inbound envelopes and building the targeted replies. +enum GarminMessageEnvelope { static let protocolVersion = 1 enum Request: String { @@ -21,6 +21,7 @@ enum GarminCommandEnvelope { case presets case command case ack + case ownerMode } private static let readEnvelopeKeys: Set = [WatchMessageKeys.protocolVersion, WatchMessageKeys.request] @@ -67,7 +68,8 @@ enum GarminCommandEnvelope { } let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000) return .command(requestIDString: requestIDString, requestID: requestID, date: date, command: command) - case .ack: + case .ack, + .ownerMode: return .invalid(requestID: requestIDString) } } @@ -83,12 +85,10 @@ enum GarminCommandEnvelope { ] } - /// Lists every configured preset; `isEnabled` reports whether it is running now, not whether - /// the watch may activate it. The switches and limits let the watch hide what the phone would - /// refuse; amounts go out as doubles in U and g. - static func presetsResponse(_ presets: WatchCommandPresets) -> [String: Any] { - let capabilities = presets.capabilities - return [ + /// The watch reads the switches and limits from the preset reply, so they ride along. `isEnabled` + /// reports whether a preset runs now, not whether the watch may start it; amounts go out in U and g. + static func presetsResponse(_ presets: WatchPresets, capabilities: WatchCommandCapabilities) -> [String: Any] { + [ WatchMessageKeys.protocolVersion: protocolVersion, WatchMessageKeys.request: Request.presets.rawValue, WatchMessageKeys.overridePresets: presets.overridePresets.map { @@ -105,6 +105,16 @@ enum GarminCommandEnvelope { ] } + /// Tells Trio-WatchControls whether it is the command app; `owner` lets an inactive one say which app is. + static func commandAppNotice(isActive: Bool, commandApp: GarminCommandApp) -> [String: Any] { + [ + WatchMessageKeys.protocolVersion: protocolVersion, + WatchMessageKeys.request: Request.ownerMode.rawValue, + WatchMessageKeys.isCommandAppActive: isActive, + WatchMessageKeys.commandApp: commandApp.rawValue + ] + } + // MARK: - Payloads private static func command(named name: String, payload: [String: Any]) -> WatchCommand? { @@ -143,12 +153,12 @@ enum GarminCommandEnvelope { // MARK: - Value Types /// Connect IQ delivers every number, and booleans too, as `NSNumber`; booleans are not numbers here. - private static func number(_ value: Any?) -> NSNumber? { + static func number(_ value: Any?) -> NSNumber? { guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } return number.doubleValue.isFinite ? number : nil } - private static func integer(_ value: Any?) -> Int64? { + static func integer(_ value: Any?) -> Int64? { guard let number = number(value) else { return nil } let double = number.doubleValue guard double.rounded() == double, abs(double) < 9.0E15 else { return nil } @@ -175,29 +185,38 @@ enum GarminCommandEnvelope { } } -/// Connect IQ side of watch commands: turns one inbound message into at most one reply for the +/// Connect IQ side of watch messages: one inbound message becomes at most one reply for the /// requesting app plus an optional state refresh. It has no broadcast path by design. -struct GarminCommandRouter { +struct GarminMessageRouter { struct Outcome { /// Sent to the requesting `IQApp` only. var reply: [String: Any]? /// Trigger name for a regular watch-state update, if one is due. var refreshTrigger: String? - /// A preset started or stopped, so every Complication app needs the targeted preset list. + /// A preset started or stopped, so the command app needs the targeted preset list. var pushesPresets = false static let none = Outcome(reply: nil, refreshTrigger: nil) } let processor: WatchCommandProcessor - var log: (LogLevel, String) -> Void = { TrioLogger.log($0, .watch, $1, file: #file, function: #function, line: #line) } + let loadPresets: () async throws -> WatchPresets + var log: WatchCommandLogger = .app - func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { - let inbound = GarminCommandEnvelope.parse(message) - log(.debug, "Garmin \(inbound.logName) received from \(appName)") + /// An unregistered command app still gets its commands answered, so a watch that missed the switch to + /// the other command app sees a refusal instead of waiting for an ack that never comes. + func route( + _ message: Any, + from appUUID: UUID, + isRegistered: Bool, + isCommandApp: Bool = false, + appName: String + ) async -> Outcome { + let inbound = GarminMessageEnvelope.parse(message) + log.debug("Garmin \(appName) sent a \(inbound.logName)") - guard isRegistered else { - log(.warning, "Garmin \(inbound.logName) from unregistered app \(appUUID.uuidString) ignored") + guard isRegistered || (isCommandApp && inbound.isCommand) else { + log.info("Ignored a \(inbound.logName) from unregistered Garmin app \(appUUID.uuidString)") return .none } @@ -208,19 +227,21 @@ struct GarminCommandRouter { case .presets: do { - let presets = try await processor.presets() - return Outcome(reply: GarminCommandEnvelope.presetsResponse(presets), refreshTrigger: nil) + let response = try await GarminMessageEnvelope.presetsResponse( + loadPresets(), + capabilities: processor.capabilities() + ) + return Outcome(reply: response, refreshTrigger: nil) } catch { - log(.warning, "Garmin presets could not be loaded (\(WatchCommandErrorCategory.name(for: error)))") + log.warning("Loading presets for Garmin \(appName) failed (\(WatchCommandErrorCategory.name(for: error)))") return .none } case let .command(requestIDString, requestID, date, command): - log(.info, "Garmin \(command.name) accepted from \(appName)") let request = WatchCommandRequest(requestID: requestID, appUUID: appUUID, date: date, command: command) let result = await processor.process(request) return Outcome( - reply: GarminCommandEnvelope.acknowledgment(requestID: requestIDString, result: result), + reply: GarminMessageEnvelope.acknowledgment(requestID: requestIDString, result: result), refreshTrigger: result.refreshesState ? "WatchCommand" : nil, pushesPresets: result.acknowledged && command.changesPresetState ) @@ -229,13 +250,18 @@ struct GarminCommandRouter { // without a request ID the watch could not match an answer, so none is sent guard let requestID else { return .none } let result = WatchCommandResult.failure(String(localized: "Invalid command.", comment: "Watch command ack")) - return Outcome(reply: GarminCommandEnvelope.acknowledgment(requestID: requestID, result: result), refreshTrigger: nil) + return Outcome(reply: GarminMessageEnvelope.acknowledgment(requestID: requestID, result: result), refreshTrigger: nil) } } } private extension GarminInboundMessage { /// Never includes payload values: a command body can carry an insulin amount. + var isCommand: Bool { + if case .command = self { return true } + return false + } + var logName: String { switch self { case .legacyStatus: return "legacy status request" diff --git a/TrioTests/AlertCatalogRegistryEntriesTests.swift b/TrioTests/AlertCatalogRegistryEntriesTests.swift index b91d0a2095..cd81a7fefd 100644 --- a/TrioTests/AlertCatalogRegistryEntriesTests.swift +++ b/TrioTests/AlertCatalogRegistryEntriesTests.swift @@ -242,4 +242,11 @@ import Testing #expect(AlertCatalogRegistry.lookup(id("trio.aps", alertID))?.category == "Algorithm") } } + + @Test("Tidepool re-login alert is a Normal-tier Services entry") func tidepoolLoginEntry() { + let entry = AlertCatalogRegistry.lookup(id("TidepoolService", "authentication-needed")) + #expect(entry?.interruptionLevel == .active) + #expect(entry?.category == "Services") + #expect(entry?.concept == .tidepoolLoginRequired) + } } diff --git a/TrioTests/BasalRoundingDriverParityTests.swift b/TrioTests/BasalRoundingDriverParityTests.swift index b9259bc32b..6319b9ce70 100644 --- a/TrioTests/BasalRoundingDriverParityTests.swift +++ b/TrioTests/BasalRoundingDriverParityTests.swift @@ -1,7 +1,5 @@ -import DanaKit import Foundation import MedtrumKit -import MinimedKit import Testing @testable import Trio @@ -35,9 +33,6 @@ import Testing ] private static let tables: [(String, [Double])] = [ - ("Minimed 723 (gen >= 23)", PumpModel.model723.supportedBasalRates), - ("Minimed 522 (pre-x23)", PumpModel.model522.supportedBasalRates), - ("Dana", DanaKitPumpManager.onboardingSupportedBasalRates), ("Medtrum", MedtrumPumpManager.onboardingSupportedBasalRates), // Pod's table is a local inside OmnipodKit's BasalDeliveryTable, so replicate Eros here ("Omnipod Eros", (1 ... 600).map { Double($0) / 20 }) @@ -85,16 +80,6 @@ import Testing } } - @Test("the lossy conversion this replaced really did drop an increment") func lossyConversionRegression() { - // pins why nearestDouble exists, so nobody reverts it to Double(truncating:) - let dana = DanaKitPumpManager.onboardingSupportedBasalRates - for rate in [Decimal(7) / 100, Decimal(14) / 100, Decimal(199) / 100] { - let lossy = Double(truncating: rate as NSNumber) - #expect((dana.last { $0 <= lossy } ?? 0) != rate.nearestDouble) - #expect((dana.last { $0 <= rate.nearestDouble } ?? 0) == rate.nearestDouble) - } - } - @Test("every kit table is non-empty and normalises without collisions") func tablesNormaliseCleanly() { for (_, table) in Self.tables { let rates = normalised(table) diff --git a/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift b/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift new file mode 100644 index 0000000000..eeafdd37ae --- /dev/null +++ b/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Swinject +import Testing +@testable import Trio + +/// A bolus of zero or less must be refused with an answer. `enactBolus` used to return without +/// calling back, so a watch that sent 0 U waited on "Processing…" forever. +@Suite("Enact bolus amount validation", .serialized) struct EnactBolusAmountTests { + private static func makeAPSManager() async throws -> APSManager { + let coreDataStack = try await CoreDataStack.createForTests() + let resolver = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: coreDataStack.newTaskContext()) + ]).resolver + return try #require(resolver.resolve(APSManager.self)) + } + + @Test("Bolus of zero or less is refused with an answer", arguments: [ + 0.0, + -0.05, + -3.0, + Double.nan + ]) func testInvalidAmountAnswersFailure(amount: Double) async throws { + let apsManager = try await Self.makeAPSManager() + var answers: [(success: Bool, message: String)] = [] + + await apsManager.enactBolus(amount: amount, isSMB: false) { success, message in + answers.append((success, message)) + } + + #expect(answers.count == 1) + #expect(answers.first?.success == false) + #expect(answers.first?.message == BaseAPSManager.invalidBolusAmountMessage) + } +} diff --git a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift index 6ca9db30ba..bce7dcb7bb 100644 --- a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift +++ b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift @@ -277,6 +277,7 @@ private final class StubTidepoolManager: TidepoolManager { func addTidepoolService(service _: Service) {} func getTidepoolServiceUI() -> ServiceUI? { nil } func getTidepoolPluginHost() -> PluginHost? { nil } + let tidepoolLoginRequired = CurrentValueSubject(false) func uploadCarbs() async {} func deleteCarbs(withSyncId _: UUID, carbs _: Decimal, at _: Date, enteredBy _: String) {} func uploadInsulin() async {} diff --git a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift index 83db5f81f3..12f61c7f95 100644 --- a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift +++ b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift @@ -14,6 +14,7 @@ import Testing pumpTimeMismatch: Bool = false, lastGlucoseDate: Date?, maxIOB: Decimal = 10, + tidepoolLoginRequired: Bool = false, hasOverride: Bool = false, hasTempTarget: Bool = false, hasTempProfile: Bool = false, @@ -26,6 +27,7 @@ import Testing pumpTimeMismatch: pumpTimeMismatch, lastGlucoseDate: lastGlucoseDate, maxIOB: maxIOB, + tidepoolLoginRequired: tidepoolLoginRequired, hasOverride: hasOverride, hasTempTarget: hasTempTarget, hasTempProfile: hasTempProfile, @@ -141,4 +143,15 @@ import Testing @Test("Acknowledged release notes fall back to stats") func testAcknowledgedShowsStats() { #expect(resolve(lastGlucoseDate: fresh, hasUnacknowledgedReleaseNotes: false) == .stats) } + + @Test("Tidepool login sits between dosing warnings and release notes") func testTidepoolLoginRequired() { + #expect(resolve(lastGlucoseDate: fresh, tidepoolLoginRequired: true) == .tidepoolLoginRequired) + #expect(resolve( + lastGlucoseDate: fresh, + tidepoolLoginRequired: true, + hasUnacknowledgedReleaseNotes: true + ) == .tidepoolLoginRequired) + #expect(resolve(lastGlucoseDate: fresh, maxIOB: 0, tidepoolLoginRequired: true) == .maxIOBZero) + #expect(resolve(lastGlucoseDate: stale, tidepoolLoginRequired: true) == .cgmStale) + } } diff --git a/TrioTests/DeviceCatalogTests.swift b/TrioTests/DeviceCatalogTests.swift index b7f76c678f..68a6a0989c 100644 --- a/TrioTests/DeviceCatalogTests.swift +++ b/TrioTests/DeviceCatalogTests.swift @@ -1,11 +1,8 @@ -import DanaKit import Foundation import LoopKitUI import MachO import MedtrumKit -import MinimedKit import ObjectiveC -import OmnipodKit import Testing @testable import Trio import UIKit @@ -36,10 +33,7 @@ import UIKit /// LoopKit only exposes `pluginIdentifier` per instance, so the catalog states it; these are persisted values. @Test("Pump entry ids are their manager's persisted plugin identifier") func testPumpIdentifiersMatchManagers() { let expected = [ - "OmniPumpManager": "Omni", - "MinimedPumpManager": "Minimed500", "MedtrumPumpManager": "Medtrum", - "DanaKitPumpManager": "Dana", "MockPumpManager": "MockPumpManager" ] for entry in DeviceCatalog.pumps { @@ -59,20 +53,6 @@ import UIKit // MARK: - Persisted identifier resolution - @Test("Legacy Omnipod identifiers resolve to the universal Omnipod manager") func testLegacyOmnipodIdentifiers() { - // OmniKit persisted "Omnipod" and OmniBLE persisted "Omnipod-DASH" before OmnipodKit unified them. - for identifier in ["Omni", "Omnipod", "Omnipod-DASH"] { - let entry = DeviceCatalog.pumpEntry(forPersistedIdentifier: identifier) - #expect(entry?.name == "Omnipod", "\(identifier) should resolve to Omnipod, got \(entry?.name ?? "nil")") - } - } - - @Test("MiniMed resolves by its real plugin identifier") func testMinimedIdentifier() { - // MinimedPumpManager.pluginIdentifier is "Minimed500", not "Minimed". - #expect(DeviceCatalog.pumpEntry(forPersistedIdentifier: "Minimed500")?.name == "MiniMed") - #expect(DeviceCatalog.pumpEntry(forPersistedIdentifier: "Minimed") == nil) - } - @Test("Unknown identifiers do not resolve") func testUnknownIdentifier() { #expect(DeviceCatalog.pumpEntry(forPersistedIdentifier: "NotARealPump") == nil) } @@ -130,9 +110,9 @@ import UIKit // MARK: - Model sub-lines @Test("Model sub-lines render as a comma-joined list") func testSupportedModelsLine() { - let omnipod = DeviceCatalog.pumps.first { $0.name == "Omnipod" } - #expect(omnipod?.supportedModelsLine == "Classic, DASH, 5") - #expect(omnipod?.hintLine == "Omnipod (Classic, DASH, 5)") + let medtrum = DeviceCatalog.pumps.first { $0.name == "Medtrum Nano" } + #expect(medtrum?.supportedModelsLine == "200U, 300U") + #expect(medtrum?.hintLine == "Medtrum Nano (200U, 300U)") let g5 = DeviceCatalog.cgms.first { $0.name == "Dexcom G5" } #expect(g5?.supportedModelsLine == nil, "An empty model list must collapse the row to one line") @@ -159,7 +139,11 @@ import UIKit "G6SensorKit" // native G6 transport, not wired up in Trio ] - let missingPumps = declared.pumps.subtracting(DeviceCatalog.pumps.map(\.id)) + let excludedPumps: Set = ["Omni", "Minimed500", "Dana"] + + let missingPumps = declared.pumps + .subtracting(DeviceCatalog.pumps.map(\.id)) + .subtracting(excludedPumps) #expect(missingPumps.isEmpty, "Vendored pumps absent from DeviceCatalog: \(missingPumps.sorted())") let missingCGMs = declared.cgms @@ -259,21 +243,9 @@ import UIKit #expect(!offered.contains { $0.manufacturer == .simulator }) } - @Test("Omnipod stays the onboarding fallback") func testFallbackIsOmnipod() { - #expect(DeviceCatalog.defaultOnboardingPump.name == "Omnipod") - // The old code fell back to the DASH bounds when nothing was selected. - // Now the kit's own grid, which excludes 0 for Eros compatibility. - let capability = DeviceCatalog.defaultOnboardingPump.basalCapability - #expect(capability == BasalRateCapability(minimum: 0.05, maximum: 30, step: 0.05)) - } - @Test("Basal bounds come from each kit's own declared grid") func testBasalBounds() { // Derived from each kit's declared grid, with 0 dropped because oref rejects a 0 basal rate. let expected: [String: BasalRateCapability] = [ - "Omnipod": BasalRateCapability(minimum: 0.05, maximum: 30, step: 0.05), - "MiniMed": BasalRateCapability(minimum: 0.05, maximum: 35, step: 0.05), - // Dana delivers in 0.01 steps; Trio previously offered 0.05. - "Dana": BasalRateCapability(minimum: 0.01, maximum: 3, step: 0.01), "Medtrum Nano": BasalRateCapability(minimum: 0.05, maximum: 30, step: 0.05) ] for (name, capability) in expected { @@ -286,9 +258,6 @@ import UIKit // A single min/max/step picker can only represent a uniform grid. If a kit ever ships a banded onboarding // grid (MiniMed's paired gen-23 grid is banded 0.025/0.05/0.1), this fails instead of silently coarsening. let grids: [String: [Double]] = [ - "Omnipod": OmniPumpManager.onboardingSupportedBasalRates, - "MiniMed": MinimedPumpManager.onboardingSupportedBasalRates, - "Dana": DanaKitPumpManager.onboardingSupportedBasalRates, "Medtrum Nano": MedtrumPumpManager.onboardingSupportedBasalRates ] for (name, grid) in grids { @@ -297,19 +266,15 @@ import UIKit } @Test("An upgrading user's paired pump is preselected") func testUpgradePreselection() { - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "Minimed500").name == "MiniMed") - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "Dana").name == "Dana") - // Legacy identifiers from the retired OmniKit / OmniBLE managers. - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "Omnipod-DASH").name == "Omnipod") // The simulator is catalogued but never offered in onboarding, so it must fall back rather than // preselecting an entry the picker has no row for. - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "MockPumpManager").name == "Omnipod") - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "NotAPump").name == "Omnipod") + #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "MockPumpManager").name == "Medtrum Nano") + #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "NotAPump").name == "Medtrum Nano") } @Test("Only tubed pumps report a rewind") func testRewindReporting() { // rewindResetsAutosens is only meaningful for pumps with a reservoir to rewind. - let expected = ["Omnipod": false, "MiniMed": true, "Dana": true, "Medtrum Nano": false] + let expected = ["Medtrum Nano": false] for (name, reports) in expected { let entry = DeviceCatalog.pumps.first { $0.name == name } #expect(entry?.reportsRewindEvents == reports, "\(name) rewind behaviour changed") diff --git a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift new file mode 100644 index 0000000000..2462efe5fb --- /dev/null +++ b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift @@ -0,0 +1,271 @@ +import Combine +import ConnectIQ +import CoreData +import Foundation +import Swinject +import Testing + +@testable import Trio + +/// Overrides the services the Garmin manager reads for watch replies. +final class GarminOverrideAssembly: Assembly { + private let presets: WatchPresetsProvider + + init(presets: WatchPresetsProvider) { + self.presets = presets + } + + func assemble(container: Container) { + container.register(WatchPresetsProvider.self) { [presets] _ in presets } + } +} + +func makeGarminResolver(stack: CoreDataStack, overrides: [Assembly]) -> Resolver { + Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: stack.newTaskContext()) + ] + overrides).resolver +} + +/// Stands in for the Connect IQ SDK, records every message with the exact app it targeted and +/// answers with scripted send results. It has no app-status API, like the production seam. +final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { + struct Sent { + let message: Any + let appUUID: UUID + let deviceUUID: UUID + + var request: String? { (message as? [String: Any])?[WatchMessageKeys.request] as? String } + var isState: Bool { message is [Any] } + } + + private let lock = NSLock() + private var messages: [Sent] = [] + private var scripted: [String: [IQSendMessageResult]] = [:] + private var persistent: [String: IQSendMessageResult] = [:] + + var sent: [Sent] { lock.withLock { messages } } + + /// Consumed in order before falling back to the persistent result, then `.success`. + func script(_ results: [IQSendMessageResult], for appUUID: UUID, on deviceUUID: UUID) { + lock.withLock { scripted["\(appUUID)-\(deviceUUID)", default: []] += results } + } + + func setResult(_ result: IQSendMessageResult?, for appUUID: UUID, on deviceUUID: UUID) { + lock.withLock { persistent["\(appUUID)-\(deviceUUID)"] = result } + } + + func registerDevice(_: IQDevice, delegate _: IQDeviceEventDelegate) {} + + func registerApp(_: IQApp, delegate _: IQAppMessageDelegate) {} + + func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) { + let target = "\(app.uuid!)-\(app.device.uuid!)" + let result: IQSendMessageResult = lock.withLock { + messages.append(Sent(message: message, appUUID: app.uuid, deviceUUID: app.device.uuid)) + if var queue = scripted[target], !queue.isEmpty { + let next = queue.removeFirst() + scripted[target] = queue + return next + } + return persistent[target] ?? .success + } + completion(result) + } +} + +/// Collects the trigger names of the manager's watch state refreshes. +final class RefreshTriggerRecorder: @unchecked Sendable { + private let lock = NSLock() + private var names: [String] = [] + private var subscription: AnyCancellable? + + init(_ manager: BaseGarminManager) { + subscription = manager.stateRefreshTriggers.sink { [weak self] name in + guard let self else { return } + self.lock.withLock { self.names.append(name) } + } + } + + func count(_ name: String) -> Int { + lock.withLock { names.filter { $0 == name }.count } + } +} + +@Suite("Garmin Manager Delivery Tests", .serialized) final class GarminManagerDeliveryTests { + let complicationUUID = GarminWatchface.complication.watchfaceUUID! + let datafieldUUID = GarminDatafield.swissalpine.datafieldUUID! + let deviceA = IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "A")! + let deviceB = IQDevice(id: UUID(), modelName: "Enduro 3", friendlyName: "B")! + + let presets = StubWatchPresetsProvider() + let client = SpyConnectIQClient() + let presetChanges = PassthroughSubject() + let manager: BaseGarminManager + + init() async throws { + let stack = try await CoreDataStack.createForTests() + let resolver = makeGarminResolver(stack: stack, overrides: [GarminOverrideAssembly(presets: presets)]) + + let settings = try #require(resolver.resolve(SettingsManager.self)) + settings.settings.garminSettings.watchface = .complication + settings.settings.garminSettings.isWatchfaceDataEnabled = true + settings.settings.garminSettings.datafield = .swissalpine + + manager = BaseGarminManager( + resolver: resolver, + connectIQClient: client, + presetChanges: presetChanges.eraseToAnyPublisher(), + sendRetryDelay: 0.01, + broadcastDebounce: .milliseconds(10) + ) + manager.updateDeviceList([deviceA, deviceB]) + } + + deinit { + // the device list is persisted in user defaults + manager.updateDeviceList([]) + } + + private func app(_ uuid: UUID, on device: IQDevice) -> IQApp { + IQApp(uuid: uuid, store: UUID(), device: device) + } + + private func broadcast(_ sgv: Int) { + manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": sgv]])) + } + + private func states(to appUUID: UUID? = nil, on device: IQDevice? = nil) -> [SpyConnectIQClient.Sent] { + client.sent.filter { + $0.isState && (appUUID == nil || $0.appUUID == appUUID) && (device == nil || $0.deviceUUID == device?.uuid) + } + } + + private func sent(_ request: String) -> [SpyConnectIQClient.Sent] { + client.sent.filter { $0.request == request } + } + + /// Lets queued main-queue work and debounces run before asserting that nothing more was sent. + private func settle() async { + try? await Task.sleep(nanoseconds: 150_000_000) + } + + // MARK: - Broadcast + + @Test("Each app gets exactly one send per broadcast and an unchanged state is not resent") func testOneSendPerApp() async { + broadcast(120) + #expect(await eventually { self.states().count == 4 }) + + broadcast(120) + await settle() + #expect(states().count == 4) + + broadcast(121) + #expect(await eventually { self.states().count == 8 }) + } + + @Test("A state that failed for good stays owed and goes out with the next broadcast") func testFailedStateIsOwed() async { + client.script([.failure_Timeout, .failure_Timeout], for: complicationUUID, on: deviceA.uuid) + broadcast(120) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceA).count == 2 }) + + broadcast(120) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceA).count == 3 }) + await settle() + #expect(states().count == 6, "Only the owed app is resent an unchanged state") + } + + @Test("AppNotFound is not resent and the app is skipped until it is heard from") func testAppNotFound() async { + client.setResult(.failure_AppNotFound, for: datafieldUUID, on: deviceA.uuid) + broadcast(120) + #expect(await eventually { self.states().count == 4 }) + await settle() + #expect(states(to: datafieldUUID, on: deviceA).count == 1, "No resend for an app that is not installed") + + broadcast(121) + #expect(await eventually { self.states().count == 7 }) + await settle() + #expect(states(to: datafieldUUID, on: deviceA).count == 1) + + client.setResult(nil, for: datafieldUUID, on: deviceA.uuid) + // any message proves the channel; a status request would also start a refresh and blur the count + manager.receivedMessage(["v": 1, "req": "presets"], from: app(datafieldUUID, on: deviceA)) + #expect(await eventually { self.states(to: self.datafieldUUID, on: self.deviceA).count == 2 }) + } + + @Test("A disconnected device gets nothing until it reconnects, then its owed state") func testDisconnectedGate() async { + manager.deviceStatusChanged(deviceA, status: .notConnected) + broadcast(120) + #expect(await eventually { self.states(on: self.deviceB).count == 2 }) + await settle() + #expect(states(on: deviceA).isEmpty, "No SDK traffic toward a disconnected watch") + + manager.deviceCharacteristicsDiscovered(deviceA) + // the reconnect also starts a regular refresh, which may add more sends + #expect(await eventually { self.states(on: self.deviceA).count >= 2 }) + } + + @Test("A message from an owed app delivers its state right away") func testInboundFlushesOwed() async { + client.script([.failure_DeviceIsBusy, .failure_DeviceIsBusy], for: complicationUUID, on: deviceB.uuid) + broadcast(120) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceB).count == 2 }) + + manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: deviceB)) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceB).count == 3 }) + } + + // MARK: - Inbound + + @Test("Status requests from registered apps refresh the state") func testStatusRefreshes() async { + let triggers = RefreshTriggerRecorder(manager) + manager.receivedMessage("status", from: app(complicationUUID, on: deviceA)) + + #expect(await eventually { triggers.count("WatchRequest") == 1 }) + } + + @Test("A message from an unregistered device is ignored") func testUnregisteredDevice() async throws { + let triggers = RefreshTriggerRecorder(manager) + let stranger = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "C")) + manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: stranger)) + + await settle() + #expect(client.sent.isEmpty) + #expect(triggers.count("WatchRequest") == 0) + } + + // MARK: - Presets + + @Test("A preset request is answered only to the requesting app") func testPresetsReplyTargeted() async { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: deviceA)) + + #expect(await eventually { !self.sent("presets").isEmpty }) + await settle() + let replies = sent("presets") + #expect(replies.count == 1) + #expect(replies.first?.appUUID == complicationUUID) + #expect(replies.first?.deviceUUID == deviceA.uuid) + } + + @Test("Preset storage changes push to Complication apps only") func testStorageChangePushesPresets() async { + presetChanges.send(()) + + #expect(await eventually { self.sent("presets").count == 2 }) + #expect(sent("presets").allSatisfy { $0.appUUID == complicationUUID }) + #expect(Set(sent("presets").map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + } + + @Test("A failed preset push stays owed and follows the next delivered message") func testPresetPushOwed() async { + client.script([.failure_Timeout, .failure_Timeout], for: complicationUUID, on: deviceA.uuid) + presetChanges.send(()) + #expect(await eventually { self.sent("presets").filter { $0.deviceUUID == self.deviceA.uuid }.count == 2 }) + + broadcast(120) + #expect(await eventually { self.sent("presets").filter { $0.deviceUUID == self.deviceA.uuid }.count == 3 }) + } +} diff --git a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift similarity index 50% rename from TrioTests/WatchCommandTests/GarminCommandRouterTests.swift rename to TrioTests/WatchCommandTests/GarminMessageRouterTests.swift index d24155ee48..0ed27461b1 100644 --- a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -3,10 +3,22 @@ import Testing @testable import Trio -@Suite("Garmin Command Router Tests") struct GarminCommandRouterTests { +@Suite("Garmin Message Router Tests") struct GarminMessageRouterTests { let requestID = "3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b" let appUUID = UUID() let milliseconds: UInt64 = 1_800_000_000_123 + let presets = StubWatchPresetsProvider() + + private func router( + _ processor: WatchCommandProcessor = SpyWatchCommandProcessor(), + logs: LogCapture? = nil + ) -> GarminMessageRouter { + var router = GarminMessageRouter(processor: processor, loadPresets: { [presets] in try await presets.presets() }) + if let logs { + router.log = logs.logger + } + return router + } private func envelope(command: String, payload: [String: Any]) -> [String: Any] { [ @@ -20,27 +32,29 @@ import Testing } private func parsedCommand(_ message: Any) -> WatchCommand? { - guard case let .command(_, _, _, command) = GarminCommandEnvelope.parse(message) else { return nil } + guard case let .command(_, _, _, command) = GarminMessageEnvelope.parse(message) else { return nil } return command } // MARK: - Parsing @Test("Bare status string is the legacy status request") func testLegacyStatus() { - #expect(GarminCommandEnvelope.parse("status") == .legacyStatus) - #expect(GarminCommandEnvelope.parse("bolus") == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse("status") == .legacyStatus) + #expect(GarminMessageEnvelope.parse("bolus") == .invalid(requestID: nil)) } @Test("v1 status and presets requests carry no other fields") func testReadRequests() { - #expect(GarminCommandEnvelope.parse(["v": 1, "req": "status"]) == .status) - #expect(GarminCommandEnvelope.parse(["v": 1, "req": "presets"]) == .presets) - #expect(GarminCommandEnvelope.parse(["v": 1, "req": "presets", "extra": true]) == .invalid(requestID: nil)) - #expect(GarminCommandEnvelope.parse(["v": 2, "req": "status"]) == .invalid(requestID: nil)) - #expect(GarminCommandEnvelope.parse(["v": "1", "req": "status"]) == .invalid(requestID: nil)) - #expect(GarminCommandEnvelope.parse(["v": true, "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "status"]) == .status) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "presets"]) == .presets) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "presets", "extra": true]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": 2, "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": "1", "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": true, "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "unknown"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(42) == .invalid(requestID: nil)) } - @Test("Every phase-1 command parses with its exact payload") func testCommands() { + @Test("Every command parses with its exact payload") func testCommands() { #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 1.5])) == .bolus(1.5)) #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 2])) == .bolus(2)) #expect(parsedCommand(envelope(command: "carbs", payload: ["carbs": 45])) == .carbs(45)) @@ -60,8 +74,8 @@ import Testing } @Test("The command date is read as Unix milliseconds") func testDate() { - guard case let .command(echoedID, uuid, date, _) = GarminCommandEnvelope.parse( - envelope(command: "cancelOverride", payload: [:]) + guard case let .command(echoedID, uuid, date, _) = GarminMessageEnvelope.parse( + envelope(command: "carbs", payload: ["carbs": 10]) ) else { Issue.record("Expected a command") return @@ -93,51 +107,49 @@ import Testing ] for (command, payload) in cases { #expect( - GarminCommandEnvelope.parse(envelope(command: command, payload: payload)) == .invalid(requestID: requestID), + GarminMessageEnvelope.parse(envelope(command: command, payload: payload)) == .invalid(requestID: requestID), "\(command) with \(payload.keys.sorted())" ) } } @Test("Malformed envelopes are rejected") func testMalformedEnvelopes() { - var extraField = envelope(command: "cancelOverride", payload: [:]) + var extraField = envelope(command: "carbs", payload: ["carbs": 10]) extraField["auth"] = ["scheme": "hmac-sha256"] - #expect(GarminCommandEnvelope.parse(extraField) == .invalid(requestID: requestID)) + #expect(GarminMessageEnvelope.parse(extraField) == .invalid(requestID: requestID)) - var missingDate = envelope(command: "cancelOverride", payload: [:]) + var missingDate = envelope(command: "carbs", payload: ["carbs": 10]) missingDate["date"] = nil - #expect(GarminCommandEnvelope.parse(missingDate) == .invalid(requestID: requestID)) + #expect(GarminMessageEnvelope.parse(missingDate) == .invalid(requestID: requestID)) - var negativeDate = envelope(command: "cancelOverride", payload: [:]) + var negativeDate = envelope(command: "carbs", payload: ["carbs": 10]) negativeDate["date"] = -1 - #expect(GarminCommandEnvelope.parse(negativeDate) == .invalid(requestID: requestID)) + #expect(GarminMessageEnvelope.parse(negativeDate) == .invalid(requestID: requestID)) - var badPayload = envelope(command: "cancelOverride", payload: [:]) + var badPayload = envelope(command: "carbs", payload: ["carbs": 10]) badPayload["payload"] = "none" - #expect(GarminCommandEnvelope.parse(badPayload) == .invalid(requestID: requestID)) + #expect(GarminMessageEnvelope.parse(badPayload) == .invalid(requestID: requestID)) - var wrongVersion = envelope(command: "cancelOverride", payload: [:]) + var wrongVersion = envelope(command: "carbs", payload: ["carbs": 10]) wrongVersion["v"] = 2 - #expect(GarminCommandEnvelope.parse(wrongVersion) == .invalid(requestID: requestID)) + #expect(GarminMessageEnvelope.parse(wrongVersion) == .invalid(requestID: requestID)) } @Test("Request IDs must be RFC 4122 UUIDs") func testRequestIDFormat() { - #expect(GarminCommandEnvelope.rfc4122UUID(requestID) != nil) - #expect(GarminCommandEnvelope.rfc4122UUID(requestID.uppercased()) != nil) + #expect(GarminMessageEnvelope.rfc4122UUID(requestID) != nil) + #expect(GarminMessageEnvelope.rfc4122UUID(requestID.uppercased()) != nil) // variant nibble 'c' is the Microsoft layout, not RFC 4122 - #expect(GarminCommandEnvelope.rfc4122UUID("3f2b8c1e-4a5d-4e6f-ca7b-1c2d3e4f5a6b") == nil) - #expect(GarminCommandEnvelope.rfc4122UUID("3f2b8c1e4a5d4e6f9a7b1c2d3e4f5a6b") == nil) - #expect(GarminCommandEnvelope.rfc4122UUID("not-a-uuid") == nil) + #expect(GarminMessageEnvelope.rfc4122UUID("3f2b8c1e-4a5d-4e6f-ca7b-1c2d3e4f5a6b") == nil) + #expect(GarminMessageEnvelope.rfc4122UUID("3f2b8c1e4a5d4e6f9a7b1c2d3e4f5a6b") == nil) + #expect(GarminMessageEnvelope.rfc4122UUID("not-a-uuid") == nil) } // MARK: - Routing @Test("Status requests refresh the broadcast state and send no reply") func testStatusRouting() async { - let router = GarminCommandRouter(processor: SpyWatchCommandProcessor()) - let messages: [Any] = ["status", ["v": 1, "req": "status"] as [String: Any]] for message in messages { - let outcome = await router.route(message, from: appUUID, isRegistered: true, appName: "test") + let outcome = await router().route(message, from: appUUID, isRegistered: true, appName: "test") #expect(outcome.reply == nil) #expect(outcome.refreshTrigger == "WatchRequest") } @@ -145,9 +157,7 @@ import Testing @Test("Unregistered apps are ignored without executing") func testUnregisteredApp() async { let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - - let outcome = await router.route( + let outcome = await router(processor).route( envelope(command: "carbs", payload: ["carbs": 10]), from: appUUID, isRegistered: false, @@ -159,75 +169,94 @@ import Testing #expect(processor.processed.isEmpty) } - @Test("A command gets exactly the v1 ack and triggers a refresh on success") func testCommandAck() async throws { + @Test("An unregistered command app gets only its commands answered") func testUnregisteredCommandApp() async { let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) + let sut = router(processor) - let outcome = await router.route( + let command = await sut.route( envelope(command: "carbs", payload: ["carbs": 10]), from: appUUID, - isRegistered: true, + isRegistered: false, + isCommandApp: true, + appName: "test" + ) + let presets = await sut.route( + ["v": 1, "req": "presets"], + from: appUUID, + isRegistered: false, + isCommandApp: true, + appName: "test" + ) + let status = await sut.route( + ["v": 1, "req": "status"], + from: appUUID, + isRegistered: false, + isCommandApp: true, appName: "test" ) - let reply = try #require(outcome.reply) - #expect(Set(reply.keys) == ["v", "req", "requestId", "acknowledged", "ackCode", "message"]) - #expect(reply["v"] as? Int == 1) - #expect(reply["req"] as? String == "ack") - #expect(reply["requestId"] as? String == requestID) - #expect(reply["acknowledged"] as? Bool == true) - #expect(reply["ackCode"] as? String == "carbs_logged") - #expect(outcome.refreshTrigger == "WatchCommand") - + #expect(command.reply?[WatchMessageKeys.request] as? String == "ack") #expect(processor.processed.count == 1) - #expect(processor.processed.first?.appUUID == appUUID) - #expect(processor.processed.first?.command == .carbs(10)) + #expect(presets.reply == nil) + #expect(status.refreshTrigger == nil) } - @Test("Failures do not refresh; partial failures do") func testRefreshRules() async { - let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - let message = envelope(command: "mealBolus", payload: ["carbs": 10, "bolus": 1]) + @Test("The command app notice carries the exact keys Trio-WatchControls reads") func testCommandAppNotice() { + let notice = GarminMessageEnvelope.commandAppNotice(isActive: false, commandApp: .complication) - processor.result = .failure("Watch commands are disabled.") - let failed = await router.route(message, from: appUUID, isRegistered: true, appName: "test") - #expect(failed.refreshTrigger == nil) - #expect(failed.reply?["ackCode"] as? String == "failure") + #expect(Set(notice.keys) == ["v", "req", "active", "owner"]) + #expect(notice["req"] as? String == "ownerMode") + #expect(notice["active"] as? Bool == false) + #expect(notice["owner"] as? String == "complication") + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "ownerMode"]) == .invalid(requestID: nil)) + } - processor.result = WatchCommandResult(acknowledged: false, ackCode: .partialFailure, message: "Carbs logged.") - let partial = await router.route(message, from: appUUID, isRegistered: true, appName: "test") - #expect(partial.refreshTrigger == "WatchCommand") - #expect(partial.reply?["ackCode"] as? String == "partial_failure") + @Test("Invalid messages get neither a reply nor a refresh") func testInvalidIgnored() async { + let outcome = await router().route(["v": 1, "req": "bogus"], from: appUUID, isRegistered: true, appName: "test") + + #expect(outcome.reply == nil) + #expect(outcome.refreshTrigger == nil) } - @Test("A preset request gets a targeted presets reply and no broadcast refresh") func testPresetsReply() async throws { + @Test("A preset reply carries the switches and limits the watch reads") func testPresetsReplySwitches() async throws { let processor = SpyWatchCommandProcessor() - processor.presetsResult = WatchCommandPresets( - overridePresets: [OverridePresetWatch(name: "Sport", isEnabled: true)], - tempTargetPresets: [TempTargetPresetWatch(name: "Walk", isEnabled: false)], - capabilities: WatchCommandCapabilities( - isCommandControlEnabled: true, - isBolusCommandEnabled: false, - maxBolus: 5, - maxCarbs: 120, - bolusIncrement: 0.05 - ) + processor.capabilitiesResult = WatchCommandCapabilities( + isCommandControlEnabled: true, + isBolusCommandEnabled: false, + maxBolus: 5, + maxCarbs: 120, + bolusIncrement: 0.05 ) - let router = GarminCommandRouter(processor: processor) - let outcome = await router.route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") + let outcome = await router(processor) + .route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") let reply = try #require(outcome.reply) #expect(Set(reply.keys) == [ "v", "req", "overridePresets", "tempTargetPresets", "isCommandControlEnabled", "isBolusCommandEnabled", "maxBolus", "maxCarbs", "bolusIncrement" ]) + #expect(reply["req"] as? String == "presets") + #expect((reply["overridePresets"] as? [[String: Any]])?.isEmpty == true) + #expect((reply["tempTargetPresets"] as? [[String: Any]])?.isEmpty == true) #expect(reply["isCommandControlEnabled"] as? Bool == true) - #expect(reply["isBolusCommandEnabled"] as? Bool == false) - #expect(reply["maxBolus"] as? Double == 5) #expect(reply["maxCarbs"] as? Double == 120) // a Decimal float literal is not exact, so compare with a tolerance #expect(abs((reply["bolusIncrement"] as? Double ?? 0) - 0.05) < 1E-9) + #expect(outcome.refreshTrigger == nil) + } + + @Test("A preset request gets a targeted presets reply and no broadcast refresh") func testPresetsReply() async throws { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: true, ref: .presetID("sport"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] + + let outcome = await router().route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") + + let reply = try #require(outcome.reply) + #expect(Set(reply.keys) == [ + "v", "req", "overridePresets", "tempTargetPresets", + "isCommandControlEnabled", "isBolusCommandEnabled", "maxBolus", "maxCarbs", "bolusIncrement" + ]) #expect(reply["req"] as? String == "presets") let overrides = try #require(reply["overridePresets"] as? [[String: Any]]) #expect(overrides.first?["name"] as? String == "Sport") @@ -237,12 +266,65 @@ import Testing #expect(outcome.refreshTrigger == nil) } + @Test("A preset load failure is logged by category only") func testPresetErrorSanitized() async { + presets.error = SensitiveTestError() + let logs = LogCapture() + + let outcome = await router(logs: logs) + .route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") + + #expect(outcome.reply == nil) + #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("(unexpected)") }) + #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) + } + + @Test("A command gets exactly the v1 ack and triggers a refresh on success") func testCommandAck() async throws { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + + let outcome = await sut.route( + envelope(command: "carbs", payload: ["carbs": 10]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + + let reply = try #require(outcome.reply) + #expect(Set(reply.keys) == ["v", "req", "requestId", "acknowledged", "ackCode", "message"]) + #expect(reply["v"] as? Int == 1) + #expect(reply["req"] as? String == "ack") + #expect(reply["requestId"] as? String == requestID) + #expect(reply["acknowledged"] as? Bool == true) + #expect(reply["ackCode"] as? String == "carbs_logged") + #expect(outcome.refreshTrigger == "WatchCommand") + + #expect(processor.processed.count == 1) + #expect(processor.processed.first?.appUUID == appUUID) + #expect(processor.processed.first?.command == .carbs(10)) + } + + @Test("Failures do not refresh; partial failures do") func testRefreshRules() async { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + let message = envelope(command: "mealBolus", payload: ["carbs": 10, "bolus": 1]) + + processor.result = .failure("Watch commands are disabled.") + let failed = await sut.route(message, from: appUUID, isRegistered: true, appName: "test") + #expect(failed.refreshTrigger == nil) + #expect(failed.reply?["ackCode"] as? String == "failure") + + processor.result = WatchCommandResult(acknowledged: false, ackCode: .partialFailure, message: "Carbs logged.") + let partial = await sut.route(message, from: appUUID, isRegistered: true, appName: "test") + #expect(partial.refreshTrigger == "WatchCommand") + #expect(partial.reply?["ackCode"] as? String == "partial_failure") + } + @Test("Only successful preset commands push the preset list") func testPresetPushRules() async { let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) + let sut = router(processor) processor.result = .success(.overrideStarted, "Override started.") - let started = await router.route( + let started = await sut.route( envelope(command: "activateOverride", payload: ["name": "Sport"]), from: appUUID, isRegistered: true, @@ -251,7 +333,7 @@ import Testing #expect(started.pushesPresets) processor.result = .success(.tempTargetStopped, "Temp target stopped.") - let stopped = await router.route( + let stopped = await sut.route( envelope(command: "cancelTempTarget", payload: [:]), from: appUUID, isRegistered: true, @@ -260,7 +342,7 @@ import Testing #expect(stopped.pushesPresets) processor.result = .failure("Override preset not found.") - let failed = await router.route( + let failed = await sut.route( envelope(command: "activateOverride", payload: ["name": "Sport"]), from: appUUID, isRegistered: true, @@ -269,7 +351,7 @@ import Testing #expect(failed.pushesPresets == false) processor.result = .success(.carbsLogged, "Carbs logged.") - let carbs = await router.route( + let carbs = await sut.route( envelope(command: "carbs", payload: ["carbs": 10]), from: appUUID, isRegistered: true, @@ -278,25 +360,11 @@ import Testing #expect(carbs.pushesPresets == false) } - @Test("A preset load failure is logged by category only") func testPresetErrorSanitized() async { - let processor = SpyWatchCommandProcessor() - processor.presetsError = SensitiveTestError() - let logs = LogCapture() - var router = GarminCommandRouter(processor: processor) - router.log = logs.record - - let outcome = await router.route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") - - #expect(outcome.reply == nil) - #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("(unexpected)") }) - #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) - } - @Test("Invalid commands are answered only when they name a request ID") func testInvalidReplies() async { let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) + let sut = router(processor) - let named = await router.route( + let named = await sut.route( envelope(command: "carbs", payload: ["carbs": "ten"]), from: appUUID, isRegistered: true, @@ -306,10 +374,33 @@ import Testing #expect(named.reply?["acknowledged"] as? Bool == false) #expect(named.refreshTrigger == nil) - let anonymous = await router.route(["v": 1, "req": "command"], from: appUUID, isRegistered: true, appName: "test") + let anonymous = await sut.route(["v": 1, "req": "command"], from: appUUID, isRegistered: true, appName: "test") #expect(anonymous.reply == nil) #expect(processor.processed.isEmpty) } + + // MARK: - Presets + + @Test("Presets are sorted by name and keep their state") func testPresetsSorted() async throws { + presets.overrides = [ + WatchPresetEntry(name: "b", isActive: false, ref: .presetID("b")), + WatchPresetEntry(name: "A", isActive: true, ref: .presetID("A")), + WatchPresetEntry(name: "c", isActive: false, ref: .presetID("c")) + ] + presets.tempTargets = [ + WatchPresetEntry(name: "Walk 10", isActive: false, ref: .presetID("10")), + WatchPresetEntry(name: "Walk 9", isActive: false, ref: .presetID("9")) + ] + + let sorted = try await presets.presets() + + #expect(sorted.overridePresets == [ + OverridePresetWatch(name: "A", isEnabled: true), + OverridePresetWatch(name: "b", isEnabled: false), + OverridePresetWatch(name: "c", isEnabled: false) + ]) + #expect(sorted.tempTargetPresets.map(\.name) == ["Walk 9", "Walk 10"]) + } } @Suite("Garmin Command Settings Migration Tests") struct GarminCommandSettingsMigrationTests { @@ -335,14 +426,85 @@ import Testing @Test("GarminWatchSettings without command keys keeps its other values") func testGroupedSettingsMigration() throws { let json = - #"{"watchface": "swissalpine", "datafields": ["loopgraph"], "primaryAttributeChoice": "isf", "secondaryAttributeChoice": "eventualBG", "isWatchfaceDataEnabled": true}"# + #"{"watchface": "swissalpine", "datafield": "swissalpine", "primaryAttributeChoice": "isf", "secondaryAttributeChoice": "eventualBG", "isWatchfaceDataEnabled": true}"# let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) #expect(settings.watchface == .swissalpine) - #expect(settings.datafields == [.loopgraph]) + #expect(settings.datafield == .swissalpine) #expect(settings.primaryAttributeChoice == .isf) #expect(settings.isWatchfaceDataEnabled == true) #expect(settings.isCommandControlEnabled == false) #expect(settings.isBolusCommandEnabled == false) } + + @Test("Legacy singular none decodes to no datafield") func testLegacyDatafieldNoneDecodesNone() throws { + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{"datafield": "none"}"#.utf8)) + + #expect(settings.datafield == GarminDatafield.none) + #expect(settings.isCommandControlEnabled == false) + #expect(settings.isBolusCommandEnabled == false) + } + + @Test("A plural datafields array migrates to its first valid entry") func testPluralDatafieldsMigratesToFirstValid() throws { + let json = #"{"datafields": ["not-a-datafield", "none", "loopgraph", "trio"], "datafield": "swissalpine"}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) + + #expect(settings.datafield == .loopgraph) + } + + @Test( + "An explicit empty plural datafields array migrates to no datafield" + ) func testExplicitEmptyPluralDatafieldsMigratesToNone() throws { + let json = #"{"datafields": [], "datafield": "swissalpine"}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) + + #expect(settings.datafield == GarminDatafield.none) + } + + @Test( + "Missing or invalid datafield keys fall back to the Trio default" + ) func testMissingOrInvalidDatafieldKeysFallBackToDefault() throws { + let missing = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{}"#.utf8)) + let invalidPlural = try JSONDecoder() + .decode(GarminWatchSettings.self, from: Data(#"{"datafields": "not-an-array"}"#.utf8)) + let unknownPluralEntries = try JSONDecoder() + .decode(GarminWatchSettings.self, from: Data(#"{"datafields": ["not-a-datafield", 3]}"#.utf8)) + let invalidSingular = try JSONDecoder() + .decode(GarminWatchSettings.self, from: Data(#"{"datafield": "not-a-datafield"}"#.utf8)) + + #expect(missing.datafield == .trio) + #expect(invalidPlural.datafield == .trio) + #expect(unknownPluralEntries.datafield == .trio) + #expect(invalidSingular.datafield == .trio) + } + + @Test( + "An unusable plural datafields falls back to a valid legacy singular" + ) func testUnusablePluralDatafieldsFallsBackToLegacySingular() throws { + let invalidPluralJSON = #"{"datafields": "not-an-array", "datafield": "swissalpine"}"# + let unknownPluralJSON = #"{"datafields": ["not-a-datafield"], "datafield": "swissalpine"}"# + let invalidPlural = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(invalidPluralJSON.utf8)) + let unknownPluralEntries = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(unknownPluralJSON.utf8)) + + #expect(invalidPlural.datafield == .swissalpine) + #expect(unknownPluralEntries.datafield == .swissalpine) + } + + @Test("TrioSettings migrates the plural garminDatafields array") func testTrioSettingsMigratesPluralDatafields() throws { + let decoder = JSONDecoder() + let plural = try decoder + .decode(TrioSettings.self, from: Data(#"{"garminDatafields": ["none", "swissalpine", "trio"]}"#.utf8)) + let empty = try decoder + .decode(TrioSettings.self, from: Data(#"{"garminDatafields": [], "garminDatafield": "trio"}"#.utf8)) + let singular = try decoder.decode(TrioSettings.self, from: Data(#"{"garminDatafield": "loopgraph"}"#.utf8)) + let invalid = try decoder + .decode(TrioSettings.self, from: Data(#"{"garminDatafields": ["not-a-datafield"]}"#.utf8)) + let missing = try decoder.decode(TrioSettings.self, from: Data(#"{}"#.utf8)) + + #expect(plural.garminSettings.datafield == .swissalpine) + #expect(empty.garminSettings.datafield == GarminDatafield.none) + #expect(singular.garminSettings.datafield == .loopgraph) + #expect(invalid.garminSettings.datafield == GarminDatafield.none) + #expect(missing.garminSettings.datafield == GarminDatafield.none) + } } diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index d1238ff382..49ac647769 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -50,6 +50,7 @@ private final class WatchCommandOverrideAssembly: Assembly { private let validator: BolusSafetyValidator private let processor: WatchCommandProcessor? private let apsManager: APSManager? + private let presets: WatchPresetsProvider private let adjustmentContext: (() -> NSManagedObjectContext)? init( @@ -57,12 +58,14 @@ private final class WatchCommandOverrideAssembly: Assembly { validator: BolusSafetyValidator, processor: WatchCommandProcessor?, apsManager: APSManager?, + presets: WatchPresetsProvider, adjustmentContext: (() -> NSManagedObjectContext)? ) { self.carbsContext = carbsContext self.validator = validator self.processor = processor self.apsManager = apsManager + self.presets = presets self.adjustmentContext = adjustmentContext } @@ -71,6 +74,7 @@ private final class WatchCommandOverrideAssembly: Assembly { BaseCarbsStorage(resolver: r, contextProvider: carbsContext) }.inObjectScope(.container) container.register(BolusSafetyValidator.self) { [validator] _ in validator } + container.register(WatchPresetsProvider.self) { [presets] _ in presets } if let processor { container.register(WatchCommandProcessor.self) { _ in processor }.inObjectScope(.container) } @@ -91,6 +95,7 @@ private func makeResolver( validator: BolusSafetyValidator = StubBolusSafetyValidator(), processor: WatchCommandProcessor? = nil, apsManager: APSManager? = nil, + presets: WatchPresetsProvider = StubWatchPresetsProvider(), adjustmentContext: (() -> NSManagedObjectContext)? = nil ) -> Resolver { Assembler([ @@ -106,9 +111,11 @@ private func makeResolver( validator: validator, processor: processor, apsManager: apsManager, + presets: presets, adjustmentContext: adjustmentContext ) - ]).resolver + // same scope as the app, so the test and the managers share one settings manager + ], parent: nil, defaultObjectScope: .container).resolver } // MARK: - Carb Persistence @@ -166,9 +173,9 @@ private func makeResolver( let processor = BaseWatchCommandProcessor( resolver: resolver, actions: BaseWatchCommandActions(resolver: resolver), - log: logs.record + log: logs.logger ) - let appUUID = UUID() + let appUUID = try #require(GarminCommandApp(settings.settings.garminSettings).appUUID) let carbs = await processor.process( WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: .carbs(40)) @@ -190,61 +197,14 @@ private func makeResolver( // MARK: - Garmin Manager -final class InstalledAppStatus: IQAppStatus { - override var isInstalled: Bool { true } -} - -/// Stands in for the Connect IQ SDK and records every message with the exact app it targeted. -final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { - struct Sent { - let message: Any - let appUUID: UUID - let deviceUUID: UUID - - var request: String? { (message as? [String: Any])?[WatchMessageKeys.request] as? String } - } - - private let lock = NSLock() - private var messages: [Sent] = [] - /// Remaining failures per app UUID + device UUID, to exercise the retry. - private var failures: [String: Int] = [:] - - var sent: [Sent] { lock.withLock { messages } } - - func failNextSend(to appUUID: UUID, on deviceUUID: UUID) { - lock.withLock { failures["\(appUUID)-\(deviceUUID)", default: 0] += 1 } - } - - func registerDevice(_: IQDevice, delegate _: IQDeviceEventDelegate) {} - - func registerApp(_: IQApp, delegate _: IQAppMessageDelegate) {} - - func appStatus(of _: IQApp, completion: @escaping (IQAppStatus?) -> Void) { - completion(InstalledAppStatus()) - } - - func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) { - let target = "\(app.uuid!)-\(app.device.uuid!)" - let fails: Bool = lock.withLock { - messages.append(Sent(message: message, appUUID: app.uuid, deviceUUID: app.device.uuid)) - guard let remaining = failures[target], remaining > 0 else { return false } - failures[target] = remaining - 1 - return true - } - // raw value 1 is IQSendMessageResult_Failure_Unknown - completion(fails ? IQSendMessageResult(rawValue: 1)! : .success) - } -} - @Suite("Garmin Manager Command Integration Tests", .serialized) final class GarminManagerCommandIntegrationTests { let complicationUUID = GarminWatchface.complication.watchfaceUUID! - let datafieldUUID = GarminDatafield.loopgraph.datafieldUUID! + let datafieldUUID = GarminDatafield.swissalpine.datafieldUUID! let deviceA = IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "A")! let deviceB = IQDevice(id: UUID(), modelName: "Enduro 3", friendlyName: "B")! let processor: SpyWatchCommandProcessor let client: SpyConnectIQClient - let presetChanges: PassthroughSubject let settings: SettingsManager let authorization: WatchCommandAuthorization let manager: BaseGarminManager @@ -253,25 +213,26 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { let stack = try await CoreDataStack.createForTests() let processor = SpyWatchCommandProcessor() let client = SpyConnectIQClient() - let presetChanges = PassthroughSubject() let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }, processor: processor) let settings = try #require(resolver.resolve(SettingsManager.self)) settings.settings.garminSettings.watchface = .complication settings.settings.garminSettings.isWatchfaceDataEnabled = true - settings.settings.garminSettings.datafields = [.loopgraph] + settings.settings.garminSettings.datafield = .swissalpine settings.settings.garminSettings.isCommandControlEnabled = true + // settings persist on the simulator between runs, so start from a known state + settings.settings.garminSettings.isBolusCommandEnabled = false + settings.settings.maxCarbs = 100 self.processor = processor self.client = client - self.presetChanges = presetChanges self.settings = settings authorization = try #require(resolver.resolve(WatchCommandAuthorization.self)) manager = BaseGarminManager( resolver: resolver, connectIQClient: client, - presetChanges: presetChanges.eraseToAnyPublisher(), - sendRetryDelay: 0.01 + sendRetryDelay: 0.01, + broadcastDebounce: .milliseconds(10) ) manager.updateDeviceList([deviceA, deviceB]) } @@ -313,25 +274,14 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { #expect(acks.allSatisfy { $0.appUUID == complicationUUID && $0.deviceUUID == deviceB.uuid }) #expect(acks.first.flatMap { ($0.message as? [String: Any])?["requestId"] as? String } == requestID) #expect(processor.processed.map(\.appUUID) == [complicationUUID]) - #expect(client.sent.allSatisfy { $0.appUUID != datafieldUUID }, "Nothing command-related is fanned out") - } - - @Test("A preset request is answered only to the requesting app") func testPresetsReplyTargeted() async { - processor.presetsResult = WatchCommandPresets( - overridePresets: [OverridePresetWatch(name: "Sport", isEnabled: false)], - tempTargetPresets: [] + #expect( + client.sent.filter { !$0.isState }.allSatisfy { $0.appUUID != datafieldUUID }, + "Nothing command-related is fanned out" ) - manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: deviceA)) - - #expect(await eventually { !self.sent("presets").isEmpty }) - let replies = sent("presets") - #expect(replies.count == 1) - #expect(replies.first?.appUUID == complicationUUID) - #expect(replies.first?.deviceUUID == deviceA.uuid) } - @Test("A failed ack is resent to the same app") func testAckRetryTarget() async { - client.failNextSend(to: complicationUUID, on: deviceA.uuid) + @Test("A failed ack is resent once to the same app and never owed") func testAckRetryTarget() async { + client.script([.failure_Timeout, .failure_Timeout], for: complicationUUID, on: deviceA.uuid) let requestID = UUID().uuidString manager.receivedMessage( command("carbs", payload: ["carbs": 20], requestID: requestID), @@ -342,9 +292,15 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { let acks = sent("ack") #expect(acks.allSatisfy { $0.appUUID == complicationUUID && $0.deviceUUID == deviceA.uuid }) #expect(acks.allSatisfy { ($0.message as? [String: Any])?["requestId"] as? String == requestID }) + + // the watch expires an unanswered command itself, so later deliveries carry no stale ack + manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": 120]])) + #expect(await eventually { self.client.sent.contains { $0.isState } }) + try? await Task.sleep(nanoseconds: 150_000_000) + #expect(sent("ack").count == 2) } - @Test("A started preset pushes the list to every Complication app, never to datafields") func testCommandPushesPresets() async { + @Test("A started preset pushes the list to every Complication app, never to a datafield") func testCommandPushesPresets() async { processor.result = .success(.overrideStarted, "Override started.") manager.receivedMessage( command("activateOverride", payload: ["name": "Sport"], requestID: UUID().uuidString), @@ -356,19 +312,12 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { #expect(Set(pushes.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) #expect(pushes.allSatisfy { $0.appUUID == complicationUUID }) #expect(sent("ack").map(\.deviceUUID) == [deviceA.uuid]) - #expect(client.sent.allSatisfy { $0.appUUID != datafieldUUID }) - } - - @Test("Preset storage changes push to Complication apps only") func testStorageChangePushesPresets() async { - presetChanges.send(()) - - #expect(await eventually { self.sent("presets").count == 2 }) - #expect(sent("presets").allSatisfy { $0.appUUID == complicationUUID }) - #expect(Set(sent("presets").map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) - #expect(processor.processed.isEmpty) + #expect(client.sent.filter { !$0.isState }.allSatisfy { $0.appUUID != datafieldUUID }) } - @Test("Command switch and max carbs changes push presets to Complication apps only") func testSettingsPushPresets() async { + @Test( + "Command switch and max carbs changes push the preset reply to Complication apps only" + ) func testSettingsPushPresets() async { settings.settings.garminSettings.isBolusCommandEnabled = true #expect(await eventually { self.sent("presets").count == 2 }) @@ -379,10 +328,10 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { settings.settings.garminSettings.primaryAttributeChoice = .isf try? await Task.sleep(nanoseconds: 200_000_000) - #expect(sent("presets").count == 4, "Display settings do not push presets") + #expect(sent("presets").count == 4, "Display settings do not push the preset reply") } - @Test("A message from an unregistered device is ignored") func testUnregisteredDevice() async throws { + @Test("A command from an unregistered device is ignored") func testUnregisteredDevice() async throws { let stranger = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "C")) manager.receivedMessage( command("carbs", payload: ["carbs": 20], requestID: UUID().uuidString), @@ -390,7 +339,10 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { ) try await Task.sleep(nanoseconds: 200_000_000) - #expect(client.sent.isEmpty) + // Registration notices to the known devices are unrelated traffic; what this guards is that + // the stranger itself is answered with nothing at all. + #expect(!client.sent.contains { $0.deviceUUID == stranger.uuid }) + #expect(sent("ack").isEmpty) #expect(processor.processed.isEmpty) } @@ -413,23 +365,126 @@ final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { #expect(authorization.current == start + 1, "Display settings leave commands alone") #expect(triggers.count("CommandSettings") == refreshes) } -} -/// Collects the trigger names of the manager's watch state refreshes. -final class RefreshTriggerRecorder: @unchecked Sendable { - private let lock = NSLock() - private var names: [String] = [] - private var subscription: AnyCancellable? + // MARK: - Command App - init(_ manager: BaseGarminManager) { - subscription = manager.stateRefreshTriggers.sink { [weak self] name in - guard let self else { return } - self.lock.withLock { self.names.append(name) } - } + private var watchControlsUUID: UUID { GarminWatchControls.appUUID! } + + private func notice(_ sent: SpyConnectIQClient.Sent) -> (active: Bool?, owner: String?) { + let message = sent.message as? [String: Any] + return (message?["active"] as? Bool, message?["owner"] as? String) + } + + /// Both command apps receive ownerMode notices, so assertions address one app's stream rather + /// than counting all of them: the Complication is told it is active at registration and told to + /// stand down whenever another app takes over. + private func notices(for appUUID: UUID) -> [SpyConnectIQClient.Sent] { + sent("ownerMode").filter { $0.appUUID == appUUID } + } + + private func notices(for appUUID: UUID, on device: IQDevice) -> [SpyConnectIQClient.Sent] { + sent("ownerMode").filter { $0.appUUID == appUUID && $0.deviceUUID == device.uuid } } - func count(_ name: String) -> Int { - lock.withLock { names.filter { $0 == name }.count } + @Test("Selecting another watchface hands commands to Trio-WatchControls on every device") func testHandOver() async { + let triggers = RefreshTriggerRecorder(manager) + let start = authorization.current + + settings.settings.garminSettings.watchface = .trio + + #expect(authorization.current == start + 1, "Pending Complication commands are revoked") + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) + let activations = notices(for: watchControlsUUID) + #expect(Set(activations.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(activations.allSatisfy { self.notice($0) == (true, "watchControls") }) + #expect( + await eventually { self.notices(for: self.complicationUUID).suffix(2) + .allSatisfy { self.notice($0) == (false, "watchControls") } + }, + "The Complication is told who took over" + ) + #expect(await eventually { self.sent("presets").count == 2 }) + #expect(sent("presets").allSatisfy { $0.appUUID == self.watchControlsUUID }) + #expect(await eventually { triggers.count("CommandSettings") >= 1 }, "The new command app gets state right away") + } + + @Test("Selecting Complication again tells Trio-WatchControls to stand down") func testStandDown() async { + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) + + settings.settings.garminSettings.watchface = .complication + + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 4 }) + let standDowns = notices(for: watchControlsUUID).suffix(2) + #expect(Set(standDowns.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(standDowns.allSatisfy { self.notice($0) == (false, "complication") }) + } + + @Test("Switching commands off tells Trio-WatchControls that commands are off") func testCommandsOffNotice() async { + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) + + settings.settings.garminSettings.isCommandControlEnabled = false + + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 4 }) + #expect(notices(for: watchControlsUUID).suffix(2).allSatisfy { self.notice($0) == (false, "none") }) + } + + @Test( + "Picking Complication while commands are off tells the deregistered Trio-WatchControls who owns them" + ) func testOwnerChangeWhileDeregistered() async { + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) + settings.settings.garminSettings.isCommandControlEnabled = false + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 4 }) + + settings.settings.garminSettings.watchface = .complication + + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 6 }) + let standDowns = notices(for: watchControlsUUID).suffix(2) + #expect(Set(standDowns.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(standDowns.allSatisfy { self.notice($0) == (false, "complication") }) + } + + @Test( + "An inactive Trio-WatchControls is told again when it sends anything, and its commands are answered" + ) func testInactiveAppContact() async { + manager.receivedMessage(["v": 1, "req": "presets"], from: app(watchControlsUUID, on: deviceA)) + + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 1 }) + let first = notices(for: watchControlsUUID)[0] + #expect(first.deviceUUID == deviceA.uuid) + #expect(notice(first) == (false, "complication")) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(sent("presets").isEmpty, "An inactive app gets no preset list") + + manager.receivedMessage( + command("carbs", payload: ["carbs": 20], requestID: UUID().uuidString), + from: app(watchControlsUUID, on: deviceA) + ) + #expect(await eventually { self.sent("ack").count == 1 }) + #expect(sent("ack")[0].appUUID == watchControlsUUID) + #expect(processor.processed.map(\.appUUID) == [watchControlsUUID], "The processor refuses it, not silence") + } + + @Test("A failed activation is owed, a failed stand-down is not") func testNoticeDelivery() async { + client.setResult(.failure_Timeout, for: watchControlsUUID, on: deviceA.uuid) + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.notices(for: self.watchControlsUUID, on: self.deviceA).count == 2 }) + client.setResult(nil, for: watchControlsUUID, on: deviceA.uuid) + + manager.receivedMessage(["v": 1, "req": "presets"], from: app(watchControlsUUID, on: deviceA)) + #expect(await eventually { self.notices(for: self.watchControlsUUID, on: self.deviceA).count == 3 }) + #expect(notice(notices(for: watchControlsUUID, on: deviceA).last!) == (true, "watchControls")) + + client.setResult(.failure_Timeout, for: watchControlsUUID, on: deviceB.uuid) + settings.settings.garminSettings.watchface = .complication + #expect(await eventually { self.notices(for: self.watchControlsUUID, on: self.deviceB).count == 3 }) + client.setResult(nil, for: watchControlsUUID, on: deviceB.uuid) + manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": 120]])) + #expect(await eventually { self.client.sent.contains { $0.isState } }) + try? await Task.sleep(nanoseconds: 150_000_000) + #expect(notices(for: watchControlsUUID, on: deviceB).count == 3) } } @@ -462,8 +517,9 @@ final class RefreshTriggerRecorder: @unchecked Sendable { settings.settings.isGarminBolusCommandEnabled = true } - private func request(_ command: WatchCommand) -> WatchCommandRequest { - WatchCommandRequest(requestID: UUID(), appUUID: UUID(), date: Date(), command: command) + private func request(_ command: WatchCommand, from settings: SettingsManager) -> WatchCommandRequest { + let appUUID = GarminCommandApp(settings.settings.garminSettings).appUUID ?? UUID() + return WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: command) } // MARK: R1 @@ -494,8 +550,8 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let manager = BaseGarminManager( resolver: resolver, connectIQClient: client, - presetChanges: Empty().eraseToAnyPublisher(), - sendRetryDelay: 0.01 + sendRetryDelay: 0.01, + broadcastDebounce: .milliseconds(10) ) manager.updateDeviceList([device]) defer { manager.updateDeviceList([]) } @@ -550,9 +606,11 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let stack = self.stack let context = countingContext() let transactions = TestGate(isOpen: true) + let presets = StubWatchPresetsProvider() let resolver = makeResolver( stack: stack, carbsContext: { stack.newTaskContext() }, + presets: presets, adjustmentContext: { transactions.arrive() return context @@ -594,14 +652,13 @@ final class RefreshTriggerRecorder: @unchecked Sendable { ) } - let actions = StubWatchCommandActions() - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID(overrideID))] - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID(tempTargetID))] - let processor = BaseWatchCommandProcessor(resolver: resolver, actions: actions) + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID(overrideID))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID(tempTargetID))] + let processor = BaseWatchCommandProcessor(resolver: resolver, actions: StubWatchCommandActions()) let blocker = ContextQueueBlocker() blocker.block(context) - let pending = Task { await processor.process(request(command)) } + let pending = Task { await processor.process(request(command, from: settings)) } // past every processor check and inside the adjustment manager's transaction await transactions.waitForArrivals() @@ -626,7 +683,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { #expect(enabled == ["Hike", "Running"]) // the same setup commits once nothing is revoked - #expect(await processor.process(request(command)).acknowledged) + #expect(await processor.process(request(command, from: settings)).acknowledged) #expect(context.saveCount == 1) } @@ -652,7 +709,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let blocker = ContextQueueBlocker() blocker.block(context) let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 2) : .carbs(40) - let pending = Task { await processor.process(request(command)) } + let pending = Task { await processor.process(request(command, from: settings)) } await transactions.waitForArrivals() settings.settings.isGarminCommandControlEnabled = false @@ -665,7 +722,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { #expect(try await carbRows() == 0) #expect(pump.bolusRequests.isEmpty) - #expect(await processor.process(request(.carbs(40))).acknowledged) + #expect(await processor.process(request(.carbs(40), from: settings)).acknowledged) #expect(try await carbRows() == 1) } @@ -682,7 +739,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let checks = LogCapture() let delivered = try await actions.enactBolus(2) { - checks.record(.debug, pump.bolusRequests.isEmpty ? "before" : "after") + checks.record(pump.bolusRequests.isEmpty ? "before" : "after") } #expect(delivered) #expect(checks.lines == ["before"]) diff --git a/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift b/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift new file mode 100644 index 0000000000..0be54a499b --- /dev/null +++ b/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift @@ -0,0 +1,258 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Watch Command Pipeline Tests") struct WatchCommandPipelineTests { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) + let appUUID = UUID() + let authorization = WatchCommandAuthorization() + let cache = WatchCommandRequestCache() + let logs = LogCapture() + let executions = ExecutionCounter() + let done = WatchCommandResult.success(.genericSuccess, "Done.") + let pipeline: WatchCommandPipeline + + init() { + pipeline = WatchCommandPipeline( + authorization: authorization, + cache: cache, + now: { [clock] in clock.now }, + log: logs.logger + ) + } + + private func run( + _ command: String = "carbs:10", + id: UUID = UUID(), + app: UUID? = nil, + age: TimeInterval = 5, + on pipeline: WatchCommandPipeline? = nil, + gate: @escaping @Sendable() -> WatchCommandResult? = { nil }, + execute: ((WatchCommandContext) async -> WatchCommandResult)? = nil + ) async -> WatchCommandResult { + await (pipeline ?? self.pipeline).run( + requestID: id, + appUUID: app ?? appUUID, + issuedAt: now.addingTimeInterval(-age), + command: command, + name: "carbs", + gate: gate + ) { context in + executions.increment() + guard let execute else { return done } + return await execute(context) + } + } + + /// Executes like a side effect: parks on `gate`, then runs the final check right before writing. + private func guardedWrite( + parkingOn gate: TestGate, + checkingExpiry: Bool = false + ) -> (WatchCommandContext) async -> WatchCommandResult { + { [done] context in + await gate.pass() + do { + try context.finalCheck(checkingExpiry: checkingExpiry)() + return done + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch { + return .failure("unexpected") + } + } + } + + // MARK: - Registration + + @Test("Pipeline and authorization are registered once for the whole app") func testContainerScoped() { + let resolver = TrioApp().resolver + #expect(resolver.resolve(WatchCommandPipeline.self) === resolver.resolve(WatchCommandPipeline.self)) + #expect(resolver.resolve(WatchCommandAuthorization.self) === resolver.resolve(WatchCommandAuthorization.self)) + } + + // MARK: - Freshness + + @Test( + "Timestamps are accepted from 2 minutes old to 60 seconds ahead", + arguments: [ + (TimeInterval(120), true), + (TimeInterval(120.001), false), + (TimeInterval(-60), true), + (TimeInterval(-60.001), false), + (TimeInterval(0), true) + ] + ) func testFreshnessWindow(age: TimeInterval, accepted: Bool) async { + let result = await run(age: age) + + #expect(result.acknowledged == accepted) + #expect(executions.value == (accepted ? 1 : 0)) + } + + @Test("Expiry is re-checked only where the side effect asks for it") func testExpiryRecheckIsOptIn() async { + let gate = TestGate() + let plain = Task { await run(age: 100, execute: guardedWrite(parkingOn: gate)) } + let strict = Task { await run(age: 100, execute: guardedWrite(parkingOn: gate, checkingExpiry: true)) } + await gate.waitForArrivals(2) + clock.advance(by: 30) + gate.open() + + #expect(await plain.value.acknowledged) + #expect(await strict.value.message == "Command expired. Please try again.") + } + + // MARK: - Gates & Revocation + + @Test("A closed gate rejects before execution") func testGateRejects() async { + let result = await run(gate: { .failure("Off.") }) + + #expect(result == .failure("Off.")) + #expect(executions.value == 0) + } + + @Test("The gate is re-read at the final check") func testGateRereadAtFinalCheck() async { + let isOff = TestFlag() + let gate = TestGate() + let pending = Task { await run(gate: { isOff.value ? .failure("Off.") : nil }, execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + isOff.set() + gate.open() + + #expect(await pending.value == .failure("Off.")) + } + + @Test("A revocation while pending fails the final check, even if switched back on") func testRevokedWhilePending() async { + let gate = TestGate() + let pending = Task { await run(execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + authorization.revoke() + gate.open() + + let result = await pending.value + #expect(!result.acknowledged) + #expect(result.message == "Watch command settings changed.") + #expect(await run().acknowledged, "A command admitted after the revocation runs") + } + + // MARK: - Deduplication + + @Test("A completed duplicate replays its result without executing again") func testCompletedDuplicate() async { + let id = UUID() + let first = await run(id: id) + let second = await run(id: id) + + #expect(first == second) + #expect(executions.value == 1) + } + + @Test("Rejections are cached as terminal results too") func testRejectionCached() async { + let id = UUID() + let first = await run(id: id, gate: { .failure("Off.") }) + let second = await run(id: id) + + #expect(first == second) + #expect(executions.value == 0) + } + + @Test("A duplicate of an in-flight request is rejected with the in-progress code") func testInFlightDuplicate() async { + let gate = TestGate() + let id = UUID() + let pending = Task { await run(id: id, execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + + let duplicate = await run(id: id) + gate.open() + + #expect(!duplicate.acknowledged) + #expect(duplicate.ackCode == .inProgress, "Watches tell a running duplicate apart by code, not by text") + #expect(await pending.value.acknowledged) + #expect(executions.value == 1) + } + + @Test("Reusing a request ID for another command is rejected") func testConflict() async { + let id = UUID() + _ = await run("carbs:10", id: id) + let result = await run("carbs:20", id: id) + + #expect(!result.acknowledged) + #expect(executions.value == 1) + } + + @Test("Request IDs are scoped per app") func testScopedPerApp() async { + let id = UUID() + _ = await run(id: id) + _ = await run(id: id, app: UUID()) + + #expect(executions.value == 2) + } + + @Test("A cache full of running requests rejects instead of evicting one") func testFull() async { + let small = WatchCommandPipeline( + authorization: authorization, + cache: WatchCommandRequestCache(capacity: 1), + now: { [clock] in clock.now } + ) + let gate = TestGate() + let pending = Task { await run(on: small, execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + + let rejected = await run(on: small) + gate.open() + + #expect(rejected.message == "Too many requests in progress. Please try again.") + #expect(await pending.value.acknowledged) + } + + @Test("An expired request replays its cached result without re-running") func testExpiredReplay() async { + let id = UUID() + let first = await run(id: id, age: WatchCommandPipeline.maximumCommandAge - 5) + clock.advance(by: 10) + + #expect(await run(id: id, age: WatchCommandPipeline.maximumCommandAge - 5) == first) + #expect(executions.value == 1) + } + + // MARK: - Safe Logging + + @Test("Outcomes are logged at info and refusals of misuse at warning") func testLogLevels() async { + _ = await run() + let id = UUID() + _ = await run("carbs:10", id: id) + _ = await run("carbs:20", id: id) + + #expect(logs.entries.contains { $0.level == .info && $0.line == "Watch carbs command done (success)" }) + #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("request ID was already used") }) + } + + @Test("Error categories are stable and content-free") func testErrorCategories() { + #expect(WatchCommandErrorCategory.name(for: AdjustmentError.persistenceFailed("row 42")) == "adjustmentPersistence") + #expect(WatchCommandErrorCategory.name(for: CoreDataError.fetchError(function: "f", file: "g")) == "coreData") + #expect(WatchCommandErrorCategory.name(for: CancellationError()) == "cancelled") + let saveError = NSError(domain: NSCocoaErrorDomain, code: 134_030, userInfo: ["detail": SensitiveTestError.marker]) + #expect(WatchCommandErrorCategory.name(for: saveError) == "cocoa(134030)") + #expect(WatchCommandErrorCategory.name(for: SensitiveTestError()) == "unexpected") + } +} + +final class ExecutionCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { lock.withLock { count } } + + func increment() { + lock.withLock { count += 1 } + } +} + +final class TestFlag: @unchecked Sendable { + private let lock = NSLock() + private var isSet = false + + var value: Bool { lock.withLock { isSet } } + + func set() { + lock.withLock { isSet = true } + } +} diff --git a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift index f1f6466713..5be936fcad 100644 --- a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift @@ -4,17 +4,18 @@ import Testing @testable import Trio -/// Covers what the processor decides before and around the side effects: freshness, gates, -/// limits, deduplication and the meal sequencing. Pump, Core Data and presets are stubbed. +/// What the processor decides before and around the side effects; pump, Core Data and presets are stubbed. @Suite("Watch Command Processor Tests") struct WatchCommandProcessorTests { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) - let appUUID = UUID() + // the default watchface leaves commands to Trio-WatchControls + let appUUID = GarminWatchControls.appUUID! let settings = StubSettingsManager() let validator = StubBolusSafetyValidator() let adjustments = StubAdjustmentManager() let actions = StubWatchCommandActions() + let presets = StubWatchPresetsProvider() let cache = WatchCommandRequestCache() let logs = LogCapture() let container = Container() @@ -29,14 +30,17 @@ import Testing container.register(BolusSafetyValidator.self) { [validator] _ in validator } container.register(AdjustmentManager.self) { [adjustments] _ in adjustments } container.register(WatchCommandAuthorization.self) { [settings] _ in settings.authorization } + container.register(WatchPresetsProvider.self) { [presets] _ in presets } + container.register(WatchCommandPipeline.self) { [settings, cache, clock, logs] _ in + WatchCommandPipeline(authorization: settings.authorization, cache: cache, now: { clock.now }, log: logs.logger) + }.inObjectScope(.container) - processor = Self.makeProcessor(container: container, actions: actions, cache: cache, clock: clock, logs: logs) + processor = Self.makeProcessor(container: container, actions: actions, clock: clock, logs: logs) } private static func makeProcessor( container: Container, actions: WatchCommandActions, - cache: WatchCommandRequestCache = WatchCommandRequestCache(), insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), clock: TestClock, logs: LogCapture @@ -44,10 +48,9 @@ import Testing BaseWatchCommandProcessor( resolver: container, actions: actions, - cache: cache, insulinLane: insulinLane, now: { clock.now }, - log: logs.record + log: logs.logger ) } @@ -202,7 +205,7 @@ import Testing // accepted command looks back over the full standard window. _ = await processor.process(request(.bolus(1), age: 0)) let second = Self.makeProcessor(container: container, actions: StubWatchCommandActions(), clock: clock, logs: logs) - _ = await second.process(request(.bolus(1), age: BaseWatchCommandProcessor.maximumCommandAge)) + _ = await second.process(request(.bolus(1), age: WatchCommandPipeline.maximumCommandAge)) #expect(validator.lookbackStarts == [standardWindowStart, standardWindowStart]) } @@ -297,15 +300,61 @@ import Testing @Test("Request IDs are scoped per app") func testScopedPerApp() async { let id = UUID() _ = await processor.process(request(.carbs(10), id: id)) - _ = await processor.process(request(.carbs(10), id: id, app: UUID())) + settings.settings.garminSettings.watchface = .complication + _ = await processor.process(request(.carbs(10), id: id, app: GarminWatchface.complication.watchfaceUUID)) #expect(actions.storedCarbs.count == 2) } + // MARK: - Command App + + @Test("Only the command app picked by the watchface may send commands") func testCommandAppOnly() async { + let fromComplication = await processor.process(request(.carbs(10), app: GarminWatchface.complication.watchfaceUUID)) + let fromWatchface = await processor.process(request(.carbs(10), app: GarminWatchface.trio.watchfaceUUID)) + let fromWatchControls = await processor.process(request(.carbs(10))) + + #expect(fromComplication.acknowledged == false) + #expect(fromComplication.message == "Commands are handled by another watch app.") + #expect(fromWatchface.acknowledged == false) + #expect(fromWatchControls.acknowledged) + #expect(actions.storedCarbs.count == 1) + #expect(logs.lines.contains("Garmin carbs command refused, sent by an app that is not the command app")) + } + + @Test("With commands off the refusal names the switch, not another app") func testCommandsOffMessage() async { + settings.settings.isGarminCommandControlEnabled = false + + let result = await processor.process(request(.carbs(10))) + + #expect(result.message == "Watch commands are disabled.") + } + + @Test( + "Changing the command app revokes pending commands, other watchface changes do not", + arguments: [GarminWatchface.complication, .swissalpine] + ) func testCommandAppChangeRevokes(watchface: GarminWatchface) { + let start = settings.authorization.current + settings.settings.garminSettings.watchface = watchface + + #expect(settings.authorization.current - start == (watchface == .complication ? 1 : 0)) + } + + @Test("Command app follows the watchface and the command switch") func testCommandAppMapping() { + var garmin = GarminWatchSettings() + garmin.isCommandControlEnabled = true + #expect(GarminCommandApp(garmin) == .watchControls) + garmin.isCommandControlEnabled = false + #expect(GarminCommandApp(garmin) == .none) + garmin.watchface = .complication + #expect(GarminCommandApp(garmin) == .complication) + #expect(GarminCommandApp.complication.appUUID == GarminWatchface.complication.watchfaceUUID) + #expect(GarminCommandApp.none.appUUID == nil) + } + // MARK: - Adjustments @Test("Activating an override uses the exact preset and the watch source") func testActivateOverride() async { - actions.overrides = [ + presets.overrides = [ WatchPresetEntry(name: "Sport ", isActive: false, ref: .presetID("trailing")), WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("exact")) ] @@ -318,22 +367,22 @@ import Testing @Test("An inactive preset can be activated; its state only reports whether it runs") func testInactivePresetActivates( ) async throws { - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: true, ref: .presetID("walk"))] + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: true, ref: .presetID("walk"))] #expect(await processor.process(request(.activateOverride(name: "Sport"))).ackCode == .overrideStarted) #expect(await processor.process(request(.activateTempTarget(name: "Walk"))).ackCode == .tempTargetStarted) #expect(adjustments.calls == [.activateOverride(.presetID("sport")), .activateTempTarget(.presetID("walk"))]) - let presets = try await processor.presets() - #expect(presets.overridePresets == [OverridePresetWatch(name: "Sport", isEnabled: false)]) - #expect(presets.tempTargetPresets == [TempTargetPresetWatch(name: "Walk", isEnabled: true)]) + let list = try await presets.presets() + #expect(list.overridePresets == [OverridePresetWatch(name: "Sport", isEnabled: false)]) + #expect(list.tempTargetPresets == [TempTargetPresetWatch(name: "Walk", isEnabled: true)]) } @Test("Missing or ambiguous override names are rejected", arguments: ["Missing", "Sport"]) func testOverrideNotUnique( name: String ) async { - actions.overrides = [ + presets.overrides = [ WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("a")), WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("b")) ] @@ -362,52 +411,23 @@ import Testing } @Test("Activating a temp target uses the exact preset") func testActivateTempTarget() async { - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] let result = await processor.process(request(.activateTempTarget(name: "Walk"))) #expect(result == .success(.tempTargetStarted, result.message)) #expect(adjustments.calls == [.activateTempTarget(.presetID("walk"))]) } - // MARK: - Presets - - @Test("Presets are sorted by name and keep their state") func testPresetsSorted() async throws { - actions.overrides = [ - WatchPresetEntry(name: "b", isActive: false, ref: .presetID("b")), - WatchPresetEntry(name: "A", isActive: true, ref: .presetID("A")), - WatchPresetEntry(name: "c", isActive: false, ref: .presetID("c")) - ] - actions.tempTargets = [ - WatchPresetEntry(name: "Walk 10", isActive: false, ref: .presetID("10")), - WatchPresetEntry(name: "Walk 9", isActive: false, ref: .presetID("9")) - ] - - let presets = try await processor.presets() - - #expect(presets.overridePresets == [ - OverridePresetWatch(name: "A", isEnabled: true), - OverridePresetWatch(name: "b", isEnabled: false), - OverridePresetWatch(name: "c", isEnabled: false) - ]) - #expect(presets.tempTargetPresets.map(\.name) == ["Walk 9", "Walk 10"]) - } + // MARK: - Capabilities - @Test("Presets stay readable with commands disabled") func testPresetsIgnoreMasterSwitch() async throws { - settings.settings.isGarminCommandControlEnabled = false - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("s"))] - - #expect(try await processor.presets().overridePresets.count == 1) - } - - @Test("Presets carry the command switches and limits as they are at call time") func testPresetsCapabilities( - ) async throws { + @Test("Capabilities report the command switches and limits as they are at call time") func testCapabilities() { settings.settings.isGarminCommandControlEnabled = true settings.settings.isGarminBolusCommandEnabled = true settings.settings.maxCarbs = 120 settings.pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 5, maxBasal: 2) settings.preferences.bolusIncrement = 0.1 - #expect(try await processor.presets().capabilities == WatchCommandCapabilities( + #expect(processor.capabilities() == WatchCommandCapabilities( isCommandControlEnabled: true, isBolusCommandEnabled: true, maxBolus: 5, @@ -416,7 +436,7 @@ import Testing )) settings.settings.isGarminCommandControlEnabled = false - #expect(try await processor.presets().capabilities.isCommandControlEnabled == false) + #expect(processor.capabilities().isCommandControlEnabled == false) } // MARK: - Revocation @@ -492,14 +512,14 @@ import Testing } @Test("Disabling commands while presets load stops the activation") func testRevokedDuringPresetLoad() async { - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - actions.presetsGate = TestGate() + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.gate = TestGate() let pending = Task { await processor.process(request(.activateOverride(name: "Sport"))) } - await actions.presetsGate.waitForArrivals() + await presets.gate.waitForArrivals() settings.settings.isGarminCommandControlEnabled = false settings.settings.isGarminCommandControlEnabled = true - actions.presetsGate.open() + presets.gate.open() #expect(await pending.value.acknowledged == false) #expect(adjustments.calls.isEmpty) @@ -514,8 +534,8 @@ import Testing .activateTempTarget(name: "Walk"), .cancelTempTarget ] ) func testAdjustmentRevokedWhileQueued(command: WatchCommand) async { - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] adjustments.gate = TestGate() let pending = Task { await processor.process(request(command)) } await adjustments.gate.waitForArrivals() @@ -598,7 +618,7 @@ import Testing let first = Task { await processor.process(request(.bolus(2))) } await validator.gate.waitForArrivals() let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) - let second = Task { await processor.process(request(command, age: BaseWatchCommandProcessor.maximumCommandAge - 10)) } + let second = Task { await processor.process(request(command, age: WatchCommandPipeline.maximumCommandAge - 10)) } #expect(await eventually { await lane.waitingCount == 1 }) clock.advance(by: 20) @@ -610,7 +630,7 @@ import Testing #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) #expect(validator.validatedAmounts == [2], "The expired request never validates") #expect(actions.enactedBoluses.isEmpty) - #expect(logs.lines.contains { $0.contains("outside the freshness window") }) + #expect(logs.lines.contains { $0.contains("outside the 2 minute window") }) } @Test( @@ -619,7 +639,7 @@ import Testing ) func testExpiredDuringValidation(isMeal: Bool) async { validator.gate = TestGate() let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) - let pending = Task { await processor.process(request(command, age: BaseWatchCommandProcessor.maximumCommandAge - 5)) } + let pending = Task { await processor.process(request(command, age: WatchCommandPipeline.maximumCommandAge - 5)) } await validator.gate.waitForArrivals() clock.advance(by: 10) @@ -637,7 +657,7 @@ import Testing ) func testExpiredBeforeIssuance(isMeal: Bool) async { actions.issueGate = TestGate() let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) - let pending = Task { await processor.process(request(command, age: BaseWatchCommandProcessor.maximumCommandAge - 1)) } + let pending = Task { await processor.process(request(command, age: WatchCommandPipeline.maximumCommandAge - 1)) } await actions.issueGate.waitForArrivals() clock.advance(by: 2) @@ -650,7 +670,7 @@ import Testing @Test("An expired request replays its cached result without re-running") func testExpiredReplay() async { validator.gate = TestGate() - let request = request(.bolus(1), age: BaseWatchCommandProcessor.maximumCommandAge - 5) + let request = request(.bolus(1), age: WatchCommandPipeline.maximumCommandAge - 5) let pending = Task { await processor.process(request) } await validator.gate.waitForArrivals() clock.advance(by: 10) @@ -749,7 +769,7 @@ import Testing @Test("Error details never reach the log or the ack") func testSensitiveErrorsSanitized() async { actions.carbsError = SensitiveTestError() adjustments.error = SensitiveTestError() - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] let results = [ await processor.process(request(.carbs(10))), @@ -765,12 +785,4 @@ import Testing #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) && !$0.contains("42g") }) #expect(logs.lines.contains { $0.contains("(unexpected)") }) } - - @Test("Error categories are stable and content-free") func testErrorCategories() { - #expect(WatchCommandErrorCategory.name(for: AdjustmentError.persistenceFailed("row 42")) == "adjustmentPersistence") - #expect(WatchCommandErrorCategory.name(for: CoreDataError.fetchError(function: "f", file: "g")) == "coreData") - let saveError = NSError(domain: NSCocoaErrorDomain, code: 134_030, userInfo: ["detail": SensitiveTestError.marker]) - #expect(WatchCommandErrorCategory.name(for: saveError) == "cocoa(134030)") - #expect(WatchCommandErrorCategory.name(for: SensitiveTestError()) == "unexpected") - } } diff --git a/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift b/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift index 29fb7becb6..c764b5ae8d 100644 --- a/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift @@ -20,7 +20,7 @@ import Testing private func admitted( _ cache: WatchCommandRequestCache, _ key: WatchCommandRequestCache.Key, - _ command: WatchCommand, + _ command: AnyHashable, at now: Date ) async throws -> WatchCommandRequestCache.Ticket { let admission = await cache.admit(key, command: command, now: now) @@ -32,46 +32,46 @@ import Testing let key = key() let result = WatchCommandResult.success(.carbsLogged, "Carbs logged.") - let reservation = try await admitted(cache, key, .carbs(20), at: start) - #expect(await cache.admit(key, command: .carbs(20), now: start) == .inProgress) + let reservation = try await admitted(cache, key, "carbs:20", at: start) + #expect(await cache.admit(key, command: "carbs:20", now: start) == .inProgress) await cache.complete(reservation, result: result) - #expect(await cache.admit(key, command: .carbs(20), now: start) == .completed(result)) + #expect(await cache.admit(key, command: "carbs:20", now: start) == .completed(result)) } @Test("Reusing a request ID for another command is a conflict") func testConflict() async throws { let cache = WatchCommandRequestCache() let key = key() - _ = try await admitted(cache, key, .carbs(20), at: start) - #expect(await cache.admit(key, command: .carbs(25), now: start) == .conflict) - #expect(await cache.admit(key, command: .cancelOverride, now: start) == .conflict) + _ = try await admitted(cache, key, "carbs:20", at: start) + #expect(await cache.admit(key, command: "carbs:25", now: start) == .conflict) + #expect(await cache.admit(key, command: "cancelOverride", now: start) == .conflict) } @Test("The same request ID from another app is a separate request") func testScopedByApp() async { let cache = WatchCommandRequestCache() let requestID = UUID() - #expect(ticket(await cache.admit(key(requestID), command: .cancelOverride, now: start)) != nil) - #expect(ticket(await cache.admit(key(requestID, app: UUID()), command: .cancelOverride, now: start)) != nil) + #expect(ticket(await cache.admit(key(requestID), command: "cancelOverride", now: start)) != nil) + #expect(ticket(await cache.admit(key(requestID, app: UUID()), command: "cancelOverride", now: start)) != nil) } @Test("Completed entries expire after their lifetime") func testExpiry() async throws { let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) let key = key() - let reservation = try await admitted(cache, key, .cancelOverride, at: start) + let reservation = try await admitted(cache, key, "cancelOverride", at: start) await cache.complete(reservation, result: done) - #expect(await cache.admit(key, command: .cancelOverride, now: start.addingTimeInterval(15 * 60 - 1)) == .completed(done)) - #expect(ticket(await cache.admit(key, command: .cancelOverride, now: start.addingTimeInterval(15 * 60))) != nil) + #expect(await cache.admit(key, command: "cancelOverride", now: start.addingTimeInterval(15 * 60 - 1)) == .completed(done)) + #expect(ticket(await cache.admit(key, command: "cancelOverride", now: start.addingTimeInterval(15 * 60))) != nil) } @Test("An executing entry outlives its lifetime") func testInFlightNeverExpires() async throws { let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) let key = key() - _ = try await admitted(cache, key, .cancelOverride, at: start) - #expect(await cache.admit(key, command: .cancelOverride, now: start.addingTimeInterval(60 * 60)) == .inProgress) + _ = try await admitted(cache, key, "cancelOverride", at: start) + #expect(await cache.admit(key, command: "cancelOverride", now: start.addingTimeInterval(60 * 60)) == .inProgress) } @Test("A full cache evicts the least recently used completed entry") func testLeastRecentlyUsedEviction() async throws { @@ -79,15 +79,15 @@ import Testing let first = key() let second = key() - try await cache.complete(admitted(cache, first, .cancelOverride, at: start), result: done) - try await cache.complete(admitted(cache, second, .cancelOverride, at: start), result: done) + try await cache.complete(admitted(cache, first, "cancelOverride", at: start), result: done) + try await cache.complete(admitted(cache, second, "cancelOverride", at: start), result: done) // touching the first entry makes the second the least recently used - #expect(await cache.admit(first, command: .cancelOverride, now: start) == .completed(done)) - #expect(ticket(await cache.admit(key(), command: .cancelOverride, now: start)) != nil) + #expect(await cache.admit(first, command: "cancelOverride", now: start) == .completed(done)) + #expect(ticket(await cache.admit(key(), command: "cancelOverride", now: start)) != nil) #expect(await cache.count == 2) - #expect(await cache.admit(first, command: .cancelOverride, now: start) == .completed(done)) - #expect(ticket(await cache.admit(second, command: .cancelOverride, now: start)) != nil, "Second was evicted") + #expect(await cache.admit(first, command: "cancelOverride", now: start) == .completed(done)) + #expect(ticket(await cache.admit(second, command: "cancelOverride", now: start)) != nil, "Second was evicted") } @Test("Executing entries are never evicted; a full cache rejects instead") func testCapacityKeepsInFlight() async throws { @@ -95,17 +95,17 @@ import Testing let first = key() let second = key() - let firstTicket = try await admitted(cache, first, .bolus(1), at: start) - _ = try await admitted(cache, second, .bolus(2), at: start) + let firstTicket = try await admitted(cache, first, "bolus:1", at: start) + _ = try await admitted(cache, second, "bolus:2", at: start) - #expect(await cache.admit(key(), command: .bolus(3), now: start) == .full) + #expect(await cache.admit(key(), command: "bolus:3", now: start) == .full) // a replay of a running request is still recognized, never re-admitted - #expect(await cache.admit(first, command: .bolus(1), now: start) == .inProgress) - #expect(await cache.admit(first, command: .bolus(5), now: start) == .conflict) + #expect(await cache.admit(first, command: "bolus:1", now: start) == .inProgress) + #expect(await cache.admit(first, command: "bolus:5", now: start) == .conflict) await cache.complete(firstTicket, result: done) - #expect(ticket(await cache.admit(key(), command: .bolus(3), now: start)) != nil, "A completed slot frees up") - #expect(await cache.admit(second, command: .bolus(2), now: start) == .inProgress) + #expect(ticket(await cache.admit(key(), command: "bolus:3", now: start)) != nil, "A completed slot frees up") + #expect(await cache.admit(second, command: "bolus:2", now: start) == .inProgress) } @Test("A stale execution cannot complete a newer admission of the same key") func testStaleCompletion() async throws { @@ -114,19 +114,19 @@ import Testing let stale = WatchCommandResult.failure("stale") let fresh = WatchCommandResult.success(.carbsLogged, "fresh") - let oldTicket = try await admitted(cache, key, .carbs(10), at: start) + let oldTicket = try await admitted(cache, key, "carbs:10", at: start) await cache.complete(oldTicket, result: stale) let later = start.addingTimeInterval(15 * 60) - let newTicket = try await admitted(cache, key, .carbs(10), at: later) + let newTicket = try await admitted(cache, key, "carbs:10", at: later) #expect(newTicket != oldTicket) await cache.complete(oldTicket, result: stale) - #expect(await cache.admit(key, command: .carbs(10), now: later) == .inProgress) + #expect(await cache.admit(key, command: "carbs:10", now: later) == .inProgress) await cache.complete(newTicket, result: fresh) - #expect(await cache.admit(key, command: .carbs(10), now: later) == .completed(fresh)) + #expect(await cache.admit(key, command: "carbs:10", now: later) == .completed(fresh)) await cache.complete(oldTicket, result: stale) - #expect(await cache.admit(key, command: .carbs(10), now: later) == .completed(fresh), "Completed results are final") + #expect(await cache.admit(key, command: "carbs:10", now: later) == .completed(fresh), "Completed results are final") } @Test("Expired entries are evicted before live ones") func testExpiredEvictedFirst() async throws { @@ -134,11 +134,11 @@ import Testing let old = key() let recent = key() - try await cache.complete(admitted(cache, old, .cancelOverride, at: start), result: done) - _ = await cache.admit(recent, command: .cancelOverride, now: start.addingTimeInterval(10 * 60)) - #expect(ticket(await cache.admit(key(), command: .cancelOverride, now: start.addingTimeInterval(16 * 60))) != nil) + try await cache.complete(admitted(cache, old, "cancelOverride", at: start), result: done) + _ = await cache.admit(recent, command: "cancelOverride", now: start.addingTimeInterval(10 * 60)) + #expect(ticket(await cache.admit(key(), command: "cancelOverride", now: start.addingTimeInterval(16 * 60))) != nil) - #expect(await cache.admit(recent, command: .cancelOverride, now: start.addingTimeInterval(16 * 60)) == .inProgress) + #expect(await cache.admit(recent, command: "cancelOverride", now: start.addingTimeInterval(16 * 60)) == .inProgress) } @Test("Default limits are 256 entries for 15 minutes") func testDefaults() { diff --git a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift index 24e41529b0..3030d8beef 100644 --- a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift +++ b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift @@ -4,19 +4,6 @@ import LoopKit import LoopKitUI @testable import Trio -/// Revokes from its setter the way `BaseSettingsManager` does. -final class StubSettingsManager: SettingsManager { - let authorization = WatchCommandAuthorization() - var settings = TrioSettings() { - willSet { authorization.settingsWillChange(from: settings, to: newValue) } - } - - var preferences = Preferences() - var pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2) - - func updateInsulinCurve(_: InsulinType?) {} -} - /// Parks async calls until the test opens it, and lets the test wait until calls have arrived, /// so suspended-operation tests do not depend on scheduling luck. final class TestGate: @unchecked Sendable { @@ -107,6 +94,63 @@ func eventually(_ condition: () async -> Bool) async -> Bool { return await condition() } +/// Error whose every description carries a marker that must never reach a log or an ack. +struct SensitiveTestError: LocalizedError, CustomStringConvertible { + static let marker = "SENSITIVE-7f3a" + + var description: String { "carbs=42g bolus=3.5U \(Self.marker)" } + var errorDescription: String? { description } +} + +/// Collects log lines from the injectable log sinks. +final class LogCapture: @unchecked Sendable { + private let lock = NSLock() + private var storage: [(level: WatchCommandLogger.Level, line: String)] = [] + + var lines: [String] { lock.withLock { storage.map(\.line) } } + var entries: [(level: WatchCommandLogger.Level, line: String)] { lock.withLock { storage } } + + var logger: WatchCommandLogger { + WatchCommandLogger { [self] level, line, _, _, _ in record(line, level: level) } + } + + func record(_ line: String, level: WatchCommandLogger.Level = .debug) { + lock.withLock { storage.append((level, line)) } + } +} + +final class StubWatchPresetsProvider: WatchPresetsProvider, @unchecked Sendable { + var overrides: [WatchPresetEntry] = [] + var tempTargets: [WatchPresetEntry] = [] + var error: Error? + var gate = TestGate(isOpen: true) + + func overridePresets() async throws -> [WatchPresetEntry] { + await gate.pass() + if let error { throw error } + return overrides + } + + func tempTargetPresets() async throws -> [WatchPresetEntry] { + await gate.pass() + if let error { throw error } + return tempTargets + } +} + +/// Revokes from its setter the way `BaseSettingsManager` does. +final class StubSettingsManager: SettingsManager { + let authorization = WatchCommandAuthorization() + var settings = TrioSettings() { + willSet { authorization.settingsWillChange(from: settings, to: newValue) } + } + + var preferences = Preferences() + var pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2) + + func updateInsulinCurve(_: InsulinType?) {} +} + /// Counts how many validations and pump requests overlap, across concurrent requests. final class InsulinConcurrencyProbe: @unchecked Sendable { private let lock = NSLock() @@ -161,86 +205,6 @@ final class StubBolusSafetyValidator: BolusSafetyValidator { func fetchTotalRecentBolusAmount(since _: Date) async throws -> Decimal { 0 } } -final class StubAdjustmentManager: AdjustmentManager { - enum Call: Equatable { - case activateOverride(AdjustmentRef) - case cancelOverride - case activateTempTarget(AdjustmentRef) - case cancelTempTarget - } - - private(set) var calls: [Call] = [] - private(set) var sources: [AdjustmentSource] = [] - var error: Error? - /// Parks guarded calls before their final check, the way the adjustment serializer would. - var gate = TestGate(isOpen: true) - - private func record(_ call: Call, source: AdjustmentSource) throws -> AdjustmentOutcome { - calls.append(call) - sources.append(source) - if let error { throw error } - return AdjustmentOutcome(started: nil, ended: []) - } - - func activateOverride(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws - -> AdjustmentOutcome - { - try record(.activateOverride(preset), source: source) - } - - func cancelOverride(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { - try record(.cancelOverride, source: source) - } - - func activateTempTarget(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws - -> AdjustmentOutcome - { - try record(.activateTempTarget(preset), source: source) - } - - func cancelTempTarget(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { - try record(.cancelTempTarget, source: source) - } - - private func guarded( - _ call: Call, - source: AdjustmentSource, - authorize: @Sendable() throws -> Void - ) async throws -> AdjustmentOutcome { - await gate.pass() - try authorize() - return try record(call, source: source) - } - - func activateOverride( - _ preset: AdjustmentRef, - source: AdjustmentSource, - authorize: @escaping @Sendable() throws -> Void - ) async throws -> AdjustmentOutcome { - try await guarded(.activateOverride(preset), source: source, authorize: authorize) - } - - func cancelOverride(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws - -> AdjustmentOutcome - { - try await guarded(.cancelOverride, source: source, authorize: authorize) - } - - func activateTempTarget( - _ preset: AdjustmentRef, - source: AdjustmentSource, - authorize: @escaping @Sendable() throws -> Void - ) async throws -> AdjustmentOutcome { - try await guarded(.activateTempTarget(preset), source: source, authorize: authorize) - } - - func cancelTempTarget(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws - -> AdjustmentOutcome - { - try await guarded(.cancelTempTarget, source: source, authorize: authorize) - } -} - final class StubWatchCommandActions: WatchCommandActions { private let lock = NSLock() private var recordedEvents: [String] = [] @@ -253,8 +217,6 @@ final class StubWatchCommandActions: WatchCommandActions { var enactedBoluses: [Decimal] { lock.withLock { boluses } } var carbsError: Error? var bolusSucceeds = true - var overrides: [WatchPresetEntry] = [] - var tempTargets: [WatchPresetEntry] = [] /// Parks the carb save before its final check, the way a busy Core Data context would. var carbsTransactionGate = TestGate(isOpen: true) @@ -264,7 +226,6 @@ final class StubWatchCommandActions: WatchCommandActions { var issueGate = TestGate(isOpen: true) /// Parks the bolus after it was issued, while the pump is still working on it. var bolusGate = TestGate(isOpen: true) - var presetsGate = TestGate(isOpen: true) var probe: InsulinConcurrencyProbe? /// Side effects are recorded only after `authorize` passed, as in production. @@ -289,55 +250,20 @@ final class StubWatchCommandActions: WatchCommandActions { await bolusGate.pass() return bolusSucceeds } - - func overridePresets() async throws -> [WatchPresetEntry] { - await presetsGate.pass() - return overrides - } - - func tempTargetPresets() async throws -> [WatchPresetEntry] { - await presetsGate.pass() - return tempTargets - } } final class SpyWatchCommandProcessor: WatchCommandProcessor { var result = WatchCommandResult.success(.carbsLogged, "Carbs logged.") - var presetsResult = WatchCommandPresets(overridePresets: [], tempTargetPresets: []) - var presetsError: Error? + var capabilitiesResult = WatchCommandCapabilities() private(set) var processed: [WatchCommandRequest] = [] - private(set) var presetRequests = 0 func process(_ request: WatchCommandRequest) async -> WatchCommandResult { processed.append(request) return result } - func presets() async throws -> WatchCommandPresets { - presetRequests += 1 - if let presetsError { throw presetsError } - return presetsResult - } -} - -/// Error whose every description carries a marker that must never reach a log or an ack. -struct SensitiveTestError: LocalizedError, CustomStringConvertible { - static let marker = "SENSITIVE-7f3a" - - var description: String { "carbs=42g bolus=3.5U \(Self.marker)" } - var errorDescription: String? { description } -} - -/// Collects log lines from the injectable log sinks. -final class LogCapture: @unchecked Sendable { - private let lock = NSLock() - private var storage: [(level: LogLevel, line: String)] = [] - - var lines: [String] { lock.withLock { storage.map(\.line) } } - var entries: [(level: LogLevel, line: String)] { lock.withLock { storage } } - - func record(_ level: LogLevel, _ line: String) { - lock.withLock { storage.append((level, line)) } + func capabilities() -> WatchCommandCapabilities { + capabilitiesResult } } @@ -382,3 +308,83 @@ final class SpyAPSManager: APSManager, @unchecked Sendable { func cancelBolus(_: ((Bool, String) -> Void)?) async {} let iobFileDidUpdate = PassthroughSubject() } + +final class StubAdjustmentManager: AdjustmentManager { + enum Call: Equatable { + case activateOverride(AdjustmentRef) + case cancelOverride + case activateTempTarget(AdjustmentRef) + case cancelTempTarget + } + + private(set) var calls: [Call] = [] + private(set) var sources: [AdjustmentSource] = [] + var error: Error? + /// Parks guarded calls before their final check, the way the adjustment serializer would. + var gate = TestGate(isOpen: true) + + private func record(_ call: Call, source: AdjustmentSource) throws -> AdjustmentOutcome { + calls.append(call) + sources.append(source) + if let error { throw error } + return AdjustmentOutcome(started: nil, ended: []) + } + + func activateOverride(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws + -> AdjustmentOutcome + { + try record(.activateOverride(preset), source: source) + } + + func cancelOverride(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { + try record(.cancelOverride, source: source) + } + + func activateTempTarget(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws + -> AdjustmentOutcome + { + try record(.activateTempTarget(preset), source: source) + } + + func cancelTempTarget(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { + try record(.cancelTempTarget, source: source) + } + + private func guarded( + _ call: Call, + source: AdjustmentSource, + authorize: @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + await gate.pass() + try authorize() + return try record(call, source: source) + } + + func activateOverride( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await guarded(.activateOverride(preset), source: source, authorize: authorize) + } + + func cancelOverride(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws + -> AdjustmentOutcome + { + try await guarded(.cancelOverride, source: source, authorize: authorize) + } + + func activateTempTarget( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await guarded(.activateTempTarget(preset), source: source, authorize: authorize) + } + + func cancelTempTarget(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws + -> AdjustmentOutcome + { + try await guarded(.cancelTempTarget, source: source, authorize: authorize) + } +} diff --git a/ci_scripts/ci_post_xcodebuild.sh b/ci_scripts/ci_post_xcodebuild.sh index 7f2132ae79..4fad050aac 100755 --- a/ci_scripts/ci_post_xcodebuild.sh +++ b/ci_scripts/ci_post_xcodebuild.sh @@ -4,7 +4,15 @@ if [[ -d "$CI_APP_STORE_SIGNED_APP_PATH" ]]; then TESTFLIGHT_DIR_PATH=../TestFlight mkdir -p $TESTFLIGHT_DIR_PATH - echo "" >> $TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt - echo "Last commits:" >> $TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt - git fetch --deepen 6 && git log -6 --pretty=format:"%h %s" | nl -s ". " >> $TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt + WHAT_TO_TEST=$TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt + echo "" >> $WHAT_TO_TEST + echo "Last commits:" >> $WHAT_TO_TEST + # Best effort: a shallow CI clone needs a deepen pass to see history, but the + # fetch must not recurse into submodules. Stale gitlinks from deep history + # (MedtrumKit/EversenseKit/CGMBLEKit pins that the .gitmodules upstream no + # longer has) make a recursive fetch abort with + # "upload-pack: not our ref", which used to short-circuit this line and drop + # the whole commit list from the release notes. + git -c submodule.recurse=false fetch --deepen 6 >/dev/null 2>&1 || true + git log -6 --pretty=format:"%h %s" | nl -s ". " >> $WHAT_TO_TEST fi diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 399d0ea3f0..4e9e2714aa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -49,6 +49,70 @@ xcconfig = parse_xcconfig_file(xcconfig_path) # Access BUNDLE_IDENTIFIER from the xcconfig file after replacing $(DEVELOPMENT_TEAM) with ENV["TEAMID"] ENV["BUNDLE_ID"] = xcconfig["BUNDLE_IDENTIFIER"] +# Plain-text excerpt of the newest release notes that scripts/capture-release-notes.sh bundled +# into the IPA. Mirrors the in-app sheet (ReleaseNotes.swift): summary, callouts, then highlights. +def bundled_release_notes(ipa_path) + json = IO.popen(["unzip", "-p", ipa_path, "Payload/Trio.app/BundledReleaseNotes.json"], err: File::NULL, &:read).force_encoding("UTF-8") + release = JSON.parse(json).first + return nil unless release + + plain = ->(text) { text.gsub(/!\[[^\]]*\]\([^)]*\)/, "").gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').gsub("**", "").delete("`") } + raw_lines = release["body"].gsub(/\r\n?/, "\n").split("\n") + lines = raw_lines.map(&:strip) + + summary = [] + lines.each do |line| + break if line.start_with?("##") || ["---", "***"].include?(line) + next if line.empty? || line.start_with?("#", ">") + summary << plain.(line) + end + + callouts = [] + current = nil + lines.each do |line| + unless line.start_with?(">") + current = nil + next + end + content = line[1..].strip + if (marker = content[/\A\[!([^\]]*)\]/, 1]) + kind = marker.upcase + current = %w[NOTE TIP IMPORTANT WARNING CAUTION].include?(kind) ? [kind, []] : nil + callouts << current if current + elsif current && !content.empty? + current[1] << plain.(content) + end + end + + highlights = [] + inside = false + raw_lines.each do |raw| + line = raw.strip + if line.start_with?("#") + heading = line.sub(/\A#+/, "").strip + if heading.casecmp?("What's Changed At A Glance") + inside = true + elsif inside + break + end + next + end + next unless inside + break if ["---", "***"].include?(line) + next unless line.start_with?("- ", "* ") && !line[2..].strip.empty? + level = [raw[/\A[ \t]*/].gsub("\t", " ").length / 2, 2].min + highlights << " " * level + "- " + plain.(line[2..].strip) + end + + sections = [release["name"], summary.join("\n\n")] + sections += callouts.reject { |_, text| text.empty? }.map { |kind, text| "#{kind}:\n" + text.join("\n") } + sections << "What's Changed At A Glance\n" + highlights.join("\n") unless highlights.empty? + sections.reject(&:empty?).join("\n\n") +rescue StandardError => e + UI.important("Could not read bundled release notes: #{e}") + nil +end + # limit lane names to letters and underscores platform :ios do desc "Build Tai" @@ -165,12 +229,33 @@ platform :ios do key_content: "#{FASTLANE_KEY}" ) + # APP_DEV_VERSION only has a 4th component off main (e.g. 1.0.2.33 on dev) + dev_version = xcconfig["APP_DEV_VERSION"].to_s + on_main = dev_version.split(".").length != 4 + version_note = on_main ? "" : " "+dev_version + notes = git_branch+version_note+" "+last_git_commit[:abbreviated_commit_hash] + release_notes = on_main && bundled_release_notes("#{GITHUB_WORKSPACE}/Trio.ipa") + notes += "\n\n"+release_notes if release_notes + + # changelog alone only updates existing "What to Test" notes, so name the primary locale to create one. + # Keep changelog too: without it, skip_waiting_for_build_processing returns before notes are set. + primary_locale = begin + Spaceship::ConnectAPI::App.find(ENV["BUNDLE_ID"])&.primary_locale + rescue StandardError => e + UI.important("Could not look up primary locale, using en-US: #{e}") + nil + end || "en-US" + upload_to_testflight( api_key: api_key, skip_submission: false, ipa: "Trio.ipa", skip_waiting_for_build_processing: true, - changelog: git_branch+" "+last_git_commit[:abbreviated_commit_hash], + changelog: notes, + localized_build_info: { + "default" => { whats_new: notes }, + primary_locale => { whats_new: notes }, + }, verbose: true ) end