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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.0.3
* `InstallIdProvider` — new device-info provider returning a per-app-install identifier (UUID v7) persisted in `UserDefaults` under `"kontextso.installId"`. Generated on first call, validated against the canonical UUID shape on read (overwrites on corruption), and stable across launches and conversations until the user uninstalls or clears app data. Sibling iOS SDKs (sdk-swift, sdk-react-native, sdk-flutter) attach it to every `/init`, `/preload`, `/error`, and `/debug` request so the ad server can key pacing, frequency caps, and per-install diagnostics to a stable identity independent of `conversationId` or `userId`.

## 0.0.2
* `NetworkInfoProvider`: `detail` is now reported only when `type == "cellular"`. CoreTelephony reports the cellular radio's RAT independently of the active network path, so previously `{type: "wifi", detail: "5g"}` could appear when the cellular radio was up for calls/fallback but Wi-Fi was carrying data. Mirrors sdk-react-native's behaviour.
* `AudioInfoProvider`: live volume tracking now works across consecutive `collect()` calls. `outputVolume` is undefined unless the session is active, and iOS only refreshes the property while something is observing it via KVO. `collect()` now calls `ensureSessionActive()` and the activator installs a permanent KVO observer on `outputVolume`, so volume updates are reflected without needing a video ad on screen.
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.2"
s.version = "0.0.3"
s.summary = "Kontext shared native modules for iOS SDKs"
s.description = <<-DESC
KontextKit bundles the iOS-native primitives shared across every Kontext
Expand Down
80 changes: 80 additions & 0 deletions Sources/DeviceInfo/InstallIdProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

/// Per-app-install identifier. Generated as a UUID v7 on first SDK use,
/// persisted to `UserDefaults`, and attached to every ad-server request
/// (`/init`, `/preload`, `/error`, `/debug`) so the server can key pacing,
/// frequency caps, and per-install diagnostics to a stable client identity
/// independent of `conversationId` or `userId`.
///
/// Survives app launches; resets only when the user uninstalls the app
/// (or clears app data). Matches the `kontextso:installId` localStorage
/// key used by `@kontextso/sdk-js` so web and native installs share the
/// same shape on the wire.
public enum InstallIdProvider {

/// UserDefaults key under which the install ID is persisted.
private static let storageKey = "kontextso.installId"

/// Returns the persisted install ID, generating + storing one on
/// first call. The stored value is validated against the canonical
/// UUID shape (8-4-4-4-12 hex) and overwritten if corrupted — guards
/// against accidental tampering, partial UserDefaults migration, or
/// a future change to the generator.
public static func getOrCreate(
defaults: UserDefaults = .standard
) -> String {
if let existing = defaults.string(forKey: storageKey),
isCanonicalUUID(existing) {
return existing
}
let fresh = uuidv7()
defaults.set(fresh, forKey: storageKey)
return fresh
}

/// Generates a UUID v7 string per RFC 9562:
///
/// 48-bit big-endian Unix-epoch milliseconds | version (0111) | 12 random bits |
/// variant (10) | 62 random bits
///
/// Time-ordered, so server-side sorts and B-tree indexes stay friendly.
static func uuidv7() -> String {
let ts = UInt64(Date().timeIntervalSince1970 * 1000)
var bytes = [UInt8](repeating: 0, count: 16)
// 48-bit timestamp, big-endian
bytes[0] = UInt8((ts >> 40) & 0xff)
bytes[1] = UInt8((ts >> 32) & 0xff)
bytes[2] = UInt8((ts >> 24) & 0xff)
bytes[3] = UInt8((ts >> 16) & 0xff)
bytes[4] = UInt8((ts >> 8) & 0xff)
bytes[5] = UInt8(ts & 0xff)
// Random bytes for the remaining 10 octets.
let randomStatus = SecRandomCopyBytes(kSecRandomDefault, 10, &bytes[6])
if randomStatus != errSecSuccess {
// SecRandomCopyBytes is documented to never fail in practice;
// fall back to arc4random_buf so we still produce a well-formed
// UUID rather than ship zero bytes.
arc4random_buf(&bytes[6], 10)
}
// Version 7 in the high nibble of byte 6.
bytes[6] = (bytes[6] & 0x0f) | 0x70
// Variant 10 in the high two bits of byte 8.
bytes[8] = (bytes[8] & 0x3f) | 0x80

let hex = bytes.map { String(format: "%02x", $0) }.joined()
let i0 = hex.startIndex
let i8 = hex.index(i0, offsetBy: 8)
let i12 = hex.index(i8, offsetBy: 4)
let i16 = hex.index(i12, offsetBy: 4)
let i20 = hex.index(i16, offsetBy: 4)
return "\(hex[i0..<i8])-\(hex[i8..<i12])-\(hex[i12..<i16])-\(hex[i16..<i20])-\(hex[i20...])"
}

/// Validates any canonical UUID shape (8-4-4-4-12 hex). Intentionally
/// not version- or variant-locked so a future change to the generator
/// (e.g. v8) doesn't invalidate the IDs already in users' UserDefaults.
static func isCanonicalUUID(_ value: String) -> Bool {
guard value.count == 36 else { return false }
return UUID(uuidString: value) != nil
}
}
102 changes: 102 additions & 0 deletions Tests/DeviceInfo/InstallIdProviderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation
import Testing
@testable import KontextKit

