From 319c8f23ba9164decf98b791d2ee217ba1bd4e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:36:27 +0100 Subject: [PATCH 01/12] feat: add install attribution matching support --- CHANGELOG.md | 6 + .../Attribution/AttributionFetcher.swift | 9 +- .../Attribution/AttributionPoster.swift | 16 +- .../TrackableSuperwallEvent.swift | 35 ++++ .../Superwall Placement/SuperwallEvent.swift | 66 ++++++++ .../SuperwallEventObjc.swift | 5 + .../Config/Options/SuperwallOptions.swift | 12 ++ .../Extensions/SuperwallExtension.md | 1 + Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../Models/AdServicesResponse.swift | 34 ++++ Sources/SuperwallKit/Network/API.swift | 15 ++ Sources/SuperwallKit/Network/Endpoint.swift | 19 +++ Sources/SuperwallKit/Network/Network.swift | 151 +++++++++++++++++- .../PermissionsHandler+Tracking.swift | 13 +- .../Storage/Cache/CacheKeys.swift | 24 +++ Sources/SuperwallKit/Storage/Storage.swift | 89 +++++++++++ Sources/SuperwallKit/Superwall.swift | 70 +++++++- SuperwallKit.podspec | 2 +- 18 files changed, 561 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca15b1de5..ab56fb1ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.0 + +### Enhancements + +- Adds install attribution matching support. If you set up performance marketing integrations on the Superwall dashboard, the SDK will attempt to match the install and track an `attribution_match` event. The attribution properties will be added to user attributes so that they can be used as breakdowns and filters in the charts. + ## 4.14.2 ### Enhancements diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index d7657c9faa..a1f728977b 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -11,6 +11,8 @@ import AdServices #endif final class AttributionFetcher { + private static let zeroAdvertisingIdentifier = "00000000-0000-0000-0000-000000000000" + var integrationAttributes: [String: String] { queue.sync { _integrationAttributes @@ -43,7 +45,12 @@ final class AttributionFetcher { return nil } - return identifierValue.uuidString + let identifier = identifierValue.uuidString + if identifier.caseInsensitiveCompare(Self.zeroAdvertisingIdentifier) == .orderedSame { + return nil + } + + return identifier } #endif return nil diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 9563824ab8..3b8065f6eb 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -120,7 +120,21 @@ final class AttributionPoster { ) let data = await network.sendToken(token) - Superwall.shared.setUserAttributes(data) + if let data, !data.isEmpty { + Superwall.shared.setUserAttributes(data) + } + + let matched = !(data?.isEmpty ?? true) + await Superwall.shared.track( + InternalSuperwallEvent.AttributionMatch( + info: AttributionMatchInfo( + provider: .appleSearchAds, + matched: matched, + source: matched ? (data?["acquisition_source"] as? String ?? "apple_search_ads") : nil, + reason: data == nil ? "request_failed" : (matched ? nil : "no_attribution") + ) + ) + ) } catch { await Superwall.shared.track( InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 97dbd5a3fa..392550400b 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -108,6 +108,41 @@ enum InternalSuperwallEvent { var audienceFilterParams: [String: Any] = [:] } + struct AttributionMatch: TrackableSuperwallEvent { + let info: AttributionMatchInfo + + var superwallEvent: SuperwallEvent { + return .attributionMatch(info: info) + } + + func getSuperwallParameters() async -> [String: Any] { [:] } + + var audienceFilterParams: [String: Any] { + var parameters: [String: Any] = [ + "provider": info.provider.rawValue, + "matched": info.matched, + ] + + if let source = info.source { + parameters["source"] = source + } + if let confidence = info.confidence { + parameters["confidence"] = confidence + } + if let matchScore = info.matchScore { + parameters["match_score"] = matchScore + } + if let reason = info.reason { + parameters["reason"] = reason + } + if let retryAfterTrackingPermission = info.retryAfterTrackingPermission { + parameters["retry_after_tracking_permission"] = retryAfterTrackingPermission + } + + return parameters + } + } + struct IntegrationAttributes: TrackableSuperwallEvent { var superwallEvent: SuperwallEvent { return .integrationAttributes(audienceFilterParams) diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index f02caf9af1..3db80750f3 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -13,6 +13,67 @@ import Foundation /// These placement are tracked internally by the SDK and sent to the delegate method ``SuperwallDelegate/handleSuperwallEvent(withInfo:)-50exd``. public typealias SuperwallPlacement = SuperwallEvent +/// Information about an install attribution result emitted by Superwall. +public struct AttributionMatchInfo: Sendable { + /// The attribution provider that produced the result. + public enum Provider: String, Sendable { + /// Superwall's mobile measurement matching flow. + case mmp + + /// Apple Search Ads attribution. + case appleSearchAds = "apple_search_ads" + } + + /// The provider that produced the attribution result. + public let provider: Provider + + /// Whether the attribution attempt resulted in a match. + public let matched: Bool + + /// The resolved acquisition source, if one was found. + public let source: String? + + /// The confidence label returned by the provider, if available. + public let confidence: String? + + /// The numeric match score returned by the provider, if available. + public let matchScore: Double? + + /// The reason for a non-match or failure, if available. + public let reason: String? + + /// Whether this attribution attempt was a retry after tracking permission was granted. + public let retryAfterTrackingPermission: Bool? + + /// Creates a new install attribution result. + /// + /// - Parameters: + /// - provider: The attribution provider that produced the result. + /// - matched: Whether the attribution attempt matched. + /// - source: The resolved acquisition source, if one was found. + /// - confidence: The provider's confidence label, if available. + /// - matchScore: The provider's numeric match score, if available. + /// - reason: The reason for a non-match or failure, if available. + /// - retryAfterTrackingPermission: Whether the attempt happened after tracking permission was granted. + public init( + provider: Provider, + matched: Bool, + source: String? = nil, + confidence: String? = nil, + matchScore: Double? = nil, + reason: String? = nil, + retryAfterTrackingPermission: Bool? = nil + ) { + self.provider = provider + self.matched = matched + self.source = source + self.confidence = confidence + self.matchScore = matchScore + self.reason = reason + self.retryAfterTrackingPermission = retryAfterTrackingPermission + } +} + /// Analytical events that are automatically tracked by Superwall. /// /// These events are tracked internally by the SDK and sent to the delegate method ``SuperwallDelegate/handleSuperwallEvent(withInfo:)-50exd``. @@ -105,6 +166,9 @@ public enum SuperwallEvent { /// When the user attributes are set. case userAttributes(_ attributes: [String: Any]) + /// When install attribution is resolved or fails to resolve. + case attributionMatch(info: AttributionMatchInfo) + /// When the user purchased a non recurring product. case nonRecurringProductPurchase(product: TransactionProduct, paywallInfo: PaywallInfo) @@ -374,6 +438,8 @@ extension SuperwallEvent { return .init(objcEvent: .transactionRestore) case .userAttributes: return .init(objcEvent: .userAttributes) + case .attributionMatch: + return .init(objcEvent: .attributionMatch) case .nonRecurringProductPurchase: return .init(objcEvent: .nonRecurringProductPurchase) case .paywallResponseLoadStart: diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index ce36084a7c..109e94ee08 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -90,6 +90,9 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When the user attributes are set. case userAttributes + /// When install attribution is resolved or fails to resolve. + case attributionMatch + /// When the user purchased a non recurring product. case nonRecurringProductPurchase @@ -312,6 +315,8 @@ public enum SuperwallEventObjc: Int, CaseIterable { return "transaction_restore" case .userAttributes: return "user_attributes" + case .attributionMatch: + return "attribution_match" case .nonRecurringProductPurchase: return "nonRecurringProduct_purchase" case .paywallResponseLoadStart: diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c86f925762..20f267d726 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -227,6 +227,18 @@ public final class SuperwallOptions: NSObject, Encodable { } } + var mmpHost: String { + switch self { + case .developer, + .custom: + return "mmp.superwall.dev" + case .local: + return "localhost:3045" + default: + return "mmp.superwall.com" + } + } + private enum CodingKeys: String, CodingKey { case networkEnvironment case customDomain diff --git a/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md b/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md index 1eb16b3160..1c3550a15a 100644 --- a/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md +++ b/Sources/SuperwallKit/Documentation.docc/Extensions/SuperwallExtension.md @@ -54,6 +54,7 @@ The `Superwall` class is used to access all the features of the SDK. Before usin - `PaywallInfo` - `SuperwallEvent` - `SuperwallEventObjc` +- `AttributionMatchInfo` - `PaywallSkippedReason` - `PaywallSkippedReasonObjc` - `PaywallViewController` diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 41e6bcf49c..19f2179977 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.14.2 +4.15.0 """ diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index a0c016705e..44d8eb5321 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -10,3 +10,37 @@ import Foundation struct AdServicesResponse: Decodable { let attribution: [String: JSON] } + +// MARK: - MMP Attribution + +struct MMPMatchRequest: Encodable { + let platform: String + let appUserId: String? + let deviceId: String? + let vendorId: String? + let idfa: String? + let idfv: String? + let appVersion: String + let sdkVersion: String + let osVersion: String + let deviceModel: String + let deviceLocale: String + let deviceLanguageCode: String + let bundleId: String + let clientTimestamp: String + let metadata: [String: String] +} + +struct MMPMatchResponse: Decodable { + let matched: Bool + let confidence: String? + let matchScore: Double? + let clickId: Int? + let linkId: String? + let network: String? + let redirectUrl: String? + let queryParams: [String: String]? + let acquisitionAttributes: [String: JSON]? + let matchedAt: String? + let breakdown: [String: JSON]? +} diff --git a/Sources/SuperwallKit/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index 495f2572a0..d84a8cfb99 100644 --- a/Sources/SuperwallKit/Network/API.swift +++ b/Sources/SuperwallKit/Network/API.swift @@ -13,6 +13,7 @@ enum EndpointHost { case enrichment case adServices case subscriptionsApi + case mmp } protocol ApiHostConfig { @@ -34,6 +35,7 @@ struct Api { let enrichment: Enrichment let adServices: AdServices let subscriptionsApi: SubscriptionsAPI + let mmp: MMP init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { base = Base(networkEnvironment: networkEnvironment) @@ -41,6 +43,7 @@ struct Api { enrichment = Enrichment(networkEnvironment: networkEnvironment) adServices = AdServices(networkEnvironment: networkEnvironment) subscriptionsApi = SubscriptionsAPI(networkEnvironment: networkEnvironment) + mmp = MMP(networkEnvironment: networkEnvironment) } func getConfig(host: EndpointHost) -> ApiHostConfig { @@ -55,6 +58,8 @@ struct Api { return adServices case .subscriptionsApi: return subscriptionsApi + case .mmp: + return mmp } } @@ -109,4 +114,14 @@ struct Api { self.networkEnvironment = networkEnvironment } } + + struct MMP: ApiHostConfig { + let networkEnvironment: SuperwallOptions.NetworkEnvironment + var host: String { return networkEnvironment.mmpHost } + var path: String { return "/" } + + init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { + self.networkEnvironment = networkEnvironment + } + } } diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 7a4b9b9a49..c10294d8ba 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -423,3 +423,22 @@ extension Endpoint where ) } } + +// MARK: - MMP +extension Endpoint where + Kind == EndpointKinds.SubscriptionsAPI, + Response == MMPMatchResponse { + static func matchMMPInstall(request: MMPMatchRequest) -> Self { + let bodyData = try? JSONEncoder().encode(request) + + return Endpoint( + retryCount: 2, + components: Components( + host: .mmp, + path: "api/match", + bodyData: bodyData + ), + method: .post + ) + } +} diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index bd863ef451..67e3ca9533 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -308,7 +308,7 @@ class Network { } } - func sendToken(_ token: String) async -> [String: Any] { + func sendToken(_ token: String) async -> [String: Any]? { do { let jsonDict = try await urlSession.request( .adServices(token: token), @@ -323,10 +323,35 @@ class Network { info: ["payload": token], error: error ) - return [:] + return nil } } + private func mergeMMPAcquisitionAttributesIfNeeded( + _ acquisitionAttributes: [String: JSON], + identityManager: IdentityManager + ) { + let attributes = convertJSONToDictionary(attribution: acquisitionAttributes) + guard !attributes.isEmpty else { + return + } + + let currentAttributes = identityManager.userAttributes + let hasChanges = attributes.contains { key, value in + guard let currentValue = currentAttributes[key] else { + return true + } + + return JSON(currentValue).rawString([:]) != JSON(value).rawString([:]) + } + + guard hasChanges else { + return + } + + Superwall.shared.setUserAttributes(attributes) + } + func redeemEntitlements(request: RedeemRequest) async throws -> RedeemResponse { return try await urlSession.request( .redeem(request: request), @@ -390,4 +415,126 @@ class Network { throw error } } + + func matchMMPInstall( + idfa: String?, + isTrackingPermissionRetry: Bool = false + ) async -> Bool { + guard + let deviceHelper = factory.deviceHelper, + let identityManager = factory.identityManager + else { + Logger.debug( + logLevel: .warn, + scope: .network, + message: "Skipped: /api/match", + info: ["reason": "Dependencies unavailable"] + ) + return false + } + + let rawMetadata = [ + "preferredLocaleIdentifier": deviceHelper.preferredLocaleIdentifier, + "preferredLanguageCode": deviceHelper.preferredLanguageCode, + "preferredRegionCode": deviceHelper.preferredRegionCode, + "interfaceType": deviceHelper.interfaceType, + "appInstalledAt": deviceHelper.appInstalledAtString, + "radioType": deviceHelper.radioType, + "isLowPowerModeEnabled": deviceHelper.isLowPowerModeEnabled, + "isSandbox": deviceHelper.isSandbox, + "platformWrapper": deviceHelper.platformWrapper, + "platformWrapperVersion": deviceHelper.platformWrapperVersion + ] + + let metadata = rawMetadata.reduce(into: [String: String]()) { result, entry in + guard let value = entry.value, !value.isEmpty else { + return + } + result[entry.key] = value + } + + let request = MMPMatchRequest( + platform: "ios", + appUserId: identityManager.appUserId, + deviceId: deviceHelper.vendorId, + vendorId: deviceHelper.vendorId, + idfa: idfa, + idfv: deviceHelper.vendorId, + appVersion: deviceHelper.appVersion, + sdkVersion: sdkVersion, + osVersion: deviceHelper.osVersion, + deviceModel: deviceHelper.model, + deviceLocale: deviceHelper.localeIdentifier, + deviceLanguageCode: deviceHelper.languageCode, + bundleId: deviceHelper.bundleId, + clientTimestamp: Date().isoString, + metadata: metadata + ) + + do { + let response: MMPMatchResponse = try await urlSession.request( + .matchMMPInstall(request: request), + data: SuperwallRequestData(factory: factory) + ) + + debugPrint("[Superwall] /api/match response:", response) + + Logger.debug( + logLevel: .debug, + scope: .network, + message: "Request Completed: /api/match", + info: [ + "matched": response.matched, + "confidence": response.confidence as Any, + "link_id": response.linkId as Any, + ] + ) + + if let acquisitionAttributes = response.acquisitionAttributes { + mergeMMPAcquisitionAttributesIfNeeded( + acquisitionAttributes, + identityManager: identityManager + ) + } + + await Superwall.shared.track( + InternalSuperwallEvent.AttributionMatch( + info: AttributionMatchInfo( + provider: .mmp, + matched: response.matched, + source: response.acquisitionAttributes?["acquisition_source"]?.string ?? response.network, + confidence: response.confidence, + matchScore: response.matchScore, + reason: response.breakdown?["reason"]?.string, + retryAfterTrackingPermission: isTrackingPermissionRetry + ) + ) + ) + + return true + } catch { + Logger.debug( + logLevel: .error, + scope: .network, + message: "Request Failed: /api/match", + info: ["payload": request], + error: error + ) + + debugPrint("[Superwall] /api/match error:", error) + + await Superwall.shared.track( + InternalSuperwallEvent.AttributionMatch( + info: AttributionMatchInfo( + provider: .mmp, + matched: false, + reason: "request_failed", + retryAfterTrackingPermission: isTrackingPermissionRetry + ) + ) + ) + + return false + } + } } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift index 7bd70bdfd8..676dfc73a3 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift @@ -7,6 +7,11 @@ import Foundation +extension Notification.Name { + static let superwallTrackingPermissionGranted = + Notification.Name("com.superwall.trackingPermissionGranted") +} + extension PermissionHandler { func checkTrackingPermission() -> PermissionStatus { guard #available(iOS 14, macCatalyst 14.0, macOS 11.0, tvOS 14.0, *) else { @@ -45,6 +50,12 @@ extension PermissionHandler { } let status = await proxy.requestTrackingAuthorization() - return status.toTrackingPermissionStatus + let permissionStatus = status.toTrackingPermissionStatus + + if permissionStatus == .granted { + NotificationCenter.default.post(name: .superwallTrackingPermissionGranted, object: nil) + } + + return permissionStatus } } diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 4265176d30..1cd0e313c9 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -57,6 +57,30 @@ enum DidTrackAppInstall: Storable { typealias Value = Bool } +enum DidCompleteMMPInstallAttributionMatch: Storable { + static var key: String { + "store.didCompleteMMPInstallAttributionMatch" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Bool +} + +enum IsEligibleForMMPInstallAttributionMatch: Storable { + static var key: String { + "store.isEligibleForMMPInstallAttributionMatch" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Bool +} + +enum DidCompleteMMPInstallAttributionMatchAfterTrackingPermission: Storable { + static var key: String { + "store.didCompleteMMPInstallAttributionMatchAfterTrackingPermission" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Bool +} + enum DidTrackFirstSeen: Storable { static var key: String { "store.didTrackFirstSeen.v2" diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index 30bbba79c2..bc546522e5 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -8,6 +8,8 @@ import Foundation class Storage { + private static let mmpInstallAttributionWindow: TimeInterval = 7 * 24 * 60 * 60 + /// The interface that manages core data. let coreDataManager: CoreDataManager @@ -209,6 +211,93 @@ class Storage { save(true, forType: DidTrackAppInstall.self) } + func recordMMPInstallAttributionMatch( + matchInstall: @escaping () async -> Bool + ) { + let didCompleteAttributionMatch = get(DidCompleteMMPInstallAttributionMatch.self) ?? false + if didCompleteAttributionMatch { + return + } + + Task { [weak self] in + let didCompleteMatch = await matchInstall() + guard didCompleteMatch else { + return + } + + self?.save(true, forType: DidCompleteMMPInstallAttributionMatch.self) + } + } + + func hasTrackedAppInstall() -> Bool { + get(DidTrackAppInstall.self) ?? false + } + + func shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: Bool, + appInstalledAtString: String + ) -> Bool { + if hadTrackedAppInstallBeforeConfigure { + return false + } + + guard isMMPInstallAttributionWindowOpen(appInstalledAtString: appInstalledAtString) else { + return false + } + + save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + return true + } + + func shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: String + ) -> Bool { + let isEligible = get(IsEligibleForMMPInstallAttributionMatch.self) ?? false + guard isEligible else { + return false + } + + guard isMMPInstallAttributionWindowOpen(appInstalledAtString: appInstalledAtString) else { + return false + } + + let didCompleteTrackingPermissionMatch = + get(DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self) ?? false + return !didCompleteTrackingPermissionMatch + } + + func recordTrackingPermissionMMPInstallAttributionMatch( + matchInstall: @escaping () async -> Bool + ) { + let didCompleteTrackingPermissionMatch = + get(DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self) ?? false + if didCompleteTrackingPermissionMatch { + return + } + + Task { [weak self] in + let didCompleteMatch = await matchInstall() + guard didCompleteMatch else { + return + } + + self?.save(true, forType: DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self) + } + } + + private func isMMPInstallAttributionWindowOpen(appInstalledAtString: String) -> Bool { + guard !appInstalledAtString.isEmpty else { + return true + } + + let formatter = ISO8601DateFormatter() + guard let appInstallDate = formatter.date(from: appInstalledAtString) else { + return true + } + + return Date().timeIntervalSince(appInstallDate) <= Self.mmpInstallAttributionWindow + } + func clearCachedSessionEvents() { cache.delete(Transactions.self) } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index b7899d89ba..6f60f4f6bd 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -446,12 +446,27 @@ public final class Superwall: NSObject, ObservableObject { dependencyContainer.storage.configure(apiKey: apiKey) + let hadTrackedAppInstallBeforeConfigure = dependencyContainer.storage.hasTrackedAppInstall() dependencyContainer.storage.recordAppInstall(trackPlacement: track) async let fetchConfig: () = await dependencyContainer.configManager.fetchConfiguration() async let configureIdentity: () = await dependencyContainer.identityManager.configure() - _ = await (fetchConfig, configureIdentity) + _ = await configureIdentity + + if dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure, + appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString + ) { + dependencyContainer.storage.recordMMPInstallAttributionMatch { + await dependencyContainer.network.matchMMPInstall( + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, + isTrackingPermissionRetry: false + ) + } + } + + _ = await fetchConfig await track( InternalSuperwallEvent.ConfigAttributes( @@ -472,6 +487,59 @@ public final class Superwall: NSObject, ObservableObject { listenToConfig() listenToSubscriptionStatus() listenToCustomerInfo() + listenToTrackingPermissionGranted() + listenToApplicationDidBecomeActiveForTrackingPermission() + } + + private func listenToTrackingPermissionGranted() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTrackingPermissionGranted), + name: .superwallTrackingPermissionGranted, + object: nil + ) + } + + private func listenToApplicationDidBecomeActiveForTrackingPermission() { + guard let notificationName = SystemInfo.applicationDidBecomeActiveNotification else { + return + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleApplicationDidBecomeActiveForTrackingPermission), + name: notificationName, + object: nil + ) + } + + @objc + private func handleTrackingPermissionGranted() { + retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded() + } + + @objc + private func handleApplicationDidBecomeActiveForTrackingPermission() { + guard dependencyContainer.permissionHandler.checkTrackingPermission() == .granted else { + return + } + + retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded() + } + + private func retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded() { + guard dependencyContainer.storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString + ) else { + return + } + + dependencyContainer.storage.recordTrackingPermissionMMPInstallAttributionMatch { + await self.dependencyContainer.network.matchMMPInstall( + idfa: self.dependencyContainer.attributionFetcher.identifierForAdvertisers, + isTrackingPermissionRetry: true + ) + } } private func listenToConfig() { diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 824ebdc635..cd0272c647 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.14.2" + s.version = "4.15.0" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" From a96633b6a569e8db622e4eb14f400fdce7c3e71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:37:17 +0100 Subject: [PATCH 02/12] update packages --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8dcbeeb950..fd93809f9d 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "155ea739f45f54189ca83ee9088b373c1415d98b", - "version": "5.64.0" + "revision": "abb0d68c3e7ba97b16ab51c38fcaca16b0e358c8", + "version": "5.66.0" } }, { From 4ab306dded727270dbd99dadf5c505d2c980f3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:07:35 +0100 Subject: [PATCH 03/12] refine attribution match event payload --- .../TrackableSuperwallEvent.swift | 5 +- .../Superwall Placement/SuperwallEvent.swift | 46 +++++++++++-------- .../Models/AdServicesResponse.swift | 2 +- Sources/SuperwallKit/Network/Network.swift | 11 ++--- Sources/SuperwallKit/Superwall.swift | 6 +-- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 392550400b..aafbeaf4f4 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -127,7 +127,7 @@ enum InternalSuperwallEvent { parameters["source"] = source } if let confidence = info.confidence { - parameters["confidence"] = confidence + parameters["confidence"] = confidence.rawValue } if let matchScore = info.matchScore { parameters["match_score"] = matchScore @@ -135,9 +135,6 @@ enum InternalSuperwallEvent { if let reason = info.reason { parameters["reason"] = reason } - if let retryAfterTrackingPermission = info.retryAfterTrackingPermission { - parameters["retry_after_tracking_permission"] = retryAfterTrackingPermission - } return parameters } diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 3db80750f3..22f18f7aa7 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -19,50 +19,61 @@ public struct AttributionMatchInfo: Sendable { public enum Provider: String, Sendable { /// Superwall's mobile measurement matching flow. case mmp - + /// Apple Search Ads attribution. case appleSearchAds = "apple_search_ads" } - /// The provider that produced the attribution result. + /// The confidence level of the attribution result. + public enum Confidence: String, Decodable, Sendable { + /// A high-confidence attribution result. + case high + + /// A medium-confidence attribution result. + case medium + + /// A low-confidence attribution result. + case low + } + + /// The attribution provider that produced the result. public let provider: Provider /// Whether the attribution attempt resulted in a match. public let matched: Bool /// The resolved acquisition source, if one was found. + /// + /// For example, `meta` or `apple_search_ads`. public let source: String? /// The confidence label returned by the provider, if available. - public let confidence: String? + public let confidence: Confidence? - /// The numeric match score returned by the provider, if available. + /// The numeric match score between 0 and 100 returned by the provider, if available. public let matchScore: Double? /// The reason for a non-match or failure, if available. + /// + /// For example, `below_threshold`, `no_attribution`, or `request_failed`. public let reason: String? - /// Whether this attribution attempt was a retry after tracking permission was granted. - public let retryAfterTrackingPermission: Bool? - /// Creates a new install attribution result. /// /// - Parameters: - /// - provider: The attribution provider that produced the result. - /// - matched: Whether the attribution attempt matched. - /// - source: The resolved acquisition source, if one was found. - /// - confidence: The provider's confidence label, if available. - /// - matchScore: The provider's numeric match score, if available. - /// - reason: The reason for a non-match or failure, if available. - /// - retryAfterTrackingPermission: Whether the attempt happened after tracking permission was granted. + /// - provider: The attribution provider that produced the result. + /// - matched: Whether the attribution attempt matched. + /// - source: The resolved acquisition source, if one was found. + /// - confidence: The provider's confidence label, if available. + /// - matchScore: The provider's numeric match score, if available. + /// - reason: The reason for a non-match or failure, if available. public init( provider: Provider, matched: Bool, source: String? = nil, - confidence: String? = nil, + confidence: Confidence? = nil, matchScore: Double? = nil, - reason: String? = nil, - retryAfterTrackingPermission: Bool? = nil + reason: String? = nil ) { self.provider = provider self.matched = matched @@ -70,7 +81,6 @@ public struct AttributionMatchInfo: Sendable { self.confidence = confidence self.matchScore = matchScore self.reason = reason - self.retryAfterTrackingPermission = retryAfterTrackingPermission } } diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index 44d8eb5321..0542360d18 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -33,7 +33,7 @@ struct MMPMatchRequest: Encodable { struct MMPMatchResponse: Decodable { let matched: Bool - let confidence: String? + let confidence: AttributionMatchInfo.Confidence? let matchScore: Double? let clickId: Int? let linkId: String? diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 67e3ca9533..1288354f7d 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -416,10 +416,7 @@ class Network { } } - func matchMMPInstall( - idfa: String?, - isTrackingPermissionRetry: Bool = false - ) async -> Bool { + func matchMMPInstall(idfa: String?) async -> Bool { guard let deviceHelper = factory.deviceHelper, let identityManager = factory.identityManager @@ -505,8 +502,7 @@ class Network { source: response.acquisitionAttributes?["acquisition_source"]?.string ?? response.network, confidence: response.confidence, matchScore: response.matchScore, - reason: response.breakdown?["reason"]?.string, - retryAfterTrackingPermission: isTrackingPermissionRetry + reason: response.breakdown?["reason"]?.string ) ) ) @@ -528,8 +524,7 @@ class Network { info: AttributionMatchInfo( provider: .mmp, matched: false, - reason: "request_failed", - retryAfterTrackingPermission: isTrackingPermissionRetry + reason: "request_failed" ) ) ) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6f60f4f6bd..05fd6b8ae4 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -460,8 +460,7 @@ public final class Superwall: NSObject, ObservableObject { ) { dependencyContainer.storage.recordMMPInstallAttributionMatch { await dependencyContainer.network.matchMMPInstall( - idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, - isTrackingPermissionRetry: false + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers ) } } @@ -536,8 +535,7 @@ public final class Superwall: NSObject, ObservableObject { dependencyContainer.storage.recordTrackingPermissionMMPInstallAttributionMatch { await self.dependencyContainer.network.matchMMPInstall( - idfa: self.dependencyContainer.attributionFetcher.identifierForAdvertisers, - isTrackingPermissionRetry: true + idfa: self.dependencyContainer.attributionFetcher.identifierForAdvertisers ) } } From 3804ceb992ba708dda0278bcaffe0eabd04cfa8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:42:27 +0100 Subject: [PATCH 04/12] remove match api debug prints --- Sources/SuperwallKit/Network/Network.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 1288354f7d..b77bf2a8cb 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -474,8 +474,6 @@ class Network { data: SuperwallRequestData(factory: factory) ) - debugPrint("[Superwall] /api/match response:", response) - Logger.debug( logLevel: .debug, scope: .network, @@ -517,8 +515,6 @@ class Network { error: error ) - debugPrint("[Superwall] /api/match error:", error) - await Superwall.shared.track( InternalSuperwallEvent.AttributionMatch( info: AttributionMatchInfo( From 4e710b5b7dbf3f4f9fca420c1538f9be0f95c523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:53:45 +0100 Subject: [PATCH 05/12] use superwall device id for mmp install matching --- Sources/SuperwallKit/Dependencies/FactoryProtocols.swift | 2 ++ Sources/SuperwallKit/Network/Network.swift | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 9f8aee4647..a54879f6fe 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -121,6 +121,8 @@ protocol ApiFactory: AnyObject { requestId: String ) async -> [String: String] + func makeDeviceId() -> String + func makeDefaultComponents( host: EndpointHost ) -> ApiHostConfig diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index b77bf2a8cb..4caf51d56b 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -450,13 +450,15 @@ class Network { result[entry.key] = value } + let vendorId = deviceHelper.vendorId + let request = MMPMatchRequest( platform: "ios", appUserId: identityManager.appUserId, - deviceId: deviceHelper.vendorId, - vendorId: deviceHelper.vendorId, + deviceId: factory.makeDeviceId(), + vendorId: vendorId, idfa: idfa, - idfv: deviceHelper.vendorId, + idfv: vendorId, appVersion: deviceHelper.appVersion, sdkVersion: sdkVersion, osVersion: deviceHelper.osVersion, From d315f2935c92b416749ebf90c6c64570825e8fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:03:41 +0100 Subject: [PATCH 06/12] prevent duplicate tracking permission match retries --- Sources/SuperwallKit/Storage/Storage.swift | 19 ------- Sources/SuperwallKit/Superwall.swift | 59 +++++++++++++++++++--- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index bc546522e5..f3b891c2bd 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -266,25 +266,6 @@ class Storage { return !didCompleteTrackingPermissionMatch } - func recordTrackingPermissionMMPInstallAttributionMatch( - matchInstall: @escaping () async -> Bool - ) { - let didCompleteTrackingPermissionMatch = - get(DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self) ?? false - if didCompleteTrackingPermissionMatch { - return - } - - Task { [weak self] in - let didCompleteMatch = await matchInstall() - guard didCompleteMatch else { - return - } - - self?.save(true, forType: DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self) - } - } - private func isMMPInstallAttributionWindowOpen(appInstalledAtString: String) -> Bool { guard !appInstalledAtString.isEmpty else { return true diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 05fd6b8ae4..0945e13ead 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -4,6 +4,29 @@ import Combine import Foundation import StoreKit +private actor TrackingPermissionMMPRetryGate { + private enum State { + case idle + case inFlight + case completed + } + + private var state: State = .idle + + func tryBegin() -> Bool { + guard case .idle = state else { + return false + } + + state = .inFlight + return true + } + + func finish(didComplete: Bool) { + state = didComplete ? .completed : .idle + } +} + /// The primary class for integrating Superwall into your application. After configuring via /// ``configure(apiKey:purchaseController:options:completion:)-52tke``, it provides access to /// all its features via instance functions and variables. @@ -29,6 +52,7 @@ public final class Superwall: NSObject, ObservableObject { /// A `Task` that is associated with purchasing. This is used to prevent multiple purchases /// from occurring. private var purchaseTask: Task? + private let trackingPermissionMMPRetryGate = TrackingPermissionMMPRetryGate() /// The Objective-C delegate that handles Superwall lifecycle events. @available(swift, obsoleted: 1.0) @@ -527,16 +551,35 @@ public final class Superwall: NSObject, ObservableObject { } private func retryMMPInstallAttributionMatchAfterTrackingPermissionIfNeeded() { - guard dependencyContainer.storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( - appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString - ) else { - return - } + Task { [weak self] in + guard let self else { + return + } + + guard await trackingPermissionMMPRetryGate.tryBegin() else { + return + } + + let appInstalledAtString = dependencyContainer.deviceHelper.appInstalledAtString + guard dependencyContainer.storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: appInstalledAtString + ) else { + await trackingPermissionMMPRetryGate.finish(didComplete: false) + return + } - dependencyContainer.storage.recordTrackingPermissionMMPInstallAttributionMatch { - await self.dependencyContainer.network.matchMMPInstall( - idfa: self.dependencyContainer.attributionFetcher.identifierForAdvertisers + let didCompleteMatch = await dependencyContainer.network.matchMMPInstall( + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers ) + + if didCompleteMatch { + dependencyContainer.storage.save( + true, + forType: DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self + ) + } + + await trackingPermissionMMPRetryGate.finish(didComplete: didCompleteMatch) } } From da279bb8ff315a5d0cc323ef10128d9a32ea42b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:15:28 +0100 Subject: [PATCH 07/12] refine attribution matching request handling --- .../SuperwallKit/Models/AdServicesResponse.swift | 2 ++ Sources/SuperwallKit/Network/Endpoint.swift | 2 +- Sources/SuperwallKit/Network/Network.swift | 8 +++++++- Sources/SuperwallKit/Superwall.swift | 13 +++++++++++-- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index 0542360d18..2638a7b231 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -20,6 +20,8 @@ struct MMPMatchRequest: Encodable { let vendorId: String? let idfa: String? let idfv: String? + let advertiserTrackingEnabled: Bool + let applicationTrackingEnabled: Bool let appVersion: String let sdkVersion: String let osVersion: String diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index c10294d8ba..ca3571e86a 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -298,7 +298,7 @@ extension Endpoint where method: .post ) } -} +This is } // MARK: - Ad Services diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 4caf51d56b..0b9b919a09 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -416,7 +416,11 @@ class Network { } } - func matchMMPInstall(idfa: String?) async -> Bool { + func matchMMPInstall( + idfa: String?, + advertiserTrackingEnabled: Bool, + applicationTrackingEnabled: Bool + ) async -> Bool { guard let deviceHelper = factory.deviceHelper, let identityManager = factory.identityManager @@ -459,6 +463,8 @@ class Network { vendorId: vendorId, idfa: idfa, idfv: vendorId, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: applicationTrackingEnabled, appVersion: deviceHelper.appVersion, sdkVersion: sdkVersion, osVersion: deviceHelper.osVersion, diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 0945e13ead..ada35d2717 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -482,9 +482,14 @@ public final class Superwall: NSObject, ObservableObject { hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure, appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString ) { + let advertiserTrackingEnabled = + dependencyContainer.permissionHandler.checkTrackingPermission() == .granted + dependencyContainer.storage.recordMMPInstallAttributionMatch { await dependencyContainer.network.matchMMPInstall( - idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: true ) } } @@ -568,8 +573,12 @@ public final class Superwall: NSObject, ObservableObject { return } + let advertiserTrackingEnabled = + dependencyContainer.permissionHandler.checkTrackingPermission() == .granted let didCompleteMatch = await dependencyContainer.network.matchMMPInstall( - idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: true ) if didCompleteMatch { From e1ca6669c58bfa49fe5dce294882f5a3cb3676a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:23:14 +0100 Subject: [PATCH 08/12] fix attribution retry and objc event stability --- .../Analytics/Superwall Placement/SuperwallEventObjc.swift | 6 +++--- Sources/SuperwallKit/Network/Endpoint.swift | 2 +- Sources/SuperwallKit/Storage/Storage.swift | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index 109e94ee08..fd57b6c413 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -90,9 +90,6 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When the user attributes are set. case userAttributes - /// When install attribution is resolved or fails to resolve. - case attributionMatch - /// When the user purchased a non recurring product. case nonRecurringProductPurchase @@ -263,6 +260,9 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When a user navigates to a page in a multi-page paywall. case paywallPageView + /// When install attribution is resolved or fails to resolve. + case attributionMatch + public init(event: SuperwallEvent) { self = event.backingData.objcEvent } diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index ca3571e86a..c10294d8ba 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -298,7 +298,7 @@ extension Endpoint where method: .post ) } -This is } +} // MARK: - Ad Services diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index f3b891c2bd..c2f6ac4820 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -237,7 +237,8 @@ class Storage { hadTrackedAppInstallBeforeConfigure: Bool, appInstalledAtString: String ) -> Bool { - if hadTrackedAppInstallBeforeConfigure { + let didCompleteMatch = get(DidCompleteMMPInstallAttributionMatch.self) ?? false + if hadTrackedAppInstallBeforeConfigure && didCompleteMatch { return false } From 29568f2c863ed4422408c129a1791809306a9859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:43:59 +0100 Subject: [PATCH 09/12] avoid false attribution attribute updates --- Sources/SuperwallKit/Network/Network.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 0b9b919a09..5cce55e04e 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -342,7 +342,7 @@ class Network { return true } - return JSON(currentValue).rawString([:]) != JSON(value).rawString([:]) + return String(describing: currentValue) != String(describing: value) } guard hasChanges else { From 52065c936bfcd46e9237e3823fcb3c25549f93ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:03:09 +0100 Subject: [PATCH 10/12] clarify mmp attribution request completion --- Sources/SuperwallKit/Network/Network.swift | 1 + .../Storage/Cache/CacheKeys.swift | 6 +++-- Sources/SuperwallKit/Storage/Storage.swift | 22 +++++++++---------- Sources/SuperwallKit/Superwall.swift | 8 +++---- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 5cce55e04e..7006448166 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -513,6 +513,7 @@ class Network { ) ) + // A successful response means the request was processed, even if no attribution match was found. return true } catch { Logger.debug( diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 1cd0e313c9..02d9c8bb20 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -57,8 +57,9 @@ enum DidTrackAppInstall: Storable { typealias Value = Bool } -enum DidCompleteMMPInstallAttributionMatch: Storable { +enum DidCompleteMMPInstallAttributionRequest: Storable { static var key: String { + // Preserve the existing cache key so upgrades don't re-run install attribution. "store.didCompleteMMPInstallAttributionMatch" } static var directory: SearchPathDirectory = .appSpecificDocuments @@ -73,8 +74,9 @@ enum IsEligibleForMMPInstallAttributionMatch: Storable { typealias Value = Bool } -enum DidCompleteMMPInstallAttributionMatchAfterTrackingPermission: Storable { +enum DidCompleteMMPInstallAttributionRequestAfterTrackingPermission: Storable { static var key: String { + // Preserve the existing cache key so upgrades don't re-run the ATT retry path. "store.didCompleteMMPInstallAttributionMatchAfterTrackingPermission" } static var directory: SearchPathDirectory = .appSpecificDocuments diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index c2f6ac4820..2574e26856 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -214,18 +214,18 @@ class Storage { func recordMMPInstallAttributionMatch( matchInstall: @escaping () async -> Bool ) { - let didCompleteAttributionMatch = get(DidCompleteMMPInstallAttributionMatch.self) ?? false - if didCompleteAttributionMatch { + let didCompleteAttributionRequest = get(DidCompleteMMPInstallAttributionRequest.self) ?? false + if didCompleteAttributionRequest { return } Task { [weak self] in - let didCompleteMatch = await matchInstall() - guard didCompleteMatch else { + let didCompleteRequest = await matchInstall() + guard didCompleteRequest else { return } - self?.save(true, forType: DidCompleteMMPInstallAttributionMatch.self) + self?.save(true, forType: DidCompleteMMPInstallAttributionRequest.self) } } @@ -234,11 +234,11 @@ class Storage { } func shouldAttemptInitialMMPInstallAttributionMatch( - hadTrackedAppInstallBeforeConfigure: Bool, + hadTrackedAppInstallBeforeConfigure _: Bool, appInstalledAtString: String ) -> Bool { - let didCompleteMatch = get(DidCompleteMMPInstallAttributionMatch.self) ?? false - if hadTrackedAppInstallBeforeConfigure && didCompleteMatch { + let didCompleteRequest = get(DidCompleteMMPInstallAttributionRequest.self) ?? false + if didCompleteRequest { return false } @@ -262,9 +262,9 @@ class Storage { return false } - let didCompleteTrackingPermissionMatch = - get(DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self) ?? false - return !didCompleteTrackingPermissionMatch + let didCompleteTrackingPermissionRequest = + get(DidCompleteMMPInstallAttributionRequestAfterTrackingPermission.self) ?? false + return !didCompleteTrackingPermissionRequest } private func isMMPInstallAttributionWindowOpen(appInstalledAtString: String) -> Bool { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index ada35d2717..c8fed7ce49 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -575,20 +575,20 @@ public final class Superwall: NSObject, ObservableObject { let advertiserTrackingEnabled = dependencyContainer.permissionHandler.checkTrackingPermission() == .granted - let didCompleteMatch = await dependencyContainer.network.matchMMPInstall( + let didCompleteRequest = await dependencyContainer.network.matchMMPInstall( idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, advertiserTrackingEnabled: advertiserTrackingEnabled, applicationTrackingEnabled: true ) - if didCompleteMatch { + if didCompleteRequest { dependencyContainer.storage.save( true, - forType: DidCompleteMMPInstallAttributionMatchAfterTrackingPermission.self + forType: DidCompleteMMPInstallAttributionRequestAfterTrackingPermission.self ) } - await trackingPermissionMMPRetryGate.finish(didComplete: didCompleteMatch) + await trackingPermissionMMPRetryGate.finish(didComplete: didCompleteRequest) } } From a8df631a8db94d94d754552cfdb1cababf4e5e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:10:07 +0100 Subject: [PATCH 11/12] send additional mmp fingerprint signals --- .../SuperwallKit/Models/AdServicesResponse.swift | 4 ++++ .../Network/Device Helper/DeviceHelper.swift | 16 ++++++++++++++++ Sources/SuperwallKit/Network/Network.swift | 4 ++++ 3 files changed, 24 insertions(+) diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index 2638a7b231..0c57f55e75 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -28,6 +28,10 @@ struct MMPMatchRequest: Encodable { let deviceModel: String let deviceLocale: String let deviceLanguageCode: String + let timezoneOffsetSeconds: Int + let screenWidth: Int + let screenHeight: Int + let devicePixelRatio: Double let bundleId: String let clientTimestamp: String let metadata: [String: String] diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index f5512bc875..db973f96a9 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -118,6 +118,22 @@ class DeviceHelper { "\(Int(TimeZone.current.secondsFromGMT()))" } + var timezoneOffsetSeconds: Int { + TimeZone.current.secondsFromGMT() + } + + var screenWidth: Int { + Int(UIScreen.main.bounds.width.rounded()) + } + + var screenHeight: Int { + Int(UIScreen.main.bounds.height.rounded()) + } + + var devicePixelRatio: Double { + Double(UIScreen.main.scale) + } + var isFirstAppOpen: Bool { return !storage.didTrackFirstSession } diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 7006448166..4bc2e48c52 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -471,6 +471,10 @@ class Network { deviceModel: deviceHelper.model, deviceLocale: deviceHelper.localeIdentifier, deviceLanguageCode: deviceHelper.languageCode, + timezoneOffsetSeconds: deviceHelper.timezoneOffsetSeconds, + screenWidth: deviceHelper.screenWidth, + screenHeight: deviceHelper.screenHeight, + devicePixelRatio: deviceHelper.devicePixelRatio, bundleId: deviceHelper.bundleId, clientTimestamp: Date().isoString, metadata: metadata From 73f514a38b988d277425fa33e6b4ec51bdc7e22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:14:55 +0100 Subject: [PATCH 12/12] Bugfix for sdk upgraders --- Sources/SuperwallKit/Storage/Storage.swift | 17 +++++++++++++++-- .../Receipt Manager/ReceiptManager.swift | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index 2574e26856..6db7c2d635 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -234,7 +234,7 @@ class Storage { } func shouldAttemptInitialMMPInstallAttributionMatch( - hadTrackedAppInstallBeforeConfigure _: Bool, + hadTrackedAppInstallBeforeConfigure: Bool, appInstalledAtString: String ) -> Bool { let didCompleteRequest = get(DidCompleteMMPInstallAttributionRequest.self) ?? false @@ -242,6 +242,11 @@ class Storage { return false } + let isEligible = get(IsEligibleForMMPInstallAttributionMatch.self) ?? false + if hadTrackedAppInstallBeforeConfigure && !isEligible { + return false + } + guard isMMPInstallAttributionWindowOpen(appInstalledAtString: appInstalledAtString) else { return false } @@ -272,8 +277,16 @@ class Storage { return true } + let formatterWithFractionalSeconds = ISO8601DateFormatter() + formatterWithFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let formatter = ISO8601DateFormatter() - guard let appInstallDate = formatter.date(from: appInstalledAtString) else { + + guard + let appInstallDate = + formatterWithFractionalSeconds.date(from: appInstalledAtString) + ?? formatter.date(from: appInstalledAtString) + else { return true } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index ef82f876e6..509723a06f 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -112,7 +112,7 @@ actor ReceiptManager { } // Don't register if app transaction ID is nil - guard Self.appTransactionId != nil else { + if Self.appTransactionId == nil { return }