diff --git a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift index bb9c7a2b2..84bb41d00 100644 --- a/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift +++ b/Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift @@ -20,6 +20,23 @@ 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 + /// (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 +123,21 @@ final class FramesPaymentViewController: UIViewController { setupNavigationBar() setupViewModel() setupViewsInOrder() + setupPrivacyCover() viewModel.updateAll() } 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() } override func viewWillAppear(_ animated: Bool) { @@ -119,9 +145,22 @@ final class FramesPaymentViewController: UIViewController { navigationController?.setNavigationBarHidden(false, animated: animated) setUpKeyboard() + registerPrivacyCoverHandlers() 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 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() + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -159,6 +198,94 @@ 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() { + 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, + selector: #selector(addPrivacyCover), + name: UIScene.willDeactivateNotification, + 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) + } + } + + /// 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) + if #available(iOS 13.0, *) { + notificationCenter.removeObserver(self, name: UIScene.willDeactivateNotification, object: nil) + notificationCenter.removeObserver(self, name: UIScene.didActivateNotification, object: nil) + } + } + + /// 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 + } + + @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..fc6bf1990 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,121 @@ 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) + // Observers remain active through viewWillDisappear (to cover animated transitions). + viewController.viewWillDisappear(false) + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + let coverView = viewController.view.subviews.last + 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.willResignActiveNotification, object: nil) + XCTAssertTrue(coverView?.isHidden ?? false, + "Privacy cover must not respond after viewDidDisappear deregisters observers") + } + + 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 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.) + + // 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") + } + + func testPrivacyCoverObserversUpgradedAfterWindowAttachment() { + // 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) + + // Phase 2 – scene becomes available; viewDidAppear triggers the upgrade. + viewController.windowSceneProvider = { [weak sceneToken] in sceneToken } + viewController.viewDidAppear(false) + + let coverView = viewController.view.subviews.last + + // 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") + } + } }