diff --git a/CHANGELOG.md b/CHANGELOG.md index 232052c..55953ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.0 +### Breaking +`NetworkInfoProvider` no longer detects `network.type`. `NetworkInfo.type` is now `String?` (always nil), `NetworkInfo.detail` is removed, and `collectAsDict()` omits the `type`, `carrier`, and `detail` keys. Consumers that read these fields (sdk-swift, sdk-react-native, sdk-flutter) must update — only `userAgent` is still populated. + +* Remove on-device `network.type` detection. It resolved the type by bridging `NWPathMonitor.pathUpdateHandler` into a `CheckedContinuation`, but that handler is not one-shot: it re-fires on every path change and `monitor.cancel()` does not retract callbacks already dispatched to the monitor's queue, so a second callback resumed the continuation twice and crashed the app (`EXC_BREAKPOINT`) on flapping/constrained networks. This ran on every `/preload`, so at scale it was a high-volume production crash present in every KontextKit release to date (and in sdk-swift 1.0.5+ before the code was extracted here). The ad server does not use `network.type` for ad selection — it only forwards it to DSPs as OpenRTB `connectiontype` — so the value is removed rather than guarded: a continuation that no longer exists cannot be double-resumed. +* Drop `network.carrier` and `network.detail` collection (CoreTelephony). `carrier` was already always nil on iOS 16+; `detail` (cellular radio access technology) was only meaningful alongside `type`. `userAgent` — the one field the ad server consumes — is unchanged. + ## 0.0.5 * `Frameworks/OMLICENSE`: ship the IAB Tech Lab OM License v1.1 text alongside the bundled `OMSDK_Kontextso.xcframework`. Required by OM License Section 4(a) for any Object-form redistribution — without this file in the published pod, downstream consumers receive the binary but not the license text it ships under. Wired into the podspec via `s.preserve_paths`. The xcframework binary is unchanged (still IAB OMSDK 1.6.4); this is a license-compliance fix only. Mirrors the equivalent fix for the Android redistribution in `kontextkit-android` 0.0.6 (`omsdk-android/LICENSE`). KontextKit's own Swift sources remain Apache-2.0. diff --git a/KontextKit.podspec b/KontextKit.podspec index 03a9ed5..6980a19 100644 --- a/KontextKit.podspec +++ b/KontextKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "KontextKit" - s.version = "0.0.5" + s.version = "0.1.0" s.summary = "Kontext shared native modules for iOS SDKs" s.description = <<-DESC KontextKit bundles the iOS-native primitives shared across every Kontext diff --git a/Sources/DeviceInfo/NetworkInfoProvider.swift b/Sources/DeviceInfo/NetworkInfoProvider.swift index 457e699..8707e0a 100644 --- a/Sources/DeviceInfo/NetworkInfoProvider.swift +++ b/Sources/DeviceInfo/NetworkInfoProvider.swift @@ -1,8 +1,13 @@ -import CoreTelephony -import Network import WebKit /// Provides network information for ad targeting and analytics. +/// +/// Only `userAgent` is collected on-device. `type` detection was removed +/// (it relied on an `NWPathMonitor` whose non-one-shot `pathUpdateHandler` +/// resumed a `CheckedContinuation` twice and crashed the app — +/// EXC_BREAKPOINT — on flapping / constrained networks), and `carrier` / +/// `detail` are no longer collected. The ad server does not use these +/// fields for ad selection, so dropping them is behaviourally safe. public enum NetworkInfoProvider { /// Cached user agent string (collected once via WKWebView). Marked @@ -11,132 +16,42 @@ public enum NetworkInfoProvider { @MainActor private static var cachedUserAgent: String? - /// Network information result. + /// Network information result. `type` and `carrier` are kept in the + /// shape (always nil) so the wire format stays stable and a value can be + /// reintroduced later without a breaking change; only `userAgent` is + /// populated today. public struct NetworkInfo: Sendable { - public let type: String // "wifi", "cellular", "ethernet", "other" - /// Always `nil` on iOS 16+: Apple removed access to carrier - /// metadata (`CTCarrier.carrierName` returns "—" / nil and the - /// underlying `serviceSubscriberCellularProviders` is deprecated). - /// Kept in the schema so the wire shape stays stable across - /// iOS versions and other platforms (Android still reports - /// carrier where possible). - public let carrier: String? - public let detail: String? // "5g", "lte", "3g", "2g", "hspa", "edge", "gprs" + public let type: String? // "wifi", "cellular", "ethernet", "other" — always nil + public let carrier: String? // always nil public let userAgent: String? // Browser user agent string } /// Dictionary form of `collect()` for bridge layers (RN, Flutter) - /// that want a `[String: Any]` directly. + /// that want a `[String: Any]` directly. Nil fields are dropped so + /// consumers see "field absent" rather than `NSNull`. @MainActor public static func collectAsDict() async -> [String: Any] { let info = await collect() - var dict: [String: Any] = ["type": info.type] + var dict: [String: Any] = [:] + if let type = info.type { dict["type"] = type } if let carrier = info.carrier { dict["carrier"] = carrier } - if let detail = info.detail { dict["detail"] = detail } if let userAgent = info.userAgent { dict["userAgent"] = userAgent } return dict } - /// Collects network information asynchronously. - /// - /// `type` is resolved by spinning up a one-shot `NWPathMonitor` and - /// awaiting its first `pathUpdateHandler` callback (mirrors v3 - /// sdk-swift's pattern; an unstarted monitor's `currentPath` returns - /// stale/`.unsatisfied` data). The monitor is cancelled inside the - /// handler so it doesn't outlive the call. - /// - /// `carrier` is intentionally always nil on iOS 16+ — see field doc. - /// `detail` (radio access technology) still works on modern iOS - /// because `serviceCurrentRadioAccessTechnology` was kept; only - /// `CTCarrier.carrierName` was removed. + /// Collects network information asynchronously. Only `userAgent` is + /// resolved (via WKWebView); `type` and `carrier` are always nil. @MainActor public static func collect() async -> NetworkInfo { - // `async let` runs both async sub-tasks concurrently (Swift's - // equivalent of JS `Promise.all`). `currentNetworkType()` waits - // up to 100ms on `NWPathMonitor`; `currentUserAgent()` blocks on - // WKWebView's `evaluateJavaScript`. Sequential awaits would add - // their latencies; concurrent awaits cap at the slower of the two. - async let type = currentNetworkType() - async let userAgent = currentUserAgent() - - let resolvedType = await type - // CoreTelephony reports the cellular radio's access tech even - // when Wi-Fi is the active path (the radio stays up on 5G/LTE - // for calls and fallback). Gating on `type == "cellular"` - // keeps `detail` describing the connection that's actually - // carrying data. Mirrors sdk-react-native's - // AdsProvider.tsx behaviour. - let detail = resolvedType == "cellular" ? currentRadioDetail() : nil - - return NetworkInfo( - type: resolvedType, + NetworkInfo( + type: nil, carrier: nil, - detail: detail, - userAgent: await userAgent + userAgent: await currentUserAgent() ) } // MARK: - Private - /// Resolves the current network type, with a 100ms safety-net timeout - /// that returns `"other"` if `NWPathMonitor.pathUpdateHandler` never - /// fires (rare — the OS normally has a path cached and the handler - /// fires within a few milliseconds, but the simulator and freshly - /// restored devices have been observed to stall indefinitely). - private static func currentNetworkType() async -> String { - await withTaskGroup(of: String.self) { group in - group.addTask { await observeNetworkType() } - group.addTask { - try? await Task.sleep(nanoseconds: 100_000_000) - return "other" - } - let first = await group.next() ?? "other" - group.cancelAll() - return first - } - } - - /// One-shot `NWPathMonitor` read: `pathUpdateHandler` fires once with - /// the current state, we cancel the monitor, resume the continuation. - /// Mirrors v3 sdk-swift's pattern. - private static func observeNetworkType() async -> String { - await withCheckedContinuation { continuation in - let monitor = NWPathMonitor() - monitor.pathUpdateHandler = { path in - let type: String - if path.usesInterfaceType(.wifi) { - type = "wifi" - } else if path.usesInterfaceType(.cellular) { - type = "cellular" - } else if path.usesInterfaceType(.wiredEthernet) { - type = "ethernet" - } else { - type = "other" - } - monitor.cancel() - continuation.resume(returning: type) - } - monitor.start(queue: DispatchQueue.global(qos: .background)) - } - } - - /// Reads the current radio access technology synchronously. - /// Multi-SIM aware via `dataServiceIdentifier` (iOS 13+), with - /// fallback to the first available provider for older devices. - private static func currentRadioDetail() -> String? { - let info = CTTelephonyNetworkInfo() - let radioAccess: String? - if #available(iOS 13.0, *), - let dataId = info.dataServiceIdentifier, - let dict = info.serviceCurrentRadioAccessTechnology { - radioAccess = dict[dataId] ?? dict.values.first - } else { - radioAccess = info.serviceCurrentRadioAccessTechnology?.values.first - } - guard let radioAccess else { return nil } - return mapRadioAccess(radioAccess) - } - @MainActor private static func currentUserAgent() async -> String? { if let cached = cachedUserAgent { @@ -147,27 +62,4 @@ public enum NetworkInfoProvider { cachedUserAgent = ua return ua } - - /// Maps a raw `CTRadioAccessTechnology*` constant to a wire-friendly - /// detail string. Internal (not private) so unit tests can exercise - /// the table without needing `CTTelephonyNetworkInfo`. - static func mapRadioAccess(_ radioAccess: String) -> String? { - switch radioAccess { - case CTRadioAccessTechnologyGPRS: return "gprs" - case CTRadioAccessTechnologyEdge: return "edge" - case CTRadioAccessTechnologyWCDMA: return "3g" - case CTRadioAccessTechnologyHSDPA, CTRadioAccessTechnologyHSUPA: return "hspa" - case CTRadioAccessTechnologyCDMA1x: return "2g" - case CTRadioAccessTechnologyCDMAEVDORev0, CTRadioAccessTechnologyCDMAEVDORevA, - CTRadioAccessTechnologyCDMAEVDORevB, CTRadioAccessTechnologyeHRPD: return "3g" - case CTRadioAccessTechnologyLTE: return "lte" - default: - if #available(iOS 14.1, *) { - if radioAccess == CTRadioAccessTechnologyNRNSA || radioAccess == CTRadioAccessTechnologyNR { - return "5g" - } - } - return nil - } - } } diff --git a/Tests/DeviceInfo/NetworkInfoProviderTests.swift b/Tests/DeviceInfo/NetworkInfoProviderTests.swift index e0d91e7..082bc95 100644 --- a/Tests/DeviceInfo/NetworkInfoProviderTests.swift +++ b/Tests/DeviceInfo/NetworkInfoProviderTests.swift @@ -1,111 +1,44 @@ import Testing -import CoreTelephony @testable import KontextKit struct NetworkInfoProviderTests { - // MARK: - mapRadioAccess (pure function table) + // MARK: - collect / collectAsDict - @Test func mapsLTE() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyLTE) == "lte") - } - - @Test func mapsGPRS() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyGPRS) == "gprs") - } - - @Test func mapsEdge() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyEdge) == "edge") - } - - @Test func mapsWCDMAToThreeG() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyWCDMA) == "3g") - } - - @Test func mapsHSPAVariantsToHspa() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyHSDPA) == "hspa") - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyHSUPA) == "hspa") - } - - @Test func mapsCDMA1xToTwoG() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyCDMA1x) == "2g") - } - - @Test func mapsCDMAEvDoVariantsToThreeG() { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyCDMAEVDORev0) == "3g") - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyCDMAEVDORevA) == "3g") - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyCDMAEVDORevB) == "3g") - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyeHRPD) == "3g") - } - - /// 5G constants only exist on iOS 14.1+. KontextKit's minimum is iOS 14, - /// so the runtime check is genuine — guard the test body the same way. - @Test func mapsFiveGVariants() { - if #available(iOS 14.1, *) { - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyNR) == "5g") - #expect(NetworkInfoProvider.mapRadioAccess(CTRadioAccessTechnologyNRNSA) == "5g") - } - } - - @Test func mapsUnknownConstantToNil() { - #expect(NetworkInfoProvider.mapRadioAccess("CTRadioAccessTechnologyMystery") == nil) - } - - // MARK: - collect / collectAsDict (smoke tests) - - @Test @MainActor func typeIsOneOfFourValidValues() async { + /// On-device network-type detection was removed (the `NWPathMonitor` + /// read could double-resume its continuation and crash the app), so + /// `type` is always nil and the ad server sends no `network.type`. + @Test @MainActor func typeIsAlwaysNil() async { let info = await NetworkInfoProvider.collect() - #expect(["wifi", "cellular", "ethernet", "other"].contains(info.type)) + #expect(info.type == nil) } - /// On iOS 16+ Apple removed access to `CTCarrier.carrierName`, so - /// `collect()` is documented to always return `nil` for this field. - /// CI runs iOS 18, so the contract is exercised here. - @Test @MainActor func carrierIsAlwaysNilOnIOS16Plus() async { + /// `carrier` is no longer collected (and CTCarrier is unavailable on + /// iOS 16+ anyway), so it is always nil. + @Test @MainActor func carrierIsAlwaysNil() async { let info = await NetworkInfoProvider.collect() #expect(info.carrier == nil) } - @Test @MainActor func dictHasTypeKey() async { - let dict = await NetworkInfoProvider.collectAsDict() - #expect(dict["type"] != nil) - } - - /// Bridge dict omits nil fields — carrier on iOS 16+ is always nil, - /// so the key must not appear at all (otherwise RN/Flutter consumers - /// would see `NSNull` and have to special-case it). - @Test @MainActor func dictOmitsNilCarrier() async { + /// `type` and `carrier` are always nil, and the bridge dict drops nil + /// fields, so those keys (and the removed `detail`) must be absent — + /// RN/Flutter consumers see "field absent", not `NSNull`. + @Test @MainActor func dictOmitsNilFields() async { let dict = await NetworkInfoProvider.collectAsDict() + #expect(dict["type"] == nil) #expect(dict["carrier"] == nil) + #expect(dict["detail"] == nil) } - // MARK: - detail gating - - /// `detail` describes the *active connection's* access technology. - /// CoreTelephony reports the cellular radio's RAT even when Wi-Fi - /// is the active path (the radio stays up on 5G/LTE for calls and - /// fallback) — without gating, the SDK would ship `{type: "wifi", - /// detail: "5g"}`, which misrepresents the data path to DSPs. - /// - /// Sim/CI runners report `type == "wifi"` or `"other"`, never - /// `"cellular"`. So at least on those, `detail` MUST be nil. (A - /// real cellular device exercises the positive branch via the - /// `mapRadioAccess` table tests above plus the production code - /// path.) - @Test @MainActor func detailIsNilOnNonCellular() async { - let info = await NetworkInfoProvider.collect() - if info.type != "cellular" { - #expect(info.detail == nil) - } - } - - @Test @MainActor func dictOmitsDetailOnNonCellular() async { + /// `userAgent` is the only field still collected; when present it must + /// be surfaced verbatim in the bridge dict. + @Test @MainActor func dictUserAgentMatchesInfo() async { let info = await NetworkInfoProvider.collect() let dict = await NetworkInfoProvider.collectAsDict() - if info.type != "cellular" { - // Bridge dict drops nil — sdk-react-native / sdk-flutter - // consumers see "field absent", not NSNull. - #expect(dict["detail"] == nil) + if let userAgent = info.userAgent { + #expect(dict["userAgent"] as? String == userAgent) + } else { + #expect(dict["userAgent"] == nil) } } }