Crossdeck Docs
Dashboard

@cross-deck/android — Android SDK reference

Reference Current version: 1.4.4 · ~18 min read · Updated May 25, 2026

@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

Install

One Maven coordinate covers every Android device we support. Tested on Android 5.0+ (API 21+), which covers ~99% of active devices.

SurfaceMinimumNotes
AndroidAPI 21 (Android 5.0)The SDK uses no API-version-conditional code; one binary serves every supported device.
Java toolchainJava 17Matches the AGP 8.x default and modern Android Studio defaults.
Kotlin1.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
    }
}
Why nullable instead of 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()
}
Yes — 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:

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:

FieldSet byWire shapeLifetime
anonymousIdSDK on first launchanon_<32-char hex>Persists until reset(). Regenerated on reset so the next anonymous session is fully unlinked from the prior identified user.
developerUserIdidentify(userId)Your auth provider's stable id (e.g. Firebase Auth uid, Auth0 sub)Persists until identify with a different id, or reset().
crossdeckCustomerIdServer response on /identity/aliascdcust_<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",
    ),
)
Unconditional cache clear. Every 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.
Repeated identify is SAFE but not free — gate it. Calling 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 / Infinitynull; 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

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:

Naming conventions

The screen name becomes the grouping key on the dashboard. Two rules:

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

Google Play receipt verification is backend v1.1.

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,
)
Chains, doesn't replace. The previous default handler is captured at install time and forwarded to AFTER our snapshot. Crashlytics, Sentry, Bugsnag — all keep working when you turn on Crossdeck's error capture.

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:

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 typeCollected?PurposeEncrypted in transit?User can request deletion?
User IDsYes — developerUserId passed to identify()Analytics, app functionalityYes (HTTPS/TLS)Yes — via forget()
Email addressesOptional — only if passed to identify(email = ...)AnalyticsYesYes — via forget()
App interactions (app activity)Yes — track() eventsAnalyticsYesYes — via forget()
Crash logsOptional — only if captureUncaughtExceptions: trueApp functionality (diagnostics)YesYes — 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.

OptionTypeDefaultPurpose
appIdString(required)Crossdeck App ID from the dashboard. Goes on every batch envelope.
publicKeyString(required)Publishable key (cd_pub_live_… or cd_pub_test_…). Safe to embed in a shipping APK.
environmentEnvironment(required)Must match the publicKey prefix. cd_pub_live_ ↔ PRODUCTION, cd_pub_test_ ↔ SANDBOX.
baseUrlString?https://api.cross-deck.com/v1Override for self-hosted relays / local emulator. The error-capture self-skip pivots on THIS URL.
storageKeyValueStorage?SharedPreferencesStoragePass MemoryStorage in tests, or wire EncryptedSharedPreferences for multi-process / sensitive deployments.
initialConsentConsentStateConsentState(analytics = true, errors = true)Default-grant both channels. Wire opt-out for strict-consent flows.
scrubPiiBooleantruePII scrubber default. Disable only with hard requirement + explicit consent.
queueConfigEventQueueConfigbatchSize=20, flushIntervalMs=5000, maxBufferSize=1000Tune for chatty apps or strict bandwidth caps.
breadcrumbCapacityInt50Ring buffer cap. Lower for memory-constrained surfaces.
captureUncaughtExceptionsBooleanfalseInstall global Throwable handler (chains into prior handler).
beforeSendErrorBeforeSendErrorHandler?nullFilter / mutate error events before they enter the queue.
onPermanentFailurePermanentFailureHandler?nullNotified when a batch is dropped (4xx hard-stop or retry-exhausted).
debugLoggerDebugLoggerNoopDebugLoggerPass DefaultDebugLogger in development to route to android.util.Log.

API reference

Crossdeck

