SDK reference · Android

The Android SDK

The Android SDK runs in your app with the project publishable key. It reports crashes on the next launch, the screens people open and the time they spend on them, their visits, the events you made public and the signed-in user, from a queue on disk that survives being offline. It is on Maven Central.

The documentation is in English only. Ravenkeep em português

minSdk 21
Android 5.0 and newer, from Kotlin or Java
rk_pub_
Publishable key, accepted only from the apps you list
Never throws
Stores and sends on its own threads, never the caller's

Installation

The libraries are public on Maven Central: io.ravenkeep:ravenkeep-android, and io.ravenkeep:ravenkeep-android-compose for the Compose helper, at the same version. An Android project already lists Maven Central (mavenCentral() in settings.gradle.kts), so there is no other repository to add and no token. Add them to your app module:

app/build.gradle.ktsKotlin
dependencies {
    implementation("io.ravenkeep:ravenkeep-android:0.1.0")
    // Only for the Compose helper, TrackScreen
    implementation("io.ravenkeep:ravenkeep-android-compose:0.1.0")
}
  • Its only dependency is the Kotlin standard library (2.0 or newer); the Compose helper adds androidx.compose.runtime:runtime 1.9.4 or newer.
  • The SDK declares the INTERNET permission in its manifest. It needs no keep rule of its own in R8 (it uses no reflection).

Quickstart

  • List the app. In the console, Settings, then Projects, then your project's Apps tab, add your application id (com.acme.shop). Each id is its own entry, so a debug build with a .debug suffix needs one too. A request from an app that is not listed is refused (app_not_allowed) and stays queued: listing the app later delivers it.
  • Start the SDK once, in your Application class (registered with android:name in the manifest), with the project publishable key from the Install tab.
ShopApplication.ktKotlin
import android.app.Application
import io.ravenkeep.android.Ravenkeep
import io.ravenkeep.android.RavenkeepOptions

class ShopApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // The publishable key is safe in an app: it only works from the apps listed in the project.
        Ravenkeep.init(this, RavenkeepOptions(publishableKey = "rk_pub_lumen_xxxxxxxxxxxx"))
    }
}
ShopApplication.javaJava
Ravenkeep.init(this, new RavenkeepOptions("rk_pub_lumen_xxxxxxxxxxxx"));

From then on the SDK names a screen after each activity, measures the time spent on it, keeps the visit and reports crashes, with no other call. init runs in the app's main process only (another android:process is ignored) and refuses a secret key (rk_secret_), which would ship inside the app, and a key that is not rk_pub_ followed by the key's own characters (letters, digits, _ and -, at most 200 in all): a line break or a space pasted inside it would break every request, while white space around it is trimmed as JavaScript's trim() trims it (a byte order mark from a paste included), as the iOS and browser SDKs do. Called from an activity instead, the SDK follows that activity from then on and names its screen when it next resumes; in a process Android restored, init cannot tell that activity was restored, so the deep link it was first opened with adds its utm_* to its view again: call init in Application.onCreate.

A direct-boot-aware app runs before the user first unlocks the device after a reboot, when the app's storage (and the SDK's files) cannot be read: init then waits, with a logcat line, and finishes with the same options when the user unlocks (Intent.ACTION_USER_UNLOCKED). Until then nothing is captured, a crash included, and the calls are ignored as before init, another init too: the first call's options are the ones used (an init made once the device is unlocked, before the system's broadcast arrives, finishes the first call then). Other apps never run before the first unlock.

Configuration

RavenkeepOptions takes the key and, as named arguments in Kotlin (or overloads in Java), these settings:

OptionDefaultMeaning
publishableKeynoneThe project publishable key, rk_pub_..., trimmed. A secret key, or one with a space or a line break inside, is refused and never sent.
hosthttps://api.ravenkeep.ioAPI origin. Change it only for a self-hosted Ravenkeep.
environmentproductionThe crashes' environment: crash groups are kept per environment.
releasethe app's versionNameThe crashes' release, so a resolved crash regresses only in a newer one.
captureCrashestrueReport uncaught exceptions, sent on the next launch.
trackActivityScreenstrueName a screen after an activity's class when the app names none itself.
startOptedOutfalseStart opted out until optIn(), on a device where the SDK never ran (consent).
debugfalseLog what the SDK does to logcat (tag Ravenkeep); never a key, a token or a user hash.
flushAt20Send as soon as this many events are queued.
flushIntervalMs5000Send this long after the first unsent event at the latest (at least 1 second).

