Skip to content

feat: add android install attribution matching#389

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

feat: add android install attribution matching#389
yusuftor wants to merge 2 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 install attribution matching via a new MMP (Mobile Measurement Partner) fingerprinting flow. On first configure within a 7-day install window, the SDK collects device fingerprint fields (screen dimensions, locale, timezone, etc.) and an optional Play Store install-referrer click ID (sw_mmp_click_id), posts them to a new /api/match endpoint, and merges any returned acquisitionAttributes into user attributes. A new attribution_match Superwall event is fired on both success and failure. The feature is guarded by two new persistent flags (DidCompleteMMPInstallAttributionRequest, IsEligibleForMMPInstallAttributionMatch) so the network call is made at most once per install.\n\nKey findings:\n\n- clickId: Int? in MmpMatchResponse should be Long? — Play Store referrer click IDs are 64-bit integers; a value exceeding Int.MAX_VALUE will throw a SerializationException at deserialization time even though coerceInputValues = true does not cover numeric widths.\n- Unsafe .jsonPrimitive cast in readJsonString — The jsonPrimitive extension property throws IllegalArgumentException for non-primitive JSON values; the exception escapes the outer withErrorTracking scope because recordMMPInstallAttributionRequest launches a detached coroutine, risking an unhandled coroutine exception.\n- Double URL-decoding in getUrlParamsUri.getQueryParameters() already percent-decodes values; the subsequent URLDecoder.decode() call causes double-decoding, which can garble values containing a literal % and silently corrupts the existing code referral parameter.\n- APPLE_SEARCH_ADS provider enum is dead code on Android — the variant will never be produced by the SDK and may confuse consumers.\n- referrerClient.endConnection() may be skipped on timeout — the custom property getter returns null when the client is not yet isReady, so a timed-out getInstallReferrerParams call does not explicitly release the underlying InstallReferrerClient binding.

Confidence Score: 4/5

Safe to merge after fixing the clickId type and the unsafe jsonPrimitive cast; remaining findings are P2 clean-ups.

Two P1 issues remain: the Int? type for clickId in MmpMatchResponse that can cause a deserialization exception on real-world click IDs, and the unsafe .jsonPrimitive cast in readJsonString that can throw an unhandled exception in a detached coroutine. Both are small, targeted fixes. All other findings are P2 style/correctness suggestions that do not block the primary attribution path.

MmpService.kt (clickId type) and Network.kt (readJsonString cast) need fixes before merge.

Important Files Changed

Filename Overview
superwall/src/main/java/com/superwall/sdk/network/MmpService.kt New file introducing MMP match request/response models and service; clickId in response is typed as Int? instead of Long?, risking serialization failure for large click IDs.
superwall/src/main/java/com/superwall/sdk/network/Network.kt Adds matchMMPInstall and acquisition-attribute merging; readJsonString uses an unsafe .jsonPrimitive cast that throws IllegalArgumentException for non-primitive JSON values.
superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt Refactored into shared getInstallReferrerParams; adds checkForMmpClickId; double URL-decoding and potential endConnection leak on timeout are pre-existing and inherited issues.
superwall/src/main/java/com/superwall/sdk/Superwall.kt Integrates MMP attribution flow on first install; fetchConfiguration() deliberately moved after identity configure and MMP check; logic is sound.
superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt Adds shouldAttemptInitialMMPInstallAttributionMatch and recordMMPInstallAttributionRequest with a 7-day eligibility window; gating logic is correct.
superwall/src/main/java/com/superwall/sdk/analytics/superwall/AttributionMatchInfo.kt New data class for attribution result; APPLE_SEARCH_ADS provider enum value is dead code on Android.
superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt Adds two new storage keys with correct serializers and directory settings.
superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt Exposes timezoneOffsetSeconds, screenWidth, screenHeight, devicePixelRatio, and appInstalledAtMillis as typed properties.
superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt Wires MmpService into the Network constructor using api.subscription.host; otherwise correct.
superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt Adds matchMMPInstall to the SuperwallAPI interface with a default null parameter; straightforward addition.

Sequence Diagram

sequenceDiagram
    participant SW as Superwall.configure()
    participant LS as LocalStorage
    participant DLR as DeepLinkReferrer
    participant PS as Play Store (InstallReferrer API)
    participant NET as Network
    participant MMP as MmpService (/api/match)
    participant IM as IdentityManager

    SW->>LS: read(DidTrackAppInstall) → hadTracked
    SW->>LS: recordAppInstall()
    SW->>IM: configure() [await]
    SW->>LS: shouldAttemptInitialMMPInstallAttributionMatch(hadTracked, appInstalledAtMillis)
    LS-->>SW: true (first install, within 7-day window)
    SW->>DLR: new DeepLinkReferrer(context, ioScope)
    DLR->>PS: startConnection()
    SW->>DLR: checkForMmpClickId() [5s timeout]
    PS-->>DLR: referrer string (sw_mmp_click_id=...)
    DLR-->>SW: Result<Long> (click ID or null)
    SW->>LS: recordMMPInstallAttributionRequest { matchMMPInstall(clickId) }
    Note over LS: launches async in ioScope
    SW->>NET: fetchConfiguration() [concurrent]
    LS->>NET: matchMMPInstall(clickId) [async]
    NET->>MMP: POST /api/match (device fingerprint + clickId)
    MMP-->>NET: MmpMatchResponse { matched, acquisitionAttributes, ... }
    NET->>SW: setUserAttributes(acquisitionAttributes)
    NET->>SW: track(AttributionMatch event)
    LS->>LS: write(DidCompleteMMPInstallAttributionRequest, true)
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/network/MmpService.kt
Line: 38

Comment:
**`clickId` typed as `Int` may overflow or throw during deserialization**

The request field `installReferrerClickId` is correctly typed as `Long?`, but the corresponding response field is typed as `Int?`. Play Store install-referrer click IDs are large 64-bit integers. If the server echoes back or assigns a click ID that exceeds `Int.MAX_VALUE` (~2.1 billion), kotlinx.serialization will throw a `SerializationException` when deserializing the response JSON — even with `coerceInputValues = true`, which only coerces enum/null values, not numeric widths. This would cause the match to fail and the attribution event to be tracked with reason `request_failed`.

```suggestion
    val clickId: Long? = null,
```

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: 107-110

Comment:
**Unsafe `jsonPrimitive` cast can throw `IllegalArgumentException`**

The `jsonPrimitive` extension property on `JsonElement` is non-nullable and throws `IllegalArgumentException` if the element is a `JsonObject` or `JsonArray` rather than a `JsonPrimitive`. The null-safe `?.` operator only guards against a null `JsonElement`, not against a wrong type. If the backend ever returns a non-string value for `acquisition_source` (in `acquisitionAttributes`) or `reason` (in `breakdown`), this will throw inside the coroutine launched by `recordMMPInstallAttributionRequest`, which is outside the `withErrorTracking` scope in `Superwall.kt`, risking an unhandled coroutine exception.

Use a safe cast instead:

```suggestion
    ): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull
```

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/web/DeepLinkReferrer.kt
Line: 166-168

Comment:
**Double URL-decoding of query parameter values**

`android.net.Uri.getQueryParameters(key)` already decodes percent-encoded characters (e.g., `%20` → space). Applying `URLDecoder.decode` again on the already-decoded values causes double-decoding: a value that originally contained an encoded `%25` (literal percent sign) would first be decoded to `%`, and then `URLDecoder` would try to interpret `%` as the start of a new escape sequence, potentially throwing a `java.lang.IllegalArgumentException` or silently garbling the value.

For the numeric `sw_mmp_click_id` this is harmless today, but it is a latent bug for any future string-valued parameters, and it already affects the `code` parameter used by `checkForReferral`.

```suggestion
        return uri.queryParameterNames.associateWith { key ->
            uri.getQueryParameters(key)
        }
```

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/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.

---

This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt
Line: 144-157

Comment:
**`referrerClient.endConnection()` may not be called on timeout**

The custom property getter for `referrerClient` returns `null` when the underlying field is not yet `isReady`. If `getInstallReferrerParams` times out before the Play Store service completes its handshake, `referrerClient?.endConnection()` evaluates as a no-op (the getter returns null), and only the backing field is nulled out. The live `InstallReferrerClient` binding is abandoned without being explicitly released.

To ensure the connection is always closed, bypass the custom getter by calling `endConnection` on the backing field directly, or promote the close logic to a dedicated `closeClient()` helper that accesses `field` (as is done inside `tryConnecting`'s `catch` block).

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

Reviews (1): Last reviewed commit: "style: format android attribution change..." | Re-trigger Greptile

Greptile also left 4 inline comments on this PR.

val matched: Boolean,
val confidence: AttributionMatchInfo.Confidence? = null,
val matchScore: Double? = null,
val clickId: Int? = null,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 clickId typed as Int may overflow or throw during deserialization

The request field installReferrerClickId is correctly typed as Long?, but the corresponding response field is typed as Int?. Play Store install-referrer click IDs are large 64-bit integers. If the server echoes back or assigns a click ID that exceeds Int.MAX_VALUE (~2.1 billion), kotlinx.serialization will throw a SerializationException when deserializing the response JSON — even with coerceInputValues = true, which only coerces enum/null values, not numeric widths. This would cause the match to fail and the attribution event to be tracked with reason request_failed.

Suggested change
val clickId: Int? = null,
val clickId: Long? = null,
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/network/MmpService.kt
Line: 38

Comment:
**`clickId` typed as `Int` may overflow or throw during deserialization**

The request field `installReferrerClickId` is correctly typed as `Long?`, but the corresponding response field is typed as `Int?`. Play Store install-referrer click IDs are large 64-bit integers. If the server echoes back or assigns a click ID that exceeds `Int.MAX_VALUE` (~2.1 billion), kotlinx.serialization will throw a `SerializationException` when deserializing the response JSON — even with `coerceInputValues = true`, which only coerces enum/null values, not numeric widths. This would cause the match to fail and the attribution event to be tracked with reason `request_failed`.

```suggestion
    val clickId: Long? = null,
```

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

Comment on lines +107 to +110
}

