From 47531c5192267c9b20ab7991d3937b0aaac14755 Mon Sep 17 00:00:00 2001 From: bdot <134958415+bdot-real@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:44:35 -0400 Subject: [PATCH] Publish Score iOS SDK 0.3.0 --- INTEGRATION.md | 127 ++++++++++++++++++------ Package.swift | 4 +- README.md | 17 ++-- ScoreTracker.podspec | 9 +- Sources/ScoreTracker/AppLifecycle.swift | 104 +++++++++++++++++++ Sources/ScoreTracker/Exports.swift | 19 ++++ Sources/ScoreTracker/ScoreClient.swift | 106 +++++++++++++++++--- 7 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 Sources/ScoreTracker/AppLifecycle.swift create mode 100644 Sources/ScoreTracker/Exports.swift diff --git a/INTEGRATION.md b/INTEGRATION.md index 535b41a..dcbd295 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -8,9 +8,9 @@ identically — see [`conformance/CONTRACT.md`](conformance/CONTRACT.md). --- -## Install — 0.2.0 beta (public, no auth) +## Install — 0.3.0 beta (public, no auth) -Distributed publicly from **[`3HL-SCORE/public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score)** (#284) — **no Gitea token / login required**. The iOS XCFramework is a public **release asset** (`mobile-v0.2.0`) on that repo; the Android artifacts live in the org's **Gitea Maven package registry** (anonymous read). +Distributed publicly from **[`3HL-SCORE/public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score)** (#284) — **no Gitea token / login required**. The iOS XCFramework is a public **release asset** (`mobile-v0.3.0`) on that repo; the Android artifacts live in the org's **Gitea Maven package registry** (anonymous read). ### Android — Gradle (public Maven registry) ```kotlin @@ -18,7 +18,7 @@ repositories { maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } // public — no credentials } dependencies { - implementation("com.threehalveslabs.scoretracker:shared-android:0.2.0") + implementation("com.threehalveslabs.scoretracker:shared-android:0.3.0") } ``` @@ -34,19 +34,21 @@ add it by **git URL** — no registry config, no login: ```swift // your app's Package.swift dependencies: [ - .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.2.0") + .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.3.0") ], // target: .product(name: "ScoreTracker", package: "public-sdks-score") ``` Or in Xcode: **File → Add Packages…** → paste the URL. The binary `ScoreTrackerKit` -target pulls the XCFramework from the **public** `mobile-v0.2.0` release asset (SPM +target pulls the XCFramework from the **public** `mobile-v0.3.0` release asset (SPM verifies the checksum) — no `~/.netrc` credentials needed. Verified with an anonymous `swift package resolve`. ### iOS — direct XCFramework -Download `ScoreTrackerKit.xcframework.zip` from the **mobile-v0.2.0** release on -[`public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases) -and drag the `.xcframework` into your Xcode project; add `ScoreClient.swift`. +Download `ScoreTracker-pod.zip` from the **mobile-v0.3.0** release on +[`public-sdks-score`](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases). +It holds `ScoreTrackerKit.xcframework` and the Swift facade sources +(`Sources/ScoreTracker/`: `ScoreClient.swift`, `Exports.swift`, `AppLifecycle.swift`). +Drag the `.xcframework` into your Xcode project and add the Swift files to your app target. --- @@ -59,7 +61,6 @@ let score = ScoreClient( publishableKey: "pk_live_…", siteId: "urc-ios", endpoint: "https://", - sdkVersion: "0.2.0", appVersion: Bundle.main.shortVersion, os: "iOS \(UIDevice.current.systemVersion)", device: UIDevice.current.model, @@ -69,14 +70,26 @@ let score = ScoreClient( // Nothing sends until consent is granted (see Consent below). score.grantAllConsent() -// Track app earning events -score.track("poll_vote", props: ["pollId": "motm-r12", "choice": "player_7"]) +// Track app earning events with the generated `Events` taxonomy (preferred): +// the wire name and its consent `requires` come from the shared schema, so iOS, +// Android and web emit byte-identical events (#206, #485). +score.track(Events.shared.PollVote, props: ["pollId": "motm-r12", "choice": "player_7"]) + +// …or by name, for an event not in `Events`. `requires:` gates it on consent. +score.track("custom_badge_tap", props: ["badgeId": "b1"], requires: [.analytics]) score.screen("match_centre") // Flush is async; the SDK also auto-flushes (see Offline & flushing) Task { try? await score.flush() } ``` +> The envelope's `sdk.name` and `sdk.version` are baked into the build: `…-ios` +> or `…-android` from the platform, and the version from the release (#486). +> There's nothing to pass. Read them with `ScoreClient.sdkName` / +> `ScoreClient.sdkVersion` (Swift) or `SdkInfo.CURRENT` (Kotlin) when a support +> conversation asks "which version are you on?". The old `sdkVersion:` argument +> and `TrackerConfig(…, sdk = …)` still compile, but are deprecated and ignored. + The durable offline queue and persistent `anonymousId` are handled natively (NSUserDefaults) — events survive app cold starts and flush when back online. @@ -94,7 +107,6 @@ val score = newScoreTracker( publishableKey = "pk_live_…", siteId = "urc-android", endpoint = "https://", - sdk = SdkInfo("@3halves-labs/score-tracker-android", "0.2.0"), device = DeviceDescriptor(os = "Android ${Build.VERSION.RELEASE}", device = Build.MODEL, appVersion = BuildConfig.VERSION_NAME, locale = Locale.getDefault().toLanguageTag()), ), @@ -136,9 +148,12 @@ score.setConsent(analytics: groups.analytics, personalization: groups.personaliz score.setConsent(ConsentState(analytics = a, personalization = p, advertising = ad)) ``` -Events declaring a category (e.g. `trackAdvertising(...)` on iOS, or -`requires = listOf(ConsentCategory.ADVERTISING)` on Android) are dropped until -that category is granted. +Events declaring a category are dropped until that category is granted. A typed +`Events` definition carries its own (`Events.PollVote` requires analytics, +`Events.AdImpression` advertising), identically on iOS and Android. For a named +event, pass it yourself: `requires: [.advertising]` on iOS (or the +`trackAdvertising(...)` shorthand), `requires = listOf(ConsentCategory.ADVERTISING)` +on Android. ## PII scrubbing @@ -174,9 +189,9 @@ score.track("profile_updated", mapOf("email" to "fan@example.com", "favouriteTea // iOS let score = ScoreClient( publishableKey: "pk_…", siteId: "app.example.com-1a2b3c", - endpoint: "https://api.example.com", sdkVersion: "0.2.0", + endpoint: "https://api.example.com", pii: PiiOptions.Classify(rules: [ - PiiRule(field: "email", action: .hash), + PiiRule(field: "email", action: .hash_), // `hash_`, not `hash`: that name is NSObject's PiiRule(field: "phone", action: .redact), PiiRule(field: "memberNumber", action: .tokenize), PiiRule(field: "dateOfBirth", action: .drop), @@ -189,29 +204,85 @@ To remove keys without per-field actions, use ## Auth0 (JWT) -Mirrors the web tracker (#109): supply a token provider — it's consulted before -each flush, so flushes carry a fresh access token. Failure → anonymous, never -halts. +Mirrors the web tracker's `getToken` (#109, #483): supply a token provider. It's +consulted before each flush, so flushes carry a fresh access token. It is +**async**, so hand it Auth0's own refresh call directly; there's nothing to block +on or cache yourself. ```swift -score.setTokenProvider { Auth0Session.shared.accessToken } // called before each flush -score.identify(userId: "auth0|abc123") // optional: set userId now +// iOS — Auth0.swift's CredentialsManager renews the token when it's expired. +score.setTokenProvider { try await credentialsManager.credentials().accessToken } +score.identify(userId: "auth0|abc123") // optional: set userId now ``` ```kotlin -score.setTokenProvider { auth0.credentials?.accessToken } +// Android — Auth0.Android's CredentialsManager has a suspend awaitCredentials(). +score.setTokenProvider { credentialsManager.awaitCredentials().accessToken } score.identify("auth0|abc123") ``` +How the provider is used: +- **Before each flush**, and **single-flight**: overlapping flushes share one + in-flight call instead of starting several. +- **Bounded:** if it hasn't answered within 5 s, the flush goes ahead without + waiting. The call keeps running, and its token is used once it arrives. + Android can change this with the `tokenRefreshTimeoutMs` parameter on + `ScoreTracker`. +- **Never halts tracking:** if the provider throws or returns null, the flush + uses the last known token, or goes anonymous if there isn't one. +- A synchronous closure still works (`{ cachedToken }`), so existing code + compiles unchanged. + ## Offline & flushing - **Durable queue:** events persist (NSUserDefaults / SharedPreferences), survive cold start, are FIFO-capped (oldest dropped), and clear only on a successful POST (a failed flush retains them for retry). -- **Auto-flush:** `score.startAutoFlush(everySeconds: 30)` (iOS) / - `score.startAutoFlush(scope, intervalMs)` (Android). -- **Lifecycle hooks:** call `onForeground()` / `onNetworkAvailable()` to flush - promptly — wire them to `UIApplication`/`NWPathMonitor` (iOS) or - `ProcessLifecycleOwner`/connectivity callbacks (Android). +- **Lifecycle & connectivity — one call (#484).** Bind the tracker once at + startup and it flushes when it should: + + ```swift + // iOS — in application(_:didFinishLaunchingWithOptions:) + score.bindToAppLifecycle() + ``` + ```kotlin + // Android — in Application.onCreate(), on the main thread + score.bindToAppLifecycle(this) + ``` + + | When | What the tracker does | + |---|---| + | App comes to the foreground (or is already there when you bind) | Flush, and flush every 30 s while it stays there | + | App goes to the background | Stop the periodic flush; one **best-effort flush bounded to 5 s**, so it can't hold up suspension (on iOS, inside a background task that ends as soon as the flush does) | + | Network comes back | Flush | + + Both platforms run the same shared logic. Options: `autoFlushEverySeconds` / + `autoFlushIntervalMs` (`nil`/`null` to run your own auto-flush) and + `backgroundBudgetSeconds` / `backgroundBudgetMs`. Undo with + `unbindAppLifecycle()` (iOS) or `close()` on the returned binding (Android). + Android declares `ACCESS_NETWORK_STATE` in the library manifest, a normal + permission with no prompt. A bounded flush that runs out of time loses + nothing: events leave the queue only after their POST succeeds. +- **Delivery tiers (#487).** Mark an event that must not be lost as critical: + + ```swift + score.track(Events.shared.PollVote, props: […], delivery: .critical) // iOS + ``` + ```kotlin + score.track(Events.PollVote, mapOf(…), delivery = DeliveryMode.CRITICAL) // Android + ``` + + Critical events go out first in every flush, survive app restarts as + critical, and are the last to go when the queue is full: best-effort + events, including new ones, are shed before any critical event. If a + critical event is lost anyway, because it aged past 72 h or the queue was + entirely critical, the SDK sends a `delivery_failed` event naming it + (`originalEventId`, `originalEventName`, `reason`: `age_evicted` or + `queue_full`), the same event the web tracker sends. The tier is local: + it never appears on the wire. +- **Manual control instead:** binding is opt-in. Skip it and call + `onForeground()`, `onBackground()` and `onNetworkAvailable()` yourself, plus + `startAutoFlush(everySeconds: 30)` (iOS) / `startAutoFlush(scope, intervalMs)` + (Android). ## Canonical app earning events diff --git a/Package.swift b/Package.swift index 53568a9..393a791 100644 --- a/Package.swift +++ b/Package.swift @@ -15,8 +15,8 @@ let package = Package( targets: [ .binaryTarget( name: "ScoreTrackerKit", - url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.2.0/ScoreTrackerKit.xcframework.zip", - checksum: "328f8e36fc0c71d5b82bd4cc79d3d12b4520dea2db2358c73922348d83144b47" + url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.3.0/ScoreTrackerKit.xcframework.zip", + checksum: "81edf75ed592ef2b4cf17af8aea406333cebba62b2e5b69c28e386e8bc415a7d" ), .target( name: "ScoreTracker", diff --git a/README.md b/README.md index 25b471d..f392973 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,18 @@ Public, auth-free distribution of the **Score mobile tracker** SDKs for iOS and > This repo hosts the **consumable artifacts** (release assets + package manifests). The SDK source lives in 3 Halves Labs' private platform repo. -## Install — 0.2.0 (no token / login required) +## Install — 0.3.0 (no token / login required) ### iOS — Swift Package Manager -```bash -swift package-registry set https://git.3halves-labs.com/api/packages/3HL-SCORE/swift -``` +Add this repo by git URL. There's no registry to configure and no login. ```swift // Package.swift -dependencies: [ .package(id: "threehalveslabs.ScoreTracker", from: "0.2.0") ] -// target: .product(name: "ScoreTracker", package: "ScoreTracker") +dependencies: [ .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.3.0") ] +// target: .product(name: "ScoreTracker", package: "public-sdks-score") ``` +Or in Xcode: **File → Add Packages…** and paste the URL. (The old Swift package +*registry* route, `threehalveslabs.ScoreTracker`, was retired; that package no +longer exists.) ### iOS — CocoaPods ```ruby @@ -28,7 +29,7 @@ pod 'ScoreTracker', ``` ### iOS — direct XCFramework -Download `ScoreTrackerKit.xcframework.zip` from the [`mobile-v0.2.0` release](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases) and drag it into Xcode. +Download `ScoreTracker-pod.zip` from the [`mobile-v0.3.0` release](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases). It contains `ScoreTrackerKit.xcframework` plus the Swift facade sources (`Sources/ScoreTracker/`). Drag the framework into Xcode and add the Swift files to your app target. ### Android — Gradle ```kotlin @@ -36,7 +37,7 @@ repositories { maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } // public — no credentials } dependencies { - implementation("com.threehalveslabs.scoretracker:shared-android:0.2.0") + implementation("com.threehalveslabs.scoretracker:shared-android:0.3.0") } ``` diff --git a/ScoreTracker.podspec b/ScoreTracker.podspec index cb69c01..4459bfe 100644 --- a/ScoreTracker.podspec +++ b/ScoreTracker.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'ScoreTracker' - s.version = '0.2.0' + s.version = '0.3.0' s.summary = 'Score mobile tracker (iOS) — KMP shared core + Swift facade.' s.description = <<-DESC Lightweight app analytics / fan-engagement tracker. Emits the same event @@ -13,8 +13,11 @@ Pod::Spec.new do |s| s.platform = :ios, '14.0' s.swift_version = '5.9' - # Release attaches the zipped XCFramework as a PUBLIC asset on public-sdks-score (#284). - s.source = { :http => 'https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.2.0/ScoreTrackerKit.xcframework.zip' } + # release.sh attaches ScoreTracker-pod.zip — the XCFramework plus the Swift + # facade sources — as a PUBLIC asset on public-sdks-score (#284, #491), and + # stamps s.version above, which this URL is built from. (0.2.0's pod pointed + # at the SPM zip, which holds the framework alone, so it had no ScoreClient.) + s.source = { :http => "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v#{s.version}/ScoreTracker-pod.zip" } s.vendored_frameworks = 'ScoreTrackerKit.xcframework' s.source_files = 'Sources/ScoreTracker/**/*.swift' diff --git a/Sources/ScoreTracker/AppLifecycle.swift b/Sources/ScoreTracker/AppLifecycle.swift new file mode 100644 index 0000000..f945640 --- /dev/null +++ b/Sources/ScoreTracker/AppLifecycle.swift @@ -0,0 +1,104 @@ +import Foundation +import Network +import ScoreTrackerKit +import UIKit + +/// Everything `bindToAppLifecycle()` installed, so `unbindAppLifecycle()` can +/// remove exactly that (#484). +final class AppBindings { + let controller: AppLifecycleController + var observers: [NSObjectProtocol] = [] + var pathMonitor: NWPathMonitor? + var networkWasSatisfied = true + + init(controller: AppLifecycleController) { self.controller = controller } +} + +extension ScoreClient { + + /// Flush on the app's lifecycle and connectivity, so you don't wire them by + /// hand (#484). One call, opt-in — typically in + /// `application(_:didFinishLaunchingWithOptions:)`: + /// + /// score.bindToAppLifecycle() + /// + /// - Foreground (`willEnterForeground`, or already active when you bind): + /// flush, and flush every `autoFlushEverySeconds` (nil: leave auto-flush + /// to you). + /// - Background (`didEnterBackground`): stop the periodic flush, then a + /// best-effort flush bounded by `backgroundBudgetSeconds`, inside a + /// background task that ends as soon as the flush does. + /// - Network back (`NWPathMonitor` turns satisfied): flush. + /// + /// The behaviour is the shared `AppLifecycleController`, the same one the + /// Android binding drives. Call on the main thread. If you use + /// `startAutoFlush(everySeconds:)` yourself, binding with an interval + /// replaces it; pass `autoFlushEverySeconds: nil` to keep yours. + @available(iOSApplicationExtension, unavailable, message: "App lifecycle notifications aren't delivered to extensions; flush manually.") + public func bindToAppLifecycle(autoFlushEverySeconds: Double? = 30, backgroundBudgetSeconds: Double = 5) { + dispatchPrecondition(condition: .onQueue(.main)) + unbindAppLifecycle() + if autoFlushEverySeconds != nil { stopAutoFlush() } // one periodic flush, not two + + let controller = IosPlatformKt.doNewAppLifecycleController( + tracker: tracker, + autoFlushIntervalMs: Int64((autoFlushEverySeconds ?? 0) * 1000), // 0 → none + backgroundBudgetMs: Int64(backgroundBudgetSeconds * 1000) + ) + let bindings = AppBindings(controller: controller) + let center = NotificationCenter.default + + bindings.observers.append(center.addObserver( + forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main + ) { _ in controller.appDidEnterForeground() }) + + bindings.observers.append(center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { _ in + // Ask iOS for time to finish the flush; hand it back the moment the + // bounded flush is done (or, if iOS's own deadline comes first, then). + var task = UIBackgroundTaskIdentifier.invalid + let end = { + guard task != .invalid else { return } + UIApplication.shared.endBackgroundTask(task) + task = .invalid + } + task = UIApplication.shared.beginBackgroundTask(withName: "ScoreTracker.flush", expirationHandler: end) + controller.appDidEnterBackground(onDone: end) + }) + + // Android parity: binding while already in the foreground counts as one. + if UIApplication.shared.applicationState == .active { + controller.appDidEnterForeground() + } + + // Flush on the transition to "satisfied", not on every path update. + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak bindings] path in + DispatchQueue.main.async { + guard let bindings else { return } + let satisfied = path.status == .satisfied + if satisfied && !bindings.networkWasSatisfied { bindings.controller.networkDidBecomeAvailable() } + bindings.networkWasSatisfied = satisfied + } + } + monitor.start(queue: DispatchQueue(label: "com.threehalveslabs.scoretracker.network")) + bindings.pathMonitor = monitor + + appBindings = bindings + } + + /// Remove everything `bindToAppLifecycle()` installed. Safe to call when not bound. + public func unbindAppLifecycle() { + dispatchPrecondition(condition: .onQueue(.main)) + unbindAppLifecycleFromAnyContext() + } + + func unbindAppLifecycleFromAnyContext() { + guard let bindings = appBindings else { return } + bindings.observers.forEach { NotificationCenter.default.removeObserver($0) } + bindings.pathMonitor?.cancel() + bindings.controller.close() + appBindings = nil + } +} diff --git a/Sources/ScoreTracker/Exports.swift b/Sources/ScoreTracker/Exports.swift new file mode 100644 index 0000000..9704b58 --- /dev/null +++ b/Sources/ScoreTracker/Exports.swift @@ -0,0 +1,19 @@ +import ScoreTrackerKit + +// The types in ScoreClient's public API live in the ScoreTrackerKit framework. +// The integration guide has apps `import ScoreTracker` only, which left them +// unable to name any of these, so `Events.shared.PollVote` and +// `requires: [ConsentCategory.advertising]` didn't compile in an app (#485). +// Re-exported by name; an app that also imports ScoreTrackerKit sees the same +// types, because a module's declarations shadow those of the modules it imports. + +/// Generated loyalty event definitions (`Events.shared.PollVote`, …), shared +/// with the web and Android trackers. +public typealias Events = ScoreTrackerKit.Events +public typealias EventDefinition = ScoreTrackerKit.EventDefinition +public typealias ConsentCategory = ScoreTrackerKit.ConsentCategory +public typealias ConsentState = ScoreTrackerKit.ConsentState +public typealias DeliveryMode = ScoreTrackerKit.DeliveryMode +public typealias PiiOptions = ScoreTrackerKit.PiiOptions +public typealias PiiRule = ScoreTrackerKit.PiiRule +public typealias PiiAction = ScoreTrackerKit.PiiAction diff --git a/Sources/ScoreTracker/ScoreClient.swift b/Sources/ScoreTracker/ScoreClient.swift index 653dc91..783e71f 100644 --- a/Sources/ScoreTracker/ScoreClient.swift +++ b/Sources/ScoreTracker/ScoreClient.swift @@ -7,14 +7,49 @@ import ScoreTrackerKit /// and persistent `anonymousId` are handled natively (NSUserDefaults) — see #176. public final class ScoreClient { - private let tracker: ScoreTracker + let tracker: ScoreTracker private var autoFlushTask: Task? + /// Set while `bindToAppLifecycle()` is in effect (AppLifecycle.swift, #484). + var appBindings: AppBindings? /// - Parameters: /// - endpoint: middleware base URL; events POST to `/api/tracking/website`. /// - initialConsent: defaults to "none" — nothing fires until `setConsent(...)`. /// - pii: field-level PII handling (#482), scrubbed before events are queued on the device. + /// - tokenRefreshTimeoutSeconds: how long a flush waits for the token provider (#483). + /// + /// The envelope's `sdk.name` / `sdk.version` are baked into the framework + /// (#486); there is nothing to pass. public init( + publishableKey: String, + siteId: String, + endpoint: String, + appVersion: String? = nil, + os: String? = nil, + device: String? = nil, + locale: String? = nil, + initialConsent: ConsentState = ConsentState.companion.NONE, + pii: PiiOptions? = nil, + tokenRefreshTimeoutSeconds: Double = 5 + ) { + let config = TrackerConfig( + publishableKey: publishableKey, + siteId: siteId, + endpoint: endpoint, + device: DeviceDescriptor(os: os, device: device, appVersion: appVersion, locale: locale) + ) + tracker = IosPlatformKt.doNewScoreTracker( + config: config, + initialConsent: initialConsent, + pii: pii, + tokenRefreshTimeoutMs: Int64(tokenRefreshTimeoutSeconds * 1000) + ) + } + + /// The pre-#486 signature. `sdkVersion` is ignored: the SDK reports the + /// version it was built as. + @available(*, deprecated, message: "The SDK reports its own version now (#486); sdkVersion is ignored. Drop the argument.") + public convenience init( publishableKey: String, siteId: String, endpoint: String, @@ -26,16 +61,17 @@ public final class ScoreClient { initialConsent: ConsentState = ConsentState.companion.NONE, pii: PiiOptions? = nil ) { - let config = TrackerConfig( - publishableKey: publishableKey, - siteId: siteId, - endpoint: endpoint, - sdk: SdkInfo(name: "@3halves-labs/score-tracker-ios", version: sdkVersion), - device: DeviceDescriptor(os: os, device: device, appVersion: appVersion, locale: locale) + self.init( + publishableKey: publishableKey, siteId: siteId, endpoint: endpoint, + appVersion: appVersion, os: os, device: device, locale: locale, + initialConsent: initialConsent, pii: pii ) - tracker = IosPlatformKt.doNewScoreTracker(config: config, initialConsent: initialConsent, pii: pii) } + /// What this build reports as `sdk.name` / `sdk.version` — for support and logs. + public static var sdkName: String { SdkInfo.companion.CURRENT.name } + public static var sdkVersion: String { SdkInfo.companion.CURRENT.version } + // MARK: - Consent (wire to your CMP / OneTrust) public func setConsent(analytics: Bool, personalization: Bool, advertising: Bool) { @@ -51,9 +87,21 @@ public final class ScoreClient { } public func setToken(_ token: String?) { tracker.setToken(token: token) } - /// Provider is consulted before each flush — wire to your Auth0 access token. - public func setTokenProvider(_ provider: @escaping () -> String?) { - tracker.setTokenProvider(provider: provider) + /// Supply the fan's access token on demand — wire to Auth0's + /// `credentialsManager.credentials()` (#483). `async`, so a silent refresh + /// needs no blocking; a plain synchronous closure works too. Consulted + /// before each flush, single-flight. If it throws, returns nil or takes + /// longer than the tracker's timeout (5 s), the flush goes ahead with the + /// last known token — anonymous if there is none. + public func setTokenProvider(_ provider: @escaping () async throws -> String?) { + tracker.setCallbackTokenProvider { complete in + Task { complete(try? await provider()) } // a throw counts as "no token" + } + } + + /// Stop consulting a provider; the last token set stays until replaced. + public func clearTokenProvider() { + tracker.setCallbackTokenProvider(provider: nil) } public var anonymousId: String { tracker.anonymousId } @@ -61,16 +109,35 @@ public final class ScoreClient { // MARK: - Tracking - /// Track an app event. Returns false if dropped by the consent gate. + /// Track a typed loyalty event — the preferred form (#485). `Events` is + /// generated from the same schemas as the web and Android trackers, so the + /// name and its consent contract (`requires`) are identical on every + /// platform. Returns false if dropped by the consent gate. + /// + /// score.track(Events.shared.PollVote, props: ["pollId": "motm-r12", "choice": "player_7"]) @discardableResult - public func track(_ name: String, props: [String: Any] = [:]) -> Bool { - tracker.track(name: name, props: props, type: .track, requires: [], delivery: .bestEffort) + public func track(_ event: EventDefinition, props: [String: Any] = [:], delivery: DeliveryMode = .bestEffort) -> Bool { + tracker.track(event: event, props: props, delivery: delivery) + } + + /// Track an app event by name, for events not in `Events`. `requires` gates + /// it on consent categories — it is dropped unless every one is granted. + /// Returns false if dropped by the consent gate. + @discardableResult + public func track( + _ name: String, + props: [String: Any] = [:], + requires: [ConsentCategory] = [], + delivery: DeliveryMode = .bestEffort + ) -> Bool { + tracker.track(name: name, props: props, type: .track, requires: requires, delivery: delivery) } /// Track an event gated on advertising consent (dropped until granted). + /// Shorthand for `track(name, props:, requires: [.advertising])`. @discardableResult public func trackAdvertising(_ name: String, props: [String: Any] = [:]) -> Bool { - tracker.track(name: name, props: props, type: .track, requires: [ConsentCategory.advertising], delivery: .bestEffort) + track(name, props: props, requires: [ConsentCategory.advertising]) } /// Record an app-screen view. @@ -91,9 +158,18 @@ public final class ScoreClient { public func onForeground() async throws { _ = try await tracker.onForeground() } /// Call when network connectivity is regained. public func onNetworkAvailable() async throws { _ = try await tracker.onNetworkAvailable() } + /// Best-effort flush as the app goes to the background (#484), bounded by + /// `budgetSeconds` so it cannot hold up suspension. Returns false if it ran + /// out of time; unsent events stay queued. `bindToAppLifecycle()` calls this + /// for you. + @discardableResult + public func onBackground(budgetSeconds: Double = 5) async throws -> Bool { + try await tracker.onBackground(budgetMs: Int64(budgetSeconds * 1000)).boolValue + } public func shutdown() async throws { stopAutoFlush() + await MainActor.run { unbindAppLifecycleFromAnyContext() } try await tracker.shutdown() }