Ravenkeep times the events of a publishable key when they arrive, so flushIntervalMs is how far a visit's times can be off in the reports (the browser SDK waits 1 second); a longer wait sends fewer requests. The events of one request keep their order.

Identify users

identify opens the user's widget session, as the browser SDK does: later events and crashes carry the user, and Ravenkeep attributes them to the contact once the session vouches for them. The user hash is hex(HMAC-SHA256(identitySecret, userId)), computed by your backend (widgetUserHash in @ravenkeep/node); never put the identity secret in the app. With identity verification on (the default), a missing or wrong hash fails with identity_verification_failed.

SignIn.ktKotlin
// After sign-in, and on every launch of a signed-in app: the hash stays in memory only.
Ravenkeep.identify(
    userId = me.id,
    userHash = me.ravenkeepUserHash, // from your backend
    email = me.email,
    account = RavenkeepAccount(me.teamId, me.teamName),
) { result ->
    result.error?.let { Log.w("Shop", "Ravenkeep identify: ${it.code}") }
}

// On sign-out
Ravenkeep.reset()
  • The callback receives an IdentifyResult: data (the contact id, the user id, whether it is verified, when the session expires) or error, never both, on the SDK's own thread (before init: not_initialized, at once, on the caller's).
  • The SDK keeps the user and the session's token on disk and renews the token while the app runs, 5 minutes before it expires: the expiry is kept on the device's clock from the API's answer, so a device clock hours off still renews on time (at most once a minute), and a renewal that came due while the device slept runs when the app comes back to the foreground or sends its next batch. A renewal that gets no answer, a 408, a 429 or a 5xx is retried every 30 seconds, or after the API's Retry-After up to 5 minutes, while the token lasts; a refused one drops the token until the next identify. The hash is never written to disk, so the SDK cannot renew a session across launches: call identify on every launch of a signed-in app. Until it does, events carry the stored user and token while that token is valid (at most 12 hours after it was issued), then go without it, anonymous.
  • Each call's callback gets its own call's answer. Calls with the same input while one is in flight share its request; a call with another input for the same user supersedes the one in flight, whose callback gets superseded at once (its request is aborted, freeing the SDK's network thread for the later call, unless it is stuck where no abort reaches, such as a slow name lookup). The identity kept is the last call's once it succeeds; when it gets no answer, a 408, a 429 or a 5xx, the earlier session stays until it expires and the retry sends the last call's input; when it is refused, the session is dropped. identify of another user, or reset(), answers the call in flight not_identified.
  • Only the fields you pass are sent. As in the browser, a name or an email set by your backend is never overwritten from the app.
  • reset() on sign-out ends the current view, its engaged time so far sent as the signed-in user's in its visit (dropped when that visit is over: sign-out never opens a visit), then forgets the identity, its token, the anonymous id and the visit; the next screen starts a new view. identify of another user sends the view's engaged time so far as the earlier user's, the same way, and starts a new visit. The user id is trimmed as the key is.
How identity verification works

Screens and events

A screen view is a $screen event whose screen property names it; Metrics, then Pages & screens ranks it as you named it. With trackActivityScreens on, each activity is named after its class (CheckoutActivity) when the app does not name it. Name screens yourself for anything finer, such as Compose destinations or fragments:

CartScreen.ktKotlin
// io.ravenkeep:ravenkeep-android-compose, in each destination of your navigation
composable("cart") {
    TrackScreen("Cart")
    CartScreen()
}

// Or anywhere, with the core library alone
Ravenkeep.screen("Lesson", mapOf("lessonId" to lesson.id))
  • TrackScreen(name, properties) names the screen when it enters the composition and again when its arguments change, never on a recomposition. Put it in destinations, not in dialogs or sheets: closing one does not name the screen under it again. Give its properties strings, numbers and booleans: a value without value equality (an instance of a plain class) differs on every recomposition and names the screen again each time.
  • The same name and properties as the current view is the same view: nothing is sent, so a rotation is not a new view, after a deep link too. It is sent again only in a visit an event opened without it (an event the re-created activity tracks before naming its view, 30 minutes after the last); after its visit ended, the next event names it again in the new visit. Name screens the way your routes are named (Lesson, Settings), and send the id of what they show as a property.
  • An activity opened by a deep link (ACTION_VIEW) adds the link's utm_* parameters to its view: the one the app names (screen() in onCreate, onResume or later, or TrackScreen), with the app's own values first, or else the automatic screen. Values no view took are kept 10 seconds once no new activity's view is expected (a link activity that starts the next one and finishes, a view named once its content loads), then dropped, and dropped when the app goes to the background. An activity re-created (a rotation, the app's process restored) or launched again from the recents screen keeps the intent it was first launched with: its link brought an earlier view, so it adds nothing (but for the activity given to init in a restored process: see Quickstart). A link delivered to a running singleTop or singleTask activity names its view again with them when the activity makes it its intent (setIntent(intent) in onNewIntent; the SDK reads it when the activity resumes), even after the same link. utm_source, utm_medium and utm_campaign in a screen's properties name its campaign, and a view with another campaign starts a new visit.
  • A view named while the app is in the background and no activity is being created (from a push handler or a worker) is held, since nobody sees it. When the app comes back, it is sent at the first frame of the activity in front if that activity named no view by then (it shows what the app navigated to in the background), as the user identified then (after identify of another user, theirs): a view named, the activity's own or its automatic screen, replaces it, and so does a new activity. The current view is not sent again.
