Repository navigation
Conversation
100d7e4 to
7668d57
Compare
…, keep acquisition attributes across identify - Only fire the install match once config has attribution_options.mmp.enabled, like iOS. Eligibility is still recorded at launch, and a match skipped while tracking is off starts when the app opts back in. - Redeem the install-referrer code once per install. Play keeps the referrer for the life of the install, so it was redeemed on every launch. - Merge the cached acquisition_* attributes inside the identity reset itself, so identifying as a different user no longer drops them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7668d57 to
fb2d95b
Compare
Adds `SuperwallOptions.adConsent` and `Superwall.instance.adConsent`
(`AdConsent(adUserData, adPersonalization)` of `ConsentStatus`), reported
as the `adUserDataConsent` / `adPersonalizationConsent` device attributes
("granted" / "denied") and as `ad_consent` in config attributes. Defaults
to granted; both report denied when eventTrackingBehavior is NONE.
Changing it at runtime re-sends device and config attributes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…curately Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
📚 Docs requiredWhat changed for customers: Android apps now get install attribution matching (an Why this needs docs: adds a new Coverage today: absent for Without a docs page this change also gets no changelog entry: Prompt for the docs agent — run in
|
There was a problem hiding this comment.
Important
Two paths can permanently lose data the PR is meant to deliver: a web-checkout code from the install referrer is marked redeemed even when the redemption fails, and a fresh install killed before config resolves on first launch is never marked eligible for an attribution match.
Reviewed changes
Reviewed the full PR: Android MMP install-attribution matching, the AdConsent option, and the install-referrer fixes.
- Install attribution matching — new
MMPAttributionManager,MmpService(/api/matchon a newmmpHost), a config gate viaattribution_options.mmp.enabled, and anattribution_matchevent. - Install-scoped
acquisition_*attributes — the match payload is cached inMMPAcquisitionDataand merged back into user attributes onReset/FullReset, so it survivesidentifyandreset. - Eligibility gating —
IsEligibleForMMPInstallAttributionMatch/DidCompleteMMPInstallAttributionRequestlimit matching to fresh installs within 7 days. Upgraders are excluded. - Install referrer —
DeepLinkReferrernow parses the real query string (the old parser only ever produced anitemkey, socodewas never found), memoizes the params behind a mutex, and exposescheckForMmpClickId(). The redeemer now redeems a referrer code once per install. - Ad consent —
SuperwallOptions.adConsent/Superwall.instance.adConsentare reported asadUserDataConsent/adPersonalizationConsentdevice attributes and forced to denied underEventTrackingBehavior.NONE. - Network environment —
CustomgainssubscriptionHost,enrichmentHostandmmpHostparameters.
claude-opus-5.5 | 𝕏
…sent re-sends ConsentStatus clashes with FirebaseAnalytics.ConsentStatus in apps that use both. Each adConsent assignment launched its own coroutine, so an older snapshot could be tracked after a newer one; re-sends now run one at a time and drop superseded assignments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues in this delta, just one CHANGELOG nit inline. The five threads from the previous review are still open because none of the files they point at changed.
Reviewed changes
Reviewed the one commit added since the last Pullfrog review (08901e0 → c665202).
- Renamed
ConsentStatustoAdConsentStatusacrossAdConsent.kt, the tests and the CHANGELOG. Nothing in the repo still uses the old name. - Serialized
adConsentre-sends: theSuperwall.adConsentsetter now bumps anAtomicLonggeneration and runs the re-send under aMutex. Any run whose generation is no longer current is dropped, and the snapshot is taken inside the lock, so an older consent can't be tracked after a newer one.
claude-opus-5.5 | 𝕏
A consent change made while eventTrackingBehavior was NONE was never sent, and turning tracking back on only re-sent config attributes, so Superwall kept the old value until the next session. The re-send now lives in AdConsentPublisher, used by both setters, with tests for the events it sends and for dropping superseded sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues in this delta. The six threads from earlier Pullfrog reviews are still open, including the CHANGELOG "a
AdConsentStatus" nit. None of the files they point at changed.
Reviewed changes
Reviewed the commit added since the last Pullfrog review (c665202 → 688a8af).
- Extracted
AdConsentPublisher: the generation counter and mutex that serialize ad consent re-sends moved out ofSuperwallinto an internal class. It keeps the same guarantee: a send that was superseded while waiting for the lock is dropped, and the snapshot is taken inside the lock. This also gets rid of the out-of-order imports at the top ofSuperwall.kt. - Re-sent ad consent when tracking comes back on: the
eventTrackingBehaviorsetter now remembers whether the previous value wasNONE. If it was, it callsadConsentPublisher.publish(), which sendsDeviceAttributesplus config attributes, so a consent change made while tracking was off still reaches Superwall. Every other transition sends only config attributes, as before. - Added
AdConsentPublisherTest: covers the send order and dropping a superseded publish.
claude-opus-5.5 | 𝕏
- Record install-match eligibility before config is fetched, so a first launch killed while offline is still matched on the next launch. - Mark an install-referrer code redeemed only once the server answered, so a failed request is retried on the next launch. - Stop waiting for the install referrer once the Play connection failed for good, instead of holding the lock for the full timeout. - Keep Custom environments on their previous *.superwall.dev hosts and restore the 4-argument constructor with @jvmoverloads. - Fix the MMPAcquisitionData KDoc placement and a CHANGELOG article. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. All six earlier Pullfrog threads are fixed in
9eda6ad, and the author has already resolved them.
Reviewed changes
Reviewed the one commit added since the last Pullfrog review (688a8af → 9eda6ad). The 5d8047d coverage badge commit between them changes no code.
- Moved the MMP eligibility check to the start of startup:
shouldAttemptInitialMMPInstallAttributionMatchnow runs right afterDidTrackAppInstallis read and beforerecordAppInstall. There is no suspension point between the two writes anymore, so if the first launch is killed before config arrives, the install still counts as eligible. The request is still gated on the storedshouldMatchInstallresult and still waits for config and identity. - Stopped marking failed referrer redemptions as done:
RedeemedInstallReferrerCodeis written only when the code is inLatestRedemptionResponse.allCodes. That list is@Transientand built from the server'scodes, and it is only stored on a successful/redeem. A transport failure is therefore retried on the next launch. Added a test for the success path and a check on the failure path that nothing is written. - Stopped the referrer wait early when the connection fails: the wait in
getInstallReferrerParamsreturns as soon asreferrerClientis null. A failed Play connection no longer keepsreferrerMutexlocked for the full 30s or 5s timeout. - Restored the old
Customhosts and added@JvmOverloads:subscriptionHost,enrichmentHostandmmpHostnow default to the*.superwall.devhosts thatCustomused before. The 4-argument constructor still works from Java. - Smaller fixes: moved the
MMPAcquisitionDataKDoc back onto its own object, and changed "aAdConsentStatus" to "an" in the CHANGELOG.
claude-opus-5.5 | 𝕏
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… queue Any behavior other than ALL clears the events queue, which can hold a just-queued consent update. Re-send device and config attributes after such a change, once the queue has applied it, as iOS does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. There are no open Pullfrog threads.
Reviewed changes
Reviewed the one code commit added since the last Pullfrog review (9eda6ad → c313eaf). c4773b9 only changed docs and 828b3ba only updated the coverage badge.
- Returned a
JobfromEventsQueue.setTrackingBehavior: the method now returns the job it launches on the queue's single-threaded dispatcher. That job updatestrackingBehaviorand, for anything other thanALL, clears the queued events. Nothing else calls the method or mocks it, and the repo has no.apidump that would need updating. - Re-sent ad consent whenever a tracking change can drop it: the
eventTrackingBehaviorsetter now callsadConsentPublisher.publish(after = queueUpdated)when the value changes or staysSUPERWALL_ONLY. Turning tracking off (NONE) still sends nothing. A setter call that leaves the value atALLstill sends only config attributes. So anALL→SUPERWALL_ONLYchange that clears a queued consent update now sends that consent again. - Made
AdConsentPublisher.publishwait onafter: the send joinsafterbefore it takes the mutex, so the re-sent consent can't be enqueued before the queue is cleared. The join also means it reads the updatedtrackingBehavior. If a lateradConsentpublish supersedes this one, it is still enqueued after the clear, because the queue's dispatcher runs jobs in the order they were submitted. - Added a test for
after: checks that nothing is tracked until the job passed asaftercompletes, and that both attribute events are sent afterwards.
Tests were not run here because a Gradle run doesn't finish in this sandbox, so these findings come from reading the code.
claude-opus-5.5 | 𝕏
When the developer never sets adConsent, report the purpose consents an IAB TCF banner stored in the app's default SharedPreferences when GDPR applies (ad user data = purposes 1 and 7, ad personalization = 3 and 4). The developer's value still wins once assigned, even to the default, and NONE still denies everything. A new adConsentSource device attribute says which source supplied the values, and a banner change that alters the reported consent re-sends it through AdConsentPublisher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ranted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. There are no open Pullfrog threads.
Reviewed changes
Reviewed the two commits added since the last Pullfrog review (c313eaf → 478a3a9). bae64bc and 2d0e1b8 only updated the coverage badge.
- Read ad consent from an IAB TCF banner: the new
TcfConsentReaderreadsIABTCF_gdprAppliesandIABTCF_PurposeConsentsfrom the app's default SharedPreferences. Ad user data needs purposes 1 and 7, and ad personalization needs purposes 3 and 4. That matches Google's TCF to consent mode mapping. It returns nothing when GDPR doesn't apply or no purposes are stored. - Picked a consent source:
reportedAdConsentuses the developer's value ifadConsentwas ever assigned (tracked by the newisAdConsentSet, which the custom setter flips and which is also sent asad_consent_set), then the banner, then granted.NONEstill denies everything. The source goes out as the newadConsentSourcedevice attribute and is part of theDeviceHelpertemplate fingerprint, so a change in the source alone rebuilds the cached template. - Re-sent consent when the banner changes:
configurestarts watching the banner onioScope. The listener is held strongly and registered only once. It publishes only if the reported consent actually changes, so nothing is sent while the developer's value takes precedence or while tracking isNONE. The publish then reads the banner again when it builds the device attributes. - Updated docs and tests: the CHANGELOG and KDoc now describe the TCF fallback. Added
TcfConsentReaderTest(the mapping, source precedence, and Robolectric checks of the reader and listener) and banner cases inIdentityActorIntegrationTest.
I didn't run the tests because Gradle doesn't finish in this sandbox. These findings come from reading the code.
claude-opus-5.5 | 𝕏
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ange A device-attributes event reads consent when it is built and is tracked later; a banner or setter change in between was published first and then overtaken by the stale send. After tracking device attributes, compare the consent they carried with the current one and re-send when it differs, as iOS does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The new reconcile step can loop forever. If building the device template fails, every re-send reports no consent, so it immediately triggers another re-send.
Reviewed changes
Reviewed the one code commit added since the last Pullfrog review (478a3a9 → 48915ef). 300d6c3 only changed docs and 942b157 only updated the coverage badge.
- Re-checked consent after each
DeviceAttributestrack:Superwall.tracknow callsreconcileAdConsentAfterPublishafter enqueueing anyInternalSuperwallEvent.DeviceAttributes. Unless tracking isNONE, it compares the sent map withdeviceHelper.currentAdConsentand callsadConsentPublisher.publish()if they differ. This catches a send that read consent before a banner or setter change and finished after it. It also covers sends that don't go through the publisher:ConfigState,AppSessionManager,setPlatformWrapperandsetInterfaceStyle. - Added
ReportedAdConsent.toAttributes()/differsFrom(sent)inAdConsent.kt, and madeDeviceHelper.currentAdConsentinternal. Added anAdConsentTestcase for the comparison.
The re-entry from inside AdConsentPublisher doesn't deadlock. The nested publish() launches a new coroutine, and that coroutine waits for the mutex. It also doesn't break the ordering against the events-queue clear, because the queue's serial dispatcher still runs the clear job first.
claude-opus-5.5 | 𝕏
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hout it An empty device template carries no consent keys, so reconciling against it re-sent on every send for as long as the template failed to build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. The empty-template reconcile loop from the last Pullfrog review is fixed, and that thread is already resolved.
Reviewed changes
Reviewed the one code commit added since the last Pullfrog review (48915ef → 348bf68). d76a49b only changed docs.
- Stopped re-sending consent after a send with no consent keys:
ReportedAdConsent.differsFromnow returnsfalsewhensenthas none ofadUserDataConsent,adPersonalizationConsentoradConsentSource. A failedgetTemplateDevice()returnsemptyMap(), so it no longer triggers anotherpublish(), and the loop is gone. A template that does carry the keys is still compared on all three, so a stale send is still caught. - Updated
AdConsentTest: a map with onlydeviceModeland an empty map now both count as not differing. The stale-value and different-source cases still count as differing.
claude-opus-5.5 | 𝕏
2.8.5 shipped from develop without them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Summary
Notes
Verification
Greptile Summary
This PR adds Android MMP install attribution matching by wiring a new
MmpServiceinto the SDK startup flow: on first install (within a 7-day window) the Play Store Install Referrer is queried for asw_mmp_click_id, a fingerprint payload is posted to/api/match, and the returnedacquisitionAttributesare merged into user attributes before anattribution_matchevent is tracked.Previous review concerns are well-addressed —
clickIdis nowLong?, JSON parsing uses the safeas? JsonPrimitivecast, thewithTimeoutOrNullspin-loop has adelay(50)yield point, double URL-decoding is removed, andendConnection()is called on the backing field rather than the null-guarded computed property.Key remaining observations:
checkForMmpClickId()(up to 5 s) is awaited synchronously beforefetchConfiguration(). BecauseDidCompleteMMPInstallAttributionRequestis only written on a successful match, any launch within the 7-day window where the previous request failed will incur the full Play Store referrer wait again, delaying theconfigure()completion callback and paywall availability on every such launch.attribution_matchwithreason: "request_failed"on every retry launch, which could generate noisy analytics data for the full attribution window.mergeMMPAcquisitionAttributesIfNeededcallsSuperwall.instance.setUserAttributes()from inside theNetworkclass, bypassing theApiFactoryinterface already available in the constructor and making the method untestable viaNetworkMock.Confidence Score: 4/5
Safe to merge with awareness of the startup latency regression on retry launches within the attribution window.
All previously flagged P1 issues are resolved. The three remaining findings are P2 design/quality concerns: startup latency on repeated retries, repeated failure-event noise, and a layer-coupling smell. None block correctness on first install or break existing flows, but the latency issue could noticeably degrade UX on retry launches for up to 7 days post-install.
Superwall.kt (startup critical path), LocalStorage.kt (retry/completion logic), Network.kt (Superwall.instance coupling)
Important Files Changed
checkForMmpClickId()is awaited synchronously beforefetchConfiguration(), adding up to 5 s of startup latency on every retry launch within the 7-day window.matchMMPInstallwith safe JSON helpers and acquisition-attribute merging; reaches up toSuperwall.instancefrom the network layer, breaking layer boundaries and testability.NetworkServicesubclass for/api/match;clickIdis correctly typed asLong?, serialization config is sensible, and the two-retry policy is appropriate.delay(50)added to spin-loop, double URL-decode removed,endConnection()now called on the backing field,getInstallReferrerParamsshared helper extracts common logic cleanly.DidCompleteMMPInstallAttributionRequestis only written on success, causing repeated retries (and repeated failure events) across launches within the 7-day window.Storable<Boolean>cache keys added for MMP attribution state; straightforward and consistent with existing key definitions.ProviderandConfidenceenums; clean, serializable, and well-documented.MmpServiceintoNetwork; usesapi.subscription.hostwithversion = "/"which correctly resolves to/api/matchat the subscription host.timezoneOffsetSeconds,screenWidth,screenHeight,devicePixelRatio, andappInstalledAtMillishelpers;timezoneOffsetSecondsreuses the existingrawOffsetcomputation already used bysecondsFromGMT.subscriptionHostandenrichmentHostto theCustomenvironment withbaseHostdefaults; also surfaces both intoMap()for debugging.matchMMPInstallto theSuperwallAPIinterface with a nullable default parameter;NetworkMockstub correctly returnsfalse.matchMMPInstall, returningfalse— consistent with existing mock behaviour.Sequence Diagram
sequenceDiagram participant App participant Superwall participant LocalStorage participant DeepLinkReferrer participant PlayStore as Play Store Referrer participant Network participant MmpService as MmpService (/api/match) App->>Superwall: configure() Superwall->>LocalStorage: recordAppInstall() Superwall->>Superwall: identityManager.configure() [await] Superwall->>LocalStorage: shouldAttemptInitialMMPInstallAttributionMatch() LocalStorage-->>Superwall: true (first install, within 7-day window) Superwall->>DeepLinkReferrer: checkForMmpClickId() [await, ≤5 s] DeepLinkReferrer->>PlayStore: startConnection() PlayStore-->>DeepLinkReferrer: OK / timeout DeepLinkReferrer-->>Superwall: Result<Long> (clickId or null) Superwall->>LocalStorage: recordMMPInstallAttributionRequest { ... } [fire-and-forget] Superwall->>Superwall: configManager.fetchConfiguration() [await] Superwall-->>App: completion(.success) Note over LocalStorage,MmpService: Fire-and-forget coroutine LocalStorage->>Network: matchMMPInstall(clickId) Network->>MmpService: POST /api/match MmpService-->>Network: MmpMatchResponse alt matched == true Network->>Superwall: setUserAttributes(acquisitionAttributes) Network->>Superwall: track(AttributionMatch(matched=true)) Network-->>LocalStorage: true → write DidCompleteMMPInstallAttributionRequest else network error Network->>Superwall: track(AttributionMatch(reason=request_failed)) Network-->>LocalStorage: false → flag NOT written (retry next launch) endComments Outside Diff (1)
superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt, line 121-123 (link)APPLE_SEARCH_ADSis dead code in the Android SDKApple Search Ads is an iOS-only advertising platform and has no equivalent on Android. Shipping this variant in the Android SDK is misleading — consumer code could construct an
AttributionMatchInfowithProvider.APPLE_SEARCH_ADS, but it will never be produced by the SDK itself. Consider removing the variant or annotating it as reserved for future cross-platform use to avoid confusion.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (5): Last reviewed commit: "fix android referrer and test network mo..." | Re-trigger Greptile