Initial implementation for AppLockSetupFlowCoordinator. (#1949)

Rename AppLockSettingsScreen to AppLockSetupSettingsScreen.
Implement forced logout and persist PIN entry attempts.
This commit is contained in:
Doug
2023-10-25 12:03:37 +01:00
committed by GitHub
parent 30b18031a0
commit c6e4dc2a37
53 changed files with 773 additions and 234 deletions
@@ -102,8 +102,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
backgroundTaskService: backgroundTaskService)
let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings)
let appLockNavigationCoordinator = NavigationRootCoordinator()
let appLockFlowUserIndicatorController = UserIndicatorController(rootCoordinator: appLockNavigationCoordinator)
appLockFlowCoordinator = AppLockFlowCoordinator(appLockService: appLockService,
navigationCoordinator: NavigationRootCoordinator())
userIndicatorController: appLockFlowUserIndicatorController,
navigationCoordinator: appLockNavigationCoordinator)
notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(),
appSettings: appSettings)
@@ -329,12 +332,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
// We can ignore signOut when already in the process of signing out,
// such as the SDK sending an authError due to token invalidation.
break
case (_, .signOut(let isSoft), .signingOut):
case (_, .signOut(let isSoft, _), .signingOut):
self.logout(isSoft: isSoft)
case (.signingOut, .completedSigningOut, .signedOut):
self.presentSplashScreen(isSoftLogout: false)
case (.signingOut, .showSoftLogout, .softLogout):
self.presentSplashScreen(isSoftLogout: true)
case (.signingOut(_, let disableAppLock), .completedSigningOut, .signedOut):
self.presentSplashScreen(isSoftLogout: false, disableAppLock: disableAppLock)
case (.signingOut(_, let disableAppLock), .showSoftLogout, .softLogout):
self.presentSplashScreen(isSoftLogout: true, disableAppLock: disableAppLock)
case (.signedIn, .clearCache, .initial):
self.clearCache()
default:
@@ -371,7 +374,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
navigationStackCoordinator: authenticationNavigationStackCoordinator,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appLockService: appLockFlowCoordinator.appLockService)
authenticationCoordinator?.delegate = self
authenticationCoordinator?.start()
@@ -410,7 +414,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
stateMachine.processEvent(.createdUserSession)
case .clearAllData:
self.softLogoutCoordinator = nil
stateMachine.processEvent(.signOut(isSoft: false))
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
}
}
.store(in: &cancellables)
@@ -439,7 +443,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
switch action {
case .logout:
stateMachine.processEvent(.signOut(isSoft: false))
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
case .clearCache:
stateMachine.processEvent(.clearCache)
}
@@ -512,7 +516,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
notificationManager.setUserSession(nil)
}
private func presentSplashScreen(isSoftLogout: Bool = false) {
private func presentSplashScreen(isSoftLogout: Bool = false, disableAppLock: Bool = false) {
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
if isSoftLogout {
@@ -520,6 +524,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
} else {
startAuthentication()
}
if disableAppLock {
Task {
// Ensure the navigation stack has settled.
try? await Task.sleep(for: .milliseconds(500))
appLockFlowCoordinator.appLockService.disable()
windowManager.switchToMain()
}
}
}
private func configureNotificationManager() {
@@ -549,7 +562,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
guard let self else { return }
switch callback {
case .didReceiveAuthError(let isSoftLogout):
stateMachine.processEvent(.signOut(isSoft: isSoftLogout))
stateMachine.processEvent(.signOut(isSoft: isSoftLogout, disableAppLock: false))
default:
break
}
@@ -583,6 +596,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
windowManager.switchToAlternate()
case .unlockApp:
windowManager.switchToMain()
case .forceLogout:
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
}
}
.store(in: &cancellables)
@@ -33,7 +33,7 @@ class AppCoordinatorStateMachine {
case signedIn
/// Processing a sign out request
case signingOut(isSoft: Bool)
case signingOut(isSoft: Bool, disableAppLock: Bool)
}
/// Events that can be triggered on the AppCoordinator state machine
@@ -51,7 +51,7 @@ class AppCoordinatorStateMachine {
case createdUserSession
/// Request sign out.
case signOut(isSoft: Bool)
case signOut(isSoft: Bool, disableAppLock: Bool)
/// Request the soft logout screen.
case showSoftLogout
/// Signing out completed.
@@ -80,16 +80,17 @@ class AppCoordinatorStateMachine {
stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn])
stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])
stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false) => .signedOut])
stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true) => .softLogout])
stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false, disableAppLock: false) => .signedOut,
.signingOut(isSoft: false, disableAppLock: true) => .signedOut])
stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true, disableAppLock: false) => .softLogout])
stateMachine.addRoutes(event: .clearCache, transitions: [.signedIn => .initial])
// Transitions with associated values need to be handled through `addRouteMapping`
stateMachine.addRouteMapping { event, fromState, _ in
switch (event, fromState) {
case (.signOut(let isSoft), _):
return .signingOut(isSoft: isSoft)
case (.signOut(let isSoft, let disableAppLock), _):
return .signingOut(isSoft: isSoft, disableAppLock: disableAppLock)
default:
return nil
}
+11 -1
View File
@@ -22,6 +22,9 @@ final class AppSettings {
private enum UserDefaultsKeys: String {
case lastVersionLaunched
case seenInvites
case hasShownWelcomeScreen
case appLockNumberOfPINAttempts
case appLockNumberOfBiometricAttempts
case lastLoginDate
case migratedAccounts
case timelineStyle
@@ -38,7 +41,6 @@ final class AppSettings {
case shouldCollapseRoomStateEvents
case userSuggestionsEnabled
case readReceiptsEnabled
case hasShownWelcomeScreen
case swiftUITimelineEnabled
case voiceMessageEnabled
case mentionsEnabled
@@ -122,10 +124,18 @@ final class AppSettings {
// MARK: - Security
/// The app must be locked with a PIN code as part of the authentication flow.
let appLockIsMandatory = false
/// The amount of time the app can remain in the background for without requesting the PIN/TouchID/FaceID.
let appLockGracePeriod: TimeInterval = 180
/// Any codes that the user isn't allowed to use for their PIN.
let appLockPINCodeBlockList = ["0000", "1234"]
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked).
@UserPreference(key: UserDefaultsKeys.appLockNumberOfPINAttempts, defaultValue: 0, storageType: .userDefaults(store))
var appLockNumberOfPINAttempts: Int
/// The number of attempts the user has made to unlock the app with Touch/Face ID (resets when unlocked).
@UserPreference(key: UserDefaultsKeys.appLockNumberOfBiometricAttempts, defaultValue: 0, storageType: .userDefaults(store))
var appLockNumberOfBiometricAttempts: Int
// MARK: - Authentication
@@ -22,11 +22,14 @@ enum AppLockFlowCoordinatorAction: Equatable {
case lockApp
/// Hide the unlock flow.
case unlockApp
/// Forces a logout of the user.
case forceLogout
}
/// Coordinates the display of any screens shown when the app is locked.
class AppLockFlowCoordinator: CoordinatorProtocol {
let appLockService: AppLockServiceProtocol
let userIndicatorController: UserIndicatorController
let navigationCoordinator: NavigationRootCoordinator
private var cancellables: Set<AnyCancellable> = []
@@ -36,10 +39,18 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
actionsSubject.eraseToAnyPublisher()
}
init(appLockService: AppLockServiceProtocol, navigationCoordinator: NavigationRootCoordinator) {
init(appLockService: AppLockServiceProtocol, userIndicatorController: UserIndicatorController, navigationCoordinator: NavigationRootCoordinator) {
self.appLockService = appLockService
self.userIndicatorController = userIndicatorController
self.navigationCoordinator = navigationCoordinator
appLockService.disabledPublisher
.sink { [weak self] _ in
// When the service is disabled via a force logout, we need to remove the activity indicator.
self?.userIndicatorController.retractAllIndicators()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
self?.applicationDidEnterBackground()
@@ -54,7 +65,7 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
}
func toPresentable() -> AnyView {
AnyView(navigationCoordinator.toPresentable())
AnyView(userIndicatorController.toPresentable())
}
// MARK: - App unlock
@@ -91,6 +102,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
switch action {
case .appUnlocked:
actionsSubject.send(.unlockApp)
case .forceLogout:
userIndicatorController.submitIndicator(UserIndicator(type: .modal, title: L10n.commonSigningOut, persistent: true))
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -0,0 +1,260 @@
//
// 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 SwiftState
import SwiftUI
enum AppLockSetupFlowCoordinatorAction: Equatable {
case complete
}
/// Coordinates the display of any screens used to configure the App Lock feature.
class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
private let presentingFlow: PresentationFlow
private let appLockService: AppLockServiceProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
private let modalNavigationStackCoordinator = NavigationStackCoordinator()
/// The presentation context of the flow.
enum PresentationFlow {
/// The flow is shown as for mandatory PIN creation in the authentication flow
case authentication
/// The flow is shown from the Settings screen.
case settings
}
/// States the flow can find itself in
enum State: StateType {
/// The initial state, used before the flow starts
case initial
/// The unlock screen.
case unlock
/// The create PIN screen.
case createPIN
/// The allow biometrics screen.
case biometricsPrompt
/// The settings screen.
case settings
/// The flow is finished.
case complete
}
/// Events that can be triggered on the flow state machine
enum Event: EventType {
/// Start the flow.
case start
/// The user entered a PIN.
case pinEntered
/// The user completed the biometrics prompt.
case biometricsSet
/// The user wants to change their PIN.
case changePIN
/// The user wants to dismiss the flow.
case dismiss
}
private let stateMachine: StateMachine<State, Event>
private var cancellables: Set<AnyCancellable> = []
private let actionsSubject: PassthroughSubject<AppLockSetupFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<AppLockSetupFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(presentingFlow: PresentationFlow, appLockService: AppLockServiceProtocol, navigationStackCoordinator: NavigationStackCoordinator) {
self.presentingFlow = presentingFlow
self.appLockService = appLockService
self.navigationStackCoordinator = navigationStackCoordinator
stateMachine = .init(state: .initial)
configureStateMachine()
}
func start() {
stateMachine.tryEvent(.start)
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
// Deep links not supported.
}
func clearRoute(animated: Bool) {
// Deep links not supported.
}
// MARK: - Private
private func configureStateMachine() {
stateMachine.addRouteMapping { [weak self] event, fromState, _ in
guard let self else { return nil }
switch (event, fromState) {
case (.start, .initial):
if presentingFlow == .authentication { return .createPIN }
return appLockService.isEnabled ? .unlock : .createPIN
case (.pinEntered, .unlock):
return .settings
case (.pinEntered, .createPIN):
if presentingFlow == .authentication {
return appLockService.biometryType != .none ? .biometricsPrompt : .complete
} else {
return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt
}
case (.biometricsSet, .biometricsPrompt):
return presentingFlow == .settings ? .settings : .complete
case (.changePIN, .settings):
return .createPIN
case (.dismiss, _):
return .complete
default:
return nil
}
}
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
guard let self else { return }
MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(String(describing: context.event))`.")
switch (context.fromState, context.toState) {
case (.initial, .unlock):
showPINUnlock()
case (.initial, .createPIN):
showCreatePIN()
case (.unlock, .settings):
showSettings()
case (.createPIN, .biometricsPrompt):
showBiometricsPrompt()
case (.createPIN, .settings):
navigationStackCoordinator.setSheetCoordinator(nil)
case (.biometricsPrompt, .settings):
showSettings()
case (.settings, .createPIN):
showCreatePIN()
case (_, .complete):
complete(from: context.fromState)
default:
fatalError("Unhandled transition.")
}
}
stateMachine.addErrorHandler { context in
fatalError("Unexpected transition from `\(context.fromState)` to `\(context.toState)` with event `\(String(describing: context.event))`.")
}
}
private func showCreatePIN() {
// Despite appLockService.isMandatory existing, we don't use that here,
// to allow for cancellation when changing the PIN code within settings.
let isMandatory = presentingFlow == .authentication
let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .create,
isMandatory: isMandatory,
appLockService: appLockService))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
stateMachine.tryEvent(.dismiss)
case .complete:
stateMachine.tryEvent(.pinEntered)
}
}
.store(in: &cancellables)
if presentingFlow == .authentication {
navigationStackCoordinator.push(coordinator)
} else {
modalNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(modalNavigationStackCoordinator)
}
}
private func showBiometricsPrompt() {
let coordinator = AppLockSetupBiometricsScreenCoordinator(parameters: .init(appLockService: appLockService))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .continue:
stateMachine.tryEvent(.biometricsSet)
}
}
.store(in: &cancellables)
if presentingFlow == .authentication {
navigationStackCoordinator.push(coordinator)
} else {
modalNavigationStackCoordinator.push(coordinator)
}
}
private func showPINUnlock() {
let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .unlock,
isMandatory: false,
appLockService: appLockService))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
stateMachine.tryEvent(.dismiss)
case .complete:
stateMachine.tryEvent(.pinEntered)
}
}
.store(in: &cancellables)
modalNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(modalNavigationStackCoordinator)
}
private func showSettings() {
let coordinator = AppLockSetupSettingsScreenCoordinator(parameters: .init(isMandatory: appLockService.isMandatory,
appLockService: appLockService))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .changePINCode:
stateMachine.tryEvent(.changePIN)
case .appLockDisabled:
stateMachine.tryEvent(.dismiss)
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in
self?.actionsSubject.send(.complete)
}
navigationStackCoordinator.setSheetCoordinator(nil)
}
/// Tear down the flow for completion.
private func complete(from state: State) {
switch state {
case .initial, .complete: fatalError()
case .unlock:
navigationStackCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.complete)
case .createPIN:
navigationStackCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.complete)
case .biometricsPrompt:
navigationStackCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.complete)
case .settings:
navigationStackCoordinator.pop()
actionsSubject.send(.complete)
}
}
}
+6 -2
View File
@@ -328,6 +328,8 @@ public enum L10n {
public static var commonSettings: String { return L10n.tr("Localizable", "common_settings") }
/// Shared location
public static var commonSharedLocation: String { return L10n.tr("Localizable", "common_shared_location") }
/// Signing out
public static var commonSigningOut: String { return L10n.tr("Localizable", "common_signing_out") }
/// Starting chat…
public static var commonStartingChat: String { return L10n.tr("Localizable", "common_starting_chat") }
/// Sticker
@@ -724,8 +726,10 @@ public enum L10n {
public static var screenAppLockSignoutAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_message") }
/// You are being signed out
public static var screenAppLockSignoutAlertTitle: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_title") }
/// You have 3 attempts to unlock
public static var screenAppLockSubtitle: String { return L10n.tr("Localizable", "screen_app_lock_subtitle") }
/// Plural format key: "%#@COUNT@"
public static func screenAppLockSubtitle(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_app_lock_subtitle", p1)
}
/// Plural format key: "%#@COUNT@"
public static func screenAppLockSubtitleWrongPin(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_app_lock_subtitle_wrong_pin", p1)
@@ -117,6 +117,11 @@ class AnalyticsClientMock: AnalyticsClientProtocol {
}
}
class AppLockServiceMock: AppLockServiceProtocol {
var isMandatory: Bool {
get { return underlyingIsMandatory }
set(value) { underlyingIsMandatory = value }
}
var underlyingIsMandatory: Bool!
var isEnabled: Bool {
get { return underlyingIsEnabled }
set(value) { underlyingIsEnabled = value }
@@ -132,6 +137,21 @@ class AppLockServiceMock: AppLockServiceProtocol {
set(value) { underlyingBiometricUnlockEnabled = value }
}
var underlyingBiometricUnlockEnabled: Bool!
var disabledPublisher: AnyPublisher<Void, Never> {
get { return underlyingDisabledPublisher }
set(value) { underlyingDisabledPublisher = value }
}
var underlyingDisabledPublisher: AnyPublisher<Void, Never>!
var numberOfPINAttempts: AnyPublisher<Int, Never> {
get { return underlyingNumberOfPINAttempts }
set(value) { underlyingNumberOfPINAttempts = value }
}
var underlyingNumberOfPINAttempts: AnyPublisher<Int, Never>!
var numberOfBiometricAttempts: AnyPublisher<Int, Never> {
get { return underlyingNumberOfBiometricAttempts }
set(value) { underlyingNumberOfBiometricAttempts = value }
}
var underlyingNumberOfBiometricAttempts: AnyPublisher<Int, Never>!
//MARK: - setupPINCode
@@ -25,6 +25,8 @@ struct AppLockScreenCoordinatorParameters {
enum AppLockScreenCoordinatorAction {
/// The user has successfully unlocked the app.
case appUnlocked
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
}
final class AppLockScreenCoordinator: CoordinatorProtocol {
@@ -51,6 +53,8 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
switch action {
case .appUnlocked:
self.actionsSubject.send(.appUnlocked)
case .forceLogout:
self.actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -19,6 +19,8 @@ import Foundation
enum AppLockScreenViewModelAction {
/// The user has successfully unlocked the app.
case appUnlocked
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
}
struct AppLockScreenViewState: BindableState {
@@ -36,7 +38,7 @@ struct AppLockScreenViewState: BindableState {
/// The string shown in the screen's subtitle.
var subtitle: String {
if !isSubtitleWarning {
return L10n.screenAppLockSubtitle
return L10n.screenAppLockSubtitle(maximumAttempts)
} else {
return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfPINAttempts)
}
@@ -50,8 +52,8 @@ struct AppLockScreenViewStateBindings {
}
enum AppLockScreenAlertType {
/// The user has failed too many times, they're being signed out.
case forceSignOut
/// The user has failed too many times, they're being logged out.
case forcedLogout
/// The user has forgotten their PIN, confirm they're happy to sign out.
case confirmResetPIN
}
@@ -31,6 +31,12 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
self.appLockService = appLockService
super.init(initialViewState: AppLockScreenViewState(bindings: .init()))
appLockService.numberOfPINAttempts
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
.store(in: &cancellables)
showForceLogoutAlertIfNeeded()
}
// MARK: - Public
@@ -58,24 +64,21 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
state.bindings.alertInfo = .init(id: .confirmResetPIN,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk, action: forceSignOut),
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private func handleInvalidPIN() {
MXLog.warning("Invalid PIN code entered.")
state.numberOfPINAttempts += 1
if state.numberOfPINAttempts == 3 {
state.bindings.alertInfo = .init(id: .forceSignOut,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk, action: nil))
forceSignOut()
}
showForceLogoutAlertIfNeeded()
}
private func forceSignOut() {
// To be implemented.
private func showForceLogoutAlertIfNeeded() {
if state.numberOfPINAttempts >= 3 {
state.bindings.alertInfo = .init(id: .forcedLogout,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) })
}
}
}
@@ -31,8 +31,10 @@ struct AppLockSetupBiometricsScreen: View {
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
.frame(maxWidth: .infinity)
}
.toolbar(.visible, for: .navigationBar)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.toolbar(.visible, for: .navigationBar)
.navigationBarBackButtonHidden()
.interactiveDismissDisabled()
.safeAreaInset(edge: .bottom) {
buttons
.padding(.top, 16)
@@ -21,6 +21,8 @@ struct AppLockSetupPINScreenCoordinatorParameters {
/// Whether the screen should start in create or unlock mode.
/// Specifying confirm here will raise a fatal error.
let initialMode: AppLockSetupPINScreenMode
/// Whether the screen is mandatory or can be cancelled by the user.
let isMandatory: Bool
let appLockService: AppLockServiceProtocol
}
@@ -46,6 +48,7 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = AppLockSetupPINScreenViewModel(initialMode: parameters.initialMode,
isMandatory: parameters.isMandatory,
appLockService: parameters.appLockService)
}
@@ -56,9 +59,9 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .complete:
break
actionsSubject.send(.complete)
case .cancel:
break
actionsSubject.send(.cancel)
}
}
.store(in: &cancellables)
@@ -35,6 +35,8 @@ enum AppLockSetupPINScreenMode {
struct AppLockSetupPINScreenViewState: BindableState {
/// The current mode that the screen is in.
var mode: AppLockSetupPINScreenMode
/// Whether the screen is mandatory or can be cancelled.
let isMandatory: Bool
var title: String {
switch mode {
@@ -30,9 +30,9 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
actionsSubject.eraseToAnyPublisher()
}
init(initialMode: AppLockSetupPINScreenMode, appLockService: AppLockServiceProtocol) {
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, bindings: .init(pinCode: "")))
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
}
// MARK: - Public
@@ -21,6 +21,8 @@ import SwiftUI
struct AppLockSetupPINScreen: View {
@ObservedObject var context: AppLockSetupPINScreenViewModel.Context
@FocusState private var textFieldFocus
var body: some View {
ScrollView {
VStack(spacing: 48) {
@@ -28,6 +30,7 @@ struct AppLockSetupPINScreen: View {
PINTextField(pinCode: $context.pinCode,
isSecure: context.viewState.mode == .unlock)
.focused($textFieldFocus)
.onChange(of: context.pinCode) { newValue in
guard newValue.count == 4 else { return }
context.send(viewAction: .submitPINCode)
@@ -38,6 +41,12 @@ struct AppLockSetupPINScreen: View {
.frame(maxWidth: .infinity)
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.toolbar { toolbar }
.toolbar(.visible, for: .navigationBar)
.navigationBarBackButtonHidden()
.interactiveDismissDisabled(context.viewState.isMandatory)
.alert(item: $context.alertInfo)
.onAppear { textFieldFocus = true }
}
var header: some View {
@@ -59,6 +68,17 @@ struct AppLockSetupPINScreen: View {
}
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
if !context.viewState.isMandatory {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
}
// MARK: - Previews
@@ -66,9 +86,15 @@ struct AppLockSetupPINScreen: View {
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
static let service = AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings)
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: service)
static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm, appLockService: service)
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, appLockService: service)
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
isMandatory: false,
appLockService: service)
static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm,
isMandatory: false,
appLockService: service)
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
isMandatory: false,
appLockService: service)
static var previews: some View {
NavigationStack {
@@ -17,28 +17,31 @@
import Combine
import SwiftUI
struct AppLockSettingsScreenCoordinatorParameters {
struct AppLockSetupSettingsScreenCoordinatorParameters {
/// Whether App Lock is mandatory and can be disabled by the user.
let isMandatory: Bool
let appLockService: AppLockServiceProtocol
}
enum AppLockSettingsScreenCoordinatorAction {
case done
enum AppLockSetupSettingsScreenCoordinatorAction {
case changePINCode
case appLockDisabled
}
final class AppLockSettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: AppLockSettingsScreenCoordinatorParameters
private var viewModel: AppLockSettingsScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<AppLockSettingsScreenCoordinatorAction, Never> = .init()
final class AppLockSetupSettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: AppLockSetupSettingsScreenCoordinatorParameters
private var viewModel: AppLockSetupSettingsScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<AppLockSetupSettingsScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<AppLockSettingsScreenCoordinatorAction, Never> {
var actions: AnyPublisher<AppLockSetupSettingsScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: AppLockSettingsScreenCoordinatorParameters) {
init(parameters: AppLockSetupSettingsScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = AppLockSettingsScreenViewModel(appLockService: parameters.appLockService)
viewModel = AppLockSetupSettingsScreenViewModel(appLockService: parameters.appLockService)
}
func start() {
@@ -48,15 +51,15 @@ final class AppLockSettingsScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .changePINCode:
break
case .done:
self.actionsSubject.send(.done)
actionsSubject.send(.changePINCode)
case .appLockDisabled:
actionsSubject.send(.appLockDisabled)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(AppLockSettingsScreen(context: viewModel.context))
AnyView(AppLockSetupSettingsScreen(context: viewModel.context))
}
}
@@ -17,36 +17,38 @@
import Foundation
import LocalAuthentication
enum AppLockSettingsScreenViewModelAction {
enum AppLockSetupSettingsScreenViewModelAction {
/// The user would like to enter new PIN code.
case changePINCode
case done
/// The user has disabled the App Lock feature.
case appLockDisabled
}
struct AppLockSettingsScreenViewState: BindableState {
struct AppLockSetupSettingsScreenViewState: BindableState {
/// Whether App Lock is mandatory and can be disabled by the user.
let isMandatory: Bool
let biometryType: LABiometryType
var bindings: AppLockSettingsScreenViewStateBindings
var bindings: AppLockSetupSettingsScreenViewStateBindings
var supportsBiometry: Bool { biometryType != .none }
var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
}
struct AppLockSettingsScreenViewStateBindings {
struct AppLockSetupSettingsScreenViewStateBindings {
var enableBiometrics: Bool
var alertInfo: AlertInfo<AppLockSettingsScreenAlertType>?
var alertInfo: AlertInfo<AppLockSetupSettingsScreenAlertType>?
}
enum AppLockSettingsScreenAlertType {
enum AppLockSetupSettingsScreenAlertType {
/// The alert shown to confirm the user would like to remove their PIN.
case confirmRemovePINCode
}
enum AppLockSettingsScreenViewAction {
enum AppLockSetupSettingsScreenViewAction {
/// The user would like to enter a new PIN code.
case changePINCode
/// The user would like to disable the App Lock feature.
case disable
/// The user has toggled the biometrics setting.
case enableBiometricsChanged
case done
}
@@ -17,25 +17,26 @@
import Combine
import SwiftUI
typealias AppLockSettingsScreenViewModelType = StateStoreViewModel<AppLockSettingsScreenViewState, AppLockSettingsScreenViewAction>
typealias AppLockSetupSettingsScreenViewModelType = StateStoreViewModel<AppLockSetupSettingsScreenViewState, AppLockSetupSettingsScreenViewAction>
class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLockSettingsScreenViewModelProtocol {
class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelType, AppLockSetupSettingsScreenViewModelProtocol {
private let appLockService: AppLockServiceProtocol
private var actionsSubject: PassthroughSubject<AppLockSettingsScreenViewModelAction, Never> = .init()
private var actionsSubject: PassthroughSubject<AppLockSetupSettingsScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<AppLockSettingsScreenViewModelAction, Never> {
var actions: AnyPublisher<AppLockSetupSettingsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService
super.init(initialViewState: AppLockSettingsScreenViewState(biometryType: appLockService.biometryType,
bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled)))
super.init(initialViewState: AppLockSetupSettingsScreenViewState(isMandatory: appLockService.isMandatory,
biometryType: appLockService.biometryType,
bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled)))
}
// MARK: - Public
override func process(viewAction: AppLockSettingsScreenViewAction) {
override func process(viewAction: AppLockSetupSettingsScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
@@ -45,8 +46,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc
showRemovePINAlert()
case .enableBiometricsChanged:
appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics
case .done:
actionsSubject.send(.done)
}
}
@@ -64,6 +63,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc
/// Removes the user's PIN code, disabling the App Lock feature.
private func completeRemovePIN() {
appLockService.disable()
actionsSubject.send(.done)
actionsSubject.send(.appLockDisabled)
}
}
@@ -17,7 +17,7 @@
import Combine
@MainActor
protocol AppLockSettingsScreenViewModelProtocol {
var actions: AnyPublisher<AppLockSettingsScreenViewModelAction, Never> { get }
var context: AppLockSettingsScreenViewModelType.Context { get }
protocol AppLockSetupSettingsScreenViewModelProtocol {
var actions: AnyPublisher<AppLockSetupSettingsScreenViewModelAction, Never> { get }
var context: AppLockSetupSettingsScreenViewModelType.Context { get }
}
@@ -17,16 +17,19 @@
import Compound
import SwiftUI
struct AppLockSettingsScreen: View {
@ObservedObject var context: AppLockSettingsScreenViewModel.Context
struct AppLockSetupSettingsScreen: View {
@ObservedObject var context: AppLockSetupSettingsScreenViewModel.Context
var body: some View {
Form {
Section {
ListRow(label: .plain(title: L10n.screenAppLockSettingsChangePin),
kind: .button { context.send(viewAction: .changePINCode) })
ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive),
kind: .button { context.send(viewAction: .disable) })
if !context.viewState.isMandatory {
ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive),
kind: .button { context.send(viewAction: .disable) })
}
}
if context.viewState.supportsBiometry {
@@ -48,25 +51,25 @@ struct AppLockSettingsScreen: View {
// MARK: - Previews
struct AppLockSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let faceIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID))
static let touchIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .touchID))
static let biometricsUnavailableViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none))
struct AppLockSetupSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let faceIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID))
static let touchIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(isMandatory: true, biometryType: .touchID))
static let biometricsUnavailableViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none))
static var previews: some View {
NavigationStack {
AppLockSettingsScreen(context: faceIDViewModel.context)
AppLockSetupSettingsScreen(context: faceIDViewModel.context)
}
.previewDisplayName("Face ID")
NavigationStack {
AppLockSettingsScreen(context: touchIDViewModel.context)
AppLockSetupSettingsScreen(context: touchIDViewModel.context)
}
.previewDisplayName("Touch ID")
.previewDisplayName("Touch ID (Mandatory)")
NavigationStack {
AppLockSettingsScreen(context: biometricsUnavailableViewModel.context)
AppLockSetupSettingsScreen(context: biometricsUnavailableViewModel.context)
}
.previewDisplayName("No Biometrics")
.previewDisplayName("PIN only")
}
}
@@ -29,10 +29,12 @@ class AuthenticationCoordinator: CoordinatorProtocol {
private let appSettings: AppSettings
private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
private let appLockService: AppLockServiceProtocol
private var cancellables = Set<AnyCancellable>()
private var oidcPresenter: OIDCAuthenticationPresenter?
private var appLockFlowCoordinator: AppLockSetupFlowCoordinator?
weak var delegate: AuthenticationCoordinatorDelegate?
@@ -40,12 +42,14 @@ class AuthenticationCoordinator: CoordinatorProtocol {
navigationStackCoordinator: NavigationStackCoordinator,
appSettings: AppSettings,
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
appLockService: AppLockServiceProtocol) {
self.authenticationService = authenticationService
self.navigationStackCoordinator = navigationStackCoordinator
self.appSettings = appSettings
self.analytics = analytics
self.userIndicatorController = userIndicatorController
self.appLockService = appLockService
}
func start() {
@@ -64,7 +68,7 @@ class AuthenticationCoordinator: CoordinatorProtocol {
oidcPresenter.handleUniversalLinkCallback(url)
}
// MARK: - Private
private func showOnboarding() {
@@ -237,25 +241,46 @@ class AuthenticationCoordinator: CoordinatorProtocol {
private func userHasSignedIn(userSession: UserSessionProtocol) {
appSettings.lastLoginDate = .now
showAnalyticsPromptIfNeeded { [weak self] in
guard let self else { return }
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
if appSettings.appLockIsMandatory, !appLockService.isEnabled {
showAppLockSetupFlow(userSession: userSession)
} else if analytics.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(userSession: userSession)
} else {
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
}
private func showAnalyticsPromptIfNeeded(completion: @escaping () -> Void) {
guard analytics.shouldShowAnalyticsPrompt else {
completion()
return
private func showAppLockSetupFlow(userSession: UserSessionProtocol) {
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .authentication,
appLockService: appLockService,
navigationStackCoordinator: navigationStackCoordinator)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .complete:
appLockFlowCoordinator = nil
if analytics.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(userSession: userSession)
} else {
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
}
}
.store(in: &cancellables)
appLockFlowCoordinator = coordinator
coordinator.start()
}
private func showAnalyticsPrompt(userSession: UserSessionProtocol) {
let coordinator = AnalyticsPromptScreenCoordinator(analytics: analytics, termsURL: appSettings.analyticsConfiguration.termsURL)
coordinator.actions
.sink { action in
.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
completion()
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
}
.store(in: &cancellables)
@@ -39,6 +39,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: SettingsScreenCoordinatorParameters
private var viewModel: SettingsScreenViewModelProtocol
private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator?
private let actionsSubject: PassthroughSubject<SettingsScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
@@ -68,7 +70,7 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
case .analytics:
presentAnalyticsScreen()
case .appLock:
presentAppLockSettingsScreen()
presentAppLockSetupFlow()
case .reportBug:
presentBugReportScreen()
case .about:
@@ -146,9 +148,27 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
parameters.navigationStackCoordinator?.push(coordinator)
}
private func presentAppLockSettingsScreen() {
let coordinator = AppLockSettingsScreenCoordinator(parameters: .init(appLockService: parameters.appLockService))
parameters.navigationStackCoordinator?.push(coordinator)
private func presentAppLockSetupFlow() {
guard let navigationStackCoordinator = parameters.navigationStackCoordinator else {
MXLog.error("The navigation stack has gone! 🌫️")
return
}
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .settings,
appLockService: parameters.appLockService,
navigationStackCoordinator: navigationStackCoordinator)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .complete:
// The flow coordinator tidies up the stack, no need to do anything.
appLockSetupFlowCoordinator = nil
}
}
.store(in: &cancellables)
appLockSetupFlowCoordinator = coordinator
coordinator.start()
}
private func presentBugReportScreen() {
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import LocalAuthentication
/// The service responsible for locking and unlocking the app.
@@ -24,6 +25,8 @@ class AppLockService: AppLockServiceProtocol {
private let timer: AppLockTimer
var isMandatory: Bool { appSettings.appLockIsMandatory }
var isEnabled: Bool {
do {
guard appSettings.appLockFlowEnabled else { return false }
@@ -38,6 +41,12 @@ class AppLockService: AppLockServiceProtocol {
var biometryType: LABiometryType { context.biometryType }
var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet.
var numberOfPINAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfPINAttempts }
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfBiometricAttempts }
private var disabledSubject: PassthroughSubject<Void, Never> = .init()
var disabledPublisher: AnyPublisher<Void, Never> { disabledSubject.eraseToAnyPublisher() }
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) {
self.keychainController = keychainController
self.appSettings = appSettings
@@ -68,6 +77,9 @@ class AppLockService: AppLockServiceProtocol {
func disable() {
biometricUnlockEnabled = false
keychainController.removePINCode()
appSettings.appLockNumberOfPINAttempts = 0
appSettings.appLockNumberOfBiometricAttempts = 0
disabledSubject.send()
}
func applicationDidEnterBackground() {
@@ -79,12 +91,20 @@ class AppLockService: AppLockServiceProtocol {
}
func unlock(with pinCode: String) -> Bool {
guard pinCode == keychainController.pinCode() else { return false }
guard pinCode == keychainController.pinCode() else {
MXLog.warning("Wrong PIN entered.")
appSettings.appLockNumberOfPINAttempts += 1
return false
}
return completeUnlock()
}
func unlockWithBiometrics() -> Bool {
guard biometryType != .none, biometricUnlockEnabled else { return false }
guard biometryType != .none, biometricUnlockEnabled else {
MXLog.warning("\(biometryType) failed.")
appSettings.appLockNumberOfBiometricAttempts += 1
return false
}
return completeUnlock()
}
@@ -103,6 +123,8 @@ class AppLockService: AppLockServiceProtocol {
/// Shared logic for completing an unlock via a PIN or biometry.
private func completeUnlock() -> Bool {
timer.registerUnlock()
appSettings.appLockNumberOfPINAttempts = 0
appSettings.appLockNumberOfBiometricAttempts = 0
return true
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import LocalAuthentication
enum AppLockServiceError: Error {
@@ -27,13 +28,19 @@ enum AppLockServiceError: Error {
@MainActor
protocol AppLockServiceProtocol: AnyObject {
/// The use of a PIN code is mandatory for this device.
var isMandatory: Bool { get }
/// The app has been configured to automatically lock with a PIN code.
var isEnabled: Bool { get }
/// The type of biometric authentication supported by the device.
var biometryType: LABiometryType { get }
/// Whether or not the user has enabled unlock via TouchID, FaceID or (possibly) OpticID.
var biometricUnlockEnabled: Bool { get set }
/// A publisher that advertises when the service has been disabled.
var disabledPublisher: AnyPublisher<Void, Never> { get }
/// Sets the user's PIN code used to unlock the app.
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError>
/// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice.
@@ -50,15 +57,23 @@ protocol AppLockServiceProtocol: AnyObject {
func unlock(with pinCode: String) -> Bool
/// Attempt to unlock the app using FaceID or TouchID.
func unlockWithBiometrics() -> Bool
/// The number of attempts the user had made to unlock with a PIN code.
var numberOfPINAttempts: AnyPublisher<Int, Never> { get }
/// The number of attempts the user has made to unlock with Touch/Face ID.
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { get }
}
// sourcery: AutoMockable
extension AppLockServiceProtocol { }
extension AppLockServiceMock {
static func mock(pinCode: String? = "2023", biometryType: LABiometryType = .faceID) -> AppLockServiceMock {
static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID) -> AppLockServiceMock {
let mock = AppLockServiceMock()
mock.isEnabled = pinCode != nil
mock.isMandatory = isMandatory
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
mock.numberOfBiometricAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
mock.underlyingBiometryType = biometryType
mock.underlyingBiometricUnlockEnabled = biometryType != .none
mock.unlockWithClosure = { $0 == pinCode }
@@ -127,7 +127,8 @@ class MockScreen: Identifiable {
navigationStackCoordinator: navigationStackCoordinator,
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appLockService: AppLockServiceMock())
retainedState.append(coordinator)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
@@ -162,17 +163,12 @@ class MockScreen: Identifiable {
let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService))
return coordinator
case .appLockSetupFlow:
// Use the flow coordinator once more screens are added and remove the settings screen below.
let navigationStackCoordinator = NavigationStackCoordinator()
let appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings)
let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .create, appLockService: appLockService))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .appLockSettingsScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let appLockService = AppLockServiceMock.mock(biometryType: .faceID)
let coordinator = AppLockSettingsScreenCoordinator(parameters: .init(appLockService: appLockService))
navigationStackCoordinator.setRootCoordinator(coordinator)
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .settings,
appLockService: appLockService,
navigationStackCoordinator: navigationStackCoordinator)
coordinator.start()
return navigationStackCoordinator
case .home:
let navigationStackCoordinator = NavigationStackCoordinator()
@@ -31,7 +31,6 @@ enum UITestsScreenIdentifier: String {
case templateScreen
case appLockScreen
case appLockSetupFlow
case appLockSettingsScreen
case home
case settings
case bugReport