mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Initial setup ready for PIN/Biometric app lock. (#1876)
* Add AppLockCoordinator and WindowManager.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -117,6 +129,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
|
||||
observeApplicationState()
|
||||
observeNetworkState()
|
||||
observeAppLockChanges()
|
||||
|
||||
registerBackgroundAppRefresh()
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,7 @@ final class AppSettings {
|
||||
case swiftUITimelineEnabled
|
||||
case voiceMessageEnabled
|
||||
case mentionsEnabled
|
||||
case appLockFlowEnabled
|
||||
}
|
||||
|
||||
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
|
||||
@@ -314,6 +314,91 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
|
||||
setSuggestionTriggerClosure?(suggestionTrigger)
|
||||
}
|
||||
}
|
||||
class KeychainControllerMock: KeychainControllerProtocol {
|
||||
|
||||
//MARK: - setRestorationToken
|
||||
|
||||
var setRestorationTokenForUsernameCallsCount = 0
|
||||
var setRestorationTokenForUsernameCalled: Bool {
|
||||
return setRestorationTokenForUsernameCallsCount > 0
|
||||
}
|
||||
var setRestorationTokenForUsernameReceivedArguments: (restorationToken: RestorationToken, forUsername: String)?
|
||||
var setRestorationTokenForUsernameReceivedInvocations: [(restorationToken: RestorationToken, forUsername: String)] = []
|
||||
var setRestorationTokenForUsernameClosure: ((RestorationToken, String) -> Void)?
|
||||
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername: String) {
|
||||
setRestorationTokenForUsernameCallsCount += 1
|
||||
setRestorationTokenForUsernameReceivedArguments = (restorationToken: restorationToken, forUsername: forUsername)
|
||||
setRestorationTokenForUsernameReceivedInvocations.append((restorationToken: restorationToken, forUsername: forUsername))
|
||||
setRestorationTokenForUsernameClosure?(restorationToken, forUsername)
|
||||
}
|
||||
//MARK: - restorationTokenForUsername
|
||||
|
||||
var restorationTokenForUsernameCallsCount = 0
|
||||
var restorationTokenForUsernameCalled: Bool {
|
||||
return restorationTokenForUsernameCallsCount > 0
|
||||
}
|
||||
var restorationTokenForUsernameReceivedUsername: String?
|
||||
var restorationTokenForUsernameReceivedInvocations: [String] = []
|
||||
var restorationTokenForUsernameReturnValue: RestorationToken?
|
||||
var restorationTokenForUsernameClosure: ((String) -> RestorationToken?)?
|
||||
|
||||
func restorationTokenForUsername(_ username: String) -> RestorationToken? {
|
||||
restorationTokenForUsernameCallsCount += 1
|
||||
restorationTokenForUsernameReceivedUsername = username
|
||||
restorationTokenForUsernameReceivedInvocations.append(username)
|
||||
if let restorationTokenForUsernameClosure = restorationTokenForUsernameClosure {
|
||||
return restorationTokenForUsernameClosure(username)
|
||||
} else {
|
||||
return restorationTokenForUsernameReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - restorationTokens
|
||||
|
||||
var restorationTokensCallsCount = 0
|
||||
var restorationTokensCalled: Bool {
|
||||
return restorationTokensCallsCount > 0
|
||||
}
|
||||
var restorationTokensReturnValue: [KeychainCredentials]!
|
||||
var restorationTokensClosure: (() -> [KeychainCredentials])?
|
||||
|
||||
func restorationTokens() -> [KeychainCredentials] {
|
||||
restorationTokensCallsCount += 1
|
||||
if let restorationTokensClosure = restorationTokensClosure {
|
||||
return restorationTokensClosure()
|
||||
} else {
|
||||
return restorationTokensReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - removeRestorationTokenForUsername
|
||||
|
||||
var removeRestorationTokenForUsernameCallsCount = 0
|
||||
var removeRestorationTokenForUsernameCalled: Bool {
|
||||
return removeRestorationTokenForUsernameCallsCount > 0
|
||||
}
|
||||
var removeRestorationTokenForUsernameReceivedUsername: String?
|
||||
var removeRestorationTokenForUsernameReceivedInvocations: [String] = []
|
||||
var removeRestorationTokenForUsernameClosure: ((String) -> Void)?
|
||||
|
||||
func removeRestorationTokenForUsername(_ username: String) {
|
||||
removeRestorationTokenForUsernameCallsCount += 1
|
||||
removeRestorationTokenForUsernameReceivedUsername = username
|
||||
removeRestorationTokenForUsernameReceivedInvocations.append(username)
|
||||
removeRestorationTokenForUsernameClosure?(username)
|
||||
}
|
||||
//MARK: - removeAllRestorationTokens
|
||||
|
||||
var removeAllRestorationTokensCallsCount = 0
|
||||
var removeAllRestorationTokensCalled: Bool {
|
||||
return removeAllRestorationTokensCallsCount > 0
|
||||
}
|
||||
var removeAllRestorationTokensClosure: (() -> Void)?
|
||||
|
||||
func removeAllRestorationTokens() {
|
||||
removeAllRestorationTokensCallsCount += 1
|
||||
removeAllRestorationTokensClosure?()
|
||||
}
|
||||
}
|
||||
class MediaPlayerMock: MediaPlayerProtocol {
|
||||
var mediaSource: MediaSourceProxy?
|
||||
var currentTime: TimeInterval {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2022 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
|
||||
|
||||
struct AppLockScreenCoordinatorParameters {
|
||||
/// The service used to unlock the app.
|
||||
let appLockService: AppLockServiceProtocol
|
||||
}
|
||||
|
||||
enum AppLockScreenCoordinatorAction {
|
||||
/// The user has successfully unlocked the app.
|
||||
case appUnlocked
|
||||
}
|
||||
|
||||
final class AppLockScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: AppLockScreenCoordinatorParameters
|
||||
private var viewModel: AppLockScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<AppLockScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<AppLockScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: AppLockScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = AppLockScreenViewModel(appLockService: parameters.appLockService)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
MXLog.info("Coordinator: received view model action: \(action)")
|
||||
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .appUnlocked:
|
||||
self.actionsSubject.send(.appUnlocked)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(AppLockScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// Copyright 2022 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 Foundation
|
||||
|
||||
enum AppLockScreenViewModelAction {
|
||||
/// The user has successfully unlocked the app.
|
||||
case appUnlocked
|
||||
}
|
||||
|
||||
struct AppLockScreenViewState: BindableState {
|
||||
var bindings: AppLockScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct AppLockScreenViewStateBindings { }
|
||||
|
||||
enum AppLockScreenViewAction: CustomStringConvertible {
|
||||
/// Attempt to unlock the app with the supplied PIN code.
|
||||
case submitPINCode(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .submitPINCode:
|
||||
return "submitPINCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Copyright 2022 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
|
||||
|
||||
typealias AppLockScreenViewModelType = StateStoreViewModel<AppLockScreenViewState, AppLockScreenViewAction>
|
||||
|
||||
class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModelProtocol {
|
||||
private let appLockService: AppLockServiceProtocol
|
||||
private var actionsSubject: PassthroughSubject<AppLockScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<AppLockScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(appLockService: AppLockServiceProtocol) {
|
||||
self.appLockService = appLockService
|
||||
|
||||
super.init(initialViewState: AppLockScreenViewState(bindings: .init()))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AppLockScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
case .submitPINCode(let pinCode):
|
||||
guard appLockService.unlock(with: pinCode) else {
|
||||
MXLog.warning("Invalid PIN code entered.")
|
||||
// Indicate failure here.
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.appUnlocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2022 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
|
||||
|
||||
@MainActor
|
||||
protocol AppLockScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<AppLockScreenViewModelAction, Never> { get }
|
||||
var context: AppLockScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// Copyright 2022 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 Compound
|
||||
import SwiftUI
|
||||
|
||||
// Move this to Compound.
|
||||
extension ShapeStyle where Self == Color {
|
||||
static var compound: CompoundColors { Self.compound }
|
||||
}
|
||||
|
||||
// This implementation is only for development purposes.
|
||||
|
||||
struct AppLockScreen: View {
|
||||
@ObservedObject var context: AppLockScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog {
|
||||
VStack(spacing: 8) {
|
||||
AuthenticationIconImage(image: Image(systemSymbol: .lock))
|
||||
.symbolVariant(.fill)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(UntranslatedL10n.screenAppLockTitle(InfoPlistReader.main.bundleDisplayName))
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
}
|
||||
} bottomContent: {
|
||||
Button(UntranslatedL10n.commonUnlock) {
|
||||
context.send(viewAction: .submitPINCode("0000"))
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
// Add TestablePreview conformance once we have designs.
|
||||
struct AppLockScreen_Previews: PreviewProvider {
|
||||
static let viewModel = AppLockScreenViewModel(appLockService: AppLockService(keychainController: KeychainControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings))
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
AppLockScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var swiftUITimelineEnabled: Bool { get set }
|
||||
var voiceMessageEnabled: Bool { get set }
|
||||
var mentionsEnabled: Bool { get set }
|
||||
var appLockFlowEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+7
@@ -63,6 +63,13 @@ struct DeveloperOptionsScreen: View {
|
||||
Text("Enable voice messages")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Security") {
|
||||
Toggle(isOn: $context.appLockFlowEnabled) {
|
||||
Text("PIN/Biometric lock")
|
||||
Text("Resets on reboot")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// 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 LocalAuthentication
|
||||
|
||||
@MainActor
|
||||
protocol AppLockServiceProtocol {
|
||||
/// The app has been configured to automatically lock with a PIN code.
|
||||
var isEnabled: Bool { get }
|
||||
/// The app can additionally be unlocked using FaceID or TouchID.
|
||||
var supportsBiometrics: Bool { get }
|
||||
/// The app should be unlocked with a PIN code/biometrics before being presented.
|
||||
var needsUnlock: Bool { get }
|
||||
|
||||
/// Attempt to unlock the app with the supplied PIN code.
|
||||
func unlock(with pinCode: String) -> Bool
|
||||
/// Attempt to unlock the app using FaceID or TouchID.
|
||||
func unlockWithBiometrics() -> Bool
|
||||
}
|
||||
|
||||
class AppLockService: AppLockServiceProtocol {
|
||||
private let keychainController: KeychainControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
|
||||
var isEnabled: Bool { appSettings.appLockFlowEnabled }
|
||||
var supportsBiometrics: Bool { true }
|
||||
var needsUnlock: Bool { true }
|
||||
|
||||
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) {
|
||||
self.keychainController = keychainController
|
||||
self.appSettings = appSettings
|
||||
}
|
||||
|
||||
func unlock(with pinCode: String) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
guard supportsBiometrics else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// 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 MatrixRustSDK
|
||||
|
||||
/// Adds the missing methods for conformance to the protocol.
|
||||
extension KeychainControllerMock {
|
||||
func retrieveSessionFromKeychain(userId: String) throws -> Session { fatalError("Not implemented") }
|
||||
func saveSessionInKeychain(session: Session) { fatalError("Not implemented") }
|
||||
}
|
||||
@@ -22,6 +22,7 @@ struct KeychainCredentials {
|
||||
let restorationToken: RestorationToken
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol KeychainControllerProtocol: ClientSessionDelegate {
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername: String)
|
||||
func restorationTokenForUsername(_ username: String) -> RestorationToken?
|
||||
|
||||
@@ -33,9 +33,8 @@ class UserSessionStore: UserSessionStoreProtocol {
|
||||
|
||||
var clientSessionDelegate: ClientSessionDelegate { keychainController }
|
||||
|
||||
init(backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
keychainController = KeychainController(service: .sessions,
|
||||
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
||||
init(keychainController: KeychainControllerProtocol, backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
self.keychainController = keychainController
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
baseDirectory = .sessionsBaseDirectory
|
||||
MXLog.info("Setup base directory at: \(baseDirectory)")
|
||||
|
||||
@@ -157,6 +157,10 @@ class MockScreen: Identifiable {
|
||||
let coordinator = TemplateScreenCoordinator(parameters: .init())
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .appLockScreen:
|
||||
let appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings)
|
||||
let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService))
|
||||
return coordinator
|
||||
case .home:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"),
|
||||
|
||||
@@ -29,6 +29,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case analyticsSettingsScreen
|
||||
case migration
|
||||
case templateScreen
|
||||
case appLockScreen
|
||||
case home
|
||||
case settings
|
||||
case bugReport
|
||||
|
||||
Reference in New Issue
Block a user