From 4cca2f6b5c317e79d58050a7a78e8cf77f1304ac Mon Sep 17 00:00:00 2001 From: Brian Anglin Date: Tue, 15 Sep 2026 14:27:10 -0700 Subject: [PATCH 1/7] feat: collect timestamped device IP attributes alongside enrichment --- CHANGELOG.md | 2 + CLAUDE.md | 7 ++ README.md | 21 ++++ .../Network/Device Helper/DeviceHelper.swift | 7 ++ .../Device Helper/DeviceIPCollector.swift | 98 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../DeviceIPCollectorTests.swift | 45 +++++++++ 7 files changed, 188 insertions(+) create mode 100644 Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift create mode 100644 Tests/SuperwallKitTests/DeviceIPCollectorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 697b21492..e6b305381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ## 4.17.0 +- Adds best-effort public IPv4 and observed IPv6 device attributes, with separate observation timestamps for downstream integrations. + ### 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 fd5b72bfc..9aac80841 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,3 +114,10 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL - [ ] I have run `swiftlint` in the main directory and fixed any issues. - [ ] 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. Keep collection +independent of enrichment success and purchase/configuration latency, preserve each family +separately, and filter stale cached enrichment IP fields before exposing device attributes. +Do not add customer attributes or authentication headers to the public IPv4 collection request. diff --git a/README.md b/README.md index 21ad9fc5e..a89cfe044 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,24 @@ 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 + +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 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. Attempts are coalesced and +limited to one per 15 minutes when enrichment runs. 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. +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/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 709957b94..04e73e805 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 @@ -932,6 +933,7 @@ class DeviceHelper { maxRetry: Int? = nil, timeout: Seconds? = nil ) async throws { + await ipCollector.refreshIfNeeded() let identityManager = factory.makeIdentityManager() let deviceAttributes = await getTemplateDevice() let request = EnrichmentRequest( @@ -1045,6 +1047,11 @@ class DeviceHelper { // Merge in enrichment dictionary, giving priority to // the existing values. deviceDictionary.merge(enrichmentDict) { current, _ in current } + await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) + for key in ["ipV4", "ipV6", "ipV4ObservedAt", "ipV6ObservedAt", "ipAddress", "ipAddressObservedAt"] { + deviceDictionary.removeValue(forKey: key) + } + 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..90d9dda8d --- /dev/null +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift @@ -0,0 +1,98 @@ +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 observations: [String: String] = [:] + private let lifetime: TimeInterval = 15 * 60 + + init(fetch: @escaping Fetch = DeviceIPCollector.fetchIPv4, now: @escaping () -> Date = Date.init) { + self.fetch = fetch + self.now = now + } + + func refreshIfNeeded() { + let date = now() + if let lastAttempt = lastAttempt, date.timeIntervalSince(lastAttempt) < lifetime { return } + lastAttempt = date + Task { + guard let device = try? await fetch() else { return } + record(device) + } + } + + func record(_ device: [String: String]) { + for family in [4, 6] { + let key = "ipV\(family)" + let address = device[key] ?? device["ipAddress"] + let timestamp = device["\(key)ObservedAt"] ?? device["ipAddressObservedAt"] + guard let address = address, let timestamp = timestamp, + Self.isValid(address, family: family), isFresh(timestamp) else { continue } + if let previous = observations["\(key)ObservedAt"], + let oldDate = ISO8601DateFormatter.ipObservation.date(from: previous), + let newDate = ISO8601DateFormatter.ipObservation.date(from: timestamp), oldDate > newDate { 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"], isFresh(timestamp) { + result[key] = observations[key] + result["\(key)ObservedAt"] = timestamp + } + } + return result + } + + private func isFresh(_ timestamp: String) -> Bool { + guard let date = ISO8601DateFormatter.ipObservation.date(from: timestamp) else { return false } + 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:") + } + + static func fetchIPv4() async throws -> [String: String] { + guard let url = URL(string: "https://v4.superwall-enrichment.com/api/v1/enrich") else { return [:] } + 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() + } + struct Response: Decodable { let device: [String: String] } + return try JSONDecoder().decode(Response.self, from: data).device + } +} + +private extension ISO8601DateFormatter { + static var ipObservation: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 6dc2c7fb7..a4172da78 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -184,6 +184,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 */; }; 519196E0035C17C73C525526 /* V2Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */; }; 53113582D4E7F54236FD493C /* CacheKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 865262BC55BAEA135E51698D /* CacheKeys.swift */; }; @@ -298,6 +299,7 @@ 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; 88D22C84ACDED44E3952C786 /* SK2ObserverModePurchaseDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.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 */; }; @@ -969,6 +971,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 = ""; }; @@ -1096,6 +1099,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 = ""; }; @@ -1499,6 +1503,7 @@ children = ( BB242DC77FEC0BE10C0DDC9C /* DeviceHelper.swift */, 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */, + 9E64F6845B4CB61F7C7A5657 /* DeviceIPCollector.swift */, 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */, 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */, ); @@ -2741,6 +2746,7 @@ C1B0B520B9B41EFC6141B0BF /* SuperwallKitTests */ = { isa = PBXGroup; children = ( + C8A8859BFC55BD33AF575BEE /* DeviceIPCollectorTests.swift */, C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */, FE0E783785F917188D12F4B4 /* Utils.swift */, E338A5AE4BB82E592AE9B9BA /* Analytics */, @@ -3335,6 +3341,7 @@ 4ABF9FB54105917343865145 /* DependencyContainerInitTests.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 */, @@ -3525,6 +3532,7 @@ CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */, 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */, 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */, + 8A4B27BB3442FDE107187EDE /* DeviceIPCollector.swift in Sources */, 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */, 6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */, 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift new file mode 100644 index 000000000..c029eb5fc --- /dev/null +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import SuperwallKit + +struct DeviceIPCollectorTests { + private let date = Date(timeIntervalSince1970: 1_789_505_000) + + @Test func keepsFamiliesSeparateAndRejectsStaleOrInvalidObservations() async { + let collector = DeviceIPCollector(fetch: { [:] }, 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 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 coalescesRefreshesWithoutWaitingForNetwork() async { + actor FetchCounter { + var count = 0 + func fetch() async -> [String: String] { + count += 1 + try? await Task.sleep(nanoseconds: 100_000_000) + return [:] + } + } + let counter = FetchCounter() + let collector = DeviceIPCollector(fetch: { await counter.fetch() }, now: { date }) + await collector.refreshIfNeeded() + await collector.refreshIfNeeded() + #expect(await collector.attributes().isEmpty) + try? await Task.sleep(nanoseconds: 150_000_000) + #expect(await counter.count == 1) + } +} From 4aa3b709cf027e1a44af57d161e82a4ef2d92952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:53:50 +0200 Subject: [PATCH 2/7] Move the IP attributes entry to 4.18.0 and bump the version Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++++- Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e349b989d..3b06b84ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.17.0 +## 4.18.0 + +### Enhancements - Adds best-effort public IPv4 and observed IPv6 device attributes, with separate observation timestamps for downstream integrations. +## 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/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 7ca78bdad..37a2cf07c 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.17.0 +4.18.0 """ diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 39380da9e..4d6906bcd 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.17.0" + s.version = "4.18.0" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" From 1372760b8f6736cf2e54f787657084af1feece0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:31:30 +0200 Subject: [PATCH 3/7] Address review feedback on device IP collection - Parse observation timestamps with or without milliseconds, reusing two formatters. - Take the IPv4 host from the network environment, and skip the request outside release builds since there is no dev host. - Stop removing `ipAddress` from device attributes, since the enrichment API returns it today. - Retry after a failed fetch instead of waiting out the 15 minute window, and log the failure. - Ignore non-string values in the response rather than failing to decode. - Test the DeviceHelper stale-IP filtering, second-precision timestamps and retry, without timing sleeps. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 6 +- .../Config/Options/SuperwallOptions.swift | 13 ++ Sources/SuperwallKit/Network/API.swift | 6 + .../Network/Device Helper/DeviceHelper.swift | 6 +- .../Device Helper/DeviceIPCollector.swift | 137 ++++++++++++++---- .../DeviceIPCollectorTests.swift | 87 +++++++++-- .../Network/DeviceHelperTests.swift | 35 ++++- 8 files changed, 244 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35db8a4ca..f2cf2f0c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL `DeviceIPCollector` owns session-local, timestamped IP observations. Keep collection independent of enrichment success and purchase/configuration latency, preserve each family -separately, and filter stale cached enrichment IP fields before exposing device attributes. +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 diff --git a/README.md b/README.md index a89cfe044..d7587affe 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,10 @@ Please see the [CONTRIBUTING](.github/CONTRIBUTING.md) file for how to help. 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 existing enrichment API continues to provide geo and demand scoring. +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. @@ -113,6 +116,7 @@ The extra request never blocks configuration or purchases. Attempts are coalesce limited to one per 15 minutes when enrichment runs. 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/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 83bc39523..8fa21b182 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -14,7 +14,7 @@ import CoreTelephony import StoreKit class DeviceHelper { - private let ipCollector = DeviceIPCollector() + private let ipCollector: DeviceIPCollector var localeIdentifier: String { let localeIdentifier = factory.makeLocaleIdentifier() return localeIdentifier ?? Locale.autoupdatingCurrent.identifier @@ -936,8 +936,10 @@ class DeviceHelper { entitlementsInfo: EntitlementsInfo, receiptManager: ReceiptManager, factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory, + 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 @@ -1082,7 +1084,7 @@ class DeviceHelper { // the existing values. deviceDictionary.merge(enrichmentDict) { current, _ in current } await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) - for key in ["ipV4", "ipV6", "ipV4ObservedAt", "ipV6ObservedAt", "ipAddress", "ipAddressObservedAt"] { + for key in ["ipV4", "ipV6", "ipV4ObservedAt", "ipV6ObservedAt"] { deviceDictionary.removeValue(forKey: key) } deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift index 90d9dda8d..f3102a530 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift @@ -1,27 +1,68 @@ +// +// 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 fetch: Fetch? private let now: () -> Date private var lastAttempt: Date? private var observations: [String: String] = [:] private let lifetime: TimeInterval = 15 * 60 - init(fetch: @escaping Fetch = DeviceIPCollector.fetchIPv4, now: @escaping () -> Date = Date.init) { - self.fetch = fetch + /// - 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 } - func refreshIfNeeded() { + /// 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 lastAttempt = lastAttempt, date.timeIntervalSince(lastAttempt) < lifetime { return } + if let lastAttempt = lastAttempt, + date.timeIntervalSince(lastAttempt) < lifetime { + return nil + } lastAttempt = date - Task { - guard let device = try? await fetch() else { return } - record(device) + return Task { + do { + record(try await fetch()) + } catch { + // Let the next enrichment try again rather than waiting out the window. + if lastAttempt == date { + lastAttempt = nil + } + Logger.debug( + logLevel: .debug, + scope: .network, + message: "Couldn't fetch the device's IPv4 address", + error: error + ) + } } } @@ -30,11 +71,20 @@ actor DeviceIPCollector { let key = "ipV\(family)" let address = device[key] ?? device["ipAddress"] let timestamp = device["\(key)ObservedAt"] ?? device["ipAddressObservedAt"] - guard let address = address, let timestamp = timestamp, - Self.isValid(address, family: family), isFresh(timestamp) else { continue } + 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 oldDate = ISO8601DateFormatter.ipObservation.date(from: previous), - let newDate = ISO8601DateFormatter.ipObservation.date(from: timestamp), oldDate > newDate { continue } + let previousDate = Self.date(from: previous), + previousDate > date { + continue + } observations[key] = address observations["\(key)ObservedAt"] = timestamp } @@ -43,7 +93,9 @@ actor DeviceIPCollector { func attributes() -> [String: String] { var result: [String: String] = [:] for key in ["ipV4", "ipV6"] { - if let timestamp = observations["\(key)ObservedAt"], isFresh(timestamp) { + if let timestamp = observations["\(key)ObservedAt"], + let date = Self.date(from: timestamp), + isFresh(date) { result[key] = observations[key] result["\(key)ObservedAt"] = timestamp } @@ -51,8 +103,7 @@ actor DeviceIPCollector { return result } - private func isFresh(_ timestamp: String) -> Bool { - guard let date = ISO8601DateFormatter.ipObservation.date(from: timestamp) else { return false } + private func isFresh(_ date: Date) -> Bool { let age = now().timeIntervalSince(date) return age >= -60 && age < lifetime } @@ -66,8 +117,25 @@ actor DeviceIPCollector { return inet_pton(AF_INET6, address, &bytes) == 1 && !address.lowercased().hasPrefix("::ffff:") } - static func fetchIPv4() async throws -> [String: String] { - guard let url = URL(string: "https://v4.superwall-enrichment.com/api/v1/enrich") else { return [:] } + 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 @@ -75,24 +143,35 @@ actor DeviceIPCollector { 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 + 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() } - struct Response: Decodable { let device: [String: String] } - return try JSONDecoder().decode(Response.self, from: data).device + return try parseDevice(from: data) } -} -private extension ISO8601DateFormatter { - static var ipObservation: ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter + /// 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/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift index c029eb5fc..33b5c3562 100644 --- a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -5,8 +5,29 @@ import Testing 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(fetch: { [:] }, now: { date }) + 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]) @@ -18,6 +39,23 @@ struct DeviceIPCollectorTests { #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 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)) @@ -25,21 +63,42 @@ struct DeviceIPCollectorTests { #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 { - actor FetchCounter { - var count = 0 - func fetch() async -> [String: String] { - count += 1 - try? await Task.sleep(nanoseconds: 100_000_000) - return [:] - } - } let counter = FetchCounter() - let collector = DeviceIPCollector(fetch: { await counter.fetch() }, now: { date }) - await collector.refreshIfNeeded() - await collector.refreshIfNeeded() - #expect(await collector.attributes().isEmpty) - try? await Task.sleep(nanoseconds: 150_000_000) + 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) } + + @Test func retriesAfterAFailedFetch() async { + let counter = FetchCounter(shouldFail: true) + let collector = DeviceIPCollector(url: nil, fetch: { try await counter.fetch() }, now: { date }) + await collector.refreshIfNeeded()?.value + await counter.setShouldFail(false) + await collector.refreshIfNeeded()?.value + await collector.refreshIfNeeded()?.value + #expect(await counter.count == 2) + } + + @Test func doesNothingWithoutAnEndpoint() async { + let collector = DeviceIPCollector(url: nil, now: { date }) + #expect(await collector.refreshIfNeeded() == nil) + } + + @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..78edb68ae 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,43 @@ 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. + @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 + ) + _ = dependencyContainer + deviceHelper.enrichment = 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" + ]) + ) + + 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") + } + private func expectedLiveValues() async -> ( width: Int, height: Int, scale: Double, style: String, category: String ) { From 363faa35efa07c4d89458c5bb39113e5a28100b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:52:32 +0200 Subject: [PATCH 4/7] Only collect device IPs when the backend turns on the MMP IP collection is off by default and runs only when config has the `enable_mmp` toggle on. With it off, no IPv4 request is made and no `ipV4`/`ipV6` attributes are exposed. The check happens when building device attributes, so a first launch picks it up once config arrives. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- CLAUDE.md | 4 +- README.md | 4 ++ .../Config/Models/FeatureFlags.swift | 12 +++-- .../Network/Device Helper/DeviceHelper.swift | 12 +++-- .../DeviceIPCollectorTests.swift | 8 +++ .../Network/DeviceHelperTests.swift | 51 +++++++++++++++---- 7 files changed, 75 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c16d274d3..30fb1e660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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 downstream integrations. +- Adds best-effort public IPv4 and observed IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. ### Fixes diff --git a/CLAUDE.md b/CLAUDE.md index f2cf2f0c6..962ffc2c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,9 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL ### Device IP enrichment -`DeviceIPCollector` owns session-local, timestamped IP observations. Keep collection +`DeviceIPCollector` owns session-local, timestamped IP observations. Collection is for the +MMP and stays off unless the backend's `enable_mmp` toggle 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. diff --git a/README.md b/README.md index d7587affe..8ac29b164 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ 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 the `enable_mmp` +config toggle for the app. 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 diff --git a/Sources/SuperwallKit/Config/Models/FeatureFlags.swift b/Sources/SuperwallKit/Config/Models/FeatureFlags.swift index a8866d130..dfc956f7f 100644 --- a/Sources/SuperwallKit/Config/Models/FeatureFlags.swift +++ b/Sources/SuperwallKit/Config/Models/FeatureFlags.swift @@ -23,6 +23,7 @@ struct FeatureFlags: Codable, Equatable { var enableConfigRefresh: Bool var enableTextInteraction: Bool var enableIframeNavigation: Bool + var enableMMP: Bool enum CodingKeys: String, CodingKey { case toggles @@ -45,6 +46,7 @@ struct FeatureFlags: Codable, Equatable { enableConfigRefresh = rawFeatureFlags.value(forKey: "enable_config_refresh_v2", default: false) enableTextInteraction = rawFeatureFlags.value(forKey: "enable_text_interaction", default: false) enableIframeNavigation = rawFeatureFlags.value(forKey: "enable_iframe_navigation", default: false) + enableMMP = rawFeatureFlags.value(forKey: "enable_mmp", default: false) } func encode(to encoder: Encoder) throws { @@ -60,7 +62,8 @@ struct FeatureFlags: Codable, Equatable { RawFeatureFlag(key: "enable_multiple_paywall_urls", enabled: enableMultiplePaywallUrls), RawFeatureFlag(key: "enable_config_refresh_v2", enabled: enableConfigRefresh), RawFeatureFlag(key: "enable_text_interaction", enabled: enableTextInteraction), - RawFeatureFlag(key: "enable_iframe_navigation", enabled: enableIframeNavigation) + RawFeatureFlag(key: "enable_iframe_navigation", enabled: enableIframeNavigation), + RawFeatureFlag(key: "enable_mmp", enabled: enableMMP) ] try container.encode(rawFeatureFlags, forKey: .toggles) @@ -77,7 +80,8 @@ struct FeatureFlags: Codable, Equatable { enableConfigRefresh: Bool, enableTextInteraction: Bool, enableCELLogging: Bool, - enableIframeNavigation: Bool + enableIframeNavigation: Bool, + enableMMP: Bool = false ) { self.enableExpressionParameters = enableExpressionParameters self.enableUserIdSeed = enableUserIdSeed @@ -89,6 +93,7 @@ struct FeatureFlags: Codable, Equatable { self.enableConfigRefresh = enableConfigRefresh self.enableTextInteraction = enableTextInteraction self.enableIframeNavigation = enableIframeNavigation + self.enableMMP = enableMMP } } @@ -117,7 +122,8 @@ extension FeatureFlags: Stubbable { enableConfigRefresh: true, enableTextInteraction: true, enableCELLogging: true, - enableIframeNavigation: true + enableIframeNavigation: true, + enableMMP: true ) } } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 8fa21b182..89f3745d2 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -928,6 +928,7 @@ class DeviceHelper { private unowned let factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory + & FeatureFlagsFactory init( api: Api, @@ -935,7 +936,7 @@ class DeviceHelper { network: Network, entitlementsInfo: EntitlementsInfo, receiptManager: ReceiptManager, - factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory, + factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory & FeatureFlagsFactory, ipCollector: DeviceIPCollector? = nil, isUIKitReadSafe: @escaping () -> Bool = { DeviceHelper.isUIKitReadSafe } ) { @@ -969,7 +970,6 @@ class DeviceHelper { maxRetry: Int? = nil, timeout: Seconds? = nil ) async throws { - await ipCollector.refreshIfNeeded() let identityManager = factory.makeIdentityManager() let deviceAttributes = await getTemplateDevice() let request = EnrichmentRequest( @@ -1083,11 +1083,15 @@ class DeviceHelper { // Merge in enrichment dictionary, giving priority to // the existing values. deviceDictionary.merge(enrichmentDict) { current, _ in current } - await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) for key in ["ipV4", "ipV6", "ipV4ObservedAt", "ipV6ObservedAt"] { deviceDictionary.removeValue(forKey: key) } - deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed } + // IP collection is for the MMP, which is off unless the backend turns it on. + if factory.makeFeatureFlags()?.enableMMP == true { + await ipCollector.refreshIfNeeded() + await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) + deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed } + } if #available(iOS 15.0, *), let storefront = await Storefront.current { diff --git a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift index 33b5c3562..21c85942c 100644 --- a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -95,6 +95,14 @@ struct DeviceIPCollectorTests { #expect(await collector.refreshIfNeeded() == nil) } + @Test func mmpFlagIsOffUnlessTheBackendTurnsItOn() throws { + let decoder = JSONDecoder() + let off = try decoder.decode(FeatureFlags.self, from: Data(#"{"toggles":[]}"#.utf8)) + let on = try decoder.decode(FeatureFlags.self, from: Data(#"{"toggles":[{"key":"enable_mmp","enabled":true}]}"#.utf8)) + #expect(!off.enableMMP) + #expect(on.enableMMP) + } + @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) diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift index 78edb68ae..eea8e242d 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -376,15 +376,8 @@ struct DeviceHelperTests { /// 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. - @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 - ) - _ = dependencyContainer - deviceHelper.enrichment = Enrichment( + private func makeIPEnrichment() -> Enrichment { + return Enrichment( user: JSON([:]), device: JSON([ "ipV4": "1.1.1.1", @@ -394,6 +387,23 @@ struct DeviceHelperTests { "ipAddress": "1.1.1.1" ]) ) + } + + private func setMMPFlag(_ isEnabled: Bool, on dependencyContainer: DependencyContainer) { + var config = Config.stub() + config.featureFlags.enableMMP = isEnabled + dependencyContainer.configManager.configState.send(.retrieved(config)) + } + + @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() @@ -404,6 +414,29 @@ struct DeviceHelperTests { #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") + #expect(await collector.attributes().isEmpty) + // 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 ) { From a5f1a01211ba4005a1319319e4a3e12f1f97ca9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:39:28 +0200 Subject: [PATCH 5/7] Read the MMP switch from the attribution config options Moves the IP collection switch from a general toggle to `attributionOptions.mmp.enabled`, next to Apple Search Ads. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 4 ++-- .../SuperwallKit/Config/Models/Attribution.swift | 14 ++++++++++++++ .../SuperwallKit/Config/Models/FeatureFlags.swift | 12 +++--------- .../Network/Device Helper/DeviceHelper.swift | 6 +++--- .../SuperwallKitTests/DeviceIPCollectorTests.swift | 8 ++++---- .../Network/DeviceHelperTests.swift | 5 ++++- 7 files changed, 31 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 962ffc2c3..5d1b4b5e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL ### Device IP enrichment `DeviceIPCollector` owns session-local, timestamped IP observations. Collection is for the -MMP and stays off unless the backend's `enable_mmp` toggle is on; SuperwallKit's privacy +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. diff --git a/README.md b/README.md index 8ac29b164..27b1aa56b 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ 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 the `enable_mmp` -config toggle for the app. When it's off, no IPv4 request is made and no `ipV4`/`ipV6` +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 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/Models/FeatureFlags.swift b/Sources/SuperwallKit/Config/Models/FeatureFlags.swift index dfc956f7f..a8866d130 100644 --- a/Sources/SuperwallKit/Config/Models/FeatureFlags.swift +++ b/Sources/SuperwallKit/Config/Models/FeatureFlags.swift @@ -23,7 +23,6 @@ struct FeatureFlags: Codable, Equatable { var enableConfigRefresh: Bool var enableTextInteraction: Bool var enableIframeNavigation: Bool - var enableMMP: Bool enum CodingKeys: String, CodingKey { case toggles @@ -46,7 +45,6 @@ struct FeatureFlags: Codable, Equatable { enableConfigRefresh = rawFeatureFlags.value(forKey: "enable_config_refresh_v2", default: false) enableTextInteraction = rawFeatureFlags.value(forKey: "enable_text_interaction", default: false) enableIframeNavigation = rawFeatureFlags.value(forKey: "enable_iframe_navigation", default: false) - enableMMP = rawFeatureFlags.value(forKey: "enable_mmp", default: false) } func encode(to encoder: Encoder) throws { @@ -62,8 +60,7 @@ struct FeatureFlags: Codable, Equatable { RawFeatureFlag(key: "enable_multiple_paywall_urls", enabled: enableMultiplePaywallUrls), RawFeatureFlag(key: "enable_config_refresh_v2", enabled: enableConfigRefresh), RawFeatureFlag(key: "enable_text_interaction", enabled: enableTextInteraction), - RawFeatureFlag(key: "enable_iframe_navigation", enabled: enableIframeNavigation), - RawFeatureFlag(key: "enable_mmp", enabled: enableMMP) + RawFeatureFlag(key: "enable_iframe_navigation", enabled: enableIframeNavigation) ] try container.encode(rawFeatureFlags, forKey: .toggles) @@ -80,8 +77,7 @@ struct FeatureFlags: Codable, Equatable { enableConfigRefresh: Bool, enableTextInteraction: Bool, enableCELLogging: Bool, - enableIframeNavigation: Bool, - enableMMP: Bool = false + enableIframeNavigation: Bool ) { self.enableExpressionParameters = enableExpressionParameters self.enableUserIdSeed = enableUserIdSeed @@ -93,7 +89,6 @@ struct FeatureFlags: Codable, Equatable { self.enableConfigRefresh = enableConfigRefresh self.enableTextInteraction = enableTextInteraction self.enableIframeNavigation = enableIframeNavigation - self.enableMMP = enableMMP } } @@ -122,8 +117,7 @@ extension FeatureFlags: Stubbable { enableConfigRefresh: true, enableTextInteraction: true, enableCELLogging: true, - enableIframeNavigation: true, - enableMMP: true + enableIframeNavigation: true ) } } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 89f3745d2..97d5885b3 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -928,7 +928,7 @@ class DeviceHelper { private unowned let factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory - & FeatureFlagsFactory + & ConfigStateFactory init( api: Api, @@ -936,7 +936,7 @@ class DeviceHelper { network: Network, entitlementsInfo: EntitlementsInfo, receiptManager: ReceiptManager, - factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory & FeatureFlagsFactory, + factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory & ConfigStateFactory, ipCollector: DeviceIPCollector? = nil, isUIKitReadSafe: @escaping () -> Bool = { DeviceHelper.isUIKitReadSafe } ) { @@ -1087,7 +1087,7 @@ class DeviceHelper { deviceDictionary.removeValue(forKey: key) } // IP collection is for the MMP, which is off unless the backend turns it on. - if factory.makeFeatureFlags()?.enableMMP == true { + if factory.makeConfigState().value.getConfig()?.attribution?.mmp?.enabled == true { await ipCollector.refreshIfNeeded() await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed } diff --git a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift index 21c85942c..8357cd56d 100644 --- a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -97,10 +97,10 @@ struct DeviceIPCollectorTests { @Test func mmpFlagIsOffUnlessTheBackendTurnsItOn() throws { let decoder = JSONDecoder() - let off = try decoder.decode(FeatureFlags.self, from: Data(#"{"toggles":[]}"#.utf8)) - let on = try decoder.decode(FeatureFlags.self, from: Data(#"{"toggles":[{"key":"enable_mmp","enabled":true}]}"#.utf8)) - #expect(!off.enableMMP) - #expect(on.enableMMP) + 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() { diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift index eea8e242d..99a71ee9a 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -391,7 +391,10 @@ struct DeviceHelperTests { private func setMMPFlag(_ isEnabled: Bool, on dependencyContainer: DependencyContainer) { var config = Config.stub() - config.featureFlags.enableMMP = isEnabled + config.attribution = Attribution( + appleSearchAds: AppleSearchAds(enabled: true), + mmp: MMPAttribution(enabled: isEnabled) + ) dependencyContainer.configManager.configState.send(.retrieved(config)) } From 168ae34f773a783892e0ae123e74f74942ed389e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:02:05 +0200 Subject: [PATCH 6/7] Wait a minute before retrying a failed IPv4 lookup The lookup now runs whenever device attributes are read, so clearing the last attempt on failure meant an outage or offline device retried on almost every read. Wait 60 seconds after a failure instead; a success still waits the full 15 minutes. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++-- .../Device Helper/DeviceIPCollector.swift | 14 +++++++---- .../DeviceIPCollectorTests.swift | 24 ++++++++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 27b1aa56b..d081a95aa 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,9 @@ attributes when available. They remain separate: an IPv4 response does not erase 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. Attempts are coalesced and -limited to one per 15 minutes when enrichment runs. The IPv4 collector is session-local; the existing enrichment cache may restore a still-fresh +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. diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift index f3102a530..fb453d82e 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift @@ -14,8 +14,13 @@ actor DeviceIPCollector { 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. @@ -43,18 +48,19 @@ actor DeviceIPCollector { return nil } let date = now() - if let lastAttempt = lastAttempt, - date.timeIntervalSince(lastAttempt) < lifetime { + if let nextAttemptAt = nextAttemptAt, + date < nextAttemptAt { return nil } lastAttempt = date + nextAttemptAt = date.addingTimeInterval(lifetime) return Task { do { record(try await fetch()) } catch { - // Let the next enrichment try again rather than waiting out the window. + // Try again sooner than a success would, unless a newer attempt started. if lastAttempt == date { - lastAttempt = nil + nextAttemptAt = date.addingTimeInterval(retryDelay) } Logger.debug( logLevel: .debug, diff --git a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift index 8357cd56d..ef5e94761 100644 --- a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -80,14 +80,32 @@ struct DeviceIPCollectorTests { #expect(await counter.count == 1) } - @Test func retriesAfterAFailedFetch() async { + 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 collector = DeviceIPCollector(url: nil, fetch: { try await counter.fetch() }, now: { date }) + 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) - await collector.refreshIfNeeded()?.value + 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 { From 622082926fc1ff211c6630e0cf9754187d7f0d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:18:33 +0200 Subject: [PATCH 7/7] Collect IPs on cold launch and keep each address with its own time On a cold launch the first enrichment is read before config arrives, so its IPs were dropped and the IPv4 lookup waited for a later read. Enrichment IPs are now always kept in memory (only exposing them is gated), and config arriving with the MMP on starts the lookup. An ipV6 without its own timestamp could also borrow the IPv4 ipAddressObservedAt and look fresh; each address now only uses its own timestamp. Co-Authored-By: Claude Opus 5.5 --- .../SuperwallKit/Config/ConfigManager.swift | 16 ++++-- .../Network/Device Helper/DeviceHelper.swift | 17 +++++- .../Device Helper/DeviceIPCollector.swift | 13 ++++- .../DeviceIPCollectorTests.swift | 12 ++++ .../Network/DeviceHelperTests.swift | 56 ++++++++++++++++++- 5 files changed, 103 insertions(+), 11 deletions(-) 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/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 97d5885b3..c955c557f 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -966,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 @@ -1086,10 +1099,12 @@ class DeviceHelper { 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() - await ipCollector.record(enrichmentDict.compactMapValues { $0 as? String }) deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed } } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift index fb453d82e..aa73ad20b 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceIPCollector.swift @@ -75,8 +75,17 @@ actor DeviceIPCollector { func record(_ device: [String: String]) { for family in [4, 6] { let key = "ipV\(family)" - let address = device[key] ?? device["ipAddress"] - let timestamp = device["\(key)ObservedAt"] ?? device["ipAddressObservedAt"] + // 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, diff --git a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift index ef5e94761..32d22c797 100644 --- a/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift +++ b/Tests/SuperwallKitTests/DeviceIPCollectorTests.swift @@ -49,6 +49,18 @@ struct DeviceIPCollectorTests { #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"]) diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift index 99a71ee9a..0fced94fe 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -389,13 +389,64 @@ struct DeviceHelperTests { ) } - private func setMMPFlag(_ isEnabled: Bool, on dependencyContainer: DependencyContainer) { + private func makeConfig(mmpEnabled: Bool) -> Config { var config = Config.stub() config.attribution = Attribution( appleSearchAds: AppleSearchAds(enabled: true), - mmp: MMPAttribution(enabled: isEnabled) + 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 { @@ -435,7 +486,6 @@ struct DeviceHelperTests { #expect(template["ipV6"] == nil) #expect(template["ipV6ObservedAt"] == nil) #expect(template["ipAddress"] as? String == "1.1.1.1") - #expect(await collector.attributes().isEmpty) // A lookup would have been started, so this one wouldn't be skipped. #expect(await collector.refreshIfNeeded() != nil) }