Skip to content

Add install attribution matching support#456

Open
yusuftor wants to merge 12 commits intodevelopfrom
feature/mmp
Open

Add install attribution matching support#456
yusuftor wants to merge 12 commits intodevelopfrom
feature/mmp

Conversation

@yusuftor
Copy link
Copy Markdown
Collaborator

@yusuftor yusuftor commented Mar 25, 2026

Summary

  • add install attribution matching support to the iOS SDK
  • emit a typed attribution_match event and write shared acquisition_* user attributes
  • retry install attribution once after ATT is granted and filter out the all-zero IDFA

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Greptile Summary

This PR adds full install attribution matching support to the iOS SDK. It introduces two parallel attribution paths — Apple Search Ads (via the existing AttributionPoster) and a new MMP fingerprinting flow — and emits a typed attribution_match event for both. User attributes with acquisition_* keys are written when a match is found.\n\nKey implementation highlights:\n- A TrackingPermissionMMPRetryGate actor serializes the post-ATT retry, preventing duplicate in-flight requests when both the superwallTrackingPermissionGranted notification and applicationDidBecomeActive fire in quick succession.\n- Three new persistent cache flags drive eligibility, completion, and ATT-retry state, with a 7-day attribution window check.\n- Prior review concerns (stray compile error in Endpoint.swift, debugPrint lint violations, rawString comparison fragility, attributionMatch ObjC enum ordering) are all resolved in this revision.\n\nRemaining items to consider:\n- shouldAttemptTrackingPermissionMMPInstallAttributionMatch does not check DidCompleteMMPInstallAttributionRequest, so the ATT-retry fires even when the initial match already succeeded. This may be intentional (to upgrade a confidence-less match with the now-available IDFA), but it is undocumented.\n- The new screenWidth, screenHeight, and devicePixelRatio properties in DeviceHelper access UIScreen.main without the #if os(visionOS) guard that the existing interfaceStyle property uses.

Confidence Score: 4/5

Safe to merge after clarifying the intentionality of the post-ATT retry firing even when the initial match succeeded.

All prior P0/P1 issues from earlier review rounds are resolved. The one remaining P1 flag (shouldAttemptTrackingPermissionMMPInstallAttributionMatch not gating on DidCompleteMMPInstallAttributionRequest) either reflects intentional product design or is a logic gap — a comment or small guard would close it. The UIScreen.main visionOS concern is P2. No data-loss, auth, or crash risks remain.

Sources/SuperwallKit/Storage/Storage.swift (shouldAttemptTrackingPermissionMMPInstallAttributionMatch eligibility logic), Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift (UIScreen.main visionOS guards)

Important Files Changed

Filename Overview
Sources/SuperwallKit/Superwall.swift Adds TrackingPermissionMMPRetryGate actor and two notification listeners (ATT grant + app foreground) to trigger post-ATT attribution retry. The retry gate correctly prevents duplicate in-flight requests; minor concern about whether a successful initial match should block the retry (by design or not).
Sources/SuperwallKit/Storage/Storage.swift Adds three new persistent flags and a 7-day attribution window check. Retry logic for failed initial matches is correct; ambiguity around whether the tracking-permission retry should fire after a successful initial match needs clarification.
Sources/SuperwallKit/Network/Network.swift Adds matchMMPInstall() and mergeMMPAcquisitionAttributesIfNeeded(); prior debugPrint and rawString comparison issues are resolved. Uses Logger.debug and String(describing:) comparisons consistently.
Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift attributionMatch case correctly appended after the last existing case (paywallPageView), preserving all prior raw Int values.
Sources/SuperwallKit/Network/Endpoint.swift MMP endpoint added using SubscriptionsAPI kind (confirmed by team); prior stray-text compile error resolved. JSONEncoder() default aligns with other SubscriptionsAPI endpoints.
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift New screenWidth, screenHeight, devicePixelRatio properties use UIScreen.main without the #if os(visionOS) guards that the existing interfaceStyle property uses.
Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift Emits AttributionMatch event after Apple Search Ads token send; correctly uses nil-vs-empty distinction on sendToken return to differentiate request_failed from no_attribution.
Sources/SuperwallKit/Models/AdServicesResponse.swift Adds MMPMatchRequest and MMPMatchResponse structs; camelCase encoding aligns with other SubscriptionsAPI requests.
Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift Adds AttributionMatchInfo public struct with Provider and Confidence enums, plus .attributionMatch case to SuperwallEvent.
Sources/SuperwallKit/Storage/Cache/CacheKeys.swift Three new Storable keys added with explicit string keys preserving upgrade compatibility.

Sequence Diagram