private let uuidV7Pattern = "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
private let storageKey = "kontextso.installId"

/// Pattern-match helper that doesn't require iOS 16+ (`Regex` /
/// `#/.../#` literal). `String.range(of:options:)` with
/// `.regularExpression` has been around since iOS 4 — keeps the test
/// file buildable against KontextKit's iOS 14 deployment target.
private func matchesV7Shape(_ id: String) -> Bool {
id.range(of: uuidV7Pattern, options: .regularExpression) != nil
}

/// Builds a fresh UserDefaults backed by a unique suite name so each
/// test gets isolated storage — avoids cross-test contamination on the
/// shared `.standard` defaults and lets us assert against a known-empty
/// starting state.
private func freshDefaults(_ suite: String = UUID().uuidString) -> UserDefaults {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}

struct UUIDv7Tests {
@Test func producesCanonicalV7Shape() {
let id = InstallIdProvider.uuidv7()
#expect(matchesV7Shape(id))
}

@Test func encodesCurrentTimestampInPrefix() {
let before = UInt64(Date().timeIntervalSince1970 * 1000)
let id = InstallIdProvider.uuidv7()
let after = UInt64(Date().timeIntervalSince1970 * 1000)
// First 12 hex chars (without the hyphen) are the 48-bit timestamp.
let tsHex = String(id.prefix(8)) + String(id.dropFirst(9).prefix(4))
let ts = UInt64(tsHex, radix: 16) ?? 0
#expect(ts >= before)
#expect(ts <= after)
}

@Test func emitsTimeOrderedIDsAcrossDistinctMilliseconds() {
let a = InstallIdProvider.uuidv7()
// Spin until the millisecond changes so the timestamp prefix differs.
let baseline = UInt64(Date().timeIntervalSince1970 * 1000)
while UInt64(Date().timeIntervalSince1970 * 1000) == baseline { /* spin */ }
let b = InstallIdProvider.uuidv7()
#expect(a < b)
}
}

struct InstallIdProviderTests {

@Test func generatesAndPersistsOnFirstCall() {
let defaults = freshDefaults()
let id = InstallIdProvider.getOrCreate(defaults: defaults)
#expect(matchesV7Shape(id))
#expect(defaults.string(forKey: storageKey) == id)
}

@Test func returnsSameValueOnSubsequentCalls() {
let defaults = freshDefaults()
let first = InstallIdProvider.getOrCreate(defaults: defaults)
let second = InstallIdProvider.getOrCreate(defaults: defaults)
#expect(first == second)
}

@Test func reusesValueAlreadyInStorage() {
let defaults = freshDefaults()
let seeded = "01890000-0000-7000-8000-000000000000"
defaults.set(seeded, forKey: storageKey)
#expect(InstallIdProvider.getOrCreate(defaults: defaults) == seeded)
}

@Test func acceptsNonV7UUIDForForwardCompat() {
// v4 UUID — the validator is intentionally version-agnostic so a
// future generator change doesn't invalidate IDs already on disk.
let defaults = freshDefaults()
let v4 = "550e8400-e29b-41d4-a716-446655440000"
defaults.set(v4, forKey: storageKey)
#expect(InstallIdProvider.getOrCreate(defaults: defaults) == v4)
}

@Test(arguments: [
"",
"lol-not-a-uuid",
"01890000-0000-7000-8000-00000000", // truncated
"01890000-0000-7000-8000-000000000000-extra",
"{\"foo\":\"bar\"}",
"0189zzzz-0000-7000-8000-000000000000", // non-hex chars
])
func overwritesMalformedStoredValue(_ malformed: String) {
let defaults = freshDefaults()
defaults.set(malformed, forKey: storageKey)
let id = InstallIdProvider.getOrCreate(defaults: defaults)
#expect(matchesV7Shape(id))
#expect(id != malformed)
// Replacement is persisted so subsequent calls see the same value.
#expect(defaults.string(forKey: storageKey) == id)
}
}
Loading