diff --git a/CHANGELOG.md b/CHANGELOG.md index dd797c7f8..30fb1e660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,14 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. Requires iOS 15+. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. +- Adds best-effort public IPv4 and observed IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. ### Fixes - Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading. ## 4.17.0 + ### Enhancements - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. diff --git a/CLAUDE.md b/CLAUDE.md index 58b693796..5d1b4b5e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,15 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL - [ ] I have updated the SDK documentation as well as the online docs. - [ ] I have reviewed the [contributing guide](https://github.com/superwall-me/paywall-ios/tree/master/.github/CONTRIBUTING.md) +### Device IP enrichment + +`DeviceIPCollector` owns session-local, timestamped IP observations. Collection is for the +MMP and stays off unless the backend's `attributionOptions.mmp.enabled` is on; SuperwallKit's privacy +manifest doesn't declare it, so apps that turn the MMP on declare it themselves. Keep collection +independent of enrichment success and purchase/configuration latency, preserve each family +separately, and filter stale cached `ipV4`/`ipV6` fields before exposing device attributes. +Do not add customer attributes or authentication headers to the public IPv4 collection request. + ### Integration device identifiers AttributionFetcher refreshes IDFV/IDFA/ATT when setting integration attributes and diff --git a/README.md b/README.md index 21ad9fc5e..d081a95aa 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,33 @@ Check out our sample apps for a hands-on demonstration of the SDK: ## Contributing Please see the [CONTRIBUTING](.github/CONTRIBUTING.md) file for how to help. + +### Device IP observations + +IP collection is off by default. It only runs when the backend turns on +`attributionOptions.mmp.enabled` in the app's config. When it's off, no IPv4 request is made and no `ipV4`/`ipV6` +attributes are exposed. + +During enrichment, the SDK also starts a best-effort request to +`https://v4.superwall-enrichment.com/api/v1/enrich`. It sends no user attributes or API key +to this endpoint. The host is kept separate from the main enrichment API on purpose: it +only has an IPv4 address (no AAAA record), so the request always goes out over IPv4. It has +no development version, so the request is only made with the release network environments. +Timestamps may be sent with or without milliseconds. The existing enrichment API continues to provide geo and demand scoring. +It must also return the observed `ipV4` or `ipV6` and corresponding ISO 8601 +`ipV4ObservedAt` / `ipV6ObservedAt` timestamp to capture that connection's address. + +The SDK exposes `ipV4`, `ipV6`, `ipV4ObservedAt`, and `ipV6ObservedAt` as device +attributes when available. They remain separate: an IPv4 response does not erase IPv6. +IPv6 is opportunistic; a dual-stack enrichment request can use IPv4 even on a device +with IPv6. These are public network egress addresses, potentially shared through NAT or VPN. + +The extra request never blocks configuration or purchases. While the MMP is on, a lookup +can start whenever device attributes are read, at most once every 15 minutes, or a minute +after a failed one. The IPv4 collector is session-local; the existing enrichment cache may restore a still-fresh +observation after relaunch. All observations are omitted from device attributes after 15 +minutes; network changes can make them stale sooner. +Any `ipAddress` the enrichment API returns is passed through unchanged. +They are not proof of identity. Downstream consumers must preserve the timestamps and apply +their own freshness policy. Wrapper SDKs receive this behavior when they adopt a native +SDK release containing it. diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index bc2c09581..d9162b20e 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -447,6 +447,16 @@ class ConfigManager { ) } + /// Saves `config` and applies the parts that don't depend on purchases. + private func storeAndApply(_ config: Config) { + storage.save( + config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) + storage.save(config, forType: LatestConfig.self) + triggersByPlacementName = ConfigLogic.getTriggersByPlacementName(from: config.triggers) + choosePaywallVariants(from: config.triggers) + deviceHelper.startIPCollectionIfEnabled(for: config) + } + /// Applies `config`, loads purchases from StoreKit, and sends `configState`. /// /// - Parameter savedCustomerInfo: When given, `configState` is sent before the @@ -457,11 +467,7 @@ class ConfigManager { isFirstTime: Bool, publishingEarlyFrom savedCustomerInfo: CustomerInfo? = nil ) async { - storage.save( - config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) - storage.save(config, forType: LatestConfig.self) - triggersByPlacementName = ConfigLogic.getTriggersByPlacementName(from: config.triggers) - choosePaywallVariants(from: config.triggers) + storeAndApply(config) // Evaluate test mode before loading products let testModeManager = factory.makeTestModeManager() diff --git a/Sources/SuperwallKit/Config/Models/Attribution.swift b/Sources/SuperwallKit/Config/Models/Attribution.swift index d5e7b173b..97b43a10f 100644 --- a/Sources/SuperwallKit/Config/Models/Attribution.swift +++ b/Sources/SuperwallKit/Config/Models/Attribution.swift @@ -9,8 +9,22 @@ import Foundation struct Attribution: Codable, Equatable { let appleSearchAds: AppleSearchAds? + /// Superwall's install attribution (MMP). Off unless the backend enables it. + let mmp: MMPAttribution? + + init( + appleSearchAds: AppleSearchAds?, + mmp: MMPAttribution? = nil + ) { + self.appleSearchAds = appleSearchAds + self.mmp = mmp + } } struct AppleSearchAds: Codable, Equatable { let enabled: Bool } + +struct MMPAttribution: Codable, Equatable { + let enabled: Bool +} diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 5c3a24bf0..fb11afbcd 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -273,6 +273,19 @@ public final class SuperwallOptions: NSObject, Encodable { } } + /// The host that only answers over IPv4, used to learn the device's public + /// IPv4 address. There's no non-production version, so it's `nil` outside + /// release builds. + var ipV4EnrichmentHost: String? { + switch self { + case .release, + .releaseCandidate: + return "v4.superwall-enrichment.com" + default: + return nil + } + } + var adServicesHost: String { return "api-adservices.apple.com" } diff --git a/Sources/SuperwallKit/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index d8a45fd31..cfa034aa6 100644 --- a/Sources/SuperwallKit/Network/API.swift +++ b/Sources/SuperwallKit/Network/API.swift @@ -94,6 +94,12 @@ struct Api { var scheme: String { return networkEnvironment.scheme } var host: String { return networkEnvironment.enrichmentHost } var path: String { return "/api/v1/" } + var ipV4Url: URL? { + guard let host = networkEnvironment.ipV4EnrichmentHost else { + return nil + } + return URL(string: "https://\(host)\(path)enrich") + } 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 19b15645a..c955c557f 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -14,6 +14,7 @@ import CoreTelephony import StoreKit class DeviceHelper { + private let ipCollector: DeviceIPCollector var localeIdentifier: String { let localeIdentifier = factory.makeLocaleIdentifier() return localeIdentifier ?? Locale.autoupdatingCurrent.identifier @@ -927,6 +928,7 @@ class DeviceHelper { private unowned let factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory + & ConfigStateFactory init( api: Api, @@ -934,9 +936,11 @@ class DeviceHelper { network: Network, entitlementsInfo: EntitlementsInfo, receiptManager: ReceiptManager, - factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory, + factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory & ConfigStateFactory, + ipCollector: DeviceIPCollector? = nil, isUIKitReadSafe: @escaping () -> Bool = { DeviceHelper.isUIKitReadSafe } ) { + self.ipCollector = ipCollector ?? DeviceIPCollector(url: api.enrichment.ipV4Url) self.storage = storage self.network = network self.entitlementsInfo = entitlementsInfo @@ -962,6 +966,19 @@ class DeviceHelper { } } + /// Starts the IPv4 lookup once config turns the MMP on. On a cold launch + /// the first device-attributes read happens before config arrives, so + /// without this the lookup would wait for some later read. + @discardableResult + func startIPCollectionIfEnabled(for config: Config) -> Task? { + if config.attribution?.mmp?.enabled != true { + return nil + } + return Task { + await ipCollector.refreshIfNeeded()?.value + } + } + func getEnrichment( maxRetry: Int? = nil, timeout: Seconds? = nil @@ -1079,6 +1096,17 @@ class DeviceHelper { // Merge in enrichment dictionary, giving priority to // the existing values. deviceDictionary.merge(enrichmentDict) { current, _ in current } + for key in ["ipV4", "ipV6", "ipV4ObservedAt", "ipV6ObservedAt"] { + deviceDictionary.removeValue(forKey: key) + } + // Kept in memory whatever the config says, since on a cold launch the + // first enrichment arrives before config does. + await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) + // IP collection is for the MMP, which is off unless the backend turns it on. + if factory.makeConfigState().value.getConfig()?.attribution?.mmp?.enabled == true { + await ipCollector.refreshIfNeeded() + deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed } + } if #available(iOS 15.0, *), let storefront = await Storefront.current { diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift new file mode 100644 index 000000000..aa73ad20b --- /dev/null +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift @@ -0,0 +1,192 @@ +// +// DeviceIPCollector.swift +// SuperwallKit +// +// Created by Brian Anglin on 15/09/2026. +// + +import Foundation +import Darwin + +/// Best-effort, session-local observations. Never waits on the network when reading attributes. +actor DeviceIPCollector { + typealias Fetch = () async throws -> [String: String] + private let fetch: Fetch? + private let now: () -> Date + private var lastAttempt: Date? + private var nextAttemptAt: Date? + private var observations: [String: String] = [:] + private let lifetime: TimeInterval = 15 * 60 + /// How long to wait after a failed fetch. Shorter than `lifetime` so a + /// blip is retried soon, but long enough that an outage or offline device + /// doesn't send a request on every read of the device attributes. + private let retryDelay: TimeInterval = 60 + + /// - Parameters: + /// - url: The IPv4-only endpoint to ask. When `nil`, nothing is fetched. + /// - fetch: Overrides the request, for tests. + init( + url: URL?, + fetch: Fetch? = nil, + now: @escaping () -> Date = Date.init + ) { + if let fetch = fetch { + self.fetch = fetch + } else if let url = url { + self.fetch = { try await Self.fetchIPv4(from: url) } + } else { + self.fetch = nil + } + self.now = now + } + + /// Starts a fetch in the background unless one was tried recently. The + /// returned task is only there so tests can wait for it. + @discardableResult + func refreshIfNeeded() -> Task? { + guard let fetch = fetch else { + return nil + } + let date = now() + if let nextAttemptAt = nextAttemptAt, + date < nextAttemptAt { + return nil + } + lastAttempt = date + nextAttemptAt = date.addingTimeInterval(lifetime) + return Task { + do { + record(try await fetch()) + } catch { + // Try again sooner than a success would, unless a newer attempt started. + if lastAttempt == date { + nextAttemptAt = date.addingTimeInterval(retryDelay) + } + Logger.debug( + logLevel: .debug, + scope: .network, + message: "Couldn't fetch the device's IPv4 address", + error: error + ) + } + } + } + + func record(_ device: [String: String]) { + for family in [4, 6] { + let key = "ipV\(family)" + // Each address only counts with its own timestamp, so an `ipV6` with no + // time can't borrow the time of an IPv4 `ipAddress`. + let address: String? + let timestamp: String? + if let familyAddress = device[key] { + address = familyAddress + timestamp = device["\(key)ObservedAt"] + } else { + address = device["ipAddress"] + timestamp = device["ipAddressObservedAt"] + } + guard + let address = address, + let timestamp = timestamp, + let date = Self.date(from: timestamp), + Self.isValid(address, family: family), + isFresh(date) + else { + continue + } + if let previous = observations["\(key)ObservedAt"], + let previousDate = Self.date(from: previous), + previousDate > date { + continue + } + observations[key] = address + observations["\(key)ObservedAt"] = timestamp + } + } + + func attributes() -> [String: String] { + var result: [String: String] = [:] + for key in ["ipV4", "ipV6"] { + if let timestamp = observations["\(key)ObservedAt"], + let date = Self.date(from: timestamp), + isFresh(date) { + result[key] = observations[key] + result["\(key)ObservedAt"] = timestamp + } + } + return result + } + + private func isFresh(_ date: Date) -> Bool { + let age = now().timeIntervalSince(date) + return age >= -60 && age < lifetime + } + + static func isValid(_ address: String, family: Int) -> Bool { + if family == 4 { + var bytes = in_addr() + return inet_pton(AF_INET, address, &bytes) == 1 + } + var bytes = in6_addr() + return inet_pton(AF_INET6, address, &bytes) == 1 && !address.lowercased().hasPrefix("::ffff:") + } + + private static let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let wholeSecondFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + /// Parses ISO 8601 timestamps with or without milliseconds. + static func date(from timestamp: String) -> Date? { + return fractionalFormatter.date(from: timestamp) + ?? wholeSecondFormatter.date(from: timestamp) + } + + static func fetchIPv4(from url: URL) async throws -> [String: String] { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 3 + config.timeoutIntervalForResource = 3 + let session = URLSession(configuration: config) + defer { session.finishTasksAndInvalidate() } + let data: Data = try await withCheckedThrowingContinuation { continuation in + let task = session.dataTask(with: url) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard + let response = response as? HTTPURLResponse, + response.statusCode == 200, + let data = data, + data.count < 16_384 + else { + continuation.resume(throwing: URLError(.badServerResponse)) + return + } + continuation.resume(returning: data) + } + task.resume() + } + return try parseDevice(from: data) + } + + /// Reads the string values of the response's `device` object, skipping any + /// that aren't strings. + static func parseDevice(from data: Data) throws -> [String: String] { + guard + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let device = json["device"] as? [String: Any] + else { + throw URLError(.cannotParseResponse) + } + return device.compactMapValues { $0 as? String } + } +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 3a05c6752..58c597603 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ 4EF50815E31F85400513F053 /* PresentationItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23F2FE294EBC63F81786A85 /* PresentationItems.swift */; }; 4EFA142D3D37B0564BDB52CB /* NetworkMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */; }; 4FE19D26711ECB7B2EE8806D /* TriggerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481D47E5121C521DDA268609 /* TriggerRule.swift */; }; + 506EAD727C35C5A620D1B1BC /* DeviceIPCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A8859BFC55BD33AF575BEE /* DeviceIPCollectorTests.swift */; }; 507E017DBEC2663F1B4727E0 /* Dictionary+Merging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335888285131F832E1C91A8F /* Dictionary+Merging.swift */; }; 50E0E5F4B476F2F5B8DF299E /* DevMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C76F083AB62E59C5DF7FEE /* DevMode.swift */; }; 519196E0035C17C73C525526 /* V2Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */; }; @@ -348,6 +349,7 @@ 8901727EE5E2048125791BAB /* CustomerCenterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF06E9F79CA4C3BABD0D887 /* CustomerCenterViewController.swift */; }; 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */; }; 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */; }; + 8A4B27BB3442FDE107187EDE /* DeviceIPCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E64F6845B4CB61F7C7A5657 /* DeviceIPCollector.swift */; }; 8ACC4731031DA94C709915CF /* Transaction+LatestSince.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */; }; 8AEB577682D9AB9354CB8EE9 /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2888B05A273E9EB6E9382332 /* UIColor+Hex.swift */; }; 8B200F99B71D706D45948339 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E783785F917188D12F4B4 /* Utils.swift */; }; @@ -1134,6 +1136,7 @@ 9DC4D23D1EDDA249C928930D /* PaddingListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingListener.swift; sourceTree = ""; }; 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementProcessorTests.swift; sourceTree = ""; }; + 9E64F6845B4CB61F7C7A5657 /* DeviceIPCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIPCollector.swift; sourceTree = ""; }; 9E80C81546752BCF32016A27 /* InAppReceipt+ASN1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InAppReceipt+ASN1.swift"; sourceTree = ""; }; 9EF73F40EFFEC58FA0D30DC4 /* SuperwallPlacementInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallPlacementInfo.swift; sourceTree = ""; }; 9EF9D5F77A002F6F0C03C77F /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1281,6 +1284,7 @@ C7FFF0EDFF6DA910E4B5CCB7 /* LocalizationOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationOption.swift; sourceTree = ""; }; C825F0AD231C62462873E51A /* PresentationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationRequest.swift; sourceTree = ""; }; C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+SafeRemove.swift"; sourceTree = ""; }; + C8A8859BFC55BD33AF575BEE /* DeviceIPCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIPCollectorTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -1742,6 +1746,7 @@ children = ( BB242DC77FEC0BE10C0DDC9C /* DeviceHelper.swift */, 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */, + 9E64F6845B4CB61F7C7A5657 /* DeviceIPCollector.swift */, 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */, 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */, ); @@ -3142,6 +3147,7 @@ C1B0B520B9B41EFC6141B0BF /* SuperwallKitTests */ = { isa = PBXGroup; children = ( + C8A8859BFC55BD33AF575BEE /* DeviceIPCollectorTests.swift */, C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */, FE0E783785F917188D12F4B4 /* Utils.swift */, E338A5AE4BB82E592AE9B9BA /* Analytics */, @@ -3803,6 +3809,7 @@ 6C8D27EE5DAB83D7F21F45F0 /* DevServerSettingsTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, + 506EAD727C35C5A620D1B1BC /* DeviceIPCollectorTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */, A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */, @@ -4048,6 +4055,7 @@ CC82569EF062EE50C1B06373 /* DevServerSettings.swift in Sources */, 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */, 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */, + 8A4B27BB3442FDE107187EDE /* DeviceIPCollector.swift in Sources */, DB7506D37B2BE86074BFF901 /* DeviceIdentifiers.swift in Sources */, 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */, 6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift new file mode 100644 index 000000000..32d22c797 --- /dev/null +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing +@testable import SuperwallKit + +struct DeviceIPCollectorTests { + private let date = Date(timeIntervalSince1970: 1_789_505_000) + + private actor FetchCounter { + var count = 0 + var shouldFail: Bool + + init(shouldFail: Bool = false) { + self.shouldFail = shouldFail + } + + func fetch() throws -> [String: String] { + count += 1 + if shouldFail { + throw URLError(.timedOut) + } + return [:] + } + + func setShouldFail(_ value: Bool) { + shouldFail = value + } + } + + @Test func keepsFamiliesSeparateAndRejectsStaleOrInvalidObservations() async { + let collector = DeviceIPCollector(url: nil, now: { date }) + let timestamp = ISO8601DateFormatter.string(from: date, timeZone: TimeZone(secondsFromGMT: 0)!, formatOptions: [.withInternetDateTime, .withFractionalSeconds]) + await collector.record(["ipAddress": "8.8.8.8", "ipAddressObservedAt": timestamp]) + await collector.record(["ipV6": "2001:db8::1", "ipV6ObservedAt": timestamp]) + await collector.record(["ipV4": "1.1.1.1", "ipV4ObservedAt": "2000-01-01T00:00:00.000Z"]) + await collector.record(["ipV6": "not-an-ip", "ipV6ObservedAt": timestamp]) + let attributes = await collector.attributes() + #expect(attributes["ipV4"] == "8.8.8.8") + #expect(attributes["ipV6"] == "2001:db8::1") + #expect(attributes.count == 4) + } + + @Test func acceptsTimestampsWithoutMilliseconds() async { + let collector = DeviceIPCollector(url: nil, now: { date }) + let timestamp = ISO8601DateFormatter.string(from: date, timeZone: TimeZone(secondsFromGMT: 0)!, formatOptions: [.withInternetDateTime]) + #expect(!timestamp.contains(".")) + await collector.record(["ipV4": "8.8.8.8", "ipV4ObservedAt": timestamp]) + let attributes = await collector.attributes() + #expect(attributes["ipV4"] == "8.8.8.8") + #expect(attributes["ipV4ObservedAt"] == timestamp) + } + + @Test func doesNotPairAnAddressWithAnotherFamilysTimestamp() async { + let collector = DeviceIPCollector(url: nil, now: { date }) + await collector.record([ + "ipV6": "2001:db8::1", + "ipAddress": "8.8.8.8", + "ipAddressObservedAt": "2026-09-15T20:40:00Z" + ]) + let attributes = await collector.attributes() + #expect(attributes["ipV4"] == "8.8.8.8") + #expect(attributes["ipV6"] == nil) + } + + @Test func keepsTheNewerObservation() async { + let collector = DeviceIPCollector(url: nil, now: { date }) + await collector.record(["ipV4": "8.8.8.8", "ipV4ObservedAt": "2026-09-15T20:40:00Z"]) + await collector.record(["ipV4": "1.1.1.1", "ipV4ObservedAt": "2026-09-15T20:39:00.500Z"]) + #expect(await collector.attributes()["ipV4"] == "8.8.8.8") + } + + @Test func validatesNumericFamiliesWithoutDNS() { + #expect(DeviceIPCollector.isValid("8.8.8.8", family: 4)) + #expect(!DeviceIPCollector.isValid("999.8.8.8", family: 4)) + #expect(!DeviceIPCollector.isValid("example.com", family: 6)) + #expect(!DeviceIPCollector.isValid("::ffff:8.8.8.8", family: 6)) + } + + @Test func parsesDeviceStringsAndSkipsOtherValues() throws { + let data = Data(#"{"user":{},"device":{"ipAddress":"8.8.8.8","demandScore":42}}"#.utf8) + let device = try DeviceIPCollector.parseDevice(from: data) + #expect(device == ["ipAddress": "8.8.8.8"]) + } + + @Test func coalescesRefreshesWithoutWaitingForNetwork() async { + let counter = FetchCounter() + let collector = DeviceIPCollector(url: nil, fetch: { try await counter.fetch() }, now: { date }) + let first = await collector.refreshIfNeeded() + let second = await collector.refreshIfNeeded() + #expect(first != nil) + #expect(second == nil) + await first?.value + #expect(await counter.count == 1) + } + + private final class Clock: @unchecked Sendable { + var date: Date + init(_ date: Date) { + self.date = date + } + } + + @Test func waitsAMinuteBeforeRetryingAFailedFetch() async { + let counter = FetchCounter(shouldFail: true) + let clock = Clock(date) + let collector = DeviceIPCollector(url: nil, fetch: { try await counter.fetch() }, now: { clock.date }) + await collector.refreshIfNeeded()?.value + + clock.date = date.addingTimeInterval(30) + #expect(await collector.refreshIfNeeded() == nil) + + await counter.setShouldFail(false) + clock.date = date.addingTimeInterval(61) + await collector.refreshIfNeeded()?.value + #expect(await counter.count == 2) + + // A success waits out the full 15 minutes. + clock.date = date.addingTimeInterval(61 + 14 * 60) + #expect(await collector.refreshIfNeeded() == nil) + clock.date = date.addingTimeInterval(61 + 15 * 60) + #expect(await collector.refreshIfNeeded() != nil) + } + + @Test func doesNothingWithoutAnEndpoint() async { + let collector = DeviceIPCollector(url: nil, now: { date }) + #expect(await collector.refreshIfNeeded() == nil) + } + + @Test func mmpFlagIsOffUnlessTheBackendTurnsItOn() throws { + let decoder = JSONDecoder() + let off = try decoder.decode(Attribution.self, from: Data(#"{"appleSearchAds":{"enabled":true}}"#.utf8)) + let on = try decoder.decode(Attribution.self, from: Data(#"{"mmp":{"enabled":true}}"#.utf8)) + #expect(off.mmp == nil) + #expect(on.mmp?.enabled == true) + } + + @Test func onlyReleaseEnvironmentsHaveAnIPv4Endpoint() { + #expect(Api(networkEnvironment: .release).enrichment.ipV4Url?.absoluteString == "https://v4.superwall-enrichment.com/api/v1/enrich") + #expect(Api(networkEnvironment: .releaseCandidate).enrichment.ipV4Url != nil) + #expect(Api(networkEnvironment: .developer).enrichment.ipV4Url == nil) + #expect(Api(networkEnvironment: .local).enrichment.ipV4Url == nil) + } +} diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift index 6298bf67b..0fced94fe 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -356,7 +356,8 @@ struct DeviceHelperTests { /// Builds a `DeviceHelper` with an injected gate. The container is returned /// alongside because the helper only holds its factory `unowned`. private func makeDeviceHelper( - gate: Gate + gate: Gate, + ipCollector: DeviceIPCollector? = nil ) -> (DeviceHelper, DependencyContainer) { let dependencyContainer = DependencyContainer() let deviceHelper = DeviceHelper( @@ -366,11 +367,129 @@ struct DeviceHelperTests { entitlementsInfo: dependencyContainer.entitlementsInfo, receiptManager: dependencyContainer.receiptManager, factory: dependencyContainer, + ipCollector: ipCollector, isUIKitReadSafe: { gate.isOpen } ) return (deviceHelper, dependencyContainer) } + /// Cached enrichment can carry IP observations that have since gone stale. + /// Only fresh ones may reach the device attributes, and `ipAddress` passes + /// through as the enrichment sent it. + private func makeIPEnrichment() -> Enrichment { + return Enrichment( + user: JSON([:]), + device: JSON([ + "ipV4": "1.1.1.1", + "ipV4ObservedAt": "2000-01-01T00:00:00Z", + "ipV6": "2001:db8::1", + "ipV6ObservedAt": "2026-09-15T20:40:00Z", + "ipAddress": "1.1.1.1" + ]) + ) + } + + private func makeConfig(mmpEnabled: Bool) -> Config { + var config = Config.stub() + config.attribution = Attribution( + appleSearchAds: AppleSearchAds(enabled: true), + mmp: MMPAttribution(enabled: mmpEnabled) + ) + return config + } + + private func setMMPFlag(_ isEnabled: Bool, on dependencyContainer: DependencyContainer) { + dependencyContainer.configManager.configState.send(.retrieved(makeConfig(mmpEnabled: isEnabled))) + } + + /// On a cold launch the first enrichment is read before config arrives. Its + /// IPs must still count once config turns the MMP on, and config arriving + /// must start the IPv4 lookup without waiting for another read. + @Test func coldLaunch_keepsEnrichmentIPsAndStartsLookupWhenConfigArrives() async { + let now = Date(timeIntervalSince1970: 1_789_505_000) + let fetches = FetchCount() + let collector = DeviceIPCollector( + url: nil, + fetch: { + await fetches.increment() + return [:] + }, + now: { now } + ) + let (deviceHelper, dependencyContainer) = makeDeviceHelper( + gate: Gate(true), + ipCollector: collector + ) + deviceHelper.enrichment = makeIPEnrichment() + + let beforeConfig = await deviceHelper.getTemplateDevice() + #expect(beforeConfig["ipV6"] == nil) + #expect(await fetches.count == 0) + + let config = makeConfig(mmpEnabled: true) + await deviceHelper.startIPCollectionIfEnabled(for: config)?.value + #expect(await fetches.count == 1) + + dependencyContainer.configManager.configState.send(.retrieved(config)) + deviceHelper.enrichment = nil + let afterConfig = await deviceHelper.getTemplateDevice() + #expect(afterConfig["ipV6"] as? String == "2001:db8::1") + } + + @Test func configWithMMPOff_doesNotStartLookup() { + let (deviceHelper, dependencyContainer) = makeDeviceHelper(gate: Gate(true)) + _ = dependencyContainer + #expect(deviceHelper.startIPCollectionIfEnabled(for: makeConfig(mmpEnabled: false)) == nil) + } + + private actor FetchCount { + var count = 0 + func increment() { + count += 1 + } + } + + @Test func templateDevice_dropsStaleIPObservations() async { + let now = Date(timeIntervalSince1970: 1_789_505_000) + let collector = DeviceIPCollector(url: nil, now: { now }) + let (deviceHelper, dependencyContainer) = makeDeviceHelper( + gate: Gate(true), + ipCollector: collector + ) + setMMPFlag(true, on: dependencyContainer) + deviceHelper.enrichment = makeIPEnrichment() + + let template = await deviceHelper.getTemplateDevice() + + #expect(template["ipV4"] == nil) + #expect(template["ipV4ObservedAt"] == nil) + #expect(template["ipV6"] as? String == "2001:db8::1") + #expect(template["ipV6ObservedAt"] as? String == "2026-09-15T20:40:00Z") + #expect(template["ipAddress"] as? String == "1.1.1.1") + } + + /// IP collection is off unless the backend turns on the MMP flag: no lookup + /// is started and no `ipV4`/`ipV6` attributes are exposed. + @Test func templateDevice_withoutMMPFlag_skipsIPCollection() async { + let now = Date(timeIntervalSince1970: 1_789_505_000) + let collector = DeviceIPCollector(url: nil, fetch: { [:] }, now: { now }) + let (deviceHelper, dependencyContainer) = makeDeviceHelper( + gate: Gate(true), + ipCollector: collector + ) + setMMPFlag(false, on: dependencyContainer) + deviceHelper.enrichment = makeIPEnrichment() + + let template = await deviceHelper.getTemplateDevice() + + #expect(template["ipV4"] == nil) + #expect(template["ipV6"] == nil) + #expect(template["ipV6ObservedAt"] == nil) + #expect(template["ipAddress"] as? String == "1.1.1.1") + // A lookup would have been started, so this one wouldn't be skipped. + #expect(await collector.refreshIfNeeded() != nil) + } + private func expectedLiveValues() async -> ( width: Int, height: Int, scale: Double, style: String, category: String ) {