mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
382 lines
14 KiB
Swift
382 lines
14 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
import SignalUtilitiesKit
|
|
|
|
class ScreenLockUI {
|
|
public static let shared: ScreenLockUI = ScreenLockUI()
|
|
|
|
public lazy var screenBlockingWindow: UIWindow = {
|
|
let result: UIWindow = UIWindow()
|
|
result.isHidden = false
|
|
result.windowLevel = ._Background
|
|
result.isOpaque = true
|
|
result.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary)
|
|
result.rootViewController = self.screenBlockingViewController
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var screenBlockingViewController: ScreenLockViewController = {
|
|
let result: ScreenLockViewController = ScreenLockViewController { [weak self] in
|
|
guard self?.appIsInactiveOrBackground == false else {
|
|
// This button can be pressed while the app is inactive
|
|
// for a brief window while the iOS auth UI is dismissing.
|
|
return
|
|
}
|
|
|
|
Log.info("unlockButtonWasTapped")
|
|
|
|
self?.didLastUnlockAttemptFail = false
|
|
self?.ensureUI()
|
|
}
|
|
|
|
return result
|
|
}()
|
|
|
|
/// 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 {
|
|
if self.appIsInactiveOrBackground {
|
|
if !self.isShowingScreenLockUI {
|
|
self.didLastUnlockAttemptFail = false
|
|
self.tryToActivateScreenLockBasedOnCountdown()
|
|
}
|
|
}
|
|
else if !self.didUnlockJustSucceed {
|
|
self.tryToActivateScreenLockBasedOnCountdown()
|
|
}
|
|
|
|
self.didUnlockJustSucceed = false
|
|
self.ensureUI()
|
|
}
|
|
}
|
|
private var appIsInBackground: Bool = false {
|
|
didSet {
|
|
self.didUnlockJustSucceed = false
|
|
self.tryToActivateScreenLockBasedOnCountdown()
|
|
self.ensureUI()
|
|
}
|
|
}
|
|
|
|
private var isShowingScreenLockUI: Bool = false
|
|
private var didUnlockJustSucceed: 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 db[.isScreenLockEnabled].
|
|
///
|
|
/// * The user is locked out by default on app launch.
|
|
/// * The user is also locked out if the app is sent to the background
|
|
private var isScreenLockLocked: Bool = false
|
|
|
|
// Determines what the state of the app should be.
|
|
private var desiredUIState: ScreenLockViewController.State {
|
|
if isScreenLockLocked {
|
|
if appIsInactiveOrBackground {
|
|
Log.verbose("desiredUIState: screen protection 1.")
|
|
return .protection
|
|
}
|
|
|
|
Log.verbose("desiredUIState: screen lock 2.")
|
|
return (isShowingScreenLockUI ? .protection : .lock)
|
|
}
|
|
|
|
if !self.appIsInactiveOrBackground {
|
|
// App is inactive or background.
|
|
Log.verbose("desiredUIState: none 3.");
|
|
return .none;
|
|
}
|
|
|
|
if SessionEnvironment.shared?.isRequestingPermission == true {
|
|
return .none;
|
|
}
|
|
|
|
Log.verbose("desiredUIState: screen protection 4.")
|
|
return .protection;
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
private func observeNotifications() {
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidBecomeActive),
|
|
name: .sessionDidBecomeActive,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationWillResignActive),
|
|
name: .sessionWillResignActive,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationWillEnterForeground),
|
|
name: .sessionWillEnterForeground,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidEnterBackground),
|
|
name: .sessionDidEnterBackground,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(clockDidChange),
|
|
name: .NSSystemClockDidChange,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
public func setupWithRootWindow(rootWindow: UIWindow) {
|
|
self.screenBlockingWindow.frame = rootWindow.bounds
|
|
}
|
|
|
|
public func startObserving() {
|
|
self.appIsInactiveOrBackground = (UIApplication.shared.applicationState != .active)
|
|
|
|
self.observeNotifications()
|
|
|
|
// Hide the screen blocking window until "app is ready" to
|
|
// avoid blocking the loading view.
|
|
updateScreenBlockingWindow(state: .none, animated: false)
|
|
|
|
// Initialize the screen lock state.
|
|
//
|
|
// It's not safe to access OWSScreenLock.isScreenLockEnabled
|
|
// until the app is ready.
|
|
Singleton.appReadiness.runNowOrWhenAppWillBecomeReady { [weak self] in
|
|
self?.isScreenLockLocked = Storage.shared[.isScreenLockEnabled]
|
|
self?.ensureUI()
|
|
}
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
private func tryToActivateScreenLockBasedOnCountdown() {
|
|
guard Singleton.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`.
|
|
Log.verbose("tryToActivateScreenLockUponBecomingActive NO 0")
|
|
return
|
|
}
|
|
guard Storage.shared[.isScreenLockEnabled] else {
|
|
// Screen lock is not enabled.
|
|
Log.verbose("tryToActivateScreenLockUponBecomingActive NO 1")
|
|
return;
|
|
}
|
|
guard !isScreenLockLocked else {
|
|
// Screen lock is already activated.
|
|
Log.verbose("tryToActivateScreenLockUponBecomingActive NO 2")
|
|
return;
|
|
}
|
|
|
|
self.isScreenLockLocked = true
|
|
}
|
|
|
|
/// Ensure that:
|
|
///
|
|
/// * The blocking window has the correct state.
|
|
/// * That we show the "iOS auth UI to unlock" if necessary.
|
|
private func ensureUI() {
|
|
guard Singleton.appReadiness.isAppReady else {
|
|
Singleton.appReadiness.runNowOrWhenAppWillBecomeReady { [weak self] in
|
|
self?.ensureUI()
|
|
}
|
|
return
|
|
}
|
|
|
|
let desiredUIState: ScreenLockViewController.State = self.desiredUIState
|
|
Log.verbose("ensureUI: \(desiredUIState)")
|
|
|
|
// Show the "iOS auth UI to unlock" if necessary.
|
|
if desiredUIState == .lock && !didLastUnlockAttemptFail {
|
|
tryToPresentAuthUIToUnlockScreenLock()
|
|
}
|
|
|
|
// Note: We want to regenerate the 'desiredUIState' as if we are about to show the
|
|
// 'unlock screen' UI then we shouldn't show the "unlock" button
|
|
updateScreenBlockingWindow(state: self.desiredUIState, animated: true)
|
|
}
|
|
|
|
private func tryToPresentAuthUIToUnlockScreenLock() {
|
|
guard !isShowingScreenLockUI else { return } // We're already showing the auth UI; abort
|
|
guard !appIsInactiveOrBackground else { return } // Never show the auth UI unless active
|
|
|
|
Log.info("try to unlock screen lock")
|
|
isShowingScreenLockUI = true
|
|
|
|
ScreenLock.shared.tryToUnlockScreenLock(
|
|
success: { [weak self] in
|
|
Log.info("unlock screen lock succeeded.")
|
|
self?.isShowingScreenLockUI = false
|
|
self?.isScreenLockLocked = false
|
|
self?.didUnlockJustSucceed = true
|
|
self?.ensureUI()
|
|
},
|
|
failure: { [weak self] error in
|
|
Log.info("unlock screen lock failed.")
|
|
self?.clearAuthUIWhenActive()
|
|
self?.didLastUnlockAttemptFail = true
|
|
self?.showScreenLockFailureAlert(message: "\(error)")
|
|
},
|
|
unexpectedFailure: { [weak self] error in
|
|
Log.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: { [weak self] in
|
|
Log.info("unlock screen lock cancelled.")
|
|
|
|
self?.clearAuthUIWhenActive()
|
|
self?.didLastUnlockAttemptFail = true
|
|
|
|
// Re-show the unlock UI
|
|
self?.ensureUI()
|
|
}
|
|
)
|
|
|
|
self.ensureUI()
|
|
}
|
|
|
|
private func showScreenLockFailureAlert(message: String) {
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: screenBlockingWindow.rootViewController?.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "authenticateFailed".localized(),
|
|
body: .text(message),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text,
|
|
afterClosed: { [weak self] in self?.ensureUI() } // After the alert, update the UI
|
|
)
|
|
)
|
|
screenBlockingWindow.rootViewController?.present(modal, animated: true)
|
|
}
|
|
|
|
/// 'Screen Blocking' window obscures the app screen:
|
|
///
|
|
/// * In the app switcher.
|
|
/// * During 'Screen Lock' unlock process.
|
|
private func createScreenBlockingWindow(rootWindow: UIWindow) {
|
|
let window: UIWindow = UIWindow(frame: rootWindow.bounds)
|
|
window.isHidden = false
|
|
window.windowLevel = ._Background
|
|
window.isOpaque = true
|
|
window.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary)
|
|
|
|
let viewController: ScreenLockViewController = ScreenLockViewController { [weak self] in
|
|
guard self?.appIsInactiveOrBackground == false else {
|
|
// This button can be pressed while the app is inactive
|
|
// for a brief window while the iOS auth UI is dismissing.
|
|
return
|
|
}
|
|
|
|
Log.info("unlockButtonWasTapped")
|
|
|
|
self?.didLastUnlockAttemptFail = false
|
|
self?.ensureUI()
|
|
}
|
|
window.rootViewController = viewController
|
|
|
|
self.screenBlockingWindow = window
|
|
self.screenBlockingViewController = viewController
|
|
}
|
|
|
|
/// The "screen blocking" window has three possible states:
|
|
///
|
|
/// * "Just a logo". Used when app is launching and in app switcher. Must match the "Launch Screen" storyboard pixel-for-pixel.
|
|
/// * "Screen Lock, local auth UI presented". Move the Signal logo so that it is visible.
|
|
/// * "Screen Lock, local auth UI not presented". Move the Signal logo so that it is visible, show "unlock" button.
|
|
private func updateScreenBlockingWindow(state: ScreenLockViewController.State, animated: Bool) {
|
|
let shouldShowBlockWindow: Bool = (state != .none)
|
|
|
|
OWSWindowManager.shared().isScreenBlockActive = shouldShowBlockWindow
|
|
self.screenBlockingViewController.updateUI(state: state, animated: animated)
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
private func clearAuthUIWhenActive() {
|
|
// For continuity, continue to present blocking screen in "screen lock" mode while
|
|
// dismissing the "local auth UI".
|
|
if self.appIsInactiveOrBackground {
|
|
self.shouldClearAuthUIWhenActive = true
|
|
}
|
|
else {
|
|
self.isShowingScreenLockUI = false
|
|
self.ensureUI()
|
|
}
|
|
}
|
|
|
|
@objc private func applicationDidBecomeActive() {
|
|
if self.shouldClearAuthUIWhenActive {
|
|
self.shouldClearAuthUIWhenActive = false
|
|
self.isShowingScreenLockUI = false
|
|
}
|
|
|
|
self.appIsInactiveOrBackground = false
|
|
}
|
|
|
|
@objc private func applicationWillResignActive() {
|
|
self.appIsInactiveOrBackground = true
|
|
}
|
|
|
|
@objc private func applicationWillEnterForeground() {
|
|
self.appIsInBackground = false
|
|
}
|
|
|
|
@objc private func applicationDidEnterBackground() {
|
|
self.appIsInBackground = true
|
|
}
|
|
|
|
/// Whenever the device date/time is edited by the user, trigger screen lock immediately if enabled.
|
|
@objc private func clockDidChange() {
|
|
Log.info("clock did change")
|
|
|
|
guard Singleton.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`.
|
|
Log.verbose("clockDidChange 0")
|
|
return;
|
|
}
|
|
|
|
self.isScreenLockLocked = Storage.shared[.isScreenLockEnabled]
|
|
|
|
// NOTE: this notifications fires _before_ applicationDidBecomeActive,
|
|
// which is desirable. Don't assume that though; call ensureUI
|
|
// just in case it's necessary.
|
|
self.ensureUI()
|
|
}
|
|
}
|