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 d2a04290f..90fbb797d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -315,39 +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 { - 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 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 - } - } - } - } else { - alertMessage = "Biometric authentication not available" - alertType = .error - showAlert = true } } diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index a7af74142..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,31 +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 { - completion(success) - } - } - } else 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 789d501f3..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,29 +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 { - completion(success) - } - } - } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - completion(success) - } - } - } else { - DispatchQueue.main.async { - completion(false) - } - } - } }