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

* Add AppLockCoordinator and WindowManager.
This commit is contained in:
Doug
2023-10-11 13:59:47 +01:00
committed by GitHub
parent b35bee5d8f
commit b6470c9651
32 changed files with 838 additions and 15 deletions
@@ -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)
}
}
}
@@ -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 { }
@@ -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