mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 20:09:04 +00:00
Handle invalid PIN input in the settings flow. (#1972)
This commit is contained in:
@@ -447,6 +447,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
|
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
|
||||||
case .clearCache:
|
case .clearCache:
|
||||||
stateMachine.processEvent(.clearCache)
|
stateMachine.processEvent(.clearCache)
|
||||||
|
case .forceLogout:
|
||||||
|
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import SwiftState
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum AppLockSetupFlowCoordinatorAction: Equatable {
|
enum AppLockSetupFlowCoordinatorAction: Equatable {
|
||||||
|
/// The flow is complete.
|
||||||
case complete
|
case complete
|
||||||
|
/// The user failed to remember their existing PIN.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coordinates the display of any screens used to configure the App Lock feature.
|
/// Coordinates the display of any screens used to configure the App Lock feature.
|
||||||
@@ -49,8 +52,10 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case biometricsPrompt
|
case biometricsPrompt
|
||||||
/// The settings screen.
|
/// The settings screen.
|
||||||
case settings
|
case settings
|
||||||
/// The flow is finished.
|
/// The flow is finished. This is a final state.
|
||||||
case complete
|
case complete
|
||||||
|
/// The user is being signed out. This is a final state.
|
||||||
|
case loggingOut
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events that can be triggered on the flow state machine
|
/// Events that can be triggered on the flow state machine
|
||||||
@@ -63,8 +68,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case biometricsSet
|
case biometricsSet
|
||||||
/// The user wants to change their PIN.
|
/// The user wants to change their PIN.
|
||||||
case changePIN
|
case changePIN
|
||||||
/// The user wants to dismiss the flow.
|
/// The user has disabled the app lock feature.
|
||||||
case dismiss
|
case appLockDisabled
|
||||||
|
/// The user wants to cancel the flow.
|
||||||
|
case cancel
|
||||||
|
/// The user failed to remember their existing PIN.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
private let stateMachine: StateMachine<State, Event>
|
private let stateMachine: StateMachine<State, Event>
|
||||||
@@ -108,17 +117,23 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
return appLockService.isEnabled ? .unlock : .createPIN
|
return appLockService.isEnabled ? .unlock : .createPIN
|
||||||
case (.pinEntered, .unlock):
|
case (.pinEntered, .unlock):
|
||||||
return .settings
|
return .settings
|
||||||
|
case (.cancel, .unlock):
|
||||||
|
return .complete
|
||||||
|
case (.forceLogout, .unlock):
|
||||||
|
return .loggingOut
|
||||||
case (.pinEntered, .createPIN):
|
case (.pinEntered, .createPIN):
|
||||||
if presentingFlow == .authentication {
|
if presentingFlow == .authentication {
|
||||||
return appLockService.biometryType != .none ? .biometricsPrompt : .complete
|
return appLockService.biometryType != .none ? .biometricsPrompt : .complete
|
||||||
} else {
|
} else {
|
||||||
return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt
|
return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt
|
||||||
}
|
}
|
||||||
|
case (.cancel, .createPIN):
|
||||||
|
return .complete
|
||||||
case (.biometricsSet, .biometricsPrompt):
|
case (.biometricsSet, .biometricsPrompt):
|
||||||
return presentingFlow == .settings ? .settings : .complete
|
return presentingFlow == .settings ? .settings : .complete
|
||||||
case (.changePIN, .settings):
|
case (.changePIN, .settings):
|
||||||
return .createPIN
|
return .createPIN
|
||||||
case (.dismiss, _):
|
case (.appLockDisabled, .settings):
|
||||||
return .complete
|
return .complete
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -149,6 +164,8 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
showCreatePIN()
|
showCreatePIN()
|
||||||
case (_, .complete):
|
case (_, .complete):
|
||||||
complete(from: context.fromState)
|
complete(from: context.fromState)
|
||||||
|
case (.unlock, .loggingOut):
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
default:
|
default:
|
||||||
fatalError("Unhandled transition.")
|
fatalError("Unhandled transition.")
|
||||||
}
|
}
|
||||||
@@ -170,10 +187,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
coordinator.actions.sink { [weak self] action in
|
coordinator.actions.sink { [weak self] action in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch action {
|
switch action {
|
||||||
case .cancel:
|
|
||||||
stateMachine.tryEvent(.dismiss)
|
|
||||||
case .complete:
|
case .complete:
|
||||||
stateMachine.tryEvent(.pinEntered)
|
stateMachine.tryEvent(.pinEntered)
|
||||||
|
case .cancel:
|
||||||
|
stateMachine.tryEvent(.cancel)
|
||||||
|
case .forceLogout:
|
||||||
|
fatalError("Creating a PIN can't force a logout.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -211,10 +230,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
coordinator.actions.sink { [weak self] action in
|
coordinator.actions.sink { [weak self] action in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch action {
|
switch action {
|
||||||
case .cancel:
|
|
||||||
stateMachine.tryEvent(.dismiss)
|
|
||||||
case .complete:
|
case .complete:
|
||||||
stateMachine.tryEvent(.pinEntered)
|
stateMachine.tryEvent(.pinEntered)
|
||||||
|
case .cancel:
|
||||||
|
stateMachine.tryEvent(.cancel)
|
||||||
|
case .forceLogout:
|
||||||
|
stateMachine.tryEvent(.forceLogout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -231,7 +252,7 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case .changePINCode:
|
case .changePINCode:
|
||||||
stateMachine.tryEvent(.changePIN)
|
stateMachine.tryEvent(.changePIN)
|
||||||
case .appLockDisabled:
|
case .appLockDisabled:
|
||||||
stateMachine.tryEvent(.dismiss)
|
stateMachine.tryEvent(.appLockDisabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -245,7 +266,7 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
/// Tear down the flow for completion.
|
/// Tear down the flow for completion.
|
||||||
private func complete(from state: State) {
|
private func complete(from state: State) {
|
||||||
switch state {
|
switch state {
|
||||||
case .initial, .complete: fatalError()
|
case .initial, .complete, .loggingOut: fatalError()
|
||||||
case .unlock:
|
case .unlock:
|
||||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||||
actionsSubject.send(.complete)
|
actionsSubject.send(.complete)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ enum SettingsFlowCoordinatorAction {
|
|||||||
case dismissedSettings
|
case dismissedSettings
|
||||||
case runLogoutFlow
|
case runLogoutFlow
|
||||||
case clearCache
|
case clearCache
|
||||||
|
/// Logout without a confirmation. The user forgot their PIN.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsFlowCoordinatorParameters {
|
struct SettingsFlowCoordinatorParameters {
|
||||||
@@ -107,6 +109,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
actionsSubject.send(.clearCache)
|
actionsSubject.send(.clearCache)
|
||||||
case .secureBackup:
|
case .secureBackup:
|
||||||
presentSecureBackupScreen(animated: true)
|
presentSecureBackupScreen(animated: true)
|
||||||
|
case .forceLogout:
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import SwiftUI
|
|||||||
enum UserSessionFlowCoordinatorAction {
|
enum UserSessionFlowCoordinatorAction {
|
||||||
case logout
|
case logout
|
||||||
case clearCache
|
case clearCache
|
||||||
|
/// Logout without a confirmation. The user forgot their PIN.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||||
@@ -116,6 +118,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
runLogoutFlow()
|
runLogoutFlow()
|
||||||
case .clearCache:
|
case .clearCache:
|
||||||
actionsSubject.send(.clearCache)
|
actionsSubject.send(.clearCache)
|
||||||
|
case .forceLogout:
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ enum AppLockScreenViewModelAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppLockScreenViewState: BindableState {
|
struct AppLockScreenViewState: BindableState {
|
||||||
private let maximumAttempts = 3
|
/// The number of attempts allowed to unlock the app.
|
||||||
|
let maximumAttempts = 3
|
||||||
|
|
||||||
/// The number of times the user attempted to enter their PIN.
|
/// The number of times the user attempted to enter their PIN.
|
||||||
var numberOfPINAttempts = 0
|
var numberOfPINAttempts = 0
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showForceLogoutAlertIfNeeded() {
|
private func showForceLogoutAlertIfNeeded() {
|
||||||
if state.numberOfPINAttempts >= 3 {
|
if state.numberOfPINAttempts >= state.maximumAttempts {
|
||||||
state.bindings.alertInfo = .init(id: .forcedLogout,
|
state.bindings.alertInfo = .init(id: .forcedLogout,
|
||||||
title: L10n.screenAppLockSignoutAlertTitle,
|
title: L10n.screenAppLockSignoutAlertTitle,
|
||||||
message: L10n.screenAppLockSignoutAlertMessage,
|
message: L10n.screenAppLockSignoutAlertMessage,
|
||||||
|
|||||||
+6
-2
@@ -27,10 +27,12 @@ struct AppLockSetupPINScreenCoordinatorParameters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AppLockSetupPINScreenCoordinatorAction {
|
enum AppLockSetupPINScreenCoordinatorAction {
|
||||||
/// The user cancelled PIN entry.
|
|
||||||
case cancel
|
|
||||||
/// The user succeeded PIN entry.
|
/// The user succeeded PIN entry.
|
||||||
case complete
|
case complete
|
||||||
|
/// The user cancelled PIN entry.
|
||||||
|
case cancel
|
||||||
|
/// The user failed to remember their PIN to unlock.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
|
final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
|
||||||
@@ -62,6 +64,8 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
|
|||||||
actionsSubject.send(.complete)
|
actionsSubject.send(.complete)
|
||||||
case .cancel:
|
case .cancel:
|
||||||
actionsSubject.send(.cancel)
|
actionsSubject.send(.cancel)
|
||||||
|
case .forceLogout:
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
+21
-4
@@ -21,6 +21,8 @@ enum AppLockSetupPINScreenViewModelAction {
|
|||||||
case complete
|
case complete
|
||||||
/// The user cancelled PIN entry.
|
/// The user cancelled PIN entry.
|
||||||
case cancel
|
case cancel
|
||||||
|
/// The user failed to remember their PIN to unlock.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppLockSetupPINScreenMode {
|
enum AppLockSetupPINScreenMode {
|
||||||
@@ -33,10 +35,19 @@ enum AppLockSetupPINScreenMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppLockSetupPINScreenViewState: BindableState {
|
struct AppLockSetupPINScreenViewState: BindableState {
|
||||||
|
/// The number of attempts allowed to enter the PIN.
|
||||||
|
let maximumAttempts = 3
|
||||||
|
|
||||||
/// The current mode that the screen is in.
|
/// The current mode that the screen is in.
|
||||||
var mode: AppLockSetupPINScreenMode
|
var mode: AppLockSetupPINScreenMode
|
||||||
/// Whether the screen is mandatory or can be cancelled.
|
/// Whether the screen is mandatory or can be cancelled.
|
||||||
let isMandatory: Bool
|
let isMandatory: Bool
|
||||||
|
/// The number of attempts the user has made in the `confirm` mode.
|
||||||
|
var numberOfConfirmAttempts = 0
|
||||||
|
/// The number of attempts the user has made in the `unlock` mode.
|
||||||
|
var numberOfUnlockAttempts = 0
|
||||||
|
/// The user failed to unlock the app (or forgot their PIN) and the log out is in progress.
|
||||||
|
var isLoggingOut = false
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -46,10 +57,14 @@ struct AppLockSetupPINScreenViewState: BindableState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var subtitle: String? {
|
/// Whether the subtitle is in a warning state or not.
|
||||||
switch mode {
|
var isSubtitleWarning: Bool { mode == .unlock && numberOfUnlockAttempts > 0 }
|
||||||
case .create, .confirm: return L10n.screenAppLockSetupPinContext(InfoPlistReader.main.bundleDisplayName)
|
var subtitle: String {
|
||||||
case .unlock: return nil
|
guard mode == .unlock else { return L10n.screenAppLockSetupPinContext(InfoPlistReader.main.bundleDisplayName) }
|
||||||
|
if !isSubtitleWarning {
|
||||||
|
return L10n.screenAppLockSubtitle(maximumAttempts)
|
||||||
|
} else {
|
||||||
|
return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfUnlockAttempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +83,8 @@ enum AppLockSetupPINScreenAlertType {
|
|||||||
case pinMismatch
|
case pinMismatch
|
||||||
/// An error occurred setting the PIN code in the App Lock service.
|
/// An error occurred setting the PIN code in the App Lock service.
|
||||||
case failedToSetPIN
|
case failedToSetPIN
|
||||||
|
/// The user failed to unlock the app (or forgot their PIN).
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppLockSetupPINScreenViewAction {
|
enum AppLockSetupPINScreenViewAction {
|
||||||
|
|||||||
+27
-3
@@ -32,7 +32,12 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
|
|
||||||
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
|
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
|
||||||
self.appLockService = appLockService
|
self.appLockService = appLockService
|
||||||
|
|
||||||
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
|
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
|
||||||
|
|
||||||
|
appLockService.numberOfPINAttempts
|
||||||
|
.weakAssign(to: \.state.numberOfUnlockAttempts, on: self)
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -74,6 +79,7 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
newPIN = pinCode
|
newPIN = pinCode
|
||||||
state.mode = .confirm
|
state.mode = .confirm
|
||||||
state.bindings.pinCode = ""
|
state.bindings.pinCode = ""
|
||||||
|
state.numberOfConfirmAttempts = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles a PIN input from the confirm mode. Stores the pin if it matches.
|
/// Handles a PIN input from the confirm mode. Stores the pin if it matches.
|
||||||
@@ -102,9 +108,10 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
/// Handles a PIN input for the unlock mode.
|
/// Handles a PIN input for the unlock mode.
|
||||||
private func unlock() {
|
private func unlock() {
|
||||||
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
||||||
// show an error
|
|
||||||
// https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3?node-id=13067:107631&mode=dev#591068578
|
|
||||||
state.bindings.pinCode = ""
|
state.bindings.pinCode = ""
|
||||||
|
if state.numberOfUnlockAttempts >= state.maximumAttempts {
|
||||||
|
handleError(.forceLogout)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +126,29 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
message: L10n.screenAppLockSetupPinBlacklistedDialogContent,
|
message: L10n.screenAppLockSetupPinBlacklistedDialogContent,
|
||||||
primaryButton: .init(title: L10n.actionOk) { self.state.bindings.pinCode = "" })
|
primaryButton: .init(title: L10n.actionOk) { self.state.bindings.pinCode = "" })
|
||||||
case .pinMismatch:
|
case .pinMismatch:
|
||||||
|
state.numberOfConfirmAttempts += 1
|
||||||
state.bindings.alertInfo = .init(id: error,
|
state.bindings.alertInfo = .init(id: error,
|
||||||
title: L10n.screenAppLockSetupPinMismatchDialogTitle,
|
title: L10n.screenAppLockSetupPinMismatchDialogTitle,
|
||||||
message: L10n.screenAppLockSetupPinMismatchDialogContent,
|
message: L10n.screenAppLockSetupPinMismatchDialogContent,
|
||||||
primaryButton: .init(title: L10n.actionTryAgain) { self.state.bindings.pinCode = "" })
|
primaryButton: .init(title: L10n.actionTryAgain) { self.restartCreateIfNeeded() })
|
||||||
case .failedToSetPIN:
|
case .failedToSetPIN:
|
||||||
state.bindings.alertInfo = .init(id: error)
|
state.bindings.alertInfo = .init(id: error)
|
||||||
|
case .forceLogout:
|
||||||
|
state.isLoggingOut = true // Disable the screen before showing the alert.
|
||||||
|
state.bindings.alertInfo = .init(id: error,
|
||||||
|
title: L10n.screenAppLockSignoutAlertTitle,
|
||||||
|
message: L10n.screenAppLockSignoutAlertMessage,
|
||||||
|
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartCreateIfNeeded() {
|
||||||
|
state.bindings.pinCode = ""
|
||||||
|
|
||||||
|
if state.numberOfConfirmAttempts >= state.maximumAttempts {
|
||||||
|
newPIN = ""
|
||||||
|
state.mode = .create
|
||||||
|
state.numberOfConfirmAttempts = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-8
@@ -23,9 +23,21 @@ struct AppLockSetupPINScreen: View {
|
|||||||
|
|
||||||
@FocusState private var textFieldFocus
|
@FocusState private var textFieldFocus
|
||||||
|
|
||||||
|
var stackSpacing: CGFloat {
|
||||||
|
context.viewState.mode == .unlock ? 36 : 40
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitleColor: Color {
|
||||||
|
context.viewState.isSubtitleWarning ? .compound.textCriticalPrimary : .compound.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
var interactiveDismissDisabled: Bool {
|
||||||
|
context.viewState.isMandatory || context.viewState.isLoggingOut
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 48) {
|
VStack(spacing: stackSpacing) {
|
||||||
header
|
header
|
||||||
|
|
||||||
PINTextField(pinCode: $context.pinCode,
|
PINTextField(pinCode: $context.pinCode,
|
||||||
@@ -44,7 +56,8 @@ struct AppLockSetupPINScreen: View {
|
|||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.toolbar(.visible, for: .navigationBar)
|
.toolbar(.visible, for: .navigationBar)
|
||||||
.navigationBarBackButtonHidden()
|
.navigationBarBackButtonHidden()
|
||||||
.interactiveDismissDisabled(context.viewState.isMandatory)
|
.interactiveDismissDisabled(interactiveDismissDisabled)
|
||||||
|
.disabled(context.viewState.isLoggingOut)
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
.onAppear { textFieldFocus = true }
|
.onAppear { textFieldFocus = true }
|
||||||
}
|
}
|
||||||
@@ -60,12 +73,10 @@ struct AppLockSetupPINScreen: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.compound.textPrimary)
|
.foregroundColor(.compound.textPrimary)
|
||||||
|
|
||||||
if let subtitle = context.viewState.subtitle {
|
Text(context.viewState.subtitle)
|
||||||
Text(subtitle)
|
|
||||||
.font(.compound.bodyMD)
|
.font(.compound.bodyMD)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.compound.textSecondary)
|
.foregroundColor(subtitleColor)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +95,9 @@ struct AppLockSetupPINScreen: View {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
|
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
static let service = AppLockService(keychainController: KeychainControllerMock(),
|
static let service = AppLockServiceMock.mock()
|
||||||
appSettings: ServiceLocator.shared.settings)
|
static let failedService = AppLockServiceMock.mock(numberOfPINAttempts: 1)
|
||||||
|
|
||||||
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
|
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
|
||||||
isMandatory: false,
|
isMandatory: false,
|
||||||
appLockService: service)
|
appLockService: service)
|
||||||
@@ -95,6 +107,9 @@ struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
|
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
|
||||||
isMandatory: false,
|
isMandatory: false,
|
||||||
appLockService: service)
|
appLockService: service)
|
||||||
|
static let unlockFailedViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
|
||||||
|
isMandatory: false,
|
||||||
|
appLockService: failedService)
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -111,5 +126,10 @@ struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
AppLockSetupPINScreen(context: unlockViewModel.context)
|
AppLockSetupPINScreen(context: unlockViewModel.context)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Unlock")
|
.previewDisplayName("Unlock")
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
AppLockSetupPINScreen(context: unlockFailedViewModel.context)
|
||||||
|
}
|
||||||
|
.previewDisplayName("Unlock Failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
|||||||
} else {
|
} else {
|
||||||
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||||
}
|
}
|
||||||
|
case .forceLogout:
|
||||||
|
fatalError("The PIN creation flow should not fail.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ enum SettingsScreenCoordinatorAction {
|
|||||||
case logout
|
case logout
|
||||||
case clearCache
|
case clearCache
|
||||||
case secureBackup
|
case secureBackup
|
||||||
|
/// Logout without a confirmation. The user forgot their PIN.
|
||||||
|
case forceLogout
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SettingsScreenCoordinator: CoordinatorProtocol {
|
final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||||
@@ -163,6 +165,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
|||||||
case .complete:
|
case .complete:
|
||||||
// The flow coordinator tidies up the stack, no need to do anything.
|
// The flow coordinator tidies up the stack, no need to do anything.
|
||||||
appLockSetupFlowCoordinator = nil
|
appLockSetupFlowCoordinator = nil
|
||||||
|
case .forceLogout:
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ protocol AppLockServiceProtocol: AnyObject {
|
|||||||
extension AppLockServiceProtocol { }
|
extension AppLockServiceProtocol { }
|
||||||
|
|
||||||
extension AppLockServiceMock {
|
extension AppLockServiceMock {
|
||||||
static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID) -> AppLockServiceMock {
|
static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID, numberOfPINAttempts: Int = 0) -> AppLockServiceMock {
|
||||||
let mock = AppLockServiceMock()
|
let mock = AppLockServiceMock()
|
||||||
mock.isEnabled = pinCode != nil
|
mock.isEnabled = pinCode != nil
|
||||||
mock.isMandatory = isMandatory
|
mock.isMandatory = isMandatory
|
||||||
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
|
mock.numberOfPINAttempts = CurrentValueSubject<Int, Never>(numberOfPINAttempts).eraseToAnyPublisher()
|
||||||
mock.underlyingBiometryType = biometryType
|
mock.underlyingBiometryType = biometryType
|
||||||
mock.underlyingBiometricUnlockEnabled = biometryType != .none
|
mock.underlyingBiometricUnlockEnabled = biometryType != .none
|
||||||
mock.unlockWithClosure = { $0 == pinCode }
|
mock.unlockWithClosure = { $0 == pinCode }
|
||||||
|
|||||||
@@ -37,34 +37,45 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCreatePIN() async throws {
|
func testCreatePIN() async throws {
|
||||||
|
// Given the screen in create mode.
|
||||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
||||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
||||||
|
|
||||||
|
// When entering an new PIN.
|
||||||
let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm }
|
let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm }
|
||||||
context.pinCode = "2023"
|
context.pinCode = "2023"
|
||||||
context.send(viewAction: .submitPINCode)
|
context.send(viewAction: .submitPINCode)
|
||||||
try await createDeferred.fulfill()
|
try await createDeferred.fulfill()
|
||||||
|
|
||||||
|
// Then the screen should transition to the confirm mode.
|
||||||
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
|
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
|
||||||
|
|
||||||
|
// When re-entering that PIN.
|
||||||
let confirmDeferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
let confirmDeferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
||||||
context.pinCode = "2023"
|
context.pinCode = "2023"
|
||||||
context.send(viewAction: .submitPINCode)
|
context.send(viewAction: .submitPINCode)
|
||||||
|
|
||||||
|
// Then the screen should signal it is complete.
|
||||||
try await confirmDeferred.fulfill()
|
try await confirmDeferred.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCreateWeakPIN() async throws {
|
func testCreateWeakPIN() async throws {
|
||||||
|
// Given the screen in create mode.
|
||||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
||||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
||||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
||||||
|
|
||||||
|
// When entering a weak PIN on the blocklist.
|
||||||
context.pinCode = "0000"
|
context.pinCode = "0000"
|
||||||
context.send(viewAction: .submitPINCode)
|
context.send(viewAction: .submitPINCode)
|
||||||
|
|
||||||
|
// Then the PIN should be rejected and the user alerted.
|
||||||
XCTAssertEqual(context.alertInfo?.id, .weakPIN, "The weak PIN should be rejected.")
|
XCTAssertEqual(context.alertInfo?.id, .weakPIN, "The weak PIN should be rejected.")
|
||||||
XCTAssertEqual(context.viewState.mode, .create, "The mode shouldn't transition after an invalid PIN code.")
|
XCTAssertEqual(context.viewState.mode, .create, "The mode shouldn't transition after an invalid PIN code.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCreatePINMismatch() async throws {
|
func testCreatePINMismatch() async throws {
|
||||||
|
// Given the confirm mode after entering a new PIN.
|
||||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
||||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
||||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
||||||
@@ -74,24 +85,76 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
context.send(viewAction: .submitPINCode)
|
context.send(viewAction: .submitPINCode)
|
||||||
try await createDeferred.fulfill()
|
try await createDeferred.fulfill()
|
||||||
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
|
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
|
||||||
|
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 0, "The mode should start with zero attempts.")
|
||||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert after a valid initial PIN.")
|
XCTAssertNil(context.alertInfo, "There shouldn't be an alert after a valid initial PIN.")
|
||||||
|
|
||||||
|
// When entering the new PIN incorrectly
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
context.send(viewAction: .submitPINCode)
|
||||||
|
|
||||||
|
// Then the user should be alerted.
|
||||||
|
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 1, "The mismatch should be counted.")
|
||||||
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
|
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
|
||||||
|
|
||||||
|
// When repeating this twice more.
|
||||||
|
context.pinCode = "2024"
|
||||||
|
context.send(viewAction: .submitPINCode)
|
||||||
|
context.pinCode = "2024"
|
||||||
|
context.send(viewAction: .submitPINCode)
|
||||||
|
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 3, "All the mismatches should be counted.")
|
||||||
|
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
|
||||||
|
|
||||||
|
// Then tapping the alert button should reset back to create mode.
|
||||||
|
context.alertInfo?.primaryButton.action?()
|
||||||
|
XCTAssertEqual(context.viewState.mode, .create, "The mode should revert back to creation.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnlock() async throws {
|
func testUnlock() async throws {
|
||||||
|
// Given the screen in unlock mode.
|
||||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
|
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
|
||||||
let pinCode = "2023"
|
let pinCode = "2023"
|
||||||
keychainController.pinCodeReturnValue = pinCode
|
keychainController.pinCodeReturnValue = pinCode
|
||||||
keychainController.containsPINCodeReturnValue = true
|
keychainController.containsPINCodeReturnValue = true
|
||||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||||
|
|
||||||
|
// When entering the configured PIN.
|
||||||
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
||||||
context.pinCode = pinCode
|
context.pinCode = pinCode
|
||||||
context.send(viewAction: .submitPINCode)
|
context.send(viewAction: .submitPINCode)
|
||||||
|
|
||||||
|
// Then the screen should signal it is complete.
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUnlockFailed() async throws {
|
||||||
|
// Given the screen in unlock mode.
|
||||||
|
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
|
||||||
|
keychainController.pinCodeReturnValue = "2023"
|
||||||
|
keychainController.containsPINCodeReturnValue = true
|
||||||
|
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||||
|
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 0, "The screen should start with zero attempts.")
|
||||||
|
XCTAssertFalse(context.viewState.isSubtitleWarning, "The subtitle should start without a warning.")
|
||||||
|
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not start disabled.")
|
||||||
|
|
||||||
|
// When entering a different PIN.
|
||||||
|
context.pinCode = "2024"
|
||||||
|
context.send(viewAction: .submitPINCode)
|
||||||
|
|
||||||
|
// Then the PIN should be rejected and the user notified.
|
||||||
|
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 1, "An invalid attempt should be counted.")
|
||||||
|
XCTAssertTrue(context.viewState.isSubtitleWarning, "The subtitle should then show a warning.")
|
||||||
|
XCTAssertFalse(context.viewState.isLoggingOut, "The view should still work.")
|
||||||
|
|
||||||
|
// When entering the same incorrect PIN twice more
|
||||||
|
context.pinCode = "2024"
|
||||||
|
context.send(viewAction: .submitPINCode)
|
||||||
|
context.pinCode = "2024"
|
||||||
|
context.send(viewAction: .submitPINCode)
|
||||||
|
|
||||||
|
// Then the user should be alerted that they're being signed out.
|
||||||
|
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 3, "All invalid attempts should be counted.")
|
||||||
|
XCTAssertTrue(context.viewState.isSubtitleWarning, "The subtitle should continue showing a warning.")
|
||||||
|
XCTAssertEqual(context.alertInfo?.id, .forceLogout, "An alert should be shown about a force logout.")
|
||||||
|
XCTAssertTrue(context.viewState.isLoggingOut, "The view should become disabled.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3f1fa2ac91c3c5966db4e7cf34e8f4d724547d583147ac23bd392c536b0357bb
|
oid sha256:d5ed9229cb5607a9cefca50dbee4d3ad91cf2a563c6088d388c7907184454ace
|
||||||
size 105733
|
size 105595
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a3de8520c297d0c53f500a5cbe1322d50ae1f76d7c4678ab7c7e1d243c6a8657
|
oid sha256:09722ed927d4708b3c6053c6e8d528f3484a908ee445569bf3e38c5f1007ff1a
|
||||||
size 106000
|
size 105859
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4c01d9a9aea76ed7793e772441d4556b76092550c40a1d4c0205d4fc5d71577f
|
||||||
|
size 85973
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:641c74d77ed0b43113fda06653a3a7eda92906a81a682cbd6b781aabcce51ab0
|
oid sha256:f2c675912f550b5e05abd322db817dde5a1360eaae0d678b0bc8ac5c93f75a97
|
||||||
size 76727
|
size 84354
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Handle invalid PIN input in the settings flow.
|
||||||
Reference in New Issue
Block a user