From b6d461aad6819d928e2e3e87d1c93130ba730ab0 Mon Sep 17 00:00:00 2001 From: codebymini Date: Fri, 5 Sep 2025 10:59:14 +0200 Subject: [PATCH 1/3] Add passcode fallback for Loop APNS insulin --- .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index d2a04290f..f6e9dbf4d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -326,13 +326,28 @@ struct LoopAPNSBolusView: View { if success { sendInsulinConfirmed() } else { - alertMessage = "Authentication failed" - alertType = .error - showAlert = true + // Biometric authentication failed, try passcode fallback + self.authenticateWithPasscode() } } } } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + // No biometrics available, go directly to passcode + authenticateWithPasscode() + } else { + alertMessage = "Authentication not available" + alertType = .error + showAlert = true + } + } + + private func authenticateWithPasscode() { + let context = LAContext() + var error: NSError? + + let reason = "Confirm your identity to send insulin." + + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in DispatchQueue.main.async { if success { @@ -345,7 +360,7 @@ struct LoopAPNSBolusView: View { } } } else { - alertMessage = "Biometric authentication not available" + alertMessage = "Authentication not available" alertType = .error showAlert = true } From 613ccb39d6c7bc5ee4ec65d9373e06235a076f55 Mon Sep 17 00:00:00 2001 From: codebymini Date: Fri, 5 Sep 2025 11:17:15 +0200 Subject: [PATCH 2/3] Add fallback to passcode for Trio Bolus and Meal --- LoopFollow/Remote/TRC/BolusView.swift | 21 ++++++++++++++++++--- LoopFollow/Remote/TRC/MealView.swift | 21 ++++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index a7af74142..01ef6b16c 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -130,16 +130,31 @@ struct BolusView: View { private func authenticateUser(completion: @escaping (Bool) -> Void) { let context = LAContext() var error: NSError? - let reason = "Confirm your identity to send bolus." if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in DispatchQueue.main.async { - completion(success) + if success { + completion(true) + } else { + // Biometric failed, try passcode + self.tryPasscode(completion: completion) + } } } - } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + } else { + // No biometrics available, try passcode directly + tryPasscode(completion: completion) + } + } + + private func tryPasscode(completion: @escaping (Bool) -> Void) { + let context = LAContext() + var error: NSError? + let reason = "Confirm your identity to send bolus." + + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in DispatchQueue.main.async { completion(success) diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 789d501f3..f1de53b90 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -304,16 +304,31 @@ struct MealView: View { private func authenticateUser(completion: @escaping (Bool) -> Void) { let context = LAContext() var error: NSError? - let reason = "Confirm your identity to send bolus." if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in DispatchQueue.main.async { - completion(success) + if success { + completion(true) + } else { + // Biometric failed, try passcode + self.tryPasscode(completion: completion) + } } } - } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + } else { + // No biometrics available, try passcode directly + tryPasscode(completion: completion) + } + } + + private func tryPasscode(completion: @escaping (Bool) -> Void) { + let context = LAContext() + var error: NSError? + let reason = "Confirm your identity to send bolus." + + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in DispatchQueue.main.async { completion(success) From 3829a7481500fbd4b5b200c2ead1fbc8d362c4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 5 Sep 2025 14:08:42 +0200 Subject: [PATCH 3/3] Simplified and DRYed bolus auth --- LoopFollow.xcodeproj/project.pbxproj | 4 ++ LoopFollow/Helpers/AuthService.swift | 55 ++++++++++++++++ .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 62 +++++-------------- LoopFollow/Remote/TRC/BolusView.swift | 44 +------------ LoopFollow/Remote/TRC/MealView.swift | 44 +------------ 5 files changed, 78 insertions(+), 131 deletions(-) create mode 100644 LoopFollow/Helpers/AuthService.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index ea3a54623..9ed05d28a 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE42ACF2383009A6922 /* Treatments.swift */; }; DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */; }; DD493AE92ACF2445009A6922 /* BGData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE82ACF2445009A6922 /* BGData.swift */; }; + DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A407D2E6AFEE6007B318B /* AuthService.swift */; }; DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */; }; DD4AFB3D2DB55D2900BB593F /* AlarmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */; }; DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */; }; @@ -461,6 +462,7 @@ DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = ""; }; DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = ""; }; DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = ""; }; + DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = ""; }; DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = ""; }; DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSettingsView.swift; sourceTree = ""; }; @@ -1484,6 +1486,7 @@ FCC688542489367300A0279D /* Helpers */ = { isa = PBXGroup; children = ( + DD4A407D2E6AFEE6007B318B /* AuthService.swift */, DD1D52B82E1EB5DC00432050 /* TabPosition.swift */, DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */, DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */, @@ -2013,6 +2016,7 @@ DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */, DDB9FC7F2DDB584500EFAA76 /* BolusEntry.swift in Sources */, FC9788182485969B00A7906C /* AppDelegate.swift in Sources */, + DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */, 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */, DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */, DDD10F072C529DE800D76A8E /* Observable.swift in Sources */, diff --git a/LoopFollow/Helpers/AuthService.swift b/LoopFollow/Helpers/AuthService.swift new file mode 100644 index 000000000..c00b1db9b --- /dev/null +++ b/LoopFollow/Helpers/AuthService.swift @@ -0,0 +1,55 @@ +// LoopFollow +// AuthService.swift + +import Foundation +import LocalAuthentication + +public enum AuthResult { + case success + case canceled + case unavailable + case failed +} + +public enum AuthService { + /// Unified authentication that prefers biometrics and falls back to passcode automatically. + /// - Parameters: + /// - reason: Shown in the system auth prompt. + /// - reuseDuration: Optional Touch ID/Face ID reuse window (seconds). 0 disables reuse. + /// - completion: Returns an `AuthResult` representing the outcome. + public static func authenticate(reason: String, + reuseDuration: TimeInterval = 0, + completion: @escaping (AuthResult) -> Void) + { + let context = LAContext() + context.localizedFallbackTitle = "Enter Passcode" + if reuseDuration > 0 { + context.touchIDAuthenticationAllowableReuseDuration = reuseDuration + } + + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + completion(.unavailable) + return + } + + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, err in + DispatchQueue.main.async { + if success { + completion(.success) + return + } + if let e = err as? LAError { + switch e.code { + case .userCancel, .systemCancel, .appCancel: + completion(.canceled) + default: + completion(.failed) + } + } else { + completion(.failed) + } + } + } + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index f6e9dbf4d..90fbb797d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -315,54 +315,22 @@ struct LoopAPNSBolusView: View { } private func authenticateAndSendInsulin() { - let context = LAContext() - var error: NSError? - - let reason = "Confirm your identity to send insulin." - - if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - if success { - sendInsulinConfirmed() - } else { - // Biometric authentication failed, try passcode fallback - self.authenticateWithPasscode() - } - } - } - } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - // No biometrics available, go directly to passcode - authenticateWithPasscode() - } else { - alertMessage = "Authentication not available" - alertType = .error - showAlert = true - } - } - - private func authenticateWithPasscode() { - let context = LAContext() - var error: NSError? - - let reason = "Confirm your identity to send insulin." - - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - if success { - sendInsulinConfirmed() - } else { - alertMessage = "Authentication failed" - alertType = .error - showAlert = true - } - } + AuthService.authenticate(reason: "Confirm your identity to send insulin.") { result in + switch result { + case .success: + sendInsulinConfirmed() + case .unavailable: + alertMessage = "Authentication not available" + alertType = .error + showAlert = true + case .failed: + alertMessage = "Authentication failed" + alertType = .error + showAlert = true + case .canceled: + // User canceled: no alert to avoid spammy UX + break } - } else { - alertMessage = "Authentication not available" - alertType = .error - showAlert = true } } diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 01ef6b16c..ed4669829 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -71,8 +71,8 @@ struct BolusView: View { title: Text("Confirm Bolus"), message: Text("Are you sure you want to send \(bolusAmount.doubleValue(for: HKUnit.internationalUnit()), specifier: "%.2f") U?"), primaryButton: .default(Text("Confirm"), action: { - authenticateUser { success in - if success { + AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in + if case .success = result { sendBolus() } } @@ -127,46 +127,6 @@ struct BolusView: View { } } - private func authenticateUser(completion: @escaping (Bool) -> Void) { - let context = LAContext() - var error: NSError? - let reason = "Confirm your identity to send bolus." - - if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - if success { - completion(true) - } else { - // Biometric failed, try passcode - self.tryPasscode(completion: completion) - } - } - } - } else { - // No biometrics available, try passcode directly - tryPasscode(completion: completion) - } - } - - private func tryPasscode(completion: @escaping (Bool) -> Void) { - let context = LAContext() - var error: NSError? - let reason = "Confirm your identity to send bolus." - - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - completion(success) - } - } - } else { - DispatchQueue.main.async { - completion(false) - } - } - } - private func handleValidationError(_ message: String) { alertMessage = message alertType = .validation diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index f1de53b90..ef9b096ed 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -195,8 +195,8 @@ struct MealView: View { primaryButton: .default(Text("Confirm"), action: { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if bolusAmount > 0 { - authenticateUser { success in - if success { + AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in + if case .success = result { sendMealCommand() } } @@ -300,44 +300,4 @@ struct MealView: View { alertType = .validationError showAlert = true } - - private func authenticateUser(completion: @escaping (Bool) -> Void) { - let context = LAContext() - var error: NSError? - let reason = "Confirm your identity to send bolus." - - if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - if success { - completion(true) - } else { - // Biometric failed, try passcode - self.tryPasscode(completion: completion) - } - } - } - } else { - // No biometrics available, try passcode directly - tryPasscode(completion: completion) - } - } - - private func tryPasscode(completion: @escaping (Bool) -> Void) { - let context = LAContext() - var error: NSError? - let reason = "Confirm your identity to send bolus." - - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - completion(success) - } - } - } else { - DispatchQueue.main.async { - completion(false) - } - } - } }