Files
letro-ios/ElementX/Sources/Services/AppLock/AppLockService.swift
2026-01-27 12:50:57 +02:00

196 lines
6.6 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import LocalAuthentication
/// The service responsible for locking and unlocking the app.
class AppLockService: AppLockServiceProtocol {
private let keychainController: KeychainControllerProtocol
private let appSettings: AppSettings
private let context: LAContext
private let timer: AppLockTimer
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
var isMandatory: Bool {
appSettings.appLockIsMandatory
}
var isEnabled: Bool {
do {
return try keychainController.containsPINCode()
} catch {
MXLog.error("Keychain access error: \(error)")
MXLog.error("Locking the app.")
return true
}
}
private var isEnabledSubject: PassthroughSubject<Bool, Never> = .init()
var isEnabledPublisher: AnyPublisher<Bool, Never> {
isEnabledSubject.eraseToAnyPublisher()
}
var biometryType: LABiometryType {
updateBiometrics()
guard context.evaluatedPolicyDomainState != nil else { return .none }
return context.biometryType
}
var biometricUnlockEnabled: Bool {
keychainController.containsPINCodeBiometricState()
}
var biometricUnlockTrusted: Bool {
guard let state = keychainController.pinCodeBiometricState() else { return false }
updateBiometrics()
return state == context.evaluatedPolicyDomainState
}
var numberOfPINAttempts: AnyPublisher<Int, Never> {
appSettings.$appLockNumberOfPINAttempts
}
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) {
self.keychainController = keychainController
self.appSettings = appSettings
self.context = context
timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod)
updateBiometrics()
}
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> {
let result = validate(pinCode)
guard case .success = result else { return result }
do {
try keychainController.setPINCode(pinCode)
isEnabledSubject.send(true)
return .success(())
} catch {
MXLog.error("Keychain access error: \(error)")
return .failure(.keychainError)
}
}
func validate(_ pinCode: String) -> Result<Void, AppLockServiceError> {
guard pinCode.count == 4, pinCode.allSatisfy(\.isNumber) else { return .failure(.invalidPIN) }
guard !appSettings.appLockPINCodeBlockList.contains(pinCode) else { return .failure(.weakPIN) }
return .success(())
}
func enableBiometricUnlock() -> Result<Void, AppLockServiceError> {
guard isEnabled else { return .failure(.pinNotSet) }
guard let state = context.evaluatedPolicyDomainState else { return .failure(.biometricUnlockNotSupported) }
do {
try keychainController.setPINCodeBiometricState(state)
return .success(())
} catch {
MXLog.error("Keychain access error: \(error)")
return .failure(.keychainError)
}
}
func disableBiometricUnlock() {
keychainController.removePINCodeBiometricState()
}
func disable() {
keychainController.removePINCode()
keychainController.removePINCodeBiometricState()
appSettings.appLockNumberOfPINAttempts = 0
isEnabledSubject.send(false)
}
func applicationDidEnterBackground() {
timer.applicationDidEnterBackground()
}
func computeNeedsUnlock(didBecomeActiveAt date: Date) -> Bool {
timer.computeLockState(didBecomeActiveAt: date)
}
func unlock(with pinCode: String) -> Bool {
guard pinCode == keychainController.pinCode() else {
MXLog.warning("Wrong PIN entered.")
appSettings.appLockNumberOfPINAttempts += 1
return false
}
if biometricUnlockEnabled, !biometricUnlockTrusted {
MXLog.info("Fixing trust for biometric unlock.")
updateBiometrics()
_ = enableBiometricUnlock()
}
completeUnlock()
return true
}
func unlockWithBiometrics() async -> AppLockServiceBiometricResult {
guard biometryType != .none, biometricUnlockEnabled else {
MXLog.error("Biometric unlock not setup.")
return .failed
}
guard biometricUnlockTrusted else {
MXLog.error("Biometrics have changed. PIN should be shown.")
return .failed
}
do {
let context = unlockContext()
guard try await context.evaluatePolicy(unlockPolicy, localizedReason: L10n.screenAppLockBiometricUnlockReasonIos) else {
MXLog.warning("\(context.biometryType) failed without error.")
return .failed
}
completeUnlock()
return .unlocked
} catch LAError.systemCancel {
MXLog.error("\(context.biometryType) failed: The system cancelled.")
return .interrupted
} catch {
MXLog.error("\(context.biometryType) failed: \(error)")
return .failed
}
}
// MARK: - Private
/// Queries the context for supported biometrics and enrolment state.
private func updateBiometrics() {
var error: NSError?
context.canEvaluatePolicy(unlockPolicy, error: &error)
if let error {
MXLog.error("Biometrics error: \(error)")
}
}
/// Creates a context specifically for unlocking the app. The titles are customised,
/// and the fresh context ensures that the user is promoted to unlock based on
/// `timer.gracePeriod` rather than any system to defined grace period.
private func unlockContext() -> LAContext {
// Keep using the injected context for tests etc.
guard type(of: context) == LAContext.self else { return context }
let context = LAContext()
context.localizedFallbackTitle = L10n.actionEnterPin
return context
}
/// Shared logic for completing an unlock via a PIN or biometrics.
private func completeUnlock() {
timer.registerUnlock()
appSettings.appLockNumberOfPINAttempts = 0
}
}