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
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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/",
"""
<html><body>
<video id="playing" muted loop playsinline src="$source"></video>
<video id="manual" muted loop playsinline src="$source"></video>
</body></html>
""".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<String>()
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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,7 @@ class SuperwallPaywallActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
val paywallVc = paywallView() ?: return
paywallVc.webView.onResume()
if (isBottomSheetView || isPopupView) {
setTransparentBackground()
}
Expand All @@ -801,6 +802,7 @@ class SuperwallPaywallActivity : AppCompatActivity() {
super.onPause()

val paywallVc = paywallView() ?: return
paywallVc.webView.onPause()
mainScope.launch {
paywallVc.beforeOnDestroy(forceCleanup = isFinishing)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ interface PaywallWebUI {

fun enableBackgroundRendering()

fun onPause()

fun onResume()

fun scrollBy(
x: Int,
y: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -196,6 +242,7 @@ class SWWebView(
}

private val onPageStartedPreloadHook: (WebView) -> Unit = { view ->
updateMediaPlayback()
currentDeviceLocale()?.let { locale ->
view.evaluateJavascript(DevicePreloadScript.build(locale), null)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -574,6 +623,7 @@ class SWWebView(
}

override fun destroy() {
viewDestroyed = true
onScrollChangeListener = null
super.destroy()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
private val view = View(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int, Int>? = null
var lastScrollTo: Pair<Int, Int>? = null
Expand Down
Loading
Loading