mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Handle invalid PIN input in the settings flow. (#1972)
This commit is contained in:
+6
-2
@@ -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
-4
@@ -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 {
|
||||
|
||||
+27
-3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-10
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user