Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion KontextKit.podspec
Original file line number Diff line number Diff line change
@@ -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
Expand Down
152 changes: 22 additions & 130 deletions Sources/DeviceInfo/NetworkInfoProvider.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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
}
}
}
111 changes: 22 additions & 89 deletions Tests/DeviceInfo/NetworkInfoProviderTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading