Handle invalid PIN input in the settings flow. (#1972)

This commit is contained in:
Doug
2023-10-27 14:08:06 +01:00
committed by GitHub
parent acfc564490
commit 84a16091b2
19 changed files with 209 additions and 39 deletions
@@ -447,6 +447,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
case .clearCache:
stateMachine.processEvent(.clearCache)
case .forceLogout:
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
}
}
.store(in: &cancellables)
@@ -19,7 +19,10 @@ import SwiftState
import SwiftUI
enum AppLockSetupFlowCoordinatorAction: Equatable {
/// The flow is 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.
@@ -49,8 +52,10 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
case biometricsPrompt
/// The settings screen.
case settings
/// The flow is finished.
/// The flow is finished. This is a final state.
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
@@ -63,8 +68,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
case biometricsSet
/// The user wants to change their PIN.
case changePIN
/// The user wants to dismiss the flow.
case dismiss
/// The user has disabled the app lock feature.
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>
@@ -108,17 +117,23 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
return appLockService.isEnabled ? .unlock : .createPIN
case (.pinEntered, .unlock):
return .settings
case (.cancel, .unlock):
return .complete
case (.forceLogout, .unlock):
return .loggingOut
case (.pinEntered, .createPIN):
if presentingFlow == .authentication {
return appLockService.biometryType != .none ? .biometricsPrompt : .complete
} else {
return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt
}
case (.cancel, .createPIN):
return .complete
case (.biometricsSet, .biometricsPrompt):
return presentingFlow == .settings ? .settings : .complete
case (.changePIN, .settings):
return .createPIN
case (.dismiss, _):
case (.appLockDisabled, .settings):
return .complete
default:
return nil
@@ -149,6 +164,8 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
showCreatePIN()
case (_, .complete):
complete(from: context.fromState)
case (.unlock, .loggingOut):
actionsSubject.send(.forceLogout)
default:
fatalError("Unhandled transition.")
}
@@ -170,10 +187,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
stateMachine.tryEvent(.dismiss)
case .complete:
stateMachine.tryEvent(.pinEntered)
case .cancel:
stateMachine.tryEvent(.cancel)
case .forceLogout:
fatalError("Creating a PIN can't force a logout.")
}
}
.store(in: &cancellables)
@@ -211,10 +230,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
stateMachine.tryEvent(.dismiss)
case .complete:
stateMachine.tryEvent(.pinEntered)
case .cancel:
stateMachine.tryEvent(.cancel)
case .forceLogout:
stateMachine.tryEvent(.forceLogout)
}
}
.store(in: &cancellables)
@@ -231,7 +252,7 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
case .changePINCode:
stateMachine.tryEvent(.changePIN)
case .appLockDisabled:
stateMachine.tryEvent(.dismiss)
stateMachine.tryEvent(.appLockDisabled)
}
}
.store(in: &cancellables)
@@ -245,7 +266,7 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
/// Tear down the flow for completion.
private func complete(from state: State) {
switch state {
case .initial, .complete: fatalError()
case .initial, .complete, .loggingOut: fatalError()
case .unlock:
navigationStackCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.complete)
@@ -22,6 +22,8 @@ enum SettingsFlowCoordinatorAction {
case dismissedSettings
case runLogoutFlow
case clearCache
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
}
struct SettingsFlowCoordinatorParameters {
@@ -107,6 +109,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.clearCache)
case .secureBackup:
presentSecureBackupScreen(animated: true)
case .forceLogout:
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -20,6 +20,8 @@ import SwiftUI
enum UserSessionFlowCoordinatorAction {
case logout
case clearCache
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
}
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
@@ -116,6 +118,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
runLogoutFlow()
case .clearCache:
actionsSubject.send(.clearCache)
case .forceLogout:
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -24,7 +24,8 @@ enum AppLockScreenViewModelAction {
}
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.
var numberOfPINAttempts = 0
@@ -78,7 +78,7 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
}
private func showForceLogoutAlertIfNeeded() {
if state.numberOfPINAttempts >= 3 {
if state.numberOfPINAttempts >= state.maximumAttempts {
state.bindings.alertInfo = .init(id: .forcedLogout,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
@@ -27,10 +27,12 @@ struct AppLockSetupPINScreenCoordinatorParameters {
}
enum AppLockSetupPINScreenCoordinatorAction {
/// The user cancelled PIN entry.
case cancel
/// The user succeeded PIN entry.
case complete
/// The user cancelled PIN entry.
case cancel
/// The user failed to remember their PIN to unlock.
case forceLogout
}
final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
@@ -62,6 +64,8 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.complete)
case .cancel:
actionsSubject.send(.cancel)
case .forceLogout:
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -21,6 +21,8 @@ enum AppLockSetupPINScreenViewModelAction {
case complete
/// The user cancelled PIN entry.
case cancel
/// The user failed to remember their PIN to unlock.
case forceLogout
}
enum AppLockSetupPINScreenMode {
@@ -33,10 +35,19 @@ enum AppLockSetupPINScreenMode {
}
struct AppLockSetupPINScreenViewState: BindableState {
/// The number of attempts allowed to enter the PIN.
let maximumAttempts = 3
/// The current mode that the screen is in.
var mode: AppLockSetupPINScreenMode
/// Whether the screen is mandatory or can be cancelled.
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 {
switch mode {
@@ -46,10 +57,14 @@ struct AppLockSetupPINScreenViewState: BindableState {
}
}
var subtitle: String? {
switch mode {
case .create, .confirm: return L10n.screenAppLockSetupPinContext(InfoPlistReader.main.bundleDisplayName)
case .unlock: return nil
/// Whether the subtitle is in a warning state or not.
var isSubtitleWarning: Bool { mode == .unlock && numberOfUnlockAttempts > 0 }
var subtitle: String {
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
/// An error occurred setting the PIN code in the App Lock service.
case failedToSetPIN
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
}
enum AppLockSetupPINScreenViewAction {
@@ -32,7 +32,12 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
appLockService.numberOfPINAttempts
.weakAssign(to: \.state.numberOfUnlockAttempts, on: self)
.store(in: &cancellables)
}
// MARK: - Public
@@ -74,6 +79,7 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
newPIN = pinCode
state.mode = .confirm
state.bindings.pinCode = ""
state.numberOfConfirmAttempts = 0
}
/// 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.
private func unlock() {
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 = ""
if state.numberOfUnlockAttempts >= state.maximumAttempts {
handleError(.forceLogout)
}
return
}
@@ -119,12 +126,29 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
message: L10n.screenAppLockSetupPinBlacklistedDialogContent,
primaryButton: .init(title: L10n.actionOk) { self.state.bindings.pinCode = "" })
case .pinMismatch:
state.numberOfConfirmAttempts += 1
state.bindings.alertInfo = .init(id: error,
title: L10n.screenAppLockSetupPinMismatchDialogTitle,
message: L10n.screenAppLockSetupPinMismatchDialogContent,
primaryButton: .init(title: L10n.actionTryAgain) { self.state.bindings.pinCode = "" })
primaryButton: .init(title: L10n.actionTryAgain) { self.restartCreateIfNeeded() })
case .failedToSetPIN:
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
}
}
}
@@ -23,9 +23,21 @@ struct AppLockSetupPINScreen: View {
@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 {
ScrollView {
VStack(spacing: 48) {
VStack(spacing: stackSpacing) {
header
PINTextField(pinCode: $context.pinCode,
@@ -44,7 +56,8 @@ struct AppLockSetupPINScreen: View {
.toolbar { toolbar }
.toolbar(.visible, for: .navigationBar)
.navigationBarBackButtonHidden()
.interactiveDismissDisabled(context.viewState.isMandatory)
.interactiveDismissDisabled(interactiveDismissDisabled)
.disabled(context.viewState.isLoggingOut)
.alert(item: $context.alertInfo)
.onAppear { textFieldFocus = true }
}
@@ -60,12 +73,10 @@ struct AppLockSetupPINScreen: View {
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
if let subtitle = context.viewState.subtitle {
Text(subtitle)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
}
Text(context.viewState.subtitle)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(subtitleColor)
}
}
@@ -84,8 +95,9 @@ struct AppLockSetupPINScreen: View {
// MARK: - Previews
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
static let service = AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings)
static let service = AppLockServiceMock.mock()
static let failedService = AppLockServiceMock.mock(numberOfPINAttempts: 1)
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
isMandatory: false,
appLockService: service)
@@ -95,6 +107,9 @@ struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
isMandatory: false,
appLockService: service)
static let unlockFailedViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
isMandatory: false,
appLockService: failedService)
static var previews: some View {
NavigationStack {
@@ -111,5 +126,10 @@ struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
AppLockSetupPINScreen(context: unlockViewModel.context)
}
.previewDisplayName("Unlock")
NavigationStack {
AppLockSetupPINScreen(context: unlockFailedViewModel.context)
}
.previewDisplayName("Unlock Failed")
}
}
@@ -264,6 +264,8 @@ class AuthenticationCoordinator: CoordinatorProtocol {
} else {
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
case .forceLogout:
fatalError("The PIN creation flow should not fail.")
}
}
.store(in: &cancellables)
@@ -33,6 +33,8 @@ enum SettingsScreenCoordinatorAction {
case logout
case clearCache
case secureBackup
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
}
final class SettingsScreenCoordinator: CoordinatorProtocol {
@@ -163,6 +165,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
case .complete:
// The flow coordinator tidies up the stack, no need to do anything.
appLockSetupFlowCoordinator = nil
case .forceLogout:
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -79,11 +79,11 @@ protocol AppLockServiceProtocol: AnyObject {
extension AppLockServiceProtocol { }
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()
mock.isEnabled = pinCode != nil
mock.isMandatory = isMandatory
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
mock.numberOfPINAttempts = CurrentValueSubject<Int, Never>(numberOfPINAttempts).eraseToAnyPublisher()
mock.underlyingBiometryType = biometryType
mock.underlyingBiometricUnlockEnabled = biometryType != .none
mock.unlockWithClosure = { $0 == pinCode }