<!-- Source: https://developer.retainza.com/android/ · Markdown: https://developer.retainza.com/android.md -->

Built, not yet published · Gradle

# Retainza Android SDK

Native Kotlin client: identify users, track events, receive FCM push and show in-app messages.

<!-- section: overview -->

## Overview

`com.retainza:retainza-android` is the native Kotlin client for Retainza. Identify users, track events, receive push, and display in-app messages. It requires **Android 7.0 (API 24)+** and **JDK 17**.

The behaviour is shared with the React Native SDK — attribute rules, the offline queue, sessions and consent are identical by design, and the [React Native guide](https://developer.retainza.com/react-native/) is the longer prose version. This page covers what is different on Android.

> **Warning:** **Not on Maven Central yet.** Version `1.0.0` is built but has not been released — the Sonatype namespace setup is still outstanding. Until it is, install it into your local Maven repository as described in [Install](https://developer.retainza.com/android/#install).

<!-- section: install -->

## 1. Install

```kotlin title="app/build.gradle.kts"
implementation("com.retainza:retainza-android:1.0.0")
```

Once released, it resolves from Maven Central — no extra repository declaration needed.

> **Warning:** **Not on Maven Central yet.** `1.0.0` has not been released. Until it is, publish it to your local Maven repository from the Retainza SDK repo with `./scripts/release-sdk-android.sh --local` (it publishes to `~/.m2`), and add `mavenLocal()` to the repositories in `settings.gradle.kts` — and remove it again before committing.

**Step 1:** Local install, until the release

```bash
./scripts/release-sdk-android.sh --local
```

```kotlin title="settings.gradle.kts"
dependencyResolutionManagement {
    repositories {
        mavenLocal() // temporary — remove before committing
        google()
        mavenCentral()
    }
}
```

### What it pulls in

The published artifact brings **three** dependencies: coroutines, `androidx.core`, and the Kotlin stdlib. Firebase and Compose are deliberately *not* among them — add them yourself only for the features that need them:

```kotlin title="app/build.gradle.kts"
// Only if you use Retainza push — you need this for your own
// FirebaseMessagingService anyway.
implementation("com.google.firebase:firebase-messaging:24.1.0")

// Only if you display in-app messages. Your app's Compose version wins.
implementation(platform("androidx.compose:compose-bom:2024.09.03"))
implementation("androidx.compose.material3:material3")
```

> **Note:** An app using Retainza purely for analytics should not inherit Firebase, the google-services plugin, and a `google-services.json` it has no use for. The SDK checks for these at runtime and degrades to a logged warning.

<!-- section: initialize -->

## 2. Start the SDK

In `Application.onCreate`:

```kotlin title="MyApp.kt"
import com.retainza.sdk.Retainza
import com.retainza.sdk.RetainzaConfig

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Retainza.start(
            this,
            RetainzaConfig(
                // Debug builds: pk_test_… (Sandbox). Release: pk_live_…
                // Set it per build variant with buildConfigField — see below.
                publicKey = "pk_test_…",
                userProvider = { AuthStore.userId },
            ),
        )
    }
}
```

> **Tip:** **The key decides the environment.** Set it per build variant so debug builds carry `pk_test_…` and release builds carry `pk_live_…`, then use `publicKey = BuildConfig.RETAINZA_KEY`. Your `google-services.json` must also belong to the Firebase project configured on **that environment's** FCM connection (Sandbox and Production are independent).

```kotlin title="app/build.gradle.kts"
android {
    buildTypes {
        debug   { buildConfigField("String", "RETAINZA_KEY", "\"pk_test_xxxxxxxxxxxx\"") }
        release { buildConfigField("String", "RETAINZA_KEY", "\"pk_live_xxxxxxxxxxxx\"") }
    }
}
```

`publicKey` **must** start with `pk_`. The client throws on a secret (`sk_`) key — those reach the campaign and send APIs, and anyone can unzip an APK.

`Retainza.start` fills in the two things that need a `Context`: a private SharedPreferences store, and the device/app facts (`Build.MODEL`, `Build.MANUFACTURER`, your `versionName`). `RetainzaClient` itself holds no Android types, which is why it is unit-testable with no Robolectric.

`userProvider` is called on every request, so the SDK always uses your current user. It is authoritative: the id cached by `identify` is only a fallback.

### Configuration worth knowing about

| Option | Default | Why you would change it |
| --- | --- | --- |
| `baseUrl` | https://api.retainza.com/v1 | Self-hosted backend. Must end in /v1. |
| `storage` | SharedPreferences (set by Retainza.start) | Supply your own, or InMemoryStorage() in tests. A RetainzaClient built directly with no storage is memory-only. |
| `requireConsent` | false | GDPR/CCPA — see Consent. |
| `sessionTimeoutMs` | 30 min | How long in the background ends a session. |
| `maxQueuedEvents` | 100 | Offline queue capacity. |
| `debug` | false | Logs every request. Off in production — when off the SDK writes nothing to Logcat. |
| `logger` | null | Route SDK warnings into your crash reporter. Receives WARN/ERROR even when debug is off. |
| `defaultAttributes` | null | A lambda of extra attributes merged into every identify. |
| `allowRuntimeBaseUrl` | same as debug | Lets setBaseUrl() override baseUrl at runtime (stored). setBaseUrl throws when this is off. |

The default store is a private SharedPreferences file, `com.retainza.sdk.storage`, holding `engage.queue.v1`, `engage.session.v1`, `engage.install.v1`, `engage.push.v1` and `engage.baseUrl.v1` — the same keys every Retainza SDK uses.

<!-- section: tracking -->

## 3. Identify & track

Every ingest call is a `suspend` function — call it from a coroutine.

```kotlin
// Full profile upsert. Auto attributes ride along on every call.
Retainza.client?.identify(
    "user-123",
    mapOf("plan" to "pro".attr, "seats" to 4.attr),
    RetainzaProfile(firstName = "Ada", email = "ada@example.com"),
)

// A patch — sends only what you pass.
Retainza.client?.setAttributes(mapOf("plan" to "enterprise".attr))

// An event.
Retainza.client?.track("order_placed", mapOf("total" to 42.5.attr))
```

The `.attr` extension builds an `AttrValue` from a `String`, `Int`, `Long`, `Double` or `Boolean`; use `AttrValue.Null` to clear a value. Numbers travel as `Double` — nothing nested.

Calls other than `identify` need a user id: pass `userId =`, have `userProvider` return one, or call `identify` first. Otherwise they throw `RetainzaException.MissingUserId`.

### Collections — one-to-many data

When a user can have several of something (challenges, badges, saved searches), that is a *collection*, not a pile of numbered attributes.

```kotlin
// Id only, when the list is identifiers and nothing else:
Retainza.client?.addToCollection("challenges", "lagos_10k")

// With fields, when a campaign has to FIND things in the list:
Retainza.client?.addToCollection(
    "challenges",
    CollectionItem("lagos_10k", mapOf("status" to "done".attr, "progress" to 100.attr)),
)

Retainza.client?.removeFromCollection("challenges", "lagos_10k")
Retainza.client?.setCollection("challenges", listOf("a".item, "b".item))  // replaces; empty list clears
```

The `id` is **yours** and is the merge key: writing the same id twice updates that item instead of appending, which is what makes a write replayed from the offline queue safe. Fields you omit are preserved.

Item fields are flat `AttrValue`s — same four types as attributes, no nesting. `updated_at` is stamped by the server. See the [main docs](https://developer.retainza.com/#concepts) for the caps and for the worked “challenge finished → send a message” example.

### Logout

> **Warning:** **On logout, call `reset()`.** Without it the next person on the device inherits the previous user's identity, queued events, session counters — and the previous user's **push notifications**, because nothing else takes a token off a profile.

```kotlin
Retainza.client?.reset()
```

`reset()` unregisters this device from the profile being left (best-effort: an offline logout still succeeds). It deliberately keeps the **install id**, which describes the device rather than the person — it is what tells the server a rotated FCM token is the same handset, so clearing it would make every sign-in look like a new phone.

Note that `userProvider` is yours: if it still returns the old id after logout, the SDK will use it.

### Attributes the SDK sets for you

`platform`, `sdk_version`, `app_version`, `language`, `country`, `tz`, `device_model`, `device_manufacturer`, `os_name`, `os_version`, `session_count`, `first_session_at`, `last_session_at`, `session_duration_total` (once you drive sessions — see [Sessions](https://developer.retainza.com/android/#sessions)), `push_permission` (once you call `syncPermission` — see [Push](https://developer.retainza.com/android/#push)).

Precedence is **SDK-collected → `config.defaultAttributes` → the values you pass at the call site**. Your value always wins.

> **Tip:** Event names are matched by exact string in segments — lowercase snake_case past-tense (`order_placed`) is what the docs and dashboard assume.

<!-- section: push -->

## 4. Push

### Manifest setup the SDK cannot do for you

A library manifest merges into every consuming app, so the SDK deliberately declares nothing — an app that does not use push must not inherit a notification permission, and unexplained permissions are a Play Store rejection reason.

```html title="AndroidManifest.xml"
<!-- Required on API 33+, or no notification is ever shown -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```

Declare your messaging service (below) inside `<application>`:

```html title="AndroidManifest.xml"
<service
    android:name=".MyMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
```

You also need `google-services.json` and the Google Services Gradle plugin, and your APNs key uploaded to Firebase if you also ship iOS.

### Code

Android delivers FCM callbacks to one service, and most apps already have their own — so the SDK does not ship one. Forward two calls from yours:

```kotlin title="MyMessagingService.kt"
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.retainza.sdk.push.RetainzaPush

class MyMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        RetainzaPush.handleNewToken(token)
    }

    override fun onMessageReceived(message: RemoteMessage) {
        if (RetainzaPush.handleMessageReceived(message.data)) return
        // …your own push handling
    }
}
```

`handleMessageReceived` returns `true` if the message was a Retainza one, so an app running a second push SDK can return early rather than letting both process the same payload.

> **Note:** `handleMessageReceived` only acks `delivered`; it never draws anything. Retainza sends FCM *notification* messages, so in the background Android shows them itself and `onMessageReceived` is not called at all — `delivered` is only reported for pushes that arrive while the app is in the foreground, where Android shows nothing unless you post the notification yourself. The tap still acks `opened` either way.

> **Warning:** Forwarding `onNewToken` is not optional: FCM rotates tokens on reinstall and restore-from-backup, and a rotated token Retainza never learns about is a device that silently stops receiving campaigns.

In the Activity a notification tap opens — **both callbacks**, because an already-running Activity gets the second one:

```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    RetainzaPush.handleNotificationOpened(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    RetainzaPush.handleNotificationOpened(intent)
}
```

Then register the token once you know who it belongs to — after login, not at launch, or it registers against nobody:

```kotlin
lifecycleScope.launch { RetainzaPush.fetchAndRegisterToken() }
```

Safe to repeat on every launch: an unchanged token registered less than 24 hours ago is not re-sent. Already have a token? `RetainzaPush.registerToken(token)`. Registration is never queued — if it fails offline, the next launch retries. `reset()` unregisters the device (`DELETE /v1/token`); `unregisterPush()` is public if you tear identity down your own way.

### Permission

The SDK never triggers the prompt — Android gives you one shot and when to spend it is a product decision. Request `POST_NOTIFICATIONS` yourself, then:

```kotlin
lifecycleScope.launch { RetainzaPush.syncPermission(this@MyActivity) }
```

Worth calling on every launch: permission can be revoked in Settings with no callback, and without a refresh Retainza keeps targeting an unreachable device.

> **Note:** Before API 33 there is no runtime permission, so the SDK reports `AUTHORIZED` — it cannot see a Settings-level block, and reporting denied would drop reachable devices out of every campaign.

<!-- section: inapp -->

## 5. In-app messages

One mount point, at the root of your Compose hierarchy, **inside** your theme:

```kotlin
import com.retainza.sdk.inapp.ui.RetainzaInAppHost

setContent {
    MyTheme {
        RetainzaInAppHost {
            AppNavHost()
        }
    }
}
```

That is the whole integration. It owns the poll loop, shows one message at a time, and sends the `shown` / `clicked` / `dismissed` acks every campaign's funnel is built from. Optional parameters: `client` (defaults to `Retainza.client`) and `intervalMs` (poll interval, default `15_000`).

It must be inside a `MaterialTheme` — the renderers read `MaterialTheme.colorScheme` for every colour a campaign did not specify.

> **Warning:** Apply it **once**. Messages arrive on a timer, not on navigation, so a per-screen mount would poll N times over and race to show the same campaign twice.

> **Note:** **Images are not rendered.** Compose has no built-in network image loader, and bundling Coil or Glide would force that dependency and its version on every consumer. The renderer reserves the correct aspect ratio so layout is stable, but the media slot draws a placeholder. Use a `selfhandled` campaign if a campaign genuinely needs imagery on Android. (iOS renders images natively via `AsyncImage`, which ships in the OS — this is the one place the two platforms differ.)

### Navigation

Campaigns route by *screen name*, not URL:

```kotlin
Retainza.client?.onNavigate { route ->
    navController.navigate(route.screen)
}
```

Without this a tap still acks correctly but goes nowhere. The SDK logs a warning naming the missing call (visible with `debug = true` or a `logger`).

### Not now, thanks

```kotlin
Retainza.client?.setBlockedScreenPrefixes(listOf("checkout", "video"))
Retainza.client?.setCurrentScreen("checkout/payment")
```

A blocked message stays **pending** rather than being consumed, so the campaign shows on the next poll once the user moves on.

### Rendering it yourself

For a `selfhandled` campaign the SDK draws nothing and hands you the payload. In exchange **you own the acks** — skipping them does not break rendering, it silently empties that campaign's funnel:

```kotlin
Retainza.client?.onSelfHandledInApp { message ->
    myPresenter.show(
        content = message.content,
        onShown = { message.markShown() },
        onTap = { message.markClicked(null) },
        onClose = { message.markDismissed() },
    )
}
```

A `selfhandled` message with no handler registered stays pending rather than disappearing — a half-finished integration should look like a campaign that has not shown yet, not one that vanished. A template this SDK version does not recognise is treated as `selfhandled` for the same reason.

<!-- section: offline -->

## 6. Offline queue

`track`, `identify`, `setAttributes` and the collection calls are queued when a call fails with a network error or a 5xx, and retried with exponential backoff (1s doubling to a 5-minute ceiling).

- **4xx is never queued.** A malformed body or a revoked key fails the same way forever, and queueing it would block every event behind it.
- **Ordering is preserved.** A flush stops at the first failure rather than skipping ahead.
- **Timestamps are stamped at call time**, so an event that waits an hour still records when it actually happened.
- **The oldest is dropped** past `maxQueuedEvents` (100).
- `registerPush` and the acks are **never** queued — a stale token is worse than a missing one, and a replayed ack lands in the wrong reporting window.

Inspect it with `queueSize()`; drain it manually with `flushQueue()`.

<!-- section: consent -->

## 7. Consent

Off by default, because turning it on silently would drop every existing integration's data.

```kotlin
RetainzaConfig(publicKey = "pk_…", requireConsent = true)
```

With it on **nothing** leaves the device — not even an identify — until:

```kotlin
Retainza.client?.setConsent(analytics = true, marketing = false)
```

> **Warning:** Pre-consent calls are **dropped, not queued**: replaying them afterwards is exactly what consent forbids.

`marketing` does not gate transport. It is stored as `consent_marketing` (and `analytics` as `consent_analytics`) so campaigns can target or exclude on it — someone can accept product analytics and refuse promotions.

`optOut()` is a harder switch: it stops everything and clears the queue. It is deliberately **not persisted** — an opt-out the SDK remembers is one your app cannot see, and your app owns the consent UI. Re-assert it each launch; `optIn()` lifts it.

<!-- section: sessions -->

## 8. Sessions

A session starts on foreground and ends after 30 minutes (`sessionTimeoutMs`) in the background; returning inside that window **resumes** the same session rather than inflating the count — so a user who checks a push and comes back is not counted twice.

Drive it from your process lifecycle:

```kotlin
ProcessLifecycleOwner.get().lifecycle.addObserver(
    LifecycleEventObserver { _, event ->
        scope.launch {
            when (event) {
                Lifecycle.Event.ON_START -> Retainza.client?.startSession()
                Lifecycle.Event.ON_STOP -> Retainza.client?.endSession()
                else -> Unit
            }
        }
    },
)
```

<!-- section: troubleshooting -->

## 9. Troubleshooting

| Symptom | Cause |
| --- | --- |
| Throws RetainzaException.ConfigurationError at start | You passed an sk_ key. Only pk_ keys belong in an app. |
| track throws RetainzaException.MissingUserId | No userId passed, userProvider returned null, and identify has not run. |
| setBaseUrl throws ConfigurationError | allowRuntimeBaseUrl (or debug) is off — by design in release builds. |
| fetchAndRegisterToken warns and returns null | firebase-messaging is not on the classpath. |
| No notification appears on API 33+ | POST_NOTIFICATIONS not declared, or not granted. |
| Pushes arrive, opens are zero | handleNotificationOpened not wired into both onCreate and onNewIntent. |
| Token stops working after a reinstall | onNewToken not forwarded. |
| In-app messages never appear | RetainzaInAppHost not mounted, or the current screen is blocked. |
| In-app messages look unstyled | Host mounted outside MaterialTheme. |
| Campaign images do not show | Expected — see In-app messages. |
| Tapping a message goes nowhere | No onNavigate handler. Check Logcat with debug = true. |
| Push delivered counts look low | Expected: background pushes never reach onMessageReceived — see Push. |
| Nothing sends at all | requireConsent is on and setConsent(analytics = true) was never called. |

Turn on `debug = true` in a dev build to log every request, poll and swallowed error. Check connectivity with `Retainza.client?.testConnection()` — a suspend call that hits `GET /health` and returns `ConnectionResult(ok, status)`.
