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 is the longer prose version. This page covers what is different on Android.
1. Install
implementation("com.retainza:retainza-android:1.0.0")Once released, it resolves from Maven Central — no extra repository declaration needed.
./scripts/release-sdk-android.sh --localdependencyResolutionManagement {
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:
// 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")2. Start the SDK
In Application.onCreate:
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 },
),
)
}
}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.
3. Identify & track
Every ingest call is a suspend function — call it from a coroutine.
// 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.
// 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 clearsThe 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 AttrValues — same four types as attributes, no nesting. updated_at is stamped by the server. See the main docs for the caps and for the worked “challenge finished → send a message” example.
Logout
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), push_permission (once you call syncPermission — see Push).
Precedence is SDK-collected → config.defaultAttributes → the values you pass at the call site. Your value always wins.
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.
<!-- 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>:
<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:
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.
In the Activity a notification tap opens — both callbacks, because an already-running Activity gets the second one:
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:
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:
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.
5. In-app messages
One mount point, at the root of your Compose hierarchy, inside your theme:
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.
Navigation
Campaigns route by screen name, not URL:
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
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:
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.
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). registerPushand 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().
7. Consent
Off by default, because turning it on silently would drop every existing integration's data.
RetainzaConfig(publicKey = "pk_…", requireConsent = true)With it on nothing leaves the device — not even an identify — until:
Retainza.client?.setConsent(analytics = true, marketing = false)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.
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:
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
}
}
},
)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).
