@cross-deck/android — Android SDK reference
@cross-deck/android is one native Kotlin SDK that handles all three Crossdeck pillars on Android: verified subscriptions and entitlements, behavioural analytics, and error capture. Start one Crossdeck client in your Application.onCreate and every screen tap, paywall gate, and uncaught Throwable flows through the same durable queue, the same idempotent network path, and the same dashboard your web users do. One runtime dependency — kotlinx-coroutines-android — built on HttpURLConnection / org.json / SharedPreferences only.
TL;DR
- One library, three pillars. One Maven coordinate —
com.crossdeck:crossdeck:1.+— covers analytics, subscription/entitlement gating, and error capture. No separate Firebase Analytics + Sentry + RevenueCat install. - Bank-grade durability.
track(...)returns sync; the event sits in a mutex-guarded buffer, persists toSharedPreferenceson every enqueue, and ships in batches with a stable per-batchIdempotency-Keyreused across retries. A force-quit mid-flush leaves the pending batch on disk; the next launch rehydrates and re-sends with the same key. - Microsecond entitlement gates.
cd.isEntitled("pro")is a synchronous in-memory cache read scoped to the currently-identifieddeveloperUserId— safe to call from a click handler. The cache never serves another customer's entitlements; switching users viaidentify(...)unconditionally clears it. - Built for Android lifecycle.
ActivityLifecycleCallbacksauto-trigger a queue persist + flush ononActivityPausedso the few seconds before suspension always include a drain. UncaughtThrowable+ manualcaptureError(...)route through one path with breadcrumbs attached. - Polite global citizen. The uncaught-exception handler chains into whatever was registered before us — Crashlytics, Sentry, Bugsnag — so both reporters keep working when you turn on Crossdeck's error capture.
- PII scrubbing on by default. Emails are replaced with
<email>, payment-card numbers with<card>, recursively across nestedMaps andLists — same vocabulary as the Web, Node, React Native, and Swift SDKs. The persisted queue never holds raw PII, so a crash dump of disk state is safe to share with support. - One runtime dependency. Implemented against
HttpURLConnection,org.json, andSharedPreferencesonly.kotlinx-coroutines-androidis the only thing on the classpath we drag in — every modern Android library already uses it. No OkHttp, no Moshi, no kotlinx-serialization pin inherited by your build.
Install
One Maven coordinate covers every Android device we support. Tested on Android 5.0+ (API 21+), which covers ~99% of active devices.
| Surface | Minimum | Notes |
|---|---|---|
| Android | API 21 (Android 5.0) | The SDK uses no API-version-conditional code; one binary serves every supported device. |
| Java toolchain | Java 17 | Matches the AGP 8.x default and modern Android Studio defaults. |
| Kotlin | 1.9+ | Built with strict explicit-api mode — every public class declares visibility. |
Gradle (Kotlin DSL)
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
// app/build.gradle.kts
dependencies {
implementation("com.crossdeck:crossdeck:1.+")
}
Gradle (Groovy DSL)
// app/build.gradle
dependencies {
implementation 'com.crossdeck:crossdeck:1.+'
}
Coordinate: com.crossdeck:crossdeck:1.+. The artifact ships as a single AAR — import com.crossdeck.Crossdeck brings the entire public API into scope.
Quickstart
Start the client once in Application.onCreate, then call track / identify / isEntitled / captureError from anywhere in your app. The instance is safe to share across threads. The shipping pattern wraps start in try/catch and stores the client as nullable — a typo'd key should log + degrade telemetry, never crash the host app.
import android.app.Application
import android.util.Log
import com.crossdeck.Crossdeck
import com.crossdeck.CrossdeckError
import com.crossdeck.CrossdeckOptions
import com.crossdeck.Environment
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Build-configuration switch — Debug builds never route into a
// live dashboard, Release builds never embed a test key.
val publicKey = if (BuildConfig.DEBUG) "cd_pub_test_..." else "cd_pub_live_..."
val environment = if (BuildConfig.DEBUG) Environment.SANDBOX else Environment.PRODUCTION
crossdeck = try {
Crossdeck.start(
this,
CrossdeckOptions(
appId = "app_android_acme01",
publicKey = publicKey,
environment = environment,
),
)
} catch (e: CrossdeckError) {
// invalid_secret_key / env_mismatch / missing_app_id —
// recoverable misconfig. Log + carry on without telemetry.
Log.e("Crossdeck", "start failed (${e.code}): ${e.message}", e)
null
} catch (e: Throwable) {
Log.e("Crossdeck", "start failed", e)
null
}
}
companion object {
// Process-singleton accessor. Reach from anywhere as
// \`MyApplication.crossdeck?.…\` — no
// \`(applicationContext as MyApplication).cd\` boilerplate.
var crossdeck: Crossdeck? = null
private set
}
}
lateinit? If Crossdeck.start throws (typo'd key, env mismatch, etc.) lateinit var cd: Crossdeck stays uninitialised. The FIRST access to cd from anywhere in your app then crashes with UninitializedPropertyAccessException — long after onCreate, in code that has nothing to do with the original misconfig. Nullable + safe-call (cd?.) means the host app keeps working with telemetry silently disabled. Same posture the Web/Swift SDKs ship in their respective quickstarts.
Then anywhere in your app:
// Track an event
MyApplication.crossdeck?.track("paywall_seen", mapOf("variant" to "annual"))
// Identify a customer
MyApplication.crossdeck?.identify(
userId = "user_847",
email = "[email protected]",
traits = mapOf("plan" to "pro"),
)
// Synchronous paywall gate (safe from the UI thread)
if (MyApplication.crossdeck?.isEntitled("pro_features") == true) {
showProUI()
} else {
showPaywall()
}
// Manual error capture
try {
riskyOperation()
} catch (e: Throwable) {
MyApplication.crossdeck?.captureError(e)
}
// Warm the entitlement cache + drain the queue from a coroutine.
// getEntitlements() and flush() are suspend — call from
// lifecycleScope.launch, viewModelScope.launch, or any scope.
lifecycleScope.launch {
MyApplication.crossdeck?.getEntitlements()
MyApplication.crossdeck?.flush()
}
Crossdeck is both the namespace and the instance type. The factory call val cd: Crossdeck = Crossdeck.start(...) looks duplicate-typed at first glance. It isn't. Crossdeck the type holds your client instance; Crossdeck.start(...) the static factory returns one. Mirrors AndroidX's own WorkManager / WorkManager.getInstance(...) shape — single short symbol for both surfaces.
Initialise the SDK
Call Crossdeck.start(context, options) exactly once per process. The factory validates configuration, allocates sub-modules, rehydrates the queue from disk, installs the uncaught-exception handler (if enabled), registers ActivityLifecycleCallbacks, and posts the boot heartbeat — all synchronous + non-blocking (network calls run in the background).
Hold the returned Crossdeck instance via the companion object singleton shown above (or your DI container). Constructing a second one will install a second queue + a second pair of lifecycle observers; the global error-capture singleton routes through the most-recent install.
How keys and environments fit together
The publishable-key prefix is authoritative. The environment argument is a redundancy check, not the routing switch:
cd_pub_live_…→ routes toEnvironment.PRODUCTION.environment = Environment.SANDBOXwith this prefix throwsenv_mismatch.cd_pub_test_…→ routes toEnvironment.SANDBOX.environment = Environment.PRODUCTIONwith this prefix throwsenv_mismatch.publicKeynot starting withcd_pub_→ throwsinvalid_secret_key(never embed acd_sk_…on the device).appIdempty → throwsmissing_app_id.
Drive BOTH from BuildConfig (or per-flavour buildConfigField entries in build.gradle.kts) so a Debug build can never embed a live key and Release can never embed a test key. The mismatch check is the safety net, not the source of truth.
Reaching the client outside Application
The companion object in the Quickstart gives you a process-singleton accessor — call MyApplication.crossdeck?.… from any Activity, Fragment, ViewModel, Worker, or service without the (applicationContext as MyApplication).cd boilerplate at every call site. If you use Hilt / Dagger / Koin, prefer providing the same instance into your DI graph and injecting it where you need it:
import com.example.MyApplication
// Activity / Fragment / ViewModel:
class CheckoutViewModel : ViewModel() {
fun onPurchaseTapped() {
MyApplication.crossdeck?.track(
"checkout_started",
mapOf("plan" to "annual"),
)
}
}
// Hilt alternative — provide once, inject everywhere:
@Module
@InstallIn(SingletonComponent::class)
object CrossdeckModule {
@Provides @Singleton
fun provideCrossdeck(): Crossdeck? = MyApplication.crossdeck
}
AndroidManifest
Register your custom Application in AndroidManifest.xml:
<application
android:name=".MyApplication"
android:label="@string/app_name">
<!-- … -->
</application>
No additional permissions required. INTERNET is declared in the SDK's own manifest and merged automatically by the Gradle build plugin — no entry needed in your app's manifest. The SDK does not declare or request CAMERA, READ_CONTACTS, ACCESS_FINE_LOCATION, READ_EXTERNAL_STORAGE, or any other sensitive permission.
Identity & users
The SDK owns three identity primitives, all persisted in SharedPreferences under the namespace crossdeck.kv:
| Field | Set by | Wire shape | Lifetime |
|---|---|---|---|
anonymousId | SDK on first launch | anon_<32-char hex> | Persists until reset(). Regenerated on reset so the next anonymous session is fully unlinked from the prior identified user. |
developerUserId | identify(userId) | Your auth provider's stable id (e.g. Firebase Auth uid, Auth0 sub) | Persists until identify with a different id, or reset(). |
crossdeckCustomerId | Server response on /identity/alias | cdcust_<32-char hex> | Canonical Crossdeck handle. Persists across launches. |
identify(userId, email?, traits?)
identify is synchronous on its mutating effects (sets developerUserId, clears the entitlement cache) and fires the /identity/alias network call in the background. A subsequent track on the same thread always observes the new identity — the classic identify-then-track race is impossible by construction.
cd.identify(
userId = "user_847",
email = "[email protected]", // first-class top-level — backend uses for cross-device merge
traits = mapOf(
"plan" to "pro",
"signed_up_at" to "2026-05-25T09:13:21Z",
),
)
identify wipes the entitlement cache, even if the userId matches the prior one. Bank-grade contract: a freshly-identified user must never observe the prior user's entitlements via any sync read path. The redundant rebuild is cheaper than a leak.
identify(userId) twice with the same id is well-defined and won't break anything, but it isn't a no-op: each call clears the entitlement cache (above) and re-fires /identity/alias. Firebase's addAuthStateListener in particular fires immediately on attach AND on every state change, so without a guard a single app open can re-identify multiple times. Most apps gate it:
if (user != null && user.uid != lastIdentifiedUserId) {
MyApplication.crossdeck?.identify(user.uid, email = user.email)
lastIdentifiedUserId = user.uid
}
identifyAndWait(userId, email?, traits?)
Use the suspend variant when you need the canonical crossdeckCustomerId before continuing (e.g. server-side cross-reference at sign-in):
The sync identify fires the alias POST in the background. Use identifyAndWait only when the next step in your sign-in flow needs the cdcust_… — e.g. cross-referencing it on your backend, showing a Crossdeck-side support reference, or joining your analytics warehouse against it at sign-in time. For pure telemetry purposes, the sync identify is the right call (your next track already carries the new developerUserId; the cdcust_ lands a few hundred ms later without anyone waiting).
lifecycleScope.launch {
val result = MyApplication.crossdeck?.identifyAndWait(
userId = "user_847",
email = "[email protected]",
) ?: return@launch
sendCustomerIdToOurBackend(result.crossdeckCustomerId)
}
Wiring popular auth providers
Call identify from your auth state listener so the SDK sees the user as soon as your app does.
Firebase Auth
private var lastIdentifiedUserId: String? = null
FirebaseAuth.getInstance().addAuthStateListener { auth ->
val user = auth.currentUser
if (user != null && user.uid != lastIdentifiedUserId) {
MyApplication.crossdeck?.identify(
userId = user.uid,
email = user.email,
traits = mapOf(
"display_name" to (user.displayName ?: ""),
"verified" to user.isEmailVerified.toString(),
),
)
lastIdentifiedUserId = user.uid
} else if (user == null && lastIdentifiedUserId != null) {
MyApplication.crossdeck?.reset()
lastIdentifiedUserId = null
}
}
Auth0 Android
// After exchanging the code for a credentials object
client.userInfo(credentials.accessToken).start(object : Callback<UserProfile, AuthenticationException> {
override fun onSuccess(profile: UserProfile) {
val userId = profile.getId() ?: return
if (userId != lastIdentifiedUserId) {
MyApplication.crossdeck?.identify(
userId = userId,
email = profile.email,
traits = mapOf("name" to (profile.name ?: "")),
)
lastIdentifiedUserId = userId
}
}
override fun onFailure(error: AuthenticationException) { /* … */ }
})
Sign-out / forget
reset() clears all per-customer state locally — identity, entitlements, super-properties, breadcrumbs — and regenerates the anonymousId. Critical for shared-device privacy. Sign-out without reset leaves the prior user's developerUserId + entitlement cache on disk, and the next sign-in could coalesce two different humans into the same Crossdeck record.
forget() additionally POSTs /identity/forget for GDPR right-to-be-forgotten. Local wipe always runs, even when the server returns 401 (the publishable-key identified-erasure flow requires idToken which ships in a future version; your backend can complete the server-side erasure with a secret key).
// Sync sign-out — local state only
MyApplication.crossdeck?.reset()
lastIdentifiedUserId = null
// GDPR forget — local wipe + server POST
lifecycleScope.launch {
MyApplication.crossdeck?.forget()
}
lastIdentifiedUserId = null
Events & analytics
track enqueues a structured event with the user's identity attached. It throws CrossdeckError(code: "missing_event_name") if name is empty or null — that is the only case where track throws. Bad property values are sanitised in-place: Double.NaN / Infinity → null; strings longer than 1 024 chars → truncated; cyclic graphs → "[circular]"; nesting beyond 32 levels → "[depth-exceeded]"; lambdas and unknown types → dropped. All coercions emit a debug warning but never throw — so track is always safe to call from any thread or context without wrapping property values in try/catch.
cd.track("checkout_started", mapOf(
"plan" to "annual",
"amount_cents" to 2400,
"currency" to "USD",
))
Super-properties
Super-properties merge into every event automatically. Set them once at sign-in or on a feature-flag change:
cd.registerSuperProperty("build_channel", BuildConfig.FLAVOR)
cd.registerSuperPropertyOnce("first_app_open_at", Instant.now().toString())
Durability guarantees
- Persisted on every enqueue. The buffer writes to
SharedPreferencesafter eachtrackcall. A process kill between calls cannot lose the event. - Idempotency-Key reused across retries. Each batch gets a stable
batch_…key. Server-side dedup collapses retries to a single insert. - 4xx hard stop. Permanent failures (auth, payload broken) route through the
onPermanentFailurecallback and drop. 408 (Request Timeout) and 429 (Rate Limit) stay retryable. Retry-Afterhonoured. Server is authoritative on its own rate budget. Clamped at 24h as a sanity ceiling.- Buffer overflow drops OLDEST. Cap of 1000 events per buffer; once exceeded, oldest events are evicted first so the most-recent diagnostic signal survives.
onPermanentFailure
CrossdeckOptions(
appId = "app_android_acme01",
publicKey = "cd_pub_live_...",
environment = Environment.PRODUCTION,
onPermanentFailure = { events, error ->
Log.e("Crossdeck", "Dropped ${events.size} events: ${error.code}")
},
)
Screen views
Screen views are how the dashboard's Pages tab tells you which screens your users actually use — the Android equivalent of "/dashboard · 63 views" on a web app's Pages tab. The SDK fires page.viewed on every screen-enter; the dashboard groups by the screen name so you can see in one glance which features get traffic and which sit unused.
Activities + fragments auto-track. Jetpack Compose needs one line per screen.
Activities and fragment-based screens fire page.viewed automatically via an Activity.onResume swizzle. Class name becomes the screen property; nothing to wire.
Pure-Compose hosts are the tricky case. Jetpack Compose hides NavHost destinations behind pure functions, so the runtime never sees a class name for each screen — every destination renders inside the same root ComponentActivity / MainActivity, which the swizzle's denylist skips (otherwise every navigation fires the host's name). The result: a pure-Compose app emits zero page.viewed events without help.
Industry-standard fix — and the same shape Mixpanel, Amplitude, and PostHog ship: one CrossdeckScreen("Name") { … } wrapper (or Modifier.crossdeckScreen(cd, "Name")) per user-visible destination. It fires page.viewed with { screen, title } on first composition and on every Lifecycle.Event.ON_RESUME; the Pages backend groups on screen when no url/path is present, so your Android app populates Pages identically to a web app.
The helpers ship under com.crossdeck.compose. Compose-runtime is declared compileOnly on the main module — non-Compose hosts pay zero transitive dependency cost. Hosts that already depend on Compose import the helpers and they resolve.
Where to tag — five patterns cover every Compose app
Tag the destination composable, not the container that navigates to it. Tagging a container fires on every recomposition or back-nav.
1. NavHost composable destinations:
import com.crossdeck.compose.CrossdeckScreen
NavHost(navController, startDestination = "home") {
composable("home") {
CrossdeckScreen(MyApplication.crossdeck, name = "Home") { HomeScreen() }
}
composable("create_image") {
CrossdeckScreen(MyApplication.crossdeck, name = "Create Image") {
CreateImageScreen()
}
}
}
2. NavigationBar tabs — tag each tab's destination composable, not the NavigationBar itself:
CrossdeckScreen(MyApplication.crossdeck, name = "Library") { LibraryScreen() }
3. ModalBottomSheet content:
if (showPaywall) {
ModalBottomSheet(onDismissRequest = { showPaywall = false }) {
CrossdeckScreen(MyApplication.crossdeck, name = "Paywall") { PaywallContent() }
}
}
4. Dialog / AlertDialog — tag if the dialog is a distinct screen, not a transient prompt:
Dialog(onDismissRequest = { showEditor = false }) {
CrossdeckScreen(MyApplication.crossdeck, name = "Asset Editor") {
AssetEditorDialog()
}
}
5. Modifier form — when the screen already plumbs a Modifier chain:
import com.crossdeck.compose.crossdeckScreen
@Composable
fun CreateImageScreen(modifier: Modifier = Modifier) {
Surface(
modifier = modifier.crossdeckScreen(MyApplication.crossdeck, "Create Image"),
) {
// your screen content
}
}
Don't tag these
Tagging the wrong layer either double-fires (visible to you as inflated views on Pages) or fires the wrong name:
Scaffold { … }.crossdeckScreen(…)— layout wrapper; fires before any destination resolves.NavHost(…)wrapped inCrossdeckScreen— only a problem onceNavHostactually has destinations. A single-screen "chrome" NavHost with one composable and no further routes fires the screen-view exactly once and is fine to tag. The risk is silent regression: adding more destinations later quietly introduces double-fires because every navigation replays the screen-view on the host root. Future-proof rule: if there's any chance the NavHost will gain destinations, tag the destination composable instead. If you accept the risk for a navigation-less chrome NavHost, leave it tagged.LazyColumn { items(…) { row -> CrossdeckScreen(…) { row } } }— fires per-row.- Layout containers (
Column,Row,Box,Surfacewhen it's not the screen root) — tag the screen-level composable that's inside.
Naming conventions
The screen name becomes the grouping key on the dashboard. Two rules:
- Human-readable and stable. "Create Image" (good); "CreateImageScreenV3" (leaks function names; breaks on rename); "create_image_screen" (works but reads worse on the dashboard).
- No high-cardinality data, no PII.
"Create – $promptId"generates a new Pages row per prompt and aggregates to nothing useful;"Profile – $userEmail"publishes a user's email to the grouping axis. If you want per-prompt or per-user breakdowns, pass them as event properties oncd.trackdirectly — they ride alongside the screen name without polluting the grouping key.
Opt-out
The helpers are thin wrappers around cd.track("page.viewed", …) — when autoTrack.screenViews = false is set in CrossdeckOptions, the underlying call still fires (your code asked for it explicitly; the SDK respects the contract). For strict-consent flows that want zero page.viewed events, remove the wrapper from the composition or guard it on a consent flag your app already owns.
Entitlements
Calling syncPurchases(AuditRail.GOOGLE, purchaseToken = ...) today returns CrossdeckError(code: "google_not_supported") — the SDK wire-shape is stable so your call-site won't need changes when v1.1 ships, but live Play receipt verification is not yet active. Apple StoreKit 2 receipts verify fully in v1.0.1. Android apps shipping to Google Play today should gate features on server-side confirmation of the purchase until v1.1 lands.
Entitlements answer one question fast: is this customer allowed to use feature X right now? The SDK keeps a local cache, scoped to the currently-identified developerUserId, and exposes a synchronous isEntitled read that never blocks on network.
// Synchronous — safe to call from a click handler or any thread
if (cd.isEntitled("pro_features")) {
showProUI()
} else {
showPaywall()
}
// Full snapshot for the currently identified user
val entitlements = cd.entitlementsForCurrentCustomer() ?: emptyList()
// Just the active keys
val keys = cd.activeEntitlementKeys() ?: emptyList()
Refresh from server
lifecycleScope.launch {
val ents = cd.getEntitlements()
Log.d("Crossdeck", "User has ${ents.size} active entitlements")
}
On 5xx or network failure, getEntitlements preserves the existing cache (markRefreshFailed records the failure timestamp so UI can show a "checking…" badge) and throws. Bank-grade rule: a Crossdeck outage MUST NOT fail a paying customer down to free.
onEntitlementsChange
Subscribe to cache mutations so your UI updates the moment an entitlement is granted or revoked. The handler does not fire on subscribe — pair it with a sync read at the call site.
val unsubscribe = cd.onEntitlementsChange { snapshot ->
runOnUiThread {
refreshPaywallUI(snapshot?.entitlements.orEmpty())
}
}
// Later, in onDestroy or onCleared
unsubscribe()
syncPurchases — purchase evidence
When your StoreKit / Play Billing flow completes, forward the receipt to the backend so the verified entitlement projects into the cache immediately:
lifecycleScope.launch {
val result = cd.syncPurchases(
rail = AuditRail.APPLE,
signedTransactionInfo = jws,
signedRenewalInfo = renewalJws,
)
Log.d("Crossdeck", "${result.entitlements.size} entitlements active for ${result.crossdeckCustomerId}")
}
Google Play Billing (AuditRail.GOOGLE, purchaseToken) wire-shape is accepted on the SDK side as of v1.0.0 so your call-site is stable; the backend ships full Play verification in v1.1.
Error capture
Crossdeck's error capture replaces the typical Sentry + Crashlytics pattern with one unified path. Both uncaught throws and explicit captureError(...) calls flow through the same pipeline: stack normalisation, breadcrumb attachment, PII scrubbing, beforeSend hook, and finally the durable event queue. JVM only — native (NDK / C++) crashes via signal handlers are not captured in v1.x; pair with a native crash reporter (Firebase Crashlytics NDK, Sentry Native SDK) if you ship NDK code.
Manual capture
try {
decodeUserPayload()
} catch (e: Throwable) {
cd.captureError(e) // handled = true (default)
}
cd.captureMessage("Cart hit zero items mid-checkout", BreadcrumbLevel.WARNING)
Uncaught capture
Set captureUncaughtExceptions = true and Crossdeck installs a global Thread.UncaughtExceptionHandler that captures the throwable, builds an $error event, and forwards to the prior handler so Crashlytics / Sentry / Bugsnag keep working.
CrossdeckOptions(
appId = "app_android_acme01",
publicKey = "cd_pub_live_...",
environment = Environment.PRODUCTION,
captureUncaughtExceptions = true,
)
Breadcrumbs
Breadcrumbs are a 50-entry ring buffer attached to every captured error. The SDK adds them automatically for track + identify events; you can drop your own:
cd.addBreadcrumb(Breadcrumb(
category = BreadcrumbCategory.UI,
level = BreadcrumbLevel.INFO,
message = "User opened paywall",
data = mapOf("variant" to "annual"),
))
beforeSend hook
The last chance to filter or transform an error event before it enters the queue. Return null to drop. Replaceable at runtime — install a stricter filter once consent changes:
cd.setErrorBeforeSend { event ->
if (event.type.contains("OperationCanceledException")) null
else event
}
Tags & context
Sentry-style search facets attached to every subsequent error event:
cd.setTag("build_channel", BuildConfig.FLAVOR)
cd.setContext("device", mapOf(
"manufacturer" to Build.MANUFACTURER,
"model" to Build.MODEL,
))
Self-request skip
HTTP errors against the SDK's own ingest endpoint are skipped before any processing — without this, a failed ingest would generate an error event that itself fails, ad infinitum. The hostname pivot is the configured baseUrl, so staging and self-hosted relays work correctly.
App lifecycle
Crossdeck registers an ActivityLifecycleCallbacks on the Application instance so it can drain pending events before suspension. onActivityPaused triggers a queue.persistAll() (durability) followed by a best-effort queue.flush() (latency). Android typically gives a few seconds of background time before suspension — enough to ship a small batch and ALWAYS enough to fsync the queue.
A periodic flush ticker fires every queueConfig.flushIntervalMs (default 5s) so an idle app still drains its buffer without waiting for the batch-size threshold.
Call cd.flush() explicitly before any operation that may suspend or terminate the process unusually (e.g. user-triggered force-stop test from your settings screen):
lifecycleScope.launch { cd.flush() }
Teardown
cd.stop() is the teardown path — used in tests + multi-instance setups where you need to release resources cleanly. It blocks the caller on a final persistAll() (durability wins over latency on shutdown), then cancels the internal CoroutineScope. After stop(), mutating API calls throw not_initialized; safe to call multiple times.
Privacy & consent
PII scrubber
On by default. Recursively walks every property bag, error message, breadcrumb message, and breadcrumb data field. Pattern matches:
- Email → replaced with
<email> - Card numbers (13–19 digits, space/hyphen separators) → replaced with
<card>
The persisted queue NEVER holds raw PII, so a crash dump of disk state is safe to share with support. Tokens (<email>, <card>) match the platform-wide vocabulary across Web, Node, RN, Swift, and the backend ingest scrub.
Disable with caution — only with explicit consent:
cd.setScrubPii(false)
Consent
Two independent channels — analytics + errors. Default-grant both (matches the platform-wide contract). Wire opt-out for cookie-banner / EU AGE-gate flows:
// Initial state via options
CrossdeckOptions(
appId = "app_android_acme01",
publicKey = "cd_pub_live_...",
environment = Environment.PRODUCTION,
initialConsent = ConsentState(analytics = false, errors = true),
)
// Update at runtime
cd.setConsent(ConsentState(analytics = true, errors = true))
When analytics is denied, track + identify short-circuit and emit a sdk.consent_denied debug signal. When errors is denied, the error pipeline drops captured events at the consent gate.
Sensitive-property warnings
The debug logger fires sdk.sensitive_property_warning when a property name matches a PII / secret pattern (email, password, token, secret, card, phone, anything containing password or credit_card). The event is NOT blocked — the warning surfaces in dev so you can move the value into a traits field with the proper top-level handling.
Google Play Data Safety declaration
Every app on Google Play must complete the Data Safety form in Play Console. Below is the mapping for data collected by the Crossdeck Android SDK. Add these rows to your declaration — merge them with any data your own app code collects.
| Data type | Collected? | Purpose | Encrypted in transit? | User can request deletion? |
|---|---|---|---|---|
| User IDs | Yes — developerUserId passed to identify() | Analytics, app functionality | Yes (HTTPS/TLS) | Yes — via forget() |
| Email addresses | Optional — only if passed to identify(email = ...) | Analytics | Yes | Yes — via forget() |
| App interactions (app activity) | Yes — track() events | Analytics | Yes | Yes — via forget() |
| Crash logs | Optional — only if captureUncaughtExceptions: true | App functionality (diagnostics) | Yes | Yes — via forget() |
The SDK does not collect: precise or approximate location, contacts, photos or videos, audio files, files and docs, calendar events, SMS or MMS, browsing history, search history, installed apps, device or other IDs (IMEI, Android ID, GAID). The anonymousId is a randomly-minted first-party identifier stored in SharedPreferences — it is not a device identifier and is not shared with advertising networks.
Configuration reference
The complete CrossdeckOptions surface — every parameter, its default, and its purpose.
| Option | Type | Default | Purpose |
|---|---|---|---|
appId | String | (required) | Crossdeck App ID from the dashboard. Goes on every batch envelope. |
publicKey | String | (required) | Publishable key (cd_pub_live_… or cd_pub_test_…). Safe to embed in a shipping APK. |
environment | Environment | (required) | Must match the publicKey prefix. cd_pub_live_ ↔ PRODUCTION, cd_pub_test_ ↔ SANDBOX. |
baseUrl | String? | https://api.cross-deck.com/v1 | Override for self-hosted relays / local emulator. The error-capture self-skip pivots on THIS URL. |
storage | KeyValueStorage? | SharedPreferencesStorage | Pass MemoryStorage in tests, or wire EncryptedSharedPreferences for multi-process / sensitive deployments. |
initialConsent | ConsentState | ConsentState(analytics = true, errors = true) | Default-grant both channels. Wire opt-out for strict-consent flows. |
scrubPii | Boolean | true | PII scrubber default. Disable only with hard requirement + explicit consent. |
queueConfig | EventQueueConfig | batchSize=20, flushIntervalMs=5000, maxBufferSize=1000 | Tune for chatty apps or strict bandwidth caps. |
breadcrumbCapacity | Int | 50 | Ring buffer cap. Lower for memory-constrained surfaces. |
captureUncaughtExceptions | Boolean | false | Install global Throwable handler (chains into prior handler). |
beforeSendError | BeforeSendErrorHandler? | null | Filter / mutate error events before they enter the queue. |
onPermanentFailure | PermanentFailureHandler? | null | Notified when a batch is dropped (4xx hard-stop or retry-exhausted). |
debugLogger | DebugLogger | NoopDebugLogger | Pass DefaultDebugLogger in development to route to android.util.Log. |
API reference
Crossdeck
| Method | Suspend | Throws | Purpose |
|---|---|---|---|
Crossdeck.start(context, options) | no | CrossdeckError | Designated factory. |
track(name, properties?) | no | CrossdeckError | Enqueue an analytics event. Throws missing_event_name for empty/null name; sanitises invalid property values, never throws on them. |
identify(userId, email?, traits?) | no | CrossdeckError | Link to a stable user identity. |
identifyAndWait(userId, email?, traits?) | yes | CrossdeckError | identify + await canonical cdcust_. |
forget() | yes | CrossdeckError | GDPR right-to-be-forgotten. |
syncPurchases(rail, ...) | yes | CrossdeckError | Forward purchase evidence; warm cache. |
getEntitlements() | yes | CrossdeckError | Fetch + cache the current entitlement set. |
onEntitlementsChange(handler) | no | — | Subscribe to cache mutations. Returns unsubscribe handle. |
isEntitled(key) | no | — | Synchronous gate check. |
entitlementsForCurrentCustomer() | no | — | Full snapshot for the identified user. |
activeEntitlementKeys() | no | — | Just the active keys. |
heartbeat() | yes | — | GET /sdk/heartbeat. Best-effort; null on failure. |
reset() | no | CrossdeckError | Wipe identity, entitlements, super-props, breadcrumbs. |
registerSuperProperty(k, v) | no | — | Merge into every subsequent event. |
registerSuperPropertyOnce(k, v) | no | — | First-write-wins variant. |
unregisterSuperProperty(k) | no | — | Remove. |
addBreadcrumb(crumb) | no | — | Add to ring buffer. |
captureError(throwable, handled?) | no | — | Manual error capture. |
captureMessage(message, level?) | no | — | Synthetic error event with no stack. |
setTag(k, v) / setTags(map) | no | — | Sentry-style facets on every error. |
setContext(name, data) | no | — | Named context block. |
setErrorBeforeSend(handler?) | no | — | Replace runtime hook. |
setConsent(state) | no | — | Update analytics/errors consent. |
setScrubPii(enabled) | no | — | Toggle PII scrubber. |
flush() | yes | — | Drain the queue. |
stats() | yes | — | Queue diagnostics. |
stop() | no | — | Teardown. Blocks on final persist. |
Diagnostics & debugging
The DebugLogger closure receives a structured DebugSignal + a string-keyed payload for every notable SDK event. Pass DefaultDebugLogger in development to route to android.util.Log at the Crossdeck tag:
CrossdeckOptions(
appId = "app_android_acme01",
publicKey = "cd_pub_test_...",
environment = Environment.SANDBOX,
debugLogger = DefaultDebugLogger,
)
Stream the logs:
adb logcat -s Crossdeck
Signal vocabulary
Every signal value maps to a fixed wireValue — the dashboard's onboarding checklist keys off these specific names. Renaming one is a breaking dashboard change.
| Signal | Fired when |
|---|---|
sdk.configured | SDK started or identity changed. |
sdk.first_event_sent | First event of this process landed at ingest. ONE-SHOT per process. |
sdk.invalid_key | publicKey rejected, or alias call failed. |
sdk.no_identity | track fired without a known identity. |
sdk.entitlement_cache_used | isEntitled answered from cache. |
sdk.purchase_evidence_sent | Purchase receipt POSTed successfully. |
sdk.environment_mismatch | environment declaration contradicts key prefix. |
sdk.sensitive_property_warning | Property name matches a PII/secret pattern. |
sdk.property_coerced | Sanitiser coerced or dropped a value. |
sdk.queue_persisted | Buffer / pending state written to SharedPreferences. |
sdk.queue_restored | Queue rehydrated from disk on start. |
sdk.flush_retry_scheduled | Retryable failure; next attempt scheduled. |
sdk.flush_permanent_failure | Batch dropped (4xx hard-stop or retry-exhausted). |
sdk.consent_changed | setConsent(...) called. |
sdk.consent_denied | Event dropped at consent gate. |
sdk.pii_scrubbed | PII scrubber redacted a value. |
Queue diagnostics
lifecycleScope.launch {
val stats = cd.stats()
Log.d("Crossdeck", "buffered=${stats.buffered} pending=${stats.pending} retry_at=${stats.nextRetryAtMs}")
}
ProGuard & R8
The SDK ships a consumer-rules.pro file inside its AAR. The Gradle plugin applies it automatically to every app that depends on com.crossdeck:crossdeck — no manual -keep rules are needed for the SDK itself:
-keep public class com.crossdeck.** { public *; }
-keepclassmembers class com.crossdeck.** { public *; }
This ensures the SDK's public API is never renamed or stripped, which matters for Flutter / Cordova bridges and any code that reflectively touches the SDK.
Error fingerprints under obfuscation
Stack fingerprints are computed from the top-5 frames at capture time using the raw class and method names present in the JVM stack. The SDK's own frames are never obfuscated (consumer rules above). Frames from your app code will be obfuscated if R8 is enabled — fingerprints appear as a.b.c.d() style names. They remain stable across repeated runs of the same build (same ProGuard mapping) but differ between builds.
v1.x does not include mapping-file upload. For readable symbols in production error reports, either:
- Upload your
mapping.txtto Firebase App Distribution or Google Play (Console → Android Vitals → Deobfuscation files), or - Add
-keepnames class com.yourapp.**to your ProGuard rules for the packages you want readable in error captures.
Troubleshooting
"Crossdeck.start throws invalid_secret_key"
Your publicKey doesn't start with cd_pub_. Double-check the value from the Crossdeck dashboard — secret keys (cd_sk_…) belong on your backend, not on Android.
"Crossdeck.start throws env_mismatch"
environment contradicts the key prefix. cd_pub_live_ must pair with Environment.PRODUCTION; cd_pub_test_ with Environment.SANDBOX. Confirm both halves of your build config.
"Events aren't appearing on the dashboard"
- Enable
DefaultDebugLoggerin your options. Watchadb logcat -s Crossdeckforsdk.first_event_sent— if it never fires, the network isn't reaching ingest. - Confirm
android.permission.INTERNETisn't blocked by a network-security-config that pins certificates we don't ship under. - Check
cd.stats().pending— a non-zero pending count with nosdk.first_event_sentmeans we're stuck in the retry loop. Watch forsdk.flush_retry_scheduledin logcat. - If you see
sdk.flush_permanent_failurewith codeunauthorized, yourpublicKeyis rejected — re-check it on the dashboard.
"isEntitled returns false even though the user paid"
- Confirm
identify(...)was called BEFOREisEntitled. The cache is keyed ondeveloperUserId; an unidentified caller sees no entitlements. - If the user just upgraded, the cache is cold. Call
cd.getEntitlements()to fetch. The result is cached immediately on success. - If
syncPurchasesjust completed andisEntitledstill returns false, the cache was warmed but the entitlement key may not match what the dashboard granted. Checkcd.entitlementsForCurrentCustomer()for the actual keys present.
"Crashlytics stopped reporting after I enabled captureUncaughtExceptions"
It shouldn't — the Crossdeck handler chains into the prior handler at install time. If you're not seeing reports, confirm Crashlytics was registered BEFORE Crossdeck (e.g. Firebase.initialize in Application.onCreate before Crossdeck.start). The handler chain captures whatever was installed at the moment Crossdeck installs ours.
"Multiple Crossdeck.start() calls"
Each call constructs a new instance with its own queue + lifecycle observers. The global error-capture singleton routes through the most-recent install. For clean lifecycle, call cd.stop() on the prior instance before constructing a new one.
Versioning & changelog
The Android SDK follows semantic versioning. Breaking changes only bump the major version; new features bump minor; bug fixes bump patch.
Latest: v1.4.3 — 2026-05-27
Jetpack Compose tap-label deep-walk — Stripe-premium parity with Swift v1.4.7. Compose's Button("Create Image") { … } renders into an AndroidComposeView whose own contentDescription and getText() are typically empty (the label lives on a child inside Compose's internal layout). Auto-track tap capture now descends up to 6 levels into the matched view's subtree looking for a child with contentDescription or TextView.text, with the same PII substring guard applied to descendant-found labels. Walk cap also raised from 8 to 16 levels — matches Swift's ancestor walk for parity, and Compose hierarchies on modern apps comfortably exceed 8 from the AndroidComposeView root.
v1.4.2 — 2026-05-26
Jetpack Compose screen tracking. Pure-Compose apps now populate the Pages dashboard with one line per screen — CrossdeckScreen("Name") { … } wrapper or Modifier.crossdeckScreen(cd, "Name") per user-visible destination. See Screen views for the five-pattern matrix (NavHost, NavigationBar tabs, ModalBottomSheet, Dialog, Modifier form).
com.crossdeck.compose.CrossdeckScreen("Name") { content }— wrapper composable. Firespage.viewedwith{ screen, title }on first composition AND on everyLifecycle.Event.ON_RESUMEtransition.Modifier.crossdeckScreen(cd, "Name")— alternate form for screens already plumbing a Modifier chain. Samepage.viewedshape; one call per screen-enter viaLaunchedEffect(name).- Compose declared
compileOnly— non-Compose hosts pay zero transitive dependency cost. The helpers only resolve at compile time for hosts that already depend on Compose. - Existing fragment / Activity auto-track unchanged — those keep firing
page.viewedautomatically via theActivity.onResumeswizzle.
v1.2.0 — 2026-05-25
Full bank-grade parity with Web/Node/RN/Swift v1.2.0. Closes every audit gap on the *automatic* surface — auto-tracking, perf vitals, app-lifecycle plumbing, crash-on-relaunch, network reachability, plus the missing API symmetry (group, consentStatus, respectDnt).
- Auto-tracking (default-on).
session.started/session.ended/element.clickedautomatically, pluspage.viewedon every Activity / fragmentonResume. Jetpack Compose hides destination names from the runtime, so Compose screens need oneCrossdeckScreen("Name") { … }wrapper per screen — see Screen views. Same event vocabulary as Swift / Web so cross-platform funnels work with one dashboard query. Activity.onResume + Window.Callback wrapping for taps; hit-test view-tree walk to find the deepest interactive view. Privacy guardrails (PII labels skipped,cd-noTrackcontentDescription opt-out, 100ms tap coalesce, 250ms screen dedup). 30-minute idle session threshold matches GA4/Mixpanel/Web/Swift. - ProcessLifecycleOwner foreground/background. Replaces fragile per-Activity lifecycle as source of truth — multi-Activity backstacks no longer emit spurious session events on intra-process transitions.
- Performance vitals (opt-in). Set
enablePerformanceMonitoring = trueto receiveperf.cold_launch_ms(viaProcess.getStartElapsedRealtime),perf.anr(main-thread watchdog, 5s threshold matches Android's own ANR), andperf.frame_jank(Choreographer 60s rollups: total / slow >16ms / frozen >700ms, aligned with Android Vitals). - Crash-on-relaunch persistence (default-on). Fatal exceptions persisted to
<filesDir>/crossdeck-fatal.jsonSYNCHRONOUSLY inside the uncaught-exception handler BEFORE forwarding to Crashlytics. On next launch, the SDK emits$error.recovered. Closes the audit gap where fatals were vanishing because enqueue raced process death. - Network reachability flush (default-on).
ConnectivityManager.NetworkCallbackfiresqueue.flush()on offline→online transitions. - API symmetry with Web SDK.
cd.group(groupType, groupKey, traits)for B2B grouping,cd.consentStatus(): ConsentStatefor opt-out UI,cd.resetSession()for logout flows,CrossdeckOptions(respectDnt = true)for immutable CCPA-style opt-out. - Helpers (not auto-listeners — Android's BillingClient / Intent / push delivery are owned by consumer code).
cd.handleBillingResult(responseCode, purchasesList)forwards Play Billing purchases to/purchases/sync+ firespurchase.completed/.refunded/.failed.cd.trackDeepLink(uri)extracts UTM + click-id query params.cd.trackPushReceived/trackPushInteractionsurfaces marketing IDs without logging alert bodies. - sessionId enrichment. Every event the SDK ships now carries the current
sessionIdso funnels reconstruct without explicit instrumentation. - Strictly additive. All v1.0.x call sites compile clean. Default-OFF modules (perf vitals) and default-ON modules (auto-track, reachability, crash-on-relaunch) are independently controllable via
CrossdeckOptions.
The full changelog ships in the public mirror repository:
Cross-SDK contract
The Android SDK is one of five officially-supported Crossdeck SDK implementations. Every public method here has a byte-for-byte equivalent in the Web, Node, React Native, and Swift SDKs — same names, same parameter ordering, same wire shapes, same semantics. A customer running Web + Android side-by-side observes identical behaviour for the same call.