From ca717e4656eed3bc603e4fbb15f3b045325ba4be Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 6 Aug 2026 01:27:14 -0400 Subject: [PATCH] Publish Score iOS SDK 0.1.0 (SPM package + podspec + docs) Co-Authored-By: Claude Opus 4.8 (1M context) --- INTEGRATION.md | 193 +++++++++++++++++++++++++ LICENSE | 5 + Package.swift | 26 ++++ README.md | 33 +++++ ScoreTracker.podspec | 21 +++ Sources/ScoreTracker/ScoreClient.swift | 114 +++++++++++++++ metadata.json | 1 + 7 files changed, 393 insertions(+) create mode 100644 INTEGRATION.md create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 ScoreTracker.podspec create mode 100644 Sources/ScoreTracker/ScoreClient.swift create mode 100644 metadata.json diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..362b104 --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,193 @@ +# 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.1.0 beta (hosted on Gitea) + +Everything is hosted on the org's Gitea — no external registry. The Android +artifacts live in **Gitea's Maven package registry**; the iOS XCFramework is a +**Gitea release asset** (`mobile-v0.1.0`). + +### Android — Gradle (Gitea Maven registry) +```kotlin +repositories { + maven { + url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") + // If the package registry isn't public, add a Gitea token: + // credentials(HttpHeaderCredentials::class) { name = "Authorization"; value = "token ${'$'}giteaToken" } + // authentication { create("header") } + } +} +dependencies { + implementation("com.threehalveslabs.scoretracker:shared-android:0.1.0") +} +``` + +### iOS — CocoaPods (release asset) +```ruby +pod 'ScoreTracker', + :podspec => 'https://git.3halves-labs.com/3HL-SCORE/score-anything-loyalty/raw/branch/main/mobile/ios/ScoreTracker/ScoreTracker.podspec' +``` + +### iOS — Swift Package Manager (Gitea Swift registry) +Published to Gitea's **Swift package registry** as `threehalveslabs.ScoreTracker`. +Configure the registry once (per machine / CI), then add it as a normal +dependency by **id** — no git URL needed: +```bash +swift package-registry set https://git.3halves-labs.com/api/packages/3HL-SCORE/swift +# private registry → log in with a Gitea token: +swift package-registry login https://git.3halves-labs.com/api/packages/3HL-SCORE/swift --token +``` +```swift +// your app's Package.swift +dependencies: [ .package(id: "threehalveslabs.ScoreTracker", from: "0.1.0") ] +// then: .product(name: "ScoreTracker", package: "ScoreTracker") +``` +The package's binary `ScoreTrackerKit` target pulls the XCFramework from the +`mobile-v0.1.0` release asset (SPM verifies the checksum). For a private release +asset, SPM reads credentials from `~/.netrc`. + +> Note: SPM-by-git-URL isn't available (the package lives in a monorepo subdir, +> which SPM can't resolve) — the Swift registry above is the supported path. + +### iOS — direct XCFramework +Download `ScoreTrackerKit.xcframework.zip` from the **mobile-v0.1.0** release 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.1.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.1.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. + +## 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ab0fb63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 3 Halves Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction... diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e36c08c --- /dev/null +++ b/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Public SPM package for the Score iOS SDK. `ScoreTracker` (Swift facade, +// ScoreClient) wraps the binary `ScoreTrackerKit` XCFramework, hosted as a +// public release asset on this repo. Consume via git URL: +// .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.1.0") +let package = Package( + name: "ScoreTracker", + platforms: [.iOS(.v14)], + products: [ + .library(name: "ScoreTracker", targets: ["ScoreTracker"]), + ], + targets: [ + .binaryTarget( + name: "ScoreTrackerKit", + url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.1.0/ScoreTrackerKit.xcframework.zip", + checksum: "ae4b4c9b2a7e386d58c1c1144d15c3b4e5f457099ac2677e7d22894a7e4ab3e5" + ), + .target( + name: "ScoreTracker", + dependencies: ["ScoreTrackerKit"], + path: "Sources/ScoreTracker" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bef354 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Score mobile SDKs (public distribution) + +Public, auth-free distribution of the **Score mobile tracker** SDKs for iOS and Android — the mobile counterpart of the web `@3halves-labs/score-tracker`. + +## Install — 0.1.0 (no token / login required) + +### iOS — Swift Package Manager (git URL) +```swift +// Package.swift +dependencies: [ .package(url: "https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git", from: "0.1.0") ] +// target: .product(name: "ScoreTracker", package: "public-sdks-score") +``` + +### iOS — CocoaPods +```ruby +pod 'ScoreTracker', + :podspec => 'https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/raw/branch/main/ScoreTracker.podspec' +``` + +### iOS — direct XCFramework +Download `ScoreTrackerKit.xcframework.zip` from the [`mobile-v0.1.0` release](https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases) and drag it into Xcode. + +### Android — Gradle +```kotlin +repositories { maven { url = uri("https://git.3halves-labs.com/api/packages/3HL-SCORE/maven") } } // public +dependencies { implementation("com.threehalveslabs.scoretracker:shared-android:0.1.0") } +``` + +## Docs + +Full integration guide (init, consent, Auth0, offline/flush, event catalogue): **[`INTEGRATION.md`](INTEGRATION.md)**. + +SDK source lives in 3 Halves Labs' platform repo; this repo hosts the consumable artifacts. Versions tagged `0.1.0` (SPM) / `mobile-v0.1.0` (release asset). diff --git a/ScoreTracker.podspec b/ScoreTracker.podspec new file mode 100644 index 0000000..8e32d05 --- /dev/null +++ b/ScoreTracker.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'ScoreTracker' + s.version = '0.1.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 + envelope as the Score web tracker (@3halves-labs/score-tracker), with a + durable offline queue, consent gating, and Auth0 JWT support. + DESC + s.homepage = 'https://git.3halves-labs.com/3HL-SCORE/public-sdks-score' + s.license = { :type => 'MIT' } + s.author = '3 Halves Labs' + s.platform = :ios, '14.0' + s.swift_version = '5.9' + + # Release attaches the zipped XCFramework; point :http at that asset. + s.source = { :http => 'https://git.3halves-labs.com/3HL-SCORE/public-sdks-score/releases/download/mobile-v0.1.0/ScoreTrackerKit.xcframework.zip' } + + s.vendored_frameworks = 'ScoreTrackerKit.xcframework' + s.source_files = 'Sources/ScoreTracker/**/*.swift' +end diff --git a/Sources/ScoreTracker/ScoreClient.swift b/Sources/ScoreTracker/ScoreClient.swift new file mode 100644 index 0000000..2ea6b5c --- /dev/null +++ b/Sources/ScoreTracker/ScoreClient.swift @@ -0,0 +1,114 @@ +import Foundation +import ScoreTrackerKit + +/// Idiomatic Swift facade over the Score Tracker KMP framework (#177). Wraps the +/// generated `ScoreTrackerKit` Obj-C API with Swift types, sensible defaults, and +/// `async`/`await`. Mirrors the web tracker's surface. The durable offline queue +/// and persistent `anonymousId` are handled natively (NSUserDefaults) — see #176. +public final class ScoreClient { + + private let tracker: ScoreTracker + private var autoFlushTask: Task? + + /// - Parameters: + /// - endpoint: middleware base URL; events POST to `/api/tracking/website`. + /// - initialConsent: defaults to "none" — nothing fires until `setConsent(...)`. + public init( + publishableKey: String, + siteId: String, + endpoint: String, + sdkVersion: String, + appVersion: String? = nil, + os: String? = nil, + device: String? = nil, + locale: String? = nil, + initialConsent: ConsentState = ConsentState.companion.NONE + ) { + 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) + ) + tracker = IosPlatformKt.doNewScoreTracker(config: config, initialConsent: initialConsent) + } + + // MARK: - Consent (wire to your CMP / OneTrust) + + public func setConsent(analytics: Bool, personalization: Bool, advertising: Bool) { + tracker.setConsent(state: ConsentState(analytics: analytics, personalization: personalization, advertising: advertising)) + } + public func grantAllConsent() { tracker.setConsent(state: ConsentState.companion.ALL) } + public func revokeConsent() { tracker.setConsent(state: ConsentState.companion.NONE) } + + // MARK: - Identity / Auth0 + + public func identify(userId: String, token: String? = nil) { + tracker.identify(userId: userId, token: token) + } + 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) + } + + public var anonymousId: String { tracker.anonymousId } + public var pending: Int { Int(tracker.pending()) } + + // MARK: - Tracking + + /// Track an app event. Returns false if dropped by the consent gate. + @discardableResult + public func track(_ name: String, props: [String: Any] = [:]) -> Bool { + tracker.track(name: name, props: props, type: .track, requires: [], delivery: .bestEffort) + } + + /// Track an event gated on advertising consent (dropped until granted). + @discardableResult + public func trackAdvertising(_ name: String, props: [String: Any] = [:]) -> Bool { + tracker.track(name: name, props: props, type: .track, requires: [ConsentCategory.advertising], delivery: .bestEffort) + } + + /// Record an app-screen view. + @discardableResult + public func screen(_ name: String, props: [String: Any] = [:]) -> Bool { + tracker.screen(name: name, props: props) + } + + // MARK: - Flush + + /// Send the queued batch. Returns true on a 2xx ack. + @discardableResult + public func flush() async throws -> Bool { + try await tracker.flush().boolValue + } + + /// Call when the app returns to the foreground. + public func onForeground() async throws { _ = try await tracker.onForeground() } + /// Call when network connectivity is regained. + public func onNetworkAvailable() async throws { _ = try await tracker.onNetworkAvailable() } + + public func shutdown() async throws { + stopAutoFlush() + try await tracker.shutdown() + } + + // MARK: - Auto-flush (Swift-native; avoids passing a Kotlin CoroutineScope) + + public func startAutoFlush(everySeconds: Double = 30) { + stopAutoFlush() + autoFlushTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(everySeconds * 1_000_000_000)) + _ = try? await self?.flush() + } + } + } + + public func stopAutoFlush() { + autoFlushTask?.cancel() + autoFlushTask = nil + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..87f464c --- /dev/null +++ b/metadata.json @@ -0,0 +1 @@ +{"author": {"givenName": "3 Halves", "familyName": "Labs"}, "description": "Score Tracker - mobile (iOS) tracking SDK for the 3Halves loyalty platform.", "repositoryURLs": ["https://git.3halves-labs.com/3HL-SCORE/public-sdks-score.git"]}