Skip to content
Merged

2.8.3 #464

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6d7666a
Fix product reference names restored from cached config
ianrumac Sep 7, 2026
dfc1c8e
Document cached product price fix in unreleased changelog
ianrumac Sep 7, 2026
7af3aab
Format cached product price regression tests
ianrumac Sep 7, 2026
e3c3aa6
Update coverage badge [skip ci]
github-actions[bot] Sep 7, 2026
045582d
Merge pull request #458 from superwall/ir/fix/product-name-serde
ianrumac Sep 7, 2026
70c772d
fix: convert a JSON null to null, not to the text "null"
yusuftor Sep 7, 2026
23ed514
Update coverage badge [skip ci]
github-actions[bot] Sep 7, 2026
a08b044
Add `pullfrog.yml` workflow
yusuftor Sep 7, 2026
ddb61c8
Fix web redemption product details and trial handling
ianrumac Sep 7, 2026
fb7b278
Update coverage badge [skip ci]
github-actions[bot] Sep 7, 2026
29ace48
Merge pull request #459 from superwall/fix/entitlement-unknown-fields
ianrumac Sep 8, 2026
e7d9289
Harden web trial redemption lifecycle, timing, and compatibility
ianrumac Sep 8, 2026
5415a6f
Pause/play media playback on resume/pause, avoid preloaded videos pla…
ianrumac Sep 10, 2026
5e19b71
Merge branch 'develop' into ir/fix/media-playback-pause
ianrumac Sep 10, 2026
358cddf
Update coverage badge [skip ci]
github-actions[bot] Sep 10, 2026
2bb598c
Update superwall/src/main/java/com/superwall/sdk/paywall/view/webview…
ianrumac Sep 10, 2026
ffa1a66
Address review: document constructor guard, test detach while resumed
ianrumac Sep 10, 2026
97de13a
Merge pull request #461 from superwall/ir/fix/media-playback-pause
ianrumac Sep 11, 2026
a197f3a
Fix paywall open nd template ordering
ianrumac Sep 11, 2026
bc1395e
Adress PR comments
ianrumac Sep 11, 2026
e0171b8
Update coverage badge [skip ci]
github-actions[bot] Sep 11, 2026
379bc9e
Fix PR comments on tests
ianrumac Sep 11, 2026
568d4a7
Merge branch 'develop' into ir/fix/pw-open-and-template-ordering
ianrumac Sep 11, 2026
a00b7a3
Merge pull request #463 from superwall/ir/fix/pw-open-and-template-or…
ianrumac Sep 11, 2026
cdad9fa
Fix PR comments
ianrumac Sep 11, 2026
9bd2b6b
Merge branch 'develop' into codex/fix-web-redemption-trials
ianrumac Sep 11, 2026
b2969d4
Update coverage badge [skip ci]
github-actions[bot] Sep 11, 2026
2901385
Simplify web trial redemption after the permission-wait bound.
ianrumac Sep 11, 2026
7028c71
Update coverage badge [skip ci]
github-actions[bot] Sep 11, 2026
049eb40
Restore trial-event mutex for overlapping same-code redemptions.
ianrumac Sep 11, 2026
3c7a8e8
Merge pull request #460 from superwall/codex/fix-web-redemption-trials
ianrumac Sep 11, 2026
3d5db14
Version bump
ianrumac Sep 11, 2026
24e602d
Update coverage badge [skip ci]
github-actions[bot] Sep 11, 2026
24a94ea
Update superwall/src/main/java/com/superwall/sdk/paywall/view/webview…
ianrumac Sep 11, 2026
3025a1e
Handle overflowing and offset trial end dates in RedemptionStoreProduct
ianrumac Sep 11, 2026
b3ae2b9
Fix ambiguous super calls in SWWebView onPause/onResume
ianrumac Sep 11, 2026
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.
79 changes: 79 additions & 0 deletions .github/workflows/pullfrog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: Agent prompt
name:
type: string
description: Run name

permissions:
contents: read

jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run agent
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
env:
# add at least one provider API key
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}

# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>

# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: >-
# ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
# VERTEX_LOCATION: global
# VERTEX_MODEL_ID: <vertex-model-id>

# for Azure OpenAI (https://docs.pullfrog.com/azure)
# AZURE_RESOURCE_NAME is the <name> in https://<name>.openai.azure.com
# AZURE_DEPLOYMENT is your deployment's name, not the model it serves
# AZURE_RESOURCE_NAME: my-resource
# AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }}
# AZURE_DEPLOYMENT: <deployment-name>
# both limits are required — set them to the real limits of that model
# AZURE_CONTEXT: "400000"
# AZURE_MAX_OUTPUT: "128000"

