diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 801c6fb61d..26974a2321 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -1147,6 +1147,7 @@ BD509A68B1A0B6E4599F9C7A /* DWProfileUpdateCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82926C69F3BB675BEF1A1FFD /* DWProfileUpdateCoordinator.swift */; }; C05FA6CE3DDE2D0327D334E5 /* CoinJoinRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B478AD92C1F1C11D09645DF4 /* CoinJoinRecovery.swift */; }; C0DE5A0126C7000000000001 /* SwiftDashSDKCoreLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5A0126C7000000000002 /* SwiftDashSDKCoreLifecycleTests.swift */; }; + C0DE5A1126C7000000000011 /* UIViewControllerUserActionGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5A1026C7000000000012 /* UIViewControllerUserActionGateTests.swift */; }; C124DF94278D4238FAE0975D /* OrderPreviewFeeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */; }; C1A0B2C3D4E5F60718293A02 /* ShortcutsBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A0B2C3D4E5F60718293A01 /* ShortcutsBarView.swift */; }; C1A0B2C3D4E5F60718293A03 /* ShortcutsBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A0B2C3D4E5F60718293A01 /* ShortcutsBarView.swift */; }; @@ -3142,6 +3143,7 @@ BCD0199D74601A6150ABC8D1 /* WalletAccountDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletAccountDetailViewModel.swift; sourceTree = ""; }; C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrderPreviewFeeRow.swift; sourceTree = ""; }; C0DE5A0126C7000000000002 /* SwiftDashSDKCoreLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKCoreLifecycleTests.swift; sourceTree = ""; }; + C0DE5A1026C7000000000012 /* UIViewControllerUserActionGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerUserActionGateTests.swift; sourceTree = ""; }; C1A0B2C3D4E5F60718293A01 /* ShortcutsBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsBarView.swift; sourceTree = ""; }; C1D2E3F4A5B6C7D8E9F01234 /* TransferAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferAmountView.swift; sourceTree = ""; }; C3DAD266246AA6F10001624F /* DWScreenshotWarningViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWScreenshotWarningViewController.h; sourceTree = ""; }; @@ -7070,6 +7072,7 @@ A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */, B7C072AA26C7000000000002 /* DWContestedNameStatusServiceTests.swift */, C0DE5A0126C7000000000002 /* SwiftDashSDKCoreLifecycleTests.swift */, + C0DE5A1026C7000000000012 /* UIViewControllerUserActionGateTests.swift */, CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */, CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */, 7A30000130A1000000000001 /* TransactionDirectionTests.swift */, @@ -10248,6 +10251,7 @@ A17EED0126C7000000000001 /* WalletWipeSerialExecutorTests.swift in Sources */, B7C072AA26C7000000000001 /* DWContestedNameStatusServiceTests.swift in Sources */, C0DE5A0126C7000000000001 /* SwiftDashSDKCoreLifecycleTests.swift in Sources */, + C0DE5A1126C7000000000011 /* UIViewControllerUserActionGateTests.swift in Sources */, CB9000022FE1000000000002 /* CoinbaseTransactionMetadataTests.swift in Sources */, CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */, 7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */, diff --git a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift index 44f272a2c6..a0c407f650 100644 --- a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift +++ b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift @@ -17,10 +17,58 @@ import UIKit import MessageUI +import ObjectiveC import SwiftDashSDK -@objc +private final class UserActionGate { + private var isInProgress = false + + func begin() -> Bool { + guard !isInProgress else { return false } + + isInProgress = true + return true + } + + func end() { + isInProgress = false + } +} + +private var userActionGateKey: UInt8 = 0 + extension UIViewController { + /// Starts a UI action only when this controller does not already have one + /// in flight. Call this synchronously, before navigation, presentation, or + /// an asynchronous operation can yield back to the main run loop. + /// + /// End the action when a recoverable failure/cancel occurs or when the + /// source controller becomes visible again. Successful one-way transitions + /// intentionally leave it acquired so queued taps cannot replay afterward. + @objc(dw_beginExclusiveUserAction) + func dw_beginExclusiveUserAction() -> Bool { + userActionGate.begin() + } + + @objc(dw_endExclusiveUserAction) + func dw_endExclusiveUserAction() { + userActionGate.end() + } + + private var userActionGate: UserActionGate { + if let gate = objc_getAssociatedObject(self, &userActionGateKey) as? UserActionGate { + return gate + } + + let gate = UserActionGate() + objc_setAssociatedObject( + self, + &userActionGateKey, + gate, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return gate + } + @objc func topController() -> UIViewController { if let vc = self as? UITabBarController { diff --git a/DashWallet/Sources/UI/LockScreen/DWLockScreenViewController.m b/DashWallet/Sources/UI/LockScreen/DWLockScreenViewController.m index 68da80fd01..7d786b970b 100644 --- a/DashWallet/Sources/UI/LockScreen/DWLockScreenViewController.m +++ b/DashWallet/Sources/UI/LockScreen/DWLockScreenViewController.m @@ -20,6 +20,7 @@ #import "DWLockActionButton.h" #import "DWLockPinInputView.h" #import "DWLockScreenModel.h" +#import "DWQRScanModel.h" #import "DWRecoverViewController.h" #import "DWSetPinViewController.h" #import "DWUIKit.h" @@ -70,7 +71,8 @@ static CGFloat ActionButtonsHeight(void) { @interface DWLockScreenViewController () + DWSetPinViewControllerDelegate, + UIAdaptivePresentationControllerDelegate> @property (strong, nonatomic) DWLockScreenModel *model; @@ -87,6 +89,9 @@ @interface DWLockScreenViewController () PaymentsViewController { sb("Payments").vc(PaymentsViewController.self) } diff --git a/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m b/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m index 6382616970..7b4095f68e 100644 --- a/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m +++ b/DashWallet/Sources/UI/RootNavigation/DWAppRootViewController.m @@ -51,6 +51,7 @@ @interface DWAppRootViewController () @property (nonatomic, assign) BOOL launchingWasDeferred; +@property (nonatomic, assign) BOOL onboardingCompletionInProgress; @property (nullable, nonatomic, strong) DWAppRootViewController *rootController; #if DASHPAY @@ -92,6 +93,11 @@ - (void)handleURL:(NSURL *)url { #pragma mark - DWOnboardingViewControllerDelegate - (void)onboardingViewControllerDidFinish:(DWOnboardingViewController *)controller { + if (self.onboardingCompletionInProgress) { + return; + } + self.onboardingCompletionInProgress = YES; + [self onboardingDidFinish]; // Reinstall detection: if SDK wallet keychain material survived while diff --git a/DashWallet/Sources/UI/Setup/BiometricAuth/DWBiometricAuthViewController.m b/DashWallet/Sources/UI/Setup/BiometricAuth/DWBiometricAuthViewController.m index 279d91efd4..4957b5f9ad 100644 --- a/DashWallet/Sources/UI/Setup/BiometricAuth/DWBiometricAuthViewController.m +++ b/DashWallet/Sources/UI/Setup/BiometricAuth/DWBiometricAuthViewController.m @@ -19,6 +19,7 @@ #import "DWBiometricAuthModel.h" #import "DWUIKit.h" +#import "dashwallet-Swift.h" NS_ASSUME_NONNULL_BEGIN @@ -63,6 +64,9 @@ - (BOOL)requiresNoNavigationBar { #pragma mark - Actions - (IBAction)enableBiometricButtonAction:(id)sender { + if (![self dw_beginExclusiveUserAction]) { + return; + } self.view.userInteractionEnabled = NO; __weak typeof(self) weakSelf = self; @@ -82,6 +86,11 @@ - (IBAction)enableBiometricButtonAction:(id)sender { } - (IBAction)skipBiometricButtonAction:(id)sender { + if (![self dw_beginExclusiveUserAction]) { + return; + } + self.view.userInteractionEnabled = NO; + [self.model disableBiometricAuth]; [self.delegate biometricAuthViewControllerDidFinish:self]; } diff --git a/DashWallet/Sources/UI/Setup/DWSetupViewController.m b/DashWallet/Sources/UI/Setup/DWSetupViewController.m index 96206002a8..aaa5de12de 100644 --- a/DashWallet/Sources/UI/Setup/DWSetupViewController.m +++ b/DashWallet/Sources/UI/Setup/DWSetupViewController.m @@ -75,6 +75,10 @@ - (void)viewDidLoad { - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; + [self dw_endExclusiveUserAction]; + self.createWalletButton.enabled = YES; + self.recoverWalletButton.enabled = YES; + if (!self.initialAnimationCompleted) { self.initialAnimationCompleted = YES; @@ -94,6 +98,12 @@ - (UIStatusBarStyle)preferredStatusBarStyle { #pragma mark - Actions - (IBAction)createWalletButtonAction:(id)sender { + if (![self dw_beginExclusiveUserAction]) { + return; + } + self.createWalletButton.enabled = NO; + self.recoverWalletButton.enabled = NO; + self.recoverWalletCommand = nil; [DWGlobalOptions sharedInstance].walletNeedsBackup = YES; @@ -104,6 +114,12 @@ - (IBAction)createWalletButtonAction:(id)sender { } - (IBAction)recoverWalletButtonAction:(id)sender { + if (![self dw_beginExclusiveUserAction]) { + return; + } + self.createWalletButton.enabled = NO; + self.recoverWalletButton.enabled = NO; + self.recoverWalletCommand = nil; DWRecoverViewController *controller = [[DWRecoverViewController alloc] init]; diff --git a/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverViewController.m b/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverViewController.m index 943dc79525..cff91ec747 100644 --- a/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverViewController.m +++ b/DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverViewController.m @@ -95,11 +95,13 @@ - (void)recoverContentView:(DWRecoverContentView *)view offerToReplaceIncorrectW UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction *_Nonnull action) { + [self.contentView endRecoveryAttempt]; [self.contentView activateTextView]; }]; UIAlertAction *recoverAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Recover", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction *_Nonnull action) { + [self.contentView endRecoveryAttempt]; DWPhraseRepairViewController *controller = [[DWPhraseRepairViewController alloc] initWithPhrase:phrase incorrectWord:incorrectWord]; @@ -109,7 +111,6 @@ - (void)recoverContentView:(DWRecoverContentView *)view offerToReplaceIncorrectW [alert addAction:cancelAction]; [alert addAction:recoverAction]; [self presentViewController:alert animated:YES completion:nil]; - [self showAlertWithTitle:nil message:message]; } - (void)recoverContentView:(DWRecoverContentView *)view usedWordsHaveInvalidCount:(NSArray *)words { @@ -127,11 +128,13 @@ - (void)recoverContentView:(DWRecoverContentView *)view usedWordsHaveInvalidCoun UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction *_Nonnull action) { + [self.contentView endRecoveryAttempt]; [self.contentView activateTextView]; }]; UIAlertAction *recoverAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Recover", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction *_Nonnull action) { + [self.contentView endRecoveryAttempt]; DWPhraseRepairViewController *controller = [[DWPhraseRepairViewController alloc] initWithPhrase:[words componentsJoinedByString:@" "] incorrectWord:nil]; @@ -141,7 +144,6 @@ - (void)recoverContentView:(DWRecoverContentView *)view usedWordsHaveInvalidCoun [alert addAction:cancelAction]; [alert addAction:recoverAction]; [self presentViewController:alert animated:YES completion:nil]; - [self showAlertWithTitle:nil message:message]; } else { NSString *message = NSLocalizedString(@"Recovery phrase must have 12, 15, 18, 21 or 24 words", nil); @@ -173,6 +175,7 @@ - (void)recoverContentViewPerformWipe:(DWRecoverContentView *)view { UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + [self.contentView endRecoveryAttempt]; [self.contentView activateTextView]; }]; [alert addAction:cancelAction]; @@ -308,6 +311,7 @@ - (void)showAlertWithTitle:(nullable NSString *)title message:(nullable NSString UIAlertAction *okAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction *_Nonnull action) { + [self.contentView endRecoveryAttempt]; [self.contentView activateTextView]; }]; [alert addAction:okAction]; diff --git a/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.h b/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.h index 2ad8596974..4be732955b 100644 --- a/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.h +++ b/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.h @@ -48,6 +48,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)activateTextView; - (void)continueAction; +- (void)endRecoveryAttempt; - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; diff --git a/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m b/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m index c5fd721eee..2be7b7aa7f 100644 --- a/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m +++ b/DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m @@ -34,6 +34,8 @@ @interface DWRecoverContentView () @property (readonly, nonatomic, strong) NSLayoutConstraint *topConstraint; @property (readonly, nonatomic, strong) NSLayoutConstraint *textViewMinHeightConstraint; +@property (nonatomic, assign) BOOL recoveryAttemptInProgress; + @end @implementation DWRecoverContentView @@ -133,6 +135,10 @@ - (void)continueAction { [self recoverWalletWithCurrentSeedPhrase]; } +- (void)endRecoveryAttempt { + self.recoveryAttemptInProgress = NO; +} + - (void)appendText:(NSString *)text { self.textView.text = [self.textView.text stringByAppendingFormat:@" %@", text]; } @@ -166,9 +172,10 @@ - (BOOL)textView:(UITextView *)textView - (void)recoverWalletWithCurrentSeedPhrase { NSUInteger count = [self.textView.text wordsCount]; - if (count < 10) { + if (count < 10 || self.recoveryAttemptInProgress) { return; } + self.recoveryAttemptInProgress = YES; @autoreleasepool { // @autoreleasepool ensures sensitive data will be deallocated immediately UITextView *textView = self.textView; diff --git a/DashWallet/Sources/UI/Setup/SecureWallet/BackupInfo/BackupInfoViewController.swift b/DashWallet/Sources/UI/Setup/SecureWallet/BackupInfo/BackupInfoViewController.swift index 9d284b5d73..0d7abf68e7 100644 --- a/DashWallet/Sources/UI/Setup/SecureWallet/BackupInfo/BackupInfoViewController.swift +++ b/DashWallet/Sources/UI/Setup/SecureWallet/BackupInfo/BackupInfoViewController.swift @@ -113,16 +113,23 @@ final class BackupInfoViewController: BaseViewController { @objc private func closeAction() { + guard dw_beginExclusiveUserAction() else { return } + setActionsEnabled(false) delegate?.secureWalletRoutineDidCancel(self) } @IBAction func skipButtonAction() { + guard dw_beginExclusiveUserAction() else { return } + setActionsEnabled(false) delegate?.secureWalletRoutineDidCancel(self) } @IBAction func backupButtonAction() { + guard dw_beginExclusiveUserAction() else { return } + setActionsEnabled(false) + let authManager = AuthenticationService.shared if type == .setup && authManager.didAuthenticate { @@ -132,6 +139,7 @@ final class BackupInfoViewController: BaseViewController { usingBiometricAuthentication: false, alertIfLockout: true) { [weak self] authenticated, _, _ in guard authenticated else { + self?.endUserAction() return } @@ -162,6 +170,12 @@ final class BackupInfoViewController: BaseViewController { configureHierarchy() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + endUserAction() + } + @objc static func controller(with type: SecureWalletInfoType) -> BackupInfoViewController { let controller = vc(BackupInfoViewController.self, from: sb("BackupInfo")) @@ -204,6 +218,16 @@ extension BackupInfoViewController { navigationController?.pushViewController(controller, animated: true) } + private func endUserAction() { + dw_endExclusiveUserAction() + setActionsEnabled(true) + } + + private func setActionsEnabled(_ enabled: Bool) { + bottomButtonStack.isUserInteractionEnabled = enabled + navigationItem.rightBarButtonItem?.isEnabled = enabled + } + private func reloadCloseButton() { if isCloseButtonHidden { hideCloseButton() diff --git a/DashWallet/Sources/UI/Setup/SecureWallet/Seed/BackupSeedPhraseViewController.swift b/DashWallet/Sources/UI/Setup/SecureWallet/Seed/BackupSeedPhraseViewController.swift index c140646839..c963eaa019 100644 --- a/DashWallet/Sources/UI/Setup/SecureWallet/Seed/BackupSeedPhraseViewController.swift +++ b/DashWallet/Sources/UI/Setup/SecureWallet/Seed/BackupSeedPhraseViewController.swift @@ -22,6 +22,7 @@ import UIKit class BackupSeedPhraseViewController: DWPreviewSeedPhraseViewController { var shouldCreateNewWalletOnScreenshot: Bool = false + private var hasAppeared = false override func viewDidLoad() { super.viewDidLoad() @@ -40,7 +41,20 @@ class BackupSeedPhraseViewController: DWPreviewSeedPhraseViewController { return NSLocalizedString("Continue", comment: "") } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + dw_endExclusiveUserAction() + if hasAppeared { + actionButton?.isEnabled = true + } + hasAppeared = true + } + @objc override func actionButtonAction(_ sender: Any) { + guard dw_beginExclusiveUserAction() else { return } + actionButton?.isEnabled = false + let seedPhrase = self.contentView.model let controller = DWVerifySeedPhraseViewController(seedPhrase: seedPhrase!) diff --git a/DashWallet/Sources/UI/Setup/SecureWallet/VerifiedSuccessfully/VerifiedSuccessfullyViewController.swift b/DashWallet/Sources/UI/Setup/SecureWallet/VerifiedSuccessfully/VerifiedSuccessfullyViewController.swift index 51c7ce2bb7..78a1dbac66 100644 --- a/DashWallet/Sources/UI/Setup/SecureWallet/VerifiedSuccessfully/VerifiedSuccessfullyViewController.swift +++ b/DashWallet/Sources/UI/Setup/SecureWallet/VerifiedSuccessfully/VerifiedSuccessfullyViewController.swift @@ -49,6 +49,8 @@ final class VerifiedSuccessfullyViewController : UIViewController, NavigationFul @IBAction func continueButtonAction() { + guard dw_beginExclusiveUserAction() else { return } + continueButton.isEnabled = false delegate?.secureWalletRoutineDidFinish(self) } } diff --git a/DashWallet/Sources/UI/Setup/SecureWallet/Verify/Views/DWVerifySeedPhraseContentView.m b/DashWallet/Sources/UI/Setup/SecureWallet/Verify/Views/DWVerifySeedPhraseContentView.m index 1e110f8549..fffd08a7a8 100644 --- a/DashWallet/Sources/UI/Setup/SecureWallet/Verify/Views/DWVerifySeedPhraseContentView.m +++ b/DashWallet/Sources/UI/Setup/SecureWallet/Verify/Views/DWVerifySeedPhraseContentView.m @@ -56,6 +56,7 @@ @interface DWVerifySeedPhraseContentView () @property (nonatomic, strong) NSLayoutConstraint *verificationSeedPhraseTopConstraint; @property (nonatomic, assign) BOOL initialAnimationCompleted; +@property (nonatomic, assign) BOOL verificationCompletionScheduled; @end @@ -181,6 +182,11 @@ - (void)seedPhraseView:(DWSeedPhraseView *)view didSelectWord:(DWSeedWordModel * [self.model selectWord:wordModel]; if (self.model.seedPhraseHasBeenVerified) { + if (self.verificationCompletionScheduled) { + return; + } + self.verificationCompletionScheduled = YES; + // show result when animation ends dispatch_time_t when = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(DW_VERIFY_APPEAR_ANIMATION_DURATION * NSEC_PER_SEC)); dispatch_after(when, dispatch_get_main_queue(), ^{ diff --git a/DashWallet/Sources/UI/Setup/SetPin/DWSetPinViewController.m b/DashWallet/Sources/UI/Setup/SetPin/DWSetPinViewController.m index 8c6e79a9a1..88794cb5dd 100644 --- a/DashWallet/Sources/UI/Setup/SetPin/DWSetPinViewController.m +++ b/DashWallet/Sources/UI/Setup/SetPin/DWSetPinViewController.m @@ -79,10 +79,16 @@ - (UIStatusBarStyle)preferredStatusBarStyle { #pragma mark - DWPinViewDelegate - (void)pinViewCancelButtonTap:(DWPinView *)pinView { - [self.delegate setPinViewControllerDidCancel:self]; + if ([self dw_beginExclusiveUserAction]) { + [self.delegate setPinViewControllerDidCancel:self]; + } } - (void)pinView:(DWPinView *)pinView didFinishWithPin:(NSString *)pin { + if (![self dw_beginExclusiveUserAction]) { + return; + } + BOOL success = [self.model setPin:pin]; if (success) { [self.delegate setPinViewControllerDidSetPin:self]; diff --git a/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift b/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift index d17d2cb751..64b46b1188 100644 --- a/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift +++ b/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift @@ -91,7 +91,10 @@ class BaseNavigationController: UINavigationController { @IBAction func cancelButtonAction() { - dismiss(animated: true) + guard dw_beginExclusiveUserAction() else { return } + dismiss(animated: true) { [weak self] in + self?.dw_endExclusiveUserAction() + } } @objc @@ -113,9 +116,18 @@ class BaseNavigationController: UINavigationController { } override func pushViewController(_ viewController: UIViewController, animated: Bool) { + guard !isPushAnimationInProgress else { return } + isPushAnimationInProgress = true super.pushViewController(viewController, animated: animated) + + // UIKit does not call the navigation delegate when a stack is being + // assembled before it is on screen. There is no visible transition to + // protect in that case, so do not leave the gate acquired indefinitely. + if !animated || view.window == nil { + isPushAnimationInProgress = false + } } override func viewDidLoad() { diff --git a/DashWalletTests/UIViewControllerUserActionGateTests.swift b/DashWalletTests/UIViewControllerUserActionGateTests.swift new file mode 100644 index 0000000000..bb83754dc9 --- /dev/null +++ b/DashWalletTests/UIViewControllerUserActionGateTests.swift @@ -0,0 +1,34 @@ +// +// UIViewControllerUserActionGateTests.swift +// DashWalletTests +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// + +@testable import dashwallet +import UIKit +import XCTest + +@MainActor +final class UIViewControllerUserActionGateTests: XCTestCase { + func testActionRemainsExclusiveUntilExplicitlyEnded() { + let controller = UIViewController() + + XCTAssertTrue(controller.dw_beginExclusiveUserAction()) + XCTAssertFalse(controller.dw_beginExclusiveUserAction()) + + controller.dw_endExclusiveUserAction() + + XCTAssertTrue(controller.dw_beginExclusiveUserAction()) + } + + func testEachControllerHasAnIndependentGate() { + let first = UIViewController() + let second = UIViewController() + + XCTAssertTrue(first.dw_beginExclusiveUserAction()) + XCTAssertTrue(second.dw_beginExclusiveUserAction()) + XCTAssertFalse(first.dw_beginExclusiveUserAction()) + XCTAssertFalse(second.dw_beginExclusiveUserAction()) + } +}