468 lines
15 KiB
Swift
468 lines
15 KiB
Swift
//
|
|
// 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<Void, Error>?
|
|
|
|
@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<UIViewController> = []
|
|
|
|
@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()
|
|
}
|
|
}
|