MethodSuspendThrowsPurpose
Crossdeck.start(context, options)noCrossdeckErrorDesignated factory.
track(name, properties?)noCrossdeckErrorEnqueue an analytics event. Throws missing_event_name for empty/null name; sanitises invalid property values, never throws on them.
identify(userId, email?, traits?)noCrossdeckErrorLink to a stable user identity.
identifyAndWait(userId, email?, traits?)yesCrossdeckErroridentify + await canonical cdcust_.
forget()yesCrossdeckErrorGDPR right-to-be-forgotten.
syncPurchases(rail, ...)yesCrossdeckErrorForward purchase evidence; warm cache.
getEntitlements()yesCrossdeckErrorFetch + cache the current entitlement set.
onEntitlementsChange(handler)noSubscribe to cache mutations. Returns unsubscribe handle.
isEntitled(key)noSynchronous gate check.
entitlementsForCurrentCustomer()noFull snapshot for the identified user.
activeEntitlementKeys()noJust the active keys.
heartbeat()yesGET /sdk/heartbeat. Best-effort; null on failure.
reset()noCrossdeckErrorWipe identity, entitlements, super-props, breadcrumbs.
registerSuperProperty(k, v)noMerge into every subsequent event.
registerSuperPropertyOnce(k, v)noFirst-write-wins variant.
unregisterSuperProperty(k)noRemove.
addBreadcrumb(crumb)noAdd to ring buffer.
captureError(throwable, handled?)noManual error capture.
captureMessage(message, level?)noSynthetic error event with no stack.
setTag(k, v) / setTags(map)noSentry-style facets on every error.
setContext(name, data)noNamed context block.
setErrorBeforeSend(handler?)noReplace runtime hook.
setConsent(state)noUpdate analytics/errors consent.
setScrubPii(enabled)noToggle PII scrubber.
flush()yesDrain the queue.
stats()yesQueue diagnostics.
stop()noTeardown. 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.

SignalFired when
sdk.configuredSDK started or identity changed.
sdk.first_event_sentFirst event of this process landed at ingest. ONE-SHOT per process.
sdk.invalid_keypublicKey rejected, or alias call failed.
sdk.no_identitytrack fired without a known identity.
sdk.entitlement_cache_usedisEntitled answered from cache.
sdk.purchase_evidence_sentPurchase receipt POSTed successfully.
sdk.environment_mismatchenvironment declaration contradicts key prefix.
sdk.sensitive_property_warningProperty name matches a PII/secret pattern.
sdk.property_coercedSanitiser coerced or dropped a value.
sdk.queue_persistedBuffer / pending state written to SharedPreferences.
sdk.queue_restoredQueue rehydrated from disk on start.
sdk.flush_retry_scheduledRetryable failure; next attempt scheduled.
sdk.flush_permanent_failureBatch dropped (4xx hard-stop or retry-exhausted).
sdk.consent_changedsetConsent(...) called.
sdk.consent_deniedEvent dropped at consent gate.
sdk.pii_scrubbedPII 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:

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"

  1. Enable DefaultDebugLogger in your options. Watch adb logcat -s Crossdeck for sdk.first_event_sent — if it never fires, the network isn't reaching ingest.
  2. Confirm android.permission.INTERNET isn't blocked by a network-security-config that pins certificates we don't ship under.
  3. Check cd.stats().pending — a non-zero pending count with no sdk.first_event_sent means we're stuck in the retry loop. Watch for sdk.flush_retry_scheduled in logcat.
  4. If you see sdk.flush_permanent_failure with code unauthorized, your publicKey is rejected — re-check it on the dashboard.

"isEntitled returns false even though the user paid"

  1. Confirm identify(...) was called BEFORE isEntitled. The cache is keyed on developerUserId; an unidentified caller sees no entitlements.
  2. If the user just upgraded, the cache is cold. Call cd.getEntitlements() to fetch. The result is cached immediately on success.
  3. If syncPurchases just completed and isEntitled still returns false, the cache was warmed but the entitlement key may not match what the dashboard granted. Check cd.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).

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

The full changelog ships in the public mirror repository:

View on GitHub

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.