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/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" } }, { 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..aafbeaf4f4 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..22f18f7aa7 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -13,6 +13,77 @@ 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 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? + + /// 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 +176,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 +448,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 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/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/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..0c57f55e75 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -10,3 +10,43 @@ 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 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? + 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/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/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..4bc2e48c52 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 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), @@ -390,4 +415,130 @@ class Network { throw error } } + + 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 = rawMetadata.reduce(into: [String: String]()) { 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 4265176d30..02d9c8bb20 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -57,6 +57,32 @@ 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 +} + +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..6db7c2d635 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,88 @@ 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 + } + + 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/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index b7899d89ba..c8fed7ce49 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) @@ -446,12 +470,31 @@ 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 + ) { + 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( @@ -472,6 +515,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/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"