- 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>
186 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|