Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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
Comment thread
AlvaroBrey marked this conversation as resolved.
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()
}
Comment thread
cursor[bot] marked this conversation as resolved.

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -125,6 +125,9 @@ internal fun WebViewComponentView(
},
onMainFrameLoadFailed = { loadFailed = true },
)
installGestureOwnershipProbe(
expectedOrigin = resolvedUrl.toOriginOrNull(),
) { wantsGesture -> this@apply.onContentGestureVerdict(wantsGesture) }
loadUrl(resolvedUrl)
}
}
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
AlvaroBrey marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
} 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)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
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")
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<WebView>(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()) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down