Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial setup ready for PIN/Biometric app lock. #1876

Merged
merged 7 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 windowManager(_ windowManager: WindowManager, didConfigureWith windowScene: UIWindowScene) {
pixlwave marked this conversation as resolved.
Show resolved Hide resolved
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)
pixlwave marked this conversation as resolved.
Show resolved Hide resolved
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 been configured with the following scene.
func windowManager(_ windowManager: WindowManager, didConfigureWith windowScene: UIWindowScene)
}

@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!
pixlwave marked this conversation as resolved.
Show resolved Hide resolved

/// 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:
pixlwave marked this conversation as resolved.
Show resolved Hide resolved
// https://www.fivestars.blog/articles/swiftui-windows/
overlayWindow.isUserInteractionEnabled = false

alternateWindow = UIWindow(windowScene: windowScene)

delegate?.windowManager(self, didConfigureWith: windowScene)
}

/// 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)
pixlwave marked this conversation as resolved.
Show resolved Hide resolved

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