sequenceDiagram
    participant App
    participant Superwall
    participant Storage
    participant Network
    participant ATTHandler

    App->>Superwall: configure()
    Superwall->>Storage: hasTrackedAppInstall()
    Superwall->>Storage: recordAppInstall()
    Superwall->>Superwall: await configureIdentity
    Superwall->>Storage: shouldAttemptInitialMMPInstallAttributionMatch()
    Storage-->>Superwall: true (new install or prior failure)
    Superwall->>Storage: recordMMPInstallAttributionMatch { matchMMPInstall }
    Note over Storage: starts background Task
    Storage->>Network: matchMMPInstall(idfa, advertiserTracking)
    Network-->>Storage: true/false
    Storage->>Storage: save DidCompleteMMPInstallAttributionRequest (if true)
    Superwall->>Superwall: await fetchConfig

    ATTHandler->>Superwall: superwallTrackingPermissionGranted / appDidBecomeActive
    Superwall->>Superwall: retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded
    Superwall->>Superwall: await trackingPermissionMMPRetryGate.tryBegin()
    Superwall->>Storage: shouldAttemptTrackingPermissionMMPInstallAttributionMatch()
    Storage-->>Superwall: true (eligible, window open, not yet retried)
    Superwall->>Network: matchMMPInstall(idfa with ATT, ...)
    Network-->>Superwall: true/false
    Superwall->>Storage: save DidCompleteMMPInstallAttributionRequestAfterTrackingPermission (if true)
    Superwall->>Superwall: trackingPermissionMMPRetryGate.finish()
Loading

Comments Outside Diff (3)

  1. Sources/SuperwallKit/Network/Network.swift, line 592-596 (link)

    P1 IDFA logged in error payload

    info: ["payload": request] passes the full MMPMatchRequest struct, which includes idfa, idfv, and deviceId, into the structured log on every failed /api/match call. Once the user grants ATT — exactly when the retry fires — a real IDFA will appear in crash/debug logs captured by any connected logging tool.

    The existing sendToken failure already follows this same pattern (info: ["payload": token]), so this isn't unique to the new code, but it's worth flagging as the IDFA is a more sensitive identifier than an AdServices token. Consider redacting or omitting the identifier fields from the error payload:

    Logger.debug(
      logLevel: .error,
      scope: .network,
      message: "Request Failed: /api/match",
      info: [
        "platform": request.platform,
        "appVersion": request.appVersion,
        "hasIdfa": request.idfa != nil,
      ],
      error: error
    )
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Network/Network.swift
    Line: 592-596
    
    Comment:
    **IDFA logged in error payload**
    
    `info: ["payload": request]` passes the full `MMPMatchRequest` struct, which includes `idfa`, `idfv`, and `deviceId`, into the structured log on every failed `/api/match` call. Once the user grants ATT — exactly when the retry fires — a real IDFA will appear in crash/debug logs captured by any connected logging tool.
    
    The existing `sendToken` failure already follows this same pattern (`info: ["payload": token]`), so this isn't unique to the new code, but it's worth flagging as the IDFA is a more sensitive identifier than an AdServices token. Consider redacting or omitting the identifier fields from the error payload:
    
    ```swift
    Logger.debug(
      logLevel: .error,
      scope: .network,
      message: "Request Failed: /api/match",
      info: [
        "platform": request.platform,
        "appVersion": request.appVersion,
        "hasIdfa": request.idfa != nil,
      ],
      error: error
    )
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. Sources/SuperwallKit/Network/Network.swift, line 587-588 (link)

    P1 matchMMPInstall returns true even on server-side "no match"

    matchMMPInstall returns true whenever the HTTP request succeeds (regardless of whether response.matched is true or false), and false only on a network error. This causes DidCompleteMMPInstallAttributionMatch to be saved even when the server returns matched: false with no attribution, which permanently prevents the initial match path from retrying on future launches.

    The tracking-permission retry path (shouldAttemptTrackingPermissionMMPInstallAttributionMatch) uses a different gate (IsEligibleForMMPInstallAttributionMatch) and will still fire correctly after ATT is granted — so the retry does work. The naming didCompleteMatch and the stored flag DidCompleteMMPInstallAttributionMatch are ambiguous because they conflate "HTTP request completed" with "attribution was found."

    Consider renaming the return value and the flag to DidCompleteMMPInstallAttributionRequest to make the intent explicit, or leaving a comment at the return true site clarifying that success here means "request processed; no need to retry the initial path."

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Network/Network.swift
    Line: 587-588
    
    Comment:
    **`matchMMPInstall` returns `true` even on server-side "no match"**
    
    `matchMMPInstall` returns `true` whenever the HTTP request succeeds (regardless of whether `response.matched` is `true` or `false`), and `false` only on a network error. This causes `DidCompleteMMPInstallAttributionMatch` to be saved even when the server returns `matched: false` with no attribution, which permanently prevents the *initial* match path from retrying on future launches.
    
    The tracking-permission retry path (`shouldAttemptTrackingPermissionMMPInstallAttributionMatch`) uses a different gate (`IsEligibleForMMPInstallAttributionMatch`) and will still fire correctly after ATT is granted — so the retry does work. The naming `didCompleteMatch` and the stored flag `DidCompleteMMPInstallAttributionMatch` are ambiguous because they conflate "HTTP request completed" with "attribution was found."
    
    Consider renaming the return value and the flag to `DidCompleteMMPInstallAttributionRequest` to make the intent explicit, or leaving a comment at the `return true` site clarifying that success here means "request processed; no need to retry the initial path."
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. Sources/SuperwallKit/Storage/Storage.swift, line 716-731 (link)

    P2 shouldAttemptInitialMMPInstallAttributionMatch skips only when BOTH conditions are true

    The early-exit guard is:

    if hadTrackedAppInstallBeforeConfigure && didCompleteMatch {
      return false
    }

    This means a fresh install (hadTrackedAppInstallBeforeConfigure == false) will always fall through to check the attribution window — even if DidCompleteMMPInstallAttributionMatch was somehow already persisted (e.g. from a previous install that wasn't fully cleaned up). In that edge case, an extra MMP request is fired unnecessarily.

    A more defensive guard would check didCompleteMatch independently of hadTrackedAppInstallBeforeConfigure:

    if didCompleteMatch {
      return false
    }

    The hadTrackedAppInstallBeforeConfigure == false path always has didCompleteMatch == false in practice, so removing the conjunction doesn't change real-world behavior while making the intent clearer.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Storage/Storage.swift
    Line: 716-731
    
    Comment:
    **`shouldAttemptInitialMMPInstallAttributionMatch` skips only when BOTH conditions are true**
    
    The early-exit guard is:
    
    ```swift
    if hadTrackedAppInstallBeforeConfigure && didCompleteMatch {
      return false
    }
    ```
    
    This means a fresh install (`hadTrackedAppInstallBeforeConfigure == false`) will *always* fall through to check the attribution window — even if `DidCompleteMMPInstallAttributionMatch` was somehow already persisted (e.g. from a previous install that wasn't fully cleaned up). In that edge case, an extra MMP request is fired unnecessarily.
    
    A more defensive guard would check `didCompleteMatch` independently of `hadTrackedAppInstallBeforeConfigure`:
    
    ```swift
    if didCompleteMatch {
      return false
    }
    ```
    
    The `hadTrackedAppInstallBeforeConfigure == false` path always has `didCompleteMatch == false` in practice, so removing the conjunction doesn't change real-world behavior while making the intent clearer.
    
    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: Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Line: 125-135

Comment:
**`UIScreen.main` used without visionOS guard**

The existing `interfaceStyle` property in this same file guards against visionOS with `#if os(visionOS)` precisely because `UIScreen.main` behaves differently there. The three new properties added here don't follow that pattern.

