Debounce PIN entry to show the last digit before submitting. (#2017)

This commit is contained in:
Doug
2023-11-03 07:31:01 +00:00
committed by GitHub
parent 5ae6494004
commit b10d534c94
9 changed files with 58 additions and 37 deletions
@@ -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) {
@@ -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
} }
@@ -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()
@@ -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.")
+1
View File
@@ -0,0 +1 @@
Make sure the last digit is visible when entering a PIN.