diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 01318ccb1..ed294c4a8 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.5% \ No newline at end of file +branches36.6% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index eb67a0eb4..924227022 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.7% \ No newline at end of file +coverage45.8% \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f68eae7..b37050b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased - Fix prices not showing when product/offers are fetched from cache +- Fix video loading and playing in the background on preloaded paywalls - Fix a JSON null in placement parameters or user attributes reaching audience filters as the text `"null"`, so a filter checking whether a field is null never matched. ## 2.8.2 diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt index 323a7bb2d..69792a526 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt @@ -47,9 +47,7 @@ object UITestHandler { "${it.id}" }.joinToString(separator = ", "), ) - Superwall.instance.setUserAttributes(mapOf("is_user_eligible_for_dd_offer" to true)) Superwall.instance.register(placement = "swtest") - Superwall.instance.setUserAttributes(mapOf("is_user_eligible_for_dd_offer" to null)) }, ), UITestInfo( diff --git a/superwall/src/androidTest/assets/media-playback.mp4 b/superwall/src/androidTest/assets/media-playback.mp4 new file mode 100644 index 000000000..e5c1ccc74 Binary files /dev/null and b/superwall/src/androidTest/assets/media-playback.mp4 differ diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt new file mode 100644 index 000000000..4a35d6a38 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt @@ -0,0 +1,133 @@ +package com.superwall.sdk.paywall.view.webview + +import android.util.Base64 +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** Exercises the injected script against real HTML video elements and Chromium playback. */ +@RunWith(AndroidJUnit4::class) +class MediaPlaybackScriptTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private lateinit var webView: WebView + + @Before + fun setUp() { + val video = instrumentation.context.assets.open("media-playback.mp4").use { it.readBytes() } + val source = "data:video/mp4;base64," + Base64.encodeToString(video, Base64.NO_WRAP) + val loaded = CountDownLatch(1) + instrumentation.runOnMainSync { + webView = WebView(instrumentation.targetContext) + webView.settings.javaScriptEnabled = true + webView.settings.mediaPlaybackRequiresUserGesture = false + webView.webViewClient = + object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + loaded.countDown() + } + } + webView.loadDataWithBaseURL( + "https://localhost/", + """ + + + + + """.trimIndent(), + "text/html", + "UTF-8", + null, + ) + } + assertTrue("Page did not load", loaded.await(10, TimeUnit.SECONDS)) + await("document.getElementById('playing').readyState >= 3") + } + + @After + fun tearDown() { + instrumentation.runOnMainSync { webView.destroy() } + } + + @Test + fun pausesAndResumesOnlyPreviouslyPlayingVideo() { + evaluate("document.getElementById('playing').play()") + await("document.getElementById('playing').currentTime > 0") + evaluate(MediaPlaybackScript.build(false)) + evaluate(MediaPlaybackScript.build(false)) // Repeated lifecycle callbacks must preserve the set. + assertEquals("true", evaluate("document.getElementById('playing').paused")) + val pausedTime = evaluate("document.getElementById('playing').currentTime") + Thread.sleep(150) + assertEquals(pausedTime, evaluate("document.getElementById('playing').currentTime")) + evaluate(MediaPlaybackScript.build(true)) + await("!document.getElementById('playing').paused") + await("document.getElementById('playing').currentTime !== $pausedTime") + assertEquals("true", evaluate("document.getElementById('manual').paused")) + } + + @Test + fun blocksLateAutoplayUntilPresentation() { + evaluate(MediaPlaybackScript.build(false)) + evaluate( + """ + window.late = document.getElementById('playing').cloneNode(); + late.id = 'late'; + late.autoplay = true; + document.body.appendChild(late); + """.trimIndent(), + ) + await("window.__swMediaPlayback.suspended.has(late) && late.paused") + evaluate(MediaPlaybackScript.build(true)) + await("!late.paused && late.currentTime > 0") + } + + @Test + fun doesNotResumeRemovedOrManuallyPausedMedia() { + evaluate(MediaPlaybackScript.build(true)) + evaluate("document.getElementById('playing').play()") + await("document.getElementById('playing').currentTime > 0") + evaluate("document.getElementById('playing').pause()") + evaluate(MediaPlaybackScript.build(false)) + evaluate(MediaPlaybackScript.build(true)) + assertEquals("true", evaluate("document.getElementById('playing').paused")) + + evaluate("document.getElementById('playing').play()") + await("!document.getElementById('playing').paused") + evaluate(MediaPlaybackScript.build(false)) + evaluate("window.removed = document.getElementById('playing'); removed.remove()") + evaluate(MediaPlaybackScript.build(true)) + assertEquals("true", evaluate("removed.paused")) + assertEquals("0", evaluate("window.__swMediaPlayback.suspended.size")) + } + + private fun evaluate(script: String): String { + val done = CountDownLatch(1) + val result = AtomicReference() + instrumentation.runOnMainSync { + webView.evaluateJavascript(script) { + result.set(it) + done.countDown() + } + } + assertTrue("JavaScript callback timed out", done.await(5, TimeUnit.SECONDS)) + return result.get() + } + + private fun await(condition: String) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + if (evaluate(condition) == "true") return + Thread.sleep(50) + } + assertEquals(condition, "true", evaluate(condition)) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 95c7762ad..af5f9602e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -788,6 +788,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { override fun onResume() { super.onResume() val paywallVc = paywallView() ?: return + paywallVc.webView.onResume() if (isBottomSheetView || isPopupView) { setTransparentBackground() } @@ -801,6 +802,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { super.onPause() val paywallVc = paywallView() ?: return + paywallVc.webView.onPause() mainScope.launch { paywallVc.beforeOnDestroy(forceCleanup = isFinishing) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt new file mode 100644 index 000000000..2310bdebd --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt @@ -0,0 +1,41 @@ +package com.superwall.sdk.paywall.view.webview + +/** Keeps hidden paywall media paused without suspending JavaScript needed for preloading. */ +internal object MediaPlaybackScript { + fun build(allowed: Boolean): String = + """ + (() => { + const key = '__swMediaPlayback'; + if (!window[key]) { + const state = { allowed: false, suspended: new Set() }; + state.pause = media => { + if (!media.paused && !media.ended) { + state.suspended.add(media); + media.pause(); + } + }; + // Capture also catches autoplay and media inserted after the initial scan. + document.addEventListener('play', event => { + if (!state.allowed && event.target instanceof HTMLMediaElement) { + state.pause(event.target); + } + }, true); + window[key] = state; + } + const state = window[key]; + state.allowed = $allowed; + if (!state.allowed) { + document.querySelectorAll('video, audio').forEach(state.pause); + } else { + const suspended = Array.from(state.suspended); + state.suspended.clear(); + suspended.forEach(media => { + if (media.isConnected && !media.ended) { + const result = media.play(); + if (result) result.catch(() => {}); + } + }); + } + })(); + """.trimIndent() +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt index 719aa167d..6c1889eb9 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt @@ -14,6 +14,10 @@ interface PaywallWebUI { fun enableBackgroundRendering() + fun onPause() + + fun onResume() + fun scrollBy( x: Int, y: Int, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt index 7760e4e58..6bf9c04a8 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt @@ -181,6 +181,52 @@ class SWWebView( private var lastLoadedUrl: String? = null private var loadRetryCount = 0 + private var hostPaused = false + private var viewDestroyed = false + // View callbacks can run from the superclass constructor, before Kotlin initializers. + // The JVM default (false) keeps them from evaluating JS until this is set to true, so it + // must stay a field with an initializer rather than being inlined. + private var mediaLifecycleReady = true + + private fun updateMediaPlayback() { + if (!mediaLifecycleReady || viewDestroyed) return + val allowed = !hostPaused && isAttachedToWindow && isShown && windowVisibility == View.VISIBLE + evaluateJavascript(MediaPlaybackScript.build(allowed), null) + } + + override fun onPause() { + hostPaused = true + updateMediaPlayback() + super.onPause() + } + + override fun onResume() { + super.onResume() + hostPaused = false + updateMediaPlayback() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // A cached view can be attached to a different host, including an embedded one. + onResume() + } + + override fun onDetachedFromWindow() { + if (!viewDestroyed) evaluateJavascript(MediaPlaybackScript.build(false), null) + super.onDetachedFromWindow() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + updateMediaPlayback() + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + updateMediaPlayback() + } + // The device preload script seeds `window.__SW_DEVICE_PRELOAD__` as soon as // the page starts loading, so translated paywalls render in the device locale // on first paint instead of waiting for the `template_variables` message. The @@ -196,6 +242,7 @@ class SWWebView( } private val onPageStartedPreloadHook: (WebView) -> Unit = { view -> + updateMediaPlayback() currentDeviceLocale()?.let { locale -> view.evaluateJavascript(DevicePreloadScript.build(locale), null) } @@ -459,6 +506,8 @@ class SWWebView( } is WebviewClientEvent.OnPageFinished -> { + // Reinstall after navigation in case the early injection was lost. + updateMediaPlayback() // The client records page-level failures synchronously on the // WebViewClient callback thread, so this can't miss an error // whose async OnError event hasn't been processed yet. @@ -574,6 +623,7 @@ class SWWebView( } override fun destroy() { + viewDestroyed = true onScrollChangeListener = null super.destroy() } diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt index 281f42933..00d40888a 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt @@ -380,6 +380,10 @@ class PaywallMessageHandlerTest { private inner class FakePaywallWebUI( override val messageHandler: PaywallMessageHandler, ) : PaywallWebUI { + override fun onPause() = Unit + + override fun onResume() = Unit + override var delegate: PaywallUIDelegate? = null val evaluateCalls = mutableListOf() private val view = View(context) diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt index d6b265535..125f239c5 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt @@ -517,6 +517,10 @@ class PaywallViewTest { private inner class FakePaywallWebUI( override val messageHandler: PaywallMessageHandler, ) : PaywallWebUI { + override fun onPause() = Unit + + override fun onResume() = Unit + override var delegate: PaywallUIDelegate? = null var lastScrollBy: Pair? = null var lastScrollTo: Pair? = null diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt new file mode 100644 index 000000000..98043f7c0 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt @@ -0,0 +1,82 @@ +package com.superwall.sdk.paywall.view.webview + +import android.app.Activity +import android.os.Looper +import android.view.View +import com.superwall.sdk.config.options.PaywallOptions +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +class SWWebViewMediaLifecycleTest { + @Test + fun `media follows host pause visibility and cached reattachment`() { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + val activity = controller.get() + val webView = SWWebView(activity, mockk(relaxed = true), options = { PaywallOptions() }) + try { + // A preload must remain paused even if its host resumes. + webView.onResume() + assertMediaAllowed(webView, false) + + activity.setContentView(webView) + setWindowVisibility(webView, View.VISIBLE) + assertMediaAllowed(webView, true) + webView.onPause() + assertMediaAllowed(webView, false) + webView.onResume() + assertMediaAllowed(webView, true) + + webView.visibility = View.GONE + assertMediaAllowed(webView, false) + webView.visibility = View.VISIBLE + assertMediaAllowed(webView, true) + + setWindowVisibility(webView, View.GONE) + assertMediaAllowed(webView, false) + setWindowVisibility(webView, View.VISIBLE) + assertMediaAllowed(webView, true) + + // Detaching dispatches window visibility before the window state changes, so a + // resumed view must still end paused. + activity.setContentView(View(activity)) + assertMediaAllowed(webView, false) + activity.setContentView(webView) + assertMediaAllowed(webView, true) + + webView.onPause() + activity.setContentView(View(activity)) + assertMediaAllowed(webView, false) + // Reusing a dismissed paywall in another host must clear the old pause state. + activity.setContentView(webView) + assertMediaAllowed(webView, true) + } finally { + controller.pause().stop().destroy() + webView.destroy() + } + } + + private fun setWindowVisibility(webView: SWWebView, visibility: Int) { + shadowOf(Looper.getMainLooper()).idle() + // Robolectric attaches the view but leaves the window GONE. Emulate WindowManager's + // visibility update before dispatching the same callback Android uses. + val attachInfo = ReflectionHelpers.getField(webView, "mAttachInfo") + ReflectionHelpers.setField(attachInfo, "mWindowVisibility", visibility) + webView.dispatchWindowVisibilityChanged(visibility) + } + + private fun assertMediaAllowed(webView: SWWebView, allowed: Boolean) { + shadowOf(Looper.getMainLooper()).idle() + assertEquals( + "attached=${webView.isAttachedToWindow}, shown=${webView.isShown}, window=${webView.windowVisibility}", + MediaPlaybackScript.build(allowed), + shadowOf(webView).lastEvaluatedJavascript, + ) + } +}