-
Notifications
You must be signed in to change notification settings - Fork 108
feat(RevenueCatUI): arbitrate web_view drag gestures with the paywall scroll #3823
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a35a2c1
feat(RevenueCatUI): arbitrate web_view drag gestures with the paywall…
AlvaroBrey 1aab283
feat(RevenueCatUI): resolve web_view drag ownership per gesture from …
AlvaroBrey 8a9c27c
fixup! feat(RevenueCatUI): resolve web_view drag ownership per gestur…
AlvaroBrey ab663d1
fix(paywalls): skip origin-scoped web_view scripts on unresolved orig…
AlvaroBrey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
114 changes: 114 additions & 0 deletions
114
...main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/PaywallWebView.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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() | ||
| } | ||
|
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) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
...m/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbe.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
|
AlvaroBrey marked this conversation as resolved.
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) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
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") | ||
| } | ||
| } | ||
85 changes: 85 additions & 0 deletions
85
...com/revenuecat/purchases/ui/revenuecatui/components/webview/DragGestureArbitrationTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
| } |
62 changes: 62 additions & 0 deletions
62
...venuecat/purchases/ui/revenuecatui/components/webview/WebViewGestureOwnershipProbeTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.