From bef379ea451d3a2d56219340711edb02f57b715d Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 16:51:02 +0000 Subject: [PATCH 1/6] fix(security): add privacy cover to prevent card data in app-switcher snapshots When the app resigns active, iOS captures the current window into a snapshot used in the app-switcher, potentially exposing card number, expiry, CVV and cardholder name in cleartext (CWE-524, find_01GGqWLbh2boaS9unYDSQa8U). Changes: - Add a lazily created opaque privacyCoverView in FramesPaymentViewController. - setupPrivacyCover() pins it full-screen over view in viewDidLoad; it starts hidden. - registerPrivacyCoverHandlers() / deregisterPrivacyCoverHandlers() subscribe to UIApplication.willResignActiveNotification / didBecomeActiveNotification (and their UIScene equivalents on iOS 13+) in viewWillAppear / viewWillDisappear. - addPrivacyCover() brings the cover to front and shows it; removePrivacyCover() hides it again. - Update testPaymentViewsHierarchy to reflect the new subview count (2). - Add four new unit tests covering cover visibility on resign/become- active and that observers are cleaned up on viewWillDisappear. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FramesPaymentViewController.swift | 66 +++++++++++++++++++ .../PaymentViewControllerTests.swift | 46 ++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift index bb9c7a2b2..925f74e7a 100644 --- a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift +++ b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift @@ -20,6 +20,18 @@ final class FramesPaymentViewController: UIViewController { private(set) var viewModel: PaymentViewModel private var notificationCenter = NotificationCenter.default + // MARK: - Privacy cover + + /// Opaque overlay shown while the app is inactive so the system snapshot + /// (app-switcher thumbnail) contains no card data. + private lazy var privacyCoverView: UIView = { + let cover = UIView() + cover.backgroundColor = .systemBackground + cover.translatesAutoresizingMaskIntoConstraints = false + cover.isHidden = true + return cover + }() + // MARK: - UI properties private lazy var headerView: PaymentHeaderView = { @@ -106,12 +118,14 @@ final class FramesPaymentViewController: UIViewController { setupNavigationBar() setupViewModel() setupViewsInOrder() + setupPrivacyCover() viewModel.updateAll() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) deregisterKeyboardHandlers(notificationCenter: notificationCenter) + deregisterPrivacyCoverHandlers() } override func viewWillAppear(_ animated: Bool) { @@ -119,6 +133,7 @@ final class FramesPaymentViewController: UIViewController { navigationController?.setNavigationBarHidden(false, animated: animated) setUpKeyboard() + registerPrivacyCoverHandlers() viewModel.viewControllerWillAppear() } @@ -159,6 +174,57 @@ final class FramesPaymentViewController: UIViewController { keyboardWillHide: #selector(keyboardWillHide)) } + // MARK: - Privacy cover + + private func setupPrivacyCover() { + view.addSubview(privacyCoverView) + NSLayoutConstraint.activate([ + privacyCoverView.topAnchor.constraint(equalTo: view.topAnchor), + privacyCoverView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + privacyCoverView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + privacyCoverView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func registerPrivacyCoverHandlers() { + notificationCenter.addObserver(self, + selector: #selector(addPrivacyCover), + name: UIApplication.willResignActiveNotification, + object: nil) + notificationCenter.addObserver(self, + selector: #selector(removePrivacyCover), + name: UIApplication.didBecomeActiveNotification, + object: nil) + if #available(iOS 13.0, *) { + notificationCenter.addObserver(self, + selector: #selector(addPrivacyCover), + name: UIScene.willDeactivateNotification, + object: nil) + notificationCenter.addObserver(self, + selector: #selector(removePrivacyCover), + name: UIScene.didActivateNotification, + object: nil) + } + } + + private func deregisterPrivacyCoverHandlers() { + notificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + notificationCenter.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) + if #available(iOS 13.0, *) { + notificationCenter.removeObserver(self, name: UIScene.willDeactivateNotification, object: nil) + notificationCenter.removeObserver(self, name: UIScene.didActivateNotification, object: nil) + } + } + + @objc private func addPrivacyCover() { + view.bringSubviewToFront(privacyCoverView) + privacyCoverView.isHidden = false + } + + @objc private func removePrivacyCover() { + privacyCoverView.isHidden = true + } + private func updateScrollViewInset(to contentInset: UIEdgeInsets, from notification: Notification) { var animationDuration: Double = 0 if let userInfo = notification.userInfo, diff --git a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift index 9e27f4704..9a8fc9c7d 100644 --- a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift +++ b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift @@ -42,8 +42,10 @@ class PaymentViewControllerTests: XCTestCase { viewController.viewDidLayoutSubviews() let mainView = viewController.view - XCTAssertEqual(mainView?.subviews.count, 1) + XCTAssertEqual(mainView?.subviews.count, 2) XCTAssertTrue(mainView?.subviews[0] is UIScrollView) + // Privacy cover is added last so it can be brought to front when active. + XCTAssertFalse(mainView?.subviews[1] is UIScrollView) let scrollView = mainView?.subviews[0] XCTAssertEqual(scrollView?.subviews.count, 1) @@ -194,4 +196,46 @@ class PaymentViewControllerTests: XCTestCase { XCTAssertEqual(testLogger.logCalledWithFramesLogEvents.count, 1) XCTAssertEqual(testLogger.logCalledWithFramesLogEvents.first, .paymentFormPresented) } + + // MARK: - Privacy cover tests + + func testPrivacyCoverIsHiddenInitially() { + viewController.viewDidLoad() + let coverView = viewController.view.subviews.last + XCTAssertNotNil(coverView, "Privacy cover view should be in the hierarchy") + XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover should be hidden when the app is active") + } + + func testPrivacyCoverIsShownWhenAppResignsActive() { + viewController.viewDidLoad() + viewController.viewWillAppear(false) + + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + + let coverView = viewController.view.subviews.last + XCTAssertFalse(coverView?.isHidden ?? true, "Privacy cover should be visible when the app resigns active") + } + + func testPrivacyCoverIsHiddenWhenAppBecomesActive() { + viewController.viewDidLoad() + viewController.viewWillAppear(false) + + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + let coverView = viewController.view.subviews.last + XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover should be hidden after the app becomes active again") + } + + func testPrivacyCoverObserversRemovedOnDisappear() { + viewController.viewDidLoad() + viewController.viewWillAppear(false) + viewController.viewWillDisappear(false) + + // After deregistration, resignActive should not show the cover. + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + + let coverView = viewController.view.subviews.last + XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover should stay hidden after observers are deregistered") + } } From cb8ea6025db20af8f98c660178408b4959fda023 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 17:18:38 +0000 Subject: [PATCH 2/6] Scope scene observers to owning scene and sync cover state on re-registration - registerPrivacyCoverHandlers now binds UIScene notifications to the specific UIWindowScene that hosts the payment form's window (object: owningScene). This prevents deactivation events from an unrelated scene/window from toggling the privacy cover incorrectly. - When no windowScene is available (pre-scene hosts, unit tests) the implementation falls back to UIApplication-level notifications, preserving backward compatibility. - syncPrivacyCoverToCurrentState() is called after adding observers to align cover visibility with the actual foreground/background state at registration time. This eliminates a race where the cover could remain opaque if the app became active while the VC was off-screen and the activation notification was not received. - Added regression test testPrivacyCoverSyncedOnReappearAfterMissedActivation covering the stale-cover scenario described above. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FramesPaymentViewController.swift | 47 +++++++++++++++---- .../PaymentViewControllerTests.swift | 24 ++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift index 925f74e7a..3a2dc62ec 100644 --- a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift +++ b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift @@ -187,23 +187,32 @@ final class FramesPaymentViewController: UIViewController { } private func registerPrivacyCoverHandlers() { - notificationCenter.addObserver(self, - selector: #selector(addPrivacyCover), - name: UIApplication.willResignActiveNotification, - object: nil) - notificationCenter.addObserver(self, - selector: #selector(removePrivacyCover), - name: UIApplication.didBecomeActiveNotification, - object: nil) - if #available(iOS 13.0, *) { + if #available(iOS 13.0, *), let owningScene = view.window?.windowScene { + // Scope notifications to the specific scene that owns this window so that + // activity in an unrelated window/scene cannot affect this cover. notificationCenter.addObserver(self, selector: #selector(addPrivacyCover), name: UIScene.willDeactivateNotification, - object: nil) + object: owningScene) notificationCenter.addObserver(self, selector: #selector(removePrivacyCover), name: UIScene.didActivateNotification, + object: owningScene) + // Sync immediately: if the VC reappears while the scene is already inactive + // (e.g. app-switched while the VC was off-screen), show the cover now. + syncPrivacyCoverToCurrentState(scene: owningScene) + } else { + // Fallback for pre-scene hosts: use application-level notifications. + notificationCenter.addObserver(self, + selector: #selector(addPrivacyCover), + name: UIApplication.willResignActiveNotification, + object: nil) + notificationCenter.addObserver(self, + selector: #selector(removePrivacyCover), + name: UIApplication.didBecomeActiveNotification, object: nil) + // Sync immediately against the current application state. + syncPrivacyCoverToCurrentState(scene: nil) } } @@ -216,6 +225,24 @@ final class FramesPaymentViewController: UIViewController { } } + /// Aligns cover visibility with the current activation state so stale cover + /// state cannot persist across disappear/appear cycles. + private func syncPrivacyCoverToCurrentState(scene: AnyObject?) { + if #available(iOS 13.0, *), let windowScene = scene as? UIWindowScene { + if windowScene.activationState == .foregroundActive { + removePrivacyCover() + } else { + addPrivacyCover() + } + } else { + if UIApplication.shared.applicationState == .active { + removePrivacyCover() + } else { + addPrivacyCover() + } + } + } + @objc private func addPrivacyCover() { view.bringSubviewToFront(privacyCoverView) privacyCoverView.isHidden = false diff --git a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift index 9a8fc9c7d..7d1df763f 100644 --- a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift +++ b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift @@ -238,4 +238,28 @@ class PaymentViewControllerTests: XCTestCase { let coverView = viewController.view.subviews.last XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover should stay hidden after observers are deregistered") } + + func testPrivacyCoverSyncedOnReappearAfterMissedActivation() { + // Scenario: cover is shown, the VC disappears, the app becomes active while + // the VC is off-screen (so the didBecomeActive notification is missed), then + // the VC reappears. The cover must be hidden when reappear runs. + viewController.viewDidLoad() + viewController.viewWillAppear(false) + + // Show the cover. + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + let coverView = viewController.view.subviews.last + XCTAssertFalse(coverView?.isHidden ?? true, "Cover should be visible after resign-active") + + // VC disappears and observers are removed. + viewController.viewWillDisappear(false) + + // App becomes active while VC is off-screen – notification not observed. + // (No post here; UIApplication.shared.applicationState is .active in XCTest.) + + // VC reappears; syncPrivacyCoverToCurrentState should hide the cover. + viewController.viewWillAppear(false) + XCTAssertTrue(coverView?.isHidden ?? false, + "Privacy cover must be hidden when the VC reappears and the app is already active") + } } From 077ead8e00353a2472e65b05f97a83a50346ae47 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 17:29:24 +0000 Subject: [PATCH 3/6] Re-register scene observers in viewDidAppear after window attachment viewWillAppear can run before the view has a window, so view.window?.windowScene resolves to nil and registerPrivacyCoverHandlers falls back to application-level notifications. If the payment scene then deactivates independently (while another scene keeps the app active), the app-level notification never fires and the cover stays hidden. Add viewDidAppear which, once the view is guaranteed to be in the window hierarchy, tears down the previously registered observers and re-registers using the now-available UIWindowScene. The sync performed inside registerPrivacyCoverHandlers then aligns cover visibility with the current scene state. If view.window?.windowScene is still nil (non-scene hosts, unit tests), the method is a no-op and app-level observers remain active. Add regression test testPrivacyCoverObserversUpgradedAfterWindowAttachment to confirm observers are live and functional after the viewDidAppear path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FramesPaymentViewController.swift | 12 +++++++++++ .../PaymentViewControllerTests.swift | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift index 3a2dc62ec..514ee3602 100644 --- a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift +++ b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift @@ -137,6 +137,18 @@ final class FramesPaymentViewController: UIViewController { viewModel.viewControllerWillAppear() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // The view is now attached to a window. If viewWillAppear registered + // app-level fallback observers because no scene was available yet, + // re-register now that view.window?.windowScene is resolvable so that + // scene-scoped observers are used for the remainder of the presentation. + if #available(iOS 13.0, *), view.window?.windowScene != nil { + deregisterPrivacyCoverHandlers() + registerPrivacyCoverHandlers() + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift index 7d1df763f..959b41d9a 100644 --- a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift +++ b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift @@ -262,4 +262,25 @@ class PaymentViewControllerTests: XCTestCase { XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover must be hidden when the VC reappears and the app is already active") } + + func testPrivacyCoverObserversUpgradedAfterWindowAttachment() { + // Scenario: on first presentation viewWillAppear fires before the view has + // a window (no scene available), so app-level fallback observers are + // registered. viewDidAppear fires after window attachment and must + // re-register scene-scoped observers (or keep app-level ones if still no + // scene). After viewDidAppear the app-level resign-active notification must + // still trigger the cover, confirming observers are live regardless of path. + viewController.viewDidLoad() + viewController.viewWillAppear(false) + // view.window is nil in unit tests, so only app-level observers are active here. + + viewController.viewDidAppear(false) + // view.window?.windowScene is still nil in tests, so no re-registration + // occurs and app-level observers remain active. + + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + let coverView = viewController.view.subviews.last + XCTAssertFalse(coverView?.isHidden ?? true, + "Privacy cover must be shown after resign-active even when window attachment was deferred") + } } From 2a727331afd3f4ba1b41b2eadcf01835e09a0db2 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Wed, 11 Nov 2026 09:39:24 +0000 Subject: [PATCH 4/6] Improve testability of scene-observer upgrade path via injectable scene provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an internal windowSceneProvider closure to FramesPaymentViewController so tests can supply a scene token without requiring a real scene-backed window. In production the closure is nil and the implementation falls back to view.window?.windowScene unchanged. Extract resolvedWindowScene() private helper (iOS 13+) that consults the provider first, then view.window?.windowScene. Both registerPrivacyCoverHandlers and viewDidAppear now call this helper, keeping the resolution logic in one place. Replace the previous testPrivacyCoverObserversUpgradedAfterWindowAttachment with a test that actually exercises the upgrade branch: - Phase 1: windowSceneProvider returns nil → app-level observers registered - Phase 2: provider returns a scene token → viewDidAppear upgrades to scene-scoped - Asserts app-level notifications no longer affect the cover after the upgrade - Asserts owning-scene deactivation shows the cover - Asserts an unrelated-scene notification does not affect the cover Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FramesPaymentViewController.swift | 23 ++++++++-- .../PaymentViewControllerTests.swift | 44 ++++++++++++++----- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift index 514ee3602..3ee9841d3 100644 --- a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift +++ b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift @@ -20,6 +20,11 @@ final class FramesPaymentViewController: UIViewController { private(set) var viewModel: PaymentViewModel private var notificationCenter = NotificationCenter.default + /// Overridable in tests to supply a scene object without requiring a real + /// scene-backed window. When nil the implementation resolves the scene from + /// view.window?.windowScene at registration time. + var windowSceneProvider: (() -> AnyObject?)? + // MARK: - Privacy cover /// Opaque overlay shown while the app is inactive so the system snapshot @@ -141,9 +146,9 @@ final class FramesPaymentViewController: UIViewController { super.viewDidAppear(animated) // The view is now attached to a window. If viewWillAppear registered // app-level fallback observers because no scene was available yet, - // re-register now that view.window?.windowScene is resolvable so that - // scene-scoped observers are used for the remainder of the presentation. - if #available(iOS 13.0, *), view.window?.windowScene != nil { + // re-register now that the owning scene is resolvable so that scene-scoped + // observers are used for the remainder of the presentation. + if #available(iOS 13.0, *), resolvedWindowScene() != nil { deregisterPrivacyCoverHandlers() registerPrivacyCoverHandlers() } @@ -199,7 +204,7 @@ final class FramesPaymentViewController: UIViewController { } private func registerPrivacyCoverHandlers() { - if #available(iOS 13.0, *), let owningScene = view.window?.windowScene { + if #available(iOS 13.0, *), let owningScene = resolvedWindowScene() { // Scope notifications to the specific scene that owns this window so that // activity in an unrelated window/scene cannot affect this cover. notificationCenter.addObserver(self, @@ -228,6 +233,16 @@ final class FramesPaymentViewController: UIViewController { } } + /// Returns the scene token to scope notifications to. Uses windowSceneProvider + /// when injected (for tests); otherwise falls back to view.window?.windowScene. + @available(iOS 13.0, *) + private func resolvedWindowScene() -> AnyObject? { + if let provider = windowSceneProvider { + return provider() + } + return view.window?.windowScene + } + private func deregisterPrivacyCoverHandlers() { notificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) notificationCenter.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) diff --git a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift index 959b41d9a..1f7bdaf1c 100644 --- a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift +++ b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift @@ -264,23 +264,43 @@ class PaymentViewControllerTests: XCTestCase { } func testPrivacyCoverObserversUpgradedAfterWindowAttachment() { - // Scenario: on first presentation viewWillAppear fires before the view has - // a window (no scene available), so app-level fallback observers are - // registered. viewDidAppear fires after window attachment and must - // re-register scene-scoped observers (or keep app-level ones if still no - // scene). After viewDidAppear the app-level resign-active notification must - // still trigger the cover, confirming observers are live regardless of path. + // Scenario: viewWillAppear fires before a scene is available (windowSceneProvider + // returns nil), so app-level fallback observers are registered. viewDidAppear fires + // with a scene now available; it must deregister fallback observers and re-register + // scene-scoped ones. After the upgrade, app-level notifications must NOT affect the + // cover, and scene-scoped notifications MUST. + let sceneToken = NSObject() viewController.viewDidLoad() + + // Phase 1 – no scene yet; app-level observers are registered. + viewController.windowSceneProvider = { nil } viewController.viewWillAppear(false) - // view.window is nil in unit tests, so only app-level observers are active here. + // Phase 2 – scene becomes available; viewDidAppear triggers the upgrade. + viewController.windowSceneProvider = { [weak sceneToken] in sceneToken } viewController.viewDidAppear(false) - // view.window?.windowScene is still nil in tests, so no re-registration - // occurs and app-level observers remain active. - NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) let coverView = viewController.view.subviews.last - XCTAssertFalse(coverView?.isHidden ?? true, - "Privacy cover must be shown after resign-active even when window attachment was deferred") + + // After upgrade, app-level resign-active must NOT show the cover. + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + XCTAssertTrue(coverView?.isHidden ?? false, + "App-level notifications must not affect the cover after scene-scoped upgrade") + + // After upgrade, owning-scene deactivation MUST show the cover. + if #available(iOS 13.0, *) { + NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: sceneToken) + XCTAssertFalse(coverView?.isHidden ?? true, + "Cover must be shown when the owning-scene deactivation notification fires") + + // A notification from an UNRELATED scene object must leave the cover unchanged. + NotificationCenter.default.post(name: UIScene.didActivateNotification, object: sceneToken) + XCTAssertTrue(coverView?.isHidden ?? false, "Cover should be hidden after scene re-activation") + + let unrelatedToken = NSObject() + NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: unrelatedToken) + XCTAssertTrue(coverView?.isHidden ?? false, + "An unrelated-scene notification must not affect the cover") + } } } From 56a6e4a808f4fb31374fd21d5ec53fd7920f87a0 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Wed, 11 Nov 2026 09:40:47 +0000 Subject: [PATCH 5/6] Extend observer lifetime to viewDidDisappear to cover animated transitions Move deregisterPrivacyCoverHandlers() from viewWillDisappear to viewDidDisappear. During an animated or interactive dismissal the payment view is still visible and in the view hierarchy, so observers must remain active until the transition completes and the view has fully left the screen. Update testPrivacyCoverObserversRemovedOnDisappear to assert both sides of this boundary: cover still responds between viewWillDisappear and viewDidDisappear, and stops responding after viewDidDisappear deregisters the observers. Update testPrivacyCoverSyncedOnReappearAfterMissedActivation to call viewDidDisappear (the new deregistration point) instead of viewWillDisappear. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FramesPaymentViewController.swift | 7 +++++++ .../PaymentViewControllerTests.swift | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift index 3ee9841d3..84bb41d00 100644 --- a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift +++ b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift @@ -130,6 +130,13 @@ final class FramesPaymentViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) deregisterKeyboardHandlers(notificationCenter: notificationCenter) + // Privacy cover observers are intentionally kept active until viewDidDisappear + // so the cover can still fire during animated/interactive navigation transitions + // while the payment view is still on screen. + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) deregisterPrivacyCoverHandlers() } diff --git a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift index 1f7bdaf1c..53e82fb1b 100644 --- a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift +++ b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift @@ -230,13 +230,19 @@ class PaymentViewControllerTests: XCTestCase { func testPrivacyCoverObserversRemovedOnDisappear() { viewController.viewDidLoad() viewController.viewWillAppear(false) + // Observers remain active through viewWillDisappear (to cover animated transitions). viewController.viewWillDisappear(false) - - // After deregistration, resignActive should not show the cover. NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) - let coverView = viewController.view.subviews.last - XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover should stay hidden after observers are deregistered") + XCTAssertFalse(coverView?.isHidden ?? true, + "Privacy cover must still respond between viewWillDisappear and viewDidDisappear") + + // After viewDidDisappear, observers are removed and the cover no longer responds. + viewController.viewDidDisappear(false) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + XCTAssertTrue(coverView?.isHidden ?? false, + "Privacy cover must not respond after viewDidDisappear deregisters observers") } func testPrivacyCoverSyncedOnReappearAfterMissedActivation() { @@ -251,8 +257,9 @@ class PaymentViewControllerTests: XCTestCase { let coverView = viewController.view.subviews.last XCTAssertFalse(coverView?.isHidden ?? true, "Cover should be visible after resign-active") - // VC disappears and observers are removed. + // VC fully disappears and observers are removed. viewController.viewWillDisappear(false) + viewController.viewDidDisappear(false) // App becomes active while VC is off-screen – notification not observed. // (No post here; UIApplication.shared.applicationState is .active in XCTest.) From 1dc708a7338b9c554a226100a8f348ea3cb0e126 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Wed, 11 Nov 2026 09:55:41 +0000 Subject: [PATCH 6/6] Review Updates --- Tests/UI/PaymentForm/PaymentViewControllerTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift index 53e82fb1b..fc6bf1990 100644 --- a/Tests/UI/PaymentForm/PaymentViewControllerTests.swift +++ b/Tests/UI/PaymentForm/PaymentViewControllerTests.swift @@ -237,9 +237,12 @@ class PaymentViewControllerTests: XCTestCase { XCTAssertFalse(coverView?.isHidden ?? true, "Privacy cover must still respond between viewWillDisappear and viewDidDisappear") + // Reset the cover while observers are still registered. + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover should hide when the app becomes active") + // After viewDidDisappear, observers are removed and the cover no longer responds. viewController.viewDidDisappear(false) - NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) XCTAssertTrue(coverView?.isHidden ?? false, "Privacy cover must not respond after viewDidDisappear deregisters observers")