Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

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

## 2.7.15

## Enhancements
- Improves preloading logic to reduce number of preloaded paywalls for certain campaign types

## 2.7.14

## Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ class TrackingLogicTest {
override fun experimentalProperties(): Map<String, Any> = emptyMap()

override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()

override fun customerInfoFlow() =
kotlinx.coroutines.flow.MutableStateFlow(
com.superwall.sdk.models.customer.CustomerInfo.empty(),
)

override fun activeEntitlements() = emptySet<com.superwall.sdk.models.entitlements.Entitlement>()
},
),
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class ConfigManagerTests {
every { setEnrichment(any()) } just Runs
coEvery { getTemplateDevice() } returns emptyMap()
coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub())
coEvery { preloadFingerprint() } returns "stub-fingerprint"
}

@Before
Expand Down Expand Up @@ -146,7 +147,7 @@ class ConfigManagerTests {
val assignments = Assignments(storage, network, backgroundScope)
val preload =
mockk<PaywallPreload> {
coEvery { preloadAllPaywalls(any(), any()) } just Runs
coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs
coEvery { preloadPaywallsByNames(any(), any()) } just Runs
coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
}
Expand Down Expand Up @@ -188,7 +189,7 @@ class ConfigManagerTests {
val assignmentStore = Assignments(storage, network, backgroundScope)
val preload =
mockk<PaywallPreload> {
coEvery { preloadAllPaywalls(any(), any()) } just Runs
coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs
coEvery { preloadPaywallsByNames(any(), any()) } just Runs
coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
}
Expand Down
39 changes: 37 additions & 2 deletions superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.superwall.sdk

import android.app.Application
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import androidx.work.WorkManager
import com.android.billingclient.api.BillingResult
Expand Down Expand Up @@ -720,6 +722,7 @@ class Superwall(
)
val event = InternalSuperwallEvent.SubscriptionStatusDidChange(newValue)
track(event)
dependencyContainer.configManager.recheckPreloadIfNeeded()
}
}
ioScope.launchWithTracking {
Expand All @@ -735,8 +738,33 @@ class Superwall(
dependencyContainer.storage.write(LatestCustomerInfo, new)
dependencyContainer.delegateAdapter.customerInfoDidChange(old!!, new)
track(CustomerInfoDidChange(old, new))
dependencyContainer.configManager.recheckPreloadIfNeeded()
}
}
registerConfigurationChangeListener()
}

private var configurationChangeListener: ComponentCallbacks2? = null

private fun registerConfigurationChangeListener() {
// Locale / region / language / timezone / interfaceStyle (when overrides
// are off) all reflect Android Configuration. ComponentCallbacks2 fires on
// changes and runs on the main thread; bounce through ioScope to call the
// suspend recheck.
val listener =
object : ComponentCallbacks2 {
override fun onConfigurationChanged(newConfig: Configuration) {
ioScope.launchWithTracking {
dependencyContainer.configManager.recheckPreloadIfNeeded()
}
}

override fun onLowMemory() = Unit

override fun onTrimMemory(level: Int) = Unit
}
configurationChangeListener = listener
context.applicationContext.registerComponentCallbacks(listener)
}

