mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 20:09:04 +00:00
Debounce PIN entry to show the last digit before submitting. (#2017)
This commit is contained in:
@@ -60,8 +60,6 @@ enum AppLockScreenAlertType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AppLockScreenViewAction {
|
enum AppLockScreenViewAction {
|
||||||
/// Attempt to unlock the app with the supplied PIN code.
|
|
||||||
case submitPINCode
|
|
||||||
/// Clears the PIN code after a failure animation.
|
/// Clears the PIN code after a failure animation.
|
||||||
case clearPINCode
|
case clearPINCode
|
||||||
/// The user didn't heed the warnings and can't remember their PIN.
|
/// The user didn't heed the warnings and can't remember their PIN.
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
|||||||
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
|
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
context.$viewState
|
||||||
|
.map(\.bindings.pinCode)
|
||||||
|
.removeDuplicates()
|
||||||
|
.debounce(for: 0.05, scheduler: DispatchQueue.main) // Allow the last digit to be briefly shown
|
||||||
|
.sink { [weak self] pinCode in
|
||||||
|
guard pinCode.count == 4 else { return }
|
||||||
|
self?.submit(pinCode)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
showForceLogoutAlertIfNeeded()
|
showForceLogoutAlertIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +55,6 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
|||||||
MXLog.info("View model: received view action: \(viewAction)")
|
MXLog.info("View model: received view action: \(viewAction)")
|
||||||
|
|
||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .submitPINCode:
|
|
||||||
submitPINCode()
|
|
||||||
case .clearPINCode:
|
case .clearPINCode:
|
||||||
state.bindings.pinCode = ""
|
state.bindings.pinCode = ""
|
||||||
case .forgotPIN:
|
case .forgotPIN:
|
||||||
@@ -56,8 +64,8 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
|||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func submitPINCode() {
|
private func submit(_ pinCode: String) {
|
||||||
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
guard appLockService.unlock(with: pinCode) else {
|
||||||
handleInvalidPIN()
|
handleInvalidPIN()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ struct AppLockScreen: View {
|
|||||||
.accessibilityValue(L10n.a11yDigitsEntered(context.viewState.numberOfDigitsEntered))
|
.accessibilityValue(L10n.a11yDigitsEntered(context.viewState.numberOfDigitsEntered))
|
||||||
|
|
||||||
AppLockScreenPINKeypad(pinCode: $context.pinCode)
|
AppLockScreenPINKeypad(pinCode: $context.pinCode)
|
||||||
.onChange(of: context.pinCode) { newValue in
|
|
||||||
guard newValue.count == 4 else { return }
|
|
||||||
context.send(viewAction: .submitPINCode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} bottomContent: {
|
} bottomContent: {
|
||||||
Button(L10n.screenAppLockForgotPin) {
|
Button(L10n.screenAppLockForgotPin) {
|
||||||
|
|||||||
-2
@@ -88,8 +88,6 @@ enum AppLockSetupPINScreenAlertType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AppLockSetupPINScreenViewAction {
|
enum AppLockSetupPINScreenViewAction {
|
||||||
/// Confirm the entered PIN.
|
|
||||||
case submitPINCode
|
|
||||||
/// Stop entering a PIN.
|
/// Stop entering a PIN.
|
||||||
case cancel
|
case cancel
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -38,6 +38,16 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
appLockService.numberOfPINAttempts
|
appLockService.numberOfPINAttempts
|
||||||
.weakAssign(to: \.state.numberOfUnlockAttempts, on: self)
|
.weakAssign(to: \.state.numberOfUnlockAttempts, on: self)
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
context.$viewState
|
||||||
|
.map(\.bindings.pinCode)
|
||||||
|
.removeDuplicates()
|
||||||
|
.debounce(for: 0.1, scheduler: DispatchQueue.main) // Show the last digit for long enough to be read.
|
||||||
|
.sink { [weak self] pinCode in
|
||||||
|
guard pinCode.count == 4 else { return }
|
||||||
|
self?.submit(pinCode)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -46,8 +56,6 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
MXLog.info("View model: received view action: \(viewAction)")
|
MXLog.info("View model: received view action: \(viewAction)")
|
||||||
|
|
||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .submitPINCode:
|
|
||||||
submitPINCode()
|
|
||||||
case .cancel:
|
case .cancel:
|
||||||
actionsSubject.send(.cancel)
|
actionsSubject.send(.cancel)
|
||||||
}
|
}
|
||||||
@@ -56,7 +64,7 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
|
|||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
/// Handle the entered PIN code.
|
/// Handle the entered PIN code.
|
||||||
private func submitPINCode() {
|
private func submit(_ pinCode: String) {
|
||||||
switch state.mode {
|
switch state.mode {
|
||||||
case .create:
|
case .create:
|
||||||
createPIN()
|
createPIN()
|
||||||
|
|||||||
-4
@@ -43,10 +43,6 @@ struct AppLockSetupPINScreen: View {
|
|||||||
PINTextField(pinCode: $context.pinCode,
|
PINTextField(pinCode: $context.pinCode,
|
||||||
isSecure: context.viewState.mode == .unlock)
|
isSecure: context.viewState.mode == .unlock)
|
||||||
.focused($textFieldFocus)
|
.focused($textFieldFocus)
|
||||||
.onChange(of: context.pinCode) { newValue in
|
|
||||||
guard newValue.count == 4 else { return }
|
|
||||||
context.send(viewAction: .submitPINCode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
|
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ class AppLockScreenViewModelTests: XCTestCase {
|
|||||||
// When entering it on the lock screen.
|
// When entering it on the lock screen.
|
||||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
|
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
|
||||||
viewModel.context.pinCode = pinCode
|
viewModel.context.pinCode = pinCode
|
||||||
context.send(viewAction: .submitPINCode)
|
|
||||||
let result = try await deferred.fulfill()
|
let result = try await deferred.fulfill()
|
||||||
|
|
||||||
// The app should become unlocked.
|
// The app should become unlocked.
|
||||||
@@ -76,8 +75,10 @@ class AppLockScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
||||||
|
|
||||||
// When entering it on the lock screen.
|
// When entering it on the lock screen.
|
||||||
|
var deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 1 }
|
||||||
viewModel.context.pinCode = pinCode
|
viewModel.context.pinCode = pinCode
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
context.send(viewAction: .clearPINCode) // Simulate the animation completion
|
||||||
|
|
||||||
// Then a failed attempt should be shown.
|
// Then a failed attempt should be shown.
|
||||||
XCTAssertEqual(context.viewState.numberOfPINAttempts, 1, "A failed attempt should have been recorded.")
|
XCTAssertEqual(context.viewState.numberOfPINAttempts, 1, "A failed attempt should have been recorded.")
|
||||||
@@ -85,8 +86,14 @@ class AppLockScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
||||||
|
|
||||||
// When entering twice more
|
// When entering twice more
|
||||||
context.send(viewAction: .submitPINCode)
|
deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 2 }
|
||||||
context.send(viewAction: .submitPINCode)
|
viewModel.context.pinCode = pinCode
|
||||||
|
try await deferred.fulfill()
|
||||||
|
context.send(viewAction: .clearPINCode) // Simulate the animation completion
|
||||||
|
deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 3 }
|
||||||
|
viewModel.context.pinCode = pinCode
|
||||||
|
try await deferred.fulfill()
|
||||||
|
context.send(viewAction: .clearPINCode) // Simulate the animation completion
|
||||||
|
|
||||||
// Then an alert should be shown
|
// Then an alert should be shown
|
||||||
XCTAssertEqual(context.viewState.numberOfPINAttempts, 3, "All the attempts should have been recorded.")
|
XCTAssertEqual(context.viewState.numberOfPINAttempts, 3, "All the attempts should have been recorded.")
|
||||||
@@ -94,14 +101,15 @@ class AppLockScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "An alert should now be shown.")
|
XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "An alert should now be shown.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testForceQuitRequiresLogout() {
|
func testForceQuitRequiresLogout() async throws {
|
||||||
// Given an app with a PIN set where the user attempted to unlock 3 times.
|
// Given an app with a PIN set where the user attempted to unlock 3 times.
|
||||||
keychainController.pinCodeReturnValue = "2023"
|
keychainController.pinCodeReturnValue = "2023"
|
||||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||||
appSettings.appLockNumberOfPINAttempts = 2
|
appSettings.appLockNumberOfPINAttempts = 2
|
||||||
XCTAssertNil(context.alertInfo)
|
XCTAssertNil(context.alertInfo)
|
||||||
|
let deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 3 }
|
||||||
viewModel.context.pinCode = "0000"
|
viewModel.context.pinCode = "0000"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 3, "The app should have 3 failed attempts before the force quit.")
|
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 3, "The app should have 3 failed attempts before the force quit.")
|
||||||
XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "The app should be showing the alert before the force quit.")
|
XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "The app should be showing the alert before the force quit.")
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
// When entering an new PIN.
|
// 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)
|
|
||||||
try await createDeferred.fulfill()
|
try await createDeferred.fulfill()
|
||||||
|
|
||||||
// Then the screen should transition to the confirm mode.
|
// Then the screen should transition to the confirm mode.
|
||||||
@@ -53,7 +52,6 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
// When re-entering that PIN.
|
// 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)
|
|
||||||
|
|
||||||
// Then the screen should signal it is complete.
|
// Then the screen should signal it is complete.
|
||||||
try await confirmDeferred.fulfill()
|
try await confirmDeferred.fulfill()
|
||||||
@@ -66,8 +64,9 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
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.
|
// When entering a weak PIN on the blocklist.
|
||||||
|
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
|
||||||
context.pinCode = "0000"
|
context.pinCode = "0000"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the PIN should be rejected and the user alerted.
|
// 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.")
|
||||||
@@ -82,25 +81,29 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
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)
|
|
||||||
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.")
|
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
|
// When entering the new PIN incorrectly
|
||||||
|
var deferred = deferFulfillment(context.$viewState) { $0.numberOfConfirmAttempts == 1 }
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the user should be alerted.
|
// Then the user should be alerted.
|
||||||
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 1, "The mismatch should be counted.")
|
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.
|
// When dismissing the alert and repeating twice more.
|
||||||
|
context.alertInfo?.primaryButton.action?()
|
||||||
|
deferred = deferFulfillment(context.$viewState) { $0.numberOfConfirmAttempts == 2 }
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
context.alertInfo?.primaryButton.action?()
|
||||||
|
deferred = deferFulfillment(context.$viewState) { $0.numberOfConfirmAttempts == 3 }
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 3, "All the mismatches should be counted.")
|
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 3, "All the mismatches 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.")
|
||||||
|
|
||||||
@@ -120,7 +123,6 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
// When entering the configured PIN.
|
// 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)
|
|
||||||
|
|
||||||
// Then the screen should signal it is complete.
|
// Then the screen should signal it is complete.
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
@@ -137,8 +139,10 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not start disabled.")
|
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not start disabled.")
|
||||||
|
|
||||||
// When entering a different PIN.
|
// When entering a different PIN.
|
||||||
|
var deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""],
|
||||||
|
message: "The PIN should be entered and then cleared by the view model.")
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the PIN should be rejected and the user notified.
|
// Then the PIN should be rejected and the user notified.
|
||||||
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 1, "An invalid attempt should be counted.")
|
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 1, "An invalid attempt should be counted.")
|
||||||
@@ -146,10 +150,14 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(context.viewState.isLoggingOut, "The view should still work.")
|
XCTAssertFalse(context.viewState.isLoggingOut, "The view should still work.")
|
||||||
|
|
||||||
// When entering the same incorrect PIN twice more
|
// When entering the same incorrect PIN twice more
|
||||||
|
deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""],
|
||||||
|
message: "The PIN should be entered and then cleared by the view model.")
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""],
|
||||||
|
message: "The PIN should be entered and then cleared by the view model.")
|
||||||
context.pinCode = "2024"
|
context.pinCode = "2024"
|
||||||
context.send(viewAction: .submitPINCode)
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the user should be alerted that they're being signed out.
|
// Then the user should be alerted that they're being signed out.
|
||||||
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 3, "All invalid attempts should be counted.")
|
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 3, "All invalid attempts should be counted.")
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Make sure the last digit is visible when entering a PIN.
|
||||||
Reference in New Issue
Block a user