diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d8441dda..844598f64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.2 + +### Enhancements + +- Improves Apple Search Ads attribution capture rate. +- Filters out the all-zeros IDFA sentinel (returned when App Tracking Transparency is denied) so it no longer pollutes the `idfa` attribute on attribution payloads. + +### Fixes + +- Changes the Superscript spm package repo source to a new lightweight repo meaning that the download of the package is way faster. + ## 4.15.1 ### Enhancements diff --git a/CLAUDE.md b/CLAUDE.md index f414dee131..419b8b5f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Package Management - Swift Package Manager: Primary dependency management via `Package.swift` - CocoaPods: Also supported via `SuperwallKit.podspec` -- Dependencies: `Superscript-iOS` at exact version 1.0.4 +- Dependencies: `superscript-ios-next` at exact version 1.0.14 (slim binary-target distribution; replaces the legacy `Superscript-iOS` repo whose committed xcframework bloated clones) ## Architecture Overview 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 c9e34050a5..140cce312f 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,17 +6,17 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "a06accc1543fcedc3094d4b5b9a40b84e2213e8e", - "version": "5.69.0" + "revision": "6b95744e70f1edc43f89f2b522b0832ddfdd41a1", + "version": "5.73.0" } }, { "package": "Superscript", - "repositoryURL": "https://github.com/superwall/Superscript-iOS", + "repositoryURL": "https://github.com/superwall/superscript-ios-next", "state": { "branch": null, - "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version": "1.0.13" + "revision": "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version": "1.0.14" } } ] diff --git a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 251dbbdc99..5da674204b 100644 --- a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -3,11 +3,11 @@ "pins": [ { "package": "Superscript", - "repositoryURL": "https://github.com/superwall/Superscript-iOS", + "repositoryURL": "https://github.com/superwall/superscript-ios-next", "state": { "branch": null, - "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version": "1.0.13" + "revision": "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version": "1.0.14" } } ] diff --git a/Package.resolved b/Package.resolved index 251dbbdc99..5da674204b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -3,11 +3,11 @@ "pins": [ { "package": "Superscript", - "repositoryURL": "https://github.com/superwall/Superscript-iOS", + "repositoryURL": "https://github.com/superwall/superscript-ios-next", "state": { "branch": null, - "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version": "1.0.13" + "revision": "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version": "1.0.14" } } ] diff --git a/Package.swift b/Package.swift index ff4a62e430..c1fe431dc8 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/superwall/Superscript-iOS", .exact("1.0.13")) + .package(url: "https://github.com/superwall/superscript-ios-next", .exact("1.0.14")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -27,7 +27,7 @@ let package = Package( .target( name: "SuperwallKit", dependencies: [ - .product(name: "Superscript", package: "Superscript-iOS") + .product(name: "Superscript", package: "superscript-ios-next") ], exclude: ["Resources/BundleHelper.swift"], resources: [ diff --git a/Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift b/Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift new file mode 100644 index 0000000000..86bfe49390 --- /dev/null +++ b/Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift @@ -0,0 +1,16 @@ +// +// AdServicesAttributionAttempts.swift +// SuperwallKit +// + +import Foundation + +struct AdServicesAttributionAttempts: Codable, Equatable { + /// Total attempts that have completed (either at Apple's SDK call or the + /// backend post). + var count: Int + /// When we first tried for this install. Used to bound how long we keep + /// retrying — Apple's attribution data is only useful within ~24h of install. + var firstAttemptDate: Date + var lastAttemptDate: Date +} diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index d7657c9faa..f4f15fbe7b 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -43,12 +43,41 @@ final class AttributionFetcher { return nil } + // When ATT hasn't been authorized iOS returns the all-zeros UUID + // sentinel. Don't pass that through as an IDFA — it pollutes attribution + // payloads with junk that downstream MMPs treat as a real id. + if identifierValue == Self.zeroAdvertisingIdentifier { + return nil + } + return identifierValue.uuidString } #endif return nil } + // Non-optional construction via `init(uuid:)` — `init(uuidString:)` returns + // an Optional which would make the equality check silently false-negative + // (zero-IDFA passes through unfiltered) if the literal ever failed to parse. + private static let zeroAdvertisingIdentifier = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + + /// Whether this build/environment can ever produce an AdServices token. + /// `false` on builds that didn't link AdServices.framework and on debug + /// simulator runs without `SUPERWALL_MOCK_AD_SERVICES_TOKEN`. Lets the + /// poster short-circuit before entering its 23s backoff schedule in + /// development. + var canProduceAdServicesToken: Bool { + #if !canImport(AdServices) + return false + #else + #if targetEnvironment(simulator) && DEBUG + return ProcessInfo.processInfo.environment["SUPERWALL_MOCK_AD_SERVICES_TOKEN"] != nil + #else + return true + #endif + #endif + } + // should match OS availability in https://developer.apple.com/documentation/ad_services @available(iOS 14.3, tvOS 14.3, macOS 11.1, watchOS 6.2, macCatalyst 14.3, *) var adServicesToken: String? { diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 9563824ab8..a6ba3ab84d 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -7,9 +7,38 @@ import Foundation import Combine +#if canImport(AdServices) +import AdServices +#endif final class AttributionPoster { + /// Hard cap on attempts across launches. Apple's attribution endpoint can be + /// unhealthy for short periods around install, so we want enough retries to + /// survive that, but not so many that we keep hammering it for users who + /// will never have attribution data. + static let maxAttempts = 8 + + /// Maximum window from the first attempt during which we'll keep retrying. + /// Apple's docs say the attribution token is valid for 24h after it's + /// generated and that posts should happen within that window. We generate + /// a fresh token on every attempt, but the first attempt's clock is what + /// bounds Apple's install-side attribution data — past 24h, even a fresh + /// token won't resolve to a campaign. + static let maxRetryWindow: TimeInterval = 24 * 60 * 60 + + /// In-session retry plan for transient errors from `AAAttribution.attributionToken()`, + /// which can throw `networkError` if called too soon after launch. The HTTP + /// post to our backend is already covered by `Task.retrying` inside + /// `CustomURLSession`, so we don't add an outer backoff there. + private static let tokenFetchBackoff: [TimeInterval] = [2, 6, 15] + + private let stateQueue = DispatchQueue(label: "com.superwall.attributionposter.state") private var isCollecting = false + private var currentTask: Task? + /// Monotonic generation stamped onto each outer call that claims the slot. + /// `cancelInFlight` bumps this so a late-completing call's `defer` won't + /// clobber the state belonging to a newly started call. + private var ownerGeneration: UInt64 = 0 private unowned let storage: Storage private unowned let network: Network @@ -17,25 +46,6 @@ final class AttributionPoster { private unowned let attributionFetcher: AttributionFetcher private var cancellables: [AnyCancellable] = [] - private var adServicesTokenToPostIfNeeded: String? { - get async throws { - #if os(tvOS) || os(watchOS) - return nil - #else - guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { - return nil - } - - guard storage.get(AdServicesTokenStorage.self) == nil else { - return nil - } - - await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) - return try await attributionFetcher.adServicesToken - #endif - } - } - init( storage: Storage, network: Network, @@ -59,18 +69,24 @@ final class AttributionPoster { ) } - @available(iOS 14.3, *) private func listenToConfig() { + // Track the enabled flag across config refreshes. `removeDuplicates` has + // to see the toggles to detect a transition, so we dedup on the bool + // *before* filtering. This fires once when the flag first becomes true, + // and again if it goes true → false → true mid-session. configManager.configState .compactMap { $0.getConfig() } - .first { config in - config.attribution?.appleSearchAds?.enabled == true - } + .map { $0.attribution?.appleSearchAds?.enabled == true } + .removeDuplicates() + .filter { $0 } .sink( receiveCompletion: { _ in }, - receiveValue: { _ in - Task { [weak self] in + receiveValue: { [weak self] _ in + // Match applicationWillEnterForeground's priority: default-priority + // tasks can be deferred under load, and attribution is bounded by + // Apple's 24h install window. + Task(priority: .utility) { await self?.getAdServicesTokenIfNeeded() } } @@ -85,7 +101,10 @@ final class AttributionPoster { return } - Task(priority: .background) { + // .utility rather than .background — background-priority tasks can be + // deferred indefinitely under load, but the attribution token is only + // useful within ~24h of install. + Task(priority: .utility) { if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { await getAdServicesTokenIfNeeded() } @@ -93,38 +112,287 @@ final class AttributionPoster { #endif } + /// Re-applies the cached ASA attribution dict to the current user's + /// attributes. Called from `reset(duringIdentify:)` after user files are + /// wiped so the new user identity inherits the install-scoped campaign + /// keys without us re-hitting Apple. No-op if nothing was ever fetched. + func reapplyCachedAttribution() { + guard let cached = storage.get(AdServicesAttributionDataStorage.self) else { + return + } + let attribution = convertJSONToDictionary(attribution: cached) + if attribution.isEmpty { + return + } + Superwall.shared.setUserAttributes(attribution) + } + + /// Cancel any in-flight attempt. Used during reset so we don't race a + /// completing post against the new user's state. + func cancelInFlight() { + stateQueue.sync { + currentTask?.cancel() + currentTask = nil + isCollecting = false + // Invalidate the in-flight outer call's ownership so its `defer` + // becomes a no-op once it unblocks from `await task.value` — otherwise + // it would clobber state belonging to a newly started call. + ownerGeneration &+= 1 + } + } + // Should match OS availability in https://developer.apple.com/documentation/ad_services @available(iOS 14.3, tvOS 14.3, watchOS 6.2, macOS 11.1, macCatalyst 14.3, *) @available(tvOS, unavailable) @available(watchOS, unavailable) func getAdServicesTokenIfNeeded() async { - if isCollecting { + // Single-flight: only one collection at a time. Synchronous check on the + // state queue avoids the TOCTOU race in the previous implementation. + // The generation stamp lets the cleanup `defer` detect whether we still + // own the slot when we unblock — `cancelInFlight` may have released it + // and a new call may have already claimed it. + let myGeneration: UInt64? = stateQueue.sync { + if isCollecting { + return nil + } + isCollecting = true + ownerGeneration &+= 1 + return ownerGeneration + } + guard let myGeneration = myGeneration else { return } defer { - isCollecting = false + stateQueue.sync { + guard ownerGeneration == myGeneration else { + return + } + isCollecting = false + currentTask = nil + } } - do { - isCollecting = true - guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { - return + + let attempts = storage.get(AdServicesAttributionAttemptsStorage.self) + guard canStartAttempt(currentAttempts: attempts) else { + return + } + + // Atomic ownership re-check + task creation + store. We don't track + // `.start` here — it's deferred into `runAttempt` so we don't leak an + // unpaired event if cancelInFlight raced us. Creating and storing the + // task inside the same sync block also prevents cancelInFlight from + // running between Task() and the store, which would leave the task + // uncancellable from the outside. + let task: Task? = stateQueue.sync { + guard ownerGeneration == myGeneration else { + return nil } - guard let token = try await adServicesTokenToPostIfNeeded else { - return + let task = Task { [weak self] in + await self?.runAttempt(existingAttempts: attempts) } + currentTask = task + return task + } + guard let task = task else { + return + } + await task.value + } - storage.save(token, forType: AdServicesTokenStorage.self) + /// Storage and config guards that decide whether it's worth starting a + /// fresh attempt. Pulled out of `getAdServicesTokenIfNeeded` so the main + /// flow stays under the cyclomatic-complexity budget. + private func canStartAttempt(currentAttempts: AdServicesAttributionAttempts?) -> Bool { + // Already successfully posted for this install. + if storage.get(AdServicesTokenStorage.self) != nil { + return false + } + // This device permanently can't provide an attribution token (missing + // entitlement, unsupported platform). Stop retrying. + if storage.get(AdServicesAttributionUnsupportedStorage.self) == true { + return false + } + // No-token environment (simulator without SUPERWALL_MOCK_AD_SERVICES_TOKEN, + // build without AdServices.framework). Skip without burning an attempt — + // the developer might add the env var and relaunch, or this might just + // be the simulator path of a build that works on real devices. + if !attributionFetcher.canProduceAdServicesToken { + return false + } + guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { + return false + } + if let attempts = currentAttempts { + if attempts.count >= Self.maxAttempts { + return false + } + if Date().timeIntervalSince(attempts.firstAttemptDate) > Self.maxRetryWindow { + return false + } + } + return true + } + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + 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 + // already cancelled by the time this runs, skip — no `.start`, no orphan. + if Task.isCancelled { + return + } + await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) + + let token: String + do { + token = try await fetchTokenWithBackoff() + } catch is CancellationError { + // Cancellation means `cancelInFlight` (typically via reset) asked us to + // abandon — not a real failure. Don't bookkeep an attempt, especially + // because storage may have just been wiped and `existingAttempts` is + // stale; writing it would inflate the new user's attempt count. We + // still emit a terminal so the prior `.start` isn't orphaned in + // analytics. + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(CancellationError())) + ) + return + } catch let error as PosterError where error == .permanentlyUnsupported { + // Don't burn the attempt budget on a device that will never have a + // token, but do persist a sentinel so subsequent launches short-circuit + // instead of repeating the doomed SDK call indefinitely. + storage.save(true, forType: AdServicesAttributionUnsupportedStorage.self) + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) + ) + return + } catch { + recordFailedAttempt(existing: existingAttempts) await Superwall.shared.track( - InternalSuperwallEvent.AdServicesTokenRetrieval(state: .complete(token)) + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) ) + return + } + + if Task.isCancelled { + return + } - let data = await network.sendToken(token) - Superwall.shared.setUserAttributes(data) + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .complete(token)) + ) + + do { + // CustomURLSession already wraps the request in Task.retrying (3 + // attempts × 5s for the AdServices endpoint, see Endpoint.swift), so + // we don't need our own outer backoff here. Backend error responses + // come back as non-2xx and are thrown by Task.retrying — they end up + // in the catch below and bump the cross-launch attempt budget. + let response = try await network.sendToken(token) + if Task.isCancelled { + return + } + storage.save(token, forType: AdServicesTokenStorage.self) + storage.save(response.attribution, forType: AdServicesAttributionDataStorage.self) + storage.delete(AdServicesAttributionAttemptsStorage.self) + + let attribution = convertJSONToDictionary(attribution: response.attribution) + if !attribution.isEmpty { + Superwall.shared.setUserAttributes(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 + // outcome rather than trailing off after `.complete`. + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(CancellationError())) + ) + return } catch { + recordFailedAttempt(existing: existingAttempts) await Superwall.shared.track( InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) ) } } + + /// Retrieves the token from Apple's AdServices framework, retrying transient + /// errors a few times in-session. `AAAttribution.attributionToken()` can + /// throw `networkError` very early in the app's lifecycle even though the + /// next call milliseconds later would succeed. + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + private func fetchTokenWithBackoff() async throws -> String { + var lastError: Error? + for delay in [0.0] + Self.tokenFetchBackoff { + if delay > 0 { + // Propagate cancellation from the sleep itself — using `try?` would + // swallow CancellationError and force callers waiting up to 15s for + // the next post-sleep `Task.isCancelled` check to notice. + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + if Task.isCancelled { + throw CancellationError() + } + do { + if let token = try await attributionFetcher.adServicesToken { + return token + } + throw PosterError.tokenUnavailable + } catch { + if Self.isPermanentTokenError(error) { + throw PosterError.permanentlyUnsupported + } + lastError = error + } + } + throw lastError ?? PosterError.tokenUnavailable + } + + private func recordFailedAttempt(existing: AdServicesAttributionAttempts?) { + let now = Date() + let updated: AdServicesAttributionAttempts + if let existing = existing { + updated = AdServicesAttributionAttempts( + count: existing.count + 1, + firstAttemptDate: existing.firstAttemptDate, + lastAttemptDate: now + ) + } else { + updated = AdServicesAttributionAttempts( + count: 1, + firstAttemptDate: now, + lastAttemptDate: now + ) + } + storage.save(updated, forType: AdServicesAttributionAttemptsStorage.self) + } +} + +private enum PosterError: Error, Equatable { + case tokenUnavailable + case permanentlyUnsupported +} + +extension AttributionPoster { + /// AdServices.framework error code for `platformNotSupported`. From + /// Apple's `AAAttribution.h`: + /// + /// AAAttributionErrorCodeNetworkError = 1, // transient + /// AAAttributionErrorCodeInternalError = 2, // transient + /// AAAttributionErrorCodePlatformNotSupported = 3, // permanent + /// + /// `NS_ERROR_ENUM` doesn't reliably import as a Swift symbol across SDK + /// versions, so we match on the raw code with the constant clearly named. + private static let platformNotSupportedCode = 3 + + private static func isPermanentTokenError(_ error: Error) -> Bool { + // Only `platformNotSupported` is permanent — this OS/app config will + // never produce a token. `networkError` and `internalError` are both + // transient and should keep retrying within the per-install budget. + let nsError = error as NSError + guard nsError.domain == "AAAttributionErrorDomain" else { + return false + } + return nsError.code == platformNotSupportedCode + } } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 03b9d70246..9e400afba7 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.1 +4.15.2 """ diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index a0c016705e..948c4c7a20 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -8,5 +8,10 @@ import Foundation struct AdServicesResponse: Decodable { + // Backend (`paywall-next:/apple-search-ads/token`) returns + // `{ status: "ok", attribution: {...} }` on success. Error states come + // back as non-2xx with `{ status: "error", error: "..." }`, but those are + // thrown by `CustomURLSession`'s Task.retrying before we ever decode the + // body, so we only model the success shape here. let attribution: [String: JSON] } diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index bd863ef451..370b2a5a20 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -308,13 +308,12 @@ class Network { } } - func sendToken(_ token: String) async -> [String: Any] { + func sendToken(_ token: String) async throws -> AdServicesResponse { do { - let jsonDict = try await urlSession.request( + return try await urlSession.request( .adServices(token: token), data: SuperwallRequestData(factory: factory) - ).attribution - return convertJSONToDictionary(attribution: jsonDict) + ) } catch { Logger.debug( logLevel: .error, @@ -323,7 +322,7 @@ class Network { info: ["payload": token], error: error ) - return [:] + throw error } } diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 4265176d30..6a42dc57ba 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -209,14 +209,61 @@ enum LatestEnrichment: Storable { typealias Value = Enrichment } +/// Apple Search Ads attribution is install-scoped — the campaign that drove +/// the install doesn't change when a user logs out and another user logs in +/// on the same device. So all the AdServices state lives in +/// `.appSpecificDocuments`, surviving `reset(duringIdentify:)`. The cached +/// attribution dict (``AdServicesAttributionDataStorage``) is re-applied to +/// the new user's attributes after a reset so they pick up the same +/// `apple_search_ads_*` / `acquisition_*` keys without re-fetching. enum AdServicesTokenStorage: Storable { static var key: String { "store.adServicesToken" } - static var directory: SearchPathDirectory = .userSpecificDocuments + static var directory: SearchPathDirectory = .appSpecificDocuments typealias Value = String } +/// Retry bookkeeping for the Apple Search Ads token post. +/// +/// The presence of an entry under ``AdServicesTokenStorage`` is treated as a +/// "successfully posted to backend" sentinel. While we're still trying, this +/// secondary record tracks how many attempts we've made and when, so we can +/// bound retries (Apple's attribution endpoint only yields useful data within +/// ~24h of install). +enum AdServicesAttributionAttemptsStorage: Storable { + static var key: String { + "store.adServicesAttributionAttempts" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = AdServicesAttributionAttempts +} + +/// Set when `AAAttribution.attributionToken()` returns a permanent error +/// (e.g. `platformNotSupported` / `attributionUnsupported`). Without this, +/// such devices would re-attempt the SDK call on every launch indefinitely +/// — the attempt budget doesn't cover them because we intentionally don't +/// bump it for non-transient errors. +enum AdServicesAttributionUnsupportedStorage: Storable { + static var key: String { + "store.adServicesAttributionUnsupported" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Bool +} + +/// The decoded attribution payload from the backend, cached so we can +/// re-apply it to a new user's attributes after `reset(duringIdentify:)`. +/// Install-scoped: the same campaign keys apply to whichever user is logged +/// in on this install. +enum AdServicesAttributionDataStorage: Storable { + static var key: String { + "store.adServicesAttributionData" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = [String: JSON] +} + enum SK2TransactionIds: Storable { static var key: String { "store.syncedSK2TransactionIds" diff --git a/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift b/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift index e32df3f70b..8e137900c5 100644 --- a/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift +++ b/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift @@ -13,6 +13,7 @@ enum DataStoreVersion: Int, CaseIterable, Codable { case v2 case v3 case v4 + case v5 } enum FileManagerMigrator { @@ -34,6 +35,8 @@ enum FileManagerMigrator { case .v3: V3Migrator.migrateToNextVersion(cache: cache) case .v4: + V4Migrator.migrateToNextVersion(cache: cache) + case .v5: break } diff --git a/Sources/SuperwallKit/Storage/Migration/V4Migrator.swift b/Sources/SuperwallKit/Storage/Migration/V4Migrator.swift new file mode 100644 index 0000000000..35618d6e9b --- /dev/null +++ b/Sources/SuperwallKit/Storage/Migration/V4Migrator.swift @@ -0,0 +1,36 @@ +// +// V4Migrator.swift +// SuperwallKit +// + +import Foundation + +/// Pre-v5, the AdServices token sentinel lived in `.userSpecificDocuments` — +/// it was scoped per-user so that `reset(duringIdentify:)` would wipe it and a +/// new user could re-fetch. Apple Search Ads attribution is install-scoped +/// (the campaign that drove the install doesn't change with who's logged in), +/// so as of v5 the sentinel lives in `.appSpecificDocuments` and survives +/// `reset`. This shim reads from the legacy directory during migration. +enum LegacyUserScopedAdServicesTokenStorage: Storable { + static var key: String { + // Same key string as AdServicesTokenStorage — only the directory differs. + "store.adServicesToken" + } + static var directory: SearchPathDirectory = .userSpecificDocuments + typealias Value = String +} + +/// Migrates the AdServices token sentinel from user-specific to app-specific +/// storage so existing attributed users aren't seen as "never attributed" +/// after upgrade and re-attempted. +enum V4Migrator: Migratable { + static func migrateToNextVersion(cache: Cache) { + if cache.read(AdServicesTokenStorage.self) == nil, + let legacyToken = cache.read(LegacyUserScopedAdServicesTokenStorage.self) { + cache.write(legacyToken, forType: AdServicesTokenStorage.self) + cache.delete(LegacyUserScopedAdServicesTokenStorage.self, fromDirectory: .userSpecificDocuments) + } + + cache.write(.v5, forType: Version.self) + } +} diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index e75668226d..6d3741917f 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -436,15 +436,11 @@ public final class Superwall: NSObject, ObservableObject { // This task runs on a background thread, even if called from a main thread. // This is because the function isn't marked to run on the main thread, // therefore, we don't need to make this detached. + // + // Note: we don't kick off Apple Search Ads attribution here — it requires + // config to be loaded to know whether it's enabled. `AttributionPoster` + // subscribes to `configState` and fires automatically once config arrives. Task { - Task { - #if os(iOS) || os(macOS) || os(visionOS) - if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { - await dependencyContainer.attributionPoster.getAdServicesTokenIfNeeded() - } - #endif - } - dependencyContainer.storage.recordAppInstall(trackPlacement: track) async let fetchConfig: () = await dependencyContainer.configManager.fetchConfiguration() @@ -1028,8 +1024,16 @@ public final class Superwall: NSObject, ObservableObject { /// Asynchronously resets. Presentation of paywalls is suspended until reset completes. func reset(duringIdentify: Bool) { dependencyContainer.identityManager.reset(duringIdentify: duringIdentify) + // Cancel any in-flight attribution post before wiping its storage, so a + // late-completing post can't race the new user's state. + dependencyContainer.attributionPoster.cancelInFlight() dependencyContainer.storage.reset() + // ASA attribution is install-scoped, so re-apply the cached campaign + // dict to the new user's attributes after the wipe. AdServices state + // itself lives in app-specific storage and is preserved through reset. + dependencyContainer.attributionPoster.reapplyCachedAttribution() + dependencyContainer.paywallManager.resetCache() presentationItems.reset() Task { diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 8b1f2be8b1..cdf48a52df 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.1" + s.version = "4.15.2" 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 6501ada07d..e1bb2f4e0e 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; }; 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09C238ADC0B019047FAB1DF /* JSONToDict.swift */; }; 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */; }; + 3F6DD6FB62BDF53536FC4EF7 /* V4Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D685A5912892EF9C2931B1 /* V4Migrator.swift */; }; 40314E44991DCB66B4572C25 /* LocationPermissionDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4623C4E5EDA10B38746C384 /* LocationPermissionDelegateTests.swift */; }; 40E6B7996E9BA4B5D65F1432 /* RedemptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B382F84E55D4D8C37A6021E3 /* RedemptionResult.swift */; }; 412C74624BB8933162DAAC5F /* Experiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5836EFACFA00594CE8F9F377 /* Experiment.swift */; }; @@ -360,6 +361,7 @@ AE9F583082A5CDCE595BDA2D /* AppSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8165DD71323903F7CF426D2C /* AppSession.swift */; }; AEAB0C0C168B0B3D864109EA /* CoreDataManagerFakeDataMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */; }; AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */; }; + AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */; }; AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27F0D55EF3480E2B65C8DFD /* HandleTriggerResultOperatorTests.swift */; }; B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; }; B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; @@ -411,6 +413,7 @@ C18384B9272067CFE7C7610D /* StoreProductType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A959F1F550446C980DC5E5 /* StoreProductType.swift */; }; C22C4011CB9F4D4FA99F8206 /* FakeAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */; }; C23744AAAF31A533693281B6 /* SurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5ABEC694245E0DC00E409 /* SurveyManager.swift */; }; + C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */; }; C32766E26E92FD03B1BA60A3 /* ProductPurchaserSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */; }; C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */; }; C366CDBA75B69D05DC28394A /* WaitForSubsStatusAndConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731F01C2EA1AC1F06AC1499D /* WaitForSubsStatusAndConfig.swift */; }; @@ -953,6 +956,7 @@ A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandlerTests.swift; sourceTree = ""; }; A7C3FC26DCBF604D7319FBB2 /* zh_Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hant; path = zh_Hant.lproj/Localizable.strings; sourceTree = ""; }; A7D0B6F781D32B2DCDBE689C /* PermissionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandling.swift; sourceTree = ""; }; + A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionTests.swift; sourceTree = ""; }; A94120D51C7B36AC9EA32B8B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK2.swift; sourceTree = ""; }; AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutWebViewController.swift; sourceTree = ""; }; @@ -1049,6 +1053,7 @@ C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternallySetSubscriptionStatusTests.swift; sourceTree = ""; }; C937320625239F3E10FE8D8E /* PermissionsHandler+Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionsHandler+Location.swift"; sourceTree = ""; }; C9B0C261DCC1ED74DD2BF3EA /* HiddenListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenListener.swift; sourceTree = ""; }; + C9D685A5912892EF9C2931B1 /* V4Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V4Migrator.swift; sourceTree = ""; }; CA65A320EE640CDB878F43E9 /* UIWindow+Landscape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+Landscape.swift"; sourceTree = ""; }; CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewMessageTests.swift; sourceTree = ""; }; CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasingCoordinator.swift; sourceTree = ""; }; @@ -1154,6 +1159,7 @@ F96ABDCA3686C7F1CAF41B03 /* en_GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en_GB; path = en_GB.lproj/Localizable.strings; sourceTree = ""; }; F98F66F7AA2F9F3E96849D8A /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; F9D2422F9742D74360FB716B /* TaskRetryingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRetryingTests.swift; sourceTree = ""; }; + F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionAttempts.swift; sourceTree = ""; }; FA3A82C80F89023672D56AD7 /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; FB28BCE1EE94BFE935B984AB /* DeviceHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelperTests.swift; sourceTree = ""; }; FBE7D1E1AF61D199E17B5C05 /* SWLocalizationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWLocalizationViewController.swift; sourceTree = ""; }; @@ -1210,6 +1216,7 @@ 4BE6E7AC2E38E0EB43C35AF5 /* V1Migrator.swift */, 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */, 891BDDF19DEC970709DDF4BB /* V3Migrator.swift */, + C9D685A5912892EF9C2931B1 /* V4Migrator.swift */, ); path = Migration; sourceTree = ""; @@ -2562,6 +2569,7 @@ B24A2445833C9796434E8DA2 /* Attribution */ = { isa = PBXGroup; children = ( + F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */, AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */, 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */, 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */, @@ -2853,6 +2861,7 @@ DEA1A1177CB76234AA134723 /* Attribution */ = { isa = PBXGroup; children = ( + A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, ); path = Attribution; @@ -3149,7 +3158,7 @@ ); mainGroup = 5CE8CEF97A892FFF3D0D8F06; packageReferences = ( - 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "Superscript-iOS" */, + 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); projectDirPath = ""; projectRoot = ""; @@ -3180,6 +3189,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */, 9B49485A1CFAC2621A89B150 /* AppSessionLogicTests.swift in Sources */, 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */, E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */, @@ -3317,6 +3327,7 @@ E95C8C0485512D77E37703C5 /* ASN1Templates.swift in Sources */, 2C7867867DDAD83E5856ECC9 /* ASN1Types.swift in Sources */, 5E05FDE4F45BD5B0DF6AFB9F /* ActivityIndicatorView.swift in Sources */, + AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */, ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */, 90EE0190126A3BBA4661122E /* AddPaywallProducts.swift in Sources */, 14D4EA6EAA43647D24A9716A /* AlertControllerFactory.swift in Sources */, @@ -3714,6 +3725,7 @@ 519196E0035C17C73C525526 /* V2Migrator.swift in Sources */, 12A2722FD932939699923E60 /* V2ProductsResponse.swift in Sources */, 9F517D76DDEFF5278DDBACC8 /* V3Migrator.swift in Sources */, + 3F6DD6FB62BDF53536FC4EF7 /* V4Migrator.swift in Sources */, 7DCBF8A78D0B6A7A2649553D /* Validation.swift in Sources */, 5C504112376B6E0798CA20CE /* Variables.swift in Sources */, DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */, @@ -4019,12 +4031,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "Superscript-iOS" */ = { + 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/superwall/Superscript-iOS"; + repositoryURL = "https://github.com/superwall/superscript-ios-next"; requirement = { kind = exactVersion; - version = 1.0.13; + version = 1.0.14; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -4032,7 +4044,7 @@ /* Begin XCSwiftPackageProductDependency section */ 721C720FA8360B9851DE843D /* Superscript */ = { isa = XCSwiftPackageProductDependency; - package = 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "Superscript-iOS" */; + package = 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */; productName = Superscript; }; /* End XCSwiftPackageProductDependency section */ diff --git a/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fb1b66dcba..8434c257b6 100644 --- a/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -2,12 +2,12 @@ "originHash" : "db362fdf36eaf1c479870b40c7a6c2e2165345c7210ebe36257ee38523125878", "pins" : [ { - "identity" : "superscript-ios", + "identity" : "superscript-ios-next", "kind" : "remoteSourceControl", - "location" : "https://github.com/superwall/Superscript-iOS", + "location" : "https://github.com/superwall/superscript-ios-next", "state" : { - "revision" : "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version" : "1.0.13" + "revision" : "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version" : "1.0.14" } } ], diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift new file mode 100644 index 0000000000..f5c82e735d --- /dev/null +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift @@ -0,0 +1,236 @@ +// +// AdServicesAttributionTests.swift +// SuperwallKit +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct AdServicesAttributionTests { + // MARK: - AdServicesResponse decoding + + @Test + func adServicesResponse_decodesBackendSuccessShape() throws { + // Matches paywall-next:/apple-search-ads/token success body: + // { "status": "ok", "attribution": { ... } } + // We don't model `status`; we just need attribution to decode. + let json = """ + { + "status": "ok", + "attribution": { + "apple_search_ads_campaign_id": "12345", + "apple_search_ads_keyword_id": "kw-1", + "acquisition_source": "apple_search_ads" + } + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) + + #expect(decoded.attribution["apple_search_ads_campaign_id"]?.stringValue == "12345") + #expect(decoded.attribution["apple_search_ads_keyword_id"]?.stringValue == "kw-1") + #expect(decoded.attribution["acquisition_source"]?.stringValue == "apple_search_ads") + } + + // MARK: - AdServicesAttributionAttempts round-trip + + @Test + func attempts_roundTripsThroughCodable() throws { + let original = AdServicesAttributionAttempts( + count: 3, + firstAttemptDate: Date(timeIntervalSince1970: 1_700_000_000), + lastAttemptDate: Date(timeIntervalSince1970: 1_700_001_000) + ) + + let data = try JSONEncoder().encode(original) + let restored = try JSONDecoder().decode(AdServicesAttributionAttempts.self, from: data) + + #expect(restored == original) + } + + @Test + func attempts_storageRoundTripsViaCache() { + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + + // Make sure we start clean — previous test runs can leave state behind. + storage.delete(AdServicesAttributionAttemptsStorage.self) + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == nil) + + let attempts = AdServicesAttributionAttempts( + count: 2, + firstAttemptDate: Date(timeIntervalSince1970: 1_700_000_000), + lastAttemptDate: Date(timeIntervalSince1970: 1_700_000_500) + ) + storage.save(attempts, forType: AdServicesAttributionAttemptsStorage.self) + + let restored = storage.get(AdServicesAttributionAttemptsStorage.self) + #expect(restored == attempts) + + storage.delete(AdServicesAttributionAttemptsStorage.self) + } + + // MARK: - AttributionPoster gating + + @Test + func getAdServicesToken_noOpsWhenAlreadyPosted() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + // Pretend a previous run successfully posted. + storage.save("sentinel-token", forType: AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionAttemptsStorage.self) + + await poster.getAdServicesTokenIfNeeded() + + // Sentinel should still be there and we should not have recorded a fresh + // failed attempt — the poster should have bailed before touching either. + #expect(storage.get(AdServicesTokenStorage.self) == "sentinel-token") + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == nil) + + // cleanup + storage.delete(AdServicesTokenStorage.self) + } + + @Test + func cancelInFlight_clearsCollectingFlag() { + let dependencyContainer = DependencyContainer() + // Should be a no-op when nothing is in flight, but must not crash. + dependencyContainer.attributionPoster!.cancelInFlight() + } + + // MARK: - canStartAttempt guards + // + // We test the guards by setting up storage state where they should bail, + // calling `getAdServicesTokenIfNeeded`, and asserting nothing was written + // or mutated. The alternative — passing the canStartAttempt guard — would + // make a real network call, so every test in this block must arrange for a + // bail. Config is left in `.retrieving` state for most of these because the + // tests don't depend on the config-enabled branch; the budget / sentinel + // checks short-circuit before the config check is reached only when the + // config-enabled guard is also failing — that's still a valid bail, and we + // assert "nothing changed" either way. + + @Test + func getAdServicesToken_bailsWhenMaxAttemptsReached() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionUnsupportedStorage.self) + let saturated = AdServicesAttributionAttempts( + count: AttributionPoster.maxAttempts, + firstAttemptDate: Date(), + lastAttemptDate: Date() + ) + storage.save(saturated, forType: AdServicesAttributionAttemptsStorage.self) + dependencyContainer.configManager.configState.send(.retrieved(.stub())) + + await poster.getAdServicesTokenIfNeeded() + + // Attempts record must NOT have been bumped — that would mean we tried + // anyway, defeating the cap. + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == saturated) + #expect(storage.get(AdServicesTokenStorage.self) == nil) + + storage.delete(AdServicesAttributionAttemptsStorage.self) + } + + @Test + func getAdServicesToken_bailsWhenRetryWindowExpired() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionUnsupportedStorage.self) + let firstAttempt = Date().addingTimeInterval(-AttributionPoster.maxRetryWindow - 60) + let expired = AdServicesAttributionAttempts( + count: 2, + firstAttemptDate: firstAttempt, + lastAttemptDate: firstAttempt.addingTimeInterval(30) + ) + storage.save(expired, forType: AdServicesAttributionAttemptsStorage.self) + dependencyContainer.configManager.configState.send(.retrieved(.stub())) + + await poster.getAdServicesTokenIfNeeded() + + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == expired) + #expect(storage.get(AdServicesTokenStorage.self) == nil) + + storage.delete(AdServicesAttributionAttemptsStorage.self) + } + + @Test + func attributionData_roundTripsThroughStorage() { + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + + storage.delete(AdServicesAttributionDataStorage.self) + + let cached: [String: JSON] = [ + "apple_search_ads_campaign_id": "campaign-123", + "acquisition_source": "apple_search_ads" + ] + storage.save(cached, forType: AdServicesAttributionDataStorage.self) + + let restored = storage.get(AdServicesAttributionDataStorage.self) + #expect(restored?["apple_search_ads_campaign_id"]?.stringValue == "campaign-123") + #expect(restored?["acquisition_source"]?.stringValue == "apple_search_ads") + + storage.delete(AdServicesAttributionDataStorage.self) + } + + @Test + func reapplyCachedAttribution_noCrashWhenNothingCached() { + // The user-attribute side effect happens on Superwall.shared (the global + // singleton) and is detached from the test's local DependencyContainer, + // so asserting on the resulting attributes from inside this test isn't + // reliable. Just confirm the no-op path doesn't crash with empty state. + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesAttributionDataStorage.self) + + poster.reapplyCachedAttribution() + } + + @Test + func getAdServicesToken_bailsWhenPermanentlyUnsupported() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionAttemptsStorage.self) + storage.save(true, forType: AdServicesAttributionUnsupportedStorage.self) + dependencyContainer.configManager.configState.send(.retrieved(.stub())) + + await poster.getAdServicesTokenIfNeeded() + + // No attempts record written, no success token written — the unsupported + // sentinel is a hard stop. + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == nil) + #expect(storage.get(AdServicesTokenStorage.self) == nil) + #expect(storage.get(AdServicesAttributionUnsupportedStorage.self) == true) + + storage.delete(AdServicesAttributionUnsupportedStorage.self) + } +} diff --git a/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift b/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift index 2a62e55f1a..2c08062606 100644 --- a/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift +++ b/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift @@ -12,7 +12,7 @@ import Foundation struct FileManagerMigratorTests { @Test - func migrateFromV1ToV4() { + func migrateFromV1ToV5() { let cache = CacheMock() // Write all possible values to the cache. @@ -57,9 +57,9 @@ struct FileManagerMigratorTests { #expect(newAssignments?.first?.experimentId == experimentId) #expect(newAssignments?.first?.variant == variant) - // Check the new version is v4 + // Check the new version is v5 let version = cache.read(Version.self) - #expect(version == .v4) + #expect(version == .v5) } @Test @@ -118,4 +118,51 @@ struct FileManagerMigratorTests { let version = cache.read(Version.self) #expect(version == .v4) } + + @Test + func migrateAdServicesTokenFromV4ToV5() { + let cache = CacheMock() + + // Pre-v5 state: token sentinel in user-specific docs. + cache.write("legacy-token", forType: LegacyUserScopedAdServicesTokenStorage.self) + cache.write(.v4, forType: Version.self) + + #expect(cache.read(LegacyUserScopedAdServicesTokenStorage.self) == "legacy-token") + #expect(cache.read(AdServicesTokenStorage.self) == nil) + + V4Migrator.migrateToNextVersion(cache: cache) + + // Token moved to app-specific, legacy file gone. + #expect(cache.read(AdServicesTokenStorage.self) == "legacy-token") + #expect(cache.read(LegacyUserScopedAdServicesTokenStorage.self) == nil) + #expect(cache.read(Version.self) == .v5) + } + + @Test + func migrateAdServicesTokenFromV4ToV5_noLegacyData() { + let cache = CacheMock() + cache.write(.v4, forType: Version.self) + + V4Migrator.migrateToNextVersion(cache: cache) + + // Nothing to migrate — version still bumps to v5. + #expect(cache.read(AdServicesTokenStorage.self) == nil) + #expect(cache.read(Version.self) == .v5) + } + + @Test + func migrateAdServicesTokenFromV4ToV5_doesntOverwriteExisting() { + let cache = CacheMock() + + // Both legacy and new exist (shouldn't happen in practice, but defend + // against it — the new value wins). + cache.write("legacy", forType: LegacyUserScopedAdServicesTokenStorage.self) + cache.write("current", forType: AdServicesTokenStorage.self) + cache.write(.v4, forType: Version.self) + + V4Migrator.migrateToNextVersion(cache: cache) + + #expect(cache.read(AdServicesTokenStorage.self) == "current") + #expect(cache.read(Version.self) == .v5) + } } diff --git a/project.yml b/project.yml index 1ba3470851..efd42e923c 100644 --- a/project.yml +++ b/project.yml @@ -4,8 +4,8 @@ options: packages: Superscript: - url: https://github.com/superwall/Superscript-iOS - exactVersion: "1.0.13" + url: https://github.com/superwall/superscript-ios-next + exactVersion: "1.0.14" targets: SuperwallKit: