Skip to content

Built, not yet published · Gradle

Retainza Android SDK

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

View as Markdownllms.txt

Build this with AI

Integrate the Retainza Android SDK into this Kotlin app: add the dependency the way the guide describes (it is not on Maven Central yet), start it with my public key, identify the user on login, track the key events, wire FCM push and show in-app messages.

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

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

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

Step 1: Local install, until the release
Terminal
./scripts/release-sdk-android.sh --local
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:

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")

2. Start the SDK

In Application.onCreate:

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 },
            ),
        )
    }
}
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

OptionDefaultWhy you would change it
baseUrlhttps://api.retainza.com/v1Self-hosted backend. Must end in /v1.
storageSharedPreferences (set by Retainza.start)Supply your own, or InMemoryStorage() in tests. A RetainzaClient built directly with no storage is memory-only.
requireConsentfalseGDPR/CCPA — see Consent.
sessionTimeoutMs30 minHow long in the background ends a session.
maxQueuedEvents100Offline queue capacity.
debugfalseLogs every request. Off in production — when off the SDK writes nothing to Logcat.
loggernullRoute SDK warnings into your crash reporter. Receives WARN/ERROR even when debug is off.
defaultAttributesnullA lambda of extra attributes merged into every identify.
allowRuntimeBaseUrlsame as debugLets 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.

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

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

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

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:

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.

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.

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.

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.

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

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
            }
        }
    },
)

9. Troubleshooting

SymptomCause
Throws RetainzaException.ConfigurationError at startYou passed an sk_ key. Only pk_ keys belong in an app.
track throws RetainzaException.MissingUserIdNo userId passed, userProvider returned null, and identify has not run.
setBaseUrl throws ConfigurationErrorallowRuntimeBaseUrl (or debug) is off — by design in release builds.
fetchAndRegisterToken warns and returns nullfirebase-messaging is not on the classpath.
No notification appears on API 33+POST_NOTIFICATIONS not declared, or not granted.
Pushes arrive, opens are zerohandleNotificationOpened not wired into both onCreate and onNewIntent.
Token stops working after a reinstallonNewToken not forwarded.
In-app messages never appearRetainzaInAppHost not mounted, or the current screen is blocked.
In-app messages look unstyledHost mounted outside MaterialTheme.
Campaign images do not showExpected — see In-app messages.
Tapping a message goes nowhereNo onNavigate handler. Check Logcat with debug = true.
Push delivered counts look lowExpected: background pushes never reach onMessageReceived — see Push.
Nothing sends at allrequireConsent 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).

Developer docsDashboard

Popular