private fun readJsonString(
value: Map<String, JsonElement>?,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unsafe jsonPrimitive cast can throw IllegalArgumentException

The jsonPrimitive extension property on JsonElement is non-nullable and throws IllegalArgumentException if the element is a JsonObject or JsonArray rather than a JsonPrimitive. The null-safe ?. operator only guards against a null JsonElement, not against a wrong type. If the backend ever returns a non-string value for acquisition_source (in acquisitionAttributes) or reason (in breakdown), this will throw inside the coroutine launched by recordMMPInstallAttributionRequest, which is outside the withErrorTracking scope in Superwall.kt, risking an unhandled coroutine exception.

Use a safe cast instead:

Suggested change
}
private fun readJsonString(
value: Map<String, JsonElement>?,
): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/network/Network.kt
Line: 107-110

Comment:
**Unsafe `jsonPrimitive` cast can throw `IllegalArgumentException`**

The `jsonPrimitive` extension property on `JsonElement` is non-nullable and throws `IllegalArgumentException` if the element is a `JsonObject` or `JsonArray` rather than a `JsonPrimitive`. The null-safe `?.` operator only guards against a null `JsonElement`, not against a wrong type. If the backend ever returns a non-string value for `acquisition_source` (in `acquisitionAttributes`) or `reason` (in `breakdown`), this will throw inside the coroutine launched by `recordMMPInstallAttributionRequest`, which is outside the `withErrorTracking` scope in `Superwall.kt`, risking an unhandled coroutine exception.

Use a safe cast instead:

```suggestion
    ): String? = (value?.get(key) as? JsonPrimitive)?.contentOrNull
```

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

Comment on lines +166 to 168
return uri.queryParameterNames.associateWith { key ->
uri.getQueryParameters(key).map { URLDecoder.decode(it, "UTF-8") }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double URL-decoding of query parameter values

android.net.Uri.getQueryParameters(key) already decodes percent-encoded characters (e.g., %20 → space). Applying URLDecoder.decode again on the already-decoded values causes double-decoding: a value that originally contained an encoded %25 (literal percent sign) would first be decoded to %, and then URLDecoder would try to interpret % as the start of a new escape sequence, potentially throwing a java.lang.IllegalArgumentException or silently garbling the value.

For the numeric sw_mmp_click_id this is harmless today, but it is a latent bug for any future string-valued parameters, and it already affects the code parameter used by checkForReferral.

Suggested change
return uri.queryParameterNames.associateWith { key ->
uri.getQueryParameters(key).map { URLDecoder.decode(it, "UTF-8") }
}
return uri.queryParameterNames.associateWith { key ->
uri.getQueryParameters(key)
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt
Line: 166-168

Comment:
**Double URL-decoding of query parameter values**

`android.net.Uri.getQueryParameters(key)` already decodes percent-encoded characters (e.g., `%20` → space). Applying `URLDecoder.decode` again on the already-decoded values causes double-decoding: a value that originally contained an encoded `%25` (literal percent sign) would first be decoded to `%`, and then `URLDecoder` would try to interpret `%` as the start of a new escape sequence, potentially throwing a `java.lang.IllegalArgumentException` or silently garbling the value.

For the numeric `sw_mmp_click_id` this is harmless today, but it is a latent bug for any future string-valued parameters, and it already affects the `code` parameter used by `checkForReferral`.

```suggestion
        return uri.queryParameterNames.associateWith { key ->
            uri.getQueryParameters(key)
        }
```

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

Comment on lines +144 to +157
private suspend fun getInstallReferrerParams(timeout: kotlin.time.Duration): Map<String, List<String>> {
val rawReferrer =
withTimeoutOrNull(timeout) {
while (referrerClient?.isReady != true) {
// no-op
}
referrerClient?.installReferrer?.installReferrer?.toString()
}

referrerClient?.endConnection()
referrerClient = null

return rawReferrer?.getUrlParams() ?: emptyMap()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 referrerClient.endConnection() may not be called on timeout

The custom property getter for referrerClient returns null when the underlying field is not yet isReady. If getInstallReferrerParams times out before the Play Store service completes its handshake, referrerClient?.endConnection() evaluates as a no-op (the getter returns null), and only the backing field is nulled out. The live InstallReferrerClient binding is abandoned without being explicitly released.

To ensure the connection is always closed, bypass the custom getter by calling endConnection on the backing field directly, or promote the close logic to a dedicated closeClient() helper that accesses field (as is done inside tryConnecting's catch block).

Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt
Line: 144-157

Comment:
**`referrerClient.endConnection()` may not be called on timeout**

The custom property getter for `referrerClient` returns `null` when the underlying field is not yet `isReady`. If `getInstallReferrerParams` times out before the Play Store service completes its handshake, `referrerClient?.endConnection()` evaluates as a no-op (the getter returns null), and only the backing field is nulled out. The live `InstallReferrerClient` binding is abandoned without being explicitly released.

To ensure the connection is always closed, bypass the custom getter by calling `endConnection` on the backing field directly, or promote the close logic to a dedicated `closeClient()` helper that accesses `field` (as is done inside `tryConnecting`'s `catch` block).

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

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