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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 } From 066af5d88ddfeb9858aacefecb8085582c0431f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:13:00 +0200 Subject: [PATCH 13/17] Address review feedback: visionOS guards, MMP docs, per-kind JSON encoder - DeviceHelper: guard screenWidth/screenHeight/devicePixelRatio behind #if os(visionOS) (UIScreen.main is meaningless there), matching the existing interfaceStyle pattern. - SK2StoreProduct / ProductPurchaserSK2: add visionOS 26.4 to the billing-plan availability checks so the 26.4-only StoreKit APIs aren't used under a guard that only covered iOS, fixing the visionOS build. - Storage: document that the post-ATT MMP match deliberately re-runs to upgrade the pre-ATT (no-IDFA) match with the real IDFA, not a bug. - EndpointKind: add a per-kind jsonEncoder mirroring jsonDecoder; route all Endpoint bodies through Kind.jsonEncoder so casing follows the backend (core=snake_case, SubscriptionsAPI=camelCase) instead of being hand-picked per call site. Behaviour-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Network/Device Helper/DeviceHelper.swift | 18 ++++++++++++--- Sources/SuperwallKit/Network/Endpoint.swift | 22 +++++++++---------- .../SuperwallKit/Network/EndpointKind.swift | 3 +++ Sources/SuperwallKit/Storage/Storage.swift | 10 +++++++++ .../StoreProduct/SK2StoreProduct.swift | 4 ++-- .../StoreKit 2/ProductPurchaserSK2.swift | 2 +- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index db973f96a9..5429bd968f 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -123,15 +123,27 @@ class DeviceHelper { } var screenWidth: Int { - Int(UIScreen.main.bounds.width.rounded()) + #if os(visionOS) + return 0 + #else + return Int(UIScreen.main.bounds.width.rounded()) + #endif } var screenHeight: Int { - Int(UIScreen.main.bounds.height.rounded()) + #if os(visionOS) + return 0 + #else + return Int(UIScreen.main.bounds.height.rounded()) + #endif } var devicePixelRatio: Double { - Double(UIScreen.main.scale) + #if os(visionOS) + return 1.0 + #else + return Double(UIScreen.main.scale) + #endif } var isFirstAppOpen: Bool { diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index c10294d8ba..bf4c600c93 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -91,7 +91,7 @@ extension Endpoint where Kind == EndpointKinds.Superwall, Response == EventsResponse { static func events(eventsRequest: EventsRequest) -> Self { - let bodyData = try? JSONEncoder.toSnakeCase.encode(eventsRequest) + let bodyData = try? Kind.jsonEncoder.encode(eventsRequest) return Endpoint( components: Components( @@ -104,7 +104,7 @@ extension Endpoint where } static func sessionEvents(_ session: SessionEventsRequest) -> Self { - let bodyData = try? JSONEncoder.toSnakeCase.encode(session) + let bodyData = try? Kind.jsonEncoder.encode(session) return Endpoint( components: Components( @@ -142,10 +142,10 @@ extension Endpoint where ) } else if let placement = placement { let bodyDict = ["event": placement.jsonData] - bodyData = try? JSONEncoder.toSnakeCase.encode(bodyDict) + bodyData = try? Kind.jsonEncoder.encode(bodyDict) } else if let appUserId = appUserId { let body = PaywallRequestBody(appUserId: appUserId) - bodyData = try? JSONEncoder.toSnakeCase.encode(body) + bodyData = try? Kind.jsonEncoder.encode(body) } return Endpoint( @@ -263,7 +263,7 @@ extension Endpoint where static func confirmAssignments( _ assignments: PostbackAssignmentWrapper ) -> Self { - let bodyData = try? JSONEncoder.toSnakeCase.encode(assignments) + let bodyData = try? Kind.jsonEncoder.encode(assignments) return Endpoint( components: Components( @@ -285,7 +285,7 @@ extension Endpoint where maxRetry: Int, timeout: Seconds? ) -> Self { - let bodyData = try? JSONEncoder.toSnakeCase.encode(request) + let bodyData = try? Kind.jsonEncoder.encode(request) return Endpoint( retryCount: maxRetry, @@ -307,7 +307,7 @@ extension Endpoint where Response == AdServicesResponse { static func adServices(token: String) -> Self { let body = ["token": token] - let bodyData = try? JSONEncoder.toSnakeCase.encode(body) + let bodyData = try? Kind.jsonEncoder.encode(body) return Endpoint( retryCount: 3, @@ -342,7 +342,7 @@ extension Endpoint where allowIntroductoryOffer: allowIntroductoryOffer, products: products ) - let bodyData = try? JSONEncoder().encode(body) + let bodyData = try? Kind.jsonEncoder.encode(body) return Endpoint( components: Components( @@ -361,7 +361,7 @@ extension Endpoint where Kind == EndpointKinds.SubscriptionsAPI, Response == RedeemResponse { static func redeem(request: RedeemRequest) -> Self { - let bodyData = try? JSONEncoder().encode(request) + let bodyData = try? Kind.jsonEncoder.encode(request) return Endpoint( components: Components( @@ -374,7 +374,7 @@ extension Endpoint where } static func pollRedemptionResult(request: PollRedemptionResultRequest) -> Self { - let bodyData = try? JSONEncoder().encode(request) + let bodyData = try? Kind.jsonEncoder.encode(request) return Endpoint( components: Components( @@ -429,7 +429,7 @@ extension Endpoint where Kind == EndpointKinds.SubscriptionsAPI, Response == MMPMatchResponse { static func matchMMPInstall(request: MMPMatchRequest) -> Self { - let bodyData = try? JSONEncoder().encode(request) + let bodyData = try? Kind.jsonEncoder.encode(request) return Endpoint( retryCount: 2, diff --git a/Sources/SuperwallKit/Network/EndpointKind.swift b/Sources/SuperwallKit/Network/EndpointKind.swift index 7e3635ce08..4c7b7f9243 100644 --- a/Sources/SuperwallKit/Network/EndpointKind.swift +++ b/Sources/SuperwallKit/Network/EndpointKind.swift @@ -10,6 +10,7 @@ import Foundation protocol EndpointKind { associatedtype RequestData static var jsonDecoder: JSONDecoder { get } + static var jsonEncoder: JSONEncoder { get } static func prepare( _ request: inout URLRequest, with data: RequestData @@ -39,6 +40,7 @@ struct SuperwallRequestData { enum EndpointKinds { enum Superwall: EndpointKind { static var jsonDecoder = JSONDecoder.fromSnakeCase + static var jsonEncoder = JSONEncoder.toSnakeCase static func prepare( _ request: inout URLRequest, @@ -61,6 +63,7 @@ enum EndpointKinds { enum SubscriptionsAPI: EndpointKind { static var jsonDecoder = JSONDecoder.subscriptionsApi + static var jsonEncoder = JSONEncoder() static func prepare( _ request: inout URLRequest, diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index 1190a769cc..d2c1ee5f71 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -256,6 +256,16 @@ class Storage { return true } + /// Whether to re-run the MMP install match after the user grants tracking + /// permission. + /// + /// This intentionally does NOT check `DidCompleteMMPInstallAttributionRequest`. + /// The initial match runs before ATT is granted, so it has no real IDFA (the + /// all-zeros sentinel is filtered out). Granting permission yields a real + /// IDFA, so we re-match to upgrade the earlier probabilistic result into a + /// deterministic one — even when the initial match already succeeded. It + /// fires at most once, gated by + /// `DidCompleteMMPInstallAttributionRequestAfterTrackingPermission`. func shouldAttemptTrackingPermissionMMPInstallAttributionMatch( appInstalledAtString: String ) -> Bool { diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 91e7a61da4..d8803678ab 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -44,7 +44,7 @@ struct SK2StoreProduct: StoreProductType { self.billingPlanType = billingPlanType #if compiler(>=6.3) - if #available(iOS 26.4, *), + if #available(iOS 26.4, visionOS 26.4, *), let term = Self.findPricingTerm(for: billingPlanType, in: sk2Product) { // Use the commitment *period* (= year for an annual MONTHLY product) // so the paywall reads as the underlying product (not its billing @@ -184,7 +184,7 @@ struct SK2StoreProduct: StoreProductType { } #if compiler(>=6.3) - @available(iOS 26.4, *) + @available(iOS 26.4, visionOS 26.4, *) private static func findPricingTerm( for billingPlanType: AppStoreProduct.BillingPlanType?, in sk2Product: SK2Product diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift index 94e4ffee01..2771bcd74c 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift @@ -105,7 +105,7 @@ final class ProductPurchaserSK2: Purchasing { // Apply the configured Apple billing plan (iOS 26+). If the runtime is // older or no plan is configured, the purchase proceeds with Apple's // default plan. - if #available(iOS 26.4, *), let plan = product.billingPlanType { + if #available(iOS 26.4, visionOS 26.4, *), let plan = product.billingPlanType { let sk2Plan: StoreKit.Product.SubscriptionInfo.BillingPlanType switch plan { case .upFront: sk2Plan = .upFront From 090464a875b227b63729d23bd0ebc744cdb32b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:04:59 +0200 Subject: [PATCH 14/17] Cache screen metrics at init instead of reading UIScreen.main off-main screenWidth/screenHeight/devicePixelRatio were computed via UIScreen.main (deprecated since iOS 16, main-thread-only) but read from the background async matchMMPInstall, a data race under strict concurrency. Cache them once at init on the main thread, preferring the connected UIWindowScene's screen and falling back to UIScreen.main only when no scene is attached. Safe to cache: the values feed only the MMP install payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Network/Device Helper/DeviceHelper.swift | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 5429bd968f..ec3e453d40 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -122,27 +122,42 @@ class DeviceHelper { TimeZone.current.secondsFromGMT() } - var screenWidth: Int { + /// Screen metrics, cached once at init on the main thread. + /// + /// `UIScreen` / `UIWindowScene` are main-thread-only (and `UIScreen.main` + /// is deprecated), but these are read from background contexts such as + /// `matchMMPInstall`. Reading once up front, on the main thread, keeps those + /// reads safe instead of touching main-thread-only UIKit off-thread. + let screenWidth: Int + let screenHeight: Int + let devicePixelRatio: Double + + private struct ScreenMetrics { + let width: Int + let height: Int + let scale: Double + } + + private static func makeScreenMetrics() -> ScreenMetrics { #if os(visionOS) - return 0 + return ScreenMetrics(width: 0, height: 0, scale: 1.0) #else - return Int(UIScreen.main.bounds.width.rounded()) - #endif - } - - var screenHeight: Int { - #if os(visionOS) - return 0 - #else - return Int(UIScreen.main.bounds.height.rounded()) - #endif - } - - var devicePixelRatio: Double { - #if os(visionOS) - return 1.0 - #else - return Double(UIScreen.main.scale) + let read = { () -> ScreenMetrics in + // Prefer the connected window scene's screen; fall back to the + // deprecated `UIScreen.main` only when no scene is attached yet. + let screen = UIApplication.sharedApplication? + .connectedScenes + .compactMap { $0 as? UIWindowScene } + .first? + .screen ?? UIScreen.main + let bounds = screen.bounds + return ScreenMetrics( + width: Int(bounds.width.rounded()), + height: Int(bounds.height.rounded()), + scale: Double(screen.scale) + ) + } + return Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read) #endif } @@ -529,6 +544,11 @@ class DeviceHelper { reachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, api.base.host) self.sdkVersionPadded = Self.makePaddedVersion(using: sdkVersion) self.appVersionPadded = Self.makePaddedVersion(using: appVersion) + + let screenMetrics = Self.makeScreenMetrics() + self.screenWidth = screenMetrics.width + self.screenHeight = screenMetrics.height + self.devicePixelRatio = screenMetrics.scale } func getEnrichment( From e7436a4b91723a4b24f752493bbc80f06a6a6d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:50:48 +0100 Subject: [PATCH 15/17] Capture self weakly at the closure boundary in completion handlers Move `[weak self]` from the inner `Task` to the enclosing completion closure in `TransactionManager.observe` and `WebEntitlementRedeemer`'s Stripe checkout `onClose` handlers, so the stored closures don't strongly retain `self`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../StoreKit/Transactions/TransactionManager.swift | 4 ++-- Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 6ac11909a3..f6f658cd52 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -619,8 +619,8 @@ final class TransactionManager { product: storeProduct, purchaseSource: .observeFunc(storeProduct) ) - await coordinator.setCompletion { result in - Task { [weak self] in + await coordinator.setCompletion { [weak self] result in + Task { await self?.handle( result: result, state: .observing diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 68535d18b4..2ee581b25e 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -627,8 +627,8 @@ actor WebEntitlementRedeemer { title: title, message: message, closeActionTitle: closeActionTitle, - onClose: { - Task { [weak self] in + onClose: { [weak self] in + Task { await afterRedeem() await self?.clearPendingStripeCheckoutState() } @@ -639,8 +639,8 @@ actor WebEntitlementRedeemer { title: title, message: message, closeActionTitle: closeActionTitle, - onClose: { - Task { [weak self] in + onClose: { [weak self] in + Task { await afterRedeem() await self?.clearPendingStripeCheckoutState() } From 674facb62341fef44e5c597736df5b0bb1d2a086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:34:08 +0100 Subject: [PATCH 16/17] Harden MMP match response decoding and add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decode `MMPMatchResponse.queryParams` as `[String: JSON]` instead of `[String: String]`. The backend returns array values when a query key repeats in the click URL, which previously threw and failed the whole response decode — silently reporting a real match as `request_failed` and dropping the acquisition attributes. - Decode `confidence` leniently so an unrecognised value (e.g. a future tier) degrades to `nil` rather than failing the entire response. - Document the real `matchScore` range (75-117). - Add unit tests for the install-attribution gating logic and response decoding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Superwall Placement/SuperwallEvent.swift | 6 +- .../Models/AdServicesResponse.swift | 38 +++- SuperwallKit.xcodeproj/project.pbxproj | 8 + .../Models/MMPMatchResponseTests.swift | 104 ++++++++++ .../Storage/MMPInstallAttributionTests.swift | 191 ++++++++++++++++++ 5 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 Tests/SuperwallKitTests/Models/MMPMatchResponseTests.swift create mode 100644 Tests/SuperwallKitTests/Storage/MMPInstallAttributionTests.swift diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 1030b03358..ebb25d5a39 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -50,7 +50,11 @@ public struct AttributionMatchInfo: Sendable { /// The confidence label returned by the provider, if available. public let confidence: Confidence? - /// The numeric match score between 0 and 100 returned by the provider, if available. + /// The numeric match score returned by the provider, if available. + /// + /// Higher values indicate a stronger match. A score is only returned for a + /// successful match, so in practice it ranges from the backend's match + /// threshold (currently `75`) up to around `117`. public let matchScore: Double? /// The reason for a non-match or failure, if available. diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index d089c53845..8d11b38129 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -50,8 +50,44 @@ struct MMPMatchResponse: Decodable { let linkId: String? let network: String? let redirectUrl: String? - let queryParams: [String: String]? + // The backend types `queryParams` as a free-form object whose values are + // strings *or arrays of strings* (a query key can appear more than once). + // Modelling it as `[String: String]` would throw on the array case and fail + // the whole decode, so we keep it loosely typed as JSON. + let queryParams: [String: JSON]? let acquisitionAttributes: [String: JSON]? let matchedAt: String? let breakdown: [String: JSON]? + + private enum CodingKeys: String, CodingKey { + case matched + case confidence + case matchScore + case clickId + case linkId + case network + case redirectUrl + case queryParams + case acquisitionAttributes + case matchedAt + case breakdown + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + matched = try container.decode(Bool.self, forKey: .matched) + // The backend types `confidence` as a free-form string. Decode it + // leniently so an unrecognised value (e.g. a future tier) degrades to + // `nil` instead of failing the entire response decode. + confidence = try? container.decodeIfPresent(AttributionMatchInfo.Confidence.self, forKey: .confidence) + matchScore = try container.decodeIfPresent(Double.self, forKey: .matchScore) + clickId = try container.decodeIfPresent(Int.self, forKey: .clickId) + linkId = try container.decodeIfPresent(String.self, forKey: .linkId) + network = try container.decodeIfPresent(String.self, forKey: .network) + redirectUrl = try container.decodeIfPresent(String.self, forKey: .redirectUrl) + queryParams = try container.decodeIfPresent([String: JSON].self, forKey: .queryParams) + acquisitionAttributes = try container.decodeIfPresent([String: JSON].self, forKey: .acquisitionAttributes) + matchedAt = try container.decodeIfPresent(String.self, forKey: .matchedAt) + breakdown = try container.decodeIfPresent([String: JSON].self, forKey: .breakdown) + } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index b7d0592453..6c9831aa6b 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -181,6 +181,7 @@ 533E3B63BDCED62B3BD3C662 /* StoreProductDiscountType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182DFFCC0B7AAA4C67C4079D /* StoreProductDiscountType.swift */; }; 534E94DCCD72F2F7D0EC1441 /* Task+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938EB5121B1D9EA6B2EAE9EC /* Task+Retrying.swift */; }; 5566DBCF96993C1E4D217F50 /* GetPaywallResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24EA03270476CD31B906CDC8 /* GetPaywallResult.swift */; }; + 556DDBA011967A3F2411AAE7 /* MMPInstallAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C468F707B216A2F20C6092D /* MMPInstallAttributionTests.swift */; }; 558A89440F2E1B052316FE57 /* LogPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115F0BE94943D7B60CDDD4A /* LogPresentation.swift */; }; 5621A2D2FEC048847E22BF6C /* KeypathWritable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2027BFC214905CBE589AF2 /* KeypathWritable.swift */; }; 5634C4E0E082754F7939BB60 /* ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */; }; @@ -215,6 +216,7 @@ 666FBAEC100FD378E9EC816D /* EntitlementsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBE35B7BB7FEBE02C8992D8 /* EntitlementsResponse.swift */; }; 67C020751429B5677D9A0727 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236900A8A8F95CE92E612458 /* IdentityManager.swift */; }; 67DE6918459F0E911D4D2D26 /* LogErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4F7C1AA96162D7C97493E /* LogErrors.swift */; }; + 6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */; }; 6897B0B9E3BC760FBCA2AB7C /* InternalPurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B5B7A4C7137EB233CC1262 /* InternalPurchaseController.swift */; }; 68AF64973AC860BE2A41B8D4 /* LoadingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57478172574516BD5EDD254A /* LoadingInfo.swift */; }; 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618BF4D10B7D87FAF8FB48CD /* ProductPurchaserSK1Tests.swift */; }; @@ -855,6 +857,7 @@ 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueBacked.swift; sourceTree = ""; }; 7B1CE50799F517D3D52A1BB9 /* PostbackAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostbackAssignment.swift; sourceTree = ""; }; 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitedQueue.swift; sourceTree = ""; }; + 7C468F707B216A2F20C6092D /* MMPInstallAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPInstallAttributionTests.swift; sourceTree = ""; }; 7E27997BBCEAC330E4FB3718 /* pt_BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt_BR; path = pt_BR.lproj/Localizable.strings; sourceTree = ""; }; 7FCE6A59348C9018F40D7AC5 /* LogScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScope.swift; sourceTree = ""; }; 7FE43B98D847BB6DE291F0B4 /* FakeTrackingAuthorizationStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTrackingAuthorizationStatusTests.swift; sourceTree = ""; }; @@ -1005,6 +1008,7 @@ B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProxy.swift; sourceTree = ""; }; B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalPresentationLogic.swift; sourceTree = ""; }; B88E86C67F934540D846B8BA /* EmptyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyResponse.swift; sourceTree = ""; }; + B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPMatchResponseTests.swift; sourceTree = ""; }; B9553EC1E394EF7AE8788291 /* InAppReceiptAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptAttribute.swift; sourceTree = ""; }; BA4EC02056512C9F677CC345 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; BA9100DDAD2E8596F96A1BCB /* Assignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assignment.swift; sourceTree = ""; }; @@ -1517,6 +1521,7 @@ isa = PBXGroup; children = ( F0B300DB3367C74A42536395 /* CustomerInfoDecodingTests.swift */, + B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */, 2955F8306C59091AEA338E0D /* PaywallBillingPlanTests.swift */, 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */, 9D7470637C9A0C7DF4924671 /* Assignment */, @@ -2481,6 +2486,7 @@ A1EF6DDE57A911501DFB2B4D /* Storage */ = { isa = PBXGroup; children = ( + 7C468F707B216A2F20C6092D /* MMPInstallAttributionTests.swift */, 6D1887F247BF6F770122F257 /* StorageMock.swift */, 787764B249892BBCA1088235 /* StorageTests.swift */, 0E582EAD2A75C1D7A72F2E52 /* Cache */, @@ -3260,6 +3266,8 @@ 2BEA633247B24641D45A808C /* LocationManagerProxyTests.swift in Sources */, 40314E44991DCB66B4572C25 /* LocationPermissionDelegateTests.swift in Sources */, 4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */, + 556DDBA011967A3F2411AAE7 /* MMPInstallAttributionTests.swift in Sources */, + 6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */, A9B924A1211117378743A534 /* MicrophonePermissionTests.swift in Sources */, B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */, BA957415E2E1A38A25550B99 /* MockIntroductoryPeriod.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Models/MMPMatchResponseTests.swift b/Tests/SuperwallKitTests/Models/MMPMatchResponseTests.swift new file mode 100644 index 0000000000..c443ac945c --- /dev/null +++ b/Tests/SuperwallKitTests/Models/MMPMatchResponseTests.swift @@ -0,0 +1,104 @@ +// +// MMPMatchResponseTests.swift +// SuperwallKit +// +// Tests that `MMPMatchResponse` decodes the shapes the backend `/api/match` +// endpoint can actually return. +// +// swiftlint:disable all + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite +struct MMPMatchResponseTests { + private func decode(_ json: String) throws -> MMPMatchResponse { + try JSONDecoder.subscriptionsApi.decode(MMPMatchResponse.self, from: Data(json.utf8)) + } + + /// `queryParams` values can be arrays when a query key appears more than once + /// in the click URL. This must not fail the whole decode. + @Test + func decodes_queryParamsWithDuplicatedKeyArray() throws { + let json = """ + { + "matched": true, + "confidence": "high", + "matchScore": 95, + "clickId": 123, + "linkId": "link_1", + "network": "meta", + "redirectUrl": "https://example.com", + "queryParams": { "placement": ["a", "b"], "utm_source": "meta" }, + "acquisitionAttributes": { "acquisition_source": "meta" }, + "matchedAt": "2026-06-16T00:00:00Z", + "breakdown": { "reason": "matched" } + } + """ + + let response = try decode(json) + + #expect(response.matched == true) + #expect(response.confidence == .high) + #expect(response.queryParams?["placement"]?.array?.count == 2) + #expect(response.queryParams?["utm_source"]?.string == "meta") + #expect(response.acquisitionAttributes?["acquisition_source"]?.string == "meta") + } + + /// An unrecognised `confidence` value (e.g. a future tier) should degrade to + /// `nil` rather than failing the entire response decode. + @Test + func decodes_unknownConfidenceAsNil() throws { + let json = """ + { + "matched": true, + "confidence": "very_high", + "matchScore": 110 + } + """ + + let response = try decode(json) + + #expect(response.matched == true) + #expect(response.confidence == nil) + #expect(response.matchScore == 110) + } + + @Test + func decodes_knownConfidenceLevels() throws { + #expect(try decode(#"{ "matched": true, "confidence": "high" }"#).confidence == .high) + #expect(try decode(#"{ "matched": true, "confidence": "medium" }"#).confidence == .medium) + #expect(try decode(#"{ "matched": true, "confidence": "low" }"#).confidence == .low) + } + + /// The unmatched response shape: all nullable fields are `null`, with only a + /// `breakdown.reason` explaining why. + @Test + func decodes_unmatchedResponseWithNullFields() throws { + let json = """ + { + "matched": false, + "confidence": null, + "matchScore": null, + "clickId": null, + "linkId": null, + "network": null, + "redirectUrl": null, + "queryParams": null, + "acquisitionAttributes": null, + "matchedAt": null, + "breakdown": { "reason": "below_threshold", "candidateCount": 0 } + } + """ + + let response = try decode(json) + + #expect(response.matched == false) + #expect(response.confidence == nil) + #expect(response.matchScore == nil) + #expect(response.queryParams == nil) + #expect(response.acquisitionAttributes == nil) + #expect(response.breakdown?["reason"]?.string == "below_threshold") + } +} diff --git a/Tests/SuperwallKitTests/Storage/MMPInstallAttributionTests.swift b/Tests/SuperwallKitTests/Storage/MMPInstallAttributionTests.swift new file mode 100644 index 0000000000..d49c351d11 --- /dev/null +++ b/Tests/SuperwallKitTests/Storage/MMPInstallAttributionTests.swift @@ -0,0 +1,191 @@ +// +// MMPInstallAttributionTests.swift +// SuperwallKit +// +// Tests for the MMP install-attribution gating logic in `Storage`. +// +// swiftlint:disable all + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite +struct MMPInstallAttributionTests { + private func makeStorage() -> Storage { + Storage( + factory: StorageMock.DeviceInfoFactoryMock(), + cache: CacheMock() + ) + } + + /// An ISO8601 install-date string offset from now. + private func installDate(daysAgo: Double, fractionalSeconds: Bool = false) -> String { + let date = Date().addingTimeInterval(-daysAgo * 24 * 60 * 60) + let formatter = ISO8601DateFormatter() + if fractionalSeconds { + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + } + return formatter.string(from: date) + } + + // MARK: - Initial match + + @Test + func initialMatch_freshInstallWithinWindow_returnsTrueAndMarksEligible() { + let storage = makeStorage() + + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: false, + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == true) + #expect(storage.get(IsEligibleForMMPInstallAttributionMatch.self) == true) + } + + @Test + func initialMatch_alreadyCompleted_returnsFalse() { + let storage = makeStorage() + storage.save(true, forType: DidCompleteMMPInstallAttributionRequest.self) + + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: false, + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == false) + } + + @Test + func initialMatch_upgraderWithoutEligibility_returnsFalse() { + let storage = makeStorage() + + // Existing user upgrading the SDK: they tracked the install before this + // version, and were never flagged eligible. They must not back-fill MMP. + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: true, + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == false) + #expect((storage.get(IsEligibleForMMPInstallAttributionMatch.self) ?? false) == false) + } + + @Test + func initialMatch_eligibleReturningSession_returnsTrue() { + let storage = makeStorage() + storage.save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + + // A returning session of a fresh install that was already flagged eligible + // should still proceed even though the install was tracked previously. + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: true, + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == true) + } + + @Test + func initialMatch_installOutsideWindow_returnsFalse() { + let storage = makeStorage() + + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: false, + appInstalledAtString: installDate(daysAgo: 8) + ) + + #expect(result == false) + } + + @Test + func initialMatch_emptyInstallDate_treatsWindowAsOpen() { + let storage = makeStorage() + + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: false, + appInstalledAtString: "" + ) + + #expect(result == true) + } + + @Test + func initialMatch_fractionalSecondsInstallDate_parsesWithinWindow() { + let storage = makeStorage() + + let result = storage.shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: false, + appInstalledAtString: installDate(daysAgo: 1, fractionalSeconds: true) + ) + + #expect(result == true) + } + + // MARK: - Tracking-permission (ATT) retry match + + @Test + func trackingPermissionMatch_notEligible_returnsFalse() { + let storage = makeStorage() + + let result = storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == false) + } + + @Test + func trackingPermissionMatch_eligibleWithinWindow_returnsTrue() { + let storage = makeStorage() + storage.save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + + let result = storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == true) + } + + @Test + func trackingPermissionMatch_runsEvenIfInitialRequestCompleted() { + let storage = makeStorage() + storage.save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + storage.save(true, forType: DidCompleteMMPInstallAttributionRequest.self) + + // The ATT retry intentionally ignores the initial-request flag so it can + // upgrade a probabilistic match into a deterministic one once a real IDFA + // is available. + let result = storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == true) + } + + @Test + func trackingPermissionMatch_alreadyCompletedAfterTracking_returnsFalse() { + let storage = makeStorage() + storage.save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + storage.save(true, forType: DidCompleteMMPInstallAttributionRequestAfterTrackingPermission.self) + + let result = storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: installDate(daysAgo: 1) + ) + + #expect(result == false) + } + + @Test + func trackingPermissionMatch_outsideWindow_returnsFalse() { + let storage = makeStorage() + storage.save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + + let result = storage.shouldAttemptTrackingPermissionMMPInstallAttributionMatch( + appInstalledAtString: installDate(daysAgo: 8) + ) + + #expect(result == false) + } +} From 9766072799185492b5a17b43b43426e69849e2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:38:08 +0100 Subject: [PATCH 17/17] Update Package.resolved --- .../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 4597ebe526..c552f763a6 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": "3bbaa4a88d74c863ee5bc4295b2fc628323eff0d", - "version": "5.75.0" + "revision": "629a56ecef190469914b8f0914bf0446363eb09f", + "version": "5.78.0" } }, {