/**
Expand Down Expand Up @@ -780,6 +808,7 @@ class Superwall(
dependencyContainer.deviceHelper.interfaceStyleOverride = interfaceStyle
ioScope.launch {
track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes()))
dependencyContainer.configManager.recheckPreloadIfNeeded()
}
}
}
Expand Down Expand Up @@ -884,6 +913,11 @@ class Superwall(
// Note: We intentionally do NOT unregister the activity lifecycle callbacks here
// because the activity provider will be retained and reused in the next configure call.
// This ensures the current activity is still tracked across hot reload cycles.

configurationChangeListener?.let {
context.applicationContext.unregisterComponentCallbacks(it)
configurationChangeListener = null
}
}
}

Expand Down Expand Up @@ -1409,6 +1443,7 @@ class Superwall(
type = paywallEvent.type.rawValue,
),
)
dependencyContainer.configManager.recheckPreloadIfNeeded()
}
}

Expand Down Expand Up @@ -1445,7 +1480,7 @@ class Superwall(
?: dependencyContainer
.activityProvider
?.getCurrentActivity()
) as SuperwallPaywallActivity?
) as? SuperwallPaywallActivity?
// Cancel any existing fallback notification of the same type before scheduling
// the dynamic notification from the paywall
paywallActivity?.attemptToScheduleNotifications(
Expand All @@ -1456,7 +1491,7 @@ class Superwall(
Logger.debug(
LogLevel.error,
LogScope.paywallView,
message = "No paywall activity alive to schedule notifications",
message = "No superwall paywall activity alive to schedule notifications",
)
}
}
Expand Down
40 changes: 19 additions & 21 deletions superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -246,26 +246,23 @@ object ConfigLogic {

// Loop through all the rules and check their preloading behavior
triggerRulesPerCampaign.forEach { campaignRules ->
campaignRules.forEach { rule ->
allExperimentIds.add(rule.experiment.id)

// Check the preloading behavior of each rule
when (rule.preload.behavior) {
TriggerPreloadBehavior.IF_TRUE -> {
val outcome =
expressionEvaluator.evaluateExpression(
rule = rule,
eventData = null,
)
if (outcome is TriggerRuleOutcome.NoMatch) {
skippedExperimentIds.add(rule.experiment.id)
}
}

TriggerPreloadBehavior.ALWAYS -> {}
TriggerPreloadBehavior.NEVER -> skippedExperimentIds.add(rule.experiment.id)
}
allExperimentIds.addAll(campaignRules.map { it.experiment.id })
val ifTrueRules = campaignRules
.filter { it.preload.behavior == TriggerPreloadBehavior.IF_TRUE }

val firstIfTrueMatch = ifTrueRules.firstOrNull {
expressionEvaluator.evaluateExpression(
rule = it,
eventData = null,
) is TriggerRuleOutcome.Match
}
val skippedIfTrueRules = ifTrueRules.map { it.experiment.id }.filter { rule ->
rule != firstIfTrueMatch?.experiment?.id
}
val skippedNeverRules = campaignRules.filter {
it.preload.behavior == TriggerPreloadBehavior.NEVER
}.map { it.experiment.id }
skippedExperimentIds.addAll(skippedIfTrueRules + skippedNeverRules)
}

// Remove any confirmed experiment IDs that are no longer part of a trigger
Expand Down Expand Up @@ -320,12 +317,13 @@ object ConfigLogic {
}

// Returns entitlements mapped by product ID
fun extractEntitlementsByProductId(from: List<ProductItem>) = from.associate { it.fullProductId to it.entitlements }
fun extractEntitlementsByProductId(from: List<ProductItem>) =
from.associate { it.fullProductId to it.entitlements }

// Returns entitlements mapped by product ID for CrossplatformProduct
fun extractEntitlementsByProductIdFromCrossplatform(from: List<CrossplatformProduct>) =
from.associate {
it.fullProductId to
it.entitlements.toSet()
it.entitlements.toSet()
}
}
24 changes: 24 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.models.triggers.Experiment
import com.superwall.sdk.models.triggers.ExperimentID
import com.superwall.sdk.models.triggers.Trigger
import com.superwall.sdk.models.triggers.TriggerPreloadBehavior
import com.superwall.sdk.network.SuperwallAPI
import com.superwall.sdk.network.awaitUntilNetworkExists
import com.superwall.sdk.network.device.DeviceHelper
Expand Down Expand Up @@ -142,6 +143,29 @@ open class ConfigManager(
immediate(ConfigState.Actions.PreloadAll)
}

/**
* Re-runs preload if any DeviceHelper field referenced by IF_TRUE rules has
* changed since the last preload. Cheap no-op when (a) config has no IF_TRUE
* rule or (b) the fingerprint matches the last dispatched preload.
*
* Call this from change-emitting signals (subscription status, configuration
* change, interface style override, store readiness, review-request increment).
*/
suspend fun recheckPreloadIfNeeded() {
val config = actor.state.value.getConfig() ?: return
val hasIfTrue =
config.triggers.any { trigger ->
trigger.rules.any { it.preload.behavior == TriggerPreloadBehavior.IF_TRUE }
}
if (!hasIfTrue) return
// Cheap pre-check; the authoritative dedup + atomic claim lives inside
// paywallPreload.preloadAllPaywalls so it commits only when a preload
// actually starts (and rolls back if the run is dropped).
val fingerprint = deviceHelper.preloadFingerprint()
if (paywallPreload.lastFingerprint.get() == fingerprint) return
immediate(ConfigState.Actions.PreloadIfEnabled)
}
Comment thread
ianrumac marked this conversation as resolved.

