From d0b62df51d46cb576fdb265cf9a1ad63c47a32a1 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sat, 7 Nov 2026 13:55:43 -0500 Subject: [PATCH 01/11] Stop the watch from sending a 0 U bolus after the phone reconnects The watch reset bolusAmount, carbsAmount and confirmationProgress every time the phone became reachable again, which happens on each wrist raise. A user on the confirm screen who lowered and raised their wrist had their amount replaced with 0 and confirmed a 0 U bolus. The phone's enactBolus returned without calling back for amounts of 0 or less, so the watch never got an answer, and the Processing screen's 5 s fallback never started because showCommsAnimation was already true when the view appeared. Watch: - Reachability changes no longer touch entries in progress; the main view still resets them when it appears. - sendBolusRequest refuses amounts of 0 or less with a visible message. - Every treatment request shows "iPhone not reachable. Nothing was sent." instead of an empty screen when the phone is unreachable. - WatchState times out a missing answer after 15 s, and on a send error, with a message to check Trio on the iPhone (a send error does not prove the request was not delivered). The view-level timer is removed. Phone: - enactBolus answers every caller with a failure for amounts of 0 or less, or amounts that round to 0 on the pump. Tests: - Watch: reconnect keeps the bolus entry, 0 U and negative boluses are refused, unreachable requests say nothing was sent, the timeout fires and is cancelled by an answer, a send error ends processing. - Phone: enactBolus calls back once with a failure for 0, negative and NaN. - Bring the watch test target back to compiling: drop assertions on members that no longer exist (isBolusCanceled, maxCOB, bolus cancel) and import SwiftUI instead of SwiftUICore. Co-Authored-By: Claude Opus 5.5 --- .../Views/AcknowledgementPendingView.swift | 13 +- .../WatchState+Requests.swift | 135 +++++++-------- Trio Watch App Extension/WatchState.swift | 71 ++++++-- Trio Watch App Tests/Unit Tests.swift | 160 +++++++++++++----- Trio.xcodeproj/project.pbxproj | 4 + Trio/Sources/APS/APSManager.swift | 16 +- .../Localizations/Main/Localizable.xcstrings | 9 + .../EnactBolusAmountTests.swift | 40 +++++ 8 files changed, 307 insertions(+), 141 deletions(-) create mode 100644 TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift 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/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index 30799a2c88..330d67538c 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -8,23 +8,17 @@ extension WatchState { /// - 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") + guard let session = reachableSession(.bolus, notSentLog: "Bolus request not sent, phone unreachable") else { return } - let message: [String: Any] = [ - WatchMessageKeys.bolus: amount - ] - - 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 +26,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 +34,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 + } - // Display pending communication animation - showCommsAnimation = true + /// 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() + + 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..1b1f170aa4 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -61,6 +61,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 @@ -109,7 +125,37 @@ import WatchConnectivity // 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 +297,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..b42ca8fc8a 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,135 @@ 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 - // Then - Test state transitions - watchState.handleAcknowledgment(success: true, message: "Saving carbs...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // When - raising the wrist reconnects the phone + watchState.handleReachabilityChange(isReachable: false) + watchState.handleReachabilityChange(isReachable: true) - watchState.handleAcknowledgment(success: true, message: "Enacting bolus...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .enactingBolus) + // 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) + } - watchState.handleAcknowledgment(success: true, message: "Carbs and bolus logged successfully", isFinal: true) - #expect(!watchState.isMealBolusCombo) + // MARK: - Bolus requests that must not be sent + + @Test("Zero bolus is refused on the watch", arguments: [ + Decimal(0), + Decimal(-1) + ]) func testZeroBolusRefused(amount: Decimal) async throws { + watchState.sendBolusRequest(amount) + + #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.") } } diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 4868f0800f..f9f210e02a 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -471,6 +471,7 @@ AD3D2CD42CD01B9EB8F26522 /* PumpConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.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 */; }; B015AFF32E600000000D7351 /* AdjustmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFF12E600000000D7351 /* AdjustmentManager.swift */; }; B015AFF52E600000000D7351 /* AdjustmentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */; }; B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; }; @@ -1697,6 +1698,7 @@ AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigDataFlow.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 = ""; }; B015AFF12E600000000D7351 /* AdjustmentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentManager.swift; sourceTree = ""; }; B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentManagerTests.swift; sourceTree = ""; }; B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = ""; }; @@ -4031,6 +4033,7 @@ isa = PBXGroup; children = ( B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */, + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */, ); path = BolusSafetyTests; sourceTree = ""; @@ -6613,6 +6616,7 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */, BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 50f017eecb..ce45d0c460 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -633,7 +633,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 } @@ -658,6 +661,12 @@ final class BaseAPSManager: APSManager, Injectable { let roundedAmount = pump.roundToSupportedBolusVolume(units: amount) + 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 { @@ -696,6 +705,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/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4292db903b..96c454ade6 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -132544,6 +132544,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" : { @@ -176343,6 +176346,9 @@ } } }, + "iPhone not reachable. Nothing was sent." : { + "comment" : "Watch message when a request could not be sent to the phone" + }, "is running" : { "localizations" : { "bg" : { @@ -211026,6 +211032,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/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) + } +} From 9a5cac061fb968eb6243c1c5b25f4327602a5bad Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sat, 7 Nov 2026 14:00:06 -0500 Subject: [PATCH 02/11] Expire watch carbs/bolus entries after 2 minutes An amount entered on the watch stayed on the input and confirm screens for as long as the app was left open, so an old entry could be confirmed much later. Entries now expire 2 minutes after their last edit. - Opening the carbs or bolus input screen, or changing the amount there (crown, +/-, a recommendation landing), restarts the entry's clock. - Confirming with the crown and "Log Carbs" send only an entry edited within the last 2 minutes; an older one is cleared and the watch says nothing was sent. An entry with no recorded edit is never sent. - When the app becomes active again, an entry older than 2 minutes is cleared and the watch returns to the main screen. - Returning to the main screen, Cancel and sending end the entry. Tests cover the 2 minute boundary, refusing expired and unedited entries, sending fresh ones, carbs-only entries and clearing on return to the app. Co-Authored-By: Claude Opus 5.5 --- .../Views/BolusConfirmationView.swift | 15 +-- .../Views/BolusInputView.swift | 6 +- .../Views/CarbsInputView.swift | 5 +- .../Views/TrioMainWatchView.swift | 10 ++ .../WatchState+Requests.swift | 29 ++++++ Trio Watch App Extension/WatchState.swift | 41 ++++++++ Trio Watch App Tests/Unit Tests.swift | 98 +++++++++++++++++++ .../Localizations/Main/Localizable.xcstrings | 3 + 8 files changed, 192 insertions(+), 15 deletions(-) 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 01efe3838d..80705119c5 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 4a209c95ed..0517f7ec72 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(systemName: "fork.knife") diff --git a/Trio Watch App Extension/Views/TrioMainWatchView.swift b/Trio Watch App Extension/Views/TrioMainWatchView.swift index 7ea3b95eb1..ad26386a99 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 @@ -226,6 +227,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 330d67538c..fc8b348a6e 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -4,6 +4,35 @@ 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 diff --git a/Trio Watch App Extension/WatchState.swift b/Trio Watch App Extension/WatchState.swift index 1b1f170aa4..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 @@ -123,6 +131,39 @@ 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 diff --git a/Trio Watch App Tests/Unit Tests.swift b/Trio Watch App Tests/Unit Tests.swift index b42ca8fc8a..996dfb5a53 100644 --- a/Trio Watch App Tests/Unit Tests.swift +++ b/Trio Watch App Tests/Unit Tests.swift @@ -175,4 +175,102 @@ import Testing #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/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 96c454ade6..0501941092 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -131885,6 +131885,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" : { From ade3a11a6342857fc5f51b3e2cd918362b374615 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Nov 2026 04:36:35 +0000 Subject: [PATCH 03/11] CI: Bump APP_DEV_VERSION to 1.0.2.67 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index f28200b894..4facbe4067 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.66 +APP_DEV_VERSION = 1.0.2.67 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 67110b99a3894f34f90eb7569a29178f12979757 Mon Sep 17 00:00:00 2001 From: Liroy van Hoewijk <4643445+LiroyvH@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:10:07 +0100 Subject: [PATCH 04/11] Remove localization strings for CFBundleName and CFBundleDisplayName To fix issue 1754 --- Trio/Resources/InfoPlist.xcstrings | 276 ----------------------------- 1 file changed, 276 deletions(-) diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index fbf2be848f..bcba902aef 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", From 233c31aaf4a6e75f89bd08f629e41a9c06d80f39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Nov 2026 23:51:47 +0000 Subject: [PATCH 05/11] CI: Bump APP_DEV_VERSION to 1.0.2.68 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4facbe4067..0af9a5ca6c 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.67 +APP_DEV_VERSION = 1.0.2.68 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From d1dda095d33ed0aaa2ecf9138ec3ae65211f1be0 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:52:27 +0100 Subject: [PATCH 06/11] Alert when Tidepool login is rejected (#1706) * Alert and flag when Tidepool login is rejected * Drop Tidepool sign-out now TidepoolKit clears it * Skip Tidepool re-login alert when deleting service --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Localizations/Main/Localizable.xcstrings | 6 +++ .../Sources/Modules/Home/HomeStateModel.swift | 7 +++ .../View/HomeRootView+BottomControls.swift | 15 ++++++ .../Home/View/MultiUsePanelState.swift | 4 ++ .../Settings/View/TidepoolStartView.swift | 8 ++-- .../Alerts/AlertCatalogRegistry.swift | 17 ++++++- .../Services/Alerts/AlertCatalogVendor.swift | 4 ++ .../Services/Network/TidepoolManager.swift | 47 ++++++++++++++++++- .../AlertCatalogRegistryEntriesTests.swift | 7 +++ .../CarbEntryMutationServiceTests.swift | 1 + .../MultiUsePanelStateTests.swift | 13 +++++ 12 files changed, 124 insertions(+), 7 deletions(-) diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index accbe9aa1e..cc9ad05622 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/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index e58c15cdde..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." : { diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index aeae7646c9..e14784d9c2 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -22,6 +22,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! @@ -125,6 +126,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? @@ -645,6 +647,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 24bfaf32aa..ed66e5fdd7 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -646,6 +646,7 @@ extension Home.RootView { pumpTimeMismatch: state.pumpStatusBadgeImage != nil, lastGlucoseDate: state.glucoseFromPersistence.last?.date, maxIOB: state.maxIOB, + tidepoolLoginRequired: state.tidepoolLoginRequired, hasUnacknowledgedReleaseNotes: releaseNotesService.hasUnacknowledgedNotes, dosingMode: state.dosingMode, now: state.timerDate @@ -741,6 +742,15 @@ extension Home.RootView { ) { openMaxIOBSetting() } + 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() + } case .whatsNew: panelBanner( systemImage: "sparkles", @@ -784,6 +794,11 @@ extension Home.RootView { )) } + func openTidepoolSetting() { + selectedTab = 3 + settingsPath.append(SearchResultTarget(screen: .tidepoolConfig, scrollLabel: "Tidepool Integration".localized)) + } + /// Bottom-anchored fixed zone: adjustment/bolus panel above the stats banner. @ViewBuilder func bottomControls() -> some View { VStack(spacing: HomeLayout.bottomZonePadding) { diff --git a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift index 7772ec04e1..1b06661247 100644 --- a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift +++ b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift @@ -6,6 +6,7 @@ enum MultiUsePanelState: Equatable { case pumpTimeMismatch case cgmStale case maxIOBZero + case tidepoolLoginRequired case whatsNew case dosingModeLimited(DosingMode) case stats @@ -18,6 +19,7 @@ enum MultiUsePanelState: Equatable { pumpTimeMismatch: Bool, lastGlucoseDate: Date?, maxIOB: Decimal, + tidepoolLoginRequired: Bool, hasUnacknowledgedReleaseNotes: Bool, dosingMode: DosingMode, now: Date @@ -30,6 +32,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 } // Informational, so it yields to every warning above but still displaces the stats. if hasUnacknowledgedReleaseNotes { return .whatsNew } return .stats 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/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/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/CoreDataTests/CarbEntryMutationServiceTests.swift b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift index beec5c8fdf..ed2abd815d 100644 --- a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift +++ b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift @@ -264,6 +264,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 f16397ec3d..994817679b 100644 --- a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift +++ b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift @@ -13,6 +13,7 @@ import Testing pumpTimeMismatch: Bool = false, lastGlucoseDate: Date?, maxIOB: Decimal = 10, + tidepoolLoginRequired: Bool = false, hasUnacknowledgedReleaseNotes: Bool = false, dosingMode: DosingMode = .closed ) -> MultiUsePanelState { @@ -21,6 +22,7 @@ import Testing pumpTimeMismatch: pumpTimeMismatch, lastGlucoseDate: lastGlucoseDate, maxIOB: maxIOB, + tidepoolLoginRequired: tidepoolLoginRequired, hasUnacknowledgedReleaseNotes: hasUnacknowledgedReleaseNotes, dosingMode: dosingMode, now: now @@ -103,4 +105,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) + } } From 6246f33bcce03e451303a87cdad5ead6cb50227a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Nov 2026 23:52:39 +0000 Subject: [PATCH 07/11] CI: Bump APP_DEV_VERSION to 1.0.2.69 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 0af9a5ca6c..8d335e5394 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.68 +APP_DEV_VERSION = 1.0.2.69 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 204c43bd64d345f3c740bb6a2cfa3e781cb8a678 Mon Sep 17 00:00:00 2001 From: Mike Plante <82073483+MikePlante1@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:54:34 -0500 Subject: [PATCH 08/11] Improve automated TestFlight notes for browser build (#1695) * Include dev version in TestFlight build notes Builds with a 4-part APP_DEV_VERSION (anything off main) now show it alongside the branch and commit, e.g. "dev 1.0.2.33 abc1234". * Create TestFlight What to Test note without a manual first entry fastlane's changelog option only updates What to Test notes that already exist on a build, so apps that never had one entered by hand got no notes. Passing the app's primary locale through localized_build_info makes fastlane create the note. * Add bundled release notes to TestFlight build notes Reads BundledReleaseNotes.json from the IPA (written by scripts/capture-release-notes.sh) and appends the newest release's summary, callouts and At A Glance bullets, matching what the in-app sheet shows. Builds without bundled notes keep the branch, version and commit line only. * Only add release notes to TestFlight notes on main builds Also fall back to en-US if the primary locale lookup fails, and read the bundled notes as UTF-8 regardless of the runner's locale. --- fastlane/Fastfile | 89 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2497eb86ec..f2be4b4664 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 Trio" @@ -145,13 +209,34 @@ platform :ios do issuer_id: "#{FASTLANE_ISSUER_ID}", 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 }, + }, ) end From 1197b518ec2a5bfebaa4e97831e42ba3041bcf02 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Nov 2026 22:54:50 +0000 Subject: [PATCH 09/11] CI: Bump APP_DEV_VERSION to 1.0.2.70 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 8d335e5394..c6a86ef2e8 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.69 +APP_DEV_VERSION = 1.0.2.70 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 86a9b5ea4f1c333936b09aaccb8957d42cfeed6a Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Fri, 13 Nov 2026 18:24:59 +0100 Subject: [PATCH 10/11] Fix "Sponsor this project" link --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5b46bd62b9..82877d12e5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -custom: ["https://www.nightscoutfoundation.org/donate"] +custom: ["https://donate.nightscoutfoundation.org"] From 3f6b238b450d423f9aa2585e173d50d212d5273d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Nov 2026 18:29:41 +0000 Subject: [PATCH 11/11] CI: Bump APP_DEV_VERSION to 1.0.2.71 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index c6a86ef2e8..7ddc421a39 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.70 +APP_DEV_VERSION = 1.0.2.71 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE =