Skip to content

Commit

Permalink
Initial setup ready for PIN/Biometric app lock. (#1876)
Browse files Browse the repository at this point in the history
* Add AppLockCoordinator and WindowManager.
  • Loading branch information
pixlwave authored Oct 11, 2023
1 parent b35bee5 commit b6470c9
Show file tree
Hide file tree
Showing 32 changed files with 838 additions and 15 deletions.
80 changes: 80 additions & 0 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
/* Used for testing */
"untranslated" = "Untranslated";

"screen_app_lock_title" = "%@ is locked";
"common_unlock" = "Unlock";

// MARK: - Soft logout

"soft_logout_signin_title" = "Sign in";
Expand Down
40 changes: 36 additions & 4 deletions ElementX/Sources/Application/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import MatrixRustSDK
import SwiftUI
import Version

class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, NotificationManagerDelegate {
class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, NotificationManagerDelegate, WindowManagerDelegate {
private let stateMachine: AppCoordinatorStateMachine
private let navigationRootCoordinator: NavigationRootCoordinator
private let userSessionStore: UserSessionStoreProtocol
Expand All @@ -43,8 +43,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
}
}

private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var authenticationCoordinator: AuthenticationCoordinator?
private let appLockFlowCoordinator: AppLockFlowCoordinator
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?

private let backgroundTaskService: BackgroundTaskServiceProtocol
Expand All @@ -54,6 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
private var clientProxyObserver: AnyCancellable?
private var cancellables = Set<AnyCancellable>()

let windowManager = WindowManager()
let notificationManager: NotificationManagerProtocol

private let appRouteURLParser: AppRouteURLParser
Expand Down Expand Up @@ -94,10 +96,20 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
UIApplication.shared
}

userSessionStore = UserSessionStore(backgroundTaskService: backgroundTaskService)

let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
userSessionStore = UserSessionStore(keychainController: keychainController,
backgroundTaskService: backgroundTaskService)

let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings)
appLockFlowCoordinator = AppLockFlowCoordinator(appLockService: appLockService,
navigationCoordinator: NavigationRootCoordinator())

notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(),
appSettings: appSettings)

windowManager.delegate = self

notificationManager.delegate = self
notificationManager.start()

Expand All @@ -117,6 +129,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,

observeApplicationState()
observeNetworkState()
observeAppLockChanges()

registerBackgroundAppRefresh()
}
Expand Down Expand Up @@ -178,6 +191,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
stateMachine.processEvent(.createdUserSession)
}

// MARK: - WindowManagerDelegate

func windowManagerDidConfigureWindows(_ windowManager: WindowManager) {
windowManager.alternateWindow.rootViewController = UIHostingController(rootView: appLockFlowCoordinator.toPresentable())
}

// MARK: - NotificationManagerDelegate

func registerForRemoteNotifications() {
Expand Down Expand Up @@ -553,6 +572,19 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
.store(in: &cancellables)
}

private func observeAppLockChanges() {
appLockFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .lockApp:
windowManager.switchToAlternate()
case .unlockApp:
windowManager.switchToMain()
}
}
.store(in: &cancellables)
}

private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: UIApplication.shared.applicationState == .active)
Expand Down
7 changes: 7 additions & 0 deletions ElementX/Sources/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ enum AppDelegateCallback {

class AppDelegate: NSObject, UIApplicationDelegate {
let callbacks = PassthroughSubject<AppDelegateCallback, Never>()

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Add a SceneDelegate to the SwiftUI scene so that we can connect up the WindowManager.
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
configuration.delegateClass = SceneDelegate.self
return configuration
}

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
NSTextAttachment.registerViewProviderClass(PillAttachmentViewProvider.self, forFileType: InfoPlistReader.main.pillsUTType)
Expand Down
4 changes: 4 additions & 0 deletions ElementX/Sources/Application/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class AppSettings {
case swiftUITimelineEnabled
case voiceMessageEnabled
case mentionsEnabled
case appLockFlowEnabled
}

private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
Expand Down Expand Up @@ -257,4 +258,7 @@ final class AppSettings {

@UserPreference(key: UserDefaultsKeys.mentionsEnabled, defaultValue: false, storageType: .userDefaults(store))
var mentionsEnabled

@UserPreference(key: UserDefaultsKeys.appLockFlowEnabled, defaultValue: false, storageType: .volatile)
var appLockFlowEnabled
}
7 changes: 5 additions & 2 deletions ElementX/Sources/Application/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import SwiftUI

@main
struct Application: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var applicationDelegate
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.openURL) private var openURL

private var appCoordinator: AppCoordinatorProtocol!

