diff --git a/CHANGELOG.md b/CHANGELOG.md index d02b67e61..f046cb863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Allows triggering a `transaction_abandon` offer on a `paywall_decline` offer and vice-versa, whereas previously it would trigger a presentation error. - Add a `Superwall.teardown` method and `Superwall.instance.refreshConfiguration()` for development with hot-reload based frameworks +## ⚠️ Warning ⚠️ +If you are using a Purchase Controller and web2app or app2web purchases, you will have to update your purchase controller +to listen to `Superwall.instance.customerInfo` which will provide you with the relevant web entitlements and call +`setSubscriptionStatus` accordingly. + ## 2.6.5 ## Dependencies diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index 2b91c12a7..83a06aadd 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -28,6 +28,10 @@ import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch // Extension function to convert callback to suspend function suspend fun Purchases.awaitProducts(productIds: List): List { @@ -109,6 +113,9 @@ class RevenueCatPurchaseController( val context: Context, ) : PurchaseController, UpdatedCustomerInfoListener { + private var superwallCustomerInfoJob: Job? = null + private val scope = CoroutineScope(Dispatchers.Main) + init { Purchases.logLevel = LogLevel.DEBUG Purchases.configure( @@ -124,36 +131,54 @@ class RevenueCatPurchaseController( } fun syncSubscriptionStatus() { + // Cancel any existing listener to avoid duplicates + superwallCustomerInfoJob?.cancel() + // Refetch the customer info on load - Purchases.sharedInstance.getCustomerInfoWith { - if (hasAnyActiveEntitlements(it)) { - setSubscriptionStatus( - SubscriptionStatus.Active( - it.entitlements.active - .map { - Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) - }.toSet(), - ), - ) - } else { - setSubscriptionStatus(SubscriptionStatus.Inactive) - } + Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo -> + updateSubscriptionStatus(rcCustomerInfo) } + + // Listen to Superwall customerInfo changes (for web entitlements) + superwallCustomerInfoJob = + scope.launch { + Superwall.instance.customerInfo.collect { + // When Superwall's customerInfo changes, re-fetch RC state and merge + Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo -> + updateSubscriptionStatus(rcCustomerInfo) + } + } + } } /** - * Callback for rc customer updated info + * Callback for RC customer updated info */ override fun onReceived(customerInfo: CustomerInfo) { - if (hasAnyActiveEntitlements(customerInfo)) { - setSubscriptionStatus( - SubscriptionStatus.Active( - customerInfo.entitlements.active - .map { - Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) - }.toSet(), - ), - ) + updateSubscriptionStatus(customerInfo) + } + + /** + * Merges RevenueCat entitlements with Superwall web entitlements and updates subscription status + */ + private fun updateSubscriptionStatus(rcCustomerInfo: CustomerInfo) { + val rcEntitlements = + rcCustomerInfo.entitlements.active + .map { Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) } + .toSet() + + // Merge with web entitlements from Superwall + val webEntitlements = + if (Superwall.initialized) { + Superwall.instance.entitlements.web + } else { + emptySet() + } + + val allEntitlements = rcEntitlements + webEntitlements + + if (allEntitlements.isNotEmpty()) { + setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements)) } else { setSubscriptionStatus(SubscriptionStatus.Inactive) } diff --git a/example/app/src/revenuecat/java/com/superwall/superapp/RevenueCatPurchaseController.kt b/example/app/src/revenuecat/java/com/superwall/superapp/RevenueCatPurchaseController.kt index 54e64ef12..29af1a6bd 100644 --- a/example/app/src/revenuecat/java/com/superwall/superapp/RevenueCatPurchaseController.kt +++ b/example/app/src/revenuecat/java/com/superwall/superapp/RevenueCatPurchaseController.kt @@ -28,6 +28,10 @@ import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch suspend fun Purchases.awaitProducts(productIds: List): List { val deferred = CompletableDeferred>() @@ -107,6 +111,9 @@ class RevenueCatPurchaseController( val context: Context, ) : PurchaseController, UpdatedCustomerInfoListener { + private var superwallCustomerInfoJob: Job? = null + private val scope = CoroutineScope(Dispatchers.Main) + init { Purchases.logLevel = LogLevel.DEBUG Purchases.configure( @@ -122,36 +129,54 @@ class RevenueCatPurchaseController( } fun syncSubscriptionStatus() { + // Cancel any existing listener to avoid duplicates + superwallCustomerInfoJob?.cancel() + // Refetch the customer info on load - Purchases.sharedInstance.getCustomerInfoWith { - if (hasAnyActiveEntitlements(it)) { - setSubscriptionStatus( - SubscriptionStatus.Active( - it.entitlements.active - .map { - Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) - }.toSet(), - ), - ) - } else { - setSubscriptionStatus(SubscriptionStatus.Inactive) - } + Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo -> + updateSubscriptionStatus(rcCustomerInfo) } + + // Listen to Superwall customerInfo changes (for web entitlements) + superwallCustomerInfoJob = + scope.launch { + Superwall.instance.customerInfo.collect { + // When Superwall's customerInfo changes, re-fetch RC state and merge + Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo -> + updateSubscriptionStatus(rcCustomerInfo) + } + } + } } /** - * Callback for rc customer updated info + * Callback for RC customer updated info */ override fun onReceived(customerInfo: CustomerInfo) { - if (hasAnyActiveEntitlements(customerInfo)) { - setSubscriptionStatus( - SubscriptionStatus.Active( - customerInfo.entitlements.active - .map { - Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) - }.toSet(), - ), - ) + updateSubscriptionStatus(customerInfo) + } + + /** + * Merges RevenueCat entitlements with Superwall web entitlements and updates subscription status + */ + private fun updateSubscriptionStatus(rcCustomerInfo: CustomerInfo) { + val rcEntitlements = + rcCustomerInfo.entitlements.active + .map { Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) } + .toSet() + + // Merge with web entitlements from Superwall + val webEntitlements = + if (Superwall.initialized) { + Superwall.instance.entitlements.web + } else { + emptySet() + } + + val allEntitlements = rcEntitlements + webEntitlements + + if (allEntitlements.isNotEmpty()) { + setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements)) } else { setSubscriptionStatus(SubscriptionStatus.Inactive) } diff --git a/superwall/src/main/java/com/superwall/sdk/models/serialization/DateSerializer.kt b/superwall/src/main/java/com/superwall/sdk/models/serialization/DateSerializer.kt index 5d7a679f6..d80b78d40 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/serialization/DateSerializer.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/serialization/DateSerializer.kt @@ -4,14 +4,23 @@ import com.superwall.sdk.utilities.DateUtils import com.superwall.sdk.utilities.dateFormat import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull import java.text.SimpleDateFormat import java.util.* @kotlinx.serialization.ExperimentalSerializationApi @Serializer(forClass = Date::class) object DateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) + private val dateFormat = object : ThreadLocal() { override fun initialValue() = @@ -20,6 +29,22 @@ object DateSerializer : KSerializer { } } + // Date formats to try when deserializing, in order of preference + private val dateFormats = + object : ThreadLocal>() { + override fun initialValue() = + listOf( + DateUtils.ISO_MILLIS, + DateUtils.ISO_MILLIS + "'Z'", + DateUtils.ISO_SECONDS, + DateUtils.ISO_SECONDS_TIMEZONE, + ).map { format -> + dateFormat(format).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } + } + override fun serialize( encoder: Encoder, value: Date, @@ -27,10 +52,34 @@ object DateSerializer : KSerializer { encoder.encodeString(dateFormat.get()!!.format(value)) } - override fun deserialize(decoder: Decoder): Date = - try { - dateFormat.get()!!.parse(decoder.decodeString()) - } catch (e: Throwable) { - throw IllegalArgumentException("Invalid date format", e) - }!! + override fun deserialize(decoder: Decoder): Date { + // Handle both JSON number (epoch millis) and string formats + val jsonDecoder = decoder as? JsonDecoder + if (jsonDecoder != null) { + val element = jsonDecoder.decodeJsonElement() + if (element is JsonPrimitive) { + // Try parsing as epoch milliseconds first (number) + element.longOrNull?.let { millis -> + return Date(millis) + } + // Fall back to string parsing + val dateString = element.content + return parseStringDate(dateString) + } + } + // Fallback for non-JSON decoders + val dateString = decoder.decodeString() + return parseStringDate(dateString) + } + + private fun parseStringDate(dateString: String): Date { + for (format in dateFormats.get()!!) { + try { + return format.parse(dateString)!! + } catch (e: Throwable) { + // Try next format + } + } + throw IllegalArgumentException("Invalid date format: $dateString") + } } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/LatestSubscriptionState.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/LatestSubscriptionState.kt index 565d34770..2e989cdee 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/LatestSubscriptionState.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/LatestSubscriptionState.kt @@ -1,25 +1,38 @@ package com.superwall.sdk.store.abstractions.product.receipt -import kotlinx.serialization.SerialName +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder -@Serializable +@Serializable(with = LatestSubscriptionStateSerializer::class) enum class LatestSubscriptionState { - @SerialName("GRACE_PERIOD") GRACE_PERIOD, - - @SerialName("EXPIRED") EXPIRED, - - @SerialName("SUBSCRIBED") SUBSCRIBED, - - @SerialName("BILLING_RETRY") BILLING_RETRY, - - @SerialName("REVOKED") REVOKED, - - @SerialName("UNKNOWN") UNKNOWN, } + +object LatestSubscriptionStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LatestSubscriptionState", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: LatestSubscriptionState, + ) { + encoder.encodeString(value.name) + } + + override fun deserialize(decoder: Decoder): LatestSubscriptionState { + val value = decoder.decodeString() + return LatestSubscriptionState.entries.find { + it.name.equals(value, ignoreCase = true) + } ?: LatestSubscriptionState.UNKNOWN + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/models/serialization/DateSerializerTest.kt b/superwall/src/test/java/com/superwall/sdk/models/serialization/DateSerializerTest.kt index 5bbd8eb7e..8b2b36c22 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/serialization/DateSerializerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/serialization/DateSerializerTest.kt @@ -12,10 +12,10 @@ import java.util.Calendar.* @ExperimentalSerializationApi class DateSerializerTest { + private val json = Json { serializersModule = SerializersModule { contextual(DateSerializer) } } + @Test fun `test date serializer`() { - val json = Json { serializersModule = SerializersModule { contextual(DateSerializer) } } - val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = @@ -37,4 +37,62 @@ class DateSerializerTest { val deserializedDate = json.decodeFromString(DateSerializer, jsonString) assertEquals(originalDate, deserializedDate) } + + @Test + fun `test date deserializer with Z suffix`() { + val dateWithZ = "\"2023-05-15T13:46:52.789Z\"" + val deserializedDate = json.decodeFromString(DateSerializer, dateWithZ) + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = deserializedDate } + assertEquals(2023, calendar.get(YEAR)) + assertEquals(4, calendar.get(MONTH)) // May is month 4 (0-indexed) + assertEquals(15, calendar.get(DAY_OF_MONTH)) + assertEquals(13, calendar.get(HOUR_OF_DAY)) + assertEquals(46, calendar.get(MINUTE)) + assertEquals(52, calendar.get(SECOND)) + assertEquals(789, calendar.get(MILLISECOND)) + } + + @Test + fun `test date deserializer without milliseconds`() { + val dateWithoutMillis = "\"2023-05-15T13:46:52\"" + val deserializedDate = json.decodeFromString(DateSerializer, dateWithoutMillis) + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = deserializedDate } + assertEquals(2023, calendar.get(YEAR)) + assertEquals(4, calendar.get(MONTH)) + assertEquals(15, calendar.get(DAY_OF_MONTH)) + assertEquals(13, calendar.get(HOUR_OF_DAY)) + assertEquals(46, calendar.get(MINUTE)) + assertEquals(52, calendar.get(SECOND)) + } + + @Test + fun `test date deserializer without milliseconds with Z suffix`() { + val dateWithoutMillisWithZ = "\"2023-05-15T13:46:52Z\"" + val deserializedDate = json.decodeFromString(DateSerializer, dateWithoutMillisWithZ) + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = deserializedDate } + assertEquals(2023, calendar.get(YEAR)) + assertEquals(4, calendar.get(MONTH)) + assertEquals(15, calendar.get(DAY_OF_MONTH)) + assertEquals(13, calendar.get(HOUR_OF_DAY)) + assertEquals(46, calendar.get(MINUTE)) + assertEquals(52, calendar.get(SECOND)) + } + + @Test + fun `test date deserializer with epoch milliseconds`() { + // 1765536941000 = 2025-12-12T10:55:41.000Z + val epochMillis = "1765536941000" + val deserializedDate = json.decodeFromString(DateSerializer, epochMillis) + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { time = deserializedDate } + assertEquals(2025, calendar.get(YEAR)) + assertEquals(11, calendar.get(MONTH)) // December is month 11 (0-indexed) + assertEquals(12, calendar.get(DAY_OF_MONTH)) + assertEquals(10, calendar.get(HOUR_OF_DAY)) + assertEquals(55, calendar.get(MINUTE)) + assertEquals(41, calendar.get(SECOND)) + } } diff --git a/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt b/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt index 44a54fb24..1a59f00e1 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/EntitlementsTest.kt @@ -4,8 +4,11 @@ import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When +import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.models.internal.WebRedemptionResponse +import com.superwall.sdk.models.product.Store import com.superwall.sdk.storage.LatestRedemptionResponse import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StoredEntitlementsByProductId @@ -370,4 +373,540 @@ class EntitlementsTest { } } } + + // ========================================== + // Web Entitlements Tests + // ========================================== + + @Test + fun `test web property returns active entitlements from storage`() = + runTest { + Given("storage contains web entitlements in LatestRedemptionResponse") { + val webEntitlement1 = Entitlement("web_pro", isActive = true, store = Store.STRIPE) + val webEntitlement2 = Entitlement("web_basic", isActive = true, store = Store.STRIPE) + val webEntitlement3 = Entitlement("web_inactive", isActive = false, store = Store.PADDLE) + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webEntitlement1, webEntitlement2, webEntitlement3), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("accessing the web property") { + entitlements = Entitlements(storage) + + Then("it should return only active web entitlements") { + assertEquals(setOf(webEntitlement1, webEntitlement2), entitlements.web) + } + } + } + } + + @Test + fun `test web property filters out inactive entitlements`() = + runTest { + Given("storage contains mixed active and inactive web entitlements") { + val activeWebEntitlement = Entitlement("web_active", isActive = true, store = Store.STRIPE) + val inactiveWebEntitlement = Entitlement("web_inactive", isActive = false, store = Store.STRIPE) + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(activeWebEntitlement, inactiveWebEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("accessing the web property") { + entitlements = Entitlements(storage) + + Then("it should return only active web entitlements") { + assertEquals(setOf(activeWebEntitlement), entitlements.web) + assertTrue(!entitlements.web.contains(inactiveWebEntitlement)) + } + } + } + } + + @Test + fun `test web property returns empty when no redemption response`() = + runTest { + Given("storage has no LatestRedemptionResponse") { + every { storage.read(LatestRedemptionResponse) } returns null + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("accessing the web property") { + entitlements = Entitlements(storage) + + Then("it should return empty set") { + assertTrue(entitlements.web.isEmpty()) + } + } + } + } + + // ========================================== + // External Purchase Controller + Web Entitlements Tests + // ========================================== + + @Test + fun `test active property merges backingActive with web entitlements`() = + runTest { + Given("entitlements with both status-based and web entitlements") { + val statusEntitlement = Entitlement("play_store_pro", isActive = true, store = Store.PLAY_STORE) + val webEntitlement = Entitlement("web_feature", isActive = true, store = Store.STRIPE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("setting subscription status (simulating external PC)") { + entitlements = Entitlements(storage, scope = backgroundScope) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(statusEntitlement))) + + Then("active should contain both status and web entitlements") { + assertTrue(entitlements.active.any { it.id == "play_store_pro" }) + assertTrue(entitlements.active.any { it.id == "web_feature" }) + assertEquals(2, entitlements.active.size) + } + + And("web property should still return web entitlements independently") { + assertEquals(setOf(webEntitlement), entitlements.web) + } + } + } + } + + @Test + fun `test external PC scenario - web entitlements accessible after status set by external controller`() = + runTest { + Given("an external purchase controller scenario") { + // Simulating RevenueCat setting entitlements (Play Store only) + val rcEntitlement = Entitlement("rc_premium", isActive = true, store = Store.PLAY_STORE) + + // Web entitlements from Superwall backend + val webEntitlement = Entitlement("stripe_addon", isActive = true, store = Store.STRIPE) + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("external PC sets status with only its entitlements") { + entitlements = Entitlements(storage, scope = backgroundScope) + // External PC sets status (like RC does) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(rcEntitlement))) + + Then("web entitlements should still be accessible via web property") { + assertEquals(setOf(webEntitlement), entitlements.web) + } + + And("active should automatically include web entitlements via merge") { + val activeEntitlements = entitlements.active + assertTrue(activeEntitlements.any { it.id == "rc_premium" }) + assertTrue(activeEntitlements.any { it.id == "stripe_addon" }) + } + } + } + } + + @Test + fun `test external PC merges web entitlements manually (RC controller pattern)`() = + runTest { + Given("an external purchase controller that merges web entitlements") { + val rcEntitlement = Entitlement("rc_premium", isActive = true, store = Store.PLAY_STORE) + val webEntitlement = Entitlement("web_addon", isActive = true, store = Store.STRIPE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + entitlements = Entitlements(storage, scope = backgroundScope) + + When("external PC reads web entitlements and merges them into status") { + // This simulates what the updated RC controller does: + // 1. Get RC entitlements + // 2. Read web entitlements + // 3. Merge and set status + val webFromStorage = entitlements.web + val allEntitlements = setOf(rcEntitlement) + webFromStorage + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements)) + + Then("status should contain both RC and web entitlements") { + val status = entitlements.status.value + assertTrue(status is SubscriptionStatus.Active) + val activeStatus = status as SubscriptionStatus.Active + assertTrue(activeStatus.entitlements.any { it.id == "rc_premium" }) + assertTrue(activeStatus.entitlements.any { it.id == "web_addon" }) + } + + And("active property should also contain both") { + assertTrue(entitlements.active.any { it.id == "rc_premium" }) + assertTrue(entitlements.active.any { it.id == "web_addon" }) + } + } + } + } + + // ========================================== + // Reset and Re-identify Flow Tests + // ========================================== + + @Test + fun `test reset flow - web entitlements persist in storage after status reset`() = + runTest { + Given("user has web entitlements and active status") { + val webEntitlement = Entitlement("web_pro", isActive = true, store = Store.STRIPE) + val playEntitlement = Entitlement("play_pro", isActive = true, store = Store.PLAY_STORE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "userA", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + entitlements = Entitlements(storage, scope = backgroundScope) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(playEntitlement))) + + When("status is reset to Inactive (simulating sign out)") { + entitlements.setSubscriptionStatus(SubscriptionStatus.Inactive) + + Then("web entitlements should still be accessible from storage") { + // Storage still has web entitlements + assertEquals(setOf(webEntitlement), entitlements.web) + } + + And("active should only contain web entitlements (since status is inactive but web persists)") { + // active = backingActive + activeDeviceEntitlements + web + // backingActive is cleared, activeDeviceEntitlements is cleared, but web still reads from storage + assertEquals(setOf(webEntitlement), entitlements.active) + } + } + } + } + + @Test + fun `test re-identify flow - web entitlements restored after reset and re-identify`() = + runTest { + Given("user A had web entitlements, reset, and re-identifies") { + val webEntitlement = Entitlement("web_pro", isActive = true, store = Store.STRIPE) + val newPlayEntitlement = Entitlement("new_play_pro", isActive = true, store = Store.PLAY_STORE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "userA", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + entitlements = Entitlements(storage, scope = backgroundScope) + + // Initial state + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(Entitlement("old_play")))) + + When("user resets and external PC sets new status after re-identify") { + // Reset + entitlements.setSubscriptionStatus(SubscriptionStatus.Inactive) + + // Re-identify - external PC fetches and sets new status + // Web entitlements are also re-fetched from backend (simulated by storage still having them) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(newPlayEntitlement))) + + Then("active should contain both new play entitlement and web entitlement") { + assertTrue(entitlements.active.any { it.id == "new_play_pro" }) + assertTrue(entitlements.active.any { it.id == "web_pro" }) + } + + And("web property should return web entitlements") { + assertEquals(setOf(webEntitlement), entitlements.web) + } + } + } + } + + @Test + fun `test reset with different user - simulates user switch scenario`() = + runTest { + Given("user A has entitlements, then user B identifies") { + // User A's web entitlements + val userAWebEntitlement = Entitlement("userA_web", isActive = true, store = Store.STRIPE) + val userAWebInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "userA", + entitlements = listOf(userAWebEntitlement), + isPlaceholder = false, + ) + val userARedemption = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = userAWebInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns userARedemption + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + entitlements = Entitlements(storage, scope = backgroundScope) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(Entitlement("userA_play")))) + + When("user B identifies and storage is updated with user B's web entitlements") { + // Reset for user switch + entitlements.setSubscriptionStatus(SubscriptionStatus.Inactive) + + // Storage is updated with user B's web entitlements (simulating backend fetch) + val userBWebEntitlement = Entitlement("userB_web", isActive = true, store = Store.STRIPE) + val userBWebInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "userB", + entitlements = listOf(userBWebEntitlement), + isPlaceholder = false, + ) + val userBRedemption = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = userBWebInfo, + ) + every { storage.read(LatestRedemptionResponse) } returns userBRedemption + + // User B's external PC sets status + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(Entitlement("userB_play")))) + + Then("web property should return user B's web entitlements") { + assertEquals(setOf(userBWebEntitlement), entitlements.web) + } + + And("active should contain user B's entitlements only") { + assertTrue(entitlements.active.any { it.id == "userB_play" }) + assertTrue(entitlements.active.any { it.id == "userB_web" }) + assertTrue(!entitlements.active.any { it.id == "userA_web" }) + assertTrue(!entitlements.active.any { it.id == "userA_play" }) + } + } + } + } + + // ========================================== + // All Three Sources Combined Tests + // ========================================== + + @Test + fun `test active merges all three sources - backingActive, deviceEntitlements, and web`() = + runTest { + Given("entitlements from all three sources") { + val statusEntitlement = Entitlement("from_status", isActive = true) + val deviceEntitlement = Entitlement("from_device", isActive = true) + val webEntitlement = Entitlement("from_web", isActive = true, store = Store.STRIPE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("all three sources have different entitlements") { + entitlements = Entitlements(storage, scope = backgroundScope) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(statusEntitlement))) + entitlements.activeDeviceEntitlements = setOf(deviceEntitlement) + + Then("active should contain all three entitlements") { + val active = entitlements.active + assertEquals(3, active.size) + assertTrue(active.any { it.id == "from_status" }) + assertTrue(active.any { it.id == "from_device" }) + assertTrue(active.any { it.id == "from_web" }) + } + } + } + } + + @Test + fun `test duplicate entitlement IDs are deduplicated via merge`() = + runTest { + Given("same entitlement ID from multiple sources with different properties") { + // Same ID "premium" from status and web, but different properties + val statusPremium = Entitlement("premium", isActive = true, store = Store.PLAY_STORE) + val webPremium = Entitlement("premium", isActive = true, store = Store.STRIPE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webPremium), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("both sources have entitlement with same ID") { + entitlements = Entitlements(storage, scope = backgroundScope) + entitlements.setSubscriptionStatus(SubscriptionStatus.Active(setOf(statusPremium))) + + Then("active should deduplicate and contain only one premium entitlement") { + val premiumEntitlements = entitlements.active.filter { it.id == "premium" } + assertEquals(1, premiumEntitlements.size) + } + } + } + } + + @Test + fun `test web entitlements included in active even when status is Unknown`() = + runTest { + Given("status is Unknown but web entitlements exist") { + val webEntitlement = Entitlement("web_pro", isActive = true, store = Store.STRIPE) + + val webCustomerInfo = + CustomerInfo( + subscriptions = emptyList(), + nonSubscriptions = emptyList(), + userId = "user123", + entitlements = listOf(webEntitlement), + isPlaceholder = false, + ) + val redemptionResponse = + WebRedemptionResponse( + codes = emptyList(), + allCodes = emptyList(), + customerInfo = webCustomerInfo, + ) + + every { storage.read(LatestRedemptionResponse) } returns redemptionResponse + every { storage.read(StoredSubscriptionStatus) } returns null + every { storage.read(StoredEntitlementsByProductId) } returns null + + When("status is set to Unknown") { + entitlements = Entitlements(storage, scope = backgroundScope) + entitlements.setSubscriptionStatus(SubscriptionStatus.Unknown) + + Then("web property should still return web entitlements") { + assertEquals(setOf(webEntitlement), entitlements.web) + } + + And("active should include web entitlements despite Unknown status") { + // active = backingActive + activeDeviceEntitlements + web + // Unknown clears backingActive and activeDeviceEntitlements, but web still reads from storage + assertTrue(entitlements.active.contains(webEntitlement)) + } + } + } + } } diff --git a/test_app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/test_app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index 2b91c12a7..83a06aadd 100644 --- a/test_app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/test_app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -28,6 +28,10 @@ import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch // Extension function to convert callback to suspend function suspend fun Purchases.awaitProducts(productIds: List): List { @@ -109,6 +113,9 @@ class RevenueCatPurchaseController( val context: Context, ) : PurchaseController, UpdatedCustomerInfoListener { + private var superwallCustomerInfoJob: Job? = null + private val scope = CoroutineScope(Dispatchers.Main) + init { Purchases.logLevel = LogLevel.DEBUG Purchases.configure( @@ -124,36 +131,54 @@ class RevenueCatPurchaseController( } fun syncSubscriptionStatus() { + // Cancel any existing listener to avoid duplicates + superwallCustomerInfoJob?.cancel() + // Refetch the customer info on load - Purchases.sharedInstance.getCustomerInfoWith { - if (hasAnyActiveEntitlements(it)) { - setSubscriptionStatus( - SubscriptionStatus.Active( - it.entitlements.active - .map { - Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) - }.toSet(), - ), - ) - } else { - setSubscriptionStatus(SubscriptionStatus.Inactive) - } + Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo -> + updateSubscriptionStatus(rcCustomerInfo) } + + // Listen to Superwall customerInfo changes (for web entitlements) + superwallCustomerInfoJob = + scope.launch { + Superwall.instance.customerInfo.collect { + // When Superwall's customerInfo changes, re-fetch RC state and merge + Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo -> + updateSubscriptionStatus(rcCustomerInfo) + } + } + } } /** - * Callback for rc customer updated info + * Callback for RC customer updated info */ override fun onReceived(customerInfo: CustomerInfo) { - if (hasAnyActiveEntitlements(customerInfo)) { - setSubscriptionStatus( - SubscriptionStatus.Active( - customerInfo.entitlements.active - .map { - Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) - }.toSet(), - ), - ) + updateSubscriptionStatus(customerInfo) + } + + /** + * Merges RevenueCat entitlements with Superwall web entitlements and updates subscription status + */ + private fun updateSubscriptionStatus(rcCustomerInfo: CustomerInfo) { + val rcEntitlements = + rcCustomerInfo.entitlements.active + .map { Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) } + .toSet() + + // Merge with web entitlements from Superwall + val webEntitlements = + if (Superwall.initialized) { + Superwall.instance.entitlements.web + } else { + emptySet() + } + + val allEntitlements = rcEntitlements + webEntitlements + + if (allEntitlements.isNotEmpty()) { + setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements)) } else { setSubscriptionStatus(SubscriptionStatus.Inactive) }