Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions Source/UI/PaymentForm/ViewController/FramesPaymentViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -106,22 +123,44 @@ 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()
Comment thread
robert-goheen-cko marked this conversation as resolved.
Resolved
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)

setUpKeyboard()
registerPrivacyCoverHandlers()
Comment thread
robert-goheen-cko marked this conversation as resolved.
Resolved
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")
}
Expand Down Expand Up @@ -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)
Comment thread
robert-goheen-cko marked this conversation as resolved.
Resolved
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,
Expand Down
121 changes: 120 additions & 1 deletion Tests/UI/PaymentForm/PaymentViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
}
}
Loading