# Ravenkeep Android SDK reference

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

- **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.kts**

```kotlin
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.kt**

```kotlin
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.java**

```java
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:

| Option | Default | Meaning |
| --- | --- | --- |
| `publishableKey` | none | The project publishable key, `rk_pub_...`, trimmed. A secret key, or one with a space or a line break inside, is refused and never sent. |
| `host` | `https://api.ravenkeep.io` | API origin. Change it only for a self-hosted Ravenkeep. |
| `environment` | `production` | The crashes' environment: crash groups are kept per environment. |
| `release` | the app's `versionName` | The crashes' release, so a resolved crash regresses only in a newer one. |
| `captureCrashes` | `true` | Report uncaught exceptions, sent on the next launch. |
| `trackActivityScreens` | `true` | Name a screen after an activity's class when the app names none itself. |
| `startOptedOut` | `false` | Start opted out until `optIn()`, on a device where the SDK never ran (consent). |
| `debug` | `false` | Log what the SDK does to logcat (tag `Ravenkeep`); never a key, a token or a user hash. |
| `flushAt` | `20` | Send as soon as this many events are queued. |
| `flushIntervalMs` | `5000` | Send 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.kt**

```kotlin
// 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](https://ravenkeep.io/docs#identity)

## 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.kt**

```kotlin
// 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.kt**

```kotlin
// 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](https://ravenkeep.io/docs#events)

## 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`.

> **Two limits**
>
> The SDK sees input in your activities' windows only. Input in a dialog, a popup or a Compose `Dialog` does not reach it, typing on the on-screen keyboard does not either (the keyboard is another app's window), and Android cannot tell it that your own player is audible. Call `reportInteraction()` from those places (for long text input, from a `TextWatcher`), and while a player plays with sound, so their time counts.

**Player.kt**

```kotlin
// Every few seconds while your player plays unmuted
if (player.isPlaying && player.volume > 0f) Ravenkeep.reportInteraction()
```

[Sessions, time on screens and features](https://ravenkeep.io/docs#events)

## 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.pro**

```text
-keepattributes SourceFile,LineNumberTable
```

**Sync.kt**

```kotlin
try {
    sync.run()
} catch (error: IOException) {
    Ravenkeep.captureException(error, mapOf("job" to "sync"))
}
```

[How crashes are grouped](https://ravenkeep.io/docs#crashes)

## 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.kt**

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

| Method | Returns | Description |
| --- | --- | --- |
| `init` | `Unit` | Starts the SDK with a Context and RavenkeepOptions; later calls are ignored. |
| `identify` | `Unit` | Opens the user's widget session (userId, userHash, email, name, account); the callback gets its own call's IdentifyResult, or superseded. |
| `track` | `Unit` | Queues a custom event with optional properties; the publishable key accepts public event names only. |
| `screen` | `Unit` | A screen view ($screen), ending the previous view's engaged time. |
| `captureException` | `Unit` | Reports a handled exception (level error) with optional tags, at most 10 a minute. |
| `reportInteraction` | `Unit` | The person is there although the SDK saw no input: a player with sound, a dialog. |
| `flush` | `Unit` | Sends what is queued now; the callback gets true when nothing is left queued. |
| `reset` | `Unit` | Sign-out: ends the current view, then forgets the identity, its token, the anonymous id and the visit. |
| `optOut` | `Unit` | Stops everything and deletes what the SDK kept; stored across launches. |
| `optIn` | `Unit` | Turns the SDK back on after optOut or startOptedOut, with a new anonymous id, after the calls made before it. |
| `isOptedOut` | `Boolean` | Whether the SDK is opted out; right after init, or while an optIn is on its way, it may wait up to 1 second. |
| `getSessionId` | `String?` | 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`.

| Method | Request to https://api.ravenkeep.io |
| --- | --- |
| `identify` | `POST /v1/widget/session` |
| `track`, `screen`, engaged time | `POST /v1/events` (batched) |
| `captureException`, captured crashes | `POST /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.

| Code | Status | Meaning |
| --- | --- | --- |
| `not_initialized` | 0 | `identify` before `Ravenkeep.init`. |
| `invalid_input` | 0 | The user id is empty, over 255 characters or holds control characters. |
| `opted_out` | 0 | The SDK is opted out: nothing is sent. |
| `not_identified` | 0 | Another user was identified, or `reset()` was called, before this session opened. |
| `superseded` | 0 | A 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_error` | 0 | The request never reached Ravenkeep. |
| `timeout` | 0 | No 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_response` | 2xx | The answer had no session token (a proxy or captive portal page in between). |
| `invalid_api_key` | 401 | The key is unknown or was rotated. |
| `identity_verification_failed` | 401 | The `userHash` is missing or does not match the user id. |
| `app_not_allowed` | 403 | The application id is not listed in the project's Apps. |
| `rate_limited` | 429 | More than 20 requests per second from one address, or 200 for the whole project, in a route family. |
| `http_<status>` | the status | Another 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](https://ravenkeep.io/docs/node)

---

Markdown rendering of https://ravenkeep.io/docs/android. The other docs pages in Markdown: https://ravenkeep.io/docs/index.md, https://ravenkeep.io/docs/node.md, https://ravenkeep.io/docs/browser.md, https://ravenkeep.io/docs/ios.md, https://ravenkeep.io/docs/import.md.
