From 79507c7b6f1143fac084ffcc9b47e8d4eccf4c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:41:46 +0200 Subject: [PATCH 1/5] Only run the MMP install match once config enables it The install match used to fire on every fresh install. It now waits for `attributionOptions.mmp.enabled` in config, the same way Apple Search Ads waits for its switch. The eligibility check still runs at launch so a later launch can retry if this one ends before the match completes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../Attribution/MMPAttributionManager.swift | 22 ++++- .../Dependencies/DependencyContainer.swift | 3 +- Sources/SuperwallKit/Superwall.swift | 39 +++++---- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../MMPInstallMatchConfigTests.swift | 82 +++++++++++++++++++ 6 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 30fb1e660..539d1076b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading. +- Stops matching installs for attribution unless the Superwall MMP is turned on for your app. ## 4.17.0 diff --git a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift index d6690cabf..05dbc82a7 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift @@ -4,6 +4,7 @@ // import Foundation +import Combine /// Thrown by `Network.matchMMPInstall(...)` when the dependencies needed to /// build the request aren't available yet. Distinct from a transport failure @@ -23,15 +24,34 @@ final class MMPAttributionManager { private unowned let network: Network private unowned let storage: Storage private unowned let identityManager: IdentityManager + private unowned let configManager: ConfigManager + private var pendingMatch: AnyCancellable? init( network: Network, storage: Storage, - identityManager: IdentityManager + identityManager: IdentityManager, + configManager: ConfigManager ) { self.network = network self.storage = storage self.identityManager = identityManager + self.configManager = configManager + } + + /// Calls `startMatch` once config says the MMP is enabled for this app, + /// which may be straight away if config is already loaded. It's off by + /// default, so it never fires if the backend doesn't turn it on. Works the + /// same way `AttributionPoster` waits for Apple Search Ads to be enabled. + func matchInstallOnceEnabled(_ startMatch: @escaping () -> Void) { + pendingMatch = configManager.configState + .compactMap { $0.getConfig() } + .map { $0.attribution?.mmp?.enabled == true } + .first { $0 } + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in startMatch() } + ) } /// Fires the install-attribution match and applies its result. diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 38e3a9a9e..2ad13a27e 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -188,7 +188,8 @@ final class DependencyContainer { mmpAttributionManager = MMPAttributionManager( network: network, storage: storage, - identityManager: identityManager + identityManager: identityManager, + configManager: configManager ) testModeManager = TestModeManager( diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index e9e7186e4..0226e3ead 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -549,22 +549,29 @@ public final class Superwall: NSObject, ObservableObject { hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure, appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString ) { - let advertiserTrackingEnabled = - dependencyContainer.permissionHandler.checkTrackingPermission() == .granted - - // We deliberately fire the match once and don't retry after ATT is - // granted: the backend matches on IP + device fingerprint + time decay, - // not IDFA, so a post-consent re-match wouldn't change the result. And - // because matches are time-decayed and reads are latest-wins, a later - // retry could only tie or worsen the earlier, better-timed match. - // (`idfa`/`advertiserTrackingEnabled` are sent for downstream use, not - // matching.) - dependencyContainer.storage.recordMMPInstallAttributionMatch { - await dependencyContainer.mmpAttributionManager.matchInstall( - idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, - advertiserTrackingEnabled: advertiserTrackingEnabled, - applicationTrackingEnabled: true - ) + // The eligibility check above has to run at launch whatever the + // config says: it records that this install may be matched, which a + // later launch relies on if this one ends before the match completes. + // Only the request itself waits for config to enable the MMP. + let dependencyContainer = dependencyContainer + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + let advertiserTrackingEnabled = + dependencyContainer.permissionHandler.checkTrackingPermission() == .granted + + // We deliberately fire the match once and don't retry after ATT is + // granted: the backend matches on IP + device fingerprint + time decay, + // not IDFA, so a post-consent re-match wouldn't change the result. And + // because matches are time-decayed and reads are latest-wins, a later + // retry could only tie or worsen the earlier, better-timed match. + // (`idfa`/`advertiserTrackingEnabled` are sent for downstream use, not + // matching.) + dependencyContainer.storage.recordMMPInstallAttributionMatch { + await dependencyContainer.mmpAttributionManager.matchInstall( + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: true + ) + } } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 58c597603..057bce309 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -561,6 +561,7 @@ D25B3A24CEE42FC90BFA31D2 /* SuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E4096EBF0B8C9693322CD1 /* SuperwallEvent.swift */; }; D2E381B26362F434760F9AC0 /* GameControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */; }; D330BD5F814AC52E6D04C7E7 /* InternalGetPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9F31221C5E26E20514E0C1 /* InternalGetPaywall.swift */; }; + D40450C91B0EA225DC361C93 /* MMPInstallMatchConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */; }; D443CC56731E9A20E4F43F4C /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20365697A9C396E8EC746B77 /* LoadingViewController.swift */; }; D461F38D019122194A9CB38C /* PaywallRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F71D7A7DC8FFB72CA13296 /* PaywallRequestBody.swift */; }; D4B5A9708204AB6D58904733 /* GetPaywallVcOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943CA7F8AE219A07C1245232 /* GetPaywallVcOperatorTests.swift */; }; @@ -945,6 +946,7 @@ 5C2E30544869C5469AA31832 /* FactoryProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactoryProtocols.swift; sourceTree = ""; }; 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreVersionLookup.swift; sourceTree = ""; }; 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerMock.swift; sourceTree = ""; }; + 5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPInstallMatchConfigTests.swift; sourceTree = ""; }; 5CD130C74880AD07DCD2A7AA /* RedemptionResultObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedemptionResultObjc.swift; sourceTree = ""; }; 5D44CEC91693B4B900472C1C /* Survey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Survey.swift; sourceTree = ""; }; 5D8D539E4636D23E549B4520 /* TestModePurchaseDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModePurchaseDrawer.swift; sourceTree = ""; }; @@ -3373,6 +3375,7 @@ A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, + 5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */, ); path = Attribution; sourceTree = ""; @@ -3837,6 +3840,7 @@ 40314E44991DCB66B4572C25 /* LocationPermissionDelegateTests.swift in Sources */, 4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */, 556DDBA011967A3F2411AAE7 /* MMPInstallAttributionTests.swift in Sources */, + D40450C91B0EA225DC361C93 /* MMPInstallMatchConfigTests.swift in Sources */, 6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */, A9B924A1211117378743A534 /* MicrophonePermissionTests.swift in Sources */, B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift new file mode 100644 index 000000000..7053a55d5 --- /dev/null +++ b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift @@ -0,0 +1,82 @@ +// +// MMPInstallMatchConfigTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 30/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite +struct MMPInstallMatchConfigTests { + private final class Counter { + var count = 0 + } + + private func makeConfig(mmpEnabled: Bool?) -> Config { + var config = Config.stub() + config.attribution = Attribution( + appleSearchAds: AppleSearchAds(enabled: true), + mmp: mmpEnabled.map { MMPAttribution(enabled: $0) } + ) + return config + } + + @Test + func waitsForConfigToEnableTheMMP() { + let dependencyContainer = DependencyContainer() + let counter = Counter() + + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + counter.count += 1 + } + #expect(counter.count == 0) + + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + #expect(counter.count == 1) + } + + @Test + func neverMatchesWhenConfigDoesNotEnableTheMMP() { + let dependencyContainer = DependencyContainer() + let counter = Counter() + + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + counter.count += 1 + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: nil))) + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) + + #expect(counter.count == 0) + } + + @Test + func matchesStraightAwayWhenConfigIsAlreadyLoaded() { + let dependencyContainer = DependencyContainer() + let counter = Counter() + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + counter.count += 1 + } + + #expect(counter.count == 1) + } + + @Test + func matchesOnlyOnceWhenConfigRefreshes() { + let dependencyContainer = DependencyContainer() + let counter = Counter() + + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + counter.count += 1 + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + #expect(counter.count == 1) + } +} From a64dd1f4f812f07190ede7b9ad3a37d8d9e944b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:00:07 +0200 Subject: [PATCH 2/5] Recheck the tracking opt-out when the MMP match fires The match can wait a while for config, so check that the app hasn't opted out of tracking in the meantime. Hold the dependency container weakly so a match that never fires doesn't keep it alive. Co-Authored-By: Claude Opus 5.5 --- Sources/SuperwallKit/Superwall.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 0226e3ead..224735f92 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -553,8 +553,14 @@ public final class Superwall: NSObject, ObservableObject { // config says: it records that this install may be matched, which a // later launch relies on if this one ends before the match completes. // Only the request itself waits for config to enable the MMP. - let dependencyContainer = dependencyContainer - dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { [weak dependencyContainer] in + guard let dependencyContainer = dependencyContainer else { + return + } + // The app may have opted out while the match waited for config. + if dependencyContainer.configManager.options.eventTrackingBehavior == .none { + return + } let advertiserTrackingEnabled = dependencyContainer.permissionHandler.checkTrackingPermission() == .granted From b8e96941237befacccdcf5d7fb9415d1351eeee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:13:41 +0200 Subject: [PATCH 3/5] Let paywalls that target acquisition attributes wait for the MMP match On a first launch the match starts when config arrives, the same moment an app_open paywall is unblocked, so its audiences were checked before the acquisition attributes existed. If the MMP is on, a match is still pending and the placement's audiences use acquisition_ attributes, the paywall now waits for the match, for up to 2 seconds. Co-Authored-By: Claude Opus 5.5 --- .../Attribution/MMPAttributionManager.swift | 64 +++++++++- .../WaitForSubsStatusAndConfig.swift | 5 + Sources/SuperwallKit/Superwall.swift | 6 +- .../MMPInstallMatchConfigTests.swift | 109 ++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift index 05dbc82a7..b39fefad9 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift @@ -26,6 +26,12 @@ final class MMPAttributionManager { private unowned let identityManager: IdentityManager private unowned let configManager: ConfigManager private var pendingMatch: AnyCancellable? + /// `true` from when this launch's install match is set up until it finishes + /// or is skipped. + private let isMatchPending = CurrentValueSubject(false) + + /// How long a paywall waits for an install match that's still running. + static let presentationWaitTimeout: TimeInterval = 2 init( network: Network, @@ -43,17 +49,71 @@ final class MMPAttributionManager { /// which may be straight away if config is already loaded. It's off by /// default, so it never fires if the backend doesn't turn it on. Works the /// same way `AttributionPoster` waits for Apple Search Ads to be enabled. - func matchInstallOnceEnabled(_ startMatch: @escaping () -> Void) { + /// + /// `startMatch` returns the running match, if it started one, so paywalls + /// can wait for it. + func matchInstallOnceEnabled(_ startMatch: @escaping () -> Task?) { + isMatchPending.send(true) pendingMatch = configManager.configState .compactMap { $0.getConfig() } .map { $0.attribution?.mmp?.enabled == true } .first { $0 } .sink( receiveCompletion: { _ in }, - receiveValue: { _ in startMatch() } + receiveValue: { [weak self] _ in + guard let match = startMatch() else { + self?.isMatchPending.send(false) + return + } + Task { [weak self] in + await match.value + self?.isMatchPending.send(false) + } + } ) } + /// On a first launch, a paywall can be requested while the install match is + /// still running. If the MMP is on and the placement's audiences use + /// `acquisition_*` attributes, this waits for the match, up to `timeout`, so + /// they're there when the audiences are checked. Returns straight away + /// otherwise. + func waitForPendingMatch( + ifUsedBy trigger: Trigger?, + timeout: TimeInterval = presentationWaitTimeout + ) async { + guard let trigger = trigger else { + return + } + if !isMatchPending.value { + return + } + if configManager.config?.attribution?.mmp?.enabled != true { + return + } + if !Self.usesAcquisitionAttributes(trigger) { + return + } + + let isMatchPending = isMatchPending + await withTaskGroup(of: Void.self) { group in + group.addTask { + _ = try? await isMatchPending.first { !$0 }.throwableAsync() + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + } + await group.next() + group.cancelAll() + } + } + + static func usesAcquisitionAttributes(_ trigger: Trigger) -> Bool { + return trigger.audiences.contains { audience in + audience.expression?.contains("acquisition_") == true + } + } + /// Fires the install-attribution match and applies its result. /// /// On a successful response the resolved `acquisition_*` payload is cached diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/WaitForSubsStatusAndConfig.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/WaitForSubsStatusAndConfig.swift index 49a89f16a..44f8d43c9 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/WaitForSubsStatusAndConfig.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/WaitForSubsStatusAndConfig.swift @@ -108,5 +108,10 @@ extension Superwall { // Get the identity. This may or may not wait depending on whether the dev // specifically wants to wait for assignments. try await dependencyContainer.identityManager.hasIdentity.throwableAsync() + + let trigger = request.presentationInfo.placementName.flatMap { + dependencyContainer.configManager.triggersByPlacementName[$0] + } + await dependencyContainer.mmpAttributionManager.waitForPendingMatch(ifUsedBy: trigger) } } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 224735f92..f19b8f883 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -555,11 +555,11 @@ public final class Superwall: NSObject, ObservableObject { // Only the request itself waits for config to enable the MMP. dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { [weak dependencyContainer] in guard let dependencyContainer = dependencyContainer else { - return + return nil } // The app may have opted out while the match waited for config. if dependencyContainer.configManager.options.eventTrackingBehavior == .none { - return + return nil } let advertiserTrackingEnabled = dependencyContainer.permissionHandler.checkTrackingPermission() == .granted @@ -571,7 +571,7 @@ public final class Superwall: NSObject, ObservableObject { // retry could only tie or worsen the earlier, better-timed match. // (`idfa`/`advertiserTrackingEnabled` are sent for downstream use, not // matching.) - dependencyContainer.storage.recordMMPInstallAttributionMatch { + return dependencyContainer.storage.recordMMPInstallAttributionMatch { await dependencyContainer.mmpAttributionManager.matchInstall( idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, advertiserTrackingEnabled: advertiserTrackingEnabled, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift index 7053a55d5..e9ff765bc 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift @@ -15,6 +15,12 @@ struct MMPInstallMatchConfigTests { var count = 0 } + private func makeTrigger(expression: String?) -> Trigger { + var audience = TriggerRule.stub() + audience.expression = expression + return Trigger(placementName: "app_open", audiences: [audience]) + } + private func makeConfig(mmpEnabled: Bool?) -> Config { var config = Config.stub() config.attribution = Attribution( @@ -31,6 +37,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 + return nil } #expect(counter.count == 0) @@ -45,6 +52,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 + return nil } dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: nil))) dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) @@ -60,6 +68,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 + return nil } #expect(counter.count == 1) @@ -72,6 +81,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 + return nil } dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) @@ -79,4 +89,103 @@ struct MMPInstallMatchConfigTests { #expect(counter.count == 1) } + + // MARK: - Paywalls waiting for the match + + @Test + func detectsAudiencesThatUseAcquisitionAttributes() { + #expect(MMPAttributionManager.usesAcquisitionAttributes( + makeTrigger(expression: "user.acquisition_source == \"tiktok\"") + )) + #expect(!MMPAttributionManager.usesAcquisitionAttributes( + makeTrigger(expression: "user.plan == \"pro\"") + )) + #expect(!MMPAttributionManager.usesAcquisitionAttributes(makeTrigger(expression: nil))) + } + + @Test + func paywallWaitsForARunningMatch() async { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + let gate = AsyncStream.makeStream() + let finished = Counter() + + manager.matchInstallOnceEnabled { + Task { + for await _ in gate.stream { break } + finished.count += 1 + } + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + let waiter = Task { + await manager.waitForPendingMatch( + ifUsedBy: makeTrigger(expression: "user.acquisition_source == \"tiktok\""), + timeout: 10 + ) + return finished.count + } + gate.continuation.yield() + #expect(await waiter.value == 1) + } + + @Test + func paywallStopsWaitingAfterTheTimeout() async { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + + manager.matchInstallOnceEnabled { + Task { + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + let start = Date() + await manager.waitForPendingMatch( + ifUsedBy: makeTrigger(expression: "user.acquisition_source == \"tiktok\""), + timeout: 0.1 + ) + #expect(Date().timeIntervalSince(start) < 5) + } + + @Test + func paywallDoesNotWaitWhenItsAudiencesDontNeedTheMatch() async { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + + manager.matchInstallOnceEnabled { + Task { + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + let start = Date() + await manager.waitForPendingMatch( + ifUsedBy: makeTrigger(expression: "user.plan == \"pro\""), + timeout: 30 + ) + #expect(Date().timeIntervalSince(start) < 5) + } + + @Test + func paywallDoesNotWaitWhenTheMMPIsOff() async { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + + manager.matchInstallOnceEnabled { + Task { + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) + + let start = Date() + await manager.waitForPendingMatch( + ifUsedBy: makeTrigger(expression: "user.acquisition_source == \"tiktok\""), + timeout: 30 + ) + #expect(Date().timeIntervalSince(start) < 5) + } } From 7bec056a384c8fe0587582a7ce38b9cf15d79cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:15:10 +0200 Subject: [PATCH 4/5] 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 --- CHANGELOG.md | 1 + .../Attribution/MMPAttributionManager.swift | 52 +++++++++++++++---- Sources/SuperwallKit/Superwall.swift | 30 ++++++----- .../MMPInstallMatchConfigTests.swift | 51 ++++++++++++++++-- 4 files changed, 105 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 539d1076b..67f40e4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading. - Stops matching installs for attribution unless the Superwall MMP is turned on for your app. +- Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish. ## 4.17.0 diff --git a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift index b39fefad9..4243cea4a 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift @@ -26,6 +26,9 @@ final class MMPAttributionManager { private unowned let identityManager: IdentityManager private unowned let configManager: ConfigManager private var pendingMatch: AnyCancellable? + private let lock = NSLock() + private var startMatch: (() -> Task?)? + private var hasStartedMatch = false /// `true` from when this launch's install match is set up until it finishes /// or is skipped. private let isMatchPending = CurrentValueSubject(false) @@ -50,29 +53,56 @@ final class MMPAttributionManager { /// default, so it never fires if the backend doesn't turn it on. Works the /// same way `AttributionPoster` waits for Apple Search Ads to be enabled. /// - /// `startMatch` returns the running match, if it started one, so paywalls - /// can wait for it. + /// `startMatch` returns the running match, or `nil` if it skipped it + /// because the app has opted out of tracking. A skipped match is tried + /// again when the app opts back in, via `startMatchIfEnabled()`. func matchInstallOnceEnabled(_ startMatch: @escaping () -> Task?) { + lock.lock() + self.startMatch = startMatch + lock.unlock() isMatchPending.send(true) + pendingMatch = configManager.configState .compactMap { $0.getConfig() } .map { $0.attribution?.mmp?.enabled == true } - .first { $0 } + .removeDuplicates() + .filter { $0 } .sink( receiveCompletion: { _ in }, receiveValue: { [weak self] _ in - guard let match = startMatch() else { - self?.isMatchPending.send(false) - return - } - Task { [weak self] in - await match.value - self?.isMatchPending.send(false) - } + self?.startMatchIfEnabled() } ) } + /// Starts this launch's install match if config has the MMP on and it + /// hasn't started yet. Called when config arrives and when the app turns + /// tracking back on. + func startMatchIfEnabled() { + if configManager.config?.attribution?.mmp?.enabled != true { + return + } + lock.lock() + defer { lock.unlock() } + guard + !hasStartedMatch, + let startMatch = startMatch + else { + return + } + guard let match = startMatch() else { + // Skipped while opted out. Don't hold paywalls up in the meantime. + isMatchPending.send(false) + return + } + hasStartedMatch = true + isMatchPending.send(true) + Task { [weak self] in + await match.value + self?.isMatchPending.send(false) + } + } + /// On a first launch, a paywall can be requested while the install match is /// still running. If the MMP is on and the placement's audiences use /// `acquisition_*` attributes, this waits for the match, up to `timeout`, so diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index f19b8f883..0062e7efe 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -78,6 +78,10 @@ public final class Superwall: NSObject, ObservableObject { set { options.eventTrackingBehavior = newValue + if newValue != .none { + dependencyContainer.mmpAttributionManager.startMatchIfEnabled() + } + Task { await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) } @@ -540,24 +544,22 @@ public final class Superwall: NSObject, ObservableObject { _ = await configureIdentity - // Skip install-attribution matching entirely when the developer has - // opted out of all event collection. The `/api/match` call and the - // `acquisition_*` attribute writes happen outside the event queue, so - // queue-level suppression wouldn't catch them. - if dependencyContainer.configManager.options.eventTrackingBehavior != .none, - dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( - hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure, - appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString - ) { - // The eligibility check above has to run at launch whatever the - // config says: it records that this install may be matched, which a - // later launch relies on if this one ends before the match completes. - // Only the request itself waits for config to enable the MMP. + // The eligibility check has to run at launch whatever the config or + // tracking setting says: it records that this install may be matched, + // which a later launch relies on if this one ends before the match + // completes. Only the request itself waits for config to enable the MMP. + if dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure, + appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString + ) { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { [weak dependencyContainer] in guard let dependencyContainer = dependencyContainer else { return nil } - // The app may have opted out while the match waited for config. + // Skip matching when the app has opted out of all event collection. + // The `/api/match` call and the `acquisition_*` attribute writes + // happen outside the event queue, so queue-level suppression + // wouldn't catch them. It's tried again if the app opts back in. if dependencyContainer.configManager.options.eventTrackingBehavior == .none { return nil } diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift index e9ff765bc..30bdeaa81 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift @@ -37,7 +37,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 - return nil + return Task {} } #expect(counter.count == 0) @@ -52,7 +52,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 - return nil + return Task {} } dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: nil))) dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) @@ -68,7 +68,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 - return nil + return Task {} } #expect(counter.count == 1) @@ -81,7 +81,7 @@ struct MMPInstallMatchConfigTests { dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { counter.count += 1 - return nil + return Task {} } dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) @@ -90,6 +90,49 @@ struct MMPInstallMatchConfigTests { #expect(counter.count == 1) } + @Test + func startsASkippedMatchWhenTrackingIsTurnedBackOn() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + let attempts = Counter() + let started = Counter() + var isOptedOut = true + + manager.matchInstallOnceEnabled { + attempts.count += 1 + if isOptedOut { + return nil + } + started.count += 1 + return Task {} + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + #expect(attempts.count == 1) + #expect(started.count == 0) + + isOptedOut = false + manager.startMatchIfEnabled() + manager.startMatchIfEnabled() + #expect(started.count == 1) + #expect(attempts.count == 2) + } + + @Test + func doesNotStartOnOptInWhenTheMMPIsOff() { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + let attempts = Counter() + + manager.matchInstallOnceEnabled { + attempts.count += 1 + return Task {} + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: false))) + manager.startMatchIfEnabled() + + #expect(attempts.count == 0) + } + // MARK: - Paywalls waiting for the match @Test From c8669ed5177b7d897f97ed3bfb795743466744e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:20:44 +0200 Subject: [PATCH 5/5] Make the paywall wait tests prove the paywall waited Co-Authored-By: Claude Opus 5.5 --- .../MMPInstallMatchConfigTests.swift | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift index 30bdeaa81..32317d1e3 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift @@ -150,26 +150,25 @@ struct MMPInstallMatchConfigTests { func paywallWaitsForARunningMatch() async { let dependencyContainer = DependencyContainer() let manager = dependencyContainer.mmpAttributionManager! - let gate = AsyncStream.makeStream() let finished = Counter() + // The match finishes a moment later, so the paywall only sees it finished + // if it actually waited. manager.matchInstallOnceEnabled { Task { - for await _ in gate.stream { break } + try? await Task.sleep(nanoseconds: 200_000_000) finished.count += 1 } } dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) - let waiter = Task { - await manager.waitForPendingMatch( - ifUsedBy: makeTrigger(expression: "user.acquisition_source == \"tiktok\""), - timeout: 10 - ) - return finished.count - } - gate.continuation.yield() - #expect(await waiter.value == 1) + let start = Date() + await manager.waitForPendingMatch( + ifUsedBy: makeTrigger(expression: "user.acquisition_source == \"tiktok\""), + timeout: 10 + ) + #expect(finished.count == 1) + #expect(Date().timeIntervalSince(start) >= 0.2) } @Test @@ -189,7 +188,9 @@ struct MMPInstallMatchConfigTests { ifUsedBy: makeTrigger(expression: "user.acquisition_source == \"tiktok\""), timeout: 0.1 ) - #expect(Date().timeIntervalSince(start) < 5) + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 0.1) + #expect(elapsed < 5) } @Test