suspend fun preloadPaywallsByNames(eventNames: Set<String>) {
actor.state.awaitFirstValidConfig()
immediate(ConfigState.Actions.PreloadByNames(eventNames))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import java.util.concurrent.atomic.AtomicReference

class PaywallPreload(
val factory: Factory,
Expand All @@ -36,11 +37,32 @@ class PaywallPreload(

private var currentPreloadingTask: Job? = null

/**
* Fingerprint of the device/store/subscription state of the most recent
* preload that actually *started* (i.e. wasn't dropped by the "already
* running" guard). Compared by ConfigManager.recheckPreloadIfNeeded to
* decide whether attribute changes warrant a re-preload. Atomic so concurrent
* rechecks don't both claim the same dispatch slot.
*/
internal val lastFingerprint: AtomicReference<String?> = AtomicReference(null)

suspend fun preloadAllPaywalls(
config: Config,
context: Context,
fingerprint: String? = null,
) {
if (currentPreloadingTask != null) {
if (fingerprint != null) {
val previous = lastFingerprint.get()
// Already preloaded this exact state, or another caller raced ahead.
if (previous == fingerprint) return
if (!lastFingerprint.compareAndSet(previous, fingerprint)) return
// CAS won; if the preload below is dropped, roll back so future
// rechecks can retry.
if (currentPreloadingTask != null) {
lastFingerprint.compareAndSet(fingerprint, previous)
return
}
} else if (currentPreloadingTask != null) {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,20 @@ sealed class ConfigState {
object PreloadIfEnabled : Actions(exec@{
if (!options.computedShouldPreload(deviceHelper.deviceTier)) return@exec
val config = state.value.getConfig() ?: return@exec
paywallPreload.preloadAllPaywalls(config, context)
paywallPreload.preloadAllPaywalls(
config,
context,
fingerprint = deviceHelper.preloadFingerprint(),
)
})

object PreloadAll : Actions(exec@{
val config = state.value.getConfig() ?: return@exec
paywallPreload.preloadAllPaywalls(config, context)
paywallPreload.preloadAllPaywalls(
config,
context,
fingerprint = deviceHelper.preloadFingerprint(),
)
})

data class PreloadByNames(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,11 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import com.superwall.sdk.models.serialization.DateSerializer
import kotlinx.serialization.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import java.lang.ref.WeakReference
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
Expand Down Expand Up @@ -181,6 +184,7 @@ class DependencyContainer(
ClassifierDataFactory,
ExperimentalPropertiesFactory,
CustomerInfoFactory,
ActiveEntitlementsFactory,
WebPaywallRedeemer.Factory {
internal val getPaywallComponentsFactory: GetPaywallComponentsFactory by lazy {
DefaultGetPaywallComponentsFactory(Superwall.instance)
Expand Down Expand Up @@ -773,7 +777,14 @@ class DependencyContainer(
return headers
}

private val paywallJson = Json { encodeDefaults = true }
private val paywallJson =
Json {
encodeDefaults = true
serializersModule =
SerializersModule {
contextual(Date::class, DateSerializer)
}
}

override suspend fun makePaywallView(
paywall: Paywall,
Expand Down Expand Up @@ -1142,6 +1153,9 @@ class DependencyContainer(
override fun customerInfoFlow(): StateFlow<CustomerInfo> =
Superwall.instance.customerInfo

override fun activeEntitlements(): Set<com.superwall.sdk.models.entitlements.Entitlement> =
entitlements.active

override fun updatePaywallInfo(paywallInfo: PaywallInfo) {
Superwall.instance.presentationItems.paywallInfo = paywallInfo
}
Expand Down Expand Up @@ -1224,19 +1238,19 @@ class DependencyContainer(

override suspend fun receipts(): List<TransactionReceipt> =
googleBillingWrapper.queryAllPurchases().map {
val id = it.products.first()
val product = storeManager.products(setOf(id)).first()
val id = it.products.firstOrNull() ?: return@map null
val product = storeManager.products(setOf(id)).firstOrNull() ?: return@map null
TransactionReceipt(
it.purchaseToken,
it.orderId,
it.products.first(),
id,
if (product.rawStoreProduct?.isSubscription == true) {
TransactionReceipt.ProductType.SUBSCRIPTION
} else {
TransactionReceipt.ProductType.IAP
},
)
}
}.filterNotNull()

override fun getExternalAccountId(): String = identityManager.externalAccountId

Expand Down
Loading
Loading