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.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

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

## Unreleased

- Fix prices not showing when product/offers are fetched from cache

## 2.8.2

## Enhancements
Expand Down
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() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,11 @@ object ProductItemSerializer : KSerializer<ProductItem> {
?: throw SerializationException("This class can be loaded only by Json")
val jsonObject = jsonInput.decodeJsonElement().jsonObject

// Extract fields using the expected names during deserialization
val name = jsonObject["reference_name"]?.jsonPrimitive?.content ?: ""
// API responses use reference_name; serialized cache entries use product.
val name =
jsonObject["reference_name"]?.jsonPrimitive?.content
?: jsonObject["product"]?.jsonPrimitive?.content
?: ""
val storeProductJsonObject =
jsonObject["store_product"]?.jsonObject
?: throw SerializationException("Missing store_product")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package com.superwall.sdk.paywall.request

import TemplateLogic
import com.superwall.sdk.config.PaywallPreload
import com.superwall.sdk.dependencies.VariablesFactory
import com.superwall.sdk.misc.Either
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.models.config.Config
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.ProductItem
import com.superwall.sdk.network.JsonFactory
import com.superwall.sdk.network.Network
import com.superwall.sdk.paywall.view.webview.templating.models.Variables
import com.superwall.sdk.store.StoreManager
import com.superwall.sdk.store.abstractions.product.StoreProduct
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/** Regression coverage for product reference names lost from cached config in 2.8.2. */
class CachedProductPricesReproductionTest {
private val json = JsonFactory.JSON
private val names = listOf("primary", "secondary", "offer")
private val ids =
listOf("yearly23:yearly23-base:yearly23-trial", "monthly23:monthly23-base:monthly23-trial", "yearly19_3_dc:p1y:introprice")
private val prices = listOf("€39.99", "€4.99", "€23.99")

private fun freshConfig(): Config {
val items =
ids.mapIndexed { index, id ->
val parts = id.split(":")
json.decodeFromString<ProductItem>(
"""{"reference_name":"${names[index]}","sw_composite_product_id":"$id",
"store_product":{"store":"PLAY_STORE","product_identifier":"${parts[0]}",
"base_plan_identifier":"${parts[1]}","offer":{"type":"SPECIFIED","offer_identifier":"${parts[2]}"}}}""",
)
}
val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null)
paywall.productItems = items
return Config.stub().copy(paywalls = listOf(paywall))
}

private fun restoredConfig(fresh: Config): Config =
json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh))

@Test
fun `product reference name survives serialization`() {
val original =
freshConfig()
.paywalls
.single()
.productItems
.last()
val restored = json.decodeFromString(ProductItem.serializer(), json.encodeToString(ProductItem.serializer(), original))
assertEquals(original.fullProductId, restored.fullProductId)
assertEquals("Product cache round-trip must preserve the template reference", "offer", restored.name)
}

@Test
fun `whole config round trip preserves all product references`() {
val restored = restoredConfig(freshConfig())
assertEquals(
ids,
restored.paywalls
.single()
.productItems
.map { it.fullProductId },
)
assertEquals(
names,
restored.paywalls
.single()
.productItems
.map { it.name },
)
}

@Test
fun `fresh config supplies named prices to webview`() =
runTest {
val fresh = freshConfig()
val harness = Harness(fresh)
assertNamedPrices(harness.templates(harness.load()))
}

@Test
fun `same build refresh repairs prices after loading disk cached config`() =
runTest {
val fresh = freshConfig()
val restored = restoredConfig(fresh)
val harness = Harness(restored)
val cached = harness.load()
val before = harness.templates(cached)
val loadedPrices =
before[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray.map {
it.jsonObject.values
.single()
.jsonObject["price"]!!
.jsonPrimitive.content
}
assertEquals("Product prices must be loaded before the config refresh", prices, loadedPrices)
println("Product bindings before refresh: ${before[0]}")
harness.config = fresh
// The same production diff + invalidation used by ConfigState.RefreshConfig in 2.8.2.
val changed = PaywallPreload.changedPaywallIds(restored, fresh)
assertTrue("Fixture must keep the same paywall build", changed.isEmpty())
harness.manager.removeCachedPaywalls(changed)
assertNamedPrices(harness.templates(harness.load()))
}

@Test
fun `281 full request cache reset recovers named prices`() =
runTest {
val fresh = freshConfig()
val harness = Harness(restoredConfig(fresh))
harness.load()
harness.config = fresh
// 2.8.1 invalidated every request entry on config refresh.
harness.manager.resetCache()
assertNamedPrices(harness.templates(harness.load()))
}

private fun assertNamedPrices(templates: JsonArray) {
val productRefs = templates[0].jsonObject["products"]!!.jsonArray.map { it.jsonObject["product"]!!.jsonPrimitive.content }
val variables = templates[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray
assertEquals("WebView product references must be addressable by name", names, productRefs)
names.forEachIndexed { index, name ->
assertEquals(
prices[index],
variables[index]
.jsonObject[name]!!
.jsonObject["price"]!!
.jsonPrimitive.content,
)
}
}

private inner class Harness(
var config: Config,
) {
private val store = StoreManager(mockk(relaxed = true), mockk(relaxed = true), { mockk(relaxed = true) }, track = {})
private val factory =
mockk<PaywallRequestManagerDepFactory> {
every { makeDeviceInfo() } returns mockk { every { locale } returns "en_GB" }
every { makeStaticPaywall(any(), any()) } answers { config.paywalls.single() }
every { activePaywallId() } returns null
every { currentCustomerInfo() } returns CustomerInfo.empty()
}
val manager =
PaywallRequestManager(
store,
mockk<Network>(),
factory,
IOScope(Dispatchers.Unconfined),
track = {},
getGlobalOverrides = { emptyMap() },
trackScope = IOScope(Dispatchers.Unconfined),
)

init {
ids.forEachIndexed { index, id ->
store.cacheProduct(
id,
mockk<StoreProduct>(relaxed = true) {
every { fullIdentifier } returns id
every { attributes } returns mapOf("identifier" to id, "price" to prices[index])
},
)
}
}

suspend fun load(): Paywall {
val result =
manager.getPaywall(
PaywallRequest(
null,
ResponseIdentifiers(config.paywalls.single().identifier),
PaywallRequest.Overrides(null, false),
false,
"register",
0,
),
)
check(result is Either.Success) { "Product request failed: $result" }
assertNull("Prices must load without billing errors", result.value.productsLoadingInfo.failAt)
return result.value
}

suspend fun templates(paywall: Paywall): JsonArray {
val variablesFactory = mockk<VariablesFactory>()
coEvery { variablesFactory.makeJsonVariables(any(), any(), any()) } coAnswers {
Variables(firstArg(), emptyMap(), emptyMap(), emptyMap()).templated()
}
return json.parseToJsonElement(TemplateLogic.getBase64EncodedTemplates(json, paywall, null, variablesFactory) { it }).jsonArray
}
}
}