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 @@ -branches43.6% \ No newline at end of file +branches44.2% \ 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 @@ -coverage53.6% \ No newline at end of file +coverage54% \ 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> { + 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/EventsQueue.kt b/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt index f12aad8fa..a0b51cf2d 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt @@ -78,14 +78,13 @@ class EventsQueue( } } - fun setTrackingBehavior(behavior: EventTrackingBehavior) { + fun setTrackingBehavior(behavior: EventTrackingBehavior): Job = launch { trackingBehavior = behavior if (behavior != EventTrackingBehavior.ALL) { elements.clear() } } - } private fun trackingAllowed(event: Trackable): Boolean = when (trackingBehavior) { 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..c72fdde34 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,44 @@ 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) { + // The connection failed for good, so waiting won't help. + if (referrerClient == null) return@withTimeoutOrNull 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/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index 64c46cfef..7a5127e3c 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -31,6 +31,7 @@ import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.storage.LastWebEntitlementsFetchDate import com.superwall.sdk.storage.LatestRedemptionResponse import com.superwall.sdk.storage.LatestWebCustomerInfo +import com.superwall.sdk.storage.RedeemedInstallReferrerCode import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.TrackedWebTrialCodes import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -156,8 +157,20 @@ class WebPaywallRedeemer( deepLinkReferrer .checkForReferral() .fold( - onSuccess = { - redeem(RedeemType.Code(it)) + onSuccess = { code -> + // Play keeps the install referrer for the life of the install, so + // only redeem its code on the first launch that reads it. + if (storage.read(RedeemedInstallReferrerCode) == code) { + return@fold + } + redeem(RedeemType.Code(code)) + // Only a redemption the server answered is stored with the code, so a + // failed request is retried on the next launch. + val redeemed = + storage.read(LatestRedemptionResponse)?.allCodes?.any { it.code == code } == true + if (redeemed) { + storage.write(RedeemedInstallReferrerCode, code) + } }, onFailure = { throw it }, ) 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..66b4c2e2f --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/analytics/attribution/MMPAttributionManagerTest.kt @@ -0,0 +1,334 @@ +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 com.superwall.sdk.config.ConfigState +import com.superwall.sdk.models.config.AttributionOptions +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.config.MmpAttributionOptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +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, expose the cache for resets + * without re-hitting the backend, and only match once config turns the MMP on. + */ +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( + configState = MutableStateFlow(ConfigState.None), + scope = this, + 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( + configState = MutableStateFlow(ConfigState.None), + scope = this, + 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( + configState = MutableStateFlow(ConfigState.None), + scope = this, + 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( + configState = MutableStateFlow(ConfigState.None), + scope = this, + 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( + configState = MutableStateFlow(ConfigState.None), + scope = this, + 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( + configState = MutableStateFlow(ConfigState.None), + scope = this, + 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. + val cached = + MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, + storage = storage, + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { + requests += 1 + Either.Success(matched()) + }, + ).cachedAcquisitionAttributes() + + assertEquals(1, requests) + assertEquals("tiktok", cached["acquisition_source"]) + } + + @Test + fun `cached attributes are empty when no match ever resolved`() = + runTest { + val cached = + MMPAttributionManager( + configState = MutableStateFlow(ConfigState.None), + scope = this, + storage = storage(), + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, + ).cachedAcquisitionAttributes() + + assertTrue(cached.isEmpty()) + } + + private fun configWithMmp(enabled: Boolean) = + ConfigState.Retrieved( + Config.stub().copy(attributionOptions = AttributionOptions(mmp = MmpAttributionOptions(enabled))), + ) + + private fun gatedManager( + scope: CoroutineScope, + configState: MutableStateFlow, + ) = MMPAttributionManager( + configState = configState, + scope = scope, + storage = storage(), + identityManager = identityManager(), + track = {}, + setUserAttributes = {}, + sendMatchRequest = { Either.Failure(NetworkError.Timeout) }, + ) + + @Test + fun `the match never starts while config has the MMP off`() = + runTest { + val configState = MutableStateFlow(configWithMmp(enabled = false)) + var starts = 0 + + gatedManager(backgroundScope, configState).matchInstallOnceEnabled { + starts += 1 + true + } + runCurrent() + + assertEquals(0, starts) + } + + @Test + fun `the match starts once when config turns the MMP on`() = + runTest { + val configState = MutableStateFlow(ConfigState.Retrieving) + var starts = 0 + val manager = gatedManager(backgroundScope, configState) + + manager.matchInstallOnceEnabled { + starts += 1 + true + } + runCurrent() + assertEquals(0, starts) + + configState.value = configWithMmp(enabled = true) + runCurrent() + manager.startMatchIfEnabled() + + assertEquals(1, starts) + } + + @Test + fun `a match skipped while opted out starts when tracking is turned back on`() = + runTest { + val configState = MutableStateFlow(configWithMmp(enabled = true)) + var optedOut = true + var starts = 0 + val manager = gatedManager(backgroundScope, configState) + + manager.matchInstallOnceEnabled { + if (optedOut) return@matchInstallOnceEnabled false + starts += 1 + true + } + runCurrent() + assertEquals(0, starts) + + optedOut = false + manager.startMatchIfEnabled() + manager.startMatchIfEnabled() + + assertEquals(1, starts) + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt new file mode 100644 index 000000000..276e3d2ac --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentPublisherTest.kt @@ -0,0 +1,114 @@ +package com.superwall.sdk.config.options + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdConsentPublisherTest { + private val configAttributes: Trackable = mockk() + + @Test + fun `publish sends device attributes then config attributes`() = + runTest { + Given("a publisher reporting denied personalization") { + val tracked = mutableListOf() + val publisher = + AdConsentPublisher( + scope = this@runTest, + track = { tracked += it }, + makeDeviceAttributes = { hashMapOf("adPersonalizationConsent" to "denied") }, + makeConfigAttributes = { configAttributes }, + ) + + When("it publishes") { + publisher.publish().join() + + Then("both events are tracked with the current consent") { + assertEquals(2, tracked.size) + val device = tracked[0] as InternalSuperwallEvent.DeviceAttributes + assertEquals("denied", device.deviceAttributes["adPersonalizationConsent"]) + assertTrue(tracked[1] === configAttributes) + } + } + } + } + + @Test + fun `a publish superseded while waiting is dropped`() = + runTest { + Given("a first publish blocked inside its send") { + val tracked = mutableListOf() + val release = CompletableDeferred() + var consent = "granted" + var calls = 0 + val publisher = + AdConsentPublisher( + scope = this@runTest, + track = { tracked += it }, + makeDeviceAttributes = { + calls += 1 + if (calls == 1) release.await() + hashMapOf("adPersonalizationConsent" to consent) + }, + makeConfigAttributes = { configAttributes }, + ) + val first = publisher.publish() + testScheduler.runCurrent() + + When("two more assignments arrive before it finishes") { + consent = "denied" + val second = publisher.publish() + val third = publisher.publish() + release.complete(Unit) + first.join() + second.join() + third.join() + + Then("only the first (already sending) and the latest are tracked, latest last") { + val devices = tracked.filterIsInstance() + assertEquals(2, devices.size) + assertEquals("denied", devices.last().deviceAttributes["adPersonalizationConsent"]) + assertTrue(tracked.last() === configAttributes) + } + } + } + } + + @Test + fun `publish waits for the events-queue change it follows`() = + runTest { + Given("a queue change that hasn't run yet") { + val tracked = mutableListOf() + val queueUpdated = Job() + val publisher = + AdConsentPublisher( + scope = this@runTest, + track = { tracked += it }, + makeDeviceAttributes = { hashMapOf("adUserDataConsent" to "denied") }, + makeConfigAttributes = { configAttributes }, + ) + + When("it publishes after that change") { + val sent = publisher.publish(after = queueUpdated) + testScheduler.runCurrent() + val trackedBeforeChange = tracked.size + queueUpdated.complete() + sent.join() + + Then("nothing is tracked until the queue has changed") { + assertEquals(0, trackedBeforeChange) + assertEquals(2, tracked.size) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt new file mode 100644 index 000000000..21b8b6537 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/options/AdConsentTest.kt @@ -0,0 +1,104 @@ +package com.superwall.sdk.config.options + +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import org.junit.Assert.assertEquals +import org.junit.Test + +class AdConsentTest { + @Test + fun `ad consent defaults to granted`() { + Given("default superwall options") { + val options = SuperwallOptions() + + Then("both consent signals are granted") { + assertEquals(AdConsent(AdConsentStatus.GRANTED, AdConsentStatus.GRANTED), options.adConsent) + } + } + } + + @Test + fun `consent statuses match the wire format`() { + assertEquals("granted", AdConsentStatus.GRANTED.raw) + assertEquals("denied", AdConsentStatus.DENIED.raw) + } + + @Test + fun `effective consent is unchanged unless tracking is none`() { + Given("a consent with personalization denied") { + val consent = AdConsent(adPersonalization = AdConsentStatus.DENIED) + + When("tracking is ALL or SUPERWALL_ONLY") { + Then("the consent is reported as set") { + assertEquals(consent, consent.effective(EventTrackingBehavior.ALL)) + assertEquals(consent, consent.effective(EventTrackingBehavior.SUPERWALL_ONLY)) + } + } + } + } + + @Test + fun `effective consent is denied when tracking is none`() { + Given("a fully granted consent") { + val consent = AdConsent() + + When("tracking is NONE") { + val effective = consent.effective(EventTrackingBehavior.NONE) + + Then("both signals are denied") { + assertEquals(AdConsent(AdConsentStatus.DENIED, AdConsentStatus.DENIED), effective) + } + } + } + } + + @Test + fun `toMap encodes ad consent`() { + Given("options with ad user data denied") { + val options = SuperwallOptions() + options.adConsent = AdConsent(adUserData = AdConsentStatus.DENIED) + + When("converting the options to a map") { + val map = options.toMap() + + Then("ad_consent holds both values") { + assertEquals( + mapOf("ad_user_data" to "denied", "ad_personalization" to "granted"), + map["ad_consent"], + ) + } + } + } + } + + @Test + fun `a sent consent differs only when a reported value or the source changed`() { + Given("tcf consent with personalization denied") { + val current = + ReportedAdConsent( + AdConsent(adPersonalization = AdConsentStatus.DENIED), + AdConsentSource.TCF, + ) + + Then("attributes carrying the same values match") { + assertEquals(false, current.differsFrom(HashMap(current.toAttributes()))) + } + And("an older granted send or a different source differ") { + assertEquals( + true, + current.differsFrom(hashMapOf("adUserDataConsent" to "granted", "adPersonalizationConsent" to "granted", "adConsentSource" to "tcf")), + ) + assertEquals( + true, + current.differsFrom(hashMapOf("adUserDataConsent" to "granted", "adPersonalizationConsent" to "denied", "adConsentSource" to "default")), + ) + } + And("attributes without consent keys, such as an empty template, never differ") { + assertEquals(false, current.differsFrom(hashMapOf("deviceModel" to "Pixel"))) + assertEquals(false, current.differsFrom(HashMap())) + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt b/superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt new file mode 100644 index 000000000..7ed2949a4 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/options/TcfConsentReaderTest.kt @@ -0,0 +1,400 @@ +package com.superwall.sdk.config.options + +import android.content.Context +import android.content.SharedPreferences +import android.os.Looper +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +private val GRANTED = AdConsentStatus.GRANTED +private val DENIED = AdConsentStatus.DENIED + +// Purposes 1-10. Index n-1 is purpose n. +private const val ALL_PURPOSES = "1111111111" + +class TcfAdConsentMappingTest { + @Test + fun `all four purpose combinations`() { + Given("GDPR applies") { + When("purposes 1, 3, 4 and 7 are all agreed") { + Then("both are granted") { + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent(1, ALL_PURPOSES)) + } + } + When("only purposes 1 and 7 are agreed") { + Then("only ad user data is granted") { + assertEquals(AdConsent(GRANTED, DENIED), tcfAdConsent(1, "1000001000")) + } + } + When("only purposes 3 and 4 are agreed") { + Then("only ad personalization is granted") { + assertEquals(AdConsent(DENIED, GRANTED), tcfAdConsent(1, "0011000000")) + } + } + When("none of them are agreed") { + Then("both are denied") { + assertEquals(AdConsent(DENIED, DENIED), tcfAdConsent(1, "0100010111")) + } + } + } + } + + @Test + fun `one missing purpose of a pair denies that signal`() { + Given("purpose 7 and purpose 4 not agreed") { + Then("both are denied") { + assertEquals(AdConsent(DENIED, DENIED), tcfAdConsent(1, "1110000000")) + } + } + } + + @Test + fun `gdprApplies is read defensively`() { + Given("purpose consents for everything") { + Then("1, \"1\" and true mean GDPR applies") { + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent(1, ALL_PURPOSES)) + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent("1", ALL_PURPOSES)) + assertEquals(AdConsent(GRANTED, GRANTED), tcfAdConsent(true, ALL_PURPOSES)) + } + Then("anything else means there is no banner consent") { + assertNull(tcfAdConsent(0, ALL_PURPOSES)) + assertNull(tcfAdConsent(null, ALL_PURPOSES)) + assertNull(tcfAdConsent("0", ALL_PURPOSES)) + assertNull(tcfAdConsent("true", ALL_PURPOSES)) + assertNull(tcfAdConsent(false, ALL_PURPOSES)) + assertNull(tcfAdConsent(2, ALL_PURPOSES)) + assertNull(tcfAdConsent(1.0f, ALL_PURPOSES)) + } + } + } + + @Test + fun `missing or empty purpose consents mean no banner consent`() { + Given("GDPR applies") { + Then("an empty, missing or non-string value gives no consent") { + assertNull(tcfAdConsent(1, "")) + assertNull(tcfAdConsent(1, null)) + assertNull(tcfAdConsent(1, 1111111)) + } + } + } + + @Test + fun `a too short string counts as not agreed`() { + Given("only purposes 1-4 are stored, all agreed") { + val consent = tcfAdConsent(1, "1111") + + Then("purpose 7 is not agreed, so ad user data is denied") { + assertEquals(AdConsent(DENIED, GRANTED), consent) + } + } + } +} + +class ReportedAdConsentTest { + private val banner = AdConsent(DENIED, GRANTED) + + @Test + fun `default is granted when nothing is set`() { + Given("untouched options and no banner") { + val reported = reportedAdConsent(SuperwallOptions(), null) + + Then("both are granted from the default source") { + assertEquals(ReportedAdConsent(AdConsent(), AdConsentSource.DEFAULT), reported) + assertEquals("default", reported.source.raw) + } + } + } + + @Test + fun `banner consent is used when the developer never set one`() { + Given("untouched options and a banner") { + val reported = reportedAdConsent(SuperwallOptions(), banner) + + Then("the banner's values are reported from the tcf source") { + assertEquals(ReportedAdConsent(banner, AdConsentSource.TCF), reported) + assertEquals("tcf", reported.source.raw) + } + } + } + + @Test + fun `developer consent wins over the banner`() { + Given("the developer denied ad user data and a banner granting personalization only") { + val options = SuperwallOptions { adConsent = AdConsent(adUserData = DENIED, adPersonalization = DENIED) } + val reported = reportedAdConsent(options, AdConsent(GRANTED, GRANTED)) + + Then("the developer's values are reported") { + assertEquals(ReportedAdConsent(AdConsent(DENIED, DENIED), AdConsentSource.DEVELOPER), reported) + assertEquals("developer", reported.source.raw) + } + } + } + + @Test + fun `explicitly setting the default value still wins over the banner`() { + Given("the developer assigned the default consent") { + val options = SuperwallOptions() + options.adConsent = AdConsent() + + When("a banner denies everything") { + val reported = reportedAdConsent(options, AdConsent(DENIED, DENIED)) + + Then("granted is reported from the developer source") { + assertEquals(ReportedAdConsent(AdConsent(), AdConsentSource.DEVELOPER), reported) + } + } + } + } + + @Test + fun `tracking NONE denies everything but keeps the source`() { + Given("tracking is NONE") { + fun options(set: Boolean) = + SuperwallOptions { + eventTrackingBehavior = EventTrackingBehavior.NONE + if (set) adConsent = AdConsent() + } + + Then("every source reports denied") { + val denied = AdConsent(DENIED, DENIED) + assertEquals( + ReportedAdConsent(denied, AdConsentSource.DEVELOPER), + reportedAdConsent(options(set = true), AdConsent()), + ) + assertEquals( + ReportedAdConsent(denied, AdConsentSource.TCF), + reportedAdConsent(options(set = false), AdConsent()), + ) + assertEquals( + ReportedAdConsent(denied, AdConsentSource.DEFAULT), + reportedAdConsent(options(set = false), null), + ) + } + } + } + + @Test + fun `config attributes keep the raw option and whether it was set`() { + Given("untouched options") { + val options = SuperwallOptions() + + Then("ad_consent_set is false until assigned") { + assertEquals(false, options.toMap()["ad_consent_set"]) + options.adConsent = AdConsent() + assertEquals(true, options.toMap()["ad_consent_set"]) + } + } + } +} + +@RunWith(RobolectricTestRunner::class) +class TcfConsentReaderTest { + private lateinit var preferences: SharedPreferences + private lateinit var reader: TcfConsentReader + private val configAttributes: Trackable = mockk() + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + preferences = context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE) + preferences.edit().clear().commit() + reader = TcfConsentReader(preferences) + } + + private fun writeBanner( + gdprApplies: Any?, + purposes: String?, + ) { + preferences + .edit() + .apply { + when (gdprApplies) { + is Int -> putInt(IABTCF_GDPR_APPLIES, gdprApplies) + is String -> putString(IABTCF_GDPR_APPLIES, gdprApplies) + is Boolean -> putBoolean(IABTCF_GDPR_APPLIES, gdprApplies) + null -> remove(IABTCF_GDPR_APPLIES) + } + if (purposes == null) remove(IABTCF_PURPOSE_CONSENTS) else putString(IABTCF_PURPOSE_CONSENTS, purposes) + }.commit() + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `reads the banner from preferences whatever type gdprApplies has`() { + Given("a banner granting only ad user data") { + Then("an Int, String or Boolean gdprApplies is read") { + writeBanner(1, "1000001000") + assertEquals(AdConsent(GRANTED, DENIED), reader.read()) + writeBanner("1", "1000001000") + assertEquals(AdConsent(GRANTED, DENIED), reader.read()) + writeBanner(true, "1000001000") + assertEquals(AdConsent(GRANTED, DENIED), reader.read()) + } + Then("0 or missing gdprApplies gives no consent") { + writeBanner(0, "1000001000") + assertNull(reader.read()) + writeBanner(null, "1000001000") + assertNull(reader.read()) + } + Then("empty purposes give no consent") { + writeBanner(1, "") + assertNull(reader.read()) + } + } + } + + @Test + fun `nothing stored gives no consent`() { + assertNull(reader.read()) + } + + private fun observedPublisher( + options: SuperwallOptions, + tracked: MutableList, + scope: kotlinx.coroutines.CoroutineScope, + ): AdConsentPublisher = + AdConsentPublisher( + scope = scope, + track = { tracked += it }, + makeDeviceAttributes = { + val reported = reportedAdConsent(options, reader.read()) + hashMapOf( + "adUserDataConsent" to reported.consent.adUserData.raw, + "adPersonalizationConsent" to reported.consent.adPersonalization.raw, + "adConsentSource" to reported.source.raw, + ) + }, + makeConfigAttributes = { configAttributes }, + ) + + @Test + fun `a banner change after start publishes once with the new values`() = + runTest { + Given("an observed reader with no banner yet") { + val options = SuperwallOptions() + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner stores consent for ad user data only") { + writeBanner(1, "1000001000") + testScheduler.advanceUntilIdle() + + Then("exactly one publish reports the banner values from tcf") { + val devices = tracked.filterIsInstance() + assertEquals(1, devices.size) + val attributes = devices.single().deviceAttributes + assertEquals("granted", attributes["adUserDataConsent"]) + assertEquals("denied", attributes["adPersonalizationConsent"]) + assertEquals("tcf", attributes["adConsentSource"]) + } + } + } + } + + @Test + fun `no publish when the banner write leaves consent unchanged`() = + runTest { + Given("an observed reader with a banner already stored") { + writeBanner(1, ALL_PURPOSES) + val options = SuperwallOptions() + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner rewrites the same consent and other keys change") { + writeBanner(1, ALL_PURPOSES) + writeBanner(1, "1111111111111") + preferences.edit().putString("IABTCF_TCString", "abc").commit() + shadowOf(Looper.getMainLooper()).idle() + testScheduler.advanceUntilIdle() + + Then("nothing is published") { + assertEquals(0, tracked.size) + } + } + } + } + + @Test + fun `no publish when the developer's consent takes precedence`() = + runTest { + Given("the developer set consent, even to the default") { + val options = SuperwallOptions { adConsent = AdConsent() } + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner stores denied consent") { + writeBanner(1, "0000000000") + testScheduler.advanceUntilIdle() + + Then("nothing is published and the developer's value is still reported") { + assertEquals(0, tracked.size) + assertEquals( + ReportedAdConsent(AdConsent(), AdConsentSource.DEVELOPER), + reportedAdConsent(options, reader.read()), + ) + } + } + } + } + + @Test + fun `no publish while tracking is NONE`() = + runTest { + Given("tracking is NONE") { + val options = SuperwallOptions { eventTrackingBehavior = EventTrackingBehavior.NONE } + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner stores consent") { + writeBanner(1, ALL_PURPOSES) + testScheduler.advanceUntilIdle() + + Then("nothing is published") { + assertEquals(0, tracked.size) + } + } + } + } + + @Test + fun `withdrawing the banner falls back to the default`() = + runTest { + Given("an observed reader with a denying banner") { + writeBanner(1, "0000000000") + val options = SuperwallOptions() + val tracked = mutableListOf() + val publisher = observedPublisher(options, tracked, this@runTest) + reader.observeReportedChanges({ options }) { publisher.publish() } + + When("the banner data is cleared") { + writeBanner(null, null) + testScheduler.advanceUntilIdle() + + Then("one publish reports granted from the default source") { + val devices = tracked.filterIsInstance() + assertEquals(1, devices.size) + assertEquals("granted", devices.single().deviceAttributes["adUserDataConsent"]) + assertEquals("default", devices.single().deviceAttributes["adConsentSource"]) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt index 9787be99b..c8c3c7f39 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt @@ -106,6 +106,7 @@ class IdentityActorIntegrationTest { existingAppUserId: String? = null, existingAliasId: String? = null, existingSeed: Int? = null, + installScopedAttributes: Map = emptyMap(), ): IdentityManager { existingAppUserId?.let { every { storage.read(AppUserId) } returns it } existingAliasId?.let { every { storage.read(AliasId) } returns it } @@ -124,6 +125,7 @@ class IdentityActorIntegrationTest { ioScope = IOScope(scope.coroutineContext), notifyUserChange = {}, completeReset = { resetCalled = true }, + installScopedAttributes = { installScopedAttributes }, tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = actor, @@ -230,6 +232,60 @@ class IdentityActorIntegrationTest { } } + @Test + fun `install-scoped attributes survive identifying as a different user`() = runTest { + Given("a manager identified as user-1 with a resolved MMP match") { + val manager = + createSequentialManager( + scope = backgroundScope, + installScopedAttributes = mapOf("acquisition_source" to "google_ads"), + ) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + manager.identify("user-1") + manager.awaitLatestIdentity() + + When("the app identifies as user-2") { + manager.identify("user-2") + manager.awaitLatestIdentity() + } + + Then("user-2 keeps the acquisition attributes") { + assertEquals("user-2", manager.appUserId) + assertEquals("google_ads", manager.userAttributes["acquisition_source"]) + } + } + } + + @Test + fun `install-scoped attributes survive a full reset`() = runTest { + Given("a manager identified as user-1 with a resolved MMP match") { + val manager = + createSequentialManager( + scope = backgroundScope, + installScopedAttributes = mapOf("acquisition_source" to "google_ads"), + ) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + manager.identify("user-1") + manager.awaitLatestIdentity() + + When("reset is called") { + manager.reset() + manager.awaitLatestIdentity() + } + + Then("the anonymous user keeps the acquisition attributes") { + assertNull(manager.appUserId) + assertEquals("google_ads", manager.userAttributes["acquisition_source"]) + } + } + } + // ----------------------------------------------------------------------- // Concurrency stress: rapid-fire mutations // ----------------------------------------------------------------------- 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/network/device/DeviceHelperAdConsentTest.kt b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt new file mode 100644 index 000000000..105b2d98d --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/network/device/DeviceHelperAdConsentTest.kt @@ -0,0 +1,225 @@ +package com.superwall.sdk.network.device + +import android.content.Context +import android.provider.Settings +import com.superwall.sdk.Given +import com.superwall.sdk.Superwall +import com.superwall.sdk.Then +import com.superwall.sdk.When +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.AdConsentStatus +import com.superwall.sdk.config.options.EventTrackingBehavior +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.identity.IdentityInfo +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.network.SuperwallAPI +import com.superwall.sdk.storage.LastPaywallView +import com.superwall.sdk.storage.LatestEnrichment +import com.superwall.sdk.storage.LocalStorage +import com.superwall.sdk.storage.ReviewData +import com.superwall.sdk.storage.TotalPaywallViews +import com.superwall.sdk.store.Entitlements +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DeviceHelperAdConsentTest { + private lateinit var context: Context + private lateinit var deviceHelper: DeviceHelper + + private val storage = mockk() + private val network = mockk(relaxed = true) + private val factory = mockk(relaxed = true) + private val classifier = mockk() + private val superwall = mockk() + private val entitlements = mockk() + + private var currentUserId: String? = "user-1" + private var currentEntitlements = setOf(Entitlement("basic")) + private val options = SuperwallOptions() + private var bannerConsent: AdConsent? = null + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + Settings.Secure.putString( + context.contentResolver, + Settings.Secure.ANDROID_ID, + "test-android-id", + ) + + every { storage.apiKey } returns "pk_test_key" + every { storage.didTrackFirstSession } returns true + every { storage.read(LatestEnrichment) } returns null + every { storage.read(LastPaywallView) } returns null + every { storage.read(TotalPaywallViews) } returns 2 + every { storage.read(ReviewData) } returns null + + coEvery { factory.makeIdentityInfo() } answers { + IdentityInfo(aliasId = "alias-1", appUserId = currentUserId) + } + every { factory.makeLocaleIdentifier() } returns "en_US" + coEvery { factory.activeProductIds() } returns listOf("com.test.product") + every { factory.storefrontCountryCode() } returns "US" + every { factory.makeSuperwallOptions() } returns options + every { factory.experimentalProperties() } returns emptyMap() + + every { classifier.deviceTier() } returns Tier.MID + + every { entitlements.active } answers { currentEntitlements } + every { superwall.entitlements } returns entitlements + every { superwall.subscriptionStatus } returns + MutableStateFlow(SubscriptionStatus.Inactive) + mockkObject(Superwall.Companion) + every { Superwall.instance } returns superwall + + deviceHelper = + DeviceHelper( + context = context, + storage = storage, + network = network, + factory = factory, + classifier = classifier, + tcfAdConsent = { bannerConsent }, + ) + } + + @After + fun tearDown() { + unmockkObject(Superwall.Companion) + } + + @Test + fun `template reports granted consent by default`() = + runTest { + Given("default options") { + When("getting the device template") { + val template = deviceHelper.getTemplateDevice() + + Then("both consent attributes are granted from the default source") { + assertEquals("granted", template["adUserDataConsent"]) + assertEquals("granted", template["adPersonalizationConsent"]) + assertEquals("default", template["adConsentSource"]) + } + } + } + } + + @Test + fun `runtime consent change is reflected in the memoized template`() = + runTest { + Given("a memoized device template") { + deviceHelper.getTemplateDevice() + val cachedAfterFirst = deviceHelper.cachedTemplate + + When("ad consent is changed at runtime") { + options.adConsent = + AdConsent( + adUserData = AdConsentStatus.DENIED, + adPersonalization = AdConsentStatus.GRANTED, + ) + val second = deviceHelper.getTemplateDevice() + + Then("the template is rebuilt with the new consent") { + assertEquals("denied", second["adUserDataConsent"]) + assertEquals("granted", second["adPersonalizationConsent"]) + assertNotSame(cachedAfterFirst, deviceHelper.cachedTemplate) + } + } + } + } + + @Test + fun `tracking behavior NONE reports denied regardless of consent`() = + runTest { + Given("granted ad consent and a memoized template") { + options.adConsent = AdConsent() + deviceHelper.getTemplateDevice() + + When("event tracking is set to NONE") { + options.eventTrackingBehavior = EventTrackingBehavior.NONE + val template = deviceHelper.getTemplateDevice() + + Then("both consent attributes are denied") { + assertEquals("denied", template["adUserDataConsent"]) + assertEquals("denied", template["adPersonalizationConsent"]) + } + } + } + } + + @Test + fun `banner consent is reported when the developer never set one`() = + runTest { + Given("a memoized template and no developer consent") { + deviceHelper.getTemplateDevice() + val cachedAfterFirst = deviceHelper.cachedTemplate + + When("an IAB TCF banner stores consent for ad user data only") { + bannerConsent = AdConsent(AdConsentStatus.GRANTED, AdConsentStatus.DENIED) + val template = deviceHelper.getTemplateDevice() + + Then("the template is rebuilt with the banner values and the tcf source") { + assertEquals("granted", template["adUserDataConsent"]) + assertEquals("denied", template["adPersonalizationConsent"]) + assertEquals("tcf", template["adConsentSource"]) + assertNotSame(cachedAfterFirst, deviceHelper.cachedTemplate) + } + } + } + } + + @Test + fun `developer consent wins over the banner`() = + runTest { + Given("a banner denying everything") { + bannerConsent = AdConsent(AdConsentStatus.DENIED, AdConsentStatus.DENIED) + + When("the developer sets the default consent") { + options.adConsent = AdConsent() + val template = deviceHelper.getTemplateDevice() + + Then("granted is reported from the developer source") { + assertEquals("granted", template["adUserDataConsent"]) + assertEquals("granted", template["adPersonalizationConsent"]) + assertEquals("developer", template["adConsentSource"]) + } + } + } + } + + @Test + fun `source change alone rebuilds the memoized template`() = + runTest { + Given("a memoized template with the default granted consent") { + deviceHelper.getTemplateDevice() + val cachedAfterFirst = deviceHelper.cachedTemplate + + When("a banner grants everything, so only the source changes") { + bannerConsent = AdConsent() + val template = deviceHelper.getTemplateDevice() + + Then("the template reports the tcf source") { + assertEquals("tcf", template["adConsentSource"]) + assertNotSame(cachedAfterFirst, deviceHelper.cachedTemplate) + } + } + } + } +} 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/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt index 41460a265..8377912bd 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt @@ -57,6 +57,8 @@ class WebPaywallRedeemerTest { every { read(LatestWebCustomerInfo) } returns null every { write(LatestWebCustomerInfo, any()) } just Runs every { write(LastWebEntitlementsFetchDate, any()) } just Runs + every { read(RedeemedInstallReferrerCode) } returns null + every { write(RedeemedInstallReferrerCode, any()) } just Runs } private var maxAge: () -> Long = { 1L } @@ -276,6 +278,92 @@ class WebPaywallRedeemerTest { } } + @Test + fun `an install referrer code is marked redeemed only once the server has answered`() = + runTest(testDispatcher) { + Given("a referrer code the server redeems") { + val code = "referrer_code" + val response = + WebRedemptionResponse( + codes = + listOf( + RedemptionResult.Success( + code = code, + redemptionInfo = + RedemptionInfo( + ownership = RedemptionOwnership.AppUser(appUserId = getUserId().value), + purchaserInfo = + PurchaserInfo( + getUserId().value, + "email", + StoreIdentifiers.Stripe(stripeCustomerId = "123", emptyList()), + ), + entitlements = listOf(webEntitlement), + ), + ), + ), + customerInfo = null, + ) + var storedResponse: WebRedemptionResponse? = null + every { storage.write(LatestRedemptionResponse, any()) } answers { storedResponse = secondArg() } + every { storage.read(LatestRedemptionResponse) } answers { storedResponse } + coEvery { deepLinkReferrer.checkForReferral() } returns Result.success(code) + coEvery { + network.redeemToken(any(), any(), any(), any(), any(), any(), any()) + } returns Either.Success(response) + + When("the redeemer reads it on launch") { + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + storage, + customerInfoManager = mockk(relaxed = true), + factory = TestFactory(), + ) + // Bounded: once a response is stored, entitlement polling reschedules forever. + testScheduler.advanceTimeBy(1_000) + testScheduler.runCurrent() + + Then("the code is marked as redeemed for this install") { + verify(exactly = 1) { storage.write(RedeemedInstallReferrerCode, code) } + } + } + } + } + + @Test + fun `an install referrer code already redeemed on this install is not redeemed again`() = + runTest(testDispatcher) { + Given("a referrer code that an earlier launch already redeemed") { + val code = "already_redeemed" + coEvery { deepLinkReferrer.checkForReferral() } returns Result.success(code) + every { storage.read(RedeemedInstallReferrerCode) } returns code + + When("a later launch creates the redeemer") { + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + storage, + customerInfoManager = mockk(relaxed = true), + factory = TestFactory(), + ) + testScheduler.advanceUntilIdle() + + Then("it does not redeem the code again") { + coVerify(exactly = 0) { + network.redeemToken(any(), any(), any(), any(), any(), any(), any()) + } + } + } + } + } + @Test fun `test failed referral check`() = runTest(testDispatcher) { @@ -348,6 +436,9 @@ class WebPaywallRedeemerTest { Then("it should not set entitlement status") { assert(mutableEntitlements == setOf(normalEntitlement)) + verify(exactly = 0) { + storage.write(RedeemedInstallReferrerCode, any()) + } verify(exactly = 1) { onRedemptionResult(any()) } diff --git a/version.env b/version.env index 4d3e62fb2..2ac3eac6e 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.8.5 +SUPERWALL_VERSION=2.9.0