Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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()
}
}
22 changes: 22 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,27 @@ 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
val fingerprint = deviceHelper.preloadFingerprint()
if (paywallPreload.lastFingerprint == fingerprint) return
paywallPreload.lastFingerprint = fingerprint
immediate(ConfigState.Actions.PreloadAll)
Comment thread
ianrumac marked this conversation as resolved.
Outdated
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 @@ -36,6 +36,15 @@ class PaywallPreload(

private var currentPreloadingTask: Job? = null

/**
* Fingerprint of the device/store/subscription state at the time of the most
* recent preload dispatch. Used by ConfigManager.recheckPreloadIfNeeded to
* decide whether attribute changes warrant a re-preload. Seeded by the
* PreloadIfEnabled / PreloadAll actions on every dispatch.
*/
@Volatile
internal var lastFingerprint: String? = null

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

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

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 @@ -773,7 +776,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 @@ -1224,19 +1234,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 @@ -4,9 +4,11 @@ package com.superwall.sdk.models.entitlements

import android.annotation.SuppressLint
import com.superwall.sdk.models.product.Store
import com.superwall.sdk.models.serialization.DateSerializer
import com.superwall.sdk.store.abstractions.product.receipt.LatestPeriodType
import com.superwall.sdk.store.abstractions.product.receipt.LatestSubscriptionState
import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.*
Expand All @@ -15,6 +17,7 @@ import java.util.*
* An entitlement that represents a subscription tier in your app.
*/
@SuppressLint("UnsafeOptInUsageError")
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class Entitlement(
/**
Expand Down Expand Up @@ -55,6 +58,7 @@ data class Entitlement(
*/
@SerialName("startsAt")
@Contextual
@Serializable(with = DateSerializer::class)
val startsAt: Date? = null,
/**
* The date that the entitlement was last renewed.
Expand All @@ -66,6 +70,7 @@ data class Entitlement(
*/
@SerialName("renewedAt")
@Contextual
@Serializable(with = DateSerializer::class)
val renewedAt: Date? = null,
/**
* The expiry date of the last transaction that unlocked this entitlement.
Expand All @@ -75,6 +80,7 @@ data class Entitlement(
*/
@SerialName("expiresAt")
@Contextual
@Serializable(with = DateSerializer::class)
val expiresAt: Date? = null,
/**
* Indicates whether the entitlement is active for a lifetime due to the purchase of a non-consumable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,46 @@ class DeviceHelper(
"UNKNOWN"
}

/**
* Stable fingerprint of the device/store/subscription/storage fields that can
* affect which paywalls IF_TRUE rules preload. Compared by value to decide
* whether to re-run preload after a state change. Excludes iOS-only fields
* (storeFrontCountryCode/Id/Currency, appTransactionId) and configure-time
* localResourceIds.
*/
suspend fun preloadFingerprint(): String {
val customerInfoSnapshot =
runCatching { Superwall.instance.getCustomerInfo().toString() }
.getOrDefault("")
val activeEntitlements =
runCatching {
Superwall.instance.entitlements.active
.sortedBy { it.id }
.joinToString(",") { "${it.id}:${it.type.raw}" }
}.getOrDefault("")
val activeProducts =
runCatching { factory.activeProductIds().sorted().joinToString(",") }
.getOrDefault("")
val reviewRequests =
runCatching { reviewRequestsTotal().toString() }.getOrDefault("0")

return listOf(
locale,
languageCode,
regionCode,
currencyCode,
currencySymbol,
secondsFromGMT,
interfaceStyle,
if (interfaceStyleOverride == null) "automatic" else "manual",
reviewRequests,
activeEntitlements,
customerInfoSnapshot,
activeProducts,
isSandbox.toString(),
).joinToString("|")
}
Comment thread
ianrumac marked this conversation as resolved.

suspend fun getDeviceAttributes(
sinceEvent: EventData?,
computedPropertyRequests: List<ComputedPropertyRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,8 @@ class SuperwallPaywallActivity : AppCompatActivity() {
}
}



suspend fun attemptToScheduleNotifications(
Comment thread
ianrumac marked this conversation as resolved.
notifications: List<LocalNotification>,
factory: DeviceHelperFactory,
Expand Down
Loading
Loading