From dfd3e56ad073c4f7fa5511398e4f4325f4a19f04 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/7] 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 5b2034bde..959944f7d 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -68,6 +68,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 @@ -80,6 +81,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 @@ -677,12 +679,33 @@ 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() + + 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 ca265a39b..2131a94a6 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.analytics.superwall.TransactionProduct import com.superwall.sdk.config.models.Survey @@ -143,6 +144,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 413d4aaf8..5361c8975 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 @@ -261,6 +261,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 df25d204a..7ae531805 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 8a4e5091d..e666fc5f3 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) } @@ -129,6 +131,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 202150f27..30dca6a90 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -58,6 +58,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 @@ -356,8 +357,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 bb1d55386..66b32d364 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( @@ -127,6 +198,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 7d5d382b3..5a94f6992 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 @@ -292,8 +292,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 @@ -353,6 +365,9 @@ class DeviceHelper( return formatter.format(date.getSuccess() ?: Date()) } + val appInstalledAtMillis: Long + get() = appInstallDate.time + var interfaceStyleOverride: InterfaceStyle? = null val interfaceStyle: String 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 4c76cba87ec7fd93e9fc85d4fa134ce7f0f29765 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/7] 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 959944f7d..cfd8a24c4 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -67,8 +67,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 30dca6a90..c75001784 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -381,7 +381,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 66b32d364..f0ec10f3c 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 @@ -259,8 +261,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 5a94f6992..2d8883549 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 @@ -305,7 +305,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 1d319b3d4f5360b804fe4ac35982899c60128a6b 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/7] 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 f0ec10f3c..6ebd29c3a 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 1fad123335cec28effa78dd7df7d6cbd6257dd4e 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/7] 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 e4a7eb0f6224c8864503dbb33454fb99018fbc62 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/7] 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 564cac4c65a6cb4f13e011db3fccf64ba698a2ec 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/7] 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 100d7e4f41255bdb21bcf2a64f1fd8bda9aa9116 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/7] 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() }