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 @@
-
\ No newline at end of file
+
\ 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 @@
-
\ No newline at end of file
+
\ 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,
+ )
+ }
+}