Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ class NetworkMock : SuperwallAPI {
@Throws(Exception::class)
override suspend fun getAssignments(): Either<List<Assignment>, NetworkError> = Either.Success(assignments)

override suspend fun matchMMPInstall(
installReferrerClickId: Long?,
integrationAttributes: Map<String, String>,
): Either<MmpMatchResponse, NetworkError> =
Either.Failure(NetworkError.NotFound())

override suspend fun webEntitlementsByUserId(
userId: UserId,
deviceId: DeviceVendorId,
Expand Down
45 changes: 44 additions & 1 deletion superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any?>) -> Unit,
private val sendMatchRequest: suspend (Long?) -> Either<MmpMatchResponse, NetworkError>,
) {
/**
* 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<String, JsonElement>) {
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<String, JsonElement>?,
key: String,
): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -144,6 +145,21 @@ sealed class InternalSuperwallEvent(
)
}

class AttributionMatch(
val info: AttributionMatchInfo,
override val audienceFilterParams: Map<String, Any> = emptyMap(),
) : InternalSuperwallEvent(SuperwallEvent.AttributionMatch(info)) {
override suspend fun getSuperwallParameters(): Map<String, Any> =
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<String, Any> = HashMap(),
) : InternalSuperwallEvent(SuperwallEvent.IdentityAlias()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}

Expand Down Expand Up @@ -153,6 +166,9 @@ internal fun SuperwallOptions.NetworkEnvironment.toMap(): Map<String, Any> =
"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()
Expand Down
Loading