From 1dd51aef140a9095e1dcee6989485275ea2ffc4f Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Thu, 26 Oct 2023 13:20:25 -0700 Subject: [PATCH 1/2] `Paywalls`: convert empty strings to `null` Equivalent to https://github.com/RevenueCat/purchases-ios/pull/2818 `IntroEligibilityStateView` relies on strings being `null` to determine what string is available, therefore we need to make sure we don't have empty strings in `PaywallData`. --- .../purchases/paywalls/PaywallData.kt | 25 +++++++--- .../purchases/paywalls/PaywallDataTest.kt | 46 +++++++++++++++---- .../test/resources/paywalldata-sample1.json | 2 + 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/PaywallData.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/PaywallData.kt index df640b64ac..77e6933e65 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/PaywallData.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/PaywallData.kt @@ -234,18 +234,22 @@ data class PaywallData( /** * The subtitle of the paywall screen. */ + @Serializable(with = EmptyStringToNullSerializer::class) val subtitle: String? = null, /** * The content of the main action button for purchasing a subscription. */ - @SerialName("call_to_action") val callToAction: String, + @SerialName("call_to_action") + val callToAction: String, /** * The content of the main action button for purchasing a subscription when an intro offer is available. * If `null`, no information regarding trial eligibility will be displayed. */ - @SerialName("call_to_action_with_intro_offer") val callToActionWithIntroOffer: String? = null, + @SerialName("call_to_action_with_intro_offer") + @Serializable(with = EmptyStringToNullSerializer::class) + val callToActionWithIntroOffer: String? = null, /** * The content of the main action button for purchasing a subscription when multiple intro offer are available. @@ -253,30 +257,39 @@ data class PaywallData( * If `null`, no information regarding trial eligibility will be displayed. */ @SerialName("call_to_action_with_multiple_intro_offers") + @Serializable(with = EmptyStringToNullSerializer::class) val callToActionWithMultipleIntroOffers: String? = null, /** * Description for the offer to be purchased. */ - @SerialName("offer_details") val offerDetails: String? = null, + @SerialName("offer_details") + @Serializable(with = EmptyStringToNullSerializer::class) + val offerDetails: String? = null, /** * Description for the offer to be purchased when an intro offer is available. * If `null`, no information regarding trial eligibility will be displayed. */ - @SerialName("offer_details_with_intro_offer") val offerDetailsWithIntroOffer: String? = null, + @SerialName("offer_details_with_intro_offer") + @Serializable(with = EmptyStringToNullSerializer::class) + val offerDetailsWithIntroOffer: String? = null, /** * Description for the offer to be purchased when multiple intro offers are available. * This may happen in Google Play, if you have an offer with both a free trial and a discounted price. * If `null`, no information regarding trial eligibility will be displayed. */ - @SerialName("offer_details_with_multiple_intro_offers") val offerDetailsWithMultipleIntroOffers: String? = null, + @SerialName("offer_details_with_multiple_intro_offers") + @Serializable(with = EmptyStringToNullSerializer::class) + val offerDetailsWithMultipleIntroOffers: String? = null, /** * The name representing each of the packages, most commonly a variable. */ - @SerialName("offer_name") val offerName: String? = null, + @SerialName("offer_name") + @Serializable(with = EmptyStringToNullSerializer::class) + val offerName: String? = null, /** * An optional list of features that describe this paywall. diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/PaywallDataTest.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/PaywallDataTest.kt index e484c4a8a4..80ac2a29b2 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/PaywallDataTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/PaywallDataTest.kt @@ -62,16 +62,42 @@ class PaywallDataTest { val unknownLocale = "gl_ES".toLocale() assertThat(paywall.configForLocale(unknownLocale)).isNull() - val validLocale = "en_US".toLocale() - val localizedConfiguration = paywall.configForLocale(validLocale) - assertThat(localizedConfiguration).isNotNull - assertThat(localizedConfiguration?.callToActionWithMultipleIntroOffers).isEqualTo( - "Purchase now with multiple offers" - ) - assertThat(localizedConfiguration?.offerDetailsWithMultipleIntroOffers).isEqualTo( - "Try {{ sub_offer_duration }} for free, then {{ sub_offer_price_2 }} for your first " + - "{{ sub_offer_duration_2 }}, and just {{ sub_price_per_month }} thereafter." - ) + val english = paywall.configForLocale("en_US".toLocale()) + assertThat(english).isNotNull + english?.apply { + assertThat(title).isEqualTo("Paywall") + assertThat(subtitle).isEqualTo("Description") + + assertThat(callToAction).isEqualTo("Purchase now") + assertThat(callToActionWithIntroOffer).isEqualTo("Purchase now") + assertThat(callToActionWithMultipleIntroOffers).isEqualTo("Purchase now with multiple offers") + + assertThat(offerDetails).isEqualTo("{{ sub_price_per_month }} per month") + assertThat(offerDetailsWithIntroOffer).isEqualTo( + "Start your {{ sub_offer_duration }} trial, " + + "then {{ sub_price_per_month }} per month" + ) + assertThat(offerDetailsWithMultipleIntroOffers).isEqualTo( + "Try {{ sub_offer_duration }} for free, " + + "then {{ sub_offer_price_2 }} for your first {{ sub_offer_duration_2 }}, " + + "and just {{ sub_price_per_month }} thereafter." + ) + } + + val spanish = paywall.configForLocale("es".toLocale()) + assertThat(spanish).isNotNull + spanish?.apply { + assertThat(title).isEqualTo("Tienda") + assertThat(subtitle).isNull() + + assertThat(callToAction).isEqualTo("Comprar") + assertThat(callToActionWithIntroOffer).isNull() + assertThat(callToActionWithMultipleIntroOffers).isNull() + + assertThat(offerDetails).isNull() + assertThat(offerDetailsWithIntroOffer).isNull() + assertThat(offerDetailsWithMultipleIntroOffers).isNull() + } } @Test diff --git a/purchases/src/test/resources/paywalldata-sample1.json b/purchases/src/test/resources/paywalldata-sample1.json index 5a0306ec43..5cd063cfb2 100644 --- a/purchases/src/test/resources/paywalldata-sample1.json +++ b/purchases/src/test/resources/paywalldata-sample1.json @@ -27,7 +27,9 @@ "es_ES": { "title": "Tienda", "call_to_action": "Comprar", + "call_to_action_with_multiple_intro_offers": " ", "offer_details_with_intro_offer": " ", + "offer_details_with_multiple_intro_offers": "", "offer_name": "{{ period }}", "features": [ { From 7310663c304b9ed997b53e2144572f8c0675f461 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Thu, 26 Oct 2023 13:49:52 -0700 Subject: [PATCH 2/2] `Paywalls`: `PaywallData` errors shouldn't make `Offering`s fail to decode This also adds coverage for `PaywallData` deserialization inside of `Offering` --- .../purchases/common/OfferingParser.kt | 9 +- .../common/offerings/OfferingsFactoryTest.kt | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) 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 85fa94ae13..5b299e1012 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/OfferingParser.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/OfferingParser.kt @@ -65,8 +65,13 @@ internal abstract class OfferingParser { val paywallDataJson = offeringJson.optJSONObject("paywall") - val paywallData = paywallDataJson?.let { - json.decodeFromString(it.toString()) + val paywallData: PaywallData? = paywallDataJson?.let { + try { + json.decodeFromString(it.toString()) + } catch (e: IllegalArgumentException) { + errorLog("Error deserializing paywall data", e) + null + } } return if (availablePackages.isNotEmpty()) { diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt index df8077bf97..52f5b7ad6b 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsFactoryTest.kt @@ -38,6 +38,63 @@ class OfferingsFactoryTest { "'description': 'This is the base offering', " + "'packages': []}]," + "'current_offering_id': '$STUB_OFFERING_IDENTIFIER'}") + private val oneOfferingWithInvalidPaywallResponse = JSONObject( + "" + + "{" + + "'offerings': [" + + "{" + + "'identifier': '$STUB_OFFERING_IDENTIFIER', " + + "'description': 'This is the base offering', " + + "'packages': [" + + "{'identifier': '\$rc_monthly','platform_product_identifier': '$STUB_PRODUCT_IDENTIFIER'}" + + "]," + + "'paywall': 'not a paywall'" + + "}" + + "]," + + "'current_offering_id': '$STUB_OFFERING_IDENTIFIER'" + + "}" + ) + private val oneOfferingWithPaywall = JSONObject( + "" + + "{" + + "'offerings': [" + + "{" + + "'identifier': '$STUB_OFFERING_IDENTIFIER', " + + "'description': 'This is the base offering', " + + "'packages': [" + + "{'identifier': '\$rc_monthly','platform_product_identifier': '$STUB_PRODUCT_IDENTIFIER'}" + + "]," + + "'paywall': {\n" + + " \"template_name\": \"1\",\n" + + " \"localized_strings\": {\n" + + " \"en_US\": {\n" + + " \"title\": \"Paywall\",\n" + + " \"call_to_action\": \"Purchase\",\n" + + " \"subtitle\": \"Description\"\n" + + " }\n" + + " },\n" + + " \"config\": {\n" + + " \"packages\": [\"\$rc_monthly\"],\n" + + " \"default_package\": \"\$rc_monthly\",\n" + + " \"images\": {},\n" + + " \"colors\": {\n" + + " \"light\": {\n" + + " \"background\": \"#FF00AA\",\n" + + " \"text_1\": \"#FF00AA22\",\n" + + " \"call_to_action_background\": \"#FF00AACC\",\n" + + " \"call_to_action_foreground\": \"#FF00AA\"\n" + + " }\n" + + " }\n" + + " },\n" + + " \"asset_base_url\": \"https://rc-paywalls.s3.amazonaws.com\",\n" + + " \"revision\": 7\n" + + "}" + + "}" + + "]," + + "'current_offering_id': '$STUB_OFFERING_IDENTIFIER'" + + "}" + ) + private val oneOfferingResponse = JSONObject(ONE_OFFERINGS_RESPONSE) private val oneOfferingInAppProductResponse = JSONObject(ONE_OFFERINGS_INAPP_PRODUCT_RESPONSE) @@ -142,6 +199,42 @@ class OfferingsFactoryTest { assertThat(offerings!![STUB_OFFERING_IDENTIFIER]!!.monthly!!.product).isNotNull } + @Test + fun `createOfferings with paywall`() { + val productIds = listOf(productId) + mockStoreProduct(productIds, emptyList(), ProductType.SUBS) + mockStoreProduct(productIds, productIds, ProductType.INAPP) + + var offerings: Offerings? = null + offeringsFactory.createOfferings( + oneOfferingWithPaywall, + { fail("Error: $it") }, + { offerings = it } + ) + + assertThat(offerings).isNotNull + assertThat(offerings!!.current).isNotNull + assertThat(offerings!!.current?.paywall).isNotNull + } + + @Test + fun `createOfferings does not fail if paywall is invalid`() { + val productIds = listOf(productId) + mockStoreProduct(productIds, emptyList(), ProductType.SUBS) + mockStoreProduct(productIds, productIds, ProductType.INAPP) + + var offerings: Offerings? = null + offeringsFactory.createOfferings( + oneOfferingWithInvalidPaywallResponse, + { fail("Error: $it") }, + { offerings = it } + ) + + assertThat(offerings).isNotNull + assertThat(offerings!!.current).isNotNull + assertThat(offerings!!.current?.paywall).isNull() + } + // region helpers private fun mockStoreProduct(