Mobile
Android SDK
Kotlin-first native library on Maven Central. Six products bundled — Analytics, Catch, Switch, Config, Pulse, Replay — with auto-registered lifecycle and ANR detection.
The Sankofa Android SDK is a Kotlin-first native library that ships every supported client product in a single Maven Central artifact (dev.sankofa.sdk:sankofa-android). It auto-registers across every Activity, uses WorkManager for reliable background flushing, and supports both Kotlin and Java callers.
The product set on Android is six: Analytics, Catch, Switch, Config, Pulse, Replay. Deploy / OTA is not available on Android; the engine recognises the module on the handshake but the Android SDK doesn't ship an implementation. For OTA on Android, use the React Native SDK in a hybrid app.
For installation and project setup, see Install on Android.
Requirements
- Android Studio Hedgehog or newer
minSdk 24(Android 7.0)- Kotlin 1.9+ (Java 8 also supported)
Initialize
Initialize in your Application subclass — the SDK auto-registers across every Activity from there.
import android.app.Application
import dev.sankofa.sdk.Sankofa
import dev.sankofa.sdk.SankofaConfig
import dev.sankofa.sdk.catchmod.SankofaCatch
import dev.sankofa.sdk.switchmod.SankofaSwitch
import dev.sankofa.sdk.remoteconfig.SankofaRemoteConfig
import dev.sankofa.sdk.pulse.SankofaPulse
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Switch + Config first so the auto-discovered flag/config
// snapshots Catch attaches to its events on the very first
// crash already see something useful.
SankofaSwitch.init(this, defaults = mapOf("new_checkout" to false))
SankofaRemoteConfig.init(this, defaults = mapOf("max_upload_mb" to 25))
// One-line init. enableCatch=true (default) auto-installs the
// chained Thread.UncaughtExceptionHandler + ANR watcher — no
// separate `SankofaCatch.init` call needed.
Sankofa.init(
context = this,
apiKey = BuildConfig.SANKOFA_KEY,
config = SankofaConfig(
endpoint = "https://api.sankofa.dev",
recordSessions = true,
maskAllInputs = true,
debug = BuildConfig.DEBUG,
catchEnvironment = "production",
release = "v1.0.0",
appVersion = BuildConfig.VERSION_NAME,
),
)
SankofaPulse.register(applicationContext)
}
}Register the application in your manifest:
<application
android:name=".MyApplication"
android:label="@string/app_name">
...
</application>SankofaConfig properties
endpointStringdefault https://api.sankofa.devrecordSessionsBooleandefault truemaskAllInputsBooleandefault truedebugBooleandefault falsetrackLifecycleEventsBooleandefault trueflushIntervalSecondsIntdefault 30batchSizeIntdefault 50enableCatchBooleandefault truecatchEnvironmentStringdefault livereleaseString?appVersionString?beforeSendBeforeSendFn?SankofaConfig is a Kotlin data class, so use named arguments for readability.
Analytics — events, identify, setPerson
// Track
Sankofa.track("checkout_started", mapOf(
"cart_value" to 49.99,
"item_count" to 3
))
// Tag a screen
Sankofa.screen("Checkout")
// Identify
Sankofa.identify("user_123")
// Update profile traits
Sankofa.setPerson(
name = "Ada Lovelace",
email = "[email protected]",
properties = mapOf("company" to "Sankofa Ltd")
)
// Logout
Sankofa.reset()
// Force-flush
Sankofa.flush()The event queue is backed by Room, persists across app restarts, and uses WorkManager to flush even when the app is killed.
Catch — error capture
Catch auto-boots inside Sankofa.init. Once init resolves, every Sankofa.captureException / Sankofa.log call routes to the singleton — no separate SankofaCatch.init call needed. Crashes serialize to SharedPreferences synchronously and upload on the next launch.
// Capture a handled exception from anywhere — Sentry-style.
try {
chargeCard(amount)
} catch (e: Exception) {
Sankofa.captureException(e)
}
// Capture a non-error event (warning-level).
Sankofa.captureMessage("Payment retry attempted")
// Crashlytics-style breadcrumb log — rides on the next captured
// event. Doesn't bill on its own.
Sankofa.log("checkout: applying coupon SUMMER25")
// Attach ambient context. Sticky — every subsequent event carries it.
Sankofa.setUser(CatchUserContext(id = "user_123", email = "[email protected]"))
Sankofa.setTag("flow", "checkout")
Sankofa.setExtra("cart_id", cart.id)
// Structured breadcrumb (HTTP, navigation, console, etc.).
Sankofa.addBreadcrumb(CatchBreadcrumb(
type = "user-action",
category = "ui",
message = "Tapped checkout"
))Automatic coverage
Sankofa.init installs:
- Chained
Thread.setDefaultUncaughtExceptionHandler— every JVM-uncaught exception. The previous handler (Crashlytics, etc.) still fires after Sankofa records the event. CatchAnrWatcher— main-thread hangs > 5s emit ananrevent.- Auto-discovered flag/config snapshots — every event carries the active
flag_snapshot+config_snapshotif Switch/Config are linked. No host wiring.
withScope — Sentry-style temporary scope
Sankofa.withScope { scope ->
scope.setTag("checkout_step", "payment")
scope.setExtra("cart_id", cart.id)
scope.setLevel(CatchLevel.WARNING)
Sankofa.captureException(err)
}
// Outside the closure, those tags / extras are gone.Scopes are thread-local — captures fired on a background worker won't pick up the UI thread's scope, and vice versa.
beforeSend — scrub PII / drop noise
Sankofa.init(
context = this,
apiKey = BuildConfig.SANKOFA_KEY,
config = SankofaConfig(
endpoint = "https://api.sankofa.dev",
beforeSend = { event ->
// Drop framework noise.
if (event.message?.contains("setState called after destroy") == true) {
null
} else if (event.user?.email != null) {
// PII scrubbing — strip email.
event.copy(user = event.user.copy(email = null))
} else {
event
}
},
),
)Throws inside beforeSend are swallowed — the original event ships unchanged.
ProGuard mapping upload
npx sankofa-cli catch symbols upload \
--kind proguard_mapping \
--release "$RELEASE_SHA" \
--file ./build/outputs/mapping/release/mapping.txt| Static helper | Description |
|---|---|
Sankofa.captureException(t, [opts]) | Capture a handled throwable. Returns event ID or "". |
Sankofa.captureMessage(msg, [opts]) | Non-error variant. |
Sankofa.log(msg, [category]) | Crashlytics-style breadcrumb. Doesn't bill. |
Sankofa.setUser(user) / setTag(s) / setExtra / addBreadcrumb | Ambient context. |
Sankofa.withScope { scope -> ... } | Thread-local temporary scope. |
Sankofa.flushCatch() | Force-flush queued Catch events. |
Switch — feature flags
SankofaSwitch is a Kotlin object singleton. Init it from Application.onCreate after Sankofa.init:
import dev.sankofa.sdk.switchmod.SankofaSwitch
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Sankofa.init(this, BuildConfig.SANKOFA_KEY, SankofaConfig())
SankofaSwitch.init(this, mapOf(
"new_checkout" to false,
"dark_mode_default" to false
))
}
}Anywhere in your app:
if (SankofaSwitch.getFlag("new_checkout")) {
showNewCheckout()
}
val variant = SankofaSwitch.getVariant("checkout_redesign", default = "control")
// Subscribe to changes
val token = SankofaSwitch.onChange("new_checkout") { decision ->
runOnUiThread {
binding.newCheckoutToggle.isChecked = decision.value as Boolean
}
}
// Later: cancel
token.cancel()For Compose, you can wrap the imperative API yourself with produceState / remember — there's no Sankofa-specific Compose hook today.
Config — remote config
SankofaRemoteConfig is named distinctly from SankofaConfig (the init-options data class).
import dev.sankofa.sdk.remoteconfig.SankofaRemoteConfig
// Init in Application.onCreate
SankofaRemoteConfig.init(this, mapOf(
"max_upload_mb" to 25,
"support_email" to "[email protected]"
))
// Read anywhere — generic typed read
val maxMB: Int = SankofaRemoteConfig.get("max_upload_mb", 25)
val email: String = SankofaRemoteConfig.get("support_email", "[email protected]")
// Subscribe to changes
val token = SankofaRemoteConfig.onChange("max_upload_mb") { decision ->
// Update UI
}A single generic get<T> infers the return type from the default. The SDK validates the engine's payload against the declared type at decode time.
Pulse — surveys
// In Application.onCreate, after Sankofa.init
val registered = SankofaPulse.register(applicationContext)
// Show a survey from an Activity
SankofaPulse.show(surveyId = "srv_post_purchase_feedback", activity = this)
// Listen for events
val sub = SankofaPulse.on(PulseEvent.Completed) { event ->
Log.i("pulse", "Survey ${event.surveyId} completed")
}
// Inspect eligibility
SankofaPulse.activeMatchingSurveys { surveys ->
Log.i("pulse", "Eligible: ${surveys.map { it.id }}")
}Session replay
The replay engine captures the active Activity's window via BitmapPool + Canvas drawing, with masking applied before frames leave the device. Mode is screenshot only on Android today — there's no wireframe / screenshot mode toggle.
Masking
Masking is automatic when config.maskAllInputs is true (the default) — every EditText is blacked out.
For custom masking, set the view tag:
mySensitiveView.tag = "sankofa_mask"Tagged views render as a neutral placeholder rectangle in replays.
Compose scroll-offset tagging
Classic-View scroll positions (ScrollView, RecyclerView, AbsListView) are walked from the decor-view hierarchy automatically — heatmaps and replay touch attribution see the right Y offset out of the box.
Compose hosts (LazyColumn, Modifier.verticalScroll, Modifier.scrollable) draw into a single AndroidComposeView with no per-scrollable child View, so the walker returns 0 and every below-the-fold tap collapses to the first viewport in the heatmap panorama.
Register a provider so the touch dispatcher + replay recorder can ask Compose for the real offset:
@Composable
fun ProductList() {
val scrollState = rememberScrollState()
DisposableEffect(scrollState) {
val handle = Sankofa.tagScrollContainer { scrollState.value }
onDispose { handle.remove() }
}
Column(modifier = Modifier.verticalScroll(scrollState)) {
// ...
}
}For LazyColumn / LazyRow, derive the pixel offset from the list state:
val handle = Sankofa.tagScrollContainer {
lazyListState.firstVisibleItemIndex * estimatedItemHeightPx +
lazyListState.firstVisibleItemScrollOffset
}Sankofa.tagScrollContainer { ... } returns a ScrollContainerHandle — call .remove() when the scrollable leaves composition. Handles are idempotent: removing twice is a no-op. Multiple registrations are allowed (nested scrollables); the first non-zero result wins, matching the "first scrollable wins" semantics of the classic-View walker.
A host callback that throws never crashes the touch dispatcher — exceptions are swallowed and the next provider is tried.
ProGuard / R8
The SDK ships consumer ProGuard rules at consumer-rules.pro — minification works out of the box without additional -keep directives.
Build artifact identifiers
| Field | Value |
|---|---|
| Group ID | dev.sankofa.sdk |
| Artifact ID | sankofa-android |
| Version | 1.0.0 |
| Repository | Maven Central |
API summary
| Method | Description |
|---|---|
Sankofa.init(context, apiKey, config) | Initialize the SDK. |
Sankofa.track(event, properties?) | Record an event. |
Sankofa.screen(name, properties?) | Tag the current screen. |
Sankofa.identify(userId) | Stitch anonymous → known. |
Sankofa.setPerson(name?, email?, properties?) | Update profile traits. |
Sankofa.reset() | Rotate session + clear identity. |
Sankofa.flush() | Force-drain the queue. |
Sankofa.captureException(throwable, [opts]) | Capture an error (auto-routes to Catch singleton). |
Sankofa.captureMessage(msg, [opts]) | Capture a non-error event. |
Sankofa.log(msg, [category]) | Crashlytics-style breadcrumb. |
Sankofa.setUser / setTag(s) / setExtra / addBreadcrumb | Ambient context. |
Sankofa.withScope { scope -> ... } | Temporary scope overlay. |
Sankofa.flushCatch() | Force-flush Catch events. |
Sankofa.tagScrollContainer { offset } | Register a Compose scroll-offset provider. Returns a ScrollContainerHandle whose .remove() unregisters (idempotent). |
SankofaSwitch.init(context, defaults) | Initialize Switch. |
SankofaSwitch.getFlag(key, default?) | Boolean feature flag. |
SankofaSwitch.getVariant(key, default) | Variant feature flag. |
SankofaRemoteConfig.init(context, defaults) | Initialize Config. |
SankofaRemoteConfig.get<T>(key, default) | Typed remote config. |
SankofaPulse.register(context) | Initialize Pulse. |
SankofaPulse.show(surveyId, activity) | Show a survey. |