Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/util/ScreenLockUI.swift
1 views
//
// 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

    // 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: - Public

    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: - 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
        }

        guard SSKEnvironment.shared.preferencesRef.isScreenSecurityEnabled 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()
    }
}