Skip to content

feat: add android install attribution matching#389

Open
yusuftor wants to merge 7 commits intodevelopfrom
feature/mmp-android
Open

feat: add android install attribution matching#389
yusuftor wants to merge 7 commits intodevelopfrom
feature/mmp-android

Conversation

@yusuftor
Copy link
Copy Markdown
Contributor

@yusuftor yusuftor commented Mar 27, 2026

Summary

  • add Android MMP install matching and attribution_match event support
  • send Android fingerprint fields on /api/match and merge acquisition attributes back into user attributes
  • add Play Install Referrer click-id support for deterministic Android matching

Notes

  • depends on the backend changes in superwall/paywall-next on branch
  • keeps the dashboard setup flow unchanged; Android uses the same Meta integration page per dashboard application

Verification

  • ./gradlew :superwall:compileDebugKotlin

Greptile Summary

This PR adds Android MMP install attribution matching by wiring a new MmpService into the SDK startup flow: on first install (within a 7-day window) the Play Store Install Referrer is queried for a sw_mmp_click_id, a fingerprint payload is posted to /api/match, and the returned acquisitionAttributes are merged into user attributes before an attribution_match event is tracked.

Previous review concerns are well-addressed — clickId is now Long?, JSON parsing uses the safe as? JsonPrimitive cast, the withTimeoutOrNull spin-loop has a delay(50) yield point, double URL-decoding is removed, and endConnection() is called on the backing field rather than the null-guarded computed property.

Key remaining observations:

  • Startup latency on retriescheckForMmpClickId() (up to 5 s) is awaited synchronously before fetchConfiguration(). Because DidCompleteMMPInstallAttributionRequest is 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 the configure() completion callback and paywall availability on every such launch.
  • Repeated failure events — when the backend is unreachable the SDK emits attribution_match with reason: "request_failed" on every retry launch, which could generate noisy analytics data for the full attribution window.
  • Layer couplingmergeMMPAcquisitionAttributesIfNeeded calls Superwall.instance.setUserAttributes() from inside the Network class, bypassing the ApiFactory interface already available in the constructor and making the method untestable via NetworkMock.

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

Filename Overview
superwall/src/main/java/com/superwall/sdk/Superwall.kt Adds MMP attribution matching to the configure flow; checkForMmpClickId() is awaited synchronously before fetchConfiguration(), adding up to 5 s of startup latency on every retry launch within the 7-day window.
superwall/src/main/java/com/superwall/sdk/network/Network.kt Implements matchMMPInstall with safe JSON helpers and acquisition-attribute merging; reaches up to Superwall.instance from the network layer, breaking layer boundaries and testability.
superwall/src/main/java/com/superwall/sdk/network/MmpService.kt New NetworkService subclass for /api/match; clickId is correctly typed as Long?, serialization config is sensible, and the two-retry policy is appropriate.
superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt Previous issues resolved: delay(50) added to spin-loop, double URL-decode removed, endConnection() now called on the backing field, getInstallReferrerParams shared helper extracts common logic cleanly.
superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt Adds eligibility/completion gates for MMP attribution; DidCompleteMMPInstallAttributionRequest is only written on success, causing repeated retries (and repeated failure events) across launches within the 7-day window.
superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt Two new Storable<Boolean> cache keys added for MMP attribution state; straightforward and consistent with existing key definitions.
superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt New public data class with Provider and Confidence enums; clean, serializable, and well-documented.
superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt Wires MmpService into Network; uses api.subscription.host with version = "/" which correctly resolves to /api/match at the subscription host.
superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt Adds timezoneOffsetSeconds, screenWidth, screenHeight, devicePixelRatio, and appInstalledAtMillis helpers; timezoneOffsetSeconds reuses the existing rawOffset computation already used by secondsFromGMT.
superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt Adds subscriptionHost and enrichmentHost to the Custom environment with baseHost defaults; also surfaces both in toMap() for debugging.
superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt Adds matchMMPInstall to the SuperwallAPI interface with a nullable default parameter; NetworkMock stub correctly returns false.
superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt Stub implementation added for matchMMPInstall, returning false — 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)
    end
Loading

Comments Outside Diff (1)

  1. superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt, line 121-123 (link)

    P2 APPLE_SEARCH_ADS is dead code in the Android SDK

    Apple 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 AttributionMatchInfo with Provider.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
    This is a comment left during a code review.
    Path: superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt
    Line: 121-123
    
    Comment:
    **`APPLE_SEARCH_ADS` is dead code in the Android SDK**
    
    Apple 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 `AttributionMatchInfo` with `Provider.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.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/Superwall.kt
Line: 698-705

Comment:
**`checkForMmpClickId()` blocks `fetchConfiguration()` on every retry launch**

`checkForMmpClickId()` is `await`ed in-line (up to 5 seconds) before `fetchConfiguration()` is called. Because `DidCompleteMMPInstallAttributionRequest` is only written on a successful match, a device that encounters a transient network failure on first install will retry the Play Store referrer lookup on _every_ subsequent launch within the 7-day window — each time adding up to 5 seconds of delay before `fetchConfiguration()` completes and the `configure()` completion callback fires.

On devices where the Play Store referrer service is unavailable the full 5-second timeout will always elapse, making this a recurring startup regression for the entire attribution window.

Consider launching the referrer lookup concurrently with `identityManager.configure()` and passing the result to the deferred attribution request:

```kotlin
// Option: run referrer lookup in parallel with identityManager.configure()
val clickIdDeferred = async {
    DeepLinkReferrer({ context }, ioScope).checkForMmpClickId().getOrNull()
}
dependencyContainer.identityManager.configure()

if (dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch(...)) {
    dependencyContainer.storage.recordMMPInstallAttributionRequest {
        dependencyContainer.network.matchMMPInstall(clickIdDeferred.await())
    }
}
dependencyContainer.configManager.fetchConfiguration()
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/network/Network.kt
Line: 396-423

Comment:
**`Network` layer reaches up to the `Superwall` singleton**

`mergeMMPAcquisitionAttributesIfNeeded` calls `Superwall.instance.setUserAttributes(attributes)` directly from inside the `Network` class. This:

1. Creates a circular dependency: `Network``Superwall``DependencyContainer``Network`.
2. Breaks testability — the `NetworkMock` stub in `NetworkMock.kt` can't exercise this side-effect in isolation.
3. Could theoretically cause issues if `Superwall.instance` is accessed before `Superwall.configure()` completes (e.g., in tests or unusual host app setups).

The `Network` constructor already receives `factory: ApiFactory` which exposes `factory.identityManager`. Merging attributes through that interface (or surfacing the merged map as a return value from `matchMMPInstall` so the caller in `Superwall.kt` can apply them) would stay within the established layer boundaries.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt
Line: 213-226

Comment:
**Repeated `attribution_match` failures within the attribution window**

`DidCompleteMMPInstallAttributionRequest` is only written when `matchRequest()` returns `true` (i.e., a successful server match). When the backend is unreachable or returns an error, the flag stays `false` and `shouldAttemptInitialMMPInstallAttributionMatch` will return `true` again on the next launch. Combined with the startup delay noted above, this means:

- An `attribution_match` event with `reason: "request_failed"` can be emitted on every app launch for up to 7 days.
- Analysts looking at attribution funnels will see multiple events per install on unstable connections.

If retry-on-failure is intentional, consider capping the total retry count (persisted in storage) to reduce noise, or writing the `DidCompleteMMPInstallAttributionRequest` flag after a configurable number of failures.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (5): Last reviewed commit: "fix android referrer and test network mo..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant