Complete analytics overhaul: delete EventLogger.swift, create Analytics.swift with typed event enum (~45 events), screen tracking, super properties (theme, icon pack, voting layout, etc.), session replay with kill switch, autocapture, and network telemetry. Replace all 99 call sites across 38 files with compiler-enforced typed events in object_action naming convention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
5.0 KiB
Swift
178 lines
5.0 KiB
Swift
//
|
|
// BiometricAuthManager.swift
|
|
// Feels
|
|
//
|
|
// 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 Feels 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 Feels 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)
|
|
}
|
|
}
|