// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation import LocalAuthentication @objc public class OWSScreenLock: NSObject { public enum OWSScreenLockOutcome { case success case cancel case failure(error:String) case unexpectedFailure(error:String) } @objc public let screenLockTimeoutDefault = 15 * kMinuteInterval @objc public let screenLockTimeouts = [ 1 * kMinuteInterval, 5 * kMinuteInterval, 15 * kMinuteInterval, 30 * kMinuteInterval, 1 * kHourInterval, 0 ] @objc public static let ScreenLockDidChange = Notification.Name("ScreenLockDidChange") let primaryStorage: OWSPrimaryStorage let dbConnection: YapDatabaseConnection private let OWSScreenLock_Collection = "OWSScreenLock_Collection" private let OWSScreenLock_Key_IsScreenLockEnabled = "OWSScreenLock_Key_IsScreenLockEnabled" private let OWSScreenLock_Key_ScreenLockTimeoutSeconds = "OWSScreenLock_Key_ScreenLockTimeoutSeconds" // MARK - Singleton class @objc(sharedManager) public static let shared = OWSScreenLock() private override init() { self.primaryStorage = OWSPrimaryStorage.shared() self.dbConnection = self.primaryStorage.newDatabaseConnection() super.init() SwiftSingletons.register(self) } // MARK: - Properties @objc public func isScreenLockEnabled() -> Bool { SwiftAssertIsOnMainThread(#function) if !OWSStorage.isStorageReady() { owsFail("\(logTag) accessed screen lock state before storage is ready.") return false } return self.dbConnection.bool(forKey: OWSScreenLock_Key_IsScreenLockEnabled, inCollection: OWSScreenLock_Collection, defaultValue: false) } @objc public func setIsScreenLockEnabled(_ value: Bool) { SwiftAssertIsOnMainThread(#function) assert(OWSStorage.isStorageReady()) self.dbConnection.setBool(value, forKey: OWSScreenLock_Key_IsScreenLockEnabled, inCollection: OWSScreenLock_Collection) NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) } @objc public func screenLockTimeout() -> TimeInterval { SwiftAssertIsOnMainThread(#function) if !OWSStorage.isStorageReady() { owsFail("\(logTag) accessed screen lock state before storage is ready.") return 0 } return self.dbConnection.double(forKey: OWSScreenLock_Key_ScreenLockTimeoutSeconds, inCollection: OWSScreenLock_Collection, defaultValue: screenLockTimeoutDefault) } @objc public func setScreenLockTimeout(_ value: TimeInterval) { SwiftAssertIsOnMainThread(#function) assert(OWSStorage.isStorageReady()) self.dbConnection.setDouble(value, forKey: OWSScreenLock_Key_ScreenLockTimeoutSeconds, inCollection: OWSScreenLock_Collection) NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) } // MARK: - Methods // This method should only be called: // // * On the main thread. // // Exactly one of these completions will be performed: // // * Asynchronously. // * On the main thread. @objc public func tryToUnlockScreenLock(success: @escaping (() -> Void), failure: @escaping ((Error) -> Void), unexpectedFailure: @escaping ((Error) -> Void), cancel: @escaping (() -> Void)) { SwiftAssertIsOnMainThread(#function) tryToVerifyLocalAuthentication(localizedReason: NSLocalizedString("SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK", comment: "Description of how and why Signal iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'."), completion: { (outcome: OWSScreenLockOutcome) in SwiftAssertIsOnMainThread(#function) switch outcome { case .failure(let error): Logger.error("\(self.logTag) local authentication failed with error: \(error)") failure(self.authenticationError(errorDescription: error)) case .unexpectedFailure(let error): Logger.error("\(self.logTag) local authentication failed with unexpected error: \(error)") unexpectedFailure(self.authenticationError(errorDescription: error)) case .success: Logger.verbose("\(self.logTag) local authentication succeeded.") success() case .cancel: Logger.verbose("\(self.logTag) local authentication cancelled.") cancel() } }) } // This method should only be called: // // * On the main thread. // // completionParam will be performed: // // * Asynchronously. // * On the main thread. private func tryToVerifyLocalAuthentication(localizedReason: String, completion completionParam: @escaping ((OWSScreenLockOutcome) -> Void)) { SwiftAssertIsOnMainThread(#function) let defaultErrorDescription = NSLocalizedString("SCREEN_LOCK_ENABLE_UNKNOWN_ERROR", comment: "Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode.") // Ensure completion is always called on the main thread. let completion = { (outcome: OWSScreenLockOutcome) in DispatchQueue.main.async { completionParam(outcome) } } let context = screenLockContext() var authError: NSError? let canEvaluatePolicy = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authError) if !canEvaluatePolicy || authError != nil { Logger.error("\(logTag) could not determine if local authentication is supported: \(String(describing: authError))") let outcome = self.outcomeForLAError(errorParam: authError, defaultErrorDescription: defaultErrorDescription) switch outcome { case .success: owsFail("\(self.logTag) local authentication unexpected success") completion(.failure(error:defaultErrorDescription)) case .cancel, .failure, .unexpectedFailure: completion(outcome) } return } context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: localizedReason) { success, evaluateError in if success { Logger.info("\(self.logTag) local authentication succeeded.") completion(.success) } else { let outcome = self.outcomeForLAError(errorParam: evaluateError, defaultErrorDescription: defaultErrorDescription) switch outcome { case .success: owsFail("\(self.logTag) local authentication unexpected success") completion(.failure(error:defaultErrorDescription)) case .cancel, .failure, .unexpectedFailure: completion(outcome) } } } } // MARK: - Outcome private func outcomeForLAError(errorParam: Error?, defaultErrorDescription: String) -> OWSScreenLockOutcome { if let error = errorParam { guard let laError = error as? LAError else { return .failure(error:defaultErrorDescription) } if #available(iOS 11.0, *) { switch laError.code { case .biometryNotAvailable: Logger.error("\(self.logTag) local authentication error: biometryNotAvailable.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.")) case .biometryNotEnrolled: Logger.error("\(self.logTag) local authentication error: biometryNotEnrolled.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.")) case .biometryLockout: Logger.error("\(self.logTag) local authentication error: biometryLockout.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) default: // Fall through to second switch break } } switch laError.code { case .authenticationFailed: Logger.error("\(self.logTag) local authentication error: authenticationFailed.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED", comment: "Indicates that Touch ID/Face ID/Phone Passcode authentication failed.")) case .userCancel, .userFallback, .systemCancel, .appCancel: Logger.info("\(self.logTag) local authentication cancelled.") return .cancel case .passcodeNotSet: Logger.error("\(self.logTag) local authentication error: passcodeNotSet.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET", comment: "Indicates that Touch ID/Face ID/Phone Passcode passcode is not set.")) case .touchIDNotAvailable: Logger.error("\(self.logTag) local authentication error: touchIDNotAvailable.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.")) case .touchIDNotEnrolled: Logger.error("\(self.logTag) local authentication error: touchIDNotEnrolled.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.")) case .touchIDLockout: Logger.error("\(self.logTag) local authentication error: touchIDLockout.") return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) case .invalidContext: owsFail("\(self.logTag) context not valid.") return .unexpectedFailure(error:defaultErrorDescription) case .notInteractive: owsFail("\(self.logTag) context not interactive.") return .unexpectedFailure(error:defaultErrorDescription) } } return .failure(error:defaultErrorDescription) } private func authenticationError(errorDescription: String) -> Error { return OWSErrorWithCodeDescription(.localAuthenticationError, errorDescription) } // MARK: - Context private func screenLockContext() -> LAContext { let context = LAContext() // Never recycle biometric auth. context.touchIDAuthenticationAllowableReuseDuration = TimeInterval(0) if #available(iOS 11.0, *) { assert(!context.interactionNotAllowed) } return context } }