From 3cd47900334dd964ac2fd35656aa88846011f326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:44:22 +0100 Subject: [PATCH 01/32] feat: add android install attribution matching --- .../main/java/com/superwall/sdk/Superwall.kt | 21 +++ .../trackable/TrackableSuperwallEvent.kt | 16 ++ .../superwall/AttributionMatchInfo.kt | 47 ++++++ .../sdk/analytics/superwall/SuperwallEvent.kt | 10 ++ .../analytics/superwall/SuperwallEvents.kt | 1 + .../sdk/config/options/SuperwallOptions.kt | 4 + .../sdk/dependencies/DependencyContainer.kt | 26 ++- .../com/superwall/sdk/network/MmpService.kt | 74 ++++++++ .../java/com/superwall/sdk/network/Network.kt | 159 ++++++++++++++++++ .../com/superwall/sdk/network/SuperwallAPI.kt | 2 + .../sdk/network/device/DeviceHelper.kt | 17 +- .../com/superwall/sdk/storage/CacheKeys.kt | 22 +++ .../com/superwall/sdk/storage/LocalStorage.kt | 44 +++++ .../com/superwall/sdk/web/DeepLinkReferrer.kt | 63 ++++--- 14 files changed, 481 insertions(+), 25 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/network/MmpService.kt diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 2a21ab93d..421cc3fbb 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -73,6 +73,7 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedUR import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedUrlInChrome import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.RequestPermission import com.superwall.sdk.storage.LatestCustomerInfo +import com.superwall.sdk.storage.DidTrackAppInstall import com.superwall.sdk.storage.ReviewCount import com.superwall.sdk.storage.ReviewData import com.superwall.sdk.storage.StoredSubscriptionStatus @@ -85,6 +86,7 @@ import com.superwall.sdk.store.transactions.TransactionManager import com.superwall.sdk.store.transactions.TransactionManager.PurchaseSource.* import com.superwall.sdk.utilities.flatten import com.superwall.sdk.utilities.withErrorTracking +import com.superwall.sdk.web.DeepLinkReferrer import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -716,6 +718,9 @@ class Superwall( ioScope.launch { withErrorTracking { + val hadTrackedAppInstallBeforeConfigure = + dependencyContainer.storage.read(DidTrackAppInstall) ?: false + dependencyContainer.storage.recordAppInstall { track(event = it) } @@ -733,6 +738,22 @@ class Superwall( }, ).awaitAll() } + + if ( + dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure, + appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis, + ) + ) { + val installReferrerClickId = + DeepLinkReferrer({ context }, ioScope) + .checkForMmpClickId() + .getOrNull() + + dependencyContainer.storage.recordMMPInstallAttributionRequest { + dependencyContainer.network.matchMMPInstall(installReferrerClickId) + } + } }.toResult().fold({ CoroutineScope(Dispatchers.Main).launch { completion?.invoke(Result.success(Unit)) diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt index be1a63ab5..5f2507056 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.analytics.internal.trackable +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo import com.superwall.sdk.analytics.superwall.SuperwallEvent import com.superwall.sdk.paywall.view.webview.messaging.PageViewData import com.superwall.sdk.analytics.superwall.TransactionProduct @@ -147,6 +148,21 @@ sealed class InternalSuperwallEvent( ) } + class AttributionMatch( + val info: AttributionMatchInfo, + override val audienceFilterParams: Map = emptyMap(), + ) : InternalSuperwallEvent(SuperwallEvent.AttributionMatch(info)) { + override suspend fun getSuperwallParameters(): Map = + listOfNotNull( + "provider" to info.provider.rawName, + "matched" to info.matched, + info.source?.let { "source" to it }, + info.confidence?.let { "confidence" to it.rawName }, + info.matchScore?.let { "match_score" to it }, + info.reason?.let { "reason" to it }, + ).toMap() + } + class IdentityAlias( override var audienceFilterParams: HashMap = HashMap(), ) : InternalSuperwallEvent(SuperwallEvent.IdentityAlias()) { diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt new file mode 100644 index 000000000..246e6861d --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt @@ -0,0 +1,47 @@ +package com.superwall.sdk.analytics.superwall + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +/** + * Information about an install attribution result emitted by Superwall. + */ +data class AttributionMatchInfo( + val provider: Provider, + val matched: Boolean, + val source: String? = null, + val confidence: Confidence? = null, + val matchScore: Double? = null, + val reason: String? = null, +) { + /** + * The attribution provider that produced the result. + */ + @Serializable + enum class Provider( + val rawName: String, + ) { + @SerialName("mmp") + MMP("mmp"), + + @SerialName("apple_search_ads") + APPLE_SEARCH_ADS("apple_search_ads"), + } + + /** + * The confidence level returned by the attribution provider. + */ + @Serializable + enum class Confidence( + val rawName: String, + ) { + @SerialName("high") + HIGH("high"), + + @SerialName("medium") + MEDIUM("medium"), + + @SerialName("low") + LOW("low"), + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt index b2b768822..f03e36e43 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt @@ -273,6 +273,16 @@ sealed class SuperwallEvent { get() = "user_attributes" } + /** + * When install attribution is resolved or fails to resolve. + */ + data class AttributionMatch( + val info: AttributionMatchInfo, + ) : SuperwallEvent() { + override val rawName: String + get() = "attribution_match" + } + data class NonRecurringProductPurchase( val product: TransactionProduct, val paywallInfo: PaywallInfo, diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt index c77d6236c..60caeb1c9 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt @@ -59,6 +59,7 @@ enum class SuperwallEvents( ReviewGranted("review_granted"), ReviewDenied("review_denied"), IntegrationAttributes("integration_attributes"), + AttributionMatch("attribution_match"), CustomerInfoDidChange("customerInfo_didChange"), PermissionRequested("permission_requested"), PermissionGranted("permission_granted"), diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index f00cfa12e..d2e4dc8f6 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -70,6 +70,8 @@ class SuperwallOptions() { override val collectorHost: String, override val scheme: String, override val port: Int?, + override val subscriptionHost: String = baseHost, + override val enrichmentHost: String = baseHost, ) : NetworkEnvironment(baseHost) } @@ -157,6 +159,8 @@ internal fun SuperwallOptions.NetworkEnvironment.toMap(): Map = "host_domain" to hostDomain, "base_host" to baseHost, "collector_host" to collectorHost, + "subscription_host" to subscriptionHost, + "enrichment_host" to enrichmentHost, "scheme" to scheme, port?.let { "port" to it }, ).toMap() diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index ff7c89980..723b46565 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -71,6 +71,7 @@ import com.superwall.sdk.network.BaseHostService import com.superwall.sdk.network.CollectorService import com.superwall.sdk.network.EnrichmentService import com.superwall.sdk.network.JsonFactory +import com.superwall.sdk.network.MmpService import com.superwall.sdk.network.Network import com.superwall.sdk.network.RequestExecutor import com.superwall.sdk.network.SubscriptionService @@ -410,8 +411,31 @@ class DependencyContainer( factory = this, customHttpUrlConnection = httpConnection, ), + mmpService = + MmpService( + host = api.subscription.host, + version = "/", + factory = this, + json = + Json(from = json()) { + ignoreUnknownKeys = true + namingStrategy = null + }, + customHttpUrlConnection = + CustomHttpUrlConnection( + json = + Json(from = json()) { + ignoreUnknownKeys = true + namingStrategy = null + }, + requestExecutor = + RequestExecutor { debugging, requestId -> + makeHeaders(debugging, requestId) + }, + ), + ), factory = this, - ) + ) errorTracker = ErrorTracker(scope = ioScope, cache = storage) paywallRequestManager = PaywallRequestManager( diff --git a/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt new file mode 100644 index 000000000..84cf63189 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt @@ -0,0 +1,74 @@ +package com.superwall.sdk.network + +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +@Serializable +data class MmpMatchRequest( + val platform: String, + val appUserId: String? = null, + val deviceId: String? = null, + val vendorId: String? = null, + val installReferrerClickId: Long? = null, + val appVersion: String? = null, + val sdkVersion: String? = null, + val osVersion: String? = null, + val deviceModel: String? = null, + val deviceLocale: String? = null, + val deviceLanguageCode: String? = null, + val timezoneOffsetSeconds: Int? = null, + val screenWidth: Int? = null, + val screenHeight: Int? = null, + val devicePixelRatio: Double? = null, + val bundleId: String? = null, + val clientTimestamp: String? = null, + val metadata: Map? = null, +) + +@Serializable +data class MmpMatchResponse( + val matched: Boolean, + val confidence: AttributionMatchInfo.Confidence? = null, + val matchScore: Double? = null, + val clickId: Int? = null, + val linkId: String? = null, + val network: String? = null, + val redirectUrl: String? = null, + val queryParams: Map? = null, + val acquisitionAttributes: Map? = null, + val matchedAt: String? = null, + val breakdown: Map? = null, +) + +class MmpService( + override val host: String, + override val version: String, + val factory: ApiFactory, + json: Json, + override val customHttpUrlConnection: CustomHttpUrlConnection, +) : NetworkService() { + override suspend fun makeHeaders( + isForDebugging: Boolean, + requestId: String, + ): Map = factory.makeHeaders(isForDebugging, requestId) + + private val json = + Json(json) { + namingStrategy = null + explicitNulls = false + ignoreUnknownKeys = true + coerceInputValues = true + } + + suspend fun matchInstall(request: MmpMatchRequest) = + post( + "api/match", + retryCount = 2, + body = json.encodeToString(request).toByteArray(), + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 2379f5a26..26f8328bf 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -1,7 +1,10 @@ package com.superwall.sdk.network +import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.identity.setUserAttributes import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -25,9 +28,19 @@ import com.superwall.sdk.models.internal.UserId import com.superwall.sdk.models.internal.WebRedemptionResponse import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse +import com.superwall.sdk.utilities.DateUtils +import com.superwall.sdk.utilities.dateFormat import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.util.Date +import java.util.TimeZone import java.util.UUID import kotlin.time.Duration @@ -35,9 +48,67 @@ open class Network( private val baseHostService: BaseHostService, private val collectorService: CollectorService, private val enrichmentService: EnrichmentService, + private val mmpService: MmpService, private val factory: ApiFactory, private val subscriptionService: SubscriptionService, ) : SuperwallAPI { + private fun currentIsoTimestamp(): String = + dateFormat(DateUtils.ISO_MILLIS).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date()) + "Z" + + private fun jsonElementToValue(value: JsonElement): Any? = + when (value) { + is JsonPrimitive -> { + val booleanValue = value.booleanOrNull + val longValue = value.longOrNull + val doubleValue = value.doubleOrNull + + when { + value.isString -> value.contentOrNull + booleanValue != null -> booleanValue + longValue != null -> longValue + doubleValue != null -> doubleValue + else -> value.contentOrNull + } + } + + else -> value.toString() + } + + private fun mergeMMPAcquisitionAttributesIfNeeded(acquisitionAttributes: Map) { + val attributes = + acquisitionAttributes.mapNotNull { (key, value) -> + val converted = jsonElementToValue(value) + if (converted != null) { + key to converted + } else { + null + } + }.toMap() + + if (attributes.isEmpty()) { + return + } + + val currentAttributes = factory.identityManager.userAttributes + val hasChanges = + attributes.any { (key, value) -> + currentAttributes[key]?.toString() != value.toString() + } + + if (!hasChanges) { + return + } + + Superwall.instance.setUserAttributes(attributes) + } + + private fun readJsonString( + value: Map?, + key: String, + ): String? = value?.get(key)?.jsonPrimitive?.contentOrNull + override suspend fun sendEvents(events: EventsRequest): Either = collectorService .events( @@ -128,6 +199,94 @@ open class Network( it.assignments }.logError("/assignments") + override suspend fun matchMMPInstall(installReferrerClickId: Long?): Boolean { + val deviceHelper = factory.deviceHelper + val metadata = + listOfNotNull( + deviceHelper.appInstalledAtString.takeIf { it.isNotEmpty() }?.let { + "appInstalledAt" to it + }, + deviceHelper.radioType.takeIf { it.isNotEmpty() }?.let { "radioType" to it }, + deviceHelper.interfaceStyle.takeIf { it.isNotEmpty() }?.let { + "interfaceStyle" to it + }, + deviceHelper.isLowPowerModeEnabled.takeIf { it.isNotEmpty() }?.let { + "isLowPowerModeEnabled" to it + }, + "isSandbox" to deviceHelper.isSandbox.toString(), + deviceHelper.platformWrapper.takeIf { it.isNotEmpty() }?.let { + "platformWrapper" to it + }, + deviceHelper.platformWrapperVersion.takeIf { it.isNotEmpty() }?.let { + "platformWrapperVersion" to it + }, + ).toMap() + + val request = + MmpMatchRequest( + platform = "android", + appUserId = factory.identityManager.appUserId, + deviceId = deviceHelper.deviceId, + vendorId = deviceHelper.vendorId, + installReferrerClickId = installReferrerClickId, + appVersion = deviceHelper.appVersion, + sdkVersion = deviceHelper.sdkVersion, + osVersion = deviceHelper.osVersion, + deviceModel = deviceHelper.model, + deviceLocale = deviceHelper.locale, + deviceLanguageCode = deviceHelper.languageCode, + timezoneOffsetSeconds = deviceHelper.timezoneOffsetSeconds, + screenWidth = deviceHelper.screenWidth, + screenHeight = deviceHelper.screenHeight, + devicePixelRatio = deviceHelper.devicePixelRatio, + bundleId = deviceHelper.bundleId, + clientTimestamp = currentIsoTimestamp(), + metadata = metadata, + ) + + return when ( + val result = + mmpService + .matchInstall(request) + .logError("/api/match", mapOf("payload" to request)) + ) { + is Either.Success -> { + val response = result.value + + response.acquisitionAttributes?.let(::mergeMMPAcquisitionAttributesIfNeeded) + + factory.track( + InternalSuperwallEvent.AttributionMatch( + AttributionMatchInfo( + provider = AttributionMatchInfo.Provider.MMP, + matched = response.matched, + source = readJsonString(response.acquisitionAttributes, "acquisition_source") + ?: response.network, + confidence = response.confidence, + matchScore = response.matchScore, + reason = readJsonString(response.breakdown, "reason"), + ), + ), + ) + + true + } + + is Either.Failure -> { + factory.track( + InternalSuperwallEvent.AttributionMatch( + AttributionMatchInfo( + provider = AttributionMatchInfo.Provider.MMP, + matched = false, + reason = "request_failed", + ), + ), + ) + false + } + } + } + override suspend fun redeemToken( codes: List, userId: UserId?, diff --git a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt index 250ccc3f2..f1dbef003 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt @@ -40,6 +40,8 @@ interface SuperwallAPI { suspend fun getAssignments(): Either, NetworkError> + suspend fun matchMMPInstall(installReferrerClickId: Long? = null): Boolean + suspend fun webEntitlementsByUserId( userId: UserId, deviceId: DeviceVendorId, diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index b65d3860f..e250f068a 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -276,8 +276,20 @@ class DeviceHelper( val currencySymbol: String get() = _currency?.symbol ?: "" + val timezoneOffsetSeconds: Int + get() = TimeZone.getDefault().rawOffset / 1000 + val secondsFromGMT: String - get() = (TimeZone.getDefault().rawOffset / 1000).toString() + get() = timezoneOffsetSeconds.toString() + + val screenWidth: Int + get() = classifier.getScreenWidth() + + val screenHeight: Int + get() = classifier.getScreenHeight() + + val devicePixelRatio: Double + get() = context.resources.displayMetrics.density.toDouble() val isFirstAppOpen: Boolean get() = !storage.didTrackFirstSession @@ -328,6 +340,9 @@ class DeviceHelper( val appInstalledAtString: String get() = dateFormat(DateUtils.SIMPLE).format(appInstallDate) + val appInstalledAtMillis: Long + get() = appInstallDate.time + var interfaceStyleOverride: InterfaceStyle? = null val fontSize: Int diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt index 2b3287dd2..6f67dd5b2 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -132,6 +132,28 @@ object DidTrackAppInstall : Storable { get() = Boolean.serializer() } +object DidCompleteMMPInstallAttributionRequest : Storable { + override val key: String + get() = "store.didCompleteMMPInstallAttributionRequest" + + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + + override val serializer: KSerializer + get() = Boolean.serializer() +} + +object IsEligibleForMMPInstallAttributionMatch : Storable { + override val key: String + get() = "store.isEligibleForMMPInstallAttributionMatch" + + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + + override val serializer: KSerializer + get() = Boolean.serializer() +} + object DidTrackFirstSeen : Storable { override val key: String get() = "store.didTrackFirstSeen.v2" diff --git a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt index 1091a886e..4976a16f4 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt @@ -33,6 +33,10 @@ open class LocalStorage( val coreDataManager: CoreDataManager = CoreDataManager(context = context), ) : Storage, CoroutineScope { + companion object { + private const val MMP_INSTALL_ATTRIBUTION_WINDOW_MS = 7L * 24 * 60 * 60 * 1000 + } + interface Factory : DeviceHelperFactory, HasExternalPurchaseControllerFactory @@ -179,6 +183,46 @@ open class LocalStorage( write(DidTrackAppInstall, true) } + private fun isMMPInstallAttributionWindowOpen(appInstalledAtMillis: Long): Boolean { + val ageMs = System.currentTimeMillis() - appInstalledAtMillis + return ageMs in 0..MMP_INSTALL_ATTRIBUTION_WINDOW_MS + } + + fun shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: Boolean, + appInstalledAtMillis: Long, + ): Boolean { + val didCompleteRequest = read(DidCompleteMMPInstallAttributionRequest) ?: false + if (didCompleteRequest) { + return false + } + + val isEligible = read(IsEligibleForMMPInstallAttributionMatch) ?: false + if (hadTrackedAppInstallBeforeConfigure && !isEligible) { + return false + } + + if (!isMMPInstallAttributionWindowOpen(appInstalledAtMillis)) { + return false + } + + write(IsEligibleForMMPInstallAttributionMatch, true) + return true + } + + fun recordMMPInstallAttributionRequest(matchRequest: suspend () -> Boolean) { + val didCompleteRequest = read(DidCompleteMMPInstallAttributionRequest) ?: false + if (didCompleteRequest) { + return + } + + ioScope.launch { + if (matchRequest()) { + write(DidCompleteMMPInstallAttributionRequest, true) + } + } + } + open fun clearCachedSessionEvents() { cache.delete(Transactions) } diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt index e9a6ffc32..02b44210a 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -96,21 +96,25 @@ class DeepLinkReferrer( override suspend fun checkForReferral(): Result = try { - withTimeoutOrNull(30.seconds) { - while (referrerClient?.isReady != true) { - // no-op - } - referrerClient?.installReferrer?.installReferrer?.toString() - }.let { - val query = it?.getUrlParams() ?: emptyMap() - val code = query["code"]?.firstOrNull() - referrerClient?.endConnection() - referrerClient = null - if (code == null) { - Result.failure(IllegalStateException("Play store cannot connect")) - } else { - Result.success(code) - } + val query = getInstallReferrerParams(30.seconds) + val code = query["code"]?.firstOrNull() + if (code == null) { + Result.failure(IllegalStateException("Play store cannot connect")) + } else { + Result.success(code) + } + } catch (e: Throwable) { + Result.failure(e) + } + + suspend fun checkForMmpClickId(): Result = + try { + val query = getInstallReferrerParams(5.seconds) + val clickId = query["sw_mmp_click_id"]?.firstOrNull()?.toLongOrNull() + if (clickId == null) { + Result.failure(IllegalStateException("Play store MMP click id not found")) + } else { + Result.success(clickId) } } catch (e: Throwable) { Result.failure(e) @@ -137,17 +141,30 @@ class DeepLinkReferrer( ) } + private suspend fun getInstallReferrerParams(timeout: kotlin.time.Duration): Map> { + val rawReferrer = + withTimeoutOrNull(timeout) { + while (referrerClient?.isReady != true) { + // no-op + } + referrerClient?.installReferrer?.installReferrer?.toString() + } + + referrerClient?.endConnection() + referrerClient = null + + return rawReferrer?.getUrlParams() ?: emptyMap() + } + private fun String.getUrlParams(): Map> { - val urlParts = split("\\?".toRegex()).filter(String::isNotEmpty) - if (urlParts.size < 2) { + val query = trim().removePrefix("?") + if (query.isEmpty()) { return emptyMap() } - val query = urlParts[1] - return listOf("item").associateWith { key -> - query - .split("&?$key=".toRegex()) - .filter(String::isNotEmpty) - .map { URLDecoder.decode(it, "UTF-8") } + + val uri = Uri.parse("https://superwall.invalid/?$query") + return uri.queryParameterNames.associateWith { key -> + uri.getQueryParameters(key).map { URLDecoder.decode(it, "UTF-8") } } } } From 4b0e811f6e04b7d133a71b3642ffd5bb7b780d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:46:32 +0100 Subject: [PATCH 02/32] style: format android attribution changes --- .../main/java/com/superwall/sdk/Superwall.kt | 2 +- .../superwall/AttributionMatchInfo.kt | 2 +- .../sdk/dependencies/DependencyContainer.kt | 2 +- .../java/com/superwall/sdk/network/Network.kt | 31 ++++++++++--------- .../sdk/network/device/DeviceHelper.kt | 4 ++- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 421cc3fbb..3b49657dc 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -72,8 +72,8 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedDe import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedURL import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedUrlInChrome import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.RequestPermission -import com.superwall.sdk.storage.LatestCustomerInfo import com.superwall.sdk.storage.DidTrackAppInstall +import com.superwall.sdk.storage.LatestCustomerInfo import com.superwall.sdk.storage.ReviewCount import com.superwall.sdk.storage.ReviewData import com.superwall.sdk.storage.StoredSubscriptionStatus diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt index 246e6861d..fb3ca438d 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt @@ -1,7 +1,7 @@ package com.superwall.sdk.analytics.superwall -import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Information about an install attribution result emitted by Superwall. diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 723b46565..d606d25b1 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -435,7 +435,7 @@ class DependencyContainer( ), ), factory = this, - ) + ) errorTracker = ErrorTracker(scope = ioScope, cache = storage) paywallRequestManager = PaywallRequestManager( diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 26f8328bf..0a0834e49 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -1,8 +1,8 @@ package com.superwall.sdk.network import com.superwall.sdk.Superwall -import com.superwall.sdk.analytics.superwall.AttributionMatchInfo import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo import com.superwall.sdk.dependencies.ApiFactory import com.superwall.sdk.identity.setUserAttributes import com.superwall.sdk.logger.LogLevel @@ -53,9 +53,10 @@ open class Network( private val subscriptionService: SubscriptionService, ) : SuperwallAPI { private fun currentIsoTimestamp(): String = - dateFormat(DateUtils.ISO_MILLIS).apply { - timeZone = TimeZone.getTimeZone("UTC") - }.format(Date()) + "Z" + dateFormat(DateUtils.ISO_MILLIS) + .apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date()) + "Z" private fun jsonElementToValue(value: JsonElement): Any? = when (value) { @@ -78,14 +79,15 @@ open class Network( private fun mergeMMPAcquisitionAttributesIfNeeded(acquisitionAttributes: Map) { val attributes = - acquisitionAttributes.mapNotNull { (key, value) -> - val converted = jsonElementToValue(value) - if (converted != null) { - key to converted - } else { - null - } - }.toMap() + acquisitionAttributes + .mapNotNull { (key, value) -> + val converted = jsonElementToValue(value) + if (converted != null) { + key to converted + } else { + null + } + }.toMap() if (attributes.isEmpty()) { return @@ -260,8 +262,9 @@ open class Network( AttributionMatchInfo( provider = AttributionMatchInfo.Provider.MMP, matched = response.matched, - source = readJsonString(response.acquisitionAttributes, "acquisition_source") - ?: response.network, + source = + readJsonString(response.acquisitionAttributes, "acquisition_source") + ?: response.network, confidence = response.confidence, matchScore = response.matchScore, reason = readJsonString(response.breakdown, "reason"), diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index e250f068a..66edd39d5 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -289,7 +289,9 @@ class DeviceHelper( get() = classifier.getScreenHeight() val devicePixelRatio: Double - get() = context.resources.displayMetrics.density.toDouble() + get() = + context.resources.displayMetrics.density + .toDouble() val isFirstAppOpen: Boolean get() = !storage.didTrackFirstSession From 33fe108a37e0e1d27e1cd2dba8d90bf6e532c438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:40:32 +0200 Subject: [PATCH 03/32] fix android mmp review issues --- .../java/com/superwall/sdk/network/MmpService.kt | 2 +- .../java/com/superwall/sdk/network/Network.kt | 3 +-- .../com/superwall/sdk/web/DeepLinkReferrer.kt | 16 ++++++++-------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt index 84cf63189..207a22f71 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt @@ -35,7 +35,7 @@ data class MmpMatchResponse( val matched: Boolean, val confidence: AttributionMatchInfo.Confidence? = null, val matchScore: Double? = null, - val clickId: Int? = null, + val clickId: Long? = null, val linkId: String? = null, val network: String? = null, val redirectUrl: String? = null, diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 0a0834e49..5c6ca1d8a 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -37,7 +37,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import java.util.Date import java.util.TimeZone @@ -109,7 +108,7 @@ open class Network( private fun readJsonString( value: Map?, key: String, - ): String? = value?.get(key)?.jsonPrimitive?.contentOrNull + ): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull override suspend fun sendEvents(events: EventsRequest): Either = collectorService diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt index 02b44210a..557f325b9 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -13,7 +13,6 @@ import com.superwall.sdk.misc.IOScope import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import java.net.URLDecoder import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -27,10 +26,11 @@ class DeepLinkReferrer( context: () -> Context, private val scope: IOScope, ) : CheckForReferral { - private var referrerClient: InstallReferrerClient? + private var referrerClient: InstallReferrerClient? = null + private val readyReferrerClient: InstallReferrerClient? get() { - if (field?.isReady == true) { - return field + if (referrerClient?.isReady == true) { + return referrerClient } else { return null } @@ -62,7 +62,7 @@ class DeepLinkReferrer( finished = { when (it) { InstallReferrerClient.InstallReferrerResponse.OK -> { - referrerClient?.installReferrer?.installReferrer + readyReferrerClient?.installReferrer?.installReferrer } else -> { @@ -144,10 +144,10 @@ class DeepLinkReferrer( private suspend fun getInstallReferrerParams(timeout: kotlin.time.Duration): Map> { val rawReferrer = withTimeoutOrNull(timeout) { - while (referrerClient?.isReady != true) { + while (readyReferrerClient == null) { // no-op } - referrerClient?.installReferrer?.installReferrer?.toString() + readyReferrerClient?.installReferrer?.installReferrer?.toString() } referrerClient?.endConnection() @@ -164,7 +164,7 @@ class DeepLinkReferrer( val uri = Uri.parse("https://superwall.invalid/?$query") return uri.queryParameterNames.associateWith { key -> - uri.getQueryParameters(key).map { URLDecoder.decode(it, "UTF-8") } + uri.getQueryParameters(key) } } } From 8d317a3b79c43f5412a4f853b704a395537395e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:59:59 +0200 Subject: [PATCH 04/32] remove android apple search ads attribution provider --- .../superwall/sdk/analytics/superwall/AttributionMatchInfo.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt index fb3ca438d..9a347e625 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt @@ -23,9 +23,6 @@ data class AttributionMatchInfo( ) { @SerialName("mmp") MMP("mmp"), - - @SerialName("apple_search_ads") - APPLE_SEARCH_ADS("apple_search_ads"), } /** From 0776127849795a9679d273d961456e93d779bf36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:29:39 +0200 Subject: [PATCH 05/32] await android mmp attribution request before config fetch --- .../main/java/com/superwall/sdk/storage/LocalStorage.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt index 4976a16f4..d0c21603c 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt @@ -210,16 +210,14 @@ open class LocalStorage( return true } - fun recordMMPInstallAttributionRequest(matchRequest: suspend () -> Boolean) { + suspend fun recordMMPInstallAttributionRequest(matchRequest: suspend () -> Boolean) { val didCompleteRequest = read(DidCompleteMMPInstallAttributionRequest) ?: false if (didCompleteRequest) { return } - ioScope.launch { - if (matchRequest()) { - write(DidCompleteMMPInstallAttributionRequest, true) - } + if (matchRequest()) { + write(DidCompleteMMPInstallAttributionRequest, true) } } From 4276a6ebced864053222efa2c5e60abf6b5c0099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:32:58 +0200 Subject: [PATCH 06/32] keep android mmp request off startup critical path --- .../java/com/superwall/sdk/storage/LocalStorage.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt index d0c21603c..38988ee0c 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt @@ -210,14 +210,18 @@ open class LocalStorage( return true } - suspend fun recordMMPInstallAttributionRequest(matchRequest: suspend () -> Boolean) { + fun recordMMPInstallAttributionRequest(matchRequest: suspend () -> Boolean) { val didCompleteRequest = read(DidCompleteMMPInstallAttributionRequest) ?: false if (didCompleteRequest) { return } - if (matchRequest()) { - write(DidCompleteMMPInstallAttributionRequest, true) + // Intentionally fire-and-forget so the initial config fetch stays on the startup critical path, + // matching the iOS SDK behavior. + ioScope.launch { + if (matchRequest()) { + write(DidCompleteMMPInstallAttributionRequest, true) + } } } From f5de4dfb7ea6f0e28ae883c380fff0e1f3a5e7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:18:02 +0200 Subject: [PATCH 07/32] fix android referrer and test network mock --- .../androidTest/java/com/superwall/sdk/network/NetworkMock.kt | 2 ++ .../src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt b/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt index bc85bf533..0b125cb3e 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt @@ -68,6 +68,8 @@ class NetworkMock : SuperwallAPI { @Throws(Exception::class) override suspend fun getAssignments(): Either, NetworkError> = Either.Success(assignments) + override suspend fun matchMMPInstall(installReferrerClickId: Long?): Boolean = false + override suspend fun webEntitlementsByUserId( userId: UserId, deviceId: DeviceVendorId, diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt index 557f325b9..021613f8b 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -10,6 +10,7 @@ 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 kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull @@ -145,7 +146,7 @@ class DeepLinkReferrer( val rawReferrer = withTimeoutOrNull(timeout) { while (readyReferrerClient == null) { - // no-op + delay(50) } readyReferrerClient?.installReferrer?.installReferrer?.toString() } From 3b57d05235fcc019292dbf96fa05c07d433cd3f4 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 27 Aug 2026 15:16:01 +0200 Subject: [PATCH 08/32] Update MMP resolution and linking --- CHANGELOG.md | 3 + .../com/superwall/sdk/network/NetworkMock.kt | 6 +- .../main/java/com/superwall/sdk/Superwall.kt | 30 ++- .../attribution/MMPAttributionManager.kt | 152 ++++++++++++ .../sdk/config/options/SuperwallOptions.kt | 12 + .../sdk/dependencies/DependencyContainer.kt | 28 ++- .../models/attribution/AttributionProvider.kt | 14 ++ .../java/com/superwall/sdk/network/API.kt | 9 + .../com/superwall/sdk/network/MmpService.kt | 40 ++- .../java/com/superwall/sdk/network/Network.kt | 116 +-------- .../com/superwall/sdk/network/SuperwallAPI.kt | 5 +- .../com/superwall/sdk/storage/CacheKeys.kt | 21 ++ .../com/superwall/sdk/storage/LocalStorage.kt | 14 +- .../com/superwall/sdk/web/DeepLinkReferrer.kt | 40 ++- .../attribution/MMPAttributionManagerTest.kt | 234 ++++++++++++++++++ .../sdk/network/MmpMatchResponseTest.kt | 228 +++++++++++++++++ .../sdk/storage/MMPInstallAttributionTest.kt | 167 +++++++++++++ 17 files changed, 986 insertions(+), 133 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/network/MmpMatchResponseTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/storage/MMPInstallAttributionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d919d668..d7e1a11ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,9 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 2.8.1 +## Enhancements +- Adds install attribution matching support. If you set up performance marketing integrations on the Superwall dashboard, the SDK will attempt to match the install and track an `attribution_match` event. The attribution properties will be added to user attributes so that they can be used as breakdowns and filters in the charts. The match runs once per install, within a 7-day window, off the startup critical path, and is skipped entirely when `eventTrackingBehavior` is set to `NONE`. Identifiers you've set via `Superwall.setIntegrationAttributes` are included in the match: `AttributionProvider.GOOGLE_ADS` (the Google Advertising ID) and `AttributionProvider.GOOGLE_APP_SET` are sent as the request's `aaid` and `appSetId` — the Android counterparts to `idfa` on iOS — and the remaining identifiers (`adjustId`, `appsflyerId`, `singularDeviceId` and the rest) are sent alongside them. The Play install referrer's click id is included when present. + ## Fixes - Paywalls with translations now render in the user's language on first paint instead of briefly showing the default language. - Paywall analytics events (`paywall_open`, `paywall_page_view`, `paywall_close`, etc.) now include a `presentation_id`, a unique identifier minted for each paywall presentation. Adds the previously-missing `close_reason`, `cache_key`, and `build_id` fields to these events, matching the data already sent by the iOS SDK. diff --git a/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt b/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt index 0b125cb3e..c045a1871 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt @@ -68,7 +68,11 @@ class NetworkMock : SuperwallAPI { @Throws(Exception::class) override suspend fun getAssignments(): Either, NetworkError> = Either.Success(assignments) - override suspend fun matchMMPInstall(installReferrerClickId: Long?): Boolean = false + override suspend fun matchMMPInstall( + installReferrerClickId: Long?, + integrationAttributes: Map, + ): Either = + Either.Failure(NetworkError.NotFound()) override suspend fun webEntitlementsByUserId( userId: UserId, diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 3b49657dc..1d325b54c 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -86,7 +86,6 @@ import com.superwall.sdk.store.transactions.TransactionManager import com.superwall.sdk.store.transactions.TransactionManager.PurchaseSource.* import com.superwall.sdk.utilities.flatten import com.superwall.sdk.utilities.withErrorTracking -import com.superwall.sdk.web.DeepLinkReferrer import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -739,19 +738,27 @@ class Superwall( ).awaitAll() } + // Skip install-attribution matching entirely when the developer has opted + // out of all event collection. The `/api/match` call and the + // `acquisition_*` attribute writes happen outside the event queue, so + // queue-level suppression wouldn't catch them. if ( + eventTrackingBehavior != EventTrackingBehavior.NONE && dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure, appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis, ) ) { - val installReferrerClickId = - DeepLinkReferrer({ context }, ioScope) - .checkForMmpClickId() - .getOrNull() - - dependencyContainer.storage.recordMMPInstallAttributionRequest { - dependencyContainer.network.matchMMPInstall(installReferrerClickId) + ioScope.launch { + val installReferrerClickId = + dependencyContainer.deepLinkReferrer + .checkForMmpClickId() + .getOrNull() + + dependencyContainer.storage.recordMMPInstallAttributionRequest { + dependencyContainer.mmpAttributionManager + .matchInstall(installReferrerClickId) + } } } }.toResult().fold({ @@ -962,6 +969,13 @@ class Superwall( // Called from identity actor's completeReset during identify // or full reset — just do cleanup without touching identity. dependencyContainer.storage.reset() + + // MMP install attribution is install-scoped. Re-apply the cached + // `acquisition_*` payload to the new user rather than re-running the match — + // the backend match only succeeds within the 7-day install window, so a + // logout after that would otherwise leave the new user without attributes. + dependencyContainer.mmpAttributionManager.reapplyCachedAcquisitionAttributes() + dependencyContainer.paywallManager.resetCache() presentationItems.reset() dependencyContainer.configManager.reset() diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt b/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt new file mode 100644 index 000000000..86b958b4f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt @@ -0,0 +1,152 @@ +package com.superwall.sdk.analytics.attribution + +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo +import com.superwall.sdk.identity.IdentityManager +import com.superwall.sdk.misc.Either +import com.superwall.sdk.network.MmpMatchResponse +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.storage.LocalStorage +import com.superwall.sdk.storage.MMPAcquisitionData +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Owns the MMP (mobile measurement partner) install-attribution flow: firing the + * match, persisting and re-applying the resolved install-scoped `acquisition_*` + * attributes, and tracking the outcome. + * + * [sendMatchRequest] is used purely as transport — it sends the request and returns the + * decoded response. Everything attribution-specific lives here, mirroring how + * `AttributionPoster` owns the Apple Search Ads flow on iOS. + */ +class MMPAttributionManager( + private val storage: LocalStorage, + private val identityManager: IdentityManager, + private val track: suspend (TrackableSuperwallEvent) -> Unit, + private val setUserAttributes: (Map) -> Unit, + private val sendMatchRequest: suspend (Long?) -> Either, +) { + /** + * Fires the install-attribution match and applies its result. + * + * On a successful response the resolved `acquisition_*` payload is cached + * (install-scoped, so it survives [com.superwall.sdk.Superwall.reset]) and merged + * into the current user's attributes. Returns whether the request completed — the + * caller uses this to persist the completion flag so the match isn't repeated. + */ + suspend fun matchInstall(installReferrerClickId: Long?): Boolean = + when (val result = sendMatchRequest(installReferrerClickId)) { + is Either.Success -> { + val response = result.value + + response.acquisitionAttributes?.let { + // Cache the resolved payload (install-scoped) so it can be re-applied to a + // new user's attributes after `reset` without re-matching against the backend. + storage.write(MMPAcquisitionData, it) + mergeAcquisitionAttributesIfNeeded(it) + } + + track( + InternalSuperwallEvent.AttributionMatch( + AttributionMatchInfo( + provider = AttributionMatchInfo.Provider.MMP, + matched = response.matched, + source = + readJsonString(response.acquisitionAttributes, "acquisition_source") + ?: response.network, + confidence = response.confidence, + matchScore = response.matchScore, + reason = readJsonString(response.breakdown, "reason"), + ), + ), + ) + + // A successful response means the request was processed, even if no + // attribution match was found. + true + } + + is Either.Failure -> { + track( + InternalSuperwallEvent.AttributionMatch( + AttributionMatchInfo( + provider = AttributionMatchInfo.Provider.MMP, + matched = false, + reason = "request_failed", + ), + ), + ) + false + } + } + + /** + * Re-applies the cached MMP `acquisition_*` payload to the current user's attributes. + * + * Called from [com.superwall.sdk.Superwall.reset] after user files are wiped so the new + * user identity inherits the install-scoped attribution without re-matching against the + * backend (which only succeeds within the 7-day install window). No-op if no match ever + * resolved. + */ + fun reapplyCachedAcquisitionAttributes() { + val cached = storage.read(MMPAcquisitionData) ?: return + mergeAcquisitionAttributesIfNeeded(cached) + } + + private fun mergeAcquisitionAttributesIfNeeded(acquisitionAttributes: Map) { + val attributes = + acquisitionAttributes + .mapNotNull { (key, value) -> + jsonElementToValue(value)?.let { key to it } + }.toMap() + + if (attributes.isEmpty()) { + return + } + + val currentAttributes = identityManager.userAttributes + val hasChanges = + attributes.any { (key, value) -> + currentAttributes[key]?.toString() != value.toString() + } + + if (!hasChanges) { + return + } + + setUserAttributes(attributes) + } + + private fun jsonElementToValue(value: JsonElement): Any? = + when { + value is JsonNull -> null + + value is JsonPrimitive -> { + val booleanValue = value.booleanOrNull + val longValue = value.longOrNull + val doubleValue = value.doubleOrNull + + when { + value.isString -> value.contentOrNull + booleanValue != null -> booleanValue + longValue != null -> longValue + doubleValue != null -> doubleValue + else -> value.contentOrNull + } + } + + else -> value.toString() + } + + private fun readJsonString( + value: Map?, + key: String, + ): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull +} diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index d2e4dc8f6..78bc94cf4 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -55,6 +55,16 @@ class SuperwallOptions() { "enrichment-api.superwall.dev" } + // Install-attribution matching runs on its own host, separate from the + // subscriptions API. Mirrors `mmpHost` on iOS. + open val mmpHost: String + get() = + if (this is Release) { + "mmp.superwall.com" + } else { + "mmp.superwall.dev" + } + open val port: Int? get() = null @@ -72,6 +82,7 @@ class SuperwallOptions() { override val port: Int?, override val subscriptionHost: String = baseHost, override val enrichmentHost: String = baseHost, + override val mmpHost: String = baseHost, ) : NetworkEnvironment(baseHost) } @@ -161,6 +172,7 @@ internal fun SuperwallOptions.NetworkEnvironment.toMap(): Map = "collector_host" to collectorHost, "subscription_host" to subscriptionHost, "enrichment_host" to enrichmentHost, + "mmp_host" to mmpHost, "scheme" to scheme, port?.let { "port" to it }, ).toMap() diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index d606d25b1..4de15cfe2 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -12,6 +12,7 @@ import com.superwall.sdk.SdkContextImpl import com.superwall.sdk.SdkContext import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.AttributionManager +import com.superwall.sdk.analytics.attribution.MMPAttributionManager import com.superwall.sdk.analytics.ClassifierDataFactory import com.superwall.sdk.analytics.DefaultClassifierDataFactory import com.superwall.sdk.analytics.DeviceClassifier @@ -46,6 +47,7 @@ import com.superwall.sdk.identity.IdentityPendingInterceptor import com.superwall.sdk.identity.IdentityPersistenceInterceptor import com.superwall.sdk.identity.IdentityState import com.superwall.sdk.identity.createInitialIdentityState +import com.superwall.sdk.identity.setUserAttributes import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -254,6 +256,8 @@ class DependencyContainer( internal val errorTracker: ErrorTracker internal val deepLinkRouter: DeepLinkRouter internal val attributionManager: AttributionManager + internal val mmpAttributionManager: MMPAttributionManager + internal val deepLinkReferrer: DeepLinkReferrer init { // For tracking when the app enters the background. @@ -413,7 +417,7 @@ class DependencyContainer( ), mmpService = MmpService( - host = api.subscription.host, + host = api.mmp.host, version = "/", factory = this, json = @@ -427,6 +431,11 @@ class DependencyContainer( Json(from = json()) { ignoreUnknownKeys = true namingStrategy = null + // The backend types `confidence` as a free-form string. + // Coerce an unrecognised value (e.g. a future tier) to the + // property default of `null` rather than failing the whole + // response decode. + coerceInputValues = true }, requestExecutor = RequestExecutor { debugging, requestId -> @@ -543,11 +552,15 @@ class DependencyContainer( sdkContext = sdkContext, ) + // A single install-referrer client, shared by web-checkout redemption and MMP + // install attribution — each instance opens its own Play connection. + deepLinkReferrer = DeepLinkReferrer({ context }, ioScope) + reedemer = WebPaywallRedeemer( context = context, ioScope = ioScope, - deepLinkReferrer = DeepLinkReferrer({ context }, ioScope), + deepLinkReferrer = deepLinkReferrer, network = network, storage = storage, customerInfoManager = customerInfoManager, @@ -770,6 +783,17 @@ class DependencyContainer( } }, vendorId = { VendorId(deviceHelper.vendorId) }) + mmpAttributionManager = + MMPAttributionManager( + storage = storage, + identityManager = identityManager, + track = { track(it) }, + setUserAttributes = { Superwall.instance.setUserAttributes(it) }, + sendMatchRequest = { clickId -> + network.matchMMPInstall(clickId, attributionManager.integrationAttributes) + }, + ) + /** * This loads the webview libraries in the background thread, giving us 100-200ms less lag * on first webview render. diff --git a/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt b/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt index 6e5844c26..7074e71e5 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt @@ -104,9 +104,23 @@ enum class AttributionProvider( @SerialName("mixpanel") MIXPANEL("mixpanel"), + /** + * The Google Advertising ID (AAID/GAID) for the device. + * + * The SDK collected this automatically until 2.5.5, when it was removed over Google's + * detection of the `AD_ID` permission; set it here instead. Install-attribution matching + * forwards it as the request's `aaid`, the same slot the SDK used to fill itself — the + * Android counterpart to `idfa` on iOS. + */ @SerialName("googleAds") GOOGLE_ADS("googleAds"), + /** + * The Google App Set ID for the device. + * + * As with [GOOGLE_ADS], the SDK collected this automatically until 2.5.5. Install-attribution + * matching forwards it as the request's `appSetId`. + */ @SerialName("googleAppSetId") GOOGLE_APP_SET("googleAppSetId"), diff --git a/superwall/src/main/java/com/superwall/sdk/network/API.kt b/superwall/src/main/java/com/superwall/sdk/network/API.kt index f4eaa4e82..45130e2cb 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/API.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/API.kt @@ -8,6 +8,7 @@ data class Api( val collector: Collector, val enrichment: Enrichment, val subscription: Subscriptions, + val mmp: Mmp, ) { companion object { const val version1 = "/api/v1/" @@ -21,6 +22,7 @@ data class Api( collector = Collector(networkEnvironment), enrichment = Enrichment(networkEnvironment), subscription = Subscriptions(networkEnvironment), + mmp = Mmp(networkEnvironment), ) data class Base( @@ -39,6 +41,13 @@ data class Api( // get() = "10.0.2.2:9909" } + data class Mmp( + private val networkEnvironment: SuperwallOptions.NetworkEnvironment, + ) { + val host: String + get() = networkEnvironment.mmpHost + } + data class Collector( private val networkEnvironment: SuperwallOptions.NetworkEnvironment, ) { diff --git a/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt index 207a22f71..1e82f0d34 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.network import com.superwall.sdk.analytics.superwall.AttributionMatchInfo import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.models.attribution.AttributionProvider import com.superwall.sdk.network.session.CustomHttpUrlConnection import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -14,6 +15,11 @@ data class MmpMatchRequest( val appUserId: String? = null, val deviceId: String? = null, val vendorId: String? = null, + // The Android counterpart to iOS's `idfa`/`idfv`. Sourced from the developer-supplied + // `AttributionProvider.GOOGLE_ADS` / `GOOGLE_APP_SET` integration attributes — the SDK + // stopped collecting them itself in 2.5.5. + val aaid: String? = null, + val appSetId: String? = null, val installReferrerClickId: Long? = null, val appVersion: String? = null, val sdkVersion: String? = null, @@ -28,6 +34,11 @@ data class MmpMatchRequest( val bundleId: String? = null, val clientTimestamp: String? = null, val metadata: Map? = null, + // The remaining third-party attribution identifiers the developer has set via + // `Superwall.setIntegrationAttributes` — the MMP ids (`adjustId`, `appsflyerId`, + // `singularDeviceId`, `kochavaDeviceId`, `tenjinId`) and friends. The advertising + // identifiers are promoted out of this map into [aaid] and [appSetId]. + val integrationAttributes: Map? = null, ) @Serializable @@ -45,6 +56,31 @@ data class MmpMatchResponse( val breakdown: Map? = null, ) +/** + * The advertising identifiers pulled out of the developer-supplied integration attributes, + * plus whatever attributes remain. + */ +internal data class PromotedAdvertisingIds( + val aaid: String?, + val appSetId: String?, + val remaining: Map, +) + +/** + * Promotes the Google advertising identifiers out of the integration attributes and into their + * own request fields, the way iOS sends `idfa` as a top-level field rather than loose metadata. + * + * Blank values are treated as absent, and a promoted key is removed from [remaining] so it isn't + * sent twice. + */ +internal fun Map.promoteAdvertisingIds(): PromotedAdvertisingIds = + PromotedAdvertisingIds( + aaid = this[AttributionProvider.GOOGLE_ADS.rawName]?.takeIf { it.isNotEmpty() }, + appSetId = this[AttributionProvider.GOOGLE_APP_SET.rawName]?.takeIf { it.isNotEmpty() }, + remaining = + this - AttributionProvider.GOOGLE_ADS.rawName - AttributionProvider.GOOGLE_APP_SET.rawName, + ) + class MmpService( override val host: String, override val version: String, @@ -57,12 +93,12 @@ class MmpService( requestId: String, ): Map = factory.makeHeaders(isForDebugging, requestId) + // Encode-only. Responses are decoded by [customHttpUrlConnection]'s own `Json`, which is + // where decode leniency (`coerceInputValues`) has to be configured. private val json = Json(json) { namingStrategy = null explicitNulls = false - ignoreUnknownKeys = true - coerceInputValues = true } suspend fun matchInstall(request: MmpMatchRequest) = diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 5c6ca1d8a..15e910607 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -1,10 +1,7 @@ package com.superwall.sdk.network -import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.analytics.superwall.AttributionMatchInfo import com.superwall.sdk.dependencies.ApiFactory -import com.superwall.sdk.identity.setUserAttributes import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -33,11 +30,6 @@ import com.superwall.sdk.utilities.dateFormat import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.longOrNull import java.util.Date import java.util.TimeZone import java.util.UUID @@ -57,59 +49,6 @@ open class Network( timeZone = TimeZone.getTimeZone("UTC") }.format(Date()) + "Z" - private fun jsonElementToValue(value: JsonElement): Any? = - when (value) { - is JsonPrimitive -> { - val booleanValue = value.booleanOrNull - val longValue = value.longOrNull - val doubleValue = value.doubleOrNull - - when { - value.isString -> value.contentOrNull - booleanValue != null -> booleanValue - longValue != null -> longValue - doubleValue != null -> doubleValue - else -> value.contentOrNull - } - } - - else -> value.toString() - } - - private fun mergeMMPAcquisitionAttributesIfNeeded(acquisitionAttributes: Map) { - val attributes = - acquisitionAttributes - .mapNotNull { (key, value) -> - val converted = jsonElementToValue(value) - if (converted != null) { - key to converted - } else { - null - } - }.toMap() - - if (attributes.isEmpty()) { - return - } - - val currentAttributes = factory.identityManager.userAttributes - val hasChanges = - attributes.any { (key, value) -> - currentAttributes[key]?.toString() != value.toString() - } - - if (!hasChanges) { - return - } - - Superwall.instance.setUserAttributes(attributes) - } - - private fun readJsonString( - value: Map?, - key: String, - ): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull - override suspend fun sendEvents(events: EventsRequest): Either = collectorService .events( @@ -200,7 +139,10 @@ open class Network( it.assignments }.logError("/assignments") - override suspend fun matchMMPInstall(installReferrerClickId: Long?): Boolean { + override suspend fun matchMMPInstall( + installReferrerClickId: Long?, + integrationAttributes: Map, + ): Either { val deviceHelper = factory.deviceHelper val metadata = listOfNotNull( @@ -223,12 +165,16 @@ open class Network( }, ).toMap() + val advertisingIds = integrationAttributes.promoteAdvertisingIds() + val request = MmpMatchRequest( platform = "android", appUserId = factory.identityManager.appUserId, deviceId = deviceHelper.deviceId, vendorId = deviceHelper.vendorId, + aaid = advertisingIds.aaid, + appSetId = advertisingIds.appSetId, installReferrerClickId = installReferrerClickId, appVersion = deviceHelper.appVersion, sdkVersion = deviceHelper.sdkVersion, @@ -243,50 +189,12 @@ open class Network( bundleId = deviceHelper.bundleId, clientTimestamp = currentIsoTimestamp(), metadata = metadata, + integrationAttributes = advertisingIds.remaining.takeIf { it.isNotEmpty() }, ) - return when ( - val result = - mmpService - .matchInstall(request) - .logError("/api/match", mapOf("payload" to request)) - ) { - is Either.Success -> { - val response = result.value - - response.acquisitionAttributes?.let(::mergeMMPAcquisitionAttributesIfNeeded) - - factory.track( - InternalSuperwallEvent.AttributionMatch( - AttributionMatchInfo( - provider = AttributionMatchInfo.Provider.MMP, - matched = response.matched, - source = - readJsonString(response.acquisitionAttributes, "acquisition_source") - ?: response.network, - confidence = response.confidence, - matchScore = response.matchScore, - reason = readJsonString(response.breakdown, "reason"), - ), - ), - ) - - true - } - - is Either.Failure -> { - factory.track( - InternalSuperwallEvent.AttributionMatch( - AttributionMatchInfo( - provider = AttributionMatchInfo.Provider.MMP, - matched = false, - reason = "request_failed", - ), - ), - ) - false - } - } + return mmpService + .matchInstall(request) + .logError("/api/match", mapOf("payload" to request)) } override suspend fun redeemToken( diff --git a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt index f1dbef003..d02b26712 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt @@ -40,7 +40,10 @@ interface SuperwallAPI { suspend fun getAssignments(): Either, NetworkError> - suspend fun matchMMPInstall(installReferrerClickId: Long? = null): Boolean + suspend fun matchMMPInstall( + installReferrerClickId: Long? = null, + integrationAttributes: Map = emptyMap(), + ): Either suspend fun webEntitlementsByUserId( userId: UserId, diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt index 6f67dd5b2..68b2b8c87 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement import java.io.File import java.security.MessageDigest import java.util.Date @@ -143,6 +144,26 @@ object DidCompleteMMPInstallAttributionRequest : Storable { get() = Boolean.serializer() } +/** + * The decoded MMP `acquisition_*` payload from the last successful install match, + * cached so it can be re-applied to a new user's attributes after [com.superwall.sdk.Superwall.reset]. + * + * Install-scoped: the install source doesn't change when one user logs out and another logs in + * on the same device. The backend match only runs within the 7-day install window, so re-matching + * after a reset can't be relied on — caching the resolved payload lets us repopulate the new user + * deterministically, without re-hitting the backend. + */ +object MMPAcquisitionData : Storable> { + override val key: String + get() = "store.mmpAcquisitionData" + + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + + override val serializer: KSerializer> + get() = MapSerializer(String.serializer(), JsonElement.serializer()) +} + object IsEligibleForMMPInstallAttributionMatch : Storable { override val key: String get() = "store.isEligibleForMMPInstallAttributionMatch" diff --git a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt index 38988ee0c..78eb51b14 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt @@ -184,8 +184,16 @@ open class LocalStorage( } private fun isMMPInstallAttributionWindowOpen(appInstalledAtMillis: Long): Boolean { + // Fail open when the install date is unusable — unset/epoch-zero, or in the future + // because the device clock is skewed. Mirrors iOS, which treats an empty or + // unparseable `appInstalledAtString` as in-window: silently dropping attribution + // over a bad clock is worse than occasionally attempting a match that won't land. + if (appInstalledAtMillis <= 0L) { + return true + } + val ageMs = System.currentTimeMillis() - appInstalledAtMillis - return ageMs in 0..MMP_INSTALL_ATTRIBUTION_WINDOW_MS + return ageMs <= MMP_INSTALL_ATTRIBUTION_WINDOW_MS } fun shouldAttemptInitialMMPInstallAttributionMatch( @@ -216,8 +224,8 @@ open class LocalStorage( return } - // Intentionally fire-and-forget so the initial config fetch stays on the startup critical path, - // matching the iOS SDK behavior. + // Intentionally fire-and-forget so the match never blocks the caller. Matches the iOS SDK, + // where `recordMMPInstallAttributionMatch` returns a detached Task. ioScope.launch { if (matchRequest()) { write(DidCompleteMMPInstallAttributionRequest, true) diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt index 021613f8b..635ccf725 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -12,6 +12,8 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.IOScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.Duration.Companion.milliseconds @@ -28,6 +30,8 @@ class DeepLinkReferrer( private val scope: IOScope, ) : CheckForReferral { private var referrerClient: InstallReferrerClient? = null + private val referrerMutex = Mutex() + private var cachedReferrerParams: Map>? = null private val readyReferrerClient: InstallReferrerClient? get() { if (referrerClient?.isReady == true) { @@ -142,20 +146,32 @@ class DeepLinkReferrer( ) } - private suspend fun getInstallReferrerParams(timeout: kotlin.time.Duration): Map> { - val rawReferrer = - withTimeoutOrNull(timeout) { - while (readyReferrerClient == null) { - delay(50) - } - readyReferrerClient?.installReferrer?.installReferrer?.toString() - } + /** + * Resolves the Play install referrer's query params. + * + * This instance is shared between web-checkout redemption and MMP install attribution, so + * the result is memoized: the referrer is immutable for the lifetime of an install, and + * the first reader would otherwise tear the connection down before the second one runs. + * A timeout is *not* memoized — Play can simply be slow at cold start — and leaves the + * client connected so a later call can still succeed. + */ + private suspend fun getInstallReferrerParams(timeout: kotlin.time.Duration): Map> = + referrerMutex.withLock { + cachedReferrerParams?.let { return@withLock it } + + val rawReferrer = + withTimeoutOrNull(timeout) { + while (readyReferrerClient == null) { + delay(50) + } + readyReferrerClient?.installReferrer?.installReferrer?.toString() + } ?: return@withLock emptyMap() - referrerClient?.endConnection() - referrerClient = null + referrerClient?.endConnection() + referrerClient = null - return rawReferrer?.getUrlParams() ?: emptyMap() - } + rawReferrer.getUrlParams().also { cachedReferrerParams = it } + } private fun String.getUrlParams(): Map> { val query = trim().removePrefix("?") diff --git a/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt new file mode 100644 index 000000000..7cfea6d9d --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt @@ -0,0 +1,234 @@ +package com.superwall.sdk.analytics.attribution + +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo +import com.superwall.sdk.identity.IdentityManager +import com.superwall.sdk.misc.Either +import com.superwall.sdk.network.MmpMatchResponse +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.storage.LocalStorage +import com.superwall.sdk.storage.MMPAcquisitionData +import com.superwall.sdk.storage.Storable +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Mirrors iOS's `MMPAttributionManager` behaviour: cache the resolved `acquisition_*` + * payload, merge it into user attributes, track the outcome, and re-apply the cache after + * a reset without re-hitting the backend. + */ +class MMPAttributionManagerTest { + private val stored = mutableMapOf() + + private fun storage(): LocalStorage { + val storage = mockk(relaxed = true) + every { storage.write(any>(), any()) } answers { + stored[firstArg>().key] = secondArg() + } + every { storage.read(any>()) } answers { + stored[firstArg>().key] + } + return storage + } + + private fun identityManager(attributes: Map = emptyMap()): IdentityManager { + val identityManager = mockk(relaxed = true) + every { identityManager.userAttributes } returns attributes + return identityManager + } + + private fun matched( + acquisitionAttributes: Map? = + mapOf( + "acquisition_source" to JsonPrimitive("tiktok"), + "acquisition_campaign" to JsonPrimitive("spring_sale"), + ), + ) = MmpMatchResponse( + matched = true, + confidence = AttributionMatchInfo.Confidence.HIGH, + matchScore = 0.92, + network = "tiktok_ads", + acquisitionAttributes = acquisitionAttributes, + breakdown = mapOf("reason" to JsonPrimitive("ip_and_fingerprint")), + ) + + @Test + fun `a successful match caches the payload, merges attributes and tracks the outcome`() = + runTest { + val tracked = mutableListOf() + val applied = mutableListOf>() + + val manager = + MMPAttributionManager( + storage = storage(), + identityManager = identityManager(), + track = { tracked += it }, + setUserAttributes = { applied += it }, + sendMatchRequest = { Either.Success(matched()) }, + ) + + val completed = manager.matchInstall(installReferrerClickId = 42L) + + assertTrue(completed) + assertEquals("tiktok", applied.single()["acquisition_source"]) + assertEquals("spring_sale", applied.single()["acquisition_campaign"]) + assertEquals(2, (stored[MMPAcquisitionData.key] as Map<*, *>).size) + + val event = tracked.single() as InternalSuperwallEvent.AttributionMatch + assertTrue(event.info.matched) + assertEquals(AttributionMatchInfo.Provider.MMP, event.info.provider) + assertEquals("tiktok", event.info.source) + assertEquals(AttributionMatchInfo.Confidence.HIGH, event.info.confidence) + assertEquals(0.92, event.info.matchScore!!, 0.0001) + assertEquals("ip_and_fingerprint", event.info.reason) + } + + @Test + fun `source falls back to the network name when no acquisition_source is present`() = + runTest { + val tracked = mutableListOf() + + val manager = + MMPAttributionManager( + storage = storage(), + identityManager = identityManager(), + track = { tracked += it }, + setUserAttributes = {}, + sendMatchRequest = { + Either.Success( + matched(acquisitionAttributes = mapOf("acquisition_campaign" to JsonPrimitive("x"))), + ) + }, + ) + + manager.matchInstall(null) + + val event = tracked.single() as InternalSuperwallEvent.AttributionMatch + assertEquals("tiktok_ads", event.info.source) + } + + @Test + fun `an unmatched response still counts as a completed request`() = + runTest { + val tracked = mutableListOf() + + val manager = + MMPAttributionManager( + storage = storage(), + identityManager = identityManager(), + track = { tracked += it }, + setUserAttributes = {}, + sendMatchRequest = { Either.Success(MmpMatchResponse(matched = false)) }, + ) + + // A processed request that found nothing must not be retried on next launch. + assertTrue(manager.matchInstall(null)) + assertFalse((tracked.single() as InternalSuperwallEvent.AttributionMatch).info.matched) + assertNull(stored[MMPAcquisitionData.key]) + } + + @Test + fun `a failed request tracks request_failed and does not count as completed`() = + runTest { + val tracked = mutableListOf() + + val manager = + MMPAttributionManager( + storage = storage(), + identityManager = identityManager(), + track = { tracked += it }, + setUserAttributes = {}, + sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, + ) + + assertFalse(manager.matchInstall(null)) + + val event = tracked.single() as InternalSuperwallEvent.AttributionMatch + assertFalse(event.info.matched) + assertEquals("request_failed", event.info.reason) + } + + @Test + fun `attributes already on the user are not re-applied`() = + runTest { + val applied = mutableListOf>() + + val manager = + MMPAttributionManager( + storage = storage(), + identityManager = + identityManager( + mapOf( + "acquisition_source" to "tiktok", + "acquisition_campaign" to "spring_sale", + ), + ), + track = {}, + setUserAttributes = { applied += it }, + sendMatchRequest = { Either.Success(matched()) }, + ) + + manager.matchInstall(null) + + assertTrue(applied.isEmpty()) + } + + @Test + fun `reapply restores the cached payload to a new user without re-matching`() = + runTest { + val applied = mutableListOf>() + var requests = 0 + val storage = storage() + + // First run: match resolves and caches. + MMPAttributionManager( + storage = storage, + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { + requests += 1 + Either.Success(matched()) + }, + ).matchInstall(null) + + // After `reset()` the user's attributes are gone, but the install-scoped cache isn't. + MMPAttributionManager( + storage = storage, + identityManager = identityManager(), + track = {}, + setUserAttributes = { applied += it }, + sendMatchRequest = { + requests += 1 + Either.Success(matched()) + }, + ).reapplyCachedAcquisitionAttributes() + + assertEquals(1, requests) + assertEquals("tiktok", applied.single()["acquisition_source"]) + } + + @Test + fun `reapply is a no-op when no match ever resolved`() = + runTest { + val applied = mutableListOf>() + + MMPAttributionManager( + storage = storage(), + identityManager = identityManager(), + track = {}, + setUserAttributes = { applied += it }, + sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, + ).reapplyCachedAcquisitionAttributes() + + assertTrue(applied.isEmpty()) + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/network/MmpMatchResponseTest.kt b/superwall/src/test/java/com/superwall/sdk/network/MmpMatchResponseTest.kt new file mode 100644 index 000000000..7112defe3 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/network/MmpMatchResponseTest.kt @@ -0,0 +1,228 @@ +package com.superwall.sdk.network + +import com.superwall.sdk.analytics.superwall.AttributionMatchInfo +import com.superwall.sdk.models.attribution.AttributionProvider +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Mirrors `MMPMatchResponseTests` on iOS. The decoder configuration under test is the one + * `DependencyContainer` hands to `MmpService`'s `CustomHttpUrlConnection`. + */ +class MmpMatchResponseTest { + private val json = + Json { + ignoreUnknownKeys = true + namingStrategy = null + coerceInputValues = true + } + + private fun decode(raw: String): MmpMatchResponse = json.decodeFromString(raw) + + + @Test + fun `decodes queryParams with a duplicated key as an array`() { + val response = + decode( + """ + { + "matched": true, + "queryParams": { "utm_source": "google", "tag": ["a", "b"] } + } + """.trimIndent(), + ) + + assertTrue(response.matched) + assertEquals("google", response.queryParams?.get("utm_source")?.jsonPrimitive?.contentOrNull) + assertEquals(2, (response.queryParams?.get("tag") as JsonArray).size) + } + + @Test + fun `decodes an unknown confidence tier as null instead of throwing`() { + val response = + decode( + """ + { "matched": true, "confidence": "extremely_high" } + """.trimIndent(), + ) + + assertTrue(response.matched) + assertNull(response.confidence) + } + + @Test + fun `decodes known confidence levels`() { + assertEquals( + AttributionMatchInfo.Confidence.HIGH, + decode("""{ "matched": true, "confidence": "high" }""").confidence, + ) + assertEquals( + AttributionMatchInfo.Confidence.MEDIUM, + decode("""{ "matched": true, "confidence": "medium" }""").confidence, + ) + assertEquals( + AttributionMatchInfo.Confidence.LOW, + decode("""{ "matched": true, "confidence": "low" }""").confidence, + ) + } + + @Test + fun `decodes an unmatched response with null fields`() { + val response = + decode( + """ + { + "matched": false, + "confidence": null, + "matchScore": null, + "clickId": null, + "network": null, + "acquisitionAttributes": null, + "breakdown": { "reason": "no_click_found" } + } + """.trimIndent(), + ) + + assertEquals(false, response.matched) + assertNull(response.confidence) + assertNull(response.matchScore) + assertNull(response.acquisitionAttributes) + assertEquals( + "no_click_found", + (response.breakdown?.get("reason") as JsonPrimitive).contentOrNull, + ) + } + + @Test + fun `decodes unknown top level keys without failing`() { + val response = + decode( + """ + { "matched": true, "somethingNewFromTheBackend": { "a": 1 } } + """.trimIndent(), + ) + + assertTrue(response.matched) + } + + @Test + fun `encodes the request without null fields and in camelCase`() { + val encoder = + Json { + namingStrategy = null + explicitNulls = false + } + val encoded = + encoder.encodeToString( + MmpMatchRequest.serializer(), + MmpMatchRequest( + platform = "android", + appUserId = "abc", + installReferrerClickId = 42L, + ), + ) + + assertTrue(encoded.contains("\"appUserId\":\"abc\"")) + assertTrue(encoded.contains("\"installReferrerClickId\":42")) + assertTrue(!encoded.contains("deviceId")) + } + + @Test + fun `promotes the advertising identifiers into their own fields`() { + val promoted = + mapOf( + AttributionProvider.GOOGLE_ADS.rawName to "aaid-value", + AttributionProvider.GOOGLE_APP_SET.rawName to "app-set-value", + AttributionProvider.ADJUST_ID.rawName to "adjust-value", + ).promoteAdvertisingIds() + + assertEquals("aaid-value", promoted.aaid) + assertEquals("app-set-value", promoted.appSetId) + // Promoted keys must not be sent twice. + assertEquals(mapOf("adjustId" to "adjust-value"), promoted.remaining) + } + + @Test + fun `leaves the advertising identifiers null when the developer set none`() { + val promoted = + mapOf(AttributionProvider.ADJUST_ID.rawName to "adjust-value").promoteAdvertisingIds() + + assertNull(promoted.aaid) + assertNull(promoted.appSetId) + assertEquals(1, promoted.remaining.size) + } + + @Test + fun `treats a blank advertising identifier as absent but still consumes the key`() { + val promoted = mapOf(AttributionProvider.GOOGLE_ADS.rawName to "").promoteAdvertisingIds() + + assertNull(promoted.aaid) + assertTrue(promoted.remaining.isEmpty()) + } + + @Test + fun `encodes the promoted advertising identifiers as top level fields`() { + val encoder = + Json { + namingStrategy = null + explicitNulls = false + } + val promoted = + mapOf(AttributionProvider.GOOGLE_ADS.rawName to "aaid-value").promoteAdvertisingIds() + val encoded = + encoder.encodeToString( + MmpMatchRequest.serializer(), + MmpMatchRequest(platform = "android", aaid = promoted.aaid), + ) + + assertTrue(encoded.contains("\"aaid\":\"aaid-value\"")) + assertTrue(!encoded.contains("googleAds")) + } + + @Test + fun `encodes integration attributes when the developer has set any`() { + val encoder = + Json { + namingStrategy = null + explicitNulls = false + } + val encoded = + encoder.encodeToString( + MmpMatchRequest.serializer(), + MmpMatchRequest( + platform = "android", + integrationAttributes = + mapOf( + "googleAppSetId" to "app-set-id", + "adjustId" to "adjust-id", + ), + ), + ) + + assertTrue(encoded.contains("\"googleAppSetId\":\"app-set-id\"")) + assertTrue(encoded.contains("\"adjustId\":\"adjust-id\"")) + } + + @Test + fun `omits integration attributes entirely when none are set`() { + val encoder = + Json { + namingStrategy = null + explicitNulls = false + } + val encoded = + encoder.encodeToString( + MmpMatchRequest.serializer(), + MmpMatchRequest(platform = "android"), + ) + + assertTrue(!encoded.contains("integrationAttributes")) + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/storage/MMPInstallAttributionTest.kt b/superwall/src/test/java/com/superwall/sdk/storage/MMPInstallAttributionTest.kt new file mode 100644 index 000000000..c3d491120 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/storage/MMPInstallAttributionTest.kt @@ -0,0 +1,167 @@ +package com.superwall.sdk.storage + +import android.content.Context +import com.superwall.sdk.misc.IOScope +import io.mockk.mockk +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Mirrors `MMPInstallAttributionTests` on iOS. + */ +@RunWith(RobolectricTestRunner::class) +class MMPInstallAttributionTest { + private lateinit var context: Context + private lateinit var storage: LocalStorage + + private val dayMs = 24L * 60 * 60 * 1000 + + private fun installedDaysAgo(days: Double): Long = + System.currentTimeMillis() - (days * dayMs).toLong() + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + val json = Json { ignoreUnknownKeys = true } + storage = + LocalStorage( + context = context, + json = json, + _apiKey = "test_key", + factory = mockk(relaxed = true), + ioScope = IOScope(UnconfinedTestDispatcher()), + cache = Cache(context, ioQueue = UnconfinedTestDispatcher(), json = json), + coreDataManager = mockk(relaxed = true), + ) + } + + @Test + fun `fresh install within the window is eligible and is marked eligible`() { + val result = + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = false, + appInstalledAtMillis = installedDaysAgo(1.0), + ) + + assertTrue(result) + assertEquals(true, storage.read(IsEligibleForMMPInstallAttributionMatch)) + } + + @Test + fun `an already completed request is not retried`() { + storage.write(DidCompleteMMPInstallAttributionRequest, true) + + val result = + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = false, + appInstalledAtMillis = installedDaysAgo(1.0), + ) + + assertFalse(result) + } + + @Test + fun `an upgrader that never became eligible is skipped`() { + // Already tracked app install on a previous SDK version, and was never marked eligible. + val result = + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = true, + appInstalledAtMillis = installedDaysAgo(1.0), + ) + + assertFalse(result) + assertNull(storage.read(IsEligibleForMMPInstallAttributionMatch)) + } + + @Test + fun `an eligible returning session retries the match`() { + storage.write(IsEligibleForMMPInstallAttributionMatch, true) + + val result = + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = true, + appInstalledAtMillis = installedDaysAgo(2.0), + ) + + assertTrue(result) + } + + @Test + fun `an install outside the seven day window is skipped`() { + val result = + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = false, + appInstalledAtMillis = installedDaysAgo(8.0), + ) + + assertFalse(result) + } + + @Test + fun `an install right at the window boundary is still eligible`() { + val result = + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = false, + appInstalledAtMillis = installedDaysAgo(6.9), + ) + + assertTrue(result) + } + + @Test + fun `an unknown install date is treated as within the window`() { + // Fails open, matching iOS: an unusable install date must not silently drop attribution. + assertTrue( + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = false, + appInstalledAtMillis = 0L, + ), + ) + } + + @Test + fun `a future install date from a skewed clock is treated as within the window`() { + assertTrue( + storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = false, + appInstalledAtMillis = System.currentTimeMillis() + dayMs, + ), + ) + } + + @Test + fun `a completed request is not re-run once recorded`() { + var calls = 0 + storage.write(DidCompleteMMPInstallAttributionRequest, true) + + storage.recordMMPInstallAttributionRequest { + calls += 1 + true + } + + assertEquals(0, calls) + } + + @Test + fun `a failed request does not mark the match complete`() { + storage.recordMMPInstallAttributionRequest { false } + + assertNull(storage.read(DidCompleteMMPInstallAttributionRequest)) + } + + @Test + fun `a successful request marks the match complete`() { + storage.recordMMPInstallAttributionRequest { true } + + assertEquals(true, storage.read(DidCompleteMMPInstallAttributionRequest)) + } +} From fb2d95bbed0a463dec4b301805616c93336c5e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:03:35 +0200 Subject: [PATCH 09/32] fix(mmp): gate install matching on config, redeem referrer codes once, keep acquisition attributes across identify - Only fire the install match once config has attribution_options.mmp.enabled, like iOS. Eligibility is still recorded at launch, and a match skipped while tracking is off starts when the app opts back in. - Redeem the install-referrer code once per install. Play keeps the referrer for the life of the install, so it was redeemed on every launch. - Merge the cached acquisition_* attributes inside the identity reset itself, so identifying as a different user no longer drops them. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/superwall/sdk/Superwall.kt | 46 +++--- .../attribution/MMPAttributionManager.kt | 70 +++++++-- .../sdk/dependencies/DependencyContainer.kt | 3 + .../superwall/sdk/identity/IdentityContext.kt | 3 + .../superwall/sdk/identity/IdentityManager.kt | 1 + .../sdk/identity/IdentityManagerActor.kt | 2 + .../sdk/models/config/AttributionOptions.kt | 16 ++ .../com/superwall/sdk/models/config/Config.kt | 5 + .../com/superwall/sdk/storage/CacheKeys.kt | 12 ++ .../superwall/sdk/web/WebPaywallRedeemer.kt | 11 +- .../attribution/MMPAttributionManagerTest.kt | 146 +++++++++++++++--- .../identity/IdentityActorIntegrationTest.kt | 56 +++++++ .../sdk/web/WebPaywallRedeemerTest.kt | 32 ++++ 13 files changed, 344 insertions(+), 59 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/models/config/AttributionOptions.kt diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 1d325b54c..fff1abd51 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -175,6 +175,10 @@ class Superwall( paywallView?.webView?.messageHandler?.passEventTrackingBehaviorToWebView(newValue) } + if (newValue != EventTrackingBehavior.NONE) { + dependencyContainer.mmpAttributionManager.startMatchIfEnabled() + } + // When opting out entirely, don't emit the config-attributes event — // it would otherwise transmit attributes right after the opt-out. if (newValue == EventTrackingBehavior.NONE) { @@ -738,27 +742,35 @@ class Superwall( ).awaitAll() } - // Skip install-attribution matching entirely when the developer has opted - // out of all event collection. The `/api/match` call and the - // `acquisition_*` attribute writes happen outside the event queue, so - // queue-level suppression wouldn't catch them. + // The eligibility check runs whatever the config or tracking setting + // says: it records that this install may be matched, which a later + // launch relies on. Only the request waits for config to enable the MMP. if ( - eventTrackingBehavior != EventTrackingBehavior.NONE && dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure, appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis, ) ) { - ioScope.launch { - val installReferrerClickId = - dependencyContainer.deepLinkReferrer - .checkForMmpClickId() - .getOrNull() - - dependencyContainer.storage.recordMMPInstallAttributionRequest { - dependencyContainer.mmpAttributionManager - .matchInstall(installReferrerClickId) + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + // Skip matching when the app has opted out of all event collection. + // The `/api/match` call and the `acquisition_*` attribute writes + // happen outside the event queue, so queue-level suppression + // wouldn't catch them. It's tried again if the app opts back in. + if (eventTrackingBehavior == EventTrackingBehavior.NONE) { + return@matchInstallOnceEnabled false } + ioScope.launch { + val installReferrerClickId = + dependencyContainer.deepLinkReferrer + .checkForMmpClickId() + .getOrNull() + + dependencyContainer.storage.recordMMPInstallAttributionRequest { + dependencyContainer.mmpAttributionManager + .matchInstall(installReferrerClickId) + } + } + true } } }.toResult().fold({ @@ -970,12 +982,6 @@ class Superwall( // or full reset — just do cleanup without touching identity. dependencyContainer.storage.reset() - // MMP install attribution is install-scoped. Re-apply the cached - // `acquisition_*` payload to the new user rather than re-running the match — - // the backend match only succeeds within the 7-day install window, so a - // logout after that would otherwise leave the new user without attributes. - dependencyContainer.mmpAttributionManager.reapplyCachedAcquisitionAttributes() - dependencyContainer.paywallManager.resetCache() presentationItems.reset() dependencyContainer.configManager.reset() diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt b/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt index 86b958b4f..7396b0d7c 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt @@ -3,12 +3,18 @@ package com.superwall.sdk.analytics.attribution import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.analytics.superwall.AttributionMatchInfo +import com.superwall.sdk.config.ConfigState +import com.superwall.sdk.config.getConfig import com.superwall.sdk.identity.IdentityManager import com.superwall.sdk.misc.Either import com.superwall.sdk.network.MmpMatchResponse import com.superwall.sdk.network.NetworkError import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.MMPAcquisitionData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive @@ -26,13 +32,19 @@ import kotlinx.serialization.json.longOrNull * decoded response. Everything attribution-specific lives here, mirroring how * `AttributionPoster` owns the Apple Search Ads flow on iOS. */ -class MMPAttributionManager( +internal class MMPAttributionManager( private val storage: LocalStorage, private val identityManager: IdentityManager, private val track: suspend (TrackableSuperwallEvent) -> Unit, private val setUserAttributes: (Map) -> Unit, private val sendMatchRequest: suspend (Long?) -> Either, + private val configState: StateFlow, + private val scope: CoroutineScope, ) { + private val lock = Any() + private var startMatch: (() -> Boolean)? = null + private var hasStartedMatch = false + /** * Fires the install-attribution match and applies its result. * @@ -88,24 +100,49 @@ class MMPAttributionManager( } /** - * Re-applies the cached MMP `acquisition_*` payload to the current user's attributes. + * The cached MMP `acquisition_*` payload as attribute values, or empty if no match + * ever resolved. The identity reset merges these into every new user, since the + * backend match only succeeds within the 7-day install window. + */ + fun cachedAcquisitionAttributes(): Map = + storage.read(MMPAcquisitionData)?.toAttributeValues().orEmpty() + + /** + * Calls [startMatch] once config turns the MMP on for this app, which may be + * straight away. It's off by default, so it never fires unless the backend + * enables it. * - * Called from [com.superwall.sdk.Superwall.reset] after user files are wiped so the new - * user identity inherits the install-scoped attribution without re-matching against the - * backend (which only succeeds within the 7-day install window). No-op if no match ever - * resolved. + * [startMatch] returns whether it started the match, or false if it skipped it + * because the app has opted out of tracking. A skipped match is tried again + * when the app opts back in, via [startMatchIfEnabled]. + */ + fun matchInstallOnceEnabled(startMatch: () -> Boolean) { + synchronized(lock) { this.startMatch = startMatch } + scope.launch { + configState.first { it.getConfig()?.isMmpEnabled == true } + startMatchIfEnabled() + } + } + + /** + * Starts this install's match if config has the MMP on and it hasn't started + * yet. Called when config arrives and when the app turns tracking back on. */ - fun reapplyCachedAcquisitionAttributes() { - val cached = storage.read(MMPAcquisitionData) ?: return - mergeAcquisitionAttributesIfNeeded(cached) + fun startMatchIfEnabled() { + if (configState.value.getConfig()?.isMmpEnabled != true) { + return + } + synchronized(lock) { + val start = startMatch + if (hasStartedMatch || start == null) { + return + } + hasStartedMatch = start() + } } private fun mergeAcquisitionAttributesIfNeeded(acquisitionAttributes: Map) { - val attributes = - acquisitionAttributes - .mapNotNull { (key, value) -> - jsonElementToValue(value)?.let { key to it } - }.toMap() + val attributes = acquisitionAttributes.toAttributeValues() if (attributes.isEmpty()) { return @@ -124,6 +161,11 @@ class MMPAttributionManager( setUserAttributes(attributes) } + private fun Map.toAttributeValues(): Map = + mapNotNull { (key, value) -> + jsonElementToValue(value)?.let { key to it } + }.toMap() + private fun jsonElementToValue(value: JsonElement): Any? = when { value is JsonNull -> null diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 4de15cfe2..8e11d331d 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -548,6 +548,7 @@ class DependencyContainer( delegate().userAttributesDidChange(it) }, webPaywallRedeemer = { reedemer }, + installScopedAttributes = { mmpAttributionManager.cachedAcquisitionAttributes() }, actor = identityActor, sdkContext = sdkContext, ) @@ -792,6 +793,8 @@ class DependencyContainer( sendMatchRequest = { clickId -> network.matchMMPInstall(clickId, attributionManager.integrationAttributes) }, + configState = configManager.configState, + scope = ioScope, ) /** diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt index 9ca63af5f..a0564db0a 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt @@ -15,5 +15,8 @@ interface IdentityContext : BaseContext { val sdkContext: SdkContext val webPaywallRedeemer: () -> WebPaywallRedeemer val completeReset: () -> Unit + + /** Install-scoped attributes (MMP `acquisition_*`) that every new identity keeps after a reset. */ + val installScopedAttributes: () -> Map val notifyUserChange: ((Map) -> Unit)? } diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt index 8d9a2c402..b8a8ae6cc 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -31,6 +31,7 @@ class IdentityManager( override val completeReset: () -> Unit = { Superwall.instance.reset(duringIdentify = true) }, + override val installScopedAttributes: () -> Map = { emptyMap() }, override val tracker: suspend (TrackableSuperwallEvent) -> Unit = { Superwall.instance.track(it) }, diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt index 390bb1151..3ce76aadf 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt @@ -390,6 +390,7 @@ data class IdentityState( object Reset : Actions({ update(Updates.Reset) + installScopedAttributes().takeIf { it.isNotEmpty() }?.let { update(Updates.AttributesMerged(it)) } // Track user_attributes with the intermediate reset state during re-identify. // Old code did this via _reset() → saveIds() → _mergeUserAttributes(shouldTrackMerge=true). val current = state.value @@ -404,6 +405,7 @@ data class IdentityState( /** Matches iOS behavior where identitySubject is set to false during the reset window. */ object FullReset : Actions({ update(Updates.Reset) // identity not ready + installScopedAttributes().takeIf { it.isNotEmpty() }?.let { update(Updates.AttributesMerged(it)) } // Track user_attributes with the new (reset) identity. // Old code did this via _reset() → saveIds() → _mergeUserAttributes(shouldTrackMerge=true). val current = state.value diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/AttributionOptions.kt b/superwall/src/main/java/com/superwall/sdk/models/config/AttributionOptions.kt new file mode 100644 index 000000000..3abf30877 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/config/AttributionOptions.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.models.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Attribution features the backend has turned on for this app. */ +@Serializable +data class AttributionOptions( + @SerialName("mmp") val mmp: MmpAttributionOptions? = null, +) + +/** Superwall's install attribution (MMP). Off unless the backend enables it. */ +@Serializable +data class MmpAttributionOptions( + @SerialName("enabled") val enabled: Boolean = false, +) diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt index 7a7e568be..9a7dda801 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt @@ -30,11 +30,16 @@ data class Config( @SerialName("bundle_id_config") val bundleIdConfig: String? = null, @SerialName("test_mode_user_ids") val testModeUserIds: List? = null, @SerialName("prioritized_campaign_id") val prioritizedCampaignId: String? = null, + @SerialName("attribution_options") val attributionOptions: AttributionOptions? = null, ) : SerializableEntity { init { locales = localizationConfig.locales.map { it.locale }.toSet() } + /** Whether the backend has turned on Superwall's install attribution (MMP) for this app. */ + val isMmpEnabled: Boolean + get() = attributionOptions?.mmp?.enabled == true + val allComputedProperties: List get() = triggers.flatMap { trigger -> diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt index 68b2b8c87..1eb2d976f 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -153,6 +153,18 @@ object DidCompleteMMPInstallAttributionRequest : Storable { * after a reset can't be relied on — caching the resolved payload lets us repopulate the new user * deterministically, without re-hitting the backend. */ +/** The install-referrer redemption code already handed to the redeemer, so it's redeemed once per install. */ +object RedeemedInstallReferrerCode : Storable { + override val key: String + get() = "store.redeemedInstallReferrerCode" + + override val directory: SearchPathDirectory + get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS + + override val serializer: KSerializer + get() = String.serializer() +} + object MMPAcquisitionData : Storable> { override val key: String get() = "store.mmpAcquisitionData" diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index 64c46cfef..dc150031d 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -31,6 +31,7 @@ import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.storage.LastWebEntitlementsFetchDate import com.superwall.sdk.storage.LatestRedemptionResponse import com.superwall.sdk.storage.LatestWebCustomerInfo +import com.superwall.sdk.storage.RedeemedInstallReferrerCode import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.TrackedWebTrialCodes import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -156,8 +157,14 @@ class WebPaywallRedeemer( deepLinkReferrer .checkForReferral() .fold( - onSuccess = { - redeem(RedeemType.Code(it)) + onSuccess = { code -> + // Play keeps the install referrer for the life of the install, so + // only redeem its code on the first launch that reads it. + if (storage.read(RedeemedInstallReferrerCode) == code) { + return@fold + } + redeem(RedeemType.Code(code)) + storage.write(RedeemedInstallReferrerCode, code) }, onFailure = { throw it }, ) diff --git a/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt index 7cfea6d9d..66b4c2e2f 100644 --- a/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt @@ -12,6 +12,13 @@ import com.superwall.sdk.storage.MMPAcquisitionData import com.superwall.sdk.storage.Storable import io.mockk.every import io.mockk.mockk +import com.superwall.sdk.config.ConfigState +import com.superwall.sdk.models.config.AttributionOptions +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.config.MmpAttributionOptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertEquals @@ -22,8 +29,8 @@ import org.junit.Test /** * Mirrors iOS's `MMPAttributionManager` behaviour: cache the resolved `acquisition_*` - * payload, merge it into user attributes, track the outcome, and re-apply the cache after - * a reset without re-hitting the backend. + * payload, merge it into user attributes, track the outcome, expose the cache for resets + * without re-hitting the backend, and only match once config turns the MMP on. */ class MMPAttributionManagerTest { private val stored = mutableMapOf() @@ -68,6 +75,8 @@ class MMPAttributionManagerTest { val manager = MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, storage = storage(), identityManager = identityManager(), track = { tracked += it }, @@ -98,6 +107,8 @@ class MMPAttributionManagerTest { val manager = MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, storage = storage(), identityManager = identityManager(), track = { tracked += it }, @@ -122,6 +133,8 @@ class MMPAttributionManagerTest { val manager = MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, storage = storage(), identityManager = identityManager(), track = { tracked += it }, @@ -142,6 +155,8 @@ class MMPAttributionManagerTest { val manager = MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, storage = storage(), identityManager = identityManager(), track = { tracked += it }, @@ -163,6 +178,8 @@ class MMPAttributionManagerTest { val manager = MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, storage = storage(), identityManager = identityManager( @@ -190,6 +207,8 @@ class MMPAttributionManagerTest { // First run: match resolves and caches. MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, storage = storage, identityManager = identityManager(), track = {}, @@ -201,34 +220,115 @@ class MMPAttributionManagerTest { ).matchInstall(null) // After `reset()` the user's attributes are gone, but the install-scoped cache isn't. - MMPAttributionManager( - storage = storage, - identityManager = identityManager(), - track = {}, - setUserAttributes = { applied += it }, - sendMatchRequest = { - requests += 1 - Either.Success(matched()) - }, - ).reapplyCachedAcquisitionAttributes() + val cached = + MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, + storage = storage, + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { + requests += 1 + Either.Success(matched()) + }, + ).cachedAcquisitionAttributes() assertEquals(1, requests) - assertEquals("tiktok", applied.single()["acquisition_source"]) + assertEquals("tiktok", cached["acquisition_source"]) } @Test - fun `reapply is a no-op when no match ever resolved`() = + fun `cached attributes are empty when no match ever resolved`() = runTest { - val applied = mutableListOf>() + val cached = + MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, + storage = storage(), + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, + ).cachedAcquisitionAttributes() - MMPAttributionManager( - storage = storage(), - identityManager = identityManager(), - track = {}, - setUserAttributes = { applied += it }, - sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, - ).reapplyCachedAcquisitionAttributes() + assertTrue(cached.isEmpty()) + } - assertTrue(applied.isEmpty()) + private fun configWithMmp(enabled: Boolean) = + ConfigState.Retrieved( + Config.stub().copy(attributionOptions = AttributionOptions(mmp = MmpAttributionOptions(enabled))), + ) + + private fun gatedManager( + scope: CoroutineScope, + configState: MutableStateFlow, + ) = MMPAttributionManager( + configState = configState, + scope = scope, + storage = storage(), + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, + ) + + @Test + fun `the match never starts while config has the MMP off`() = + runTest { + val configState = MutableStateFlow(configWithMmp(enabled = false)) + var starts = 0 + + gatedManager(backgroundScope, configState).matchInstallOnceEnabled { + starts += 1 + true + } + runCurrent() + + assertEquals(0, starts) + } + + @Test + fun `the match starts once when config turns the MMP on`() = + runTest { + val configState = MutableStateFlow(ConfigState.Retrieving) + var starts = 0 + val manager = gatedManager(backgroundScope, configState) + + manager.matchInstallOnceEnabled { + starts += 1 + true + } + runCurrent() + assertEquals(0, starts) + + configState.value = configWithMmp(enabled = true) + runCurrent() + manager.startMatchIfEnabled() + + assertEquals(1, starts) + } + + @Test + fun `a match skipped while opted out starts when tracking is turned back on`() = + runTest { + val configState = MutableStateFlow(configWithMmp(enabled = true)) + var optedOut = true + var starts = 0 + val manager = gatedManager(backgroundScope, configState) + + manager.matchInstallOnceEnabled { + if (optedOut) return@matchInstallOnceEnabled false + starts += 1 + true + } + runCurrent() + assertEquals(0, starts) + + optedOut = false + manager.startMatchIfEnabled() + manager.startMatchIfEnabled() + + assertEquals(1, starts) } } diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt index 9787be99b..c8c3c7f39 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt @@ -106,6 +106,7 @@ class IdentityActorIntegrationTest { existingAppUserId: String? = null, existingAliasId: String? = null, existingSeed: Int? = null, + installScopedAttributes: Map = emptyMap(), ): IdentityManager { existingAppUserId?.let { every { storage.read(AppUserId) } returns it } existingAliasId?.let { every { storage.read(AliasId) } returns it } @@ -124,6 +125,7 @@ class IdentityActorIntegrationTest { ioScope = IOScope(scope.coroutineContext), notifyUserChange = {}, completeReset = { resetCalled = true }, + installScopedAttributes = { installScopedAttributes }, tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = actor, @@ -230,6 +232,60 @@ class IdentityActorIntegrationTest { } } + @Test + fun `install-scoped attributes survive identifying as a different user`() = runTest { + Given("a manager identified as user-1 with a resolved MMP match") { + val manager = + createSequentialManager( + scope = backgroundScope, + installScopedAttributes = mapOf("acquisition_source" to "google_ads"), + ) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + manager.identify("user-1") + manager.awaitLatestIdentity() + + When("the app identifies as user-2") { + manager.identify("user-2") + manager.awaitLatestIdentity() + } + + Then("user-2 keeps the acquisition attributes") { + assertEquals("user-2", manager.appUserId) + assertEquals("google_ads", manager.userAttributes["acquisition_source"]) + } + } + } + + @Test + fun `install-scoped attributes survive a full reset`() = runTest { + Given("a manager identified as user-1 with a resolved MMP match") { + val manager = + createSequentialManager( + scope = backgroundScope, + installScopedAttributes = mapOf("acquisition_source" to "google_ads"), + ) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + manager.identify("user-1") + manager.awaitLatestIdentity() + + When("reset is called") { + manager.reset() + manager.awaitLatestIdentity() + } + + Then("the anonymous user keeps the acquisition attributes") { + assertNull(manager.appUserId) + assertEquals("google_ads", manager.userAttributes["acquisition_source"]) + } + } + } + // ----------------------------------------------------------------------- // Concurrency stress: rapid-fire mutations // ----------------------------------------------------------------------- diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt index 41460a265..4291707bd 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt @@ -57,6 +57,8 @@ class WebPaywallRedeemerTest { every { read(LatestWebCustomerInfo) } returns null every { write(LatestWebCustomerInfo, any()) } just Runs every { write(LastWebEntitlementsFetchDate, any()) } just Runs + every { read(RedeemedInstallReferrerCode) } returns null + every { write(RedeemedInstallReferrerCode, any()) } just Runs } private var maxAge: () -> Long = { 1L } @@ -276,6 +278,36 @@ class WebPaywallRedeemerTest { } } + @Test + fun `an install referrer code already redeemed on this install is not redeemed again`() = + runTest(testDispatcher) { + Given("a referrer code that an earlier launch already redeemed") { + val code = "already_redeemed" + coEvery { deepLinkReferrer.checkForReferral() } returns Result.success(code) + every { storage.read(RedeemedInstallReferrerCode) } returns code + + When("a later launch creates the redeemer") { + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + storage, + customerInfoManager = mockk(relaxed = true), + factory = TestFactory(), + ) + testScheduler.advanceUntilIdle() + + Then("it does not redeem the code again") { + coVerify(exactly = 0) { + network.redeemToken(any(), any(), any(), any(), any(), any(), any()) + } + } + } + } + } + @Test fun `test failed referral check`() = runTest(testDispatcher) { From bd765ca476aa6720c8c03c3170848d010a4f91e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:21:29 +0200 Subject: [PATCH 10/32] feat(mmp): add ad consent option for Google Ads conversion uploads Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` (`AdConsent(adUserData, adPersonalization)` of `ConsentStatus`), reported as the `adUserDataConsent` / `adPersonalizationConsent` device attributes ("granted" / "denied") and as `ad_consent` in config attributes. Defaults to granted; both report denied when eventTrackingBehavior is NONE. Changing it at runtime re-sends device and config attributes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../main/java/com/superwall/sdk/Superwall.kt | 26 +++ .../superwall/sdk/config/options/AdConsent.kt | 45 +++++ .../sdk/config/options/SuperwallOptions.kt | 12 ++ .../sdk/network/device/DeviceHelper.kt | 18 +- .../templating/models/DeviceTemplate.kt | 2 + .../sdk/config/options/AdConsentTest.kt | 74 ++++++++ .../device/DeviceHelperAdConsentTest.kt | 163 ++++++++++++++++++ 8 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e1a11ac..0756053c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Enhancements - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each a `ConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix subscribers with an unexpired subscription being reported as inactive when Google Play fails to answer a purchase query, for example when the billing client isn't ready at launch. A query that succeeds and reports no purchases still deactivates straight away. diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index fff1abd51..a5412855e 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -17,6 +17,7 @@ import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.billing.toInternalResult import com.superwall.sdk.config.ConfigState import com.superwall.sdk.config.models.ConfigurationStatus +import com.superwall.sdk.config.options.AdConsent import com.superwall.sdk.config.options.EventTrackingBehavior import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.customercenter.CustomerCenterConfiguration @@ -190,6 +191,31 @@ class Superwall( } } + /** + * The user's consent for ad measurement, forwarded with conversions that Superwall + * uploads to ad networks such as Google Ads. + * + * Defaults to granted. Apps with users in the EEA, UK or Switzerland must set this + * from their consent flow. Changes are sent to Superwall straight away. + * + * You can also set the initial value via [SuperwallOptions.adConsent] before calling + * [configure]. + */ + var adConsent: AdConsent + get() = options.adConsent + set(newValue) { + options.adConsent = newValue + + if (options.eventTrackingBehavior == EventTrackingBehavior.NONE) { + return + } + + ioScope.launch { + track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) + track(dependencyContainer.makeConfigAttributes()) + } + } + /** * The presented paywall view. */ diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt new file mode 100644 index 000000000..47b98eae5 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -0,0 +1,45 @@ +package com.superwall.sdk.config.options + +/** + * Whether the user has granted or denied a consent signal. + */ +enum class ConsentStatus( + val raw: String, +) { + GRANTED("granted"), + DENIED("denied"), + ; + + override fun toString(): String = raw +} + +/** + * The user's consent for ad measurement, forwarded with conversions that Superwall + * uploads to ad networks such as Google Ads. + * + * - [adUserData]: consent to send user data to the ad network for advertising. + * - [adPersonalization]: consent to use that data for personalized advertising. + * + * Both default to [ConsentStatus.GRANTED]. + */ +data class AdConsent( + val adUserData: ConsentStatus = ConsentStatus.GRANTED, + val adPersonalization: ConsentStatus = ConsentStatus.GRANTED, +) + +/** + * The consent actually reported. Everything is denied when [EventTrackingBehavior.NONE] + * is set, regardless of [AdConsent]. + */ +internal fun AdConsent.effective(eventTrackingBehavior: EventTrackingBehavior): AdConsent = + if (eventTrackingBehavior == EventTrackingBehavior.NONE) { + AdConsent(adUserData = ConsentStatus.DENIED, adPersonalization = ConsentStatus.DENIED) + } else { + this + } + +internal fun AdConsent.toMap(): Map = + mapOf( + "ad_user_data" to adUserData.raw, + "ad_personalization" to adPersonalization.raw, + ) diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index 78bc94cf4..157ebe74b 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -100,6 +100,17 @@ class SuperwallOptions() { // You can also change this at runtime via [com.superwall.sdk.Superwall.eventTrackingBehavior]. var eventTrackingBehavior: EventTrackingBehavior = EventTrackingBehavior.ALL + // The user's consent for ad measurement, reported to Superwall as the device + // attributes `adUserDataConsent` and `adPersonalizationConsent` and forwarded with + // conversions uploaded to ad networks such as Google Ads. + // + // Defaults to granted for both. Apps with users in the EEA, UK or Switzerland must + // set this from their consent flow. Both are reported as denied while + // [eventTrackingBehavior] is [EventTrackingBehavior.NONE]. + // + // You can also change this at runtime via [com.superwall.sdk.Superwall.adConsent]. + var adConsent: AdConsent = AdConsent() + // Enables the sending of non-Superwall tracked events and properties back to the Superwall servers. // Defaults to `true`. // @@ -192,6 +203,7 @@ internal fun SuperwallOptions.toMap(): Map = // backends/dashboards still reading it don't treat opted-out clients as the // default. Mirrors the deprecated property (true only for `ALL`). "is_external_data_collection_enabled" to (eventTrackingBehavior == EventTrackingBehavior.ALL), + "ad_consent" to adConsent.toMap(), localeIdentifier?.let { "locale_identifier" to it }, "is_game_controller_enabled" to isGameControllerEnabled, "logging" to logging.toMap(), diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 66edd39d5..c37f5bfa2 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -16,6 +16,8 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.DefaultClassifierDataFactory import com.superwall.sdk.analytics.DeviceClassifier import com.superwall.sdk.analytics.Tier +import com.superwall.sdk.config.options.AdConsent +import com.superwall.sdk.config.options.effective import com.superwall.sdk.dependencies.ActiveEntitlementsFactory import com.superwall.sdk.dependencies.CustomerInfoFactory import com.superwall.sdk.dependencies.ExperimentalPropertiesFactory @@ -610,13 +612,22 @@ class DeviceHelper( totalPaywallViews.toString(), reviewRequestCount.toString(), factory.storefrontCountryCode() ?: "", + effectiveAdConsent.adUserData.raw, + effectiveAdConsent.adPersonalization.raw, ).joinToString("|") + private val effectiveAdConsent: AdConsent + get() = + factory.makeSuperwallOptions().let { + it.adConsent.effective(it.eventTrackingBehavior) + } + private suspend fun buildDeviceTemplate( identityInfo: IdentityInfo, volatileFields: VolatileTemplateFields, - ): DeviceTemplate = - DeviceTemplate( + ): DeviceTemplate { + val adConsent = effectiveAdConsent + return DeviceTemplate( publicApiKey = storage.apiKey, platform = "Android", appUserId = identityInfo.appUserId ?: "", @@ -678,7 +689,10 @@ class DeviceHelper( reviewRequestCount = reviewRequestCount, kotlinVersion = kotlinVersion, storeFrontCountryCode = factory.storefrontCountryCode(), + adUserDataConsent = adConsent.adUserData.raw, + adPersonalizationConsent = adConsent.adPersonalization.raw, ) + } suspend fun getTemplateDevice(): Map { return withErrorTracking { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt index 79cecc6b5..1faf801de 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt @@ -70,6 +70,8 @@ data class DeviceTemplate( val reviewRequestCount: Int, val kotlinVersion: String, val storeFrontCountryCode: String? = null, + val adUserDataConsent: String = "granted", + val adPersonalizationConsent: String = "granted", ) { fun toDictionary(json: Json): Map = json.encodeToJsonElement(serializer(), this).jsonObject.toNullableTypedMap() } diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt new file mode 100644 index 000000000..b1cb29e29 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt @@ -0,0 +1,74 @@ +package com.superwall.sdk.config.options + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import org.junit.Assert.assertEquals +import org.junit.Test + +class AdConsentTest { + @Test + fun `ad consent defaults to granted`() { + Given("default superwall options") { + val options = SuperwallOptions() + + Then("both consent signals are granted") { + assertEquals(AdConsent(ConsentStatus.GRANTED, ConsentStatus.GRANTED), options.adConsent) + } + } + } + + @Test + fun `consent statuses match the wire format`() { + assertEquals("granted", ConsentStatus.GRANTED.raw) + assertEquals("denied", ConsentStatus.DENIED.raw) + } + + @Test + fun `effective consent is unchanged unless tracking is none`() { + Given("a consent with personalization denied") { + val consent = AdConsent(adPersonalization = ConsentStatus.DENIED) + + When("tracking is ALL or SUPERWALL_ONLY") { + Then("the consent is reported as set") { + assertEquals(consent, consent.effective(EventTrackingBehavior.ALL)) + assertEquals(consent, consent.effective(EventTrackingBehavior.SUPERWALL_ONLY)) + } + } + } + } + + @Test + fun `effective consent is denied when tracking is none`() { + Given("a fully granted consent") { + val consent = AdConsent() + + When("tracking is NONE") { + val effective = consent.effective(EventTrackingBehavior.NONE) + + Then("both signals are denied") { + assertEquals(AdConsent(ConsentStatus.DENIED, ConsentStatus.DENIED), effective) + } + } + } + } + + @Test + fun `toMap encodes ad consent`() { + Given("options with ad user data denied") { + val options = SuperwallOptions() + options.adConsent = AdConsent(adUserData = ConsentStatus.DENIED) + + When("converting the options to a map") { + val map = options.toMap() + + Then("ad_consent holds both values") { + assertEquals( + mapOf("ad_user_data" to "denied", "ad_personalization" to "granted"), + map["ad_consent"], + ) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt new file mode 100644 index 000000000..a0aee2946 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt @@ -0,0 +1,163 @@ +package com.superwall.sdk.network.device + +import android.content.Context +import android.provider.Settings +import com.superwall.sdk.Given +import com.superwall.sdk.Superwall +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.DeviceClassifier +import com.superwall.sdk.analytics.Tier +import com.superwall.sdk.config.options.AdConsent +import com.superwall.sdk.config.options.ConsentStatus +import com.superwall.sdk.config.options.EventTrackingBehavior +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.identity.IdentityInfo +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.network.SuperwallAPI +import com.superwall.sdk.storage.LastPaywallView +import com.superwall.sdk.storage.LatestEnrichment +import com.superwall.sdk.storage.LocalStorage +import com.superwall.sdk.storage.ReviewData +import com.superwall.sdk.storage.TotalPaywallViews +import com.superwall.sdk.store.Entitlements +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DeviceHelperAdConsentTest { + private lateinit var context: Context + private lateinit var deviceHelper: DeviceHelper + + private val storage = mockk() + private val network = mockk(relaxed = true) + private val factory = mockk(relaxed = true) + private val classifier = mockk() + private val superwall = mockk() + private val entitlements = mockk() + + private var currentUserId: String? = "user-1" + private var currentEntitlements = setOf(Entitlement("basic")) + private val options = SuperwallOptions() + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + Settings.Secure.putString( + context.contentResolver, + Settings.Secure.ANDROID_ID, + "test-android-id", + ) + + every { storage.apiKey } returns "pk_test_key" + every { storage.didTrackFirstSession } returns true + every { storage.read(LatestEnrichment) } returns null + every { storage.read(LastPaywallView) } returns null + every { storage.read(TotalPaywallViews) } returns 2 + every { storage.read(ReviewData) } returns null + + coEvery { factory.makeIdentityInfo() } answers { + IdentityInfo(aliasId = "alias-1", appUserId = currentUserId) + } + every { factory.makeLocaleIdentifier() } returns "en_US" + coEvery { factory.activeProductIds() } returns listOf("com.test.product") + every { factory.storefrontCountryCode() } returns "US" + every { factory.makeSuperwallOptions() } returns options + every { factory.experimentalProperties() } returns emptyMap() + + every { classifier.deviceTier() } returns Tier.MID + + every { entitlements.active } answers { currentEntitlements } + every { superwall.entitlements } returns entitlements + every { superwall.subscriptionStatus } returns + MutableStateFlow(SubscriptionStatus.Inactive) + mockkObject(Superwall.Companion) + every { Superwall.instance } returns superwall + + deviceHelper = + DeviceHelper( + context = context, + storage = storage, + network = network, + factory = factory, + classifier = classifier, + ) + } + + @After + fun tearDown() { + unmockkObject(Superwall.Companion) + } + + @Test + fun `template reports granted consent by default`() = + runTest { + Given("default options") { + When("getting the device template") { + val template = deviceHelper.getTemplateDevice() + + Then("both consent attributes are granted") { + assertEquals("granted", template["adUserDataConsent"]) + assertEquals("granted", template["adPersonalizationConsent"]) + } + } + } + } + + @Test + fun `runtime consent change is reflected in the memoized template`() = + runTest { + Given("a memoized device template") { + deviceHelper.getTemplateDevice() + val cachedAfterFirst = deviceHelper.cachedTemplate + + When("ad consent is changed at runtime") { + options.adConsent = + AdConsent( + adUserData = ConsentStatus.DENIED, + adPersonalization = ConsentStatus.GRANTED, + ) + val second = deviceHelper.getTemplateDevice() + + Then("the template is rebuilt with the new consent") { + assertEquals("denied", second["adUserDataConsent"]) + assertEquals("granted", second["adPersonalizationConsent"]) + assertNotSame(cachedAfterFirst, deviceHelper.cachedTemplate) + } + } + } + } + + @Test + fun `tracking behavior NONE reports denied regardless of consent`() = + runTest { + Given("granted ad consent and a memoized template") { + options.adConsent = AdConsent() + deviceHelper.getTemplateDevice() + + When("event tracking is set to NONE") { + options.eventTrackingBehavior = EventTrackingBehavior.NONE + val template = deviceHelper.getTemplateDevice() + + Then("both consent attributes are denied") { + assertEquals("denied", template["adUserDataConsent"]) + assertEquals("denied", template["adPersonalizationConsent"]) + } + } + } + } +} From fa1f258c38d4724b7e79af3408676eef5a770d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:22:06 +0200 Subject: [PATCH 11/32] docs(changelog): move install attribution to 2.8.5 and describe it accurately Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0756053c9..a871a1f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,13 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 2.8.5 ## Enhancements +- Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. - Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each a `ConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes +- Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. - Fix subscribers with an unexpired subscription being reported as inactive when Google Play fails to answer a purchase query, for example when the billing client isn't ready at launch. A query that succeeds and reports no purchases still deactivates straight away. - Fix subscribers being reported as inactive when Google Play returns an active purchase whose product no longer maps to an entitlement in config. A lost mapping is no longer treated as the subscription ending. - Fix purchases being refunded by Google Play for not being acknowledged when they did not complete through the billing flow callback, for example a pending purchase that settled while the app was closed, the app being killed mid-purchase, or a Play Store promo code. The `AutomaticPurchaseController` now acknowledges any unacknowledged purchase whenever it syncs the subscription status, including on launch, and retries acknowledgements that fail. @@ -53,9 +55,6 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 2.8.1 -## Enhancements -- Adds install attribution matching support. If you set up performance marketing integrations on the Superwall dashboard, the SDK will attempt to match the install and track an `attribution_match` event. The attribution properties will be added to user attributes so that they can be used as breakdowns and filters in the charts. The match runs once per install, within a 7-day window, off the startup critical path, and is skipped entirely when `eventTrackingBehavior` is set to `NONE`. Identifiers you've set via `Superwall.setIntegrationAttributes` are included in the match: `AttributionProvider.GOOGLE_ADS` (the Google Advertising ID) and `AttributionProvider.GOOGLE_APP_SET` are sent as the request's `aaid` and `appSetId` — the Android counterparts to `idfa` on iOS — and the remaining identifiers (`adjustId`, `appsflyerId`, `singularDeviceId` and the rest) are sent alongside them. The Play install referrer's click id is included when present. - ## Fixes - Paywalls with translations now render in the user's language on first paint instead of briefly showing the default language. - Paywall analytics events (`paywall_open`, `paywall_page_view`, `paywall_close`, etc.) now include a `presentation_id`, a unique identifier minted for each paywall presentation. Adds the previously-missing `close_reason`, `cache_key`, and `build_id` fields to these events, matching the data already sent by the iOS SDK. From 08901e0a0576f9dccfb139f41b1ce12c1e5acc46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 8 Oct 2026 16:29:46 +0000 Subject: [PATCH 12/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index bad276e8a..c90f88c08 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43.6% \ No newline at end of file +branches43% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 5205c3d53..d40de80ed 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.6% \ No newline at end of file +coverage51.8% \ No newline at end of file From c66520256ccb3657b6a5dd943705687f02e393c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:36:20 +0200 Subject: [PATCH 13/32] fix(mmp): rename ConsentStatus to AdConsentStatus and serialize adConsent re-sends ConsentStatus clashes with FirebaseAnalytics.ConsentStatus in apps that use both. Each adConsent assignment launched its own coroutine, so an older snapshot could be tracked after a newer one; re-sends now run one at a time and drop superseded assignments. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../main/java/com/superwall/sdk/Superwall.kt | 18 ++++++++++++++++-- .../superwall/sdk/config/options/AdConsent.kt | 10 +++++----- .../sdk/config/options/AdConsentTest.kt | 12 ++++++------ .../device/DeviceHelperAdConsentTest.kt | 6 +++--- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a871a1f92..7dc46ddf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. -- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each a `ConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each a `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index a5412855e..46c6beaaf 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -1,5 +1,8 @@ package com.superwall.sdk +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.Mutex +import java.util.concurrent.atomic.AtomicLong import android.app.Application import android.content.ComponentCallbacks2 import android.content.Context @@ -210,12 +213,23 @@ class Superwall( return } + // Re-sends run one at a time and drop superseded assignments, so an older + // snapshot can never be tracked after a newer one. + val generation = adConsentGeneration.incrementAndGet() ioScope.launch { - track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) - track(dependencyContainer.makeConfigAttributes()) + adConsentMutex.withLock { + if (generation != adConsentGeneration.get()) { + return@withLock + } + track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) + track(dependencyContainer.makeConfigAttributes()) + } } } + private val adConsentGeneration = AtomicLong() + private val adConsentMutex = Mutex() + /** * The presented paywall view. */ diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt index 47b98eae5..0b4c029b0 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -3,7 +3,7 @@ package com.superwall.sdk.config.options /** * Whether the user has granted or denied a consent signal. */ -enum class ConsentStatus( +enum class AdConsentStatus( val raw: String, ) { GRANTED("granted"), @@ -20,11 +20,11 @@ enum class ConsentStatus( * - [adUserData]: consent to send user data to the ad network for advertising. * - [adPersonalization]: consent to use that data for personalized advertising. * - * Both default to [ConsentStatus.GRANTED]. + * Both default to [AdConsentStatus.GRANTED]. */ data class AdConsent( - val adUserData: ConsentStatus = ConsentStatus.GRANTED, - val adPersonalization: ConsentStatus = ConsentStatus.GRANTED, + val adUserData: AdConsentStatus = AdConsentStatus.GRANTED, + val adPersonalization: AdConsentStatus = AdConsentStatus.GRANTED, ) /** @@ -33,7 +33,7 @@ data class AdConsent( */ internal fun AdConsent.effective(eventTrackingBehavior: EventTrackingBehavior): AdConsent = if (eventTrackingBehavior == EventTrackingBehavior.NONE) { - AdConsent(adUserData = ConsentStatus.DENIED, adPersonalization = ConsentStatus.DENIED) + AdConsent(adUserData = AdConsentStatus.DENIED, adPersonalization = AdConsentStatus.DENIED) } else { this } diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt index b1cb29e29..6ac3ceb77 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt @@ -13,21 +13,21 @@ class AdConsentTest { val options = SuperwallOptions() Then("both consent signals are granted") { - assertEquals(AdConsent(ConsentStatus.GRANTED, ConsentStatus.GRANTED), options.adConsent) + assertEquals(AdConsent(AdConsentStatus.GRANTED, AdConsentStatus.GRANTED), options.adConsent) } } } @Test fun `consent statuses match the wire format`() { - assertEquals("granted", ConsentStatus.GRANTED.raw) - assertEquals("denied", ConsentStatus.DENIED.raw) + assertEquals("granted", AdConsentStatus.GRANTED.raw) + assertEquals("denied", AdConsentStatus.DENIED.raw) } @Test fun `effective consent is unchanged unless tracking is none`() { Given("a consent with personalization denied") { - val consent = AdConsent(adPersonalization = ConsentStatus.DENIED) + val consent = AdConsent(adPersonalization = AdConsentStatus.DENIED) When("tracking is ALL or SUPERWALL_ONLY") { Then("the consent is reported as set") { @@ -47,7 +47,7 @@ class AdConsentTest { val effective = consent.effective(EventTrackingBehavior.NONE) Then("both signals are denied") { - assertEquals(AdConsent(ConsentStatus.DENIED, ConsentStatus.DENIED), effective) + assertEquals(AdConsent(AdConsentStatus.DENIED, AdConsentStatus.DENIED), effective) } } } @@ -57,7 +57,7 @@ class AdConsentTest { fun `toMap encodes ad consent`() { Given("options with ad user data denied") { val options = SuperwallOptions() - options.adConsent = AdConsent(adUserData = ConsentStatus.DENIED) + options.adConsent = AdConsent(adUserData = AdConsentStatus.DENIED) When("converting the options to a map") { val map = options.toMap() diff --git a/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt index a0aee2946..a392cc6ba 100644 --- a/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt @@ -9,7 +9,7 @@ import com.superwall.sdk.When import com.superwall.sdk.analytics.DeviceClassifier import com.superwall.sdk.analytics.Tier import com.superwall.sdk.config.options.AdConsent -import com.superwall.sdk.config.options.ConsentStatus +import com.superwall.sdk.config.options.AdConsentStatus import com.superwall.sdk.config.options.EventTrackingBehavior import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.identity.IdentityInfo @@ -128,8 +128,8 @@ class DeviceHelperAdConsentTest { When("ad consent is changed at runtime") { options.adConsent = AdConsent( - adUserData = ConsentStatus.DENIED, - adPersonalization = ConsentStatus.GRANTED, + adUserData = AdConsentStatus.DENIED, + adPersonalization = AdConsentStatus.GRANTED, ) val second = deviceHelper.getTemplateDevice() From 0eb0035c91d852465ee386650cbc62e17d07a9d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 8 Oct 2026 16:44:29 +0000 Subject: [PATCH 14/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index c90f88c08..e9ee233ff 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43% \ No newline at end of file +branches43.3% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index d40de80ed..95726b2c4 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage51.8% \ No newline at end of file +coverage53% \ No newline at end of file From 688a8af137c7ace66f7dd722d768035015da9e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:45:57 +0200 Subject: [PATCH 15/32] fix(mmp): re-send ad consent when tracking is turned back on A consent change made while eventTrackingBehavior was NONE was never sent, and turning tracking back on only re-sent config attributes, so Superwall kept the old value until the next session. The re-send now lives in AdConsentPublisher, used by both setters, with tests for the events it sends and for dropping superseded sends. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/superwall/sdk/Superwall.kt | 35 ++++---- .../sdk/config/options/AdConsentPublisher.kt | 39 +++++++++ .../config/options/AdConsentPublisherTest.kt | 84 +++++++++++++++++++ 3 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 46c6beaaf..b728363fd 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -1,8 +1,5 @@ package com.superwall.sdk -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.sync.Mutex -import java.util.concurrent.atomic.AtomicLong import android.app.Application import android.content.ComponentCallbacks2 import android.content.Context @@ -20,6 +17,7 @@ import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.billing.toInternalResult import com.superwall.sdk.config.ConfigState import com.superwall.sdk.config.models.ConfigurationStatus +import com.superwall.sdk.config.options.AdConsentPublisher import com.superwall.sdk.config.options.AdConsent import com.superwall.sdk.config.options.EventTrackingBehavior import com.superwall.sdk.config.options.SuperwallOptions @@ -171,6 +169,7 @@ class Superwall( var eventTrackingBehavior: EventTrackingBehavior get() = options.eventTrackingBehavior set(newValue) { + val wasNone = options.eventTrackingBehavior == EventTrackingBehavior.NONE options.eventTrackingBehavior = newValue dependencyContainer.eventsQueue.setTrackingBehavior(newValue) @@ -189,6 +188,13 @@ class Superwall( return } + // Nothing was sent while tracking was off, so ad consent changed in the + // meantime hasn't reached Superwall. Re-send it with the config attributes. + if (wasNone) { + adConsentPublisher.publish() + return + } + ioScope.launch { track(dependencyContainer.makeConfigAttributes()) } @@ -213,22 +219,17 @@ class Superwall( return } - // Re-sends run one at a time and drop superseded assignments, so an older - // snapshot can never be tracked after a newer one. - val generation = adConsentGeneration.incrementAndGet() - ioScope.launch { - adConsentMutex.withLock { - if (generation != adConsentGeneration.get()) { - return@withLock - } - track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) - track(dependencyContainer.makeConfigAttributes()) - } - } + adConsentPublisher.publish() } - private val adConsentGeneration = AtomicLong() - private val adConsentMutex = Mutex() + private val adConsentPublisher by lazy { + AdConsentPublisher( + scope = ioScope, + track = { track(it) }, + makeDeviceAttributes = { dependencyContainer.makeSessionDeviceAttributes() }, + makeConfigAttributes = { dependencyContainer.makeConfigAttributes() }, + ) + } /** * The presented paywall view. diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt new file mode 100644 index 000000000..6d7840501 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt @@ -0,0 +1,39 @@ +package com.superwall.sdk.config.options + +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicLong + +/** + * Sends the current ad consent to Superwall as device and config attributes. + * + * Sends run one at a time, and one superseded by a later [publish] is dropped, so + * an older snapshot is never tracked after a newer one. + */ +internal class AdConsentPublisher( + private val scope: CoroutineScope, + private val track: suspend (Trackable) -> Unit, + private val makeDeviceAttributes: suspend () -> HashMap, + private val makeConfigAttributes: () -> Trackable, +) { + private val generation = AtomicLong() + private val mutex = Mutex() + + fun publish(): Job { + val current = generation.incrementAndGet() + return scope.launch { + mutex.withLock { + if (current != generation.get()) { + return@withLock + } + track(InternalSuperwallEvent.DeviceAttributes(makeDeviceAttributes())) + track(makeConfigAttributes()) + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt new file mode 100644 index 000000000..338c82920 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt @@ -0,0 +1,84 @@ +package com.superwall.sdk.config.options + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdConsentPublisherTest { + private val configAttributes: Trackable = mockk() + + @Test + fun `publish sends device attributes then config attributes`() = + runTest { + Given("a publisher reporting denied personalization") { + val tracked = mutableListOf() + val publisher = + AdConsentPublisher( + scope = this@runTest, + track = { tracked += it }, + makeDeviceAttributes = { hashMapOf("adPersonalizationConsent" to "denied") }, + makeConfigAttributes = { configAttributes }, + ) + + When("it publishes") { + publisher.publish().join() + + Then("both events are tracked with the current consent") { + assertEquals(2, tracked.size) + val device = tracked[0] as InternalSuperwallEvent.DeviceAttributes + assertEquals("denied", device.deviceAttributes["adPersonalizationConsent"]) + assertTrue(tracked[1] === configAttributes) + } + } + } + } + + @Test + fun `a publish superseded while waiting is dropped`() = + runTest { + Given("a first publish blocked inside its send") { + val tracked = mutableListOf() + val release = CompletableDeferred() + var consent = "granted" + var calls = 0 + val publisher = + AdConsentPublisher( + scope = this@runTest, + track = { tracked += it }, + makeDeviceAttributes = { + calls += 1 + if (calls == 1) release.await() + hashMapOf("adPersonalizationConsent" to consent) + }, + makeConfigAttributes = { configAttributes }, + ) + val first = publisher.publish() + testScheduler.runCurrent() + + When("two more assignments arrive before it finishes") { + consent = "denied" + val second = publisher.publish() + val third = publisher.publish() + release.complete(Unit) + first.join() + second.join() + third.join() + + Then("only the first (already sending) and the latest are tracked, latest last") { + val devices = tracked.filterIsInstance() + assertEquals(2, devices.size) + assertEquals("denied", devices.last().deviceAttributes["adPersonalizationConsent"]) + assertTrue(tracked.last() === configAttributes) + } + } + } + } +} From 5d8047d51ff65677b441e9cf902582e4fca98d67 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 8 Oct 2026 16:56:32 +0000 Subject: [PATCH 16/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index e9ee233ff..311551146 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43.3% \ No newline at end of file +branches43.4% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 95726b2c4..77e8d6dda 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53% \ No newline at end of file +coverage53.2% \ No newline at end of file From 9eda6adc92085a1cd7da7024be3c73e3c740fe26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:01:42 +0200 Subject: [PATCH 17/32] fix(mmp): address review on install matching, referrer and Custom hosts - Record install-match eligibility before config is fetched, so a first launch killed while offline is still matched on the next launch. - Mark an install-referrer code redeemed only once the server answered, so a failed request is retried on the next launch. - Stop waiting for the install referrer once the Play connection failed for good, instead of holding the lock for the full timeout. - Keep Custom environments on their previous *.superwall.dev hosts and restore the 4-argument constructor with @JvmOverloads. - Fix the MMPAcquisitionData KDoc placement and a CHANGELOG article. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../main/java/com/superwall/sdk/Superwall.kt | 20 ++++--- .../sdk/config/options/SuperwallOptions.kt | 22 ++++--- .../com/superwall/sdk/storage/CacheKeys.kt | 18 +++--- .../com/superwall/sdk/web/DeepLinkReferrer.kt | 2 + .../superwall/sdk/web/WebPaywallRedeemer.kt | 8 ++- .../sdk/web/WebPaywallRedeemerTest.kt | 59 +++++++++++++++++++ 7 files changed, 102 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc46ddf7..2b78450f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. -- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each a `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index b728363fd..6d5bf2bb0 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -765,6 +765,16 @@ class Superwall( val hadTrackedAppInstallBeforeConfigure = dependencyContainer.storage.read(DidTrackAppInstall) ?: false + // The eligibility check runs before anything else can stall, whatever + // the config or tracking setting says: it records that this install may + // be matched, which a later launch relies on if this one is killed before + // config arrives. Only the request waits for config to enable the MMP. + val shouldMatchInstall = + dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure, + appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis, + ) + dependencyContainer.storage.recordAppInstall { track(event = it) } @@ -783,15 +793,7 @@ class Superwall( ).awaitAll() } - // The eligibility check runs whatever the config or tracking setting - // says: it records that this install may be matched, which a later - // launch relies on. Only the request waits for config to enable the MMP. - if ( - dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( - hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure, - appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis, - ) - ) { + if (shouldMatchInstall) { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { // Skip matching when the app has opted out of all event collection. // The `/api/match` call and the `acquisition_*` attribute writes diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index 157ebe74b..7b990bd1c 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -75,15 +75,19 @@ class SuperwallOptions() { class Developer : NetworkEnvironment("superwall.dev") - class Custom( - override val baseHost: String, - override val collectorHost: String, - override val scheme: String, - override val port: Int?, - override val subscriptionHost: String = baseHost, - override val enrichmentHost: String = baseHost, - override val mmpHost: String = baseHost, - ) : NetworkEnvironment(baseHost) + // The optional hosts default to the same `*.superwall.dev` hosts a `Custom` + // environment used before they could be overridden. + class Custom + @JvmOverloads + constructor( + override val baseHost: String, + override val collectorHost: String, + override val scheme: String, + override val port: Int?, + override val subscriptionHost: String = "subscriptions-api.superwall.dev", + override val enrichmentHost: String = "enrichment-api.superwall.dev", + override val mmpHost: String = "mmp.superwall.dev", + ) : NetworkEnvironment(baseHost) } // **WARNING:**: Determines which network environment your SDK should use. diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt index 1eb2d976f..c380c6469 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -144,15 +144,6 @@ object DidCompleteMMPInstallAttributionRequest : Storable { get() = Boolean.serializer() } -/** - * The decoded MMP `acquisition_*` payload from the last successful install match, - * cached so it can be re-applied to a new user's attributes after [com.superwall.sdk.Superwall.reset]. - * - * Install-scoped: the install source doesn't change when one user logs out and another logs in - * on the same device. The backend match only runs within the 7-day install window, so re-matching - * after a reset can't be relied on — caching the resolved payload lets us repopulate the new user - * deterministically, without re-hitting the backend. - */ /** The install-referrer redemption code already handed to the redeemer, so it's redeemed once per install. */ object RedeemedInstallReferrerCode : Storable { override val key: String @@ -165,6 +156,15 @@ object RedeemedInstallReferrerCode : Storable { get() = String.serializer() } +/** + * The decoded MMP `acquisition_*` payload from the last successful install match, + * cached so it can be re-applied to a new user's attributes after [com.superwall.sdk.Superwall.reset]. + * + * Install-scoped: the install source doesn't change when one user logs out and another logs in + * on the same device. The backend match only runs within the 7-day install window, so re-matching + * after a reset can't be relied on — caching the resolved payload lets us repopulate the new user + * deterministically, without re-hitting the backend. + */ object MMPAcquisitionData : Storable> { override val key: String get() = "store.mmpAcquisitionData" diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt index 635ccf725..c72fdde34 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -162,6 +162,8 @@ class DeepLinkReferrer( val rawReferrer = withTimeoutOrNull(timeout) { while (readyReferrerClient == null) { + // The connection failed for good, so waiting won't help. + if (referrerClient == null) return@withTimeoutOrNull null delay(50) } readyReferrerClient?.installReferrer?.installReferrer?.toString() diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index dc150031d..7a5127e3c 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -164,7 +164,13 @@ class WebPaywallRedeemer( return@fold } redeem(RedeemType.Code(code)) - storage.write(RedeemedInstallReferrerCode, code) + // Only a redemption the server answered is stored with the code, so a + // failed request is retried on the next launch. + val redeemed = + storage.read(LatestRedemptionResponse)?.allCodes?.any { it.code == code } == true + if (redeemed) { + storage.write(RedeemedInstallReferrerCode, code) + } }, onFailure = { throw it }, ) diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt index 4291707bd..8377912bd 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt @@ -278,6 +278,62 @@ class WebPaywallRedeemerTest { } } + @Test + fun `an install referrer code is marked redeemed only once the server has answered`() = + runTest(testDispatcher) { + Given("a referrer code the server redeems") { + val code = "referrer_code" + val response = + WebRedemptionResponse( + codes = + listOf( + RedemptionResult.Success( + code = code, + redemptionInfo = + RedemptionInfo( + ownership = RedemptionOwnership.AppUser(appUserId = getUserId().value), + purchaserInfo = + PurchaserInfo( + getUserId().value, + "email", + StoreIdentifiers.Stripe(stripeCustomerId = "123", emptyList()), + ), + entitlements = listOf(webEntitlement), + ), + ), + ), + customerInfo = null, + ) + var storedResponse: WebRedemptionResponse? = null + every { storage.write(LatestRedemptionResponse, any()) } answers { storedResponse = secondArg() } + every { storage.read(LatestRedemptionResponse) } answers { storedResponse } + coEvery { deepLinkReferrer.checkForReferral() } returns Result.success(code) + coEvery { + network.redeemToken(any(), any(), any(), any(), any(), any(), any()) + } returns Either.Success(response) + + When("the redeemer reads it on launch") { + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + storage, + customerInfoManager = mockk(relaxed = true), + factory = TestFactory(), + ) + // Bounded: once a response is stored, entitlement polling reschedules forever. + testScheduler.advanceTimeBy(1_000) + testScheduler.runCurrent() + + Then("the code is marked as redeemed for this install") { + verify(exactly = 1) { storage.write(RedeemedInstallReferrerCode, code) } + } + } + } + } + @Test fun `an install referrer code already redeemed on this install is not redeemed again`() = runTest(testDispatcher) { @@ -380,6 +436,9 @@ class WebPaywallRedeemerTest { Then("it should not set entitlement status") { assert(mutableEntitlements == setOf(normalEntitlement)) + verify(exactly = 0) { + storage.write(RedeemedInstallReferrerCode, any()) + } verify(exactly = 1) { onRedemptionResult(any()) } From 828b3bafa3958fbb6fdcb680ac7f911eb39a0339 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 8 Oct 2026 17:20:23 +0000 Subject: [PATCH 18/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 311551146..6b3b6017c 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43.4% \ No newline at end of file +branches43.9% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 77e8d6dda..f90ec39d9 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.2% \ No newline at end of file +coverage53.8% \ No newline at end of file From c4773b94a8a4bf33d54c1fb492072ddc10235af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:16:52 +0200 Subject: [PATCH 19/32] docs(mmp): say adConsent is only used for Google Ads today Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- superwall/src/main/java/com/superwall/sdk/Superwall.kt | 4 ++-- .../java/com/superwall/sdk/config/options/AdConsent.kt | 8 ++++---- .../com/superwall/sdk/config/options/SuperwallOptions.kt | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b78450f4..3f9aaf17d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. -- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads. Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads (other ad networks don't use it yet). Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 6d5bf2bb0..af65862c3 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -201,8 +201,8 @@ class Superwall( } /** - * The user's consent for ad measurement, forwarded with conversions that Superwall - * uploads to ad networks such as Google Ads. + * The user's consent for ad measurement, forwarded with the conversions Superwall + * uploads to Google Ads. Other ad networks don't use it yet. * * Defaults to granted. Apps with users in the EEA, UK or Switzerland must set this * from their consent flow. Changes are sent to Superwall straight away. diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt index 0b4c029b0..9744b65f9 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -14,11 +14,11 @@ enum class AdConsentStatus( } /** - * The user's consent for ad measurement, forwarded with conversions that Superwall - * uploads to ad networks such as Google Ads. + * The user's consent for ad measurement, forwarded with the conversions Superwall + * uploads to Google Ads. Other ad networks don't use it yet. * - * - [adUserData]: consent to send user data to the ad network for advertising. - * - [adPersonalization]: consent to use that data for personalized advertising. + * - [adUserData]: consent to send user data to Google for advertising. + * - [adPersonalization]: consent for Google to use that data for personalized advertising. * * Both default to [AdConsentStatus.GRANTED]. */ diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index 7b990bd1c..9ef17b105 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -106,7 +106,7 @@ class SuperwallOptions() { // The user's consent for ad measurement, reported to Superwall as the device // attributes `adUserDataConsent` and `adPersonalizationConsent` and forwarded with - // conversions uploaded to ad networks such as Google Ads. + // the conversions Superwall uploads to Google Ads. Other ad networks don't use it yet. // // Defaults to granted for both. Apps with users in the EEA, UK or Switzerland must // set this from their consent flow. Both are reported as denied while From c313eafb841c10cb0740b6f4957fa65872a58dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:23:03 +0200 Subject: [PATCH 20/32] fix(mmp): re-send ad consent when a tracking change clears the events queue Any behavior other than ALL clears the events queue, which can hold a just-queued consent update. Re-send device and config attributes after such a change, once the queue has applied it, as iOS does. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/superwall/sdk/Superwall.kt | 13 ++++---- .../sdk/config/options/AdConsentPublisher.kt | 6 ++-- .../com/superwall/sdk/storage/EventsQueue.kt | 3 +- .../config/options/AdConsentPublisherTest.kt | 30 +++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index af65862c3..70781a4d7 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -169,10 +169,10 @@ class Superwall( var eventTrackingBehavior: EventTrackingBehavior get() = options.eventTrackingBehavior set(newValue) { - val wasNone = options.eventTrackingBehavior == EventTrackingBehavior.NONE + val previous = options.eventTrackingBehavior options.eventTrackingBehavior = newValue - dependencyContainer.eventsQueue.setTrackingBehavior(newValue) + val queueUpdated = dependencyContainer.eventsQueue.setTrackingBehavior(newValue) mainScope.launch { paywallView?.webView?.messageHandler?.passEventTrackingBehaviorToWebView(newValue) @@ -188,10 +188,11 @@ class Superwall( return } - // Nothing was sent while tracking was off, so ad consent changed in the - // meantime hasn't reached Superwall. Re-send it with the config attributes. - if (wasNone) { - adConsentPublisher.publish() + // Ad consent may not have reached Superwall: nothing is sent while tracking is + // off, and anything but ALL clears the events queue, which can hold a consent + // update. Re-send it with the config attributes once the queue has changed. + if (previous != newValue || newValue != EventTrackingBehavior.ALL) { + adConsentPublisher.publish(after = queueUpdated) return } diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt index 6d7840501..8a768aed1 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt @@ -13,7 +13,8 @@ import java.util.concurrent.atomic.AtomicLong * Sends the current ad consent to Superwall as device and config attributes. * * Sends run one at a time, and one superseded by a later [publish] is dropped, so - * an older snapshot is never tracked after a newer one. + * an older snapshot is never tracked after a newer one. A send waits for [after], + * so it can't be queued before an events-queue change it follows. */ internal class AdConsentPublisher( private val scope: CoroutineScope, @@ -24,9 +25,10 @@ internal class AdConsentPublisher( private val generation = AtomicLong() private val mutex = Mutex() - fun publish(): Job { + fun publish(after: Job? = null): Job { val current = generation.incrementAndGet() return scope.launch { + after?.join() mutex.withLock { if (current != generation.get()) { return@withLock diff --git a/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt b/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt index f12aad8fa..a0b51cf2d 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt @@ -78,14 +78,13 @@ class EventsQueue( } } - fun setTrackingBehavior(behavior: EventTrackingBehavior) { + fun setTrackingBehavior(behavior: EventTrackingBehavior): Job = launch { trackingBehavior = behavior if (behavior != EventTrackingBehavior.ALL) { elements.clear() } } - } private fun trackingAllowed(event: Trackable): Boolean = when (trackingBehavior) { diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt index 338c82920..276e3d2ac 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt @@ -7,6 +7,7 @@ import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.internal.trackable.Trackable import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -81,4 +82,33 @@ class AdConsentPublisherTest { } } } + + @Test + fun `publish waits for the events-queue change it follows`() = + runTest { + Given("a queue change that hasn't run yet") { + val tracked = mutableListOf() + val queueUpdated = Job() + val publisher = + AdConsentPublisher( + scope = this@runTest, + track = { tracked += it }, + makeDeviceAttributes = { hashMapOf("adUserDataConsent" to "denied") }, + makeConfigAttributes = { configAttributes }, + ) + + When("it publishes after that change") { + val sent = publisher.publish(after = queueUpdated) + testScheduler.runCurrent() + val trackedBeforeChange = tracked.size + queueUpdated.complete() + sent.join() + + Then("nothing is tracked until the queue has changed") { + assertEquals(0, trackedBeforeChange) + assertEquals(2, tracked.size) + } + } + } + } } From bae64bcf235f7174c4d2372c8810be13ac5a01a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 Oct 2026 09:34:15 +0000 Subject: [PATCH 21/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 6b3b6017c..6e54b4338 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43.9% \ No newline at end of file +branches43.5% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index f90ec39d9..77e8d6dda 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.8% \ No newline at end of file +coverage53.2% \ No newline at end of file From 2d0e1b819e9d215c6bc5475621390843b95c382c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 Oct 2026 09:41:27 +0000 Subject: [PATCH 22/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 6e54b4338..6b3b6017c 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43.5% \ No newline at end of file +branches43.9% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 77e8d6dda..0b297d8c0 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.2% \ No newline at end of file +coverage53.9% \ No newline at end of file From a50fd1315fb24e09857e5f5d7bd0b0524462e7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:32:50 +0200 Subject: [PATCH 23/32] feat(mmp): read ad consent from the app's IAB TCF consent banner When the developer never sets adConsent, report the purpose consents an IAB TCF banner stored in the app's default SharedPreferences when GDPR applies (ad user data = purposes 1 and 7, ad personalization = 3 and 4). The developer's value still wins once assigned, even to the default, and NONE still denies everything. A new adConsentSource device attribute says which source supplied the values, and a banner change that alters the reported consent re-sends it through AdConsentPublisher. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../main/java/com/superwall/sdk/Superwall.kt | 12 +- .../superwall/sdk/config/options/AdConsent.kt | 38 ++ .../sdk/config/options/SuperwallOptions.kt | 16 +- .../sdk/config/options/TcfConsentReader.kt | 115 +++++ .../sdk/dependencies/DependencyContainer.kt | 9 + .../sdk/network/device/DeviceHelper.kt | 20 +- .../templating/models/DeviceTemplate.kt | 1 + .../config/options/TcfConsentReaderTest.kt | 400 ++++++++++++++++++ .../device/DeviceHelperAdConsentTest.kt | 64 ++- 10 files changed, 660 insertions(+), 17 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9aaf17d..dd13ba8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. -- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads (other ad networks don't use it yet). Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads (other ad networks don't use it yet). Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. If `adConsent` is never set, the SDK uses the purpose consents stored by an IAB TCF consent banner when GDPR applies (reported with the new `adConsentSource` device attribute as `developer`, `tcf` or `default`). ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 70781a4d7..7cc39bd11 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -205,8 +205,8 @@ class Superwall( * The user's consent for ad measurement, forwarded with the conversions Superwall * uploads to Google Ads. Other ad networks don't use it yet. * - * Defaults to granted. Apps with users in the EEA, UK or Switzerland must set this - * from their consent flow. Changes are sent to Superwall straight away. + * If not set, the SDK uses the consent stored by an IAB TCF consent banner when EU rules + * apply, otherwise granted. Changes are sent to Superwall straight away. * * You can also set the initial value via [SuperwallOptions.adConsent] before calling * [configure]. @@ -761,6 +761,14 @@ class Superwall( addListeners() + // Off the main thread: the first read loads the app's default preferences. + ioScope.launch { + dependencyContainer.tcfConsentReader.observeReportedChanges( + options = { options }, + onChange = { adConsentPublisher.publish() }, + ) + } + ioScope.launch { withErrorTracking { val hadTrackedAppInstallBeforeConfigure = diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt index 9744b65f9..94d959974 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -43,3 +43,41 @@ internal fun AdConsent.toMap(): Map = "ad_user_data" to adUserData.raw, "ad_personalization" to adPersonalization.raw, ) + +/** + * Where the reported ad consent came from, sent as the `adConsentSource` device attribute. + */ +internal enum class AdConsentSource( + val raw: String, +) { + DEVELOPER("developer"), + TCF("tcf"), + DEFAULT("default"), +} + +/** + * The ad consent reported to Superwall, with [source] naming what supplied it before the + * [EventTrackingBehavior.NONE] rule was applied. + */ +internal data class ReportedAdConsent( + val consent: AdConsent, + val source: AdConsentSource, +) + +/** + * Picks the consent to report: the developer's [SuperwallOptions.adConsent] if it was ever + * set, else the IAB TCF [bannerConsent] if present, else the granted default. Everything is + * denied while [SuperwallOptions.eventTrackingBehavior] is [EventTrackingBehavior.NONE]. + */ +internal fun reportedAdConsent( + options: SuperwallOptions, + bannerConsent: AdConsent?, +): ReportedAdConsent { + val (consent, source) = + when { + options.isAdConsentSet -> options.adConsent to AdConsentSource.DEVELOPER + bannerConsent != null -> bannerConsent to AdConsentSource.TCF + else -> AdConsent() to AdConsentSource.DEFAULT + } + return ReportedAdConsent(consent.effective(options.eventTrackingBehavior), source) +} diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index 9ef17b105..fce74fc2a 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -108,12 +108,21 @@ class SuperwallOptions() { // attributes `adUserDataConsent` and `adPersonalizationConsent` and forwarded with // the conversions Superwall uploads to Google Ads. Other ad networks don't use it yet. // - // Defaults to granted for both. Apps with users in the EEA, UK or Switzerland must - // set this from their consent flow. Both are reported as denied while - // [eventTrackingBehavior] is [EventTrackingBehavior.NONE]. + // If not set, the SDK uses the consent stored by an IAB TCF consent banner when EU rules + // apply, otherwise granted. Both are reported as denied while [eventTrackingBehavior] is + // [EventTrackingBehavior.NONE]. // // You can also change this at runtime via [com.superwall.sdk.Superwall.adConsent]. var adConsent: AdConsent = AdConsent() + set(value) { + field = value + isAdConsentSet = true + } + + // Whether [adConsent] was ever assigned, even to its default. Only then does it take + // precedence over an IAB TCF consent banner. + internal var isAdConsentSet: Boolean = false + private set // Enables the sending of non-Superwall tracked events and properties back to the Superwall servers. // Defaults to `true`. @@ -208,6 +217,7 @@ internal fun SuperwallOptions.toMap(): Map = // default. Mirrors the deprecated property (true only for `ALL`). "is_external_data_collection_enabled" to (eventTrackingBehavior == EventTrackingBehavior.ALL), "ad_consent" to adConsent.toMap(), + "ad_consent_set" to isAdConsentSet, localeIdentifier?.let { "locale_identifier" to it }, "is_game_controller_enabled" to isGameControllerEnabled, "logging" to logging.toMap(), diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt b/superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt new file mode 100644 index 000000000..9c47163f1 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt @@ -0,0 +1,115 @@ +package com.superwall.sdk.config.options + +import android.content.SharedPreferences + +internal const val IABTCF_GDPR_APPLIES = "IABTCF_gdprApplies" +internal const val IABTCF_PURPOSE_CONSENTS = "IABTCF_PurposeConsents" + +/** + * Maps the IAB TCF values a consent banner stores to ad consent, or `null` when the banner + * holds no consent: GDPR doesn't apply, or no purpose consents are stored. + * + * Ad user data needs purposes 1 and 7; ad personalization needs purposes 3 and 4. + * [gdprApplies] is accepted as `1`, `"1"` or `true`, since some banners store it untyped. + */ +internal fun tcfAdConsent( + gdprApplies: Any?, + purposeConsents: Any?, +): AdConsent? { + val applies = + when (gdprApplies) { + is Int -> gdprApplies == 1 + is String -> gdprApplies == "1" + is Boolean -> gdprApplies + else -> false + } + if (!applies || purposeConsents !is String || purposeConsents.isEmpty()) { + return null + } + + fun agreed(vararg purposes: Int) = purposes.all { purposeConsents.getOrNull(it - 1) == '1' } + + fun status(granted: Boolean) = if (granted) AdConsentStatus.GRANTED else AdConsentStatus.DENIED + + return AdConsent( + adUserData = status(agreed(1, 7)), + adPersonalization = status(agreed(3, 4)), + ) +} + +/** + * Reads the ad consent stored by the app's IAB TCF consent banner in its default + * SharedPreferences, and reports changes to it. + */ +internal class TcfConsentReader( + private val preferences: SharedPreferences, +) { + private var lastConsent: AdConsent? = null + + // SharedPreferences holds listeners weakly, so this keeps it registered. + private var listener: SharedPreferences.OnSharedPreferenceChangeListener? = null + + fun read(): AdConsent? = + try { + tcfAdConsent( + gdprApplies = preferences.untyped(IABTCF_GDPR_APPLIES), + purposeConsents = preferences.untyped(IABTCF_PURPOSE_CONSENTS), + ) + } catch (_: Throwable) { + null + } + + /** + * Calls [onChange] with the previous and new banner consent whenever a write to the + * TCF keys changes it. Only the first call registers. + */ + @Synchronized + fun observe(onChange: (previous: AdConsent?, current: AdConsent?) -> Unit) { + if (listener != null) return + lastConsent = read() + val listener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + // A null key means the preferences were cleared. + if (key != null && key != IABTCF_GDPR_APPLIES && key != IABTCF_PURPOSE_CONSENTS) { + return@OnSharedPreferenceChangeListener + } + val (previous, current) = + synchronized(this) { + val previous = lastConsent + val current = read() + lastConsent = current + previous to current + } + if (previous != current) { + onChange(previous, current) + } + } + this.listener = listener + preferences.registerOnSharedPreferenceChangeListener(listener) + } + + /** + * Calls [onChange] when a banner change alters the consent reported with [options]: + * not while the developer's [SuperwallOptions.adConsent] takes precedence, and not + * while tracking is [EventTrackingBehavior.NONE], since nothing is sent then. + */ + fun observeReportedChanges( + options: () -> SuperwallOptions, + onChange: () -> Unit, + ) = observe { previous, current -> + val currentOptions = options() + if (currentOptions.eventTrackingBehavior == EventTrackingBehavior.NONE) { + return@observe + } + if (reportedAdConsent(currentOptions, previous) != reportedAdConsent(currentOptions, current)) { + onChange() + } + } + + private fun SharedPreferences.untyped(key: String): Any? { + if (!contains(key)) return null + return runCatching { getInt(key, 0) }.getOrNull() + ?: runCatching { getString(key, null) }.getOrNull() + ?: runCatching { getBoolean(key, false) }.getOrNull() + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 8e11d331d..be5bda645 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -32,6 +32,7 @@ import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.config.ConfigState import com.superwall.sdk.config.PaywallPreload import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.config.options.TcfConsentReader import com.superwall.sdk.customer.CustomerInfoManager import com.superwall.sdk.customercenter.CustomerCenterManager import com.superwall.sdk.models.customer.CustomerInfo @@ -259,6 +260,13 @@ class DependencyContainer( internal val mmpAttributionManager: MMPAttributionManager internal val deepLinkReferrer: DeepLinkReferrer + /** Ad consent stored by the app's IAB TCF consent banner, in its default SharedPreferences. */ + internal val tcfConsentReader: TcfConsentReader by lazy { + TcfConsentReader( + context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE), + ) + } + init { // For tracking when the app enters the background. uiScope.launch { @@ -466,6 +474,7 @@ class DependencyContainer( network = network, factory = this, classifier = DeviceClassifier(DefaultClassifierDataFactory { context }), + tcfAdConsent = { tcfConsentReader.read() }, ) assignments = diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index c37f5bfa2..46e728a79 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -17,6 +17,8 @@ import com.superwall.sdk.analytics.DefaultClassifierDataFactory import com.superwall.sdk.analytics.DeviceClassifier import com.superwall.sdk.analytics.Tier import com.superwall.sdk.config.options.AdConsent +import com.superwall.sdk.config.options.ReportedAdConsent +import com.superwall.sdk.config.options.reportedAdConsent import com.superwall.sdk.config.options.effective import com.superwall.sdk.dependencies.ActiveEntitlementsFactory import com.superwall.sdk.dependencies.CustomerInfoFactory @@ -83,6 +85,7 @@ class DeviceHelper( val network: SuperwallAPI, val factory: Factory, private val classifier: DeviceClassifier = DeviceClassifier(DefaultClassifierDataFactory { context }), + private val tcfAdConsent: () -> AdConsent? = { null }, ) { interface Factory : IdentityInfoFactory, @@ -612,21 +615,17 @@ class DeviceHelper( totalPaywallViews.toString(), reviewRequestCount.toString(), factory.storefrontCountryCode() ?: "", - effectiveAdConsent.adUserData.raw, - effectiveAdConsent.adPersonalization.raw, + currentAdConsent.let { "${it.consent.adUserData}:${it.consent.adPersonalization}:${it.source.raw}" }, ).joinToString("|") - private val effectiveAdConsent: AdConsent - get() = - factory.makeSuperwallOptions().let { - it.adConsent.effective(it.eventTrackingBehavior) - } + private val currentAdConsent: ReportedAdConsent + get() = reportedAdConsent(factory.makeSuperwallOptions(), tcfAdConsent()) private suspend fun buildDeviceTemplate( identityInfo: IdentityInfo, volatileFields: VolatileTemplateFields, ): DeviceTemplate { - val adConsent = effectiveAdConsent + val adConsent = currentAdConsent return DeviceTemplate( publicApiKey = storage.apiKey, platform = "Android", @@ -689,8 +688,9 @@ class DeviceHelper( reviewRequestCount = reviewRequestCount, kotlinVersion = kotlinVersion, storeFrontCountryCode = factory.storefrontCountryCode(), - adUserDataConsent = adConsent.adUserData.raw, - adPersonalizationConsent = adConsent.adPersonalization.raw, + adUserDataConsent = adConsent.consent.adUserData.raw, + adPersonalizationConsent = adConsent.consent.adPersonalization.raw, + adConsentSource = adConsent.source.raw, ) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt index 1faf801de..ea0d3552f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt @@ -72,6 +72,7 @@ data class DeviceTemplate( val storeFrontCountryCode: String? = null, val adUserDataConsent: String = "granted", val adPersonalizationConsent: String = "granted", + val adConsentSource: String = "default", ) { fun toDictionary(json: Json): Map = json.encodeToJsonElement(serializer(), this).jsonObject.toNullableTypedMap() } diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt new file mode 100644 index 000000000..7ed2949a4 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt @@ -0,0 +1,400 @@ +package com.superwall.sdk.config.options + +import android.content.Context +import android.content.SharedPreferences +import android.os.Looper +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +private val GRANTED = AdConsentStatus.GRANTED +private val DENIED = AdConsentStatus.DENIED + +// Purposes 1-10. Index n-1 is purpose n. +private const val ALL_PURPOSES = "1111111111" + +class TcfAdConsentMappingTest { + @Test + fun `all four purpose combinations`() { + Given("GDPR applies") { + When("purposes 1, 3, 4 and 7 are all agreed") { + Then("both are granted") { + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent(1, ALL_PURPOSES)) + } + } + When("only purposes 1 and 7 are agreed") { + Then("only ad user data is granted") { + assertEquals(AdConsent(GRANTED, DENIED), tcfAdConsent(1, "1000001000")) + } + } + When("only purposes 3 and 4 are agreed") { + Then("only ad personalization is granted") { + assertEquals(AdConsent(DENIED, GRANTED), tcfAdConsent(1, "0011000000")) + } + } + When("none of them are agreed") { + Then("both are denied") { + assertEquals(AdConsent(DENIED, DENIED), tcfAdConsent(1, "0100010111")) + } + } + } + } + + @Test + fun `one missing purpose of a pair denies that signal`() { + Given("purpose 7 and purpose 4 not agreed") { + Then("both are denied") { + assertEquals(AdConsent(DENIED, DENIED), tcfAdConsent(1, "1110000000")) + } + } + } + + @Test + fun `gdprApplies is read defensively`() { + Given("purpose consents for everything") { + Then("1, \"1\" and true mean GDPR applies") { + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent(1, ALL_PURPOSES)) + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent("1", ALL_PURPOSES)) + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent(true, ALL_PURPOSES)) + } + Then("anything else means there is no banner consent") { + assertNull(tcfAdConsent(0, ALL_PURPOSES)) + assertNull(tcfAdConsent(null, ALL_PURPOSES)) + assertNull(tcfAdConsent("0", ALL_PURPOSES)) + assertNull(tcfAdConsent("true", ALL_PURPOSES)) + assertNull(tcfAdConsent(false, ALL_PURPOSES)) + assertNull(tcfAdConsent(2, ALL_PURPOSES)) + assertNull(tcfAdConsent(1.0f, ALL_PURPOSES)) + } + } + } + + @Test + fun `missing or empty purpose consents mean no banner consent`() { + Given("GDPR applies") { + Then("an empty, missing or non-string value gives no consent") { + assertNull(tcfAdConsent(1, "")) + assertNull(tcfAdConsent(1, null)) + assertNull(tcfAdConsent(1, 1111111)) + } + } + } + + @Test + fun `a too short string counts as not agreed`() { + Given("only purposes 1-4 are stored, all agreed") { + val consent = tcfAdConsent(1, "1111") + + Then("purpose 7 is not agreed, so ad user data is denied") { + assertEquals(AdConsent(DENIED, GRANTED), consent) + } + } + } +} + +class ReportedAdConsentTest { + private val banner = AdConsent(DENIED, GRANTED) + + @Test + fun `default is granted when nothing is set`() { + Given("untouched options and no banner") { + val reported = reportedAdConsent(SuperwallOptions(), null) + + Then("both are granted from the default source") { + assertEquals(ReportedAdConsent(AdConsent(), AdConsentSource.DEFAULT), reported) + assertEquals("default", reported.source.raw) + } + } + } + + @Test + fun `banner consent is used when the developer never set one`() { + Given("untouched options and a banner") { + val reported = reportedAdConsent(SuperwallOptions(), banner) + + Then("the banner's values are reported from the tcf source") { + assertEquals(ReportedAdConsent(banner, AdConsentSource.TCF), reported) + assertEquals("tcf", reported.source.raw) + } + } + } + + @Test + fun `developer consent wins over the banner`() { + Given("the developer denied ad user data and a banner granting personalization only") { + val options = SuperwallOptions { adConsent = AdConsent(adUserData = DENIED, adPersonalization = DENIED) } + val reported = reportedAdConsent(options, AdConsent(GRANTED, GRANTED)) + + Then("the developer's values are reported") { + assertEquals(ReportedAdConsent(AdConsent(DENIED, DENIED), AdConsentSource.DEVELOPER), reported) + assertEquals("developer", reported.source.raw) + } + } + } + + @Test + fun `explicitly setting the default value still wins over the banner`() { + Given("the developer assigned the default consent") { + val options = SuperwallOptions() + options.adConsent = AdConsent() + + When("a banner denies everything") { + val reported = reportedAdConsent(options, AdConsent(DENIED, DENIED)) + + Then("granted is reported from the developer source") { + assertEquals(ReportedAdConsent(AdConsent(), AdConsentSource.DEVELOPER), reported) + } + } + } + } + + @Test + fun `tracking NONE denies everything but keeps the source`() { + Given("tracking is NONE") { + fun options(set: Boolean) = + SuperwallOptions { + eventTrackingBehavior = EventTrackingBehavior.NONE + if (set) adConsent = AdConsent() + } + + Then("every source reports denied") { + val denied = AdConsent(DENIED, DENIED) + assertEquals( + ReportedAdConsent(denied, AdConsentSource.DEVELOPER), + reportedAdConsent(options(set = true), AdConsent()), + ) + assertEquals( + ReportedAdConsent(denied, AdConsentSource.TCF), + reportedAdConsent(options(set = false), AdConsent()), + ) + assertEquals( + ReportedAdConsent(denied, AdConsentSource.DEFAULT), + reportedAdConsent(options(set = false), null), + ) + } + } + } + + @Test + fun `config attributes keep the raw option and whether it was set`() { + Given("untouched options") { + val options = SuperwallOptions() + + Then("ad_consent_set is false until assigned") { + assertEquals(false, options.toMap()["ad_consent_set"]) + options.adConsent = AdConsent() + assertEquals(true, options.toMap()["ad_consent_set"]) + } + } + } +} + +@RunWith(RobolectricTestRunner::class) +class TcfConsentReaderTest { + private lateinit var preferences: SharedPreferences + private lateinit var reader: TcfConsentReader + private val configAttributes: Trackable = mockk() + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + preferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE) + preferences.edit().clear().commit() + reader = TcfConsentReader(preferences) + } + + private fun writeBanner( + gdprApplies: Any?, + purposes: String?, + ) { + preferences + .edit() + .apply { + when (gdprApplies) { + is Int -> putInt(IABTCF_GDPR_APPLIES, gdprApplies) + is String -> putString(IABTCF_GDPR_APPLIES, gdprApplies) + is Boolean -> putBoolean(IABTCF_GDPR_APPLIES, gdprApplies) + null -> remove(IABTCF_GDPR_APPLIES) + } + if (purposes == null) remove(IABTCF_PURPOSE_CONSENTS) else putString(IABTCF_PURPOSE_CONSENTS, purposes) + }.commit() + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `reads the banner from preferences whatever type gdprApplies has`() { + Given("a banner granting only ad user data") { + Then("an Int, String or Boolean gdprApplies is read") { + writeBanner(1, "1000001000") + assertEquals(AdConsent(GRANTED, DENIED), reader.read()) + writeBanner("1", "1000001000") + assertEquals(AdConsent(GRANTED, DENIED), reader.read()) + writeBanner(true, "1000001000") + assertEquals(AdConsent(GRANTED, DENIED), reader.read()) + } + Then("0 or missing gdprApplies gives no consent") { + writeBanner(0, "1000001000") + assertNull(reader.read()) + writeBanner(null, "1000001000") + assertNull(reader.read()) + } + Then("empty purposes give no consent") { + writeBanner(1, "") + assertNull(reader.read()) + } + } + } + + @Test + fun `nothing stored gives no consent`() { + assertNull(reader.read()) + } + + private fun observedPublisher( + options: SuperwallOptions, + tracked: MutableList, + scope: kotlinx.coroutines.CoroutineScope, + ): AdConsentPublisher = + AdConsentPublisher( + scope = scope, + track = { tracked += it }, + makeDeviceAttributes = { + val reported = reportedAdConsent(options, reader.read()) + hashMapOf( + "adUserDataConsent" to reported.consent.adUserData.raw, + "adPersonalizationConsent" to reported.consent.adPersonalization.raw, + "adConsentSource" to reported.source.raw, + ) + }, + makeConfigAttributes = { configAttributes }, + ) + + @Test + fun `a banner change after start publishes once with the new values`() = + runTest { + Given("an observed reader with no banner yet") { + val options = SuperwallOptions() + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner stores consent for ad user data only") { + writeBanner(1, "1000001000") + testScheduler.advanceUntilIdle() + + Then("exactly one publish reports the banner values from tcf") { + val devices = tracked.filterIsInstance() + assertEquals(1, devices.size) + val attributes = devices.single().deviceAttributes + assertEquals("granted", attributes["adUserDataConsent"]) + assertEquals("denied", attributes["adPersonalizationConsent"]) + assertEquals("tcf", attributes["adConsentSource"]) + } + } + } + } + + @Test + fun `no publish when the banner write leaves consent unchanged`() = + runTest { + Given("an observed reader with a banner already stored") { + writeBanner(1, ALL_PURPOSES) + val options = SuperwallOptions() + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner rewrites the same consent and other keys change") { + writeBanner(1, ALL_PURPOSES) + writeBanner(1, "1111111111111") + preferences.edit().putString("IABTCF_TCString", "abc").commit() + shadowOf(Looper.getMainLooper()).idle() + testScheduler.advanceUntilIdle() + + Then("nothing is published") { + assertEquals(0, tracked.size) + } + } + } + } + + @Test + fun `no publish when the developer's consent takes precedence`() = + runTest { + Given("the developer set consent, even to the default") { + val options = SuperwallOptions { adConsent = AdConsent() } + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner stores denied consent") { + writeBanner(1, "0000000000") + testScheduler.advanceUntilIdle() + + Then("nothing is published and the developer's value is still reported") { + assertEquals(0, tracked.size) + assertEquals( + ReportedAdConsent(AdConsent(), AdConsentSource.DEVELOPER), + reportedAdConsent(options, reader.read()), + ) + } + } + } + } + + @Test + fun `no publish while tracking is NONE`() = + runTest { + Given("tracking is NONE") { + val options = SuperwallOptions { eventTrackingBehavior = EventTrackingBehavior.NONE } + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner stores consent") { + writeBanner(1, ALL_PURPOSES) + testScheduler.advanceUntilIdle() + + Then("nothing is published") { + assertEquals(0, tracked.size) + } + } + } + } + + @Test + fun `withdrawing the banner falls back to the default`() = + runTest { + Given("an observed reader with a denying banner") { + writeBanner(1, "0000000000") + val options = SuperwallOptions() + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner data is cleared") { + writeBanner(null, null) + testScheduler.advanceUntilIdle() + + Then("one publish reports granted from the default source") { + val devices = tracked.filterIsInstance() + assertEquals(1, devices.size) + assertEquals("granted", devices.single().deviceAttributes["adUserDataConsent"]) + assertEquals("default", devices.single().deviceAttributes["adConsentSource"]) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt index a392cc6ba..105b2d98d 100644 --- a/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt @@ -53,6 +53,7 @@ class DeviceHelperAdConsentTest { private var currentUserId: String? = "user-1" private var currentEntitlements = setOf(Entitlement("basic")) private val options = SuperwallOptions() + private var bannerConsent: AdConsent? = null @Before fun setUp() { @@ -95,6 +96,7 @@ class DeviceHelperAdConsentTest { network = network, factory = factory, classifier = classifier, + tcfAdConsent = { bannerConsent }, ) } @@ -110,9 +112,10 @@ class DeviceHelperAdConsentTest { When("getting the device template") { val template = deviceHelper.getTemplateDevice() - Then("both consent attributes are granted") { + Then("both consent attributes are granted from the default source") { assertEquals("granted", template["adUserDataConsent"]) assertEquals("granted", template["adPersonalizationConsent"]) + assertEquals("default", template["adConsentSource"]) } } } @@ -160,4 +163,63 @@ class DeviceHelperAdConsentTest { } } } + + @Test + fun `banner consent is reported when the developer never set one`() = + runTest { + Given("a memoized template and no developer consent") { + deviceHelper.getTemplateDevice() + val cachedAfterFirst = deviceHelper.cachedTemplate + + When("an IAB TCF banner stores consent for ad user data only") { + bannerConsent = AdConsent(AdConsentStatus.GRANTED, AdConsentStatus.DENIED) + val template = deviceHelper.getTemplateDevice() + + Then("the template is rebuilt with the banner values and the tcf source") { + assertEquals("granted", template["adUserDataConsent"]) + assertEquals("denied", template["adPersonalizationConsent"]) + assertEquals("tcf", template["adConsentSource"]) + assertNotSame(cachedAfterFirst, deviceHelper.cachedTemplate) + } + } + } + } + + @Test + fun `developer consent wins over the banner`() = + runTest { + Given("a banner denying everything") { + bannerConsent = AdConsent(AdConsentStatus.DENIED, AdConsentStatus.DENIED) + + When("the developer sets the default consent") { + options.adConsent = AdConsent() + val template = deviceHelper.getTemplateDevice() + + Then("granted is reported from the developer source") { + assertEquals("granted", template["adUserDataConsent"]) + assertEquals("granted", template["adPersonalizationConsent"]) + assertEquals("developer", template["adConsentSource"]) + } + } + } + } + + @Test + fun `source change alone rebuilds the memoized template`() = + runTest { + Given("a memoized template with the default granted consent") { + deviceHelper.getTemplateDevice() + val cachedAfterFirst = deviceHelper.cachedTemplate + + When("a banner grants everything, so only the source changes") { + bannerConsent = AdConsent() + val template = deviceHelper.getTemplateDevice() + + Then("the template reports the tcf source") { + assertEquals("tcf", template["adConsentSource"]) + assertNotSame(cachedAfterFirst, deviceHelper.cachedTemplate) + } + } + } + } } From 478a3a95aecb2298ce63e582d6b7339b3044a5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:33:19 +0200 Subject: [PATCH 24/32] docs(changelog): describe TCF banner consent as the fallback before granted Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd13ba8d8..2d37a959f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. -- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads (other ad networks don't use it yet). Both default to granted; apps with users in the EEA, UK or Switzerland must set them from their consent flow. Both are reported as denied when `eventTrackingBehavior` is `NONE`. If `adConsent` is never set, the SDK uses the purpose consents stored by an IAB TCF consent banner when GDPR applies (reported with the new `adConsentSource` device attribute as `developer`, `tcf` or `default`). +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads (other ad networks don't use it yet). If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. From 300d6c339a37e3d69d16cc0377b88474c1e3cdf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:50:46 +0200 Subject: [PATCH 25/32] docs(mmp): adConsent now also applies to Meta Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- superwall/src/main/java/com/superwall/sdk/Superwall.kt | 2 +- .../main/java/com/superwall/sdk/config/options/AdConsent.kt | 6 +++--- .../com/superwall/sdk/config/options/SuperwallOptions.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d37a959f..8caf1f0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. - Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. -- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads (other ad networks don't use it yet). If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). Both are reported as denied when `eventTrackingBehavior` is `NONE`. +- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads and Meta. If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 7cc39bd11..f7daed960 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -203,7 +203,7 @@ class Superwall( /** * The user's consent for ad measurement, forwarded with the conversions Superwall - * uploads to Google Ads. Other ad networks don't use it yet. + * uploads to Google Ads and Meta. * * If not set, the SDK uses the consent stored by an IAB TCF consent banner when EU rules * apply, otherwise granted. Changes are sent to Superwall straight away. diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt index 94d959974..a8baf5e3c 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -15,10 +15,10 @@ enum class AdConsentStatus( /** * The user's consent for ad measurement, forwarded with the conversions Superwall - * uploads to Google Ads. Other ad networks don't use it yet. + * uploads to Google Ads and Meta. * - * - [adUserData]: consent to send user data to Google for advertising. - * - [adPersonalization]: consent for Google to use that data for personalized advertising. + * - [adUserData]: consent to send user data to ad networks for advertising. + * - [adPersonalization]: consent for them to use that data for personalized advertising. * * Both default to [AdConsentStatus.GRANTED]. */ diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index fce74fc2a..ebeacbbbb 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -106,7 +106,7 @@ class SuperwallOptions() { // The user's consent for ad measurement, reported to Superwall as the device // attributes `adUserDataConsent` and `adPersonalizationConsent` and forwarded with - // the conversions Superwall uploads to Google Ads. Other ad networks don't use it yet. + // the conversions Superwall uploads to Google Ads and Meta. // // If not set, the SDK uses the consent stored by an IAB TCF consent banner when EU rules // apply, otherwise granted. Both are reported as denied while [eventTrackingBehavior] is From 942b1571c37cbc9f50a6cc8d0ad91562b62b69d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 Oct 2026 13:08:52 +0000 Subject: [PATCH 26/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 6b3b6017c..706329fa4 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches43.9% \ No newline at end of file +branches44% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 0b297d8c0..f90ec39d9 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.9% \ No newline at end of file +coverage53.8% \ No newline at end of file From 48915efaac20812bb95ed21922df415ef470171a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:18:23 +0200 Subject: [PATCH 27/32] fix(mmp): re-send ad consent when a device-attributes send raced a change A device-attributes event reads consent when it is built and is tracked later; a banner or setter change in between was published first and then overtaken by the stale send. After tracking device attributes, compare the consent they carried with the current one and re-send when it differs, as iOS does. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/superwall/sdk/Superwall.kt | 14 ++++++++++ .../sdk/analytics/internal/Tracking.kt | 4 +++ .../superwall/sdk/config/options/AdConsent.kt | 12 +++++++++ .../sdk/network/device/DeviceHelper.kt | 2 +- .../sdk/config/options/AdConsentTest.kt | 27 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index f7daed960..d6ee0140b 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -18,6 +18,7 @@ import com.superwall.sdk.billing.toInternalResult import com.superwall.sdk.config.ConfigState import com.superwall.sdk.config.models.ConfigurationStatus import com.superwall.sdk.config.options.AdConsentPublisher +import com.superwall.sdk.config.options.differsFrom import com.superwall.sdk.config.options.AdConsent import com.superwall.sdk.config.options.EventTrackingBehavior import com.superwall.sdk.config.options.SuperwallOptions @@ -223,6 +224,19 @@ class Superwall( adConsentPublisher.publish() } + /** + * Re-sends device attributes when the ad consent [sent] carried is no longer current: a + * send that read consent before a banner or setter change finished after it. + */ + internal fun reconcileAdConsentAfterPublish(sent: Map) { + if (options.eventTrackingBehavior == EventTrackingBehavior.NONE) { + return + } + if (dependencyContainer.deviceHelper.currentAdConsent.differsFrom(sent)) { + adConsentPublisher.publish() + } + } + private val adConsentPublisher by lazy { AdConsentPublisher( scope = ioScope, diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt index e896b1725..5a5ceb19e 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.analytics.internal import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.internal.trackable.Trackable import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEventInfo @@ -96,6 +97,9 @@ suspend fun Superwall.track(event: Trackable): Result { event = event, ) } + if (event is InternalSuperwallEvent.DeviceAttributes) { + reconcileAdConsentAfterPublish(event.deviceAttributes) + } dependencyContainer.storage.coreDataManager.saveEventData(eventData) if (event.canImplicitlyTriggerPaywall) { diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt index a8baf5e3c..b0f620e44 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -64,6 +64,18 @@ internal data class ReportedAdConsent( val source: AdConsentSource, ) +/** The device attributes this consent is reported as. */ +internal fun ReportedAdConsent.toAttributes(): Map = + mapOf( + "adUserDataConsent" to consent.adUserData.raw, + "adPersonalizationConsent" to consent.adPersonalization.raw, + "adConsentSource" to source.raw, + ) + +/** Whether device attributes that carried [sent] no longer report this consent. */ +internal fun ReportedAdConsent.differsFrom(sent: Map): Boolean = + toAttributes().any { (key, value) -> sent[key] != value } + /** * Picks the consent to report: the developer's [SuperwallOptions.adConsent] if it was ever * set, else the IAB TCF [bannerConsent] if present, else the granted default. Everything is diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 46e728a79..509c62ed2 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -618,7 +618,7 @@ class DeviceHelper( currentAdConsent.let { "${it.consent.adUserData}:${it.consent.adPersonalization}:${it.source.raw}" }, ).joinToString("|") - private val currentAdConsent: ReportedAdConsent + internal val currentAdConsent: ReportedAdConsent get() = reportedAdConsent(factory.makeSuperwallOptions(), tcfAdConsent()) private suspend fun buildDeviceTemplate( diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt index 6ac3ceb77..3c8dd721f 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.config.options +import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When @@ -71,4 +72,30 @@ class AdConsentTest { } } } + + @Test + fun `a sent consent differs only when a reported value or the source changed`() { + Given("tcf consent with personalization denied") { + val current = + ReportedAdConsent( + AdConsent(adPersonalization = AdConsentStatus.DENIED), + AdConsentSource.TCF, + ) + + Then("attributes carrying the same values match") { + assertEquals(false, current.differsFrom(HashMap(current.toAttributes()))) + } + And("an older granted send, a different source or missing keys differ") { + assertEquals( + true, + current.differsFrom(hashMapOf("adUserDataConsent" to "granted", "adPersonalizationConsent" to "granted", "adConsentSource" to "tcf")), + ) + assertEquals( + true, + current.differsFrom(hashMapOf("adUserDataConsent" to "granted", "adPersonalizationConsent" to "denied", "adConsentSource" to "default")), + ) + assertEquals(true, current.differsFrom(hashMapOf("deviceModel" to "Pixel"))) + } + } + } } From d76a49b9eb488853f67f6c993dacd6515deee8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:34:34 +0200 Subject: [PATCH 28/32] docs: describe what the code does, not its history Co-Authored-By: Claude Opus 5.5 --- superwall/src/main/java/com/superwall/sdk/Superwall.kt | 4 ++-- .../sdk/models/attribution/AttributionProvider.kt | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index d6ee0140b..e64bc5799 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -225,8 +225,8 @@ class Superwall( } /** - * Re-sends device attributes when the ad consent [sent] carried is no longer current: a - * send that read consent before a banner or setter change finished after it. + * Re-sends device attributes when the ad consent [sent] carried differs from the current + * consent. Called after every device attributes send. */ internal fun reconcileAdConsentAfterPublish(sent: Map) { if (options.eventTrackingBehavior == EventTrackingBehavior.NONE) { diff --git a/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt b/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt index 7074e71e5..be4aadbd8 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt @@ -105,12 +105,8 @@ enum class AttributionProvider( MIXPANEL("mixpanel"), /** - * The Google Advertising ID (AAID/GAID) for the device. - * - * The SDK collected this automatically until 2.5.5, when it was removed over Google's - * detection of the `AD_ID` permission; set it here instead. Install-attribution matching - * forwards it as the request's `aaid`, the same slot the SDK used to fill itself — the - * Android counterpart to `idfa` on iOS. + * The Google Advertising ID (AAID/GAID) for the device. The SDK doesn't collect it, so + * set it here if your app has it. */ @SerialName("googleAds") GOOGLE_ADS("googleAds"), From 348bf68fc9c4d5aca793333a50c311deabd8a23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:46:48 +0200 Subject: [PATCH 29/32] fix(mmp): don't re-send ad consent after a device attributes send without it An empty device template carries no consent keys, so reconciling against it re-sent on every send for as long as the template failed to build. Co-Authored-By: Claude Opus 5.5 --- .../com/superwall/sdk/config/options/AdConsent.kt | 12 +++++++++--- .../superwall/sdk/config/options/AdConsentTest.kt | 7 +++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt index b0f620e44..ef3e10c63 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt @@ -72,9 +72,15 @@ internal fun ReportedAdConsent.toAttributes(): Map = "adConsentSource" to source.raw, ) -/** Whether device attributes that carried [sent] no longer report this consent. */ -internal fun ReportedAdConsent.differsFrom(sent: Map): Boolean = - toAttributes().any { (key, value) -> sent[key] != value } +/** + * Whether device attributes that carried [sent] report a different consent. Attributes with + * no consent keys, such as an empty template, never differ. + */ +internal fun ReportedAdConsent.differsFrom(sent: Map): Boolean { + val attributes = toAttributes() + if (attributes.keys.none(sent::containsKey)) return false + return attributes.any { (key, value) -> sent[key] != value } +} /** * Picks the consent to report: the developer's [SuperwallOptions.adConsent] if it was ever diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt index 3c8dd721f..21b8b6537 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt @@ -85,7 +85,7 @@ class AdConsentTest { Then("attributes carrying the same values match") { assertEquals(false, current.differsFrom(HashMap(current.toAttributes()))) } - And("an older granted send, a different source or missing keys differ") { + And("an older granted send or a different source differ") { assertEquals( true, current.differsFrom(hashMapOf("adUserDataConsent" to "granted", "adPersonalizationConsent" to "granted", "adConsentSource" to "tcf")), @@ -94,7 +94,10 @@ class AdConsentTest { true, current.differsFrom(hashMapOf("adUserDataConsent" to "granted", "adPersonalizationConsent" to "denied", "adConsentSource" to "default")), ) - assertEquals(true, current.differsFrom(hashMapOf("deviceModel" to "Pixel"))) + } + And("attributes without consent keys, such as an empty template, never differ") { + assertEquals(false, current.differsFrom(hashMapOf("deviceModel" to "Pixel"))) + assertEquals(false, current.differsFrom(HashMap())) } } } From efa71badb9c1b19e2d1089108cf082118d09133c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 Oct 2026 13:54:20 +0000 Subject: [PATCH 30/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 706329fa4..f419ee68f 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches44% \ No newline at end of file +branches44.1% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index f90ec39d9..0b297d8c0 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.8% \ No newline at end of file +coverage53.9% \ No newline at end of file From f7d90ac509ac66b5e5cc8d772728d7ec0a79b2f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 Oct 2026 14:05:46 +0000 Subject: [PATCH 31/32] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index f419ee68f..32d809e1f 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches44.1% \ No newline at end of file +branches44.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 0b297d8c0..e25a92d05 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage53.9% \ No newline at end of file +coverage54% \ No newline at end of file From 22f5dde5d8605f420f6034f738efa0393faaa226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:17:59 +0200 Subject: [PATCH 32/32] chore(release): move install attribution and ad consent to 2.9.0 2.8.5 shipped from develop without them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +++++++++--- version.env | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8caf1f0e2..aefb5d461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,22 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. -## 2.8.5 +## 2.9.0 ## Enhancements - Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on. -- Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. -- Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. - Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads and Meta. If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). Both are reported as denied when `eventTrackingBehavior` is `NONE`. ## Fixes - Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install. + +## 2.8.5 + +## Enhancements +- Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`. +- Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events. + +## Fixes - Fix subscribers with an unexpired subscription being reported as inactive when Google Play fails to answer a purchase query, for example when the billing client isn't ready at launch. A query that succeeds and reports no purchases still deactivates straight away. - Fix subscribers being reported as inactive when Google Play returns an active purchase whose product no longer maps to an entitlement in config. A lost mapping is no longer treated as the subscription ending. - Fix purchases being refunded by Google Play for not being acknowledged when they did not complete through the billing flow callback, for example a pending purchase that settled while the app was closed, the app being killed mid-purchase, or a Play Store promo code. The `AutomaticPurchaseController` now acknowledges any unacknowledged purchase whenever it syncs the subscription status, including on launch, and retries acknowledgements that fail. diff --git a/version.env b/version.env index 4d3e62fb2..2ac3eac6e 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.8.5 +SUPERWALL_VERSION=2.9.0