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.

KotlinMyApplication.kt
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:

xmlAndroidManifest.xml
<application
  android:name=".MyApplication"
  android:label="@string/app_name">
  ...
</application>

SankofaConfig properties

endpointStringdefault https://api.sankofa.dev
Server base URL.
recordSessionsBooleandefault true
Enable Replay capture (screenshot mode).
maskAllInputsBooleandefault true
Auto-mask all EditText views in replays.
debugBooleandefault false
Verbose Logcat output.
trackLifecycleEventsBooleandefault true
Auto-track app open / foregrounded / backgrounded.
flushIntervalSecondsIntdefault 30
Foreground flush cadence.
batchSizeIntdefault 50
Maximum events per batch upload.
enableCatchBooleandefault true
Auto-install Catch + chained UncaughtExceptionHandler + ANR watcher. Set false to defer Catch boot.
catchEnvironmentStringdefault live
Environment tag stamped on every captured event.
releaseString?
Release identifier (e.g. '[email protected]') sent on every Catch event.
appVersionString?
App version override sent in the Catch device context.
beforeSendBeforeSendFn?
Synchronous hook fired AFTER event composition but BEFORE enqueue. Return modified event or null to drop.

SankofaConfig is a Kotlin data class, so use named arguments for readability.

Analytics — events, identify, setPerson

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

Kotlin
// 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 an anr event.
  • Auto-discovered flag/config snapshots — every event carries the active flag_snapshot + config_snapshot if Switch/Config are linked. No host wiring.

withScope — Sentry-style temporary scope

Kotlin
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

Kotlin
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

bash
npx sankofa-cli catch symbols upload \
--kind proguard_mapping \
--release "$RELEASE_SHA" \
--file ./build/outputs/mapping/release/mapping.txt
Static helperDescription
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 / addBreadcrumbAmbient 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:

KotlinMyApplication.kt
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:

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

Kotlin
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

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

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

Kotlin
@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:

Kotlin
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

FieldValue
Group IDdev.sankofa.sdk
Artifact IDsankofa-android
Version1.0.0
RepositoryMaven Central

API summary

MethodDescription
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 / addBreadcrumbAmbient 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.

What's next

Edit this page on GitHub