init() {
Expand All @@ -28,7 +29,9 @@ struct Application: App {
} else if ProcessInfo.isRunningUnitTests {
appCoordinator = UnitTestsAppCoordinator()
} else {
appCoordinator = AppCoordinator(appDelegate: applicationDelegate)
let coordinator = AppCoordinator(appDelegate: appDelegate)
SceneDelegate.windowManager = coordinator.windowManager
appCoordinator = coordinator
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,18 @@ class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomSt

/// Sets or replaces the presented coordinator
/// - Parameter coordinator: the coordinator to display
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
rootModule = nil
return
}
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
var transaction = Transaction()
transaction.disablesAnimations = !animated

rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
withTransaction(transaction) {
guard let coordinator else {
rootModule = nil
return
}

rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
}

/// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise
Expand Down
29 changes: 29 additions & 0 deletions ElementX/Sources/Application/Windowing/SceneDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import SwiftUI

/// A basic window scene delegate used to configure the `WindowManager`.
///
/// We don't support multiple scenes right now, so the implementation is pretty basic.
class SceneDelegate: NSObject, UIWindowSceneDelegate {
weak static var windowManager: WindowManager!

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene, !ProcessInfo.isRunningTests else { return }
Self.windowManager.configure(with: windowScene)
}
}
67 changes: 67 additions & 0 deletions ElementX/Sources/Application/Windowing/WindowManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Combine
import SwiftUI

protocol WindowManagerDelegate: AnyObject {
/// The window manager has configured its windows.
func windowManagerDidConfigureWindows(_ windowManager: WindowManager)
}

@MainActor
/// A window manager that supports switching between a main app window with an overlay and
/// an alternate window to switch contexts whilst also preserving the main view hierarchy.
class WindowManager {
weak var delegate: WindowManagerDelegate?

/// The app's main window (we only support a single scene).
private(set) var mainWindow: UIWindow!
/// Presented on top of the main window, to display e.g. user indicators.
private(set) var overlayWindow: UIWindow!
/// A secondary window that can be presented instead of the main/overlay window combo.
private(set) var alternateWindow: UIWindow!

/// Configures the window manager to operate on the supplied scene.
func configure(with windowScene: UIWindowScene) {
mainWindow = windowScene.keyWindow

overlayWindow = UIWindow(windowScene: windowScene)
overlayWindow.backgroundColor = .clear
// We don't support user interaction on our indicators so disable interaction, to pass
// touches through to the main window. If this changes, there's another solution here:
// https://www.fivestars.blog/articles/swiftui-windows/
overlayWindow.isUserInteractionEnabled = false

alternateWindow = UIWindow(windowScene: windowScene)

delegate?.windowManagerDidConfigureWindows(self)
}

/// Shows the main and overlay window combo, hiding the alternate window.
func switchToMain() {
mainWindow.isHidden = false
overlayWindow.isHidden = false
alternateWindow.isHidden = true
}

/// Shows the alternate window, hiding the main and overlay combo.
func switchToAlternate() {
alternateWindow.isHidden = false
overlayWindow.isHidden = true
mainWindow.isHidden = true
}
}
87 changes: 87 additions & 0 deletions ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Combine
import SwiftUI

enum AppLockFlowCoordinatorAction: Equatable {
/// Display the unlock flow.
case lockApp
/// Hide the unlock flow.
case unlockApp
}

/// Coordinates the display of any screens shown when the app is locked.
class AppLockFlowCoordinator: CoordinatorProtocol {
let appLockService: AppLockServiceProtocol
let navigationCoordinator: NavigationRootCoordinator

private var cancellables: Set<AnyCancellable> = []

private let actionsSubject: PassthroughSubject<AppLockFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<AppLockFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}

init(appLockService: AppLockServiceProtocol, navigationCoordinator: NavigationRootCoordinator) {
self.appLockService = appLockService
self.navigationCoordinator = navigationCoordinator

NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
self?.showPlaceholderIfNeeded()
}
.store(in: &cancellables)

NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.sink { [weak self] _ in
self?.showUnlockScreenIfNeeded()
}
.store(in: &cancellables)
}

func toPresentable() -> AnyView {
AnyView(navigationCoordinator.toPresentable())
}

// MARK: - App unlock

/// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher.
private func showPlaceholderIfNeeded() {
guard appLockService.isEnabled else { return }

navigationCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(), animated: false)
actionsSubject.send(.lockApp)
}

/// Displays the unlock flow with the main unlock screen.
private func showUnlockScreenIfNeeded() {
guard appLockService.isEnabled, appLockService.needsUnlock else { return }

let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .appUnlocked:
actionsSubject.send(.unlockApp)
}
}
.store(in: &cancellables)

navigationCoordinator.setRootCoordinator(coordinator, animated: false)
actionsSubject.send(.lockApp)
}
}
6 changes: 6 additions & 0 deletions ElementX/Sources/Generated/Strings+Untranslated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
public enum UntranslatedL10n {
/// Unlock
public static var commonUnlock: String { return UntranslatedL10n.tr("Untranslated", "common_unlock") }
/// %@ is locked
public static func screenAppLockTitle(_ p1: Any) -> String {
return UntranslatedL10n.tr("Untranslated", "screen_app_lock_title", String(describing: p1))
}
/// Clear all data currently stored on this device?
/// Sign in again to access your account data and messages.
public static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }
Expand Down
Loading

0 comments on commit b6470c9

Please sign in to comment.