# 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.2.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). ### 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.2.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.2.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 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`. --- ## iOS (Swift) ```swift import ScoreTracker // the Swift facade (wraps the ScoreTrackerKit framework) 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, locale: Locale.current.identifier ) // 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"]) score.screen("match_centre") // Flush is async; the SDK also auto-flushes (see Offline & flushing) Task { try? await score.flush() } ``` 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://", 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()), ), ) 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 (e.g. `trackAdvertising(...)` on iOS, or `requires = listOf(ConsentCategory.ADVERTISING)` on Android) are dropped until that category is granted. ## 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", sdkVersion: "0.2.0", pii: PiiOptions.Classify(rules: [ PiiRule(field: "email", action: .hash), 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 (#109): supply a token provider — it's consulted before each flush, so flushes carry a fresh access token. Failure → anonymous, never halts. ```swift score.setTokenProvider { Auth0Session.shared.accessToken } // called before each flush score.identify(userId: "auth0|abc123") // optional: set userId now ``` ```kotlin score.setTokenProvider { auth0.credentials?.accessToken } score.identify("auth0|abc123") ``` ## 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). ## 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.