# for any OpenAI-compatible endpoint — LiteLLM, Cloudflare AI Gateway,
# self-hosted vLLM (https://docs.pullfrog.com/openai-compatible)
# OPENAI_COMPATIBLE_BASE_URL: https://litellm.example.com/v1
# OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }}
# OPENAI_COMPATIBLE_MODEL: <model-id>
# both limits are required — set them to the real limits of that model
# OPENAI_COMPATIBLE_CONTEXT: "128000"
# OPENAI_COMPATIBLE_MAX_OUTPUT: "16384"
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.

## 2.8.3

## Fixes
- Web purchase redemption now exposes the full checkout product in `didRedeemLink`, tracks `freeTrial_start` once per code, and schedules the active paywall's trial reminders from the checkout timestamp. Notification permission waits no longer block access or drop a late grant; ambiguous or already-elapsed reminders are skipped.
- Fix multi-page paywalls only reporting the entry page view. `paywall_open` now waits for an in-flight `template_variables` send, so the runtime does not treat a late template payload as a fresh load and drop later `page_view`s.
- Fix an active paywall not being reopened after its webview process crashes and is recreated. Recovery cancels template work for the old document and sends the open after the replacement loads, only if the same presentation is still active.
- 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

## Enhancements
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
4 changes: 4 additions & 0 deletions superwall/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ android {
buildConfig = true
}

testOptions {
unitTests.isIncludeAndroidResources = true
}

kotlinOptions {
jvmTarget = "17"
}
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
@@ -0,0 +1,105 @@
package com.superwall.sdk.paywall.view.webview.templating

import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.superwall.sdk.models.config.Config
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.ProductItem
import com.superwall.sdk.models.product.ProductVariable
import com.superwall.sdk.network.JsonFactory
import com.superwall.sdk.paywall.view.webview.templating.models.Variables
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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

/**
* Uses the real config and template serializers and a real emulator WebView.
* The HTML is a minimal price-binding fixture, not the customer's hosted paywall runtime.
*/
@RunWith(AndroidJUnit4::class)
class CachedProductPricesWebViewTest {
private val json = JsonFactory.JSON

@Test
fun freshConfigDisplaysOfferPrice() {
assertEquals("€23.99", renderOfferPrice(cached = false))
}

@Test
fun diskCachedConfigDisplaysOfferPrice() {
// Fails on 2.8.2: price data exists but the "offer" binding is absent.
assertEquals("€23.99", renderOfferPrice(cached = true))
}

private fun renderOfferPrice(cached: Boolean): String {
val item =
json.decodeFromString<ProductItem>(
"""{"reference_name":"offer","store_product":{"store":"PLAY_STORE",
"product_identifier":"yearly19_3_dc","base_plan_identifier":"p1y",
"offer":{"type":"SPECIFIED","offer_identifier":"introprice"}}}""",
)
val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null)
paywall.productItems = listOf(item)
val fresh = Config.stub().copy(paywalls = listOf(paywall))
val config = if (cached) json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh)) else fresh
val product =
config.paywalls
.single()
.productItems
.single()
val variables =
Variables(
listOf(ProductVariable(product.name, mapOf("price" to "€23.99"))),
emptyMap(),
emptyMap(),
emptyMap(),
)
val payload = json.encodeToString(Variables.serializer(), variables)
val instrumentation = InstrumentationRegistry.getInstrumentation()
val finished = CountDownLatch(1)
val output = AtomicReference<String>()
lateinit var webView: WebView
instrumentation.runOnMainSync {
webView = WebView(instrumentation.targetContext)
webView.settings.javaScriptEnabled = true
webView.webViewClient =
object : WebViewClient() {
override fun onPageFinished(
view: WebView,
url: String?,
) {
view.evaluateJavascript("document.getElementById('price').textContent") {
output.set(it)
finished.countDown()
}
}
}
webView.loadDataWithBaseURL(
"https://localhost/",
"""
<html><body><span id="price"></span><script>
const variables = $payload;
const products = Object.assign({}, ...variables.products);
document.getElementById('price').textContent = products.offer?.price ?? '';
</script></body></html>
""".trimIndent(),
"text/html",
"UTF-8",
null,
)
}
try {
assertTrue("WebView did not finish loading", finished.await(20, TimeUnit.SECONDS))
return json.parseToJsonElement(output.get()).jsonPrimitive.content
} finally {
instrumentation.runOnMainSync { webView.destroy() }
}
}
}
Loading
Loading