From a35a2c1711f26722a356069ebe0487b42d55b652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Fri, 24 Jul 2026 09:13:25 +0200 Subject: [PATCH 1/4] feat(RevenueCatUI): arbitrate web_view drag gestures with the paywall scroll PaywallWebView decides, once per gesture, whether the web_view or the paywall scroll owns a drag, via requestDisallowInterceptTouchEvent (the only lever Compose honors). It claims only when the web_view should own the drag (Compose won't hand a gesture back, so no pre-claim/edge-chaining). The content verdict (supplied next) covers inner scrollers/maps; native canScrollVertically/Horizontally covers root scroll and is the sole signal on old WebViews. --- .../components/webview/PaywallWebView.kt | 114 ++++++++++++++++++ .../webview/WebViewComponentView.kt | 2 +- .../webview/DragGestureArbitrationTest.kt | 85 +++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebView.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/DragGestureArbitrationTest.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebView.kt new file mode 100644 index 0000000000..02f894ea12 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebView.kt @@ -0,0 +1,114 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Looper +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.webkit.WebView +import kotlin.math.abs + +/** + * Whether the web_view should claim the drag (else the paywall scroll keeps it). An "own" verdict — an + * inner scroller or `touch-action` map that native can't see — wins; otherwise native root + * scrollability decides. [canScrollVertically]/[canScrollHorizontally] mirror View: `direction > 0` + * = down/end. + */ +@Suppress("ReturnCount", "LongParameterList") +internal fun shouldWebViewOwnGesture( + totalDx: Float, + totalDy: Float, + touchSlop: Int, + webContentWantsGesture: Boolean?, + canScrollHorizontally: (direction: Int) -> Boolean, + canScrollVertically: (direction: Int) -> Boolean, +): Boolean { + if (webContentWantsGesture == true) return true + if (abs(totalDx) < touchSlop && abs(totalDy) < touchSlop) return false + return if (abs(totalDy) >= abs(totalDx)) { + canScrollVertically(if (totalDy < 0) 1 else -1) + } else { + canScrollHorizontally(if (totalDx < 0) 1 else -1) + } +} + +/** + * A [WebView] that claims a drag from the paywall scroll it's embedded in, via + * [android.view.ViewParent.requestDisallowInterceptTouchEvent]. Compose won't hand a gesture back once + * decided, so we never pre-claim and claim exactly once, when [shouldWebViewOwnGesture] says so. + */ +@SuppressLint("ViewConstructor") +internal class PaywallWebView(context: Context) : WebView(context) { + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + // Bound once to avoid allocating a reference on every ACTION_MOVE. + private val canScrollHorizontallyReference: (Int) -> Boolean = ::canScrollHorizontally + private val canScrollVerticallyReference: (Int) -> Boolean = ::canScrollVertically + + private var downX = 0f + private var downY = 0f + private var lastTotalDx = 0f + private var lastTotalDy = 0f + private var webContentWantsGesture: Boolean? = null + private var claimedForWebView = false + + // A verdict can arrive (asynchronously) after the finger is up; only act on it during a gesture. + private var gestureActive = false + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.x + downY = event.y + lastTotalDx = 0f + lastTotalDy = 0f + webContentWantsGesture = null + claimedForWebView = false + gestureActive = true + } + MotionEvent.ACTION_MOVE -> { + lastTotalDx = event.x - downX + lastTotalDy = event.y - downY + claimForWebViewIfNeeded() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> gestureActive = false + } + return super.onTouchEvent(event) + } + + /** + * Records the content's verdict and claims immediately if it owns the gesture. The + * [WebMessageListener][androidx.webkit.WebViewCompat] callback may run off the UI thread, so hop to + * it (inline when already there, to keep the claim within touch-slop). + */ + @JvmSynthetic + fun onContentGestureVerdict(wantsGesture: Boolean) { + if (Looper.myLooper() != Looper.getMainLooper()) { + post { onContentGestureVerdict(wantsGesture) } + return + } + if (!gestureActive) return + webContentWantsGesture = wantsGesture + claimForWebViewIfNeeded() + } + + private fun claimForWebViewIfNeeded() { + if (claimedForWebView) return + val shouldOwn = shouldWebViewOwnGesture( + totalDx = lastTotalDx, + totalDy = lastTotalDy, + touchSlop = touchSlop, + webContentWantsGesture = webContentWantsGesture, + canScrollHorizontally = canScrollHorizontallyReference, + canScrollVertically = canScrollVerticallyReference, + ) + if (shouldOwn) { + claimedForWebView = true + parent?.requestDisallowInterceptTouchEvent(true) + } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 86beb3dfa9..591cd1e29b 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -94,7 +94,7 @@ internal fun WebViewComponentView( if (!loadFailed) { AndroidView( factory = { context -> - WebView(context).apply { + PaywallWebView(context).apply { applyFullSizeLayoutParams() // Must precede attach()/loadUrl: setProfile throws once the WebView has been used. applyPaywallProfile() diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/DragGestureArbitrationTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/DragGestureArbitrationTest.kt new file mode 100644 index 0000000000..d7187c01f1 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/DragGestureArbitrationTest.kt @@ -0,0 +1,85 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +internal class DragGestureArbitrationTest { + + private val slop = 8 + + @Suppress("LongParameterList") + private fun shouldOwn( + dx: Float, + dy: Float, + webContentWantsGesture: Boolean? = null, + canScrollUp: Boolean = false, + canScrollDown: Boolean = false, + canScrollLeft: Boolean = false, + canScrollRight: Boolean = false, + ) = shouldWebViewOwnGesture( + totalDx = dx, + totalDy = dy, + touchSlop = slop, + webContentWantsGesture = webContentWantsGesture, + // direction > 0 = towards the end (right/bottom), < 0 = towards the start (left/top) + canScrollHorizontally = { direction -> if (direction > 0) canScrollRight else canScrollLeft }, + canScrollVertically = { direction -> if (direction > 0) canScrollDown else canScrollUp }, + ) + + @Test + fun `content verdict to own claims immediately, even within touch slop`() { + assertThat(shouldOwn(dx = 0f, dy = 1f, webContentWantsGesture = true)).isTrue() + } + + @Test + fun `content that wants the gesture owns it regardless of native scrollability`() { + assertThat(shouldOwn(dx = 0f, dy = -50f, webContentWantsGesture = true, canScrollDown = false)).isTrue() + } + + @Test + fun `a release verdict still yields to native root scroll when the page can scroll`() { + assertThat(shouldOwn(dx = 0f, dy = -50f, webContentWantsGesture = false, canScrollDown = true)).isTrue() + } + + @Test + fun `a release verdict with nothing to scroll hands off to the paywall`() { + assertThat(shouldOwn(dx = 0f, dy = -50f, webContentWantsGesture = false, canScrollDown = false)).isFalse() + } + + @Test + fun `no verdict yet - native root scroll owns while it can scroll the dragged direction`() { + assertThat(shouldOwn(dx = 0f, dy = -50f, webContentWantsGesture = null, canScrollDown = true)).isTrue() + } + + @Test + fun `no verdict yet - hands off to the paywall when the root cannot scroll`() { + assertThat(shouldOwn(dx = 0f, dy = -50f, webContentWantsGesture = null, canScrollDown = false)).isFalse() + } + + @Test + fun `movement within touch slop does not claim without an own verdict`() { + assertThat(shouldOwn(dx = 7f, dy = -7f, canScrollUp = true)).isFalse() + } + + @Test + fun `dragging up at the bottom edge hands off to the paywall`() { + assertThat(shouldOwn(dx = 0f, dy = -50f, canScrollDown = false)).isFalse() + } + + @Test + fun `dragging down while the root can scroll up is owned by the web view`() { + assertThat(shouldOwn(dx = 0f, dy = 50f, canScrollUp = true)).isTrue() + } + + @Test + fun `horizontal-dominant drag uses horizontal scrollability`() { + assertThat(shouldOwn(dx = -50f, dy = 10f, canScrollRight = true)).isTrue() + assertThat(shouldOwn(dx = -50f, dy = 10f, canScrollRight = false)).isFalse() + } + + @Test + fun `vertical-dominant diagonal drag uses vertical scrollability`() { + assertThat(shouldOwn(dx = 30f, dy = -50f, canScrollDown = true, canScrollLeft = true)).isTrue() + assertThat(shouldOwn(dx = 30f, dy = -50f, canScrollDown = false, canScrollLeft = true)).isFalse() + } +} From 1aab283f9ba7a94d47110d47862c37545d5fc3a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Fri, 24 Jul 2026 09:13:25 +0200 Subject: [PATCH 2/4] feat(RevenueCatUI): resolve web_view drag ownership per gesture from content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injects a web-standard document-start probe that, on each touchstart, reports whether the touched element consumes the drag: an inner overflow scroller, or an element declaring touch-action (a map) — the signals canScrollVertically can't see. Per-element, so a page-global listener can't make the whole view greedy. Reported over the same secure addWebMessageListener channel the bridge uses, and removed on release so a late verdict can't fire during teardown. No-ops on old WebViews. --- .../webview/WebViewComponentView.kt | 4 + .../webview/WebViewGestureOwnershipProbe.kt | 75 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 591cd1e29b..694c4bfdc2 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -125,6 +125,9 @@ internal fun WebViewComponentView( }, onMainFrameLoadFailed = { loadFailed = true }, ) + installGestureOwnershipProbe( + expectedOrigin = resolvedUrl.toOriginOrNull(), + ) { wantsGesture -> this@apply.onContentGestureVerdict(wantsGesture) } loadUrl(resolvedUrl) } } @@ -134,6 +137,7 @@ internal fun WebViewComponentView( val bridge = bridgeHolder.bridge bridgeHolder.bridge = null bridge?.release() + webView.removeGestureOwnershipProbe() webView.stopLoading() webView.webViewClient = WebViewClient() webView.destroy() diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt new file mode 100644 index 0000000000..41c9c50fbd --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt @@ -0,0 +1,75 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.webkit.WebView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger + +private const val GESTURE_PROBE_OBJECT_NAME = "rcPaywallGestureProbe" +private const val VERDICT_OWN = "own" + +// On each touchstart, reports "own" if the touched element or an ancestor scrolls or declares a +// non-default `touch-action` — the per-element signals native canScroll can't see. Passive. +private val GESTURE_PROBE_SCRIPT = + """ + (function () { + var name = '$GESTURE_PROBE_OBJECT_NAME'; + function consumesGesture(el) { + for (var n = el; n && n.nodeType === 1; n = n.parentElement) { + var s = getComputedStyle(n); + if (s.touchAction && s.touchAction !== 'auto' && s.touchAction !== 'manipulation') return true; + if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && n.scrollHeight > n.clientHeight) return true; + if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && n.scrollWidth > n.clientWidth) return true; + } + return false; + } + document.addEventListener('touchstart', function (event) { + try { + window[name].postMessage(consumesGesture(event.target) ? '$VERDICT_OWN' : 'release'); + } catch (e) {} + }, { passive: true, capture: true }); + })(); + """.trimIndent() + +/** + * Best-effort per-gesture probe reporting via [onVerdict] whether the touched content consumes a drag. + * No-op on old WebViews lacking the features (native scrollability arbitrates alone). Content panning + * purely via a non-passive `preventDefault` without a `touch-action` isn't detected. + */ +@Suppress("TooGenericExceptionCaught") +internal fun WebView.installGestureOwnershipProbe( + expectedOrigin: String?, + onVerdict: (wantsGesture: Boolean) -> Unit, +) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return + val allowedOrigins = setOf(expectedOrigin ?: "*") + try { + WebViewCompat.addWebMessageListener( + this, + GESTURE_PROBE_OBJECT_NAME, + allowedOrigins, + ) { _, message, _, isMainFrame, _ -> + if (isMainFrame) onVerdict(message.data == VERDICT_OWN) + } + WebViewCompat.addDocumentStartJavaScript(this, GESTURE_PROBE_SCRIPT, allowedOrigins) + } catch (error: RuntimeException) { + Logger.w( + "Paywalls V2 web_view could not install the gesture-ownership probe; drag arbitration " + + "will use native scrollability only. $error", + ) + } +} + +/** Symmetric teardown for [installGestureOwnershipProbe] so a late verdict can't fire after release. */ +@Suppress("TooGenericExceptionCaught") +internal fun WebView.removeGestureOwnershipProbe() { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return + try { + WebViewCompat.removeWebMessageListener(this, GESTURE_PROBE_OBJECT_NAME) + } catch (error: RuntimeException) { + Logger.d("Paywalls V2 web_view gesture probe listener already removed. $error") + } +} From 8a9c27c2f6dc83834014a5f0eea75635c329ff3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Fri, 24 Jul 2026 09:37:05 +0200 Subject: [PATCH 3/4] fixup! feat(RevenueCatUI): resolve web_view drag ownership per gesture from content --- .../components/webview/WebViewGestureOwnershipProbe.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt index 41c9c50fbd..93cfd18d6c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt @@ -17,7 +17,8 @@ private val GESTURE_PROBE_SCRIPT = (function () { var name = '$GESTURE_PROBE_OBJECT_NAME'; function consumesGesture(el) { - for (var n = el; n && n.nodeType === 1; n = n.parentElement) { + var node = el && el.nodeType === 1 ? el : (el ? el.parentElement : null); + for (var n = node; n && n.nodeType === 1; n = n.parentElement) { var s = getComputedStyle(n); if (s.touchAction && s.touchAction !== 'auto' && s.touchAction !== 'manipulation') return true; if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && n.scrollHeight > n.clientHeight) return true; From ab663d1da4f82dda3fdc9c3ea852f9a75d49586c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Fri, 24 Jul 2026 13:07:37 +0200 Subject: [PATCH 4/4] fix(paywalls): skip origin-scoped web_view scripts on unresolved origin, name JS node-type constant Fail closed instead of falling back to a wildcard origin when the bundle origin can't be resolved, matching shouldBlockWebViewNavigation's existing posture for that case. Also names the JS nodeType magic number. Co-authored-by: Antonio Pallares --- .../webview/WebViewComponentView.kt | 3 +- .../webview/WebViewGestureOwnershipProbe.kt | 10 +-- .../WebViewGestureOwnershipProbeTest.kt | 62 +++++++++++++++++++ .../webview/WebViewTapHighlightTest.kt | 4 +- 4 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbeTest.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 694c4bfdc2..2deadca5be 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -245,11 +245,12 @@ private fun WebView.configure( @Suppress("TooGenericExceptionCaught") internal fun WebView.disableTapHighlight(expectedOrigin: String?) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return + val origin = expectedOrigin ?: return try { WebViewCompat.addDocumentStartJavaScript( this, "document.documentElement.style.webkitTapHighlightColor = 'transparent';", - setOf(expectedOrigin ?: "*"), + setOf(origin), ) } catch (error: RuntimeException) { Logger.w("Failed to disable webkit tap highlight: $error") diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt index 93cfd18d6c..7280711666 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt @@ -16,9 +16,10 @@ private val GESTURE_PROBE_SCRIPT = """ (function () { var name = '$GESTURE_PROBE_OBJECT_NAME'; + var ELEMENT_NODE = Node.ELEMENT_NODE; function consumesGesture(el) { - var node = el && el.nodeType === 1 ? el : (el ? el.parentElement : null); - for (var n = node; n && n.nodeType === 1; n = n.parentElement) { + var node = el && el.nodeType === ELEMENT_NODE ? el : (el ? el.parentElement : null); + for (var n = node; n && n.nodeType === ELEMENT_NODE; n = n.parentElement) { var s = getComputedStyle(n); if (s.touchAction && s.touchAction !== 'auto' && s.touchAction !== 'manipulation') return true; if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && n.scrollHeight > n.clientHeight) return true; @@ -39,14 +40,15 @@ private val GESTURE_PROBE_SCRIPT = * No-op on old WebViews lacking the features (native scrollability arbitrates alone). Content panning * purely via a non-passive `preventDefault` without a `touch-action` isn't detected. */ -@Suppress("TooGenericExceptionCaught") +@Suppress("TooGenericExceptionCaught", "ReturnCount") internal fun WebView.installGestureOwnershipProbe( expectedOrigin: String?, onVerdict: (wantsGesture: Boolean) -> Unit, ) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return - val allowedOrigins = setOf(expectedOrigin ?: "*") + val origin = expectedOrigin ?: return + val allowedOrigins = setOf(origin) try { WebViewCompat.addWebMessageListener( this, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbeTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbeTest.kt new file mode 100644 index 0000000000..fcd42fc4e3 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbeTest.kt @@ -0,0 +1,62 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import android.webkit.WebView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class WebViewGestureOwnershipProbeTest { + + private val webView = mockk(relaxed = true) + + @Before + fun setUp() { + mockkStatic(WebViewFeature::class, WebViewCompat::class) + every { WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) } returns true + every { WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) } returns true + every { WebViewCompat.addWebMessageListener(any(), any(), any(), any()) } returns Unit + every { WebViewCompat.addDocumentStartJavaScript(any(), any(), any()) } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `installs the probe scoped to the bundle origin when supported`() { + webView.installGestureOwnershipProbe("https://bundle.example.com") {} + + verify { + WebViewCompat.addWebMessageListener(webView, any(), setOf("https://bundle.example.com"), any()) + WebViewCompat.addDocumentStartJavaScript(webView, any(), setOf("https://bundle.example.com")) + } + } + + @Test + fun `does nothing when the expected origin is unknown`() { + webView.installGestureOwnershipProbe(null) {} + + verify(exactly = 0) { WebViewCompat.addWebMessageListener(any(), any(), any(), any()) } + verify(exactly = 0) { WebViewCompat.addDocumentStartJavaScript(any(), any(), any()) } + } + + @Test + fun `does nothing when the web message listener feature is unsupported`() { + every { WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) } returns false + + webView.installGestureOwnershipProbe("https://bundle.example.com") {} + + verify(exactly = 0) { WebViewCompat.addWebMessageListener(any(), any(), any(), any()) } + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewTapHighlightTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewTapHighlightTest.kt index c5ea77974e..35344e8b66 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewTapHighlightTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewTapHighlightTest.kt @@ -46,12 +46,12 @@ internal class WebViewTapHighlightTest { } @Test - fun `falls back to any origin when the expected origin is unknown`() { + fun `does nothing when the expected origin is unknown`() { every { WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) } returns true webView.disableTapHighlight(null) - verify { WebViewCompat.addDocumentStartJavaScript(webView, any(), setOf("*")) } + verify(exactly = 0) { WebViewCompat.addDocumentStartJavaScript(any(), any(), any()) } } @Test