From 34a6dc5fefae54bbadcb78da163124e3805f75d5 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 11:42:44 +0000 Subject: [PATCH 1/4] Add window security handling for payment UI Add a shared helper that manages window flags for the payment form and CVV component while they are shown. It resolves the hosting window (Activity, Fragment, dialog or bottom sheet), shares state between components on the same window, and restores the host app's settings afterwards. Add unit tests and a README note. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + .../frames/api/PaymentFormMediator.kt | 2 + .../api/InternalCVVComponentMediator.kt | 2 + .../com/checkout/frames/utils/SecureWindow.kt | 124 ++++++++++++++++++ .../frames/utils/SecureWindowRegistryTest.kt | 69 ++++++++++ 5 files changed, 200 insertions(+) create mode 100644 frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt create mode 100644 frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt diff --git a/README.md b/README.md index 07580bb78..9e71d5229 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,9 @@ override fun onCreateView( ): View = paymentFormMediator.provideFragmentContent(this) ``` +#### Screenshot and screen-capture protection +While the payment form or the CVV component is on screen, Frames sets `WindowManager.LayoutParams.FLAG_SECURE` on the window that hosts it: the Activity, or the dialog or bottom sheet if you show it in one. This keeps card data out of the Recents snapshot, screenshots and screen recordings. When the form leaves the screen, Frames clears the flag. If your app set `FLAG_SECURE` on that window before the form was shown, Frames leaves it set. + ## Make it your own !!! Any customisation needs to be done before creating Payment form mediator. Once mediator is created, any changes done on style will not be reflected in the UI !!! diff --git a/frames/src/main/java/com/checkout/frames/api/PaymentFormMediator.kt b/frames/src/main/java/com/checkout/frames/api/PaymentFormMediator.kt index 1fa56c86e..40c40e2f1 100644 --- a/frames/src/main/java/com/checkout/frames/api/PaymentFormMediator.kt +++ b/frames/src/main/java/com/checkout/frames/api/PaymentFormMediator.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import com.checkout.frames.screen.paymentform.PaymentFormScreen import com.checkout.frames.screen.paymentform.model.PaymentFormConfig +import com.checkout.frames.utils.SecureWindowEffect import com.checkout.logging.EventLoggerProvider import com.checkout.threedsecure.Executor import com.checkout.threedsecure.ThreeDSExecutor @@ -32,6 +33,7 @@ public class PaymentFormMediator( @Composable public fun PaymentForm() { + SecureWindowEffect() PaymentFormScreen(config) } diff --git a/frames/src/main/java/com/checkout/frames/cvvinputfield/api/InternalCVVComponentMediator.kt b/frames/src/main/java/com/checkout/frames/cvvinputfield/api/InternalCVVComponentMediator.kt index a5ab4650c..eedb0f561 100644 --- a/frames/src/main/java/com/checkout/frames/cvvinputfield/api/InternalCVVComponentMediator.kt +++ b/frames/src/main/java/com/checkout/frames/cvvinputfield/api/InternalCVVComponentMediator.kt @@ -12,6 +12,7 @@ import com.checkout.base.usecase.UseCase import com.checkout.frames.cvvinputfield.CVVInputField import com.checkout.frames.cvvinputfield.models.CVVComponentConfig import com.checkout.frames.cvvinputfield.models.InternalCVVTokenRequest +import com.checkout.frames.utils.SecureWindowEffect import com.checkout.tokenization.model.CVVTokenizationResultHandler internal class InternalCVVComponentMediator( @@ -31,6 +32,7 @@ internal class InternalCVVComponentMediator( private fun InternalCVVComponent() { val isCVVComponentAlreadyLoaded = remember { isCVVComponentCalled.value } if (!isCVVComponentAlreadyLoaded) { + SecureWindowEffect() CVVInputField(cvvComponentConfig) { onValueChange -> cvvInputFieldTextValue.value = onValueChange } diff --git a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt new file mode 100644 index 000000000..62016739b --- /dev/null +++ b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt @@ -0,0 +1,124 @@ +package com.checkout.frames.utils + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.annotation.MainThread +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalView +import java.util.WeakHashMap + +/** + * Sets [WindowManager.LayoutParams.FLAG_SECURE] on the window that hosts the calling composable for as long as it + * stays in the composition. This keeps card data out of the Recents snapshot, screenshots and screen capture. + * + * The window is resolved from the view's root, so dialogs and bottom sheets that host the SDK UI get the flag on + * their own window rather than on the Activity window behind them. + */ +@Composable +internal fun SecureWindowEffect() { + val view = LocalView.current + DisposableEffect(view) { + val target = view.findSecureTarget() + target?.let { SecureWindowRegistry.acquire(it) } + onDispose { target?.let { SecureWindowRegistry.release(it) } } + } +} + +private fun View.findSecureTarget(): SecureTarget? { + val activityWindow = context.findActivity()?.window + val root = rootView + if (activityWindow != null && activityWindow.peekDecorView() === root) return WindowSecureTarget(activityWindow) + + val rootParams = root.layoutParams as? WindowManager.LayoutParams + if (rootParams != null && root.isAttachedToWindow) return RootViewSecureTarget(root, rootParams) + + return activityWindow?.let { WindowSecureTarget(it) } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +internal interface SecureTarget { + /** Identifies the underlying window. Targets for the same window must return the same key. */ + val key: Any + fun isSecure(): Boolean + fun setSecure(secure: Boolean) +} + +private class WindowSecureTarget(private val window: Window) : SecureTarget { + override val key: Any get() = window + + override fun isSecure(): Boolean = (window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 + + override fun setSecure(secure: Boolean) { + if (secure) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } +} + +/** + * A window the SDK can't reach through an Activity, such as a View-based dialog or bottom sheet. The root view's + * [WindowManager.LayoutParams] are that window's attributes, so updating them updates the window. + */ +private class RootViewSecureTarget( + private val root: View, + private val params: WindowManager.LayoutParams, +) : SecureTarget { + override val key: Any get() = root + + override fun isSecure(): Boolean = (params.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 + + override fun setSecure(secure: Boolean) { + params.flags = if (secure) { + params.flags or WindowManager.LayoutParams.FLAG_SECURE + } else { + params.flags and WindowManager.LayoutParams.FLAG_SECURE.inv() + } + if (root.isAttachedToWindow) { + val windowManager = root.context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager + windowManager?.updateViewLayout(root, params) + } + } +} + +/** + * Reference-counts secure requests per window, so several SDK components can share a window, and clears the flag + * only if the SDK set it. + */ +@MainThread +internal object SecureWindowRegistry { + + private class Entry(var count: Int, val setBySdk: Boolean) + + private val entries = WeakHashMap() + + fun acquire(target: SecureTarget) { + val entry = entries[target.key] + if (entry != null) { + entry.count++ + return + } + val alreadySecure = target.isSecure() + if (!alreadySecure) target.setSecure(true) + entries[target.key] = Entry(count = 1, setBySdk = !alreadySecure) + } + + fun release(target: SecureTarget) { + val entry = entries[target.key] ?: return + entry.count-- + if (entry.count > 0) return + entries.remove(target.key) + if (entry.setBySdk) target.setSecure(false) + } +} diff --git a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt new file mode 100644 index 000000000..fafce0d9f --- /dev/null +++ b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt @@ -0,0 +1,69 @@ +package com.checkout.frames.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class SecureWindowRegistryTest { + + private class FakeTarget( + var secure: Boolean, + override val key: Any = Any(), + ) : SecureTarget { + var setCalls = 0 + override fun isSecure(): Boolean = secure + override fun setSecure(secure: Boolean) { + setCalls++ + this.secure = secure + } + } + + @Test + fun `when window is not secure then acquire sets and release clears the flag`() { + val target = FakeTarget(secure = false) + + SecureWindowRegistry.acquire(target) + assertTrue(target.secure) + + SecureWindowRegistry.release(target) + assertFalse(target.secure) + } + + @Test + fun `when window is already secure then flag is left untouched`() { + val target = FakeTarget(secure = true) + + SecureWindowRegistry.acquire(target) + SecureWindowRegistry.release(target) + + assertTrue(target.secure) + assertEquals(0, target.setCalls) + } + + @Test + fun `when two components share a window then flag is cleared only after the last release`() { + val key = Any() + val first = FakeTarget(secure = false, key = key) + val second = FakeTarget(secure = false, key = key) + + SecureWindowRegistry.acquire(first) + SecureWindowRegistry.acquire(second) + SecureWindowRegistry.release(first) + assertTrue(first.secure) + + SecureWindowRegistry.release(second) + assertFalse(second.secure) + assertEquals(1, first.setCalls) + assertEquals(1, second.setCalls) + } + + @Test + fun `when release is called without acquire then nothing happens`() { + val target = FakeTarget(secure = false) + + SecureWindowRegistry.release(target) + + assertEquals(0, target.setCalls) + } +} From 7293ca30f0aa518127f456453b357ca847dc6379 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 14:20:59 +0000 Subject: [PATCH 2/4] Handle window lifecycle changes in window security helper - Follow view attach and detach, so the setting moves with the view. - Key all window types on the root view, so components share one count. - Prefer the owning Window for Activity, Compose dialog and DialogFragment hosts, and read current layout params on each update. - Reapply the setting before each frame if the host resets it. - Fix a JVM signature clash in the unit test and add tests. Co-Authored-By: Claude Opus 5.5 --- .../com/checkout/frames/utils/SecureWindow.kt | 173 ++++++++++++++---- .../frames/utils/SecureWindowRegistryTest.kt | 94 +++++++--- 2 files changed, 209 insertions(+), 58 deletions(-) diff --git a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt index 62016739b..357c8b94e 100644 --- a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt +++ b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt @@ -4,59 +4,139 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.view.View +import android.view.ViewTreeObserver import android.view.Window import android.view.WindowManager import androidx.annotation.MainThread import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.platform.LocalView -import java.util.WeakHashMap +import androidx.compose.ui.window.DialogWindowProvider +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager /** - * Sets [WindowManager.LayoutParams.FLAG_SECURE] on the window that hosts the calling composable for as long as it - * stays in the composition. This keeps card data out of the Recents snapshot, screenshots and screen capture. - * - * The window is resolved from the view's root, so dialogs and bottom sheets that host the SDK UI get the flag on - * their own window rather than on the Activity window behind them. + * Sets [WindowManager.LayoutParams.FLAG_SECURE] on the window that hosts the calling composable while the composable + * is in the composition and its view is attached to a window. This keeps the payment UI out of the Recents snapshot, + * screenshots and screen capture. */ @Composable internal fun SecureWindowEffect() { val view = LocalView.current DisposableEffect(view) { - val target = view.findSecureTarget() - target?.let { SecureWindowRegistry.acquire(it) } - onDispose { target?.let { SecureWindowRegistry.release(it) } } + val binding = SecureWindowBinding(view) + binding.start() + onDispose { binding.stop() } } } -private fun View.findSecureTarget(): SecureTarget? { - val activityWindow = context.findActivity()?.window - val root = rootView - if (activityWindow != null && activityWindow.peekDecorView() === root) return WindowSecureTarget(activityWindow) +/** + * Follows the view's attach state, so the flag moves with the view if it is detached or reattached to another + * window while the composition stays alive. + */ +@MainThread +private class SecureWindowBinding(private val view: View) : View.OnAttachStateChangeListener { + + private var target: SecureTarget? = null + + fun start() { + view.addOnAttachStateChangeListener(this) + if (view.isAttachedToWindow) acquire() + } + + fun stop() { + view.removeOnAttachStateChangeListener(this) + release() + } + + override fun onViewAttachedToWindow(v: View) = acquire() + + override fun onViewDetachedFromWindow(v: View) = release() + + private fun acquire() { + if (target != null) return + target = view.findSecureTarget()?.also { SecureWindowRegistry.acquire(it) } + } - val rootParams = root.layoutParams as? WindowManager.LayoutParams - if (rootParams != null && root.isAttachedToWindow) return RootViewSecureTarget(root, rootParams) + private fun release() { + target?.let { SecureWindowRegistry.release(it) } + target = null + } +} - return activityWindow?.let { WindowSecureTarget(it) } +private fun View.findSecureTarget(): SecureTarget? { + val root = rootView + findOwningWindow(root)?.let { return WindowSecureTarget(it, root) } + if (root.layoutParams is WindowManager.LayoutParams) return RootViewSecureTarget(root) + return null } +/** Finds the [Window] whose decor view is [root]: the Activity, a Compose dialog or a [DialogFragment]. */ +private fun View.findOwningWindow(root: View): Window? = + sequenceOf( + { context.findActivity()?.window }, + { findComposeDialogWindow() }, + { findDialogFragmentWindow() }, + ).firstNotNullOfOrNull { candidate -> candidate()?.takeIf { it.peekDecorView() === root } } + private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() else -> null } +private fun View.findComposeDialogWindow(): Window? = + generateSequence(parent) { it.parent }.filterIsInstance().firstOrNull()?.window + +private fun View.findDialogFragmentWindow(): Window? { + val fragment = try { + FragmentManager.findFragment(this) + } catch (ignored: IllegalStateException) { + return null + } + return generateSequence(fragment) { it.parentFragment } + .filterIsInstance() + .firstNotNullOfOrNull { it.dialog?.window } +} + internal interface SecureTarget { /** Identifies the underlying window. Targets for the same window must return the same key. */ val key: Any + fun isSecure(): Boolean + fun setSecure(secure: Boolean) + + /** + * Calls [onFrame] before each frame is drawn. + * + * @return a function that stops the callbacks. + */ + fun onEachFrame(onFrame: () -> Unit): () -> Unit +} + +private abstract class RootSecureTarget(protected val root: View) : SecureTarget { + override val key: Any get() = root + + override fun onEachFrame(onFrame: () -> Unit): () -> Unit { + val listener = ViewTreeObserver.OnPreDrawListener { + onFrame() + // Never skip a frame: the host may clear the flag on every frame, and skipping would stall drawing. + true + } + root.viewTreeObserver.addOnPreDrawListener(listener) + return { + val observer = root.viewTreeObserver + if (observer.isAlive) observer.removeOnPreDrawListener(listener) + } + } } -private class WindowSecureTarget(private val window: Window) : SecureTarget { - override val key: Any get() = window +/** A window the SDK can reach directly. Updating the [Window] keeps its own copy of the attributes in step. */ +private class WindowSecureTarget(private val window: Window, root: View) : RootSecureTarget(root) { - override fun isSecure(): Boolean = (window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 + override fun isSecure(): Boolean = window.attributes.flags.hasSecureFlag() override fun setSecure(secure: Boolean) { if (secure) { @@ -68,40 +148,57 @@ private class WindowSecureTarget(private val window: Window) : SecureTarget { } /** - * A window the SDK can't reach through an Activity, such as a View-based dialog or bottom sheet. The root view's - * [WindowManager.LayoutParams] are that window's attributes, so updating them updates the window. + * A window the SDK can't reach through an Activity, a Compose dialog or a [DialogFragment], such as a View-based + * dialog shown by the host app. The root view's current [WindowManager.LayoutParams] are read on every call, so + * newer parameters set by the host are never replaced with an old copy. */ -private class RootViewSecureTarget( - private val root: View, - private val params: WindowManager.LayoutParams, -) : SecureTarget { - override val key: Any get() = root +private class RootViewSecureTarget(root: View) : RootSecureTarget(root) { - override fun isSecure(): Boolean = (params.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 + private val params: WindowManager.LayoutParams? + get() = root.layoutParams as? WindowManager.LayoutParams + + override fun isSecure(): Boolean = params?.flags?.hasSecureFlag() ?: false override fun setSecure(secure: Boolean) { - params.flags = if (secure) { - params.flags or WindowManager.LayoutParams.FLAG_SECURE + val current = params ?: return + val previousFlags = current.flags + current.flags = if (secure) { + previousFlags or WindowManager.LayoutParams.FLAG_SECURE } else { - params.flags and WindowManager.LayoutParams.FLAG_SECURE.inv() + previousFlags and WindowManager.LayoutParams.FLAG_SECURE.inv() } - if (root.isAttachedToWindow) { - val windowManager = root.context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager - windowManager?.updateViewLayout(root, params) + if (!root.isAttachedToWindow) return + val windowManager = root.context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager + val applied = windowManager != null && try { + windowManager.updateViewLayout(root, current) + true + } catch (ignored: IllegalArgumentException) { + // The window is being removed. + false } + // Keep the params in step with the window, so a failed update is retried on the next frame. + if (!applied) current.flags = previousFlags } } +private fun Int.hasSecureFlag(): Boolean = (this and WindowManager.LayoutParams.FLAG_SECURE) != 0 + /** * Reference-counts secure requests per window, so several SDK components can share a window, and clears the flag * only if the SDK set it. + * + * While a window is held, the flag is checked before each frame and set again if something else cleared it, for + * example a dialog republishing its attributes or a Compose dialog reapplying its default secure policy. + * + * Whether the SDK set the flag is decided when the window is first acquired. If the host app sets the flag itself + * while the SDK holds the window, the SDK still clears it on the last release. */ @MainThread internal object SecureWindowRegistry { - private class Entry(var count: Int, val setBySdk: Boolean) + private class Entry(var count: Int, val setBySdk: Boolean, val stopWatching: () -> Unit) - private val entries = WeakHashMap() + private val entries = HashMap() fun acquire(target: SecureTarget) { val entry = entries[target.key] @@ -111,7 +208,10 @@ internal object SecureWindowRegistry { } val alreadySecure = target.isSecure() if (!alreadySecure) target.setSecure(true) - entries[target.key] = Entry(count = 1, setBySdk = !alreadySecure) + val stopWatching = target.onEachFrame { + if (!target.isSecure()) target.setSecure(true) + } + entries[target.key] = Entry(count = 1, setBySdk = !alreadySecure, stopWatching = stopWatching) } fun release(target: SecureTarget) { @@ -119,6 +219,7 @@ internal object SecureWindowRegistry { entry.count-- if (entry.count > 0) return entries.remove(target.key) + entry.stopWatching() if (entry.setBySdk) target.setSecure(false) } } diff --git a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt index fafce0d9f..d7f06b313 100644 --- a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt +++ b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt @@ -2,68 +2,118 @@ package com.checkout.frames.utils import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test internal class SecureWindowRegistryTest { - private class FakeTarget( - var secure: Boolean, - override val key: Any = Any(), - ) : SecureTarget { + /** Window state shared by every target on the same window. */ + private class FakeWindow(var flagSet: Boolean) { var setCalls = 0 - override fun isSecure(): Boolean = secure + var frameCallback: (() -> Unit)? = null + } + + private class FakeTarget(private val window: FakeWindow) : SecureTarget { + override val key: Any get() = window + + override fun isSecure(): Boolean = window.flagSet + override fun setSecure(secure: Boolean) { - setCalls++ - this.secure = secure + window.setCalls++ + window.flagSet = secure + } + + override fun onEachFrame(onFrame: () -> Unit): () -> Unit { + window.frameCallback = onFrame + return { window.frameCallback = null } } } @Test fun `when window is not secure then acquire sets and release clears the flag`() { - val target = FakeTarget(secure = false) + val window = FakeWindow(flagSet = false) + val target = FakeTarget(window) SecureWindowRegistry.acquire(target) - assertTrue(target.secure) + assertTrue(window.flagSet) SecureWindowRegistry.release(target) - assertFalse(target.secure) + assertFalse(window.flagSet) } @Test fun `when window is already secure then flag is left untouched`() { - val target = FakeTarget(secure = true) + val window = FakeWindow(flagSet = true) + val target = FakeTarget(window) SecureWindowRegistry.acquire(target) SecureWindowRegistry.release(target) - assertTrue(target.secure) - assertEquals(0, target.setCalls) + assertTrue(window.flagSet) + assertEquals(0, window.setCalls) } @Test fun `when two components share a window then flag is cleared only after the last release`() { - val key = Any() - val first = FakeTarget(secure = false, key = key) - val second = FakeTarget(secure = false, key = key) + val window = FakeWindow(flagSet = false) + val first = FakeTarget(window) + val second = FakeTarget(window) SecureWindowRegistry.acquire(first) SecureWindowRegistry.acquire(second) SecureWindowRegistry.release(first) - assertTrue(first.secure) + assertTrue(window.flagSet) SecureWindowRegistry.release(second) - assertFalse(second.secure) - assertEquals(1, first.setCalls) - assertEquals(1, second.setCalls) + assertFalse(window.flagSet) + assertEquals(2, window.setCalls) } @Test fun `when release is called without acquire then nothing happens`() { - val target = FakeTarget(secure = false) + val window = FakeWindow(flagSet = false) + + SecureWindowRegistry.release(FakeTarget(window)) + + assertEquals(0, window.setCalls) + } + + @Test + fun `when flag is cleared by someone else while held then it is set again on the next frame`() { + val window = FakeWindow(flagSet = false) + val target = FakeTarget(window) + SecureWindowRegistry.acquire(target) + val onFrame = requireNotNull(window.frameCallback) + + window.flagSet = false + onFrame() + assertTrue(window.flagSet) + + SecureWindowRegistry.release(target) + } + + @Test + fun `when flag is still set then the frame check does nothing`() { + val window = FakeWindow(flagSet = false) + val target = FakeTarget(window) + SecureWindowRegistry.acquire(target) + val callsAfterAcquire = window.setCalls + + requireNotNull(window.frameCallback)() + + assertEquals(callsAfterAcquire, window.setCalls) + SecureWindowRegistry.release(target) + } + + @Test + fun `when last component is released then frame checks stop`() { + val window = FakeWindow(flagSet = false) + val target = FakeTarget(window) + SecureWindowRegistry.acquire(target) SecureWindowRegistry.release(target) - assertEquals(0, target.setCalls) + assertNull(window.frameCallback) } } From e8a5ea597609e14d0d970d8ba02b79951c9151c4 Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 15:44:13 +0000 Subject: [PATCH 3/4] Track visibility and attribute updates in window security helper - Release while the view is hidden and reacquire before it is drawn again, so hidden fragments restore the host's window settings. - Wrap the window callback for reachable windows, so attribute updates keep the setting before they reach the window manager. Co-Authored-By: Claude Opus 5.5 --- .../com/checkout/frames/utils/SecureWindow.kt | 92 +++++++++++++++---- .../frames/utils/SecureWindowRegistryTest.kt | 2 +- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt index 357c8b94e..89f8fafd7 100644 --- a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt +++ b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt @@ -18,8 +18,8 @@ import androidx.fragment.app.FragmentManager /** * Sets [WindowManager.LayoutParams.FLAG_SECURE] on the window that hosts the calling composable while the composable - * is in the composition and its view is attached to a window. This keeps the payment UI out of the Recents snapshot, - * screenshots and screen capture. + * is in the composition and its view is attached to a window and shown. This keeps the payment UI out of the Recents + * snapshot, screenshots and screen capture. */ @Composable internal fun SecureWindowEffect() { @@ -32,27 +32,60 @@ internal fun SecureWindowEffect() { } /** - * Follows the view's attach state, so the flag moves with the view if it is detached or reattached to another - * window while the composition stays alive. + * Follows the view's attach state and visibility, so the flag moves with the view if it is reattached to another + * window, and is released while the view is hidden (for example by `FragmentTransaction.hide()`) even though the + * composition stays alive. */ @MainThread -private class SecureWindowBinding(private val view: View) : View.OnAttachStateChangeListener { +private class SecureWindowBinding( + private val view: View, +) : View.OnAttachStateChangeListener, ViewTreeObserver.OnPreDrawListener { private var target: SecureTarget? = null + private var observer: ViewTreeObserver? = null fun start() { view.addOnAttachStateChangeListener(this) - if (view.isAttachedToWindow) acquire() + if (view.isAttachedToWindow) onViewAttachedToWindow(view) } fun stop() { view.removeOnAttachStateChangeListener(this) + stopObserving() release() } - override fun onViewAttachedToWindow(v: View) = acquire() + override fun onViewAttachedToWindow(v: View) { + startObserving() + if (view.isShown) acquire() + } + + override fun onViewDetachedFromWindow(v: View) { + stopObserving() + release() + } + + override fun onPreDraw(): Boolean { + val shown = view.isShown + if (shown && target == null) { + acquire() + // Skip this one frame if the flag was just taken, so the view isn't drawn before the window is secured. + // If no window could be found, draw anyway so drawing never stalls. + return target == null + } + if (!shown && target != null) release() + return true + } - override fun onViewDetachedFromWindow(v: View) = release() + private fun startObserving() { + stopObserving() + observer = view.viewTreeObserver.also { it.addOnPreDrawListener(this) } + } + + private fun stopObserving() { + observer?.takeIf { it.isAlive }?.removeOnPreDrawListener(this) + observer = null + } private fun acquire() { if (target != null) return @@ -109,17 +142,18 @@ internal interface SecureTarget { fun setSecure(secure: Boolean) /** - * Calls [onFrame] before each frame is drawn. + * Keeps the flag set while the window is held. [onFrame] is called before each frame is drawn, as a fallback that + * sets the flag again if something cleared it. * - * @return a function that stops the callbacks. + * @return a function that stops watching. */ - fun onEachFrame(onFrame: () -> Unit): () -> Unit + fun watch(onFrame: () -> Unit): () -> Unit } private abstract class RootSecureTarget(protected val root: View) : SecureTarget { override val key: Any get() = root - override fun onEachFrame(onFrame: () -> Unit): () -> Unit { + override fun watch(onFrame: () -> Unit): () -> Unit { val listener = ViewTreeObserver.OnPreDrawListener { onFrame() // Never skip a frame: the host may clear the flag on every frame, and skipping would stall drawing. @@ -133,9 +167,25 @@ private abstract class RootSecureTarget(protected val root: View) : SecureTarget } } -/** A window the SDK can reach directly. Updating the [Window] keeps its own copy of the attributes in step. */ +/** + * A window the SDK can reach directly. Updating the [Window] keeps its own copy of the attributes in step, and the + * window callback is wrapped so the flag is added back before any attribute change reaches the window manager. + */ private class WindowSecureTarget(private val window: Window, root: View) : RootSecureTarget(root) { + override fun watch(onFrame: () -> Unit): () -> Unit { + val stopFrames = super.watch(onFrame) + val original = window.callback ?: return stopFrames + val wrapper = SecureWindowCallback(original) + window.callback = wrapper + return { + stopFrames() + wrapper.active = false + // Restore the original only if nothing has wrapped ours since; otherwise ours stays as a pass-through. + if (window.callback === wrapper) window.callback = original + } + } + override fun isSecure(): Boolean = window.attributes.flags.hasSecureFlag() override fun setSecure(secure: Boolean) { @@ -181,14 +231,24 @@ private class RootViewSecureTarget(root: View) : RootSecureTarget(root) { } } +/** Adds [WindowManager.LayoutParams.FLAG_SECURE] to every attribute update before passing it on. */ +private class SecureWindowCallback(private val delegate: Window.Callback) : Window.Callback by delegate { + var active = true + + override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams) { + if (active) attrs.flags = attrs.flags or WindowManager.LayoutParams.FLAG_SECURE + delegate.onWindowAttributesChanged(attrs) + } +} + private fun Int.hasSecureFlag(): Boolean = (this and WindowManager.LayoutParams.FLAG_SECURE) != 0 /** * Reference-counts secure requests per window, so several SDK components can share a window, and clears the flag * only if the SDK set it. * - * While a window is held, the flag is checked before each frame and set again if something else cleared it, for - * example a dialog republishing its attributes or a Compose dialog reapplying its default secure policy. + * While a window is held, the target keeps the flag set if something else clears it, for example a dialog + * republishing its attributes or a Compose dialog reapplying its default secure policy. * * Whether the SDK set the flag is decided when the window is first acquired. If the host app sets the flag itself * while the SDK holds the window, the SDK still clears it on the last release. @@ -208,7 +268,7 @@ internal object SecureWindowRegistry { } val alreadySecure = target.isSecure() if (!alreadySecure) target.setSecure(true) - val stopWatching = target.onEachFrame { + val stopWatching = target.watch { if (!target.isSecure()) target.setSecure(true) } entries[target.key] = Entry(count = 1, setBySdk = !alreadySecure, stopWatching = stopWatching) diff --git a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt index d7f06b313..23387010b 100644 --- a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt +++ b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt @@ -24,7 +24,7 @@ internal class SecureWindowRegistryTest { window.flagSet = secure } - override fun onEachFrame(onFrame: () -> Unit): () -> Unit { + override fun watch(onFrame: () -> Unit): () -> Unit { window.frameCallback = onFrame return { window.frameCallback = null } } From dc19a11eb5da95225e398deb403d874cb39ee13e Mon Sep 17 00:00:00 2001 From: Robert Goheen Date: Tue, 10 Nov 2026 16:10:59 +0000 Subject: [PATCH 4/4] Fix cleanup ordering in window security helper - Make the pre-draw watcher a no-op once stopped, since an in-progress dispatch still calls removed listeners. - Clear the flag through the target that acquired the window. Co-Authored-By: Claude Opus 5.5 --- .../com/checkout/frames/utils/SecureWindow.kt | 20 ++++++++++++---- .../frames/utils/SecureWindowRegistryTest.kt | 23 +++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt index 89f8fafd7..d82854eb8 100644 --- a/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt +++ b/frames/src/main/java/com/checkout/frames/utils/SecureWindow.kt @@ -154,13 +154,16 @@ private abstract class RootSecureTarget(protected val root: View) : SecureTarget override val key: Any get() = root override fun watch(onFrame: () -> Unit): () -> Unit { + var active = true val listener = ViewTreeObserver.OnPreDrawListener { - onFrame() + // A dispatch already in progress still calls a removed listener, so check that it hasn't been stopped. + if (active) onFrame() // Never skip a frame: the host may clear the flag on every frame, and skipping would stall drawing. true } root.viewTreeObserver.addOnPreDrawListener(listener) return { + active = false val observer = root.viewTreeObserver if (observer.isAlive) observer.removeOnPreDrawListener(listener) } @@ -256,7 +259,16 @@ private fun Int.hasSecureFlag(): Boolean = (this and WindowManager.LayoutParams. @MainThread internal object SecureWindowRegistry { - private class Entry(var count: Int, val setBySdk: Boolean, val stopWatching: () -> Unit) + /** + * [target] is the target that acquired the window. It is used for every later update, including the final + * clear, so the flag is cleared through the same path that set it. + */ + private class Entry( + val target: SecureTarget, + var count: Int, + val setBySdk: Boolean, + val stopWatching: () -> Unit, + ) private val entries = HashMap() @@ -271,7 +283,7 @@ internal object SecureWindowRegistry { val stopWatching = target.watch { if (!target.isSecure()) target.setSecure(true) } - entries[target.key] = Entry(count = 1, setBySdk = !alreadySecure, stopWatching = stopWatching) + entries[target.key] = Entry(target, count = 1, setBySdk = !alreadySecure, stopWatching = stopWatching) } fun release(target: SecureTarget) { @@ -280,6 +292,6 @@ internal object SecureWindowRegistry { if (entry.count > 0) return entries.remove(target.key) entry.stopWatching() - if (entry.setBySdk) target.setSecure(false) + if (entry.setBySdk) entry.target.setSecure(false) } } diff --git a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt index 23387010b..00a7ece4d 100644 --- a/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt +++ b/frames/src/test/java/com/checkout/frames/utils/SecureWindowRegistryTest.kt @@ -14,8 +14,10 @@ internal class SecureWindowRegistryTest { var frameCallback: (() -> Unit)? = null } - private class FakeTarget(private val window: FakeWindow) : SecureTarget { - override val key: Any get() = window + private class FakeTarget( + private val window: FakeWindow, + override val key: Any = window, + ) : SecureTarget { override fun isSecure(): Boolean = window.flagSet @@ -70,6 +72,23 @@ internal class SecureWindowRegistryTest { assertEquals(2, window.setCalls) } + @Test + fun `when a different target releases last then the acquiring target clears the flag`() { + val key = Any() + val acquiringWindow = FakeWindow(flagSet = false) + val otherWindow = FakeWindow(flagSet = true) + val acquiring = FakeTarget(acquiringWindow, key) + val other = FakeTarget(otherWindow, key) + + SecureWindowRegistry.acquire(acquiring) + SecureWindowRegistry.acquire(other) + SecureWindowRegistry.release(acquiring) + SecureWindowRegistry.release(other) + + assertFalse(acquiringWindow.flagSet) + assertEquals(0, otherWindow.setCalls) + } + @Test fun `when release is called without acquire then nothing happens`() { val window = FakeWindow(flagSet = false)