Skip to content

Commit 7bec056

Browse files
yusuftorclaude
andcommitted
Start a skipped MMP match when the app turns tracking back on
The match was skipped for the rest of the launch if tracking was off when config enabled the MMP. It now starts when the app opts back in, and the launch-time setup no longer depends on the tracking setting. The match still starts at most once per launch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b8e9694 commit 7bec056

4 files changed

Lines changed: 105 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
1414

1515
- Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading.
1616
- Stops matching installs for attribution unless the Superwall MMP is turned on for your app.
17+
- Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish.
1718

1819
## 4.17.0
1920

‎Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ final class MMPAttributionManager {
2626
private unowned let identityManager: IdentityManager
2727
private unowned let configManager: ConfigManager
2828
private var pendingMatch: AnyCancellable?
29+
private let lock = NSLock()
30+
private var startMatch: (() -> Task<Void, Never>?)?
31+
private var hasStartedMatch = false
2932
/// `true` from when this launch's install match is set up until it finishes
3033
/// or is skipped.
3134
private let isMatchPending = CurrentValueSubject<Bool, Never>(false)
@@ -50,29 +53,56 @@ final class MMPAttributionManager {
5053
/// default, so it never fires if the backend doesn't turn it on. Works the
5154
/// same way `AttributionPoster` waits for Apple Search Ads to be enabled.
5255
///
53-
/// `startMatch` returns the running match, if it started one, so paywalls
54-
/// can wait for it.
56+
/// `startMatch` returns the running match, or `nil` if it skipped it
57+
/// because the app has opted out of tracking. A skipped match is tried
58+
/// again when the app opts back in, via `startMatchIfEnabled()`.
5559
func matchInstallOnceEnabled(_ startMatch: @escaping () -> Task<Void, Never>?) {
60+
lock.lock()
61+
self.startMatch = startMatch
62+
lock.unlock()
5663
isMatchPending.send(true)
64+
5765
pendingMatch = configManager.configState
5866
.compactMap { $0.getConfig() }
5967
.map { $0.attribution?.mmp?.enabled == true }
60-
.first { $0 }
68+
.removeDuplicates()
69+
.filter { $0 }
6170
.sink(
6271
receiveCompletion: { _ in },
6372
receiveValue: { [weak self] _ in
64-
guard let match = startMatch() else {
65-
self?.isMatchPending.send(false)
66-
return
67-
}
68-
Task { [weak self] in
69-
await match.value
70-
self?.isMatchPending.send(false)
71-
}
73+
self?.startMatchIfEnabled()
7274
}
7375
)
7476
}
7577

78+
/// Starts this launch's install match if config has the MMP on and it
79+
/// hasn't started yet. Called when config arrives and when the app turns
80+
/// tracking back on.
81+
func startMatchIfEnabled() {
82+
if configManager.config?.attribution?.mmp?.enabled != true {
83+
return
84+
}
85+
lock.lock()
86+
defer { lock.unlock() }
87+
guard
88+
!hasStartedMatch,
89+
let startMatch = startMatch
90+
else {
91+
return
92+
}
93+
guard let match = startMatch() else {
94+
// Skipped while opted out. Don't hold paywalls up in the meantime.
95+
isMatchPending.send(false)
96+
return
97+
}
98+
hasStartedMatch = true
99+
isMatchPending.send(true)
100+
Task { [weak self] in
101+
await match.value
102+
self?.isMatchPending.send(false)
103+
}
104+
}
105+
76106
/// On a first launch, a paywall can be requested while the install match is
77107
/// still running. If the MMP is on and the placement's audiences use
78108
/// `acquisition_*` attributes, this waits for the match, up to `timeout`, so

‎Sources/SuperwallKit/Superwall.swift‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ public final class Superwall: NSObject, ObservableObject {
7878
set {
7979
options.eventTrackingBehavior = newValue
8080

81+
if newValue != .none {
82+
dependencyContainer.mmpAttributionManager.startMatchIfEnabled()
83+
}
84+
8185
Task {
8286
await dependencyContainer.placementsQueue.setTrackingBehavior(newValue)
8387
}
@@ -540,24 +544,22 @@ public final class Superwall: NSObject, ObservableObject {
540544

541545
_ = await configureIdentity
542546

543-
// Skip install-attribution matching entirely when the developer has
544-
// opted out of all event collection. The `/api/match` call and the
545-
// `acquisition_*` attribute writes happen outside the event queue, so
546-
// queue-level suppression wouldn't catch them.
547-
if dependencyContainer.configManager.options.eventTrackingBehavior != .none,
548-
dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch(
549-
hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure,
550-
appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString
551-
) {
552-
// The eligibility check above has to run at launch whatever the
553-
// config says: it records that this install may be matched, which a
554-
// later launch relies on if this one ends before the match completes.
555-
// Only the request itself waits for config to enable the MMP.
547+
// The eligibility check has to run at launch whatever the config or
548+
// tracking setting says: it records that this install may be matched,
549+
// which a later launch relies on if this one ends before the match
550+
// completes. Only the request itself waits for config to enable the MMP.
551+
if dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch(
552+
hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure,
553+
appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString
554+
) {
556555
dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { [weak dependencyContainer] in
557556
guard let dependencyContainer = dependencyContainer else {
558557
return nil
559558
}
560-
// The app may have opted out while the match waited for config.
559+
// Skip matching when the app has opted out of all event collection.
560+
// The `/api/match` call and the `acquisition_*` attribute writes
561+
// happen outside the event queue, so queue-level suppression
562+
// wouldn't catch them. It's tried again if the app opts back in.
561563
if dependencyContainer.configManager.options.eventTrackingBehavior == .none {
562564
return nil
563565
}

‎Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ struct MMPInstallMatchConfigTests {
3737

3838
dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled {
3939
counter.count += 1
40-
return nil
40+
return Task {}
4141
}
4242
#expect(counter.count == 0)
4343

@@ -52,7 +52,7 @@ struct MMPInstallMatchConfigTests {
5252

5353
dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled {
5454
counter.count += 1
55-
return nil
55+
return Task {}
5656
}
5757
dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: nil)))
5858
dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false)))
@@ -68,7 +68,7 @@ struct MMPInstallMatchConfigTests {
6868

6969
dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled {
7070
counter.count += 1
71-
return nil
71+
return Task {}
7272
}
7373

7474
#expect(counter.count == 1)
@@ -81,7 +81,7 @@ struct MMPInstallMatchConfigTests {
8181

8282
dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled {
8383
counter.count += 1
84-
return nil
84+
return Task {}
8585
}
8686
dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true)))
8787
dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false)))
@@ -90,6 +90,49 @@ struct MMPInstallMatchConfigTests {
9090
#expect(counter.count == 1)
9191
}
9292

93+
@Test
94+
func startsASkippedMatchWhenTrackingIsTurnedBackOn() {
95+
let dependencyContainer = DependencyContainer()
96+
let manager = dependencyContainer.mmpAttributionManager!
97+
let attempts = Counter()
98+
let started = Counter()
99+
var isOptedOut = true
100+
101+
manager.matchInstallOnceEnabled {
102+
attempts.count += 1
103+
if isOptedOut {
104+
return nil
105+
}
106+
started.count += 1
107+
return Task {}
108+
}
109+
dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true)))
110+
#expect(attempts.count == 1)
111+
#expect(started.count == 0)
112+
113+
isOptedOut = false
114+
manager.startMatchIfEnabled()
115+
manager.startMatchIfEnabled()
116+
#expect(started.count == 1)
117+
#expect(attempts.count == 2)
118+
}
119+
120+
@Test
121+
func doesNotStartOnOptInWhenTheMMPIsOff() {
122+
let dependencyContainer = DependencyContainer()
123+
let manager = dependencyContainer.mmpAttributionManager!
124+
let attempts = Counter()
125+
126+
manager.matchInstallOnceEnabled {
127+
attempts.count += 1
128+
return Task {}
129+
}
130+
dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false)))
131+
manager.startMatchIfEnabled()
132+
133+
#expect(attempts.count == 0)
134+
}
135+
93136
// MARK: - Paywalls waiting for the match
94137

95138
@Test

0 commit comments

Comments
 (0)