diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 9ae092247..b76fa3a32 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -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) diff --git a/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift index 177d6e95b..e19e1e524 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift @@ -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 @@ -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) diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index abf7de30e..67c9ebc4d 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index e7af9b0a3..de760ffff 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift index f73069e09..6953ac0de 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift @@ -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 diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift index a568d68c7..af9ac99df 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift @@ -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, diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift index 0a500f3ed..12bd49931 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift index c41e75ae5..08ae27ad6 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift @@ -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 { diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift index 226724e03..d26ffff48 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift @@ -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 } } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift index 7d97f2478..83b21e400 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift @@ -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") } } diff --git a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift index ab100a3a0..af5736ec6 100644 --- a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index 0f25a70c7..b928e3663 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift index 8026e20b0..3ba26eb13 100644 --- a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift +++ b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift @@ -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().eraseToAnyPublisher() + mock.numberOfPINAttempts = CurrentValueSubject(numberOfPINAttempts).eraseToAnyPublisher() mock.underlyingBiometryType = biometryType mock.underlyingBiometricUnlockEnabled = biometryType != .none mock.unlockWithClosure = { $0 == pinCode } diff --git a/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift index 1be4058ed..aa77904d2 100644 --- a/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift @@ -37,34 +37,45 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase { } func testCreatePIN() async throws { + // Given the screen in create mode. viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) 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 } context.pinCode = "2023" context.send(viewAction: .submitPINCode) try await createDeferred.fulfill() + + // Then the screen should transition to the confirm mode. 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 } context.pinCode = "2023" context.send(viewAction: .submitPINCode) + + // Then the screen should signal it is complete. try await confirmDeferred.fulfill() } func testCreateWeakPIN() async throws { + // Given the screen in create mode. viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.") + // When entering a weak PIN on the blocklist. context.pinCode = "0000" 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.viewState.mode, .create, "The mode shouldn't transition after an invalid PIN code.") } func testCreatePINMismatch() async throws { + // Given the confirm mode after entering a new PIN. viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.") @@ -74,24 +85,76 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase { context.send(viewAction: .submitPINCode) try await createDeferred.fulfill() 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.") + // When entering the new PIN incorrectly context.pinCode = "2024" 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.") + + // 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 { + // Given the screen in unlock mode. viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService) let pinCode = "2023" keychainController.pinCodeReturnValue = pinCode keychainController.containsPINCodeReturnValue = true keychainController.containsPINCodeBiometricStateReturnValue = false + // When entering the configured PIN. let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete } context.pinCode = pinCode context.send(viewAction: .submitPINCode) + + // Then the screen should signal it is complete. 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.") + } } diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png index d4d5a8ab8..9cdbc795a 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f1fa2ac91c3c5966db4e7cf34e8f4d724547d583147ac23bd392c536b0357bb -size 105733 +oid sha256:d5ed9229cb5607a9cefca50dbee4d3ad91cf2a563c6088d388c7907184454ace +size 105595 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png index 6f11f2d57..a0d88c578 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3de8520c297d0c53f500a5cbe1322d50ae1f76d7c4678ab7c7e1d243c6a8657 -size 106000 +oid sha256:09722ed927d4708b3c6053c6e8d528f3484a908ee445569bf3e38c5f1007ff1a +size 105859 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock-Failed.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock-Failed.png new file mode 100644 index 000000000..9a1d937f3 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock-Failed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c01d9a9aea76ed7793e772441d4556b76092550c40a1d4c0205d4fc5d71577f +size 85973 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png index ed3f0b3f1..0ef6ee1a9 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:641c74d77ed0b43113fda06653a3a7eda92906a81a682cbd6b781aabcce51ab0 -size 76727 +oid sha256:f2c675912f550b5e05abd322db817dde5a1360eaae0d678b0bc8ac5c93f75a97 +size 84354 diff --git a/changelog.d/pr-1972.wip b/changelog.d/pr-1972.wip new file mode 100644 index 000000000..ff7932cb0 --- /dev/null +++ b/changelog.d/pr-1972.wip @@ -0,0 +1 @@ +Handle invalid PIN input in the settings flow. \ No newline at end of file