Files
public-sdks-score/INTEGRATION.md
2026-10-07 20:38:39 -04:00

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.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):

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 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.


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 first setConsent. A value you pass from your CMP always wins. Under GDPR nothing is sent until setConsent, as before.
  • Retention: queued events older than the cap are dropped instead of sent, including events saved before a cold start.
  • Fingerprint-adjacent signals: locale and the device model leave the envelope. os and appVersion stay.
  • 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 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.

// 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 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:

    // 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/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:

    score.track(Events.shared.PollVote, props: […], delivery: .critical)        // iOS
    
    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():

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, 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/ for full golden envelopes.