17 KiB
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.
Install — 0.6.0 beta (public, no auth)
Distributed publicly from 3HL-SCORE/public-sdks-score (#284) — no Gitea token / login required. The iOS XCFramework is a public release asset (mobile-v0.6.0) on that repo; the Android artifacts live in the org's Gitea Maven package registry (anonymous read).
Android — Gradle (public Maven registry)
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.6.0")
}
iOS — CocoaPods (public release asset)
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:
// your app's Package.swift
dependencies: [
.package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.6.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.6.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.6.0 release on
public-sdks-score.
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)
import ScoreTracker // the Swift facade (wraps the ScoreTrackerKit framework)
let score = ScoreClient(
publishableKey: "pk_live_…",
siteId: "urc-ios",
endpoint: "https://<middleware-host>",
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.nameandsdk.versionare baked into the build:…-iosor…-androidfrom the platform, and the version from the release (#486). There's nothing to pass. Read them withScoreClient.sdkName/ScoreClient.sdkVersion(Swift) orSdkInfo.CURRENT(Kotlin) when a support conversation asks "which version are you on?". The oldsdkVersion:argument andTrackerConfig(…, 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):
import com.threehalveslabs.scoretracker.*
val score = newScoreTracker(
context = applicationContext,
config = TrackerConfig(
publishableKey = "pk_live_…",
siteId = "urc-android",
endpoint = "https://<middleware-host>",
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 fromsdk/schemas/events/*.schema.jsonvianpm run gen:events. Don't editEvents.ktby 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.
// iOS — in your CMP's "consent changed" callback:
score.setConsent(analytics: groups.analytics, personalization: groups.personalization, advertising: groups.advertising)
// 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.
Regional privacy policy (GDPR, Quebec, LGPD, CPRA)
One privacy option sets the tracker's posture for the fan's region (#489),
with the same policies and values as the web tracker's privacy option. For URC,
which is UK/EU-facing, use GDPR:
// iOS
let score = ScoreClient(publishableKey: pk, siteId: "urc-ios", endpoint: endpoint,
privacy: PrivacyOptions(mode: .gdpr, region: nil, regionProvider: nil, overrides: nil))
// Android
val score = newScoreTracker(context, TrackerConfig(pk, "urc-android", endpoint,
privacy = PrivacyOptions(PrivacyMode.GDPR)))
| Policy | Starting consent | Queue kept at most | Drops locale / device |
|---|---|---|---|
GDPR (EU, EEA, UK, CH) |
nothing granted (opt-in) | 24 h | yes |
QUEBEC (Law 25) |
nothing granted | 24 h | yes |
LGPD (Brazil) |
nothing granted | 48 h | yes |
CPRA (US) |
analytics + personalization; not advertising | 168 h (the tracker's 72 h still applies) | no |
OFF |
no policy: as without the option | 72 h | no |
What the policy changes:
- Starting consent: applies only when you pass no
initialConsent, and only until your firstsetConsent. A value you pass from your CMP always wins. Under GDPR nothing is sent untilsetConsent, as before. - Retention: queued events older than the cap are dropped instead of sent, including events saved before a cold start.
- Fingerprint-adjacent signals:
localeand the device model leave the envelope.osandappVersionstay. - Declared only: "no session replay" and "no geo enrichment" are reported by
inspect(), but the SDK does neither anyway.
AUTO picks the policy from region (ISO country, optionally with a subdivision: GB, US-CA, CA-QC) or a regionProvider; with neither, it uses GDPR, the strictest. The device locale is never used: it's a language setting, not a location. Resolve the region before creating the tracker; the policy is fixed for its lifetime. To tune a setting without forking the table, pass overrides, e.g. PrivacyOverrides(retentionMaxHours = 12). inspectJson() shows the active policy under privacy.
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"<redacted>".TOKENIZE: replace it witht: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.
// 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"}
// 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.
// 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
// 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:
score.identify(userId: "auth0|abc123", traits: ["team": "urc-leinster"], replayAnonymousEvents: true)
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
tokenRefreshTimeoutMsparameter onScoreTracker. - 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:
// iOS — in application(_:didFinishLaunchingWithOptions:) score.bindToAppLifecycle()// 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/nullto run your own auto-flush) andbackgroundBudgetSeconds/backgroundBudgetMs. Undo withunbindAppLifecycle()(iOS) orclose()on the returned binding (Android). Android declaresACCESS_NETWORK_STATEin 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:
score.track(Events.shared.PollVote, props: […], delivery: .critical) // iOSscore.track(Events.PollVote, mapOf(…), delivery = DeliveryMode.CRITICAL) // AndroidCritical 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_failedevent naming it (originalEventId,originalEventName,reason:age_evictedorqueue_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()andonNetworkAvailable()yourself, plusstartAutoFlush(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():
print(score.inspectJson()) // iOS
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, thelink-anonymousstate 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/ for full golden envelopes.