diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt index 92f0832315..56e64c0a72 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/Offering.kt @@ -7,6 +7,7 @@ package com.revenuecat.purchases import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData +import dev.drewhamilton.poko.Poko /** * An offering is a collection of [Package] available for the user to purchase. @@ -27,8 +28,14 @@ constructor( val availablePackages: List, val paywall: PaywallData? = null, @InternalRevenueCatAPI - val paywallComponents: PaywallComponentsData? = null, + val paywallComponents: PaywallComponents? = null, ) { + @InternalRevenueCatAPI + @Poko + class PaywallComponents( + val uiConfig: UiConfig, + val data: PaywallComponentsData, + ) /** * Lifetime package type configured in the RevenueCat dashboard, if available. diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/UiConfig.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/UiConfig.kt new file mode 100644 index 0000000000..3527059f0f --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/UiConfig.kt @@ -0,0 +1,79 @@ +package com.revenuecat.purchases + +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey +import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import dev.drewhamilton.poko.Poko +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@InternalRevenueCatAPI +@Serializable +@JvmInline +value class ColorAlias(@get:JvmSynthetic val value: String) + +@InternalRevenueCatAPI +@Serializable +@JvmInline +value class FontAlias(@get:JvmSynthetic val value: String) + +@InternalRevenueCatAPI +@Poko +@Serializable +class UiConfig( + @get:JvmSynthetic + val app: AppConfig = AppConfig(), + @get:JvmSynthetic + val localizations: Map> = emptyMap(), + @SerialName("variable_config") + @get:JvmSynthetic + val variableConfig: VariableConfig = VariableConfig(), +) { + + @InternalRevenueCatAPI + @Poko + @Serializable + class AppConfig( + @get:JvmSynthetic + val colors: Map = emptyMap(), + @get:JvmSynthetic + val fonts: Map = emptyMap(), + ) { + @InternalRevenueCatAPI + @Poko + @Serializable + class FontsConfig( + @get:JvmSynthetic + val android: FontInfo, + ) { + + @InternalRevenueCatAPI + @Serializable + sealed interface FontInfo { + @InternalRevenueCatAPI + @Poko + @Serializable + @SerialName("name") + class Name(@get:JvmSynthetic val value: String) : FontInfo + + @InternalRevenueCatAPI + @Poko + @Serializable + @SerialName("google_fonts") + class GoogleFonts(@get:JvmSynthetic val value: String) : FontInfo + } + } + } + + @InternalRevenueCatAPI + @Poko + @Serializable + class VariableConfig( + @SerialName("variable_compatibility_map") + @get:JvmSynthetic + val variableCompatibilityMap: Map = emptyMap(), + @SerialName("function_compatibility_map") + @get:JvmSynthetic + val functionCompatibilityMap: Map = emptyMap(), + ) +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/OfferingParser.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/OfferingParser.kt index 6c7d0e99f6..ee492c85fc 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/OfferingParser.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/OfferingParser.kt @@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.Package import com.revenuecat.purchases.PackageType import com.revenuecat.purchases.PresentedOfferingContext +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.api.BuildConfig import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.paywalls.PaywallData @@ -38,16 +39,29 @@ internal abstract class OfferingParser { /** * Note: this may return an empty Offerings. */ + @OptIn(InternalRevenueCatAPI::class) fun createOfferings(offeringsJson: JSONObject, productsById: Map>): Offerings { log(LogIntent.DEBUG, OfferingStrings.BUILDING_OFFERINGS.format(productsById.size)) val jsonOfferings = offeringsJson.getJSONArray("offerings") val currentOfferingID = offeringsJson.getString("current_offering_id") + val uiConfigJson = offeringsJson.optJSONObject("ui_config") + + @Suppress("TooGenericExceptionCaught") + val uiConfig: UiConfig? = uiConfigJson?.let { + try { + json.decodeFromString(it.toString()) + } catch (e: Throwable) { + errorLog("Error deserializing ui_config", e) + null + } + } + val offerings = mutableMapOf() for (i in 0 until jsonOfferings.length()) { val offeringJson = jsonOfferings.getJSONObject(i) - createOffering(offeringJson, productsById)?.let { + createOffering(offeringJson, productsById, uiConfig)?.let { offerings[it.identifier] = it if (it.availablePackages.isEmpty()) { @@ -94,7 +108,11 @@ internal abstract class OfferingParser { @OptIn(InternalRevenueCatAPI::class) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun createOffering(offeringJson: JSONObject, productsById: Map>): Offering? { + fun createOffering( + offeringJson: JSONObject, + productsById: Map>, + uiConfig: UiConfig?, + ): Offering? { val offeringIdentifier = offeringJson.getString("identifier") val metadata = offeringJson.optJSONObject("metadata")?.toMap(deep = true) ?: emptyMap() val jsonPackages = offeringJson.getJSONArray("packages") @@ -135,6 +153,12 @@ internal abstract class OfferingParser { null } + val paywallComponents = if (paywallComponentsData != null && uiConfig != null) { + Offering.PaywallComponents(uiConfig, paywallComponentsData) + } else { + null + } + return if (availablePackages.isNotEmpty()) { Offering( offeringIdentifier, @@ -142,7 +166,7 @@ internal abstract class OfferingParser { metadata, availablePackages, paywallData, - paywallComponentsData, + paywallComponents, ) } else { null diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/Localization.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/Localization.kt index b9b5b1745d..2711d08cb5 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/Localization.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/Localization.kt @@ -5,6 +5,7 @@ import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.descriptors.PolymorphicKind @@ -64,3 +65,134 @@ private object LocalizationDataSerializer : KSerializer { decoder.decodeSerializableValue(LocalizationData.Image.serializer()) } } + +/** + * Keys for localized strings used as variable values. + */ +@InternalRevenueCatAPI +@Serializable +enum class VariableLocalizationKey { + + @SerialName("day") + DAY, + + @SerialName("daily") + DAILY, + + @SerialName("day_short") + DAY_SHORT, + + @SerialName("week") + WEEK, + + @SerialName("weekly") + WEEKLY, + + @SerialName("week_short") + WEEK_SHORT, + + @SerialName("month") + MONTH, + + @SerialName("monthly") + MONTHLY, + + @SerialName("month_short") + MONTH_SHORT, + + @SerialName("year") + YEAR, + + @SerialName("yearly") + YEARLY, + + @SerialName("year_short") + YEAR_SHORT, + + @SerialName("annual") + ANNUAL, + + @SerialName("annually") + ANNUALLY, + + @SerialName("annual_short") + ANNUAL_SHORT, + + @SerialName("free_price") + FREE_PRICE, + + @SerialName("percent") + PERCENT, + + @SerialName("num_day_zero") + NUM_DAY_ZERO, + + @SerialName("num_day_one") + NUM_DAY_ONE, + + @SerialName("num_day_two") + NUM_DAY_TWO, + + @SerialName("num_day_few") + NUM_DAY_FEW, + + @SerialName("num_day_many") + NUM_DAY_MANY, + + @SerialName("num_day_other") + NUM_DAY_OTHER, + + @SerialName("num_week_zero") + NUM_WEEK_ZERO, + + @SerialName("num_week_one") + NUM_WEEK_ONE, + + @SerialName("num_week_two") + NUM_WEEK_TWO, + + @SerialName("num_week_few") + NUM_WEEK_FEW, + + @SerialName("num_week_many") + NUM_WEEK_MANY, + + @SerialName("num_week_other") + NUM_WEEK_OTHER, + + @SerialName("num_month_zero") + NUM_MONTH_ZERO, + + @SerialName("num_month_one") + NUM_MONTH_ONE, + + @SerialName("num_month_two") + NUM_MONTH_TWO, + + @SerialName("num_month_few") + NUM_MONTH_FEW, + + @SerialName("num_month_many") + NUM_MONTH_MANY, + + @SerialName("num_month_other") + NUM_MONTH_OTHER, + + @SerialName("num_year_zero") + NUM_YEAR_ZERO, + + @SerialName("num_year_one") + NUM_YEAR_ONE, + + @SerialName("num_year_two") + NUM_YEAR_TWO, + + @SerialName("num_year_few") + NUM_YEAR_FEW, + + @SerialName("num_year_many") + NUM_YEAR_MANY, + + @SerialName("num_year_other") + NUM_YEAR_OTHER, +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt index 5bd4298f4c..e98ef572dd 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/OfferingsTest.kt @@ -7,14 +7,20 @@ package com.revenuecat.purchases import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.billingclient.api.ProductDetails +import com.revenuecat.purchases.UiConfig.AppConfig.FontsConfig.FontInfo import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey +import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.parseRGBAColor import com.revenuecat.purchases.utils.getLifetimePackageJSON import com.revenuecat.purchases.utils.stubINAPPStoreProduct import com.revenuecat.purchases.utils.stubPricingPhase import com.revenuecat.purchases.utils.stubStoreProduct import com.revenuecat.purchases.utils.stubSubscriptionOption import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail import org.json.JSONArray import org.json.JSONObject import org.junit.Test @@ -216,7 +222,7 @@ class OfferingsTest { val products = mapOf(productId to listOf(storeProductAnnual)) val offeringWithOneMonthlyPackageJson = getOfferingJSON() - val offering = offeringsParser.createOffering(offeringWithOneMonthlyPackageJson, products) + val offering = offeringsParser.createOffering(offeringWithOneMonthlyPackageJson, products, null) assertThat(offering).isNull() } @@ -265,7 +271,7 @@ class OfferingsTest { metadata = metadata ) - val offering = offeringsParser.createOffering(offeringJSON, products) + val offering = offeringsParser.createOffering(offeringJSON, products, null) assertThat(offering).isNotNull assertThat(offering!!.identifier).isEqualTo(offeringId) assertThat(offering!!.metadata).isEqualTo(metadata) @@ -553,6 +559,42 @@ class OfferingsTest { assertThat(offerings.targeting.ruleId).isEqualTo("abc123") } + @Test + fun `createOfferings creates UiConfig object`() { + // Arrange + val storeProductMonthly = getStoreProduct(productIdentifier, monthlyPeriod, monthlyBasePlanId) + val storeProductAnnual = getStoreProduct(productIdentifier, annualPeriod, annualBasePlanId) + val products = mapOf(productIdentifier to listOf(storeProductMonthly, storeProductAnnual)) + val uiConfigJson = getUiConfigJson( + colors = mapOf("primary" to "#ff00ff"), + fonts = mapOf("primary" to FontInfo.Name("Roboto")), + localizations = mapOf("en_US" to mapOf(VariableLocalizationKey.MONTHLY to "monthly")), + variableCompatibilityMap = mapOf("new var" to "guaranteed var"), + functionCompatibilityMap = mapOf("new fun" to "guaranteed fun") + ) + val offeringJson = getOfferingJSON(paywallComponents = getPaywallComponentsDataJson()) + val offeringsJson = getOfferingsJSON(offerings = JSONArray(listOf(offeringJson)), uiConfig = uiConfigJson) + + // Act + val offerings = offeringsParser.createOfferings(offeringsJson, products) + + // Assert + assertThat(offerings).isNotNull + assertThat(offerings.all.size).isEqualTo(1) + val offering = offerings.all.values.first() + + val paywallComponents = offering.paywallComponents ?: fail("paywallComponents is null") + val uiConfig = paywallComponents.uiConfig + val colorInfo = uiConfig.app.colors[ColorAlias("primary")]!!.light as ColorInfo.Hex + assertThat(colorInfo.value).isEqualTo(parseRGBAColor("#ff00ff")) + val fontInfo = uiConfig.app.fonts[FontAlias("primary")]!!.android as FontInfo.Name + assertThat(fontInfo.value).isEqualTo("Roboto") + assertThat(uiConfig.localizations[LocaleId("en_US")]!![VariableLocalizationKey.MONTHLY]) + .isEqualTo("monthly") + assertThat(uiConfig.variableConfig.variableCompatibilityMap["new var"]).isEqualTo("guaranteed var") + assertThat(uiConfig.variableConfig.functionCompatibilityMap["new fun"]).isEqualTo("guaranteed fun") + } + private fun testPackageType(packageType: PackageType) { var identifier = packageType.identifier if (identifier == null) { @@ -630,6 +672,7 @@ class OfferingsTest { ), placements: JSONObject? = null, targeting: JSONObject? = null, + uiConfig: JSONObject? = null, ): JSONObject { val offeringJsons = mutableListOf() offeringPackagesById.forEach { (offeringId, packages) -> @@ -644,13 +687,29 @@ class OfferingsTest { val offeringsJsonArray = JSONArray(offeringJsons) - return JSONObject().apply { - put("offerings", offeringsJsonArray) + return getOfferingsJSON( + offerings = offeringsJsonArray, + currentOfferingId = currentOfferingId, + placements = placements, + targeting = targeting, + uiConfig = uiConfig, + ) + } + + private fun getOfferingsJSON( + offerings: JSONArray, + currentOfferingId: String = "offering_a", + placements: JSONObject? = null, + targeting: JSONObject? = null, + uiConfig: JSONObject? = null, + ): JSONObject = + JSONObject().apply { + put("offerings", offerings) put("current_offering_id", currentOfferingId) placements?.let { put("placements", placements) } targeting?.let { put("targeting", placements) } + uiConfig?.let { put("ui_config", uiConfig) } } - } private fun getPlacementsJSON( fallbackOfferingId: String?, @@ -676,6 +735,133 @@ class OfferingsTest { } } + /** + * @param colors Color alias (e.g. "primary") to hex color. In reality we support an entire ColorScheme. + * @param fonts Font alias (e.g. "primary") to FontInfo. + * @param localizations LocaleId (e.g. "en_US") to a map of VariableLocalizationKey to its localized value. + * @param variableCompatibilityMap Map of new variables to guaranteed-to-be-available variables. + * @param functionCompatibilityMap Map of new functions to guaranteed-to-be-available functions. + */ + private fun getUiConfigJson( + colors: Map, + fonts: Map, + localizations: Map>, + variableCompatibilityMap: Map = emptyMap(), + functionCompatibilityMap: Map = emptyMap(), + ): JSONObject { + return JSONObject().apply { + put( + "app", + JSONObject().apply { + put( + "colors", + JSONObject().apply { + colors.forEach { (colorAlias, color) -> + put( + colorAlias, + JSONObject().apply { + put( + "light", + JSONObject().apply { + put("type", "hex") + put("value", color) + } + ) + } + ) + } + } + ) + put( + "fonts", + JSONObject().apply { + fonts.forEach { (fontAlias, fontInfo) -> + put( + fontAlias, + JSONObject().apply { + val fontInfoJson = JSONObject().apply { + when (fontInfo) { + is FontInfo.Name -> { + put("type", "name") + put("value", fontInfo.value) + } + + is FontInfo.GoogleFonts -> { + put("type", "google_fonts") + put("value", fontInfo.value) + } + } + } + put("android", fontInfoJson) + } + ) + } + } + ) + } + ) + put( + "localizations", + JSONObject().apply { + localizations.forEach { (localeId, variableLocalizations) -> + put( + localeId, + JSONObject().apply { + variableLocalizations.forEach { (key, value) -> put(key.name.lowercase(), value) } + } + ) + } + } + ) + put( + "variable_config", + JSONObject().apply { + put( + "variable_compatibility_map", + JSONObject().apply { variableCompatibilityMap.forEach { (key, value) -> put(key, value) } } + ) + put( + "function_compatibility_map", + JSONObject().apply { functionCompatibilityMap.forEach { (key, value) -> put(key, value) } } + ) + } + ) + } + } + + private fun getPaywallComponentsDataJson() = JSONObject( + // language=json + """ + { + "template_name": "components", + "asset_base_url": "https://assets.pawwalls.com", + "components_config": { + "base": { + "stack": { + "type": "stack", + "components": [] + }, + "background": { + "type": "color", + "value": { + "light": { + "type": "alias", + "value": "primary" + } + } + } + } + }, + "components_localizations": { + "en_US": { + "ZvS4Ck5hGM": "Hello" + } + }, + "default_locale": "en_US" + } + """.trimIndent() + ) + private fun getOfferingJSON( offeringIdentifier: String = "offering_a", packagesJSON: List = listOf( @@ -685,17 +871,15 @@ class OfferingsTest { monthlyBasePlanId ), ), - metadata: Map? = null - ) = JSONObject( - """ - { - 'description': 'This is the base offering', - 'identifier': '$offeringIdentifier', - 'packages': $packagesJSON, - 'metadata': ${if (metadata != null) JSONObject(metadata).toString() else "null"} - } - """.trimIndent() - ) + metadata: Map? = null, + paywallComponents: JSONObject? = null, + ) = JSONObject().apply { + put("description", "This is the base offering") + put("identifier", offeringIdentifier) + put("packages", JSONArray(packagesJSON)) + if (metadata != null) put("metadata", JSONObject(metadata)) else put("metadata", "null") + if (paywallComponents != null) put("paywall_components", paywallComponents) + } private fun getOfferingJSONWithoutMetadata( offeringIdentifier: String = "offering_a", diff --git a/purchases/src/test/java/com/revenuecat/purchases/UiConfigTests.kt b/purchases/src/test/java/com/revenuecat/purchases/UiConfigTests.kt new file mode 100644 index 0000000000..e3cc610eec --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/UiConfigTests.kt @@ -0,0 +1,177 @@ +package com.revenuecat.purchases + +import com.revenuecat.purchases.common.OfferingParser +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey +import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import com.revenuecat.purchases.paywalls.parseRGBAColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +internal class UiConfigTests { + + @Test + fun `Should properly deserialize UiConfig`() { + // Arrange + // language=json + val serialized = """ + { + "app": { + "colors": { + "primary": { + "light": { + "type": "hex", + "value": "#ffcc00" + } + }, + "secondary": { + "light": { + "type": "linear", + "degrees": 45, + "points": [ + { + "color": "#032400ff", + "percent": 0 + }, + { + "color": "#090979ff", + "percent": 35 + }, + { + "color": "#216c32ff", + "percent": 100 + } + ] + } + }, + "tertiary": { + "light": { + "type": "radial", + "points": [ + { + "color": "#032400ff", + "percent": 0 + }, + { + "color": "#090979ff", + "percent": 35 + }, + { + "color": "#216c32ff", + "percent": 100 + } + ] + } + } + }, + "fonts": { + "primary": { + "ios": { + "type": "name", + "value": "SF Pro" + }, + "android": { + "type": "name", + "value": "Roboto" + }, + "web": { + "type": "google_fonts", + "value": "Gothic" + } + } + } + }, + "localizations": { + "en_US": { + "monthly": "monthly" + }, + "es_ES": { + "monthly": "mensual" + } + }, + "variable_config": { + "variable_compatibility_map": { + "new var": "guaranteed var" + }, + "function_compatibility_map": { + "new fun": "guaranteed fun" + } + } + } + """.trimIndent() + val expected = UiConfig( + app = UiConfig.AppConfig( + colors = mapOf( + ColorAlias("primary") to ColorScheme( + light = ColorInfo.Hex(parseRGBAColor("#ffcc00")), + ), + ColorAlias("secondary") to ColorScheme( + light = ColorInfo.Gradient.Linear( + degrees = 45f, + points = listOf( + ColorInfo.Gradient.Point( + color = parseRGBAColor("#032400ff"), + percent = 0f, + ), + ColorInfo.Gradient.Point( + color = parseRGBAColor("#090979ff"), + percent = 35f, + ), + ColorInfo.Gradient.Point( + color = parseRGBAColor("#216c32ff"), + percent = 100f, + ) + ) + ) + ), + ColorAlias("tertiary") to ColorScheme( + light = ColorInfo.Gradient.Radial( + points = listOf( + ColorInfo.Gradient.Point( + color = parseRGBAColor("#032400ff"), + percent = 0f, + ), + ColorInfo.Gradient.Point( + color = parseRGBAColor("#090979ff"), + percent = 35f, + ), + ColorInfo.Gradient.Point( + color = parseRGBAColor("#216c32ff"), + percent = 100f, + ) + ) + ) + ) + ), + fonts = mapOf( + FontAlias("primary") to UiConfig.AppConfig.FontsConfig( + android = UiConfig.AppConfig.FontsConfig.FontInfo.Name("Roboto"), + ) + ) + ), + localizations = mapOf( + LocaleId("en_US") to mapOf( + VariableLocalizationKey.MONTHLY to "monthly" + ), + LocaleId("es_ES") to mapOf( + VariableLocalizationKey.MONTHLY to "mensual" + ) + ), + variableConfig = UiConfig.VariableConfig( + variableCompatibilityMap = mapOf( + "new var" to "guaranteed var" + ), + functionCompatibilityMap = mapOf( + "new fun" to "guaranteed fun" + ) + ) + ) + + // Act + val actual = OfferingParser.json.decodeFromString(serialized) + + // Assert + assertThat(actual).isEqualTo(expected) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt index ff5052a3b7..767425c12c 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonOfferingsTest.kt @@ -165,7 +165,7 @@ class AmazonOfferingsTest { val products = mapOf(productId to listOf(storeProductAnnual)) val offeringWithOneMonthlyPackageJson = getAmazonOfferingJSON() - val offering = offeringsParser.createOffering(offeringWithOneMonthlyPackageJson, products) + val offering = offeringsParser.createOffering(offeringWithOneMonthlyPackageJson, products, null) Assertions.assertThat(offering).isNull() } @@ -193,7 +193,7 @@ class AmazonOfferingsTest { packagesJSON = listOf(monthlyPackageJSON, annualPackageJSON) ) - val offering = offeringsParser.createOffering(offeringJSON, products) + val offering = offeringsParser.createOffering(offeringJSON, products, null) Assertions.assertThat(offering).isNotNull Assertions.assertThat(offering!!.identifier).isEqualTo(offeringId) diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/VariableLocalizationKeyTests.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/VariableLocalizationKeyTests.kt new file mode 100644 index 0000000000..a70633531d --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/VariableLocalizationKeyTests.kt @@ -0,0 +1,75 @@ +package com.revenuecat.purchases.paywalls.components.common + +import com.revenuecat.purchases.common.OfferingParser +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +internal class VariableLocalizationKeyTests( + private val serialized: String, + private val expected: VariableLocalizationKey, +) { + + companion object { + @Suppress("LongMethod") + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun parameters(): Collection<*> = VariableLocalizationKey.values().map { expected -> + val serialized = when (expected) { + VariableLocalizationKey.DAY -> "\"day\"" + VariableLocalizationKey.DAILY -> "\"daily\"" + VariableLocalizationKey.DAY_SHORT -> "\"day_short\"" + VariableLocalizationKey.WEEK -> "\"week\"" + VariableLocalizationKey.WEEKLY -> "\"weekly\"" + VariableLocalizationKey.WEEK_SHORT -> "\"week_short\"" + VariableLocalizationKey.MONTH -> "\"month\"" + VariableLocalizationKey.MONTHLY -> "\"monthly\"" + VariableLocalizationKey.MONTH_SHORT -> "\"month_short\"" + VariableLocalizationKey.YEAR -> "\"year\"" + VariableLocalizationKey.YEARLY -> "\"yearly\"" + VariableLocalizationKey.YEAR_SHORT -> "\"year_short\"" + VariableLocalizationKey.ANNUAL -> "\"annual\"" + VariableLocalizationKey.ANNUALLY -> "\"annually\"" + VariableLocalizationKey.ANNUAL_SHORT -> "\"annual_short\"" + VariableLocalizationKey.FREE_PRICE -> "\"free_price\"" + VariableLocalizationKey.PERCENT -> "\"percent\"" + VariableLocalizationKey.NUM_DAY_ZERO -> "\"num_day_zero\"" + VariableLocalizationKey.NUM_DAY_ONE -> "\"num_day_one\"" + VariableLocalizationKey.NUM_DAY_TWO -> "\"num_day_two\"" + VariableLocalizationKey.NUM_DAY_FEW -> "\"num_day_few\"" + VariableLocalizationKey.NUM_DAY_MANY -> "\"num_day_many\"" + VariableLocalizationKey.NUM_DAY_OTHER -> "\"num_day_other\"" + VariableLocalizationKey.NUM_WEEK_ZERO -> "\"num_week_zero\"" + VariableLocalizationKey.NUM_WEEK_ONE -> "\"num_week_one\"" + VariableLocalizationKey.NUM_WEEK_TWO -> "\"num_week_two\"" + VariableLocalizationKey.NUM_WEEK_FEW -> "\"num_week_few\"" + VariableLocalizationKey.NUM_WEEK_MANY -> "\"num_week_many\"" + VariableLocalizationKey.NUM_WEEK_OTHER -> "\"num_week_other\"" + VariableLocalizationKey.NUM_MONTH_ZERO -> "\"num_month_zero\"" + VariableLocalizationKey.NUM_MONTH_ONE -> "\"num_month_one\"" + VariableLocalizationKey.NUM_MONTH_TWO -> "\"num_month_two\"" + VariableLocalizationKey.NUM_MONTH_FEW -> "\"num_month_few\"" + VariableLocalizationKey.NUM_MONTH_MANY -> "\"num_month_many\"" + VariableLocalizationKey.NUM_MONTH_OTHER -> "\"num_month_other\"" + VariableLocalizationKey.NUM_YEAR_ZERO -> "\"num_year_zero\"" + VariableLocalizationKey.NUM_YEAR_ONE -> "\"num_year_one\"" + VariableLocalizationKey.NUM_YEAR_TWO -> "\"num_year_two\"" + VariableLocalizationKey.NUM_YEAR_FEW -> "\"num_year_few\"" + VariableLocalizationKey.NUM_YEAR_MANY -> "\"num_year_many\"" + VariableLocalizationKey.NUM_YEAR_OTHER -> "\"num_year_other\"" + } + arrayOf(serialized, expected) + } + } + + @Test + fun `Should properly deserialize VariableLocalizationKey`() { + // Arrange, Act + val actual = OfferingParser.json.decodeFromString(serialized) + + // Assert + assert(actual == expected) + } + +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt index 26e7dce603..0ad22215e8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent import com.revenuecat.purchases.paywalls.components.TextComponent @@ -156,7 +157,7 @@ private fun LoadedPaywallComponents_Preview() { serverDescription = "description", metadata = emptyMap(), availablePackages = emptyList(), - paywallComponents = data, + paywallComponents = Offering.PaywallComponents(UiConfig(), data), ) val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! val state = offering.toComponentsPaywallState( @@ -335,7 +336,7 @@ private fun LoadedPaywallComponents_Preview_Bless() { serverDescription = "description", metadata = emptyMap(), availablePackages = emptyList(), - paywallComponents = data, + paywallComponents = Offering.PaywallComponents(UiConfig(), data), ) val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! val state = offering.toComponentsPaywallState( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt index a24ef18d5a..5dca8f4509 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -164,7 +165,7 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { serverDescription = "serverDescription", metadata = emptyMap(), availablePackages = emptyList(), - paywallComponents = data, + paywallComponents = Offering.PaywallComponents(UiConfig(), data), ) val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! return offering.toComponentsPaywallState( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt index 7c251761f3..fd701f76af 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt @@ -38,6 +38,7 @@ import coil.ImageLoader import coil.decode.DataSource import coil.request.SuccessResult import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -467,7 +468,7 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { serverDescription = "serverDescription", metadata = emptyMap(), availablePackages = emptyList(), - paywallComponents = data, + paywallComponents = Offering.PaywallComponents(UiConfig(), data), ) val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! return offering.toComponentsPaywallState( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt index 8d5c684045..05816878b1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -1085,7 +1086,7 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { serverDescription = "serverDescription", metadata = emptyMap(), availablePackages = emptyList(), - paywallComponents = data, + paywallComponents = Offering.PaywallComponents(UiConfig(), data), ) val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! return offering.toComponentsPaywallState( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index 2acc43e855..d5b6b506bd 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -454,7 +455,7 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { serverDescription = "serverDescription", metadata = emptyMap(), availablePackages = emptyList(), - paywallComponents = data, + paywallComponents = Offering.PaywallComponents(UiConfig(), data), ) val validated = offering.validatePaywallComponentsDataOrNull()?.getOrThrow()!! return offering.toComponentsPaywallState( diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 4ff31fd232..b97a69e36c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -507,7 +507,7 @@ internal class PaywallViewModelImpl( } return PaywallEvent.Data( offeringIdentifier = offering.identifier, - paywallRevision = paywallData.revision, + paywallRevision = paywallData.data.revision, sessionIdentifier = UUID.randomUUID(), displayMode = mode.name.lowercase(), localeIdentifier = locale.toString(), diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index 4b577ea482..632f363550 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -90,15 +90,15 @@ internal fun Offering.validatePaywallComponentsDataOrNull(): RcResult return RcResult.Error(error) } // Build a NonEmptyMap of localizations, ensuring that we always have the default localization as fallback. val localizations = nonEmptyMapOf( - paywallComponents.defaultLocaleIdentifier to defaultLocalization, - paywallComponents.componentsLocalizations, + paywallComponents.data.defaultLocaleIdentifier to defaultLocalization, + paywallComponents.data.componentsLocalizations, ).mapValues { (locale, map) -> // We need to turn our NonEmptyMap into NonEmptyMap. If a certain locale // has an empty Map, we add an AllLocalizationsMissing error for that locale to our list of errors. @@ -110,7 +110,7 @@ internal fun Offering.validatePaywallComponentsDataOrNull(): RcResult