Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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.PreloadAll)
Comment thread
ianrumac marked this conversation as resolved.
Outdated
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.superwall.sdk.misc.MainScope
import com.superwall.sdk.models.config.ComputedPropertyRequest
import com.superwall.sdk.models.config.FeatureFlags
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
import com.superwall.sdk.models.paywall.Paywall
Expand Down Expand Up @@ -152,6 +153,10 @@ fun interface CustomerInfoFactory {
fun customerInfoFlow(): StateFlow<CustomerInfo>
}

fun interface ActiveEntitlementsFactory {
fun activeEntitlements(): Set<Entitlement>
}

interface PresentationFactory {
fun updatePaywallInfo(paywallInfo: PaywallInfo)

Expand Down
Loading
Loading