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..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 @@ -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() @@ -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() @@ -241,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 new file mode 100644 index 0000000000..7280711666 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt @@ -0,0 +1,78 @@ +@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'; + var ELEMENT_NODE = Node.ELEMENT_NODE; + function consumesGesture(el) { + 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; + 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", "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 origin = expectedOrigin ?: return + val allowedOrigins = setOf(origin) + 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") + } +} 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() + } +} 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