// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI class ScreenLockUI { // Unlike UIApplication.applicationState, this state reflects the // notifications, i.e. "did become active", "will resign active", // "will enter foreground", "did enter background". // // We want to update our state to reflect these transitions and have // the "update" logic be consistent with "last reported" state. i.e. // when you're responding to "will resign active", we need to behave // as though we're already inactive. // // Secondly, we need to show the screen protection _before_ we become // inactive in order for it to be reflected in the app switcher. private var appIsInactiveOrBackground: Bool = false { didSet { AssertIsOnMainThread() if appIsInactiveOrBackground { if !isShowingScreenLockUI { startScreenLockCountdownIfNecessary() } } else { tryToActivateScreenLockBasedOnCountdown() screenLockCountdownTimestamp = nil } ensureUI() } } private var appIsInBackground: Bool = false { didSet { AssertIsOnMainThread() if appIsInBackground { startScreenLockCountdownIfNecessary() } else { tryToActivateScreenLockBasedOnCountdown() } ensureUI() } } private var isShowingScreenLockUI: Bool = false private var didLastUnlockAttemptFail: Bool = false // We want to remain in "screen lock" mode while "local auth" // UI is dismissing. So we lazily clear isShowingScreenLockUI // using this property. private var shouldClearAuthUIWhenActive: Bool = false // Indicates whether or not the user is currently locked out of // the app. Should only be set if OWSScreenLock.isScreenLockEnabled. // // * The user is locked out by default on app launch. // * The user is also locked out if they spend more than // "timeout" seconds outside the app. When the user leaves // the app, a "countdown" begins. private var isScreenLockLocked: Bool = false { didSet { AssertIsOnMainThread() guard !isScreenLockLocked else { return } if let pending = pendingScreenUnlockContinuation { pendingScreenUnlockContinuation = nil pending.resume() } } } struct ScreenUnlockActionReplacedError: Error {} private var pendingScreenUnlockContinuation: CheckedContinuation? @MainActor func waitForScreenUnlockThrowingPrevious() async throws { AssertIsOnMainThread() tryToActivateScreenLockBasedOnCountdown() ensureUI() if !isScreenLockLocked { return } if let existing = pendingScreenUnlockContinuation { pendingScreenUnlockContinuation = nil existing.resume(throwing: ScreenUnlockActionReplacedError()) } try await withCheckedThrowingContinuation { continuation in self.pendingScreenUnlockContinuation = continuation } } // The "countdown" until screen lock takes effect. private var screenLockCountdownTimestamp: UInt64? lazy var screenBlockingWindow: UIWindow = { let window = OWSWindow(frame: .zero) window.isHidden = false window.windowLevel = ._background window.isOpaque = true window.backgroundColor = Theme.launchScreenBackgroundColor return window }() private lazy var screenBlockingViewController: ScreenLockViewController = { let viewController = ScreenLockViewController() viewController.delegate = self return viewController }() private let appReadiness: AppReadiness init(appReadiness: AppReadiness) { AssertIsOnMainThread() self.appReadiness = appReadiness } // MARK: - func setupWithRootWindow(_ rootWindow: UIWindow) { AssertIsOnMainThread() createScreenBlockingWindowWithRootWindow(rootWindow) } func startObserving() { appIsInactiveOrBackground = UIApplication.shared.applicationState != .active NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: NSNotification.Name.OWSApplicationDidBecomeActive, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillResignActive), name: NSNotification.Name.OWSApplicationWillResignActive, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(applicationWillEnterForeground), name: NSNotification.Name.OWSApplicationWillEnterForeground, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidEnterBackground), name: NSNotification.Name.OWSApplicationDidEnterBackground, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(screenLockDidChange), name: ScreenLock.ScreenLockDidChange, object: nil, ) // Hide the screen blocking window until "app is ready" to // avoid blocking the loading view. updateScreenBlockingWindowWithUIState(.none) // Initialize the screen lock state. // // It's not safe to access OWSScreenLock.isScreenLockEnabled // until the app is ready. appReadiness.runNowOrWhenAppWillBecomeReady { self.isScreenLockLocked = ScreenLock.shared.isScreenLockEnabled() self.ensureUI() } } // MARK: - Sensitive Content /// Tracks view controllers displaying "sensitive content" that should be /// hidden from the App Switcher. private var sensitiveContentViewControllers: WeakArray = [] @MainActor func sensitiveContentDidLoad(inViewController viewController: UIViewController) { if sensitiveContentViewControllers.contains(where: { $0 === viewController }) { return } sensitiveContentViewControllers.append(viewController) } // MARK: - UI private func updateScreenBlockingWindowWithUIState(_ uiState: ScreenLockViewController.UIState) { AssertIsOnMainThread() let shouldShowBlockWindow = uiState != .none AppEnvironment.shared.windowManagerRef.isScreenBlockActive = shouldShowBlockWindow screenBlockingViewController.updateUIWithState(uiState) } // 'Screen Blocking' window obscures the app screen: // // * In the app switcher. // * During 'Screen Lock' unlock process. private func createScreenBlockingWindowWithRootWindow(_ rootWindow: UIWindow) { AssertIsOnMainThread() screenBlockingWindow.frame = rootWindow.bounds screenBlockingWindow.rootViewController = screenBlockingViewController } // Ensure that: // // * The blocking window has the correct state. // * That we show the "iOS auth UI to unlock" if necessary. private func ensureUI() { AssertIsOnMainThread() guard appReadiness.isAppReady else { appReadiness.runNowOrWhenAppWillBecomeReady { self.ensureUI() } return } let desiredUIState = desiredUIState() updateScreenBlockingWindowWithUIState(desiredUIState) // Show the "iOS auth UI to unlock" if necessary. if desiredUIState == .screenLock, !didLastUnlockAttemptFail { tryToPresentAuthUIToUnlockScreenLock() } } private func clearAuthUIWhenActive() { // For continuity, continue to present blocking screen in "screen lock" mode while // dismissing the "local auth UI". if appIsInactiveOrBackground { shouldClearAuthUIWhenActive = true } else { isShowingScreenLockUI = false ensureUI() } } private func desiredUIState() -> ScreenLockViewController.UIState { if isScreenLockLocked { if appIsInactiveOrBackground { return .screenProtection } else { return .screenLock } } guard appIsInactiveOrBackground else { return .none } // Cull any "sensitive content" view controllers that aren't actually // presenting content. (Their presence here may suggest a retain cycle.) sensitiveContentViewControllers.removeAll(where: { viewController in !viewController.isViewLoaded || viewController.view.window == nil }) guard SSKEnvironment.shared.preferencesRef.isScreenSecurityEnabled || sensitiveContentViewControllers.elements.count > 0 else { return .none } return .screenProtection } private func tryToPresentAuthUIToUnlockScreenLock() { AssertIsOnMainThread() guard !isShowingScreenLockUI else { // We're already showing the auth UI; abort. return } guard !appIsInactiveOrBackground else { // Never show the auth UI unless active. return } Logger.info("try to unlock screen lock") isShowingScreenLockUI = true ScreenLock.shared.tryToUnlockScreenLock( success: { Logger.info("unlock screen lock succeeded.") self.isShowingScreenLockUI = false self.isScreenLockLocked = false self.ensureUI() }, failure: { error in Logger.info("unlock screen lock failed.") self.clearAuthUIWhenActive() self.didLastUnlockAttemptFail = true self.showScreenLockFailureAlertWithMessage(error.userErrorDescription) }, unexpectedFailure: { error in Logger.info("unlock screen lock unexpectedly failed.") // Local Authentication isn't working properly. // This isn't covered by the docs or the forums but in practice // it appears to be effective to retry again after waiting a bit. DispatchQueue.main.async { self.clearAuthUIWhenActive() } }, cancel: { Logger.info("unlock screen lock cancelled.") self.clearAuthUIWhenActive() self.didLastUnlockAttemptFail = true // Re-show the unlock UI. self.ensureUI() }, ) ensureUI() } private func showScreenLockFailureAlertWithMessage(_ message: String) { AssertIsOnMainThread() OWSActionSheets.showActionSheet( title: DeviceAuthenticationErrorMessage.errorSheetTitle, message: message, buttonAction: { _ in // After the alert, update the UI. self.ensureUI() }, fromViewController: screenBlockingWindow.rootViewController, ) } // MARK: - private func tryToActivateScreenLockBasedOnCountdown() { owsAssertBeta(!appIsInBackground) AssertIsOnMainThread() guard appReadiness.isAppReady else { // It's not safe to access OWSScreenLock.isScreenLockEnabled // until the app is ready. // // We don't need to try to lock the screen lock; // It will be initialized by `setupWithRootWindow`. return } guard ScreenLock.shared.isScreenLockEnabled() else { // Screen lock is not enabled. return } guard !isScreenLockLocked else { // Screen lock is already activated. return } guard let screenLockCountdownTimestamp else { // We became inactive, but never started a countdown. return } let countdownTimestamp = screenLockCountdownTimestamp let currentTimestamp = monotonicTimestamp() guard currentTimestamp >= countdownTimestamp, currentTimestamp != 0, countdownTimestamp != 0 else { // If the clock is going backwards (shouldn't happen) or the // initial/current time couldn't be fetched (shouldn't happen), err on the // side of caution and lock the screen. owsFailDebug("monotonic time isn't behaving properly") isScreenLockLocked = true return } let countdownInterval = TimeInterval(currentTimestamp - countdownTimestamp) / TimeInterval(NSEC_PER_SEC) let screenLockTimeout = ScreenLock.shared.screenLockTimeout() owsAssertDebug(screenLockTimeout >= 0) if countdownInterval >= screenLockTimeout { isScreenLockLocked = true } } private func monotonicTimestamp() -> UInt64 { let result = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) if result == 0 { Logger.warn("couldn't get monotonic time \(errno)") } return result } private func startScreenLockCountdownIfNecessary() { if screenLockCountdownTimestamp == nil { screenLockCountdownTimestamp = monotonicTimestamp() } didLastUnlockAttemptFail = false } // MARK: - Notification Observers @objc private func screenLockDidChange(_ notification: Notification) { ensureUI() } @objc private func applicationDidBecomeActive(_ notification: Notification) { if shouldClearAuthUIWhenActive { shouldClearAuthUIWhenActive = false isShowingScreenLockUI = false } appIsInactiveOrBackground = false } @objc private func applicationWillResignActive(_ notification: Notification) { appIsInactiveOrBackground = true } @objc private func applicationWillEnterForeground(_ notification: Notification) { appIsInBackground = false } @objc private func applicationDidEnterBackground(_ notification: Notification) { appIsInBackground = true } } extension ScreenLockUI: ScreenLockViewDelegate { func unlockButtonWasTapped() { AssertIsOnMainThread() guard !appIsInactiveOrBackground else { // This button can be pressed while the app is inactive // for a brief window while the iOS auth UI is dismissing. return } Logger.info("unlockButtonWasTapped") didLastUnlockAttemptFail = false ensureUI() } }