Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -461,6 +462,7 @@
DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = "<group>"; };
DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = "<group>"; };
DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = "<group>"; };
DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = "<group>"; };
DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = "<group>"; };
DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSettingsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1484,6 +1486,7 @@
FCC688542489367300A0279D /* Helpers */ = {
isa = PBXGroup;
children = (
DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
55 changes: 55 additions & 0 deletions LoopFollow/Helpers/AuthService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
47 changes: 15 additions & 32 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
29 changes: 2 additions & 27 deletions LoopFollow/Remote/TRC/BolusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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
Expand Down
29 changes: 2 additions & 27 deletions LoopFollow/Remote/TRC/MealView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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)
}
}
}
}