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.
360 lines
14 KiB
Swift
360 lines
14 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import LocalAuthentication
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
// MARK: - Singleton
|
|
|
|
public extension Singleton {
|
|
static let screenLock: SingletonConfig<ScreenLockWindow> = Dependencies.create(
|
|
identifier: "screenLock",
|
|
createInstance: { dependencies in ScreenLockWindow(using: dependencies) }
|
|
)
|
|
}
|
|
|
|
/// Obscures the app screen:
|
|
///
|
|
/// * In the app switcher.
|
|
/// * During 'Screen Lock' unlock process.
|
|
public class ScreenLockWindow {
|
|
private let dependencies: Dependencies
|
|
|
|
/// 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
|
|
@ThreadSafe private var isScreenLockLocked: Bool = false
|
|
|
|
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
|
|
|
|
// MARK: - UI
|
|
|
|
public lazy var window: UIWindow = {
|
|
let result: UIWindow = UIWindow()
|
|
result.isHidden = false
|
|
result.windowLevel = .background
|
|
result.isOpaque = true
|
|
result.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary)
|
|
result.rootViewController = self.viewController
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var viewController: ScreenLockViewController = ScreenLockViewController { [weak self, dependencies] in
|
|
guard dependencies[singleton: .appContext].isAppForegroundAndActive 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(.screenLock, "unlockButtonWasTapped")
|
|
|
|
self?.didLastUnlockAttemptFail = false
|
|
self?.ensureUI()
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
init(using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
// MARK: - Observations
|
|
|
|
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.window.frame = rootWindow.bounds
|
|
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 `isScreenLockEnabled` in `storage` until the app is ready
|
|
dependencies[singleton: .appReadiness].runNowOrWhenAppWillBecomeReady { [weak self, dependencies] in
|
|
self?.isScreenLockLocked = (dependencies[singleton: .storage, key: .isScreenLockEnabled] == true)
|
|
|
|
switch Thread.isMainThread {
|
|
case true: self?.ensureUI()
|
|
case false: DispatchQueue.main.async { self?.ensureUI() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
private func determineDesiredUIState() -> ScreenLockViewController.State {
|
|
if isScreenLockLocked {
|
|
if dependencies[singleton: .appContext].isNotInForeground {
|
|
Log.verbose(.screenLock, "App not in foreground, desiredUIState is: protection.")
|
|
return .protection
|
|
}
|
|
|
|
Log.verbose(.screenLock, "App in foreground and locked, desiredUIState is: \(isShowingScreenLockUI ? "protection" : "lock").")
|
|
return (isShowingScreenLockUI ? .protection : .lock)
|
|
}
|
|
|
|
if dependencies[singleton: .appContext].isAppForegroundAndActive {
|
|
// App is inactive or background.
|
|
Log.verbose(.screenLock, "App in foreground and not locked, desiredUIState is: none.")
|
|
return .none;
|
|
}
|
|
|
|
if SessionEnvironment.shared?.isRequestingPermission == true {
|
|
Log.verbose(.screenLock, "App requesting permissions and not locked, desiredUIState is: none.")
|
|
return .none;
|
|
}
|
|
|
|
Log.verbose(.screenLock, "desiredUIState is: protection.")
|
|
return .protection;
|
|
}
|
|
|
|
private func tryToActivateScreenLockBasedOnCountdown() {
|
|
guard dependencies[singleton: .appReadiness].isAppReady else {
|
|
/// It's not safe to access `isScreenLockEnabled` in `storage` until the app is ready
|
|
///
|
|
/// We don't need to try to lock the screen lock;
|
|
/// It will be initialized by `setupWithRootWindow`
|
|
Log.verbose(.screenLock, "tryToActivateScreenLockUponBecomingActive NO 0")
|
|
return
|
|
}
|
|
guard dependencies[singleton: .storage, key: .isScreenLockEnabled] else {
|
|
/// Screen lock is not enabled.
|
|
Log.verbose(.screenLock, "tryToActivateScreenLockUponBecomingActive NO 1")
|
|
return
|
|
}
|
|
guard !isScreenLockLocked else {
|
|
/// Screen lock is already activated.
|
|
Log.verbose(.screenLock, "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 dependencies[singleton: .appReadiness].isAppReady else {
|
|
dependencies[singleton: .appReadiness].runNowOrWhenAppWillBecomeReady { [weak self] in
|
|
self?.ensureUI()
|
|
}
|
|
return
|
|
}
|
|
|
|
let desiredUIState: ScreenLockViewController.State = determineDesiredUIState()
|
|
Log.verbose(.screenLock, "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: determineDesiredUIState(), animated: true)
|
|
}
|
|
|
|
private func tryToPresentAuthUIToUnlockScreenLock() {
|
|
/// If we're already showing the auth UI; or the app isn't active then don't do anything
|
|
guard
|
|
!isShowingScreenLockUI,
|
|
dependencies[singleton: .appContext].isAppForegroundAndActive
|
|
else { return }
|
|
|
|
Log.info(.screenLock, "Try to unlock screen lock")
|
|
isShowingScreenLockUI = true
|
|
|
|
ScreenLock.tryToUnlockScreenLock(
|
|
success: { [weak self] in
|
|
Log.info(.screenLock, "Unlock screen lock succeeded")
|
|
self?.isShowingScreenLockUI = false
|
|
self?.isScreenLockLocked = false
|
|
self?.didUnlockJustSucceed = true
|
|
self?.ensureUI()
|
|
},
|
|
failure: { [weak self] error in
|
|
Log.info(.screenLock, "Unlock screen lock failed")
|
|
self?.clearAuthUIWhenActive()
|
|
self?.didLastUnlockAttemptFail = true
|
|
self?.showScreenLockFailureAlert(message: "\(error)")
|
|
},
|
|
unexpectedFailure: { [weak self] error in
|
|
Log.warn(.screenLock, "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(.screenLock, "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: viewController.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
|
|
)
|
|
)
|
|
viewController.present(modal, animated: true)
|
|
}
|
|
|
|
/// 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.viewController.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 !dependencies[singleton: .appContext].isAppForegroundAndActive {
|
|
self.shouldClearAuthUIWhenActive = true
|
|
}
|
|
else {
|
|
self.isShowingScreenLockUI = false
|
|
self.ensureUI()
|
|
}
|
|
}
|
|
|
|
@objc private func applicationDidBecomeActive() {
|
|
if shouldClearAuthUIWhenActive {
|
|
shouldClearAuthUIWhenActive = false
|
|
isShowingScreenLockUI = false
|
|
}
|
|
|
|
if !didUnlockJustSucceed {
|
|
tryToActivateScreenLockBasedOnCountdown()
|
|
}
|
|
|
|
didUnlockJustSucceed = false
|
|
ensureUI()
|
|
}
|
|
|
|
/// When the OS shows the TouchID/FaceID/Pin UI the application will resign active (and we don't want to re-authenticate if we are
|
|
/// already locked)
|
|
///
|
|
/// Secondly, we need to show the screen protection _before_ we become inactive in order for it to be reflected in the app switcher
|
|
@objc private func applicationWillResignActive() {
|
|
if !isShowingScreenLockUI {
|
|
didLastUnlockAttemptFail = false
|
|
tryToActivateScreenLockBasedOnCountdown()
|
|
}
|
|
|
|
didUnlockJustSucceed = false
|
|
ensureUI()
|
|
}
|
|
|
|
@objc private func applicationWillEnterForeground() {
|
|
didUnlockJustSucceed = false
|
|
tryToActivateScreenLockBasedOnCountdown()
|
|
ensureUI()
|
|
}
|
|
|
|
@objc private func applicationDidEnterBackground() {
|
|
didUnlockJustSucceed = false
|
|
tryToActivateScreenLockBasedOnCountdown()
|
|
ensureUI()
|
|
}
|
|
|
|
/// Whenever the device date/time is edited by the user, trigger screen lock immediately if enabled.
|
|
@objc private func clockDidChange() {
|
|
Log.info(.screenLock, "clock did change")
|
|
|
|
guard dependencies[singleton: .appReadiness].isAppReady == true 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(.screenLock, "clockDidChange 0")
|
|
return;
|
|
}
|
|
|
|
DispatchQueue.global(qos: .background).async { [dependencies] in
|
|
self.isScreenLockLocked = (dependencies[singleton: .storage, key: .isScreenLockEnabled] == true)
|
|
|
|
DispatchQueue.main.async {
|
|
// NOTE: this notifications fires _before_ applicationDidBecomeActive,
|
|
// which is desirable. Don't assume that though; call ensureUI
|
|
// just in case it's necessary.
|
|
self.ensureUI()
|
|
}
|
|
}
|
|
}
|
|
}
|