diff --git a/CHANGELOG.md b/CHANGELOG.md index 20272126c3..edf59aa15c 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.16.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.15.4 ### Enhancements 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" } }, { diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index a6ba3ab84d..bf118d70c8 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -4,6 +4,7 @@ // // Created by Yusuf Tör on 23/09/2024. // +// swiftlint:disable file_length type_body_length import Foundation import Combine @@ -234,6 +235,7 @@ final class AttributionPoster { } @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + // swiftlint:disable:next function_body_length private func runAttempt(existingAttempts: AdServicesAttributionAttempts?) async { // Fire `.start` from inside the task body so the event isn't orphaned if // `cancelInFlight` raced the outer call's ownership re-check. If we're @@ -297,9 +299,20 @@ final class AttributionPoster { storage.delete(AdServicesAttributionAttemptsStorage.self) let attribution = convertJSONToDictionary(attribution: response.attribution) - if !attribution.isEmpty { + let matched = !attribution.isEmpty + if matched { Superwall.shared.setUserAttributes(attribution) } + await Superwall.shared.track( + InternalSuperwallEvent.AttributionMatch( + info: AttributionMatchInfo( + provider: .appleSearchAds, + matched: matched, + source: matched ? (attribution["acquisition_source"] as? String ?? "apple_search_ads") : nil, + reason: matched ? nil : "no_attribution" + ) + ) + ) } catch is CancellationError { // `.complete(token)` was already emitted above, but the post never // finished — emit a terminal `.fail` so the session has an unambiguous @@ -313,6 +326,15 @@ final class AttributionPoster { await Superwall.shared.track( InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) ) + await Superwall.shared.track( + InternalSuperwallEvent.AttributionMatch( + info: AttributionMatchInfo( + provider: .appleSearchAds, + matched: false, + reason: "request_failed" + ) + ) + ) } } diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index a6cdf95003..eb366a8352 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -108,6 +108,38 @@ 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.rawValue + } + if let matchScore = info.matchScore { + parameters["match_score"] = matchScore + } + if let reason = info.reason { + parameters["reason"] = reason + } + + 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..ebb25d5a39 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -13,6 +13,81 @@ 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 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: Confidence? + + /// 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. + /// + /// For example, `below_threshold`, `no_attribution`, or `request_failed`. + public let reason: String? + + /// 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. + public init( + provider: Provider, + matched: Bool, + source: String? = nil, + confidence: Confidence? = nil, + matchScore: Double? = nil, + reason: String? = nil + ) { + self.provider = provider + self.matched = matched + self.source = source + self.confidence = confidence + self.matchScore = matchScore + self.reason = reason + } +} + /// 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 +180,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 +452,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..fd57b6c413 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -260,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 } @@ -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 f7d42bbeda..f30a0a5d52 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -268,6 +268,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/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index c90125e07e..e7748fcb6f 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/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 e926212ec0..5338fe7359 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.4 +4.16.0 """ diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index 948c4c7a20..8d11b38129 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -15,3 +15,79 @@ struct AdServicesResponse: Decodable { // body, so we only model the success shape here. 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 advertiserTrackingEnabled: Bool + let applicationTrackingEnabled: Bool + let appVersion: String + let sdkVersion: String + let osVersion: String + 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] +} + +struct MMPMatchResponse: Decodable { + let matched: Bool + let confidence: AttributionMatchInfo.Confidence? + let matchScore: Double? + let clickId: Int? + let linkId: String? + let network: String? + let redirectUrl: 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/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/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index f5512bc875..ec3e453d40 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -118,6 +118,49 @@ class DeviceHelper { "\(Int(TimeZone.current.secondsFromGMT()))" } + var timezoneOffsetSeconds: Int { + TimeZone.current.secondsFromGMT() + } + + /// 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 ScreenMetrics(width: 0, height: 0, scale: 1.0) + #else + 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 + } + var isFirstAppOpen: Bool { return !storage.didTrackFirstSession } @@ -501,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( diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 7a4b9b9a49..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( @@ -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? Kind.jsonEncoder.encode(request) + + return Endpoint( + retryCount: 2, + components: Components( + host: .mmp, + path: "api/match", + bodyData: bodyData + ), + method: .post + ) + } +} 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/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 370b2a5a20..687d5303e7 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -4,7 +4,7 @@ // // Created by Yusuf Tör on 04/03/2022. // -// swiftlint:disable type_body_length +// swiftlint:disable type_body_length file_length import Foundation import UIKit @@ -326,6 +326,31 @@ class Network { } } + 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 String(describing: currentValue) != String(describing: value) + } + + guard hasChanges else { + return + } + + Superwall.shared.setUserAttributes(attributes) + } + func redeemEntitlements(request: RedeemRequest) async throws -> RedeemResponse { return try await urlSession.request( .redeem(request: request), @@ -389,4 +414,131 @@ class Network { throw error } } + + // swiftlint:disable:next function_body_length + func matchMMPInstall( + idfa: String?, + advertiserTrackingEnabled: Bool, + applicationTrackingEnabled: Bool + ) 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: [String: String] = rawMetadata.reduce(into: [:]) { result, entry in + guard let value = entry.value, !value.isEmpty else { + return + } + result[entry.key] = value + } + + let vendorId = deviceHelper.vendorId + + let request = MMPMatchRequest( + platform: "ios", + appUserId: identityManager.appUserId, + deviceId: factory.makeDeviceId(), + vendorId: vendorId, + idfa: idfa, + idfv: vendorId, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: applicationTrackingEnabled, + appVersion: deviceHelper.appVersion, + sdkVersion: sdkVersion, + osVersion: deviceHelper.osVersion, + 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 + ) + + do { + let response: MMPMatchResponse = try await urlSession.request( + .matchMMPInstall(request: request), + data: SuperwallRequestData(factory: factory) + ) + + 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 + ) + ) + ) + + // A successful response means the request was processed, even if no attribution match was found. + return true + } catch { + Logger.debug( + logLevel: .error, + scope: .network, + message: "Request Failed: /api/match", + info: ["payload": request], + error: error + ) + + await Superwall.shared.track( + InternalSuperwallEvent.AttributionMatch( + info: AttributionMatchInfo( + provider: .mmp, + matched: false, + reason: "request_failed" + ) + ) + ) + + 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 6a42dc57ba..15c9896683 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -57,6 +57,33 @@ enum DidTrackAppInstall: Storable { typealias Value = Bool } +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 + typealias Value = Bool +} + +enum IsEligibleForMMPInstallAttributionMatch: Storable { + static var key: String { + "store.isEligibleForMMPInstallAttributionMatch" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Bool +} + +// swiftlint:disable:next type_name +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 + 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..d2c1ee5f71 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -4,10 +4,13 @@ // // Created by Brian Anglin on 8/3/21. // +// swiftlint:disable type_body_length import Foundation class Storage { + private static let mmpInstallAttributionWindow: TimeInterval = 7 * 24 * 60 * 60 + /// The interface that manages core data. let coreDataManager: CoreDataManager @@ -209,6 +212,98 @@ class Storage { save(true, forType: DidTrackAppInstall.self) } + func recordMMPInstallAttributionMatch( + matchInstall: @escaping () async -> Bool + ) { + let didCompleteAttributionRequest = get(DidCompleteMMPInstallAttributionRequest.self) ?? false + if didCompleteAttributionRequest { + return + } + + Task { [weak self] in + let didCompleteRequest = await matchInstall() + guard didCompleteRequest else { + return + } + + self?.save(true, forType: DidCompleteMMPInstallAttributionRequest.self) + } + } + + func hasTrackedAppInstall() -> Bool { + get(DidTrackAppInstall.self) ?? false + } + + func shouldAttemptInitialMMPInstallAttributionMatch( + hadTrackedAppInstallBeforeConfigure: Bool, + appInstalledAtString: String + ) -> Bool { + let didCompleteRequest = get(DidCompleteMMPInstallAttributionRequest.self) ?? false + if didCompleteRequest { + return false + } + + let isEligible = get(IsEligibleForMMPInstallAttributionMatch.self) ?? false + if hadTrackedAppInstallBeforeConfigure && !isEligible { + return false + } + + guard isMMPInstallAttributionWindowOpen(appInstalledAtString: appInstalledAtString) else { + return false + } + + save(true, forType: IsEligibleForMMPInstallAttributionMatch.self) + 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 { + let isEligible = get(IsEligibleForMMPInstallAttributionMatch.self) ?? false + guard isEligible else { + return false + } + + guard isMMPInstallAttributionWindowOpen(appInstalledAtString: appInstalledAtString) else { + return false + } + + let didCompleteTrackingPermissionRequest = + get(DidCompleteMMPInstallAttributionRequestAfterTrackingPermission.self) ?? false + return !didCompleteTrackingPermissionRequest + } + + private func isMMPInstallAttributionWindowOpen(appInstalledAtString: String) -> Bool { + guard !appInstalledAtString.isEmpty else { + return true + } + + let formatterWithFractionalSeconds = ISO8601DateFormatter() + formatterWithFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let formatter = ISO8601DateFormatter() + + guard + let appInstallDate = + formatterWithFractionalSeconds.date(from: appInstalledAtString) + ?? formatter.date(from: appInstalledAtString) + else { + return true + } + + return Date().timeIntervalSince(appInstallDate) <= Self.mmpInstallAttributionWindow + } + func clearCachedSessionEvents() { cache.delete(Transactions.self) } 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 } 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 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/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6d3741917f..72591edb62 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) @@ -441,12 +465,31 @@ public final class Superwall: NSObject, ObservableObject { // config to be loaded to know whether it's enabled. `AttributionPoster` // subscribes to `configState` and fires automatically once config arrives. Task { + 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 + ) { + let advertiserTrackingEnabled = + dependencyContainer.permissionHandler.checkTrackingPermission() == .granted + + dependencyContainer.storage.recordMMPInstallAttributionMatch { + await dependencyContainer.network.matchMMPInstall( + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: true + ) + } + } + + _ = await fetchConfig await track( InternalSuperwallEvent.ConfigAttributes( @@ -467,6 +510,81 @@ 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() { + 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 + } + + let advertiserTrackingEnabled = + dependencyContainer.permissionHandler.checkTrackingPermission() == .granted + let didCompleteRequest = await dependencyContainer.network.matchMMPInstall( + idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers, + advertiserTrackingEnabled: advertiserTrackingEnabled, + applicationTrackingEnabled: true + ) + + if didCompleteRequest { + dependencyContainer.storage.save( + true, + forType: DidCompleteMMPInstallAttributionRequestAfterTrackingPermission.self + ) + } + + await trackingPermissionMMPRetryGate.finish(didComplete: didCompleteRequest) + } } private func listenToConfig() { 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() } diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 8167b28f93..27d2d5647d 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.4" + s.version = "4.16.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" 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) + } +}