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 bc85bf533..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,6 +68,12 @@ class NetworkMock : SuperwallAPI { @Throws(Exception::class) override suspend fun getAssignments(): Either, NetworkError> = Either.Success(assignments) + override suspend fun matchMMPInstall( + installReferrerClickId: Long?, + integrationAttributes: Map, + ): Either = + Either.Failure(NetworkError.NotFound()) + override suspend fun webEntitlementsByUserId( userId: UserId, deviceId: DeviceVendorId, diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index bdd4659ac..0c360e75a 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -70,6 +70,7 @@ 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.DidTrackAppInstall import com.superwall.sdk.storage.LatestCustomerInfo import com.superwall.sdk.storage.ReviewCount import com.superwall.sdk.storage.ReviewData @@ -87,6 +88,7 @@ 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 @@ -712,14 +714,48 @@ class Superwall( ioScope.launch { withErrorTracking { + val hadTrackedAppInstallBeforeConfigure = + dependencyContainer.storage.read(DidTrackAppInstall) ?: false + dependencyContainer.storage.recordAppInstall { 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.configManager.fetchConfiguration() 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, + ) + ) { + ioScope.launch { + val installReferrerClickId = + dependencyContainer.deepLinkReferrer + .checkForMmpClickId() + .getOrNull() + + dependencyContainer.storage.recordMMPInstallAttributionRequest { + dependencyContainer.mmpAttributionManager + .matchInstall(installReferrerClickId) + } + } + } + + fetchConfig.await() }.toResult().fold({ CoroutineScope(Dispatchers.Main).launch { completion?.invoke(Result.success(Unit)) @@ -925,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/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..9a347e625 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt @@ -0,0 +1,44 @@ +package com.superwall.sdk.analytics.superwall + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 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"), + } + + /** + * 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..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 @@ -66,6 +76,9 @@ class SuperwallOptions() { override val collectorHost: String, override val scheme: String, override val port: Int?, + override val subscriptionHost: String = baseHost, + override val enrichmentHost: String = baseHost, + override val mmpHost: String = baseHost, ) : NetworkEnvironment(baseHost) } @@ -153,6 +166,9 @@ 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, + "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 9b3b2228d..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 @@ -69,6 +71,7 @@ import com.superwall.sdk.network.BaseHostService import com.superwall.sdk.network.CollectorService import com.superwall.sdk.network.EnrichmentService import com.superwall.sdk.network.JsonFactory +import com.superwall.sdk.network.MmpService import com.superwall.sdk.network.Network import com.superwall.sdk.network.RequestExecutor import com.superwall.sdk.network.SubscriptionService @@ -243,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. @@ -399,6 +404,34 @@ class DependencyContainer( factory = this, customHttpUrlConnection = httpConnection, ), + mmpService = + MmpService( + host = api.mmp.host, + version = "/", + factory = this, + json = + Json(from = json()) { + ignoreUnknownKeys = true + namingStrategy = null + }, + customHttpUrlConnection = + CustomHttpUrlConnection( + json = + 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 -> + makeHeaders(debugging, requestId) + }, + ), + ), factory = this, ) errorTracker = ErrorTracker(scope = ioScope, cache = storage) @@ -511,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, @@ -733,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 new file mode 100644 index 000000000..1e82f0d34 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/MmpService.kt @@ -0,0 +1,110 @@ +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 +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, + // 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, + 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, + // 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 +data class MmpMatchResponse( + val matched: Boolean, + val confidence: AttributionMatchInfo.Confidence? = null, + val matchScore: Double? = null, + val clickId: Long? = 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, +) + +/** + * 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, + val factory: ApiFactory, + json: Json, + override val customHttpUrlConnection: CustomHttpUrlConnection, +) : NetworkService() { + override suspend fun makeHeaders( + isForDebugging: Boolean, + 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 + } + + 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..15e910607 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -25,9 +25,13 @@ 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 java.util.Date +import java.util.TimeZone import java.util.UUID import kotlin.time.Duration @@ -35,9 +39,16 @@ 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" + override suspend fun sendEvents(events: EventsRequest): Either = collectorService .events( @@ -128,6 +139,64 @@ open class Network( it.assignments }.logError("/assignments") + override suspend fun matchMMPInstall( + installReferrerClickId: Long?, + integrationAttributes: Map, + ): Either { + 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 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, + 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, + integrationAttributes = advertisingIds.remaining.takeIf { it.isNotEmpty() }, + ) + + return mmpService + .matchInstall(request) + .logError("/api/match", mapOf("payload" to request)) + } + 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..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,6 +40,11 @@ interface SuperwallAPI { suspend fun getAssignments(): Either, NetworkError> + suspend fun matchMMPInstall( + installReferrerClickId: Long? = null, + integrationAttributes: Map = emptyMap(), + ): Either + 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..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 @@ -272,8 +272,22 @@ 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 +338,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..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 @@ -132,6 +133,48 @@ 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() +} + +/** + * 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" + + 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..78eb51b14 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,56 @@ open class LocalStorage( write(DidTrackAppInstall, true) } + 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 <= 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 + } + + // 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) + } + } + } + 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..635ccf725 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -10,10 +10,12 @@ 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.sync.Mutex +import kotlinx.coroutines.sync.withLock 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 +29,13 @@ class DeepLinkReferrer( context: () -> Context, private val scope: IOScope, ) : CheckForReferral { - private var referrerClient: InstallReferrerClient? + private var referrerClient: InstallReferrerClient? = null + private val referrerMutex = Mutex() + private var cachedReferrerParams: Map>? = null + private val readyReferrerClient: InstallReferrerClient? get() { - if (field?.isReady == true) { - return field + if (referrerClient?.isReady == true) { + return referrerClient } else { return null } @@ -62,7 +67,7 @@ class DeepLinkReferrer( finished = { when (it) { InstallReferrerClient.InstallReferrerResponse.OK -> { - referrerClient?.installReferrer?.installReferrer + readyReferrerClient?.installReferrer?.installReferrer } else -> { @@ -96,21 +101,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 +146,42 @@ class DeepLinkReferrer( ) } + /** + * 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 + + rawReferrer.getUrlParams().also { cachedReferrerParams = it } + } + 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) } } } 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