# Mobile SDK — Integration Guide (iOS + Android) The Score mobile tracker is one Kotlin Multiplatform core shipped as a native **iOS framework** (`ScoreTrackerKit`, fronted by the Swift `ScoreClient` facade) and a native **Android AAR**. It emits the **same event envelope** as the web tracker (`@3halves-labs/score-tracker`), so app events fire challenges/goals identically — see [`conformance/CONTRACT.md`](conformance/CONTRACT.md). --- ## Install — 0.5.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.5.0`) on that repo; the Android artifacts live in the org's **Gitea Maven package registry** (anonymous read). ### Android — Gradle (public Maven registry) ```kotlin 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.5.0") } ``` ### iOS — CocoaPods (public release asset) ```ruby pod 'ScoreTracker', :podspec => 'https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/raw/branch/main/ScoreTracker.podspec' ``` ### iOS — Swift Package Manager (public git URL) The SPM package lives at the **root** of the public `public-sdks-score` repo, so 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.5.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.5.0` release asset (SPM verifies the checksum) — no `~/.netrc` credentials needed. Verified with an anonymous `swift package resolve`. ### iOS — direct XCFramework Download `ScoreTracker-pod.zip` from the **mobile-v0.5.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. --- ## iOS (Swift) ```swift import ScoreTracker // the Swift facade (wraps the ScoreTrackerKit framework) let score = ScoreClient( publishableKey: "pk_live_…", siteId: "urc-ios", endpoint: "https://", appVersion: Bundle.main.shortVersion, os: "iOS \(UIDevice.current.systemVersion)", device: UIDevice.current.model, locale: Locale.current.identifier ) // Nothing sends until consent is granted (see Consent below). score.grantAllConsent() // 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. ## Android (Kotlin) The shared core is already idiomatic Kotlin — no facade needed; construct via the `newScoreTracker(context, …)` factory (uses SharedPreferences + OkHttp): ```kotlin import com.threehalveslabs.scoretracker.* val score = newScoreTracker( context = applicationContext, config = TrackerConfig( publishableKey = "pk_live_…", siteId = "urc-android", endpoint = "https://", device = DeviceDescriptor(os = "Android ${Build.VERSION.RELEASE}", device = Build.MODEL, appVersion = BuildConfig.VERSION_NAME, locale = Locale.getDefault().toLanguageTag()), ), ) score.setConsent(ConsentState.ALL) // Either a string name… score.track("poll_vote", mapOf("pollId" to "motm-r12", "choice" to "player_7")) // …or a typed definition from the generated `Events` taxonomy (preferred): the // wire name + consent `requires` come from the shared schema, so app + web emit // byte-identical events (#206). Same `Events` exist in the web tracker. score.track(Events.PollVote, mapOf("pollId" to "motm-r12", "choice" to "player_7")) // flush()/shutdown() are suspend — call from a coroutine lifecycleScope.launch { score.flush() } ``` > `Events` (`Events.PollVote`, `Events.SportsPrediction`, `Events.SocialShare`, > `Events.ArticleRead`, `Events.ProductView`, `Events.VideoWatch`, > `Events.ProfileFavouriteTeam`, …) is generated from `sdk/schemas/events/*.schema.json` > via `npm run gen:events`. **Don't edit `Events.kt` by hand** — edit the schema and > regenerate; the web-side drift guard keeps it in lockstep. --- ## Consent (wire to your CMP / OneTrust) Mirrors the web tracker (#108): the tracker is **idle until consent is granted**, and pre-consent events are **dropped, not buffered**. Update consent whenever the user's CMP choice changes. ```swift // iOS — in your CMP's "consent changed" callback: score.setConsent(analytics: groups.analytics, personalization: groups.personalization, advertising: groups.advertising) ``` ```kotlin // Android: score.setConsent(ConsentState(analytics = a, personalization = p, advertising = ad)) ``` 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 Same options as the web tracker's `pii` (#482). Scrubbing runs **when you call `track()`, before the event is queued**, so a raw value never reaches the on-device queue (NSUserDefaults / SharedPreferences) or the network. - **Denylist:** remove matching keys. - **Classify:** a per-key action. The first rule for a key wins. - `DROP`: remove the key. - `REDACT`: replace the value with `""`. - `TOKENIZE`: replace it with `t:1`, `t:2`, …, stable for the tracker's lifetime. - `HASH`: `h:` plus FNV-1a 32-bit hex. It's identical to the web tracker, so the same value hashes the same on both platforms. **Not** cryptographic. Keys match exactly (case-sensitive) at any depth, including inside lists. ```kotlin // Android val score = newScoreTracker( context, config, pii = PiiOptions.Classify(listOf( PiiRule("email", PiiAction.HASH), // joinable across web + app PiiRule("phone", PiiAction.REDACT), PiiRule("memberNumber", PiiAction.TOKENIZE), PiiRule("dateOfBirth", PiiAction.DROP), )), ) score.track("profile_updated", mapOf("email" to "fan@example.com", "favouriteTeam" to "home-club")) // queued and sent as {"email":"h:141e28bb","favouriteTeam":"home-club"} ``` ```swift // iOS let score = ScoreClient( publishableKey: "pk_…", siteId: "app.example.com-1a2b3c", endpoint: "https://api.example.com", pii: PiiOptions.Classify(rules: [ 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), ]) ) ``` To remove keys without per-field actions, use `PiiOptions.Denylist(listOf("email", "phone"))`. ## Auth0 (JWT) 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 // 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 // Android — Auth0.Android's CredentialsManager has a suspend awaitCredentials(). score.setTokenProvider { credentialsManager.awaitCredentials().accessToken } score.identify("auth0|abc123") ``` ### Signing in: traits and replay (#488) `identify` takes the fan's traits, sent on an `identify` event (consent-gated and PII-scrubbed like any props). Pass `replayAnonymousEvents` to re-send the events the app already sent while the fan was signed out, now with the fan attached, so a poll vote cast before sign-in still earns its karma: ```swift score.identify(userId: "auth0|abc123", traits: ["team": "urc-leinster"], replayAnonymousEvents: true) ``` ```kotlin score.identify("auth0|abc123", traits = mapOf("team" to "urc-leinster"), replayAnonymousEvents = true) ``` Replay is opt-in. It re-sends at most the last 100 anonymously sent events (`identifyReplayBufferSize` on the factory / `ScoreClient` init), only those current consent still allows, marked `_backfill: true` with `_originalEventId`. Events still waiting in the queue aren't replayed: they go out with the fan on the next flush. The buffer lives in memory only, so it's empty after a cold start. After `identify`, the tracker also links this device's `anonymousId` to the fan (`POST /api/identity/link-anonymous`, with the token from your provider or `setToken`), so the server can credit events that arrive with only the `anonymousId`. It happens once per fan per device, on the next flush, after the events; it needs `analytics` consent and a token, and a failure never affects event delivery. Android's `score.lastLinkSkipReason()` (`already_linked`, `consent`, `no_token`, `error`) says why a link hasn't happened. A custom `Transport` must implement `postWithHeaders` for this call to carry the token. 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). - **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). ## Bug reports: attach `inspectJson()` When the tracker misbehaves in the field, the first thing to attach is its snapshot (#490), the counterpart of the web tracker's `inspect()`: ```swift print(score.inspectJson()) // iOS ``` ```kotlin Log.d("Score", score.inspectJson()) // Android ``` It covers: - the SDK name and version - consent: each category, and how many events the gate dropped - the queue: depth, caps, the oldest event's age, critical events, critical events lost - identity: `anonymousId`, `sessionId`, `userId`, whether a token is held, the `link-anonymous` state and the replay buffer - the last flush: when, whether it succeeded, how many events it sent, and how many failures in a row - site, endpoint and the number of PII rules **It never contains the token**, only `hasToken`, so it's safe to log or paste. `inspect()` returns the same data as a typed `InspectSnapshot`. ## Canonical app earning events `type: "track"` with these names (props open; matched by the Odoo rules engine): | `name` | example props | | --- | --- | | `poll_vote` | `{ pollId, choice }` | | `motm_submit` | `{ matchId, playerId }` | | `player_rating` | `{ matchId, playerId, rating }` | | `minigame_play` | `{ game, result, score }` | | `streak_tick` | `{ streakType, currentDays }` | See [`conformance/fixtures/`](conformance/fixtures/) for full golden envelopes.