diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index bad276e8a..32d809e1f 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index 5205c3d53..e25a92d05 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d919d668..aefb5d461 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.9.0
+
+## Enhancements
+- Adds install attribution matching. If the app has performance marketing integrations set up on the Superwall dashboard, the SDK matches the install to the ad click that led to it and tracks an `attribution_match` event. The resulting `acquisition_*` attributes are added to user attributes, so they can be used as breakdowns and filters in charts and audiences, and they carry over to new users after `identify` or `reset`. The match runs once per install, within 7 days of install, only when the dashboard has turned it on for the app, and never blocks startup. It uses the Play install referrer's click id when present. It is skipped while `eventTrackingBehavior` is `NONE` and runs if tracking is turned back on.
+- Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent` for reporting the user's ad measurement consent (`AdConsent(adUserData, adPersonalization)`, each an `AdConsentStatus` of `GRANTED` or `DENIED`). It is sent as the `adUserDataConsent` and `adPersonalizationConsent` device attributes and forwarded with the conversions Superwall uploads to Google Ads and Meta. If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). Both are reported as denied when `eventTrackingBehavior` is `NONE`.
+
+## Fixes
+- Fix web checkout codes passed through the Play install referrer never being redeemed. A code is now redeemed once, on the first launch after install.
+
## 2.8.5
## Enhancements
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 2a21ab93d..e64bc5799 100644
--- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt
+++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt
@@ -17,6 +17,9 @@ import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.billing.toInternalResult
import com.superwall.sdk.config.ConfigState
import com.superwall.sdk.config.models.ConfigurationStatus
+import com.superwall.sdk.config.options.AdConsentPublisher
+import com.superwall.sdk.config.options.differsFrom
+import com.superwall.sdk.config.options.AdConsent
import com.superwall.sdk.config.options.EventTrackingBehavior
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.customercenter.CustomerCenterConfiguration
@@ -72,6 +75,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
@@ -166,25 +170,82 @@ class Superwall(
var eventTrackingBehavior: EventTrackingBehavior
get() = options.eventTrackingBehavior
set(newValue) {
+ val previous = options.eventTrackingBehavior
options.eventTrackingBehavior = newValue
- dependencyContainer.eventsQueue.setTrackingBehavior(newValue)
+ val queueUpdated = dependencyContainer.eventsQueue.setTrackingBehavior(newValue)
mainScope.launch {
paywallView?.webView?.messageHandler?.passEventTrackingBehaviorToWebView(newValue)
}
+ if (newValue != EventTrackingBehavior.NONE) {
+ dependencyContainer.mmpAttributionManager.startMatchIfEnabled()
+ }
+
// When opting out entirely, don't emit the config-attributes event —
// it would otherwise transmit attributes right after the opt-out.
if (newValue == EventTrackingBehavior.NONE) {
return
}
+ // Ad consent may not have reached Superwall: nothing is sent while tracking is
+ // off, and anything but ALL clears the events queue, which can hold a consent
+ // update. Re-send it with the config attributes once the queue has changed.
+ if (previous != newValue || newValue != EventTrackingBehavior.ALL) {
+ adConsentPublisher.publish(after = queueUpdated)
+ return
+ }
+
ioScope.launch {
track(dependencyContainer.makeConfigAttributes())
}
}
+ /**
+ * The user's consent for ad measurement, forwarded with the conversions Superwall
+ * uploads to Google Ads and Meta.
+ *
+ * If not set, the SDK uses the consent stored by an IAB TCF consent banner when EU rules
+ * apply, otherwise granted. Changes are sent to Superwall straight away.
+ *
+ * You can also set the initial value via [SuperwallOptions.adConsent] before calling
+ * [configure].
+ */
+ var adConsent: AdConsent
+ get() = options.adConsent
+ set(newValue) {
+ options.adConsent = newValue
+
+ if (options.eventTrackingBehavior == EventTrackingBehavior.NONE) {
+ return
+ }
+
+ adConsentPublisher.publish()
+ }
+
+ /**
+ * Re-sends device attributes when the ad consent [sent] carried differs from the current
+ * consent. Called after every device attributes send.
+ */
+ internal fun reconcileAdConsentAfterPublish(sent: Map) {
+ if (options.eventTrackingBehavior == EventTrackingBehavior.NONE) {
+ return
+ }
+ if (dependencyContainer.deviceHelper.currentAdConsent.differsFrom(sent)) {
+ adConsentPublisher.publish()
+ }
+ }
+
+ private val adConsentPublisher by lazy {
+ AdConsentPublisher(
+ scope = ioScope,
+ track = { track(it) },
+ makeDeviceAttributes = { dependencyContainer.makeSessionDeviceAttributes() },
+ makeConfigAttributes = { dependencyContainer.makeConfigAttributes() },
+ )
+ }
+
/**
* The presented paywall view.
*/
@@ -714,8 +775,29 @@ class Superwall(
addListeners()
+ // Off the main thread: the first read loads the app's default preferences.
+ ioScope.launch {
+ dependencyContainer.tcfConsentReader.observeReportedChanges(
+ options = { options },
+ onChange = { adConsentPublisher.publish() },
+ )
+ }
+
ioScope.launch {
withErrorTracking {
+ val hadTrackedAppInstallBeforeConfigure =
+ dependencyContainer.storage.read(DidTrackAppInstall) ?: false
+
+ // The eligibility check runs before anything else can stall, whatever
+ // the config or tracking setting says: it records that this install may
+ // be matched, which a later launch relies on if this one is killed before
+ // config arrives. Only the request waits for config to enable the MMP.
+ val shouldMatchInstall =
+ dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch(
+ hadTrackedAppInstallBeforeConfigure = hadTrackedAppInstallBeforeConfigure,
+ appInstalledAtMillis = dependencyContainer.deviceHelper.appInstalledAtMillis,
+ )
+
dependencyContainer.storage.recordAppInstall {
track(event = it)
}
@@ -733,6 +815,30 @@ class Superwall(
},
).awaitAll()
}
+
+ if (shouldMatchInstall) {
+ dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled {
+ // Skip matching when the app has opted out of all event collection.
+ // The `/api/match` call and the `acquisition_*` attribute writes
+ // happen outside the event queue, so queue-level suppression
+ // wouldn't catch them. It's tried again if the app opts back in.
+ if (eventTrackingBehavior == EventTrackingBehavior.NONE) {
+ return@matchInstallOnceEnabled false
+ }
+ ioScope.launch {
+ val installReferrerClickId =
+ dependencyContainer.deepLinkReferrer
+ .checkForMmpClickId()
+ .getOrNull()
+
+ dependencyContainer.storage.recordMMPInstallAttributionRequest {
+ dependencyContainer.mmpAttributionManager
+ .matchInstall(installReferrerClickId)
+ }
+ }
+ true
+ }
+ }
}.toResult().fold({
CoroutineScope(Dispatchers.Main).launch {
completion?.invoke(Result.success(Unit))
@@ -941,6 +1047,7 @@ class Superwall(
// Called from identity actor's completeReset during identify
// or full reset — just do cleanup without touching identity.
dependencyContainer.storage.reset()
+
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..7396b0d7c
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/attribution/MMPAttributionManager.kt
@@ -0,0 +1,194 @@
+package com.superwall.sdk.analytics.attribution
+
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
+import com.superwall.sdk.analytics.superwall.AttributionMatchInfo
+import com.superwall.sdk.config.ConfigState
+import com.superwall.sdk.config.getConfig
+import com.superwall.sdk.identity.IdentityManager
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.network.MmpMatchResponse
+import com.superwall.sdk.network.NetworkError
+import com.superwall.sdk.storage.LocalStorage
+import com.superwall.sdk.storage.MMPAcquisitionData
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+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.
+ */
+internal class MMPAttributionManager(
+ private val storage: LocalStorage,
+ private val identityManager: IdentityManager,
+ private val track: suspend (TrackableSuperwallEvent) -> Unit,
+ private val setUserAttributes: (Map) -> Unit,
+ private val sendMatchRequest: suspend (Long?) -> Either,
+ private val configState: StateFlow,
+ private val scope: CoroutineScope,
+) {
+ private val lock = Any()
+ private var startMatch: (() -> Boolean)? = null
+ private var hasStartedMatch = false
+
+ /**
+ * Fires the install-attribution match and applies its result.
+ *
+ * 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
+ }
+ }
+
+ /**
+ * The cached MMP `acquisition_*` payload as attribute values, or empty if no match
+ * ever resolved. The identity reset merges these into every new user, since the
+ * backend match only succeeds within the 7-day install window.
+ */
+ fun cachedAcquisitionAttributes(): Map =
+ storage.read(MMPAcquisitionData)?.toAttributeValues().orEmpty()
+
+ /**
+ * Calls [startMatch] once config turns the MMP on for this app, which may be
+ * straight away. It's off by default, so it never fires unless the backend
+ * enables it.
+ *
+ * [startMatch] returns whether it started the match, or false if it skipped it
+ * because the app has opted out of tracking. A skipped match is tried again
+ * when the app opts back in, via [startMatchIfEnabled].
+ */
+ fun matchInstallOnceEnabled(startMatch: () -> Boolean) {
+ synchronized(lock) { this.startMatch = startMatch }
+ scope.launch {
+ configState.first { it.getConfig()?.isMmpEnabled == true }
+ startMatchIfEnabled()
+ }
+ }
+
+ /**
+ * Starts this install's match if config has the MMP on and it hasn't started
+ * yet. Called when config arrives and when the app turns tracking back on.
+ */
+ fun startMatchIfEnabled() {
+ if (configState.value.getConfig()?.isMmpEnabled != true) {
+ return
+ }
+ synchronized(lock) {
+ val start = startMatch
+ if (hasStartedMatch || start == null) {
+ return
+ }
+ hasStartedMatch = start()
+ }
+ }
+
+ private fun mergeAcquisitionAttributesIfNeeded(acquisitionAttributes: Map) {
+ val attributes = acquisitionAttributes.toAttributeValues()
+
+ 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 Map.toAttributeValues(): Map =
+ mapNotNull { (key, value) ->
+ jsonElementToValue(value)?.let { key to it }
+ }.toMap()
+
+ 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/Tracking.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt
index e896b1725..5a5ceb19e 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt
@@ -1,6 +1,7 @@
package com.superwall.sdk.analytics.internal
import com.superwall.sdk.Superwall
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.Trackable
import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
@@ -96,6 +97,9 @@ suspend fun Superwall.track(event: Trackable): Result {
event = event,
)
}
+ if (event is InternalSuperwallEvent.DeviceAttributes) {
+ reconcileAdConsentAfterPublish(event.deviceAttributes)
+ }
dependencyContainer.storage.coreDataManager.saveEventData(eventData)
if (event.canImplicitlyTriggerPaywall) {
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
index be1a63ab5..5f2507056 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
@@ -1,5 +1,6 @@
package com.superwall.sdk.analytics.internal.trackable
+import com.superwall.sdk.analytics.superwall.AttributionMatchInfo
import com.superwall.sdk.analytics.superwall.SuperwallEvent
import com.superwall.sdk.paywall.view.webview.messaging.PageViewData
import com.superwall.sdk.analytics.superwall.TransactionProduct
@@ -147,6 +148,21 @@ sealed class InternalSuperwallEvent(
)
}
+ class AttributionMatch(
+ val info: AttributionMatchInfo,
+ override val audienceFilterParams: Map = emptyMap(),
+ ) : InternalSuperwallEvent(SuperwallEvent.AttributionMatch(info)) {
+ override suspend fun getSuperwallParameters(): Map =
+ listOfNotNull(
+ "provider" to info.provider.rawName,
+ "matched" to info.matched,
+ info.source?.let { "source" to it },
+ info.confidence?.let { "confidence" to it.rawName },
+ info.matchScore?.let { "match_score" to it },
+ info.reason?.let { "reason" to it },
+ ).toMap()
+ }
+
class IdentityAlias(
override var audienceFilterParams: HashMap = HashMap(),
) : InternalSuperwallEvent(SuperwallEvent.IdentityAlias()) {
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt
new file mode 100644
index 000000000..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 b2b768822..f03e36e43 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
@@ -273,6 +273,16 @@ sealed class SuperwallEvent {
get() = "user_attributes"
}
+ /**
+ * When install attribution is resolved or fails to resolve.
+ */
+ data class AttributionMatch(
+ val info: AttributionMatchInfo,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = "attribution_match"
+ }
+
data class NonRecurringProductPurchase(
val product: TransactionProduct,
val paywallInfo: PaywallInfo,
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
index c77d6236c..60caeb1c9 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
@@ -59,6 +59,7 @@ enum class SuperwallEvents(
ReviewGranted("review_granted"),
ReviewDenied("review_denied"),
IntegrationAttributes("integration_attributes"),
+ AttributionMatch("attribution_match"),
CustomerInfoDidChange("customerInfo_didChange"),
PermissionRequested("permission_requested"),
PermissionGranted("permission_granted"),
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt
new file mode 100644
index 000000000..ef3e10c63
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt
@@ -0,0 +1,101 @@
+package com.superwall.sdk.config.options
+
+/**
+ * Whether the user has granted or denied a consent signal.
+ */
+enum class AdConsentStatus(
+ val raw: String,
+) {
+ GRANTED("granted"),
+ DENIED("denied"),
+ ;
+
+ override fun toString(): String = raw
+}
+
+/**
+ * The user's consent for ad measurement, forwarded with the conversions Superwall
+ * uploads to Google Ads and Meta.
+ *
+ * - [adUserData]: consent to send user data to ad networks for advertising.
+ * - [adPersonalization]: consent for them to use that data for personalized advertising.
+ *
+ * Both default to [AdConsentStatus.GRANTED].
+ */
+data class AdConsent(
+ val adUserData: AdConsentStatus = AdConsentStatus.GRANTED,
+ val adPersonalization: AdConsentStatus = AdConsentStatus.GRANTED,
+)
+
+/**
+ * The consent actually reported. Everything is denied when [EventTrackingBehavior.NONE]
+ * is set, regardless of [AdConsent].
+ */
+internal fun AdConsent.effective(eventTrackingBehavior: EventTrackingBehavior): AdConsent =
+ if (eventTrackingBehavior == EventTrackingBehavior.NONE) {
+ AdConsent(adUserData = AdConsentStatus.DENIED, adPersonalization = AdConsentStatus.DENIED)
+ } else {
+ this
+ }
+
+internal fun AdConsent.toMap(): Map =
+ mapOf(
+ "ad_user_data" to adUserData.raw,
+ "ad_personalization" to adPersonalization.raw,
+ )
+
+/**
+ * Where the reported ad consent came from, sent as the `adConsentSource` device attribute.
+ */
+internal enum class AdConsentSource(
+ val raw: String,
+) {
+ DEVELOPER("developer"),
+ TCF("tcf"),
+ DEFAULT("default"),
+}
+
+/**
+ * The ad consent reported to Superwall, with [source] naming what supplied it before the
+ * [EventTrackingBehavior.NONE] rule was applied.
+ */
+internal data class ReportedAdConsent(
+ val consent: AdConsent,
+ val source: AdConsentSource,
+)
+
+/** The device attributes this consent is reported as. */
+internal fun ReportedAdConsent.toAttributes(): Map =
+ mapOf(
+ "adUserDataConsent" to consent.adUserData.raw,
+ "adPersonalizationConsent" to consent.adPersonalization.raw,
+ "adConsentSource" to source.raw,
+ )
+
+/**
+ * Whether device attributes that carried [sent] report a different consent. Attributes with
+ * no consent keys, such as an empty template, never differ.
+ */
+internal fun ReportedAdConsent.differsFrom(sent: Map): Boolean {
+ val attributes = toAttributes()
+ if (attributes.keys.none(sent::containsKey)) return false
+ return attributes.any { (key, value) -> sent[key] != value }
+}
+
+/**
+ * Picks the consent to report: the developer's [SuperwallOptions.adConsent] if it was ever
+ * set, else the IAB TCF [bannerConsent] if present, else the granted default. Everything is
+ * denied while [SuperwallOptions.eventTrackingBehavior] is [EventTrackingBehavior.NONE].
+ */
+internal fun reportedAdConsent(
+ options: SuperwallOptions,
+ bannerConsent: AdConsent?,
+): ReportedAdConsent {
+ val (consent, source) =
+ when {
+ options.isAdConsentSet -> options.adConsent to AdConsentSource.DEVELOPER
+ bannerConsent != null -> bannerConsent to AdConsentSource.TCF
+ else -> AdConsent() to AdConsentSource.DEFAULT
+ }
+ return ReportedAdConsent(consent.effective(options.eventTrackingBehavior), source)
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt
new file mode 100644
index 000000000..8a768aed1
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/AdConsentPublisher.kt
@@ -0,0 +1,41 @@
+package com.superwall.sdk.config.options
+
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.analytics.internal.trackable.Trackable
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Sends the current ad consent to Superwall as device and config attributes.
+ *
+ * Sends run one at a time, and one superseded by a later [publish] is dropped, so
+ * an older snapshot is never tracked after a newer one. A send waits for [after],
+ * so it can't be queued before an events-queue change it follows.
+ */
+internal class AdConsentPublisher(
+ private val scope: CoroutineScope,
+ private val track: suspend (Trackable) -> Unit,
+ private val makeDeviceAttributes: suspend () -> HashMap,
+ private val makeConfigAttributes: () -> Trackable,
+) {
+ private val generation = AtomicLong()
+ private val mutex = Mutex()
+
+ fun publish(after: Job? = null): Job {
+ val current = generation.incrementAndGet()
+ return scope.launch {
+ after?.join()
+ mutex.withLock {
+ if (current != generation.get()) {
+ return@withLock
+ }
+ track(InternalSuperwallEvent.DeviceAttributes(makeDeviceAttributes()))
+ track(makeConfigAttributes())
+ }
+ }
+ }
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
index f00cfa12e..ebeacbbbb 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
@@ -55,6 +55,16 @@ class SuperwallOptions() {
"enrichment-api.superwall.dev"
}
+ // Install-attribution matching runs on its own host, separate from the
+ // subscriptions API. Mirrors `mmpHost` on iOS.
+ open val mmpHost: String
+ get() =
+ if (this is Release) {
+ "mmp.superwall.com"
+ } else {
+ "mmp.superwall.dev"
+ }
+
open val port: Int?
get() = null
@@ -65,12 +75,19 @@ class SuperwallOptions() {
class Developer : NetworkEnvironment("superwall.dev")
- class Custom(
- override val baseHost: String,
- override val collectorHost: String,
- override val scheme: String,
- override val port: Int?,
- ) : NetworkEnvironment(baseHost)
+ // The optional hosts default to the same `*.superwall.dev` hosts a `Custom`
+ // environment used before they could be overridden.
+ class Custom
+ @JvmOverloads
+ constructor(
+ override val baseHost: String,
+ override val collectorHost: String,
+ override val scheme: String,
+ override val port: Int?,
+ override val subscriptionHost: String = "subscriptions-api.superwall.dev",
+ override val enrichmentHost: String = "enrichment-api.superwall.dev",
+ override val mmpHost: String = "mmp.superwall.dev",
+ ) : NetworkEnvironment(baseHost)
}
// **WARNING:**: Determines which network environment your SDK should use.
@@ -87,6 +104,26 @@ class SuperwallOptions() {
// You can also change this at runtime via [com.superwall.sdk.Superwall.eventTrackingBehavior].
var eventTrackingBehavior: EventTrackingBehavior = EventTrackingBehavior.ALL
+ // The user's consent for ad measurement, reported to Superwall as the device
+ // attributes `adUserDataConsent` and `adPersonalizationConsent` and forwarded with
+ // the conversions Superwall uploads to Google Ads and Meta.
+ //
+ // If not set, the SDK uses the consent stored by an IAB TCF consent banner when EU rules
+ // apply, otherwise granted. Both are reported as denied while [eventTrackingBehavior] is
+ // [EventTrackingBehavior.NONE].
+ //
+ // You can also change this at runtime via [com.superwall.sdk.Superwall.adConsent].
+ var adConsent: AdConsent = AdConsent()
+ set(value) {
+ field = value
+ isAdConsentSet = true
+ }
+
+ // Whether [adConsent] was ever assigned, even to its default. Only then does it take
+ // precedence over an IAB TCF consent banner.
+ internal var isAdConsentSet: Boolean = false
+ private set
+
// Enables the sending of non-Superwall tracked events and properties back to the Superwall servers.
// Defaults to `true`.
//
@@ -157,6 +194,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()
@@ -176,6 +216,8 @@ internal fun SuperwallOptions.toMap(): Map =
// backends/dashboards still reading it don't treat opted-out clients as the
// default. Mirrors the deprecated property (true only for `ALL`).
"is_external_data_collection_enabled" to (eventTrackingBehavior == EventTrackingBehavior.ALL),
+ "ad_consent" to adConsent.toMap(),
+ "ad_consent_set" to isAdConsentSet,
localeIdentifier?.let { "locale_identifier" to it },
"is_game_controller_enabled" to isGameControllerEnabled,
"logging" to logging.toMap(),
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt b/superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt
new file mode 100644
index 000000000..9c47163f1
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/TcfConsentReader.kt
@@ -0,0 +1,115 @@
+package com.superwall.sdk.config.options
+
+import android.content.SharedPreferences
+
+internal const val IABTCF_GDPR_APPLIES = "IABTCF_gdprApplies"
+internal const val IABTCF_PURPOSE_CONSENTS = "IABTCF_PurposeConsents"
+
+/**
+ * Maps the IAB TCF values a consent banner stores to ad consent, or `null` when the banner
+ * holds no consent: GDPR doesn't apply, or no purpose consents are stored.
+ *
+ * Ad user data needs purposes 1 and 7; ad personalization needs purposes 3 and 4.
+ * [gdprApplies] is accepted as `1`, `"1"` or `true`, since some banners store it untyped.
+ */
+internal fun tcfAdConsent(
+ gdprApplies: Any?,
+ purposeConsents: Any?,
+): AdConsent? {
+ val applies =
+ when (gdprApplies) {
+ is Int -> gdprApplies == 1
+ is String -> gdprApplies == "1"
+ is Boolean -> gdprApplies
+ else -> false
+ }
+ if (!applies || purposeConsents !is String || purposeConsents.isEmpty()) {
+ return null
+ }
+
+ fun agreed(vararg purposes: Int) = purposes.all { purposeConsents.getOrNull(it - 1) == '1' }
+
+ fun status(granted: Boolean) = if (granted) AdConsentStatus.GRANTED else AdConsentStatus.DENIED
+
+ return AdConsent(
+ adUserData = status(agreed(1, 7)),
+ adPersonalization = status(agreed(3, 4)),
+ )
+}
+
+/**
+ * Reads the ad consent stored by the app's IAB TCF consent banner in its default
+ * SharedPreferences, and reports changes to it.
+ */
+internal class TcfConsentReader(
+ private val preferences: SharedPreferences,
+) {
+ private var lastConsent: AdConsent? = null
+
+ // SharedPreferences holds listeners weakly, so this keeps it registered.
+ private var listener: SharedPreferences.OnSharedPreferenceChangeListener? = null
+
+ fun read(): AdConsent? =
+ try {
+ tcfAdConsent(
+ gdprApplies = preferences.untyped(IABTCF_GDPR_APPLIES),
+ purposeConsents = preferences.untyped(IABTCF_PURPOSE_CONSENTS),
+ )
+ } catch (_: Throwable) {
+ null
+ }
+
+ /**
+ * Calls [onChange] with the previous and new banner consent whenever a write to the
+ * TCF keys changes it. Only the first call registers.
+ */
+ @Synchronized
+ fun observe(onChange: (previous: AdConsent?, current: AdConsent?) -> Unit) {
+ if (listener != null) return
+ lastConsent = read()
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ // A null key means the preferences were cleared.
+ if (key != null && key != IABTCF_GDPR_APPLIES && key != IABTCF_PURPOSE_CONSENTS) {
+ return@OnSharedPreferenceChangeListener
+ }
+ val (previous, current) =
+ synchronized(this) {
+ val previous = lastConsent
+ val current = read()
+ lastConsent = current
+ previous to current
+ }
+ if (previous != current) {
+ onChange(previous, current)
+ }
+ }
+ this.listener = listener
+ preferences.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ /**
+ * Calls [onChange] when a banner change alters the consent reported with [options]:
+ * not while the developer's [SuperwallOptions.adConsent] takes precedence, and not
+ * while tracking is [EventTrackingBehavior.NONE], since nothing is sent then.
+ */
+ fun observeReportedChanges(
+ options: () -> SuperwallOptions,
+ onChange: () -> Unit,
+ ) = observe { previous, current ->
+ val currentOptions = options()
+ if (currentOptions.eventTrackingBehavior == EventTrackingBehavior.NONE) {
+ return@observe
+ }
+ if (reportedAdConsent(currentOptions, previous) != reportedAdConsent(currentOptions, current)) {
+ onChange()
+ }
+ }
+
+ private fun SharedPreferences.untyped(key: String): Any? {
+ if (!contains(key)) return null
+ return runCatching { getInt(key, 0) }.getOrNull()
+ ?: runCatching { getString(key, null) }.getOrNull()
+ ?: runCatching { getBoolean(key, false) }.getOrNull()
+ }
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
index ff7c89980..be5bda645 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
@@ -31,6 +32,7 @@ import com.superwall.sdk.config.ConfigManager
import com.superwall.sdk.config.ConfigState
import com.superwall.sdk.config.PaywallPreload
import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.config.options.TcfConsentReader
import com.superwall.sdk.customer.CustomerInfoManager
import com.superwall.sdk.customercenter.CustomerCenterManager
import com.superwall.sdk.models.customer.CustomerInfo
@@ -46,6 +48,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
@@ -71,6 +74,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
@@ -253,6 +257,15 @@ class DependencyContainer(
internal val errorTracker: ErrorTracker
internal val deepLinkRouter: DeepLinkRouter
internal val attributionManager: AttributionManager
+ internal val mmpAttributionManager: MMPAttributionManager
+ internal val deepLinkReferrer: DeepLinkReferrer
+
+ /** Ad consent stored by the app's IAB TCF consent banner, in its default SharedPreferences. */
+ internal val tcfConsentReader: TcfConsentReader by lazy {
+ TcfConsentReader(
+ context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE),
+ )
+ }
init {
// For tracking when the app enters the background.
@@ -410,6 +423,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)
@@ -433,6 +474,7 @@ class DependencyContainer(
network = network,
factory = this,
classifier = DeviceClassifier(DefaultClassifierDataFactory { context }),
+ tcfAdConsent = { tcfConsentReader.read() },
)
assignments =
@@ -515,15 +557,20 @@ class DependencyContainer(
delegate().userAttributesDidChange(it)
},
webPaywallRedeemer = { reedemer },
+ installScopedAttributes = { mmpAttributionManager.cachedAcquisitionAttributes() },
actor = identityActor,
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,
@@ -746,6 +793,19 @@ 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)
+ },
+ configState = configManager.configState,
+ scope = ioScope,
+ )
+
/**
* 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/identity/IdentityContext.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt
index 9ca63af5f..a0564db0a 100644
--- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt
@@ -15,5 +15,8 @@ interface IdentityContext : BaseContext {
val sdkContext: SdkContext
val webPaywallRedeemer: () -> WebPaywallRedeemer
val completeReset: () -> Unit
+
+ /** Install-scoped attributes (MMP `acquisition_*`) that every new identity keeps after a reset. */
+ val installScopedAttributes: () -> Map
val notifyUserChange: ((Map) -> Unit)?
}
diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
index 8d9a2c402..b8a8ae6cc 100644
--- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
@@ -31,6 +31,7 @@ class IdentityManager(
override val completeReset: () -> Unit = {
Superwall.instance.reset(duringIdentify = true)
},
+ override val installScopedAttributes: () -> Map = { emptyMap() },
override val tracker: suspend (TrackableSuperwallEvent) -> Unit = {
Superwall.instance.track(it)
},
diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt
index 390bb1151..3ce76aadf 100644
--- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt
+++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt
@@ -390,6 +390,7 @@ data class IdentityState(
object Reset : Actions({
update(Updates.Reset)
+ installScopedAttributes().takeIf { it.isNotEmpty() }?.let { update(Updates.AttributesMerged(it)) }
// Track user_attributes with the intermediate reset state during re-identify.
// Old code did this via _reset() → saveIds() → _mergeUserAttributes(shouldTrackMerge=true).
val current = state.value
@@ -404,6 +405,7 @@ data class IdentityState(
/** Matches iOS behavior where identitySubject is set to false during the reset window. */
object FullReset : Actions({
update(Updates.Reset) // identity not ready
+ installScopedAttributes().takeIf { it.isNotEmpty() }?.let { update(Updates.AttributesMerged(it)) }
// Track user_attributes with the new (reset) identity.
// Old code did this via _reset() → saveIds() → _mergeUserAttributes(shouldTrackMerge=true).
val current = state.value
diff --git a/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt b/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt
index 6e5844c26..be4aadbd8 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/attribution/AttributionProvider.kt
@@ -104,9 +104,19 @@ enum class AttributionProvider(
@SerialName("mixpanel")
MIXPANEL("mixpanel"),
+ /**
+ * The Google Advertising ID (AAID/GAID) for the device. The SDK doesn't collect it, so
+ * set it here if your app has it.
+ */
@SerialName("googleAds")
GOOGLE_ADS("googleAds"),
+ /**
+ * 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/models/config/AttributionOptions.kt b/superwall/src/main/java/com/superwall/sdk/models/config/AttributionOptions.kt
new file mode 100644
index 000000000..3abf30877
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/models/config/AttributionOptions.kt
@@ -0,0 +1,16 @@
+package com.superwall.sdk.models.config
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/** Attribution features the backend has turned on for this app. */
+@Serializable
+data class AttributionOptions(
+ @SerialName("mmp") val mmp: MmpAttributionOptions? = null,
+)
+
+/** Superwall's install attribution (MMP). Off unless the backend enables it. */
+@Serializable
+data class MmpAttributionOptions(
+ @SerialName("enabled") val enabled: Boolean = false,
+)
diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt
index 7a7e568be..9a7dda801 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt
@@ -30,11 +30,16 @@ data class Config(
@SerialName("bundle_id_config") val bundleIdConfig: String? = null,
@SerialName("test_mode_user_ids") val testModeUserIds: List? = null,
@SerialName("prioritized_campaign_id") val prioritizedCampaignId: String? = null,
+ @SerialName("attribution_options") val attributionOptions: AttributionOptions? = null,
) : SerializableEntity {
init {
locales = localizationConfig.locales.map { it.locale }.toSet()
}
+ /** Whether the backend has turned on Superwall's install attribution (MMP) for this app. */
+ val isMmpEnabled: Boolean
+ get() = attributionOptions?.mmp?.enabled == true
+
val allComputedProperties: List
get() =
triggers.flatMap { trigger ->
diff --git a/superwall/src/main/java/com/superwall/sdk/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 b65d3860f..509c62ed2 100644
--- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt
+++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt
@@ -16,6 +16,10 @@ import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.DefaultClassifierDataFactory
import com.superwall.sdk.analytics.DeviceClassifier
import com.superwall.sdk.analytics.Tier
+import com.superwall.sdk.config.options.AdConsent
+import com.superwall.sdk.config.options.ReportedAdConsent
+import com.superwall.sdk.config.options.reportedAdConsent
+import com.superwall.sdk.config.options.effective
import com.superwall.sdk.dependencies.ActiveEntitlementsFactory
import com.superwall.sdk.dependencies.CustomerInfoFactory
import com.superwall.sdk.dependencies.ExperimentalPropertiesFactory
@@ -81,6 +85,7 @@ class DeviceHelper(
val network: SuperwallAPI,
val factory: Factory,
private val classifier: DeviceClassifier = DeviceClassifier(DefaultClassifierDataFactory { context }),
+ private val tcfAdConsent: () -> AdConsent? = { null },
) {
interface Factory :
IdentityInfoFactory,
@@ -276,8 +281,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
@@ -328,6 +347,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
@@ -593,13 +615,18 @@ class DeviceHelper(
totalPaywallViews.toString(),
reviewRequestCount.toString(),
factory.storefrontCountryCode() ?: "",
+ currentAdConsent.let { "${it.consent.adUserData}:${it.consent.adPersonalization}:${it.source.raw}" },
).joinToString("|")
+ internal val currentAdConsent: ReportedAdConsent
+ get() = reportedAdConsent(factory.makeSuperwallOptions(), tcfAdConsent())
+
private suspend fun buildDeviceTemplate(
identityInfo: IdentityInfo,
volatileFields: VolatileTemplateFields,
- ): DeviceTemplate =
- DeviceTemplate(
+ ): DeviceTemplate {
+ val adConsent = currentAdConsent
+ return DeviceTemplate(
publicApiKey = storage.apiKey,
platform = "Android",
appUserId = identityInfo.appUserId ?: "",
@@ -661,7 +688,11 @@ class DeviceHelper(
reviewRequestCount = reviewRequestCount,
kotlinVersion = kotlinVersion,
storeFrontCountryCode = factory.storefrontCountryCode(),
+ adUserDataConsent = adConsent.consent.adUserData.raw,
+ adPersonalizationConsent = adConsent.consent.adPersonalization.raw,
+ adConsentSource = adConsent.source.raw,
)
+ }
suspend fun getTemplateDevice(): Map {
return withErrorTracking {
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt
index 79cecc6b5..ea0d3552f 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt
@@ -70,6 +70,9 @@ data class DeviceTemplate(
val reviewRequestCount: Int,
val kotlinVersion: String,
val storeFrontCountryCode: String? = null,
+ val adUserDataConsent: String = "granted",
+ val adPersonalizationConsent: String = "granted",
+ val adConsentSource: String = "default",
) {
fun toDictionary(json: Json): Map = json.encodeToJsonElement(serializer(), this).jsonObject.toNullableTypedMap()
}
diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
index 2b3287dd2..c380c6469 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,60 @@ 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 install-referrer redemption code already handed to the redeemer, so it's redeemed once per install. */
+object RedeemedInstallReferrerCode : Storable {
+ override val key: String
+ get() = "store.redeemedInstallReferrerCode"
+
+ override val directory: SearchPathDirectory
+ get() = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS
+
+ override val serializer: KSerializer
+ get() = String.serializer()
+}
+
+/**
+ * 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