On visionOS, `UIScreen.main` is available but returns a fixed virtual resolution (not the real spatial display dimensions), so the values reported would be meaningless. For consistency with the established pattern:

```swift
var screenWidth: Int {
  #if os(visionOS)
  return 0
  #else
  return Int(UIScreen.main.bounds.width.rounded())
  #endif
}

var screenHeight: Int {
  #if os(visionOS)
  return 0
  #else
  return Int(UIScreen.main.bounds.height.rounded())
  #endif
}

var devicePixelRatio: Double {
  #if os(visionOS)
  return 1.0
  #else
  return Double(UIScreen.main.scale)
  #endif
}
```

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

---

This is a comment left during a code review.
Path: Sources/SuperwallKit/Storage/Storage.swift
Line: 268-290

Comment:
**Unnecessary MMP retry fires after a successful initial match**

`shouldAttemptTrackingPermissionMMPInstallAttributionMatch` checks `DidCompleteMMPInstallAttributionRequestAfterTrackingPermission` but NOT `DidCompleteMMPInstallAttributionRequest`. This means: if the initial match (without IDFA) succeeded and set `DidCompleteMMPInstallAttributionRequest = true`, and the user later grants ATT permission, the tracking-permission retry path will still fire because `DidCompleteMMPInstallAttributionRequestAfterTrackingPermission` is never set by the initial match.

The result is a second `/api/match` call even though attribution was already successfully resolved. If the intent is to purposely upgrade the match with the IDFA (a legitimate MMP pattern), the logic is fine as-is, but it should be clearly documented. If the intent is to avoid a redundant call when the initial match already succeeded, add a check for `DidCompleteMMPInstallAttributionRequest`:

```swift
func shouldAttemptTrackingPermissionMMPInstallAttributionMatch(
  appInstalledAtString: String
) -> Bool {
  // If the initial match already completed successfully, a retry won't improve it.
  let didCompleteInitialRequest = get(DidCompleteMMPInstallAttributionRequest.self) ?? false
  if didCompleteInitialRequest {
    return false
  }
  // ... rest of existing checks
}
```

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

Reviews (8): Last reviewed commit: "Bugfix for sdk upgraders" | 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