From bfb5a9b4d965a33013d8a6162a61eb4082526a01 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 1/8] feat: add android install attribution matching --- .../main/java/com/superwall/sdk/Superwall.kt | 25 ++- .../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, 484 insertions(+), 26 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 bdd4659ac..34e47c43a 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -71,6 +71,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 @@ -83,6 +84,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 @@ -712,14 +714,35 @@ class Superwall( ioScope.launch { withErrorTracking { + val hadTrackedAppInstallBeforeConfigure = + dependencyContainer.storage.read(DidTrackAppInstall) ?: false + dependencyContainer.storage.recordAppInstall { track(event = it) } + // Implicitly wait - dependencyContainer.configManager.fetchConfiguration() dependencyContainer.identityManager.configure( neverCalledStaticConfig = dependencyContainer.storage.neverCalledStaticConfig, ) + + if ( + dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure, + appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis, + ) + ) { + val installReferrerClickId = + DeepLinkReferrer({ context }, ioScope) + .checkForMmpClickId() + .getOrNull() + + dependencyContainer.storage.recordMMPInstallAttributionRequest { + dependencyContainer.network.matchMMPInstall(installReferrerClickId) + } + } + + dependencyContainer.configManager.fetchConfiguration() }.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 be9ea5479..13e93ee28 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 @@ -144,6 +145,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 61dca8428..1aa8e7697 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 @@ -271,6 +271,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 b4e640578..9e10c6c1d 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 abb2abda7..345885aa3 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 @@ -66,6 +66,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) } @@ -153,6 +155,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 9b3b2228d..ecb211ace 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -69,6 +69,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 @@ -399,8 +400,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 f524ef91b..c69afa17f 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 @@ -272,8 +272,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 @@ -324,6 +336,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 d7f913eed..8407f2f20 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 c6aab4fdd831234b16c77d163c3fdc9d81cabef3 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 2/8] 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 34e47c43a..4c2d1d183 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -70,8 +70,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 ecb211ace..767e91f84 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -424,7 +424,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 c69afa17f..e8b27cfc8 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 @@ -285,7 +285,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 4c8b4d80363a9ffff66485ffb27bb4850ccfd060 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 3/8] 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 79a0c4713f1d929446706350d28eaadc664b991a 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 4/8] 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 b20cad616137c2ece59fb51b43f27a5774d459e0 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 5/8] 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 ab277b2527aeb0eadae65a97bf94e17e6c231e13 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 6/8] 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 3f6bb28cd2ee39a3a62967d3ebbe286f704fca5e 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 7/8] 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 7668d572f718323f3b5f6d853b58e35a3dacbd73 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 27 Aug 2026 15:16:01 +0200 Subject: [PATCH 8/8] Update MMP resolution and linking --- CHANGELOG.md | 3 + .../com/superwall/sdk/network/NetworkMock.kt | 6 +- .../main/java/com/superwall/sdk/Superwall.kt | 38 ++- .../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 +++++++++++++ version.env | 2 +- 18 files changed, 994 insertions(+), 135 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 42fbf3527..fde2cda8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,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 4c2d1d183..0c360e75a 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -84,11 +84,11 @@ 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 import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -721,28 +721,41 @@ class Superwall( track(event = it) } + // Kick the config fetch off first so nothing below it — in particular + // the install-referrer lookup, which can block for its full timeout when + // the Play Store is unavailable — sits on the startup critical path. + val fetchConfig = async { dependencyContainer.configManager.fetchConfiguration() } + // Implicitly wait dependencyContainer.identityManager.configure( neverCalledStaticConfig = dependencyContainer.storage.neverCalledStaticConfig, ) + // 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) + } } } - dependencyContainer.configManager.fetchConfiguration() + fetchConfig.await() }.toResult().fold({ CoroutineScope(Dispatchers.Main).launch { completion?.invoke(Result.success(Unit)) @@ -948,6 +961,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 345885aa3..54107a0f5 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 @@ -51,6 +51,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 @@ -68,6 +78,7 @@ class SuperwallOptions() { override val port: Int?, override val subscriptionHost: String = baseHost, override val enrichmentHost: String = baseHost, + override val mmpHost: String = baseHost, ) : NetworkEnvironment(baseHost) } @@ -157,6 +168,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 767e91f84..1a7a67789 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 @@ -44,6 +45,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 @@ -244,6 +246,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. @@ -402,7 +406,7 @@ class DependencyContainer( ), mmpService = MmpService( - host = api.subscription.host, + host = api.mmp.host, version = "/", factory = this, json = @@ -416,6 +420,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 -> @@ -535,11 +544,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, @@ -757,6 +770,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 8407f2f20..c8dcd9a84 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)) + } +} diff --git a/version.env b/version.env index 46d6905c8..8365f7172 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.8.1 +SUPERWALL_VERSION=2.8.2