Files
Reflect/Shared/Services/BiometricAuthManager.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00

186 lines
5.1 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 {
#if DEBUG
print("Authentication failed: \(error.localizedDescription)")
#endif
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 {
#if DEBUG
print("Passcode authentication failed: \(error.localizedDescription)")
#endif
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 {
#if DEBUG
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
#endif
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 {
#if DEBUG
print("Failed to enable lock: \(error.localizedDescription)")
#endif
return false
}
}
func disableLock() {
isLockEnabled = false
isUnlocked = true
AnalyticsManager.shared.track(.privacyLockDisabled)
}
}