Skip to content
Open
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.
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ class ConfigManagerTests {
private val storeKit =
mockk<StoreManager> {
coEvery { products(any()) } returns emptySet()
coEvery { loadPurchasedProducts() } just Runs
coEvery { loadPurchasedProducts(any()) } just Runs
}
private val preload =
mockk<PaywallPreload> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class TransactionManagerTest {
mockk<ActivityProvider> {
every { getCurrentActivity() } returns mockk()
}
private val storeManager = spyk(StoreManager(purchaseController, billing))
private val storeManager = spyk(StoreManager(purchaseController, billing, receiptManagerFactory = { mockk(relaxed = true) }))

private var eventsQueue = mockk<EventsQueue>(relaxUnitFun = true)
private var transactionManagerFactory =
Expand Down Expand Up @@ -203,6 +203,7 @@ class TransactionManagerTest {
ioScope = IOScope(this.coroutineContext),
storage = storage,
entitlementsById = entitlementsById,
allEntitlementsByProductId = { emptyMap() },
showRestoreDialogForWeb = showRestoreDialogForWeb,
refreshReceipt = {},
)
Expand Down Expand Up @@ -297,7 +298,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify event order") {
val transactionEvents =
events.value.filterIsInstance<InternalSuperwallEvent.Transaction>()
Expand Down Expand Up @@ -407,7 +408,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify event order") {
val transactionEvents =
events.value.filterIsInstance<InternalSuperwallEvent.Transaction>()
Expand Down Expand Up @@ -907,7 +908,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify free trial start event") {
val freeTrialStartEvent =
events.value.filterIsInstance<InternalSuperwallEvent.FreeTrialStart>()
Expand Down Expand Up @@ -950,7 +951,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify non-recurring product purchase event") {
val nonRecurringPurchaseEvent =
events.value.filterIsInstance<InternalSuperwallEvent.NonRecurringProductPurchase>()
Expand Down Expand Up @@ -994,7 +995,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify non-recurring product purchase event") {
val nonRecurringPurchaseEvent =
events.value.filterIsInstance<InternalSuperwallEvent.NonRecurringProductPurchase>()
Expand Down Expand Up @@ -1043,7 +1044,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify subscription start event") {
val subscriptionStartEvent =
events.value.filterIsInstance<InternalSuperwallEvent.SubscriptionStart>()
Expand Down Expand Up @@ -1090,7 +1091,7 @@ class TransactionManagerTest {
)
Then("The purchase is successful") {
assert(result is PurchaseResult.Purchased)
coVerify { storeManager.loadPurchasedProducts() }
coVerify { storeManager.loadPurchasedProducts(any()) }
And("Verify subscription start event") {
val subscriptionStartEvent =
events.value.filterIsInstance<InternalSuperwallEvent.SubscriptionStart>()
Expand Down
44 changes: 44 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.superwall.sdk.misc.launchWithTracking
import com.superwall.sdk.misc.toResult
import com.superwall.sdk.models.assignment.ConfirmedAssignment
import com.superwall.sdk.models.attribution.AttributionProvider
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.events.EventData
Expand All @@ -63,6 +64,7 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.Initiate
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedDeepLink
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedURL
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedUrlInChrome
import com.superwall.sdk.storage.LatestCustomerInfo
import com.superwall.sdk.storage.ReviewCount
import com.superwall.sdk.storage.ReviewData
import com.superwall.sdk.storage.StoredSubscriptionStatus
Expand All @@ -85,9 +87,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -199,6 +204,21 @@ class Superwall(
options.paywalls.overrideProductsByName = value
}

internal val _customerInfo: MutableStateFlow<CustomerInfo> = MutableStateFlow(CustomerInfo.empty())

/**
* Exposes customer info as a stateflow.
*/

val customerInfo: StateFlow<CustomerInfo> get() = _customerInfo

/**
* Gets the current CustomerInfo synchronously.
*
* @return The current CustomerInfo containing purchase and subscription data.
*/
fun getCustomerInfo(): CustomerInfo = _customerInfo.value

/**
* Sets the Java delegate that handles Superwall lifecycle events.
*/
Expand Down Expand Up @@ -554,8 +574,13 @@ class Superwall(
val cachedSubscriptionStatus =
dependencyContainer.storage.read(StoredSubscriptionStatus)
?: SubscriptionStatus.Unknown
_customerInfo.value = dependencyContainer.storage.read(LatestCustomerInfo) ?: CustomerInfo.empty()

setSubscriptionStatus(cachedSubscriptionStatus)

// Trigger initial CustomerInfo merge on startup
dependencyContainer.customerInfoManager.updateMergedCustomerInfo()

addListeners()

ioScope.launch {
Expand Down Expand Up @@ -605,6 +630,25 @@ class Superwall(
track(event)
}
}
ioScope.launchWithTracking {
_customerInfo
.asSharedFlow()
.distinctUntilChanged()
.drop(1)
.scan<CustomerInfo, Pair<CustomerInfo, CustomerInfo>?>(null) { previousPair, newStatus ->
if (previousPair == null) {
null
} else {
Pair(previousPair.second, newStatus)
}
}.filterNotNull()
.collect {
val (old, new) = it
dependencyContainer.storage.write(LatestCustomerInfo, new)
dependencyContainer.delegateAdapter.customerInfoDidChange(old, new)
track(InternalSuperwallEvent.CustomerInfoDidChange(emptyMap()))
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1105,4 +1105,15 @@ sealed class InternalSuperwallEvent(
"type" to type,
)
}

data class CustomerInfoDidChange(
override val audienceFilterParams: Map<String, Any>,
) : TrackableSuperwallEvent {
override val superwallPlacement: SuperwallEvent = SuperwallEvent.CustomerInfoDidChange
override val rawName: String = SuperwallEvent.CustomerInfoDidChange.rawName

override val canImplicitlyTriggerPaywall: Boolean = false

override suspend fun getSuperwallParameters(): Map<String, Any> = emptyMap()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,11 @@ sealed class SuperwallEvent {
get() = "review_denied"
}

object CustomerInfoDidChange : SuperwallEvent() {
override val rawName: String
get() = SuperwallEvents.CustomerInfoDidChange.rawName
}

open val rawName: String
get() = this.toString()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,5 @@ enum class SuperwallEvents(
ReviewGranted("review_granted"),
ReviewDenied("review_denied"),
IntegrationAttributes("integration_attributes"),
CustomerInfoDidChange("customerInfo_didChange"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ open class ConfigManager(
}
}
ioScope.launch {
storeManager.loadPurchasedProducts()
storeManager.loadPurchasedProducts(entitlements.entitlementsByProductId)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.superwall.sdk.customer

import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.customer.merge
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.storage.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch

/**
* Manages merging of device and web CustomerInfo sources.
*
* This class is responsible for:
* - Reading device CustomerInfo (built from Google Play receipts)
* - Reading web CustomerInfo (fetched from Superwall backend)
* - Merging both sources using priority-based rules
* - Updating the public CustomerInfo flow that the SDK exposes
* - Persisting the merged result to storage
*
* When an external purchase controller is present (e.g., RevenueCat, Qonversion):
* - The subscription status is the source of truth for active entitlements
* - Device receipts are only used for inactive entitlements (history)
* - This preserves entitlements set by external controllers that device receipts don't know about
*
* The merge happens whenever:
* - Device receipts are refreshed (via ReceiptManager)
* - Web entitlements are fetched (via WebPaywallRedeemer)
* - SDK initialization completes
*/
class CustomerInfoManager(
private val storage: Storage,
private val customerInfoFlow: MutableStateFlow<CustomerInfo>,
private val ioScope: IOScope,
private val hasExternalPurchaseController: () -> Boolean,
private val getSubscriptionStatus: () -> SubscriptionStatus,
) {
/**
* Merges device and web CustomerInfo and updates the public CustomerInfo flow.
*
* This method:
* 1. Reads the latest device CustomerInfo from storage (built from Google Play receipts)
* 2. Reads the latest web CustomerInfo from storage (fetched from backend)
* 3. Merges them using priority rules (see CustomerInfo.merge())
* 4. Persists the merged result to storage
* 5. Updates the public flow so listeners get the latest merged state
*
* When an external purchase controller is present:
* - Uses CustomerInfo.forExternalPurchaseController() instead of standard merge
* - This ensures external controller's entitlements are preserved as source of truth
*
* The merge is performed asynchronously on the IO scope to avoid blocking the caller.
*/
fun updateMergedCustomerInfo() {
ioScope.launch {
val merged: CustomerInfo

if (hasExternalPurchaseController()) {
merged =
CustomerInfo.forExternalPurchaseController(
storage = storage,
subscriptionStatus = getSubscriptionStatus(),
)

Logger.debug(
logLevel = LogLevel.debug,
scope = LogScope.superwallCore,
message =
"Built CustomerInfo for external controller - " +
"${merged.subscriptions.size} subs, " +
"${merged.entitlements.size} entitlements",
)
} else {
val deviceInfo = storage.read(LatestDeviceCustomerInfo) ?: CustomerInfo.empty()
val webInfo = storage.read(LatestWebCustomerInfo) ?: CustomerInfo.empty()

Logger.debug(
logLevel = LogLevel.debug,
scope = LogScope.superwallCore,
message =
"Merging CustomerInfo - Device: ${deviceInfo.subscriptions.size} subs, " +
"Web: ${webInfo.subscriptions.size} subs",
)

// Merge with optimization: skip merge if one source is blank
merged =
when {
deviceInfo.isPlaceholder && webInfo.isPlaceholder -> CustomerInfo.empty()
deviceInfo.isPlaceholder -> webInfo
webInfo.isPlaceholder -> deviceInfo
else -> deviceInfo.merge(webInfo) // Apply priority-based merging
}

Logger.debug(
logLevel = LogLevel.debug,
scope = LogScope.superwallCore,
message =
"Merged CustomerInfo - Total: ${merged.subscriptions.size} subs, " +
"${merged.entitlements.size} entitlements",
)
}

// Store merged result for caching/offline access
storage.write(LatestCustomerInfo, merged)

// Update public flow so listeners get the new merged state
customerInfoFlow.value = merged
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.superwall.sdk.delegate

import android.net.Uri
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.models.internal.RedemptionResult
import com.superwall.sdk.paywall.presentation.PaywallInfo
Expand Down Expand Up @@ -40,4 +41,9 @@ interface SuperwallDelegate {
fun willRedeemLink() {}

fun didRedeemLink(result: RedemptionResult) {}

fun customerInfoDidChange(
from: CustomerInfo,
to: CustomerInfo,
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.superwall.sdk.delegate

import android.net.Uri
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.internal.RedemptionResult
import com.superwall.sdk.paywall.presentation.PaywallInfo
import java.net.URI
Expand Down Expand Up @@ -90,4 +91,14 @@ class SuperwallDelegateAdapter {
error = error,
)
}

fun customerInfoDidChange(
from: CustomerInfo,
to: CustomerInfo,
) {
kotlinDelegate?.customerInfoDidChange(from, to) ?: javaDelegate?.customerInfoDidChange(
from,
to,
)
}
}
Loading