Files
Reflect/Shared/Services/BiometricAuthManager.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

178 lines
5.0 KiB
Swift

//
// BiometricAuthManager.swift
// Reflect
//
// Manages Face ID / Touch ID authentication for app privacy lock.
//
import Foundation
import LocalAuthentication
import SwiftUI
@MainActor
class BiometricAuthManager: ObservableObject {
// MARK: - Published State
@Published var isUnlocked: Bool = true
@Published var isAuthenticating: Bool = false
// MARK: - App Storage
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults)
var isLockEnabled: Bool = false
// MARK: - Biometric Capabilities
var canUseBiometrics: Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
var canUseDevicePasscode: Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
}
var biometricType: LABiometryType {
let context = LAContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return context.biometryType
}
var biometricName: String {
switch biometricType {
case .none:
return "Passcode"
case .faceID:
return "Face ID"
case .touchID:
return "Touch ID"
case .opticID:
return "Optic ID"
@unknown default:
return "Biometrics"
}
}
var biometricIcon: String {
switch biometricType {
case .none:
return "lock.fill"
case .faceID:
return "faceid"
case .touchID:
return "touchid"
case .opticID:
return "opticid"
@unknown default:
return "lock.fill"
}
}
// MARK: - Authentication
func authenticate() async -> Bool {
guard isLockEnabled else {
isUnlocked = true
return true
}
let context = LAContext()
context.localizedCancelTitle = "Cancel"
// Try biometrics first, fall back to device passcode
let policy: LAPolicy = canUseBiometrics ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
isAuthenticating = true
defer { isAuthenticating = false }
do {
let success = try await context.evaluatePolicy(
policy,
localizedReason: "Unlock Reflect to access your mood data"
)
isUnlocked = success
if success {
AnalyticsManager.shared.track(.biometricUnlockSuccess)
}
return success
} catch {
print("Authentication failed: \(error.localizedDescription)")
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
// If biometrics failed, try device passcode as fallback
if canUseDevicePasscode && policy == .deviceOwnerAuthenticationWithBiometrics {
return await authenticateWithPasscode()
}
return false
}
}
private func authenticateWithPasscode() async -> Bool {
let context = LAContext()
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Unlock Reflect to access your mood data"
)
isUnlocked = success
return success
} catch {
print("Passcode authentication failed: \(error.localizedDescription)")
return false
}
}
// MARK: - Lock Management
func lock() {
guard isLockEnabled else { return }
isUnlocked = false
AnalyticsManager.shared.track(.appLocked)
}
func enableLock() async -> Bool {
// Authenticate first to enable lock - require biometrics
let context = LAContext()
var error: NSError?
// Only allow enabling if biometrics are available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
return false
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Verify your identity to enable app lock"
)
if success {
isLockEnabled = true
isUnlocked = true
AnalyticsManager.shared.track(.privacyLockEnabled)
}
return success
} catch {
print("Failed to enable lock: \(error.localizedDescription)")
return false
}
}
func disableLock() {
isLockEnabled = false
isUnlocked = true
AnalyticsManager.shared.track(.privacyLockDisabled)
}
}