Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3cd4790
feat: add android install attribution matching
yusuftor Mar 27, 2026
4b0e811
style: format android attribution changes
yusuftor Mar 27, 2026
33fe108
fix android mmp review issues
yusuftor Mar 30, 2026
8d317a3
remove android apple search ads attribution provider
yusuftor Mar 30, 2026
0776127
await android mmp attribution request before config fetch
yusuftor Mar 30, 2026
4276a6e
keep android mmp request off startup critical path
yusuftor Mar 30, 2026
f5de4df
fix android referrer and test network mock
yusuftor Mar 30, 2026
3b57d05
Update MMP resolution and linking
ianrumac Aug 27, 2026
fb2d95b
fix(mmp): gate install matching on config, redeem referrer codes once…
yusuftor Oct 8, 2026
bd765ca
feat(mmp): add ad consent option for Google Ads conversion uploads
yusuftor Oct 8, 2026
fa1f258
docs(changelog): move install attribution to 2.8.5 and describe it ac…
yusuftor Oct 8, 2026
08901e0
Update coverage badge [skip ci]
github-actions[bot] Oct 8, 2026
c665202
fix(mmp): rename ConsentStatus to AdConsentStatus and serialize adCon…
yusuftor Oct 8, 2026
0eb0035
Update coverage badge [skip ci]
github-actions[bot] Oct 8, 2026
688a8af
fix(mmp): re-send ad consent when tracking is turned back on
yusuftor Oct 8, 2026
5d8047d
Update coverage badge [skip ci]
github-actions[bot] Oct 8, 2026
9eda6ad
fix(mmp): address review on install matching, referrer and Custom hosts
yusuftor Oct 8, 2026
828b3ba
Update coverage badge [skip ci]
github-actions[bot] Oct 8, 2026
c4773b9
docs(mmp): say adConsent is only used for Google Ads today
yusuftor Oct 9, 2026
c313eaf
fix(mmp): re-send ad consent when a tracking change clears the events…
yusuftor Oct 9, 2026
bae64bc
Update coverage badge [skip ci]
github-actions[bot] Oct 9, 2026
2d0e1b8
Update coverage badge [skip ci]
github-actions[bot] Oct 9, 2026
a50fd13
feat(mmp): read ad consent from the app's IAB TCF consent banner
yusuftor Oct 9, 2026
478a3a9
docs(changelog): describe TCF banner consent as the fallback before g…
yusuftor Oct 9, 2026
300d6c3
docs(mmp): adConsent now also applies to Meta
yusuftor Oct 9, 2026
942b157
Update coverage badge [skip ci]
github-actions[bot] Oct 9, 2026
48915ef
fix(mmp): re-send ad consent when a device-attributes send raced a ch…
yusuftor Oct 9, 2026
d76a49b
docs: describe what the code does, not its history
yusuftor Oct 9, 2026
348bf68
fix(mmp): don't re-send ad consent after a device attributes send wit…
yusuftor Oct 9, 2026
efa71ba
Update coverage badge [skip ci]
github-actions[bot] Oct 9, 2026
f7d90ac
Update coverage badge [skip ci]
github-actions[bot] Oct 9, 2026
22f5dde
chore(release): move install attribution and ad consent to 2.9.0
yusuftor Oct 9, 2026
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
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
109 changes: 108 additions & 1 deletion superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Any>) {
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.
*/
Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading