diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2a30094..97786fb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ 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. +- 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 d6690cabf..f966f5c89 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,130 @@ 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? + 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) + + /// How long a paywall waits for an install match that's still running. + static let presentationWaitTimeout: TimeInterval = 2 init( network: Network, storage: Storage, - identityManager: IdentityManager + identityManager: IdentityManager, + configManager: ConfigManager ) { self.network = network self.storage = storage self.identityManager = identityManager + self.configManager = configManager + } + + /// Marks this launch's install match as pending, so a paywall that's + /// requested before the match is set up still waits for it. + func markMatchPending() { + isMatchPending.send(true) + } + + /// 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. + /// + /// `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 } + .removeDuplicates() + .filter { $0 } + .sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] _ in + 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 + /// 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. 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/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 e9e7186e4..b0b91cc15 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) } @@ -535,36 +539,54 @@ public final class Superwall: NSObject, ObservableObject { let hadTrackedAppInstallBeforeConfigure = dependencyContainer.storage.hasTrackedAppInstall() dependencyContainer.storage.recordAppInstall(trackPlacement: track) + // 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. It runs before config is fetched so a paywall can't get + // ahead of the match being marked as pending. Only the request itself + // waits for config to enable the MMP. + let shouldMatchInstall = dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure, + appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString + ) + if shouldMatchInstall { + dependencyContainer.mmpAttributionManager.markMatchPending() + } + async let fetchConfig: () = await dependencyContainer.configManager.fetchConfiguration() async let configureIdentity: () = await dependencyContainer.identityManager.configure() _ = 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 - ) { - 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 - ) + if shouldMatchInstall { + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { [weak dependencyContainer] in + guard let dependencyContainer = dependencyContainer else { + return nil + } + // 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 + } + 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.) + return 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 743f258e1..dee3eccee 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -562,6 +562,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 */; }; @@ -946,6 +947,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 = ""; }; @@ -3376,6 +3378,7 @@ A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, + 5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */, ); path = Attribution; sourceTree = ""; @@ -3840,6 +3843,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..95987e61d --- /dev/null +++ b/Tests/SuperwallKitTests/Analytics/Attribution/MMPInstallMatchConfigTests.swift @@ -0,0 +1,264 @@ +// +// 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 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( + 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 + return Task {} + } + #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 + return Task {} + } + 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 + return Task {} + } + + #expect(counter.count == 1) + } + + @Test + func matchesOnlyOnceWhenConfigRefreshes() { + let dependencyContainer = DependencyContainer() + let counter = Counter() + + dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { + counter.count += 1 + return Task {} + } + 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) + } + + @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 + 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 finished = Counter() + + // The match finishes a moment later, so the paywall only sees it finished + // if it actually waited. + manager.matchInstallOnceEnabled { + Task { + try? await Task.sleep(nanoseconds: 200_000_000) + finished.count += 1 + } + } + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + 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) + } + + /// A paywall can be requested after the match is marked pending at launch + /// but before it's set up. It must still wait. + @Test + func paywallWaitsForAMatchMarkedPendingBeforeItsSetUp() async { + let dependencyContainer = DependencyContainer() + let manager = dependencyContainer.mmpAttributionManager! + let finished = Counter() + manager.markMatchPending() + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: true))) + + Task { + try? await Task.sleep(nanoseconds: 100_000_000) + manager.matchInstallOnceEnabled { + Task { + try? await Task.sleep(nanoseconds: 100_000_000) + finished.count += 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 + 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 + ) + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 0.1) + #expect(elapsed < 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) + } +}