Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 11 additions & 5 deletions Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions Sources/SuperwallKit/Config/Models/Attribution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
13 changes: 13 additions & 0 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/SuperwallKit/Network/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -927,16 +928,19 @@ class DeviceHelper {
private unowned let factory: IdentityFactory
& LocaleIdentifierFactory
& WebEntitlementFactory
& ConfigStateFactory

init(
api: Api,
storage: Storage,
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
Expand All @@ -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<Void, Never>? {
if config.attribution?.mmp?.enabled != true {
return nil
}
return Task {
await ipCollector.refreshIfNeeded()?.value
}
}

func getEnrichment(
maxRetry: Int? = nil,
timeout: Seconds? = nil
Expand Down Expand Up @@ -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 {
Comment thread
yusuftor marked this conversation as resolved.
await ipCollector.refreshIfNeeded()
Comment thread
yusuftor marked this conversation as resolved.
deviceDictionary.merge(await ipCollector.attributes()) { _, observed in observed }
}

if #available(iOS 15.0, *),
let storefront = await Storefront.current {
Expand Down
Loading
Loading