Checkout.ktKotlin
// With the publishable key, only the project's public event names
Ravenkeep.track("order.placed", mapOf("items" to 3, "plan" to "pro"))

track queues a custom event. With the publishable key, Ravenkeep accepts only the names listed in the project's Install tab, under Public events; anything else is refused and dropped, so send it from your backend with the Node SDK. Names are 1 to 100 characters and never start with $; properties are JSON values (strings, numbers, booleans, maps and lists) up to 32 KiB.

Naming events and what they power

Visits and engaged time

The SDK follows the same session and engaged-time rules as the browser SDK, so web and app visits compare in Metrics, then Sessions, Pages & screens and Features:

  • Visits. A visit continues while events come within 30 minutes of each other (by the device's clock) and lasts less than 24 hours. It ends on reset(), on identify() of another user and on a view with another campaign. It is stored, so a relaunch within 30 minutes continues it; going to the background ends nothing. Every event carries its sessionId; getSessionId() returns it, to place a server event in the visit. Ravenkeep times the events when they arrive, so events held back over 30 minutes (a phone offline) split their visit in the reports, as on the web.
  • A visit on the same screen. A visit that starts while a view is on screen (at the first event in the foreground after 30 minutes without one, the app just back or not; an event in the background opens the visit without it, and the next one in the foreground names it) opens on that view: its $screen is sent again first, without utm_*, so the visit has its landing screen. A visit that starts before any screen is named has none. Nothing is sent at the return itself: when the first event is a view (a deep link's, a new activity's or one you name), it opens the visit with its campaign, so a link that brings the app back makes one visit. The events a new activity tracks before its first frame (in onCreate, onStart or onResume) belong to the visit its screen opens after 30 minutes without events; within a visit, a link's campaign starts a new visit at the linked view, and the events tracked before it stay in the earlier one.
  • Engaged time. A view's time counts while the app is in the foreground, for at most 60 seconds after the view started, the app came back or the person last touched the screen, pressed a key or moved a pointer. It is sent as $screenleave with screen and engaged_ms when the view ends, when the app goes to the background and every 15 minutes; later segments of the same view carry resumed: true.
Player.ktKotlin
// Every few seconds while your player plays unmuted
if (player.isPlaying && player.volume > 0f) Ravenkeep.reportInteraction()
Sessions, time on screens and features

Crashes

With captureCrashes on, an uncaught exception is written to disk on the crashing thread, then the previous handler runs (the platform's, or another crash reporter's). The report leaves on the next launch, before the events, with its whole stack, the causes, the device and the user identified at the time. Ravenkeep groups Java and Kotlin traces by their root cause, ignoring line numbers and lambda counters, so the same bug groups alike across releases.

  • The store keeps at most 10 reports, 2.5 MiB and 3 of the same crash, each for 5 days: a crash loop never fills the device.
  • A crash before init is not captured (in a direct-boot-aware app, one before the first unlock).
  • captureException(throwable, tags) reports a handled exception (level error), at most 10 a minute.
  • For file names and line numbers in a release built with R8, keep them in your rules. R8 mapping files are not uploaded: an obfuscated release's stacks show R8's names.
proguard-rules.proR8 rules
-keepattributes SourceFile,LineNumberTable
Sync.ktKotlin
try {
    sync.run()
} catch (error: IOException) {
    Ravenkeep.captureException(error, mapOf("job" to "sync"))
}
How crashes are grouped

Delivery

  • On disk. Events wait in a queue on disk, at most 1,000 events, 2 MiB and 5 days (the oldest go first), and leave in batches of up to 100: when flushAt is reached, flushIntervalMs after the first one, when the app goes to the background, at launch and on flush().
  • Exactly once. Every event and crash gets its messageId when queued, and its file is deleted only once Ravenkeep answered. A retry after a lost answer resends the same id, and Ravenkeep stores it once.
  • Retries. Network errors, timeouts, 408, 429 and 5xx are retried with exponential backoff and jitter from 1 second up to 5 minutes, honouring Retry-After. A refused key or app (401, 403) and a redirect (a proxy, an http:// host: never followed) keep the queue and retry every 5 minutes. Items Ravenkeep refuses (a name that is not public, invalid properties) are dropped, not retried.
  • Threads. Disk and network never run on the caller's thread, and no method throws: the calls hand their work to the SDK's threads. On the caller's thread run only track, screen and captureException copying their arguments (properties past 16,384 values are dropped), reportInteraction() updating a few fields, init reading the process name (on Android 8.1 and older, the in-memory /proc/self/cmdline), getSessionId(), which may wait up to 1 second for the SDK's thread (call it off the main thread), and isOptedOut(), which may wait as long right after init or while an optIn() is on its way through that thread, and otherwise answers at once (right after optOut(), true). The crash handler alone writes on its (crashing) thread.

Privacy and consent

optOut() stops everything and deletes the queue, the stored crashes, the visit, the identity and the anonymous id; the choice is stored (a marker file synced to the disk, so a damaged state file never turns the SDK back on), and the SDK stays off on later launches until optIn(). To ask for consent first, start opted out:

ShopApplication.ktKotlin
Ravenkeep.init(this, RavenkeepOptions(publishableKey = "rk_pub_lumen_xxxxxxxxxxxx", startOptedOut = true))

// When the person consents, and if they withdraw
Ravenkeep.optIn()
Ravenkeep.optOut()

optOut() applies at once: no call made after it sends anything, and isOptedOut() answers true at once. optIn() takes effect after the calls made before it, so an event tracked while opted out and still on its way is dropped, and an optOut() made after it before it took effect leaves the SDK off. After an opt-in the current screen is named again at its next resume or screen() call. The SDK keeps its files in the app's no_backup directory, out of Auto Backup: a restored device starts with a new anonymous id.

API reference

Every method is a static method of io.ravenkeep.android.Ravenkeep, may be called from any thread and never throws. Before init the calls do nothing, and most write a logcat line saying so (debug or not: there are no options yet). From Java, a null where a value is needed is ignored with a debug line (identify(null) answers invalid_input). Callbacks run on the SDK's own thread, except before init: then at once, on the caller's (not_initialized, or false for flush).

  • initUnit

    Starts the SDK with a Context and RavenkeepOptions; later calls are ignored.

  • identifyUnit

    Opens the user's widget session (userId, userHash, email, name, account); the callback gets its own call's IdentifyResult, or superseded.

  • trackUnit

    Queues a custom event with optional properties; the publishable key accepts public event names only.

  • screenUnit

    A screen view ($screen), ending the previous view's engaged time.

  • captureExceptionUnit

    Reports a handled exception (level error) with optional tags, at most 10 a minute.

  • reportInteractionUnit

    The person is there although the SDK saw no input: a player with sound, a dialog.

  • flushUnit

    Sends what is queued now; the callback gets true when nothing is left queued.

  • resetUnit

    Sign-out: ends the current view, then forgets the identity, its token, the anonymous id and the visit.

  • optOutUnit

    Stops everything and deletes what the SDK kept; stored across launches.

  • optInUnit

    Turns the SDK back on after optOut or startOptedOut, with a new anonymous id, after the calls made before it.

  • isOptedOutBoolean

    Whether the SDK is opted out; right after init, or while an optIn is on its way, it may wait up to 1 second.

  • getSessionIdString?

    The current visit's id, never extending it; may wait up to 1 second, so call it off the main thread.

The Compose module adds TrackScreen(name, properties) (package io.ravenkeep.android.compose). Every request carries the publishable key in the apikey header, X-Ravenkeep-SDK: android/<version> and X-Ravenkeep-App: android:<application id>, never an Origin; batches of an identified user add the session token in X-Ravenkeep-Widget-Token.

MethodRequest to https://api.ravenkeep.io
identifyPOST /v1/widget/session
track, screen, engaged timePOST /v1/events (batched)
captureException, captured crashesPOST /v1/crashes (batched)

Errors and troubleshooting

Errors reach your code through identify's callback only: result.error is a RavenkeepError with a stable code and the HTTP status (0 when no answer came). Delivery handles its own failures (above); with debug: true, logcat says what happened.

CodeStatusMeaning
not_initialized0identify before Ravenkeep.init.
invalid_input0The user id is empty, over 255 characters or holds control characters.
opted_out0The SDK is opted out: nothing is sent.
not_identified0Another user was identified, or reset() was called, before this session opened.
superseded0A later identify of the same user with other input was made while this one was in flight; its request is aborted and the later call goes on.
network_error0The request never reached Ravenkeep.
timeout0No connection within 10 seconds, 10 seconds without data while reading the answer, or no answer within 60 seconds in all (the request is then aborted).
invalid_response2xxThe answer had no session token (a proxy or captive portal page in between).
invalid_api_key401The key is unknown or was rotated.
identity_verification_failed401The userHash is missing or does not match the user id.
app_not_allowed403The application id is not listed in the project's Apps.
rate_limited429More than 20 requests per second from one address, or 200 for the whole project, in a route family.
http_<status>the statusAnother answer without an error code.
  • Nothing arrives. Check that the application id, exactly as built, is in the project's Apps, and turn on debug to see the answers in logcat.
  • Crashes and events show as anonymous. They were sent before the first identify succeeded, after reset(), or on a launch that has not called identify yet once the stored token expired (12 hours at most).
  • Custom events are dropped. The name is not one of the project's public event names: send it from your backend with the Node SDK.

Compatibility

  • Android 5.0 (API 21) and newer, from Kotlin or Java. The libraries are Java 17 bytecode, which the Android Gradle Plugin 8 and newer reads.
  • Footprint of 0.1.0: the library's AAR is 158,876 bytes, and it adds about 91 KB of dex to an app built with R8, with its whole API kept. The Compose module's AAR is 5,515 bytes.
  • Sending needs Android 7.1.1 or newer: older versions do not trust the root of the API's certificate (ISRG Root X1), so the SDK sends nothing from them. Your app still builds and runs there.
  • Semantic versioning: patches fix, minors add, majors change a contract. Releases are tagged sdk-android-vX.Y.Z; before 1.0 a minor release may change the API. 0.1.0 is the first.
  • Every file of a release is signed with the OpenPGP key CAFCD1AE0ED41815B6DEC80B95B17205C387B64D (RSA 4096, on keyserver.ubuntu.com), for Gradle's dependency verification.
Continue with the Node SDK