---
title: Kotlin (Android)
description: "This article explains how to perform a full integration of an Android app with *Releva*, using the Releva Android SDK. ### Requirements - Minimum SDK: API 24 (Android 7.0); target SDK 36 - Kotlin 2.0 or later, Gradle 8.9 or…"
url: "https://releva.ai/documentation/integrations/kotlin-android/"
updated: "2026-09-22"
category: Integrations
---

# Kotlin (Android)

This article explains how to perform a full integration of an Android app with *Releva*, using the Releva Android SDK.

### Requirements

- Minimum SDK: API 24 (Android 7.0); target SDK 36
- Kotlin 2.0 or later, Gradle 8.9 or later
- Firebase Cloud Messaging, for push notifications

### Add the SDK to your project

The SDK is distributed through JitPack. Add the repository in `settings.gradle.kts`:

```
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }   // add this line
    }
}
```

Then add the dependency in your app module's `build.gradle.kts`:

```
dependencies {
    implementation("com.github.Releva-ai:sdk-kotlin:1.5.2")
}
```

### Initialize the Releva Client

Create the client once, in your `Application` class, and hold it there — every screen reads it from the same instance.

```
class MyApplication : Application() {

    lateinit var relevaClient: RelevaClient
        private set

    private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    override fun onCreate() {
        super.onCreate()

        relevaClient = RelevaClient(
            context = applicationContext,
            realm = "",                         // usually empty, provided by Releva
            accessToken = "<yourAccessToken>",  // from the Releva admin panel
            config = RelevaConfig.full()
        )

        applicationScope.launch {
            relevaClient.setDeviceId(getDeviceId())

            // Once the user is logged in:
            // relevaClient.setProfileId("<profileId>")

            relevaClient.enablePushEngagementTracking()
        }
    }

    private fun getDeviceId(): String =
        Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
}
```

Register the class in your `AndroidManifest.xml`:

```
<application
    android:name=".MyApplication"
    ... >
```

Almost every SDK call is a suspend function, so call them from a coroutine scope — `lifecycleScope` in an Activity or Fragment, `viewModelScope` in a ViewModel.

### Identify the visitor

The SDK identifies a user by the `profileId` you set. Send contact details and other profile attributes from your backend through the [Create / Update Profile API](https://releva.ai/documentation/profiles-2/profile-upsert/) instead.

```
suspend fun setProfileId(
    profileId: String,
    skipMergeWithPreviousProfileId: Boolean = false
)
```

On **login**, the default is what you want: the anonymous profile the user browsed under is merged into their account, so their pre-login behaviour carries over.

```
relevaClient.setProfileId(userId)
```

On **logout**, do the opposite — assign a fresh anonymous id and skip the merge, so the next person using the device does not inherit the previous user's profile. The push token has to be re-registered afterwards, because the merge that would normally carry it over was skipped:

```
val anonymousProfileId = UUID.randomUUID().toString()
relevaClient.setProfileId(anonymousProfileId, skipMergeWithPreviousProfileId = true)

FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
    CoroutineScope(Dispatchers.IO).launch {
        relevaClient.registerPushToken(DeviceType.ANDROID, token)
    }
}
```

Do not use `setProfileId("")` to log a user out — assign a fresh anonymous id as above.

### Push notifications

#### Add Firebase

Follow the standard Firebase Android setup, then add the dependencies and the notification permission Android 13 and later require:

```
dependencies {
    implementation("com.google.firebase:firebase-messaging:23.4.0")
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
```

```
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```

#### Extend the SDK messaging service

The SDK ships a Firebase messaging service that displays the notification, tracks engagement and routes the tap. Subclass it and fill in the four app-specific pieces:

```
class MyFirebaseMessagingService : RelevaFirebaseMessagingService() {

    override fun getMainActivityClass(): Class<*> = MainActivity::class.java

    override fun getNotificationIcon(): Int = R.drawable.ic_notification

    override fun getDefaultNotificationTitle(): String = "My App"

    override fun onPushTokenGenerated(token: String) {
        val relevaClient = (application as MyApplication).relevaClient
        CoroutineScope(Dispatchers.IO).launch {
            try {
                relevaClient.registerPushToken(DeviceType.ANDROID, token)
            } catch (e: Exception) {
                Log.e(TAG, "Error registering push token", e)
            }
        }
    }
}
```

Register it in your manifest:

```
<service
    android:name=".push.MyFirebaseMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
```

#### Handle the navigation

A notification can carry one of three targets: `screen` (a named screen in your app, with optional parameters), `url` (opened in the browser), or nothing at all (your main screen). The SDK decides which one applies and calls your handler for the screen case — implement `NavigationHandler` and map the names to your own navigation:

```
class AppNavigationHandler(private val navController: NavController) : NavigationHandler {

    override fun navigateToScreen(screenName: String, parameters: Bundle) {
        // screenName is the free-form value configured in the Releva dashboard
        when (screenName) {
            "cart" -> navController.navigate(R.id.cartFragment)
            "product_details" -> navController.navigate(R.id.productFragment, parameters)
            else -> navController.navigate(R.id.homeFragment)
        }
    }
}
```

Register it in your main Activity, and hand the SDK both the launching intent and any later ones:

```
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val navController = findNavController(R.id.nav_host_fragment)
        NavigationService.getInstance().setNavigationHandler(AppNavigationHandler(navController))

        // The app was opened from a notification
        NavigationService.getInstance().handleNotificationNavigation(this, intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // The app was already running
        NavigationService.getInstance().handleNotificationNavigation(this, intent)
    }
}
```

Miss the `onNewIntent` override and taps will work from a cold start but do nothing while the app is already open.

### Track what the visitor is doing

Screen views, product views, search, checkout and recommendations all go through one call: build a `PushRequest` with the fluent builder, then call `relevaClient.push(request)`. The builder methods chain in any order.

```
lifecycleScope.launch {
    try {
        val request = PushRequest()
            .url("myapp://product/details")
            .screenToken("<yourScreenToken>")
            .productView(ViewedProduct(productId = productId, custom = CustomFields.empty()))
            .pageCategories(listOf("electronics", "phones"))

        val response = relevaClient.push(request)

        if (response.hasRecommenders) {
            renderRecommendationsInYourUI(response.recommenders)
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error tracking product view", e)
    }
}
```

The SDK returns recommendation data; it renders none of it. Building the product cards is your app's job.

#### Two fields that are easy to get wrong

| Field | What it must be |
| --- | --- |
| `screenToken` | A token from the Pages section of your Releva admin, not a name you invent. `"abc123def456"` is a token; `"product_detail"` is not, and a screen sent with one records no page view and returns no banners or stories. Use `null` until you have configured the screen in Releva. |
| `url` | A full URL, with a scheme — either your own (`myapp://product/details`) or `https://`. A bare path such as `/product` is rejected by the backend. `null` is fine if you do not want to record one. |

The main builder methods:

| Method | Purpose |
| --- | --- |
| `url(pageUrl)` | Full page URL (custom scheme or `https://`) |
| `screenToken(token)` | The screen's token from the Releva admin |
| `productView(viewedProduct)` | The product being viewed |
| `pageProductIds(ids)` | Product ids visible on a listing or search screen |
| `pageCategories(categories)` | Categories visible on the screen |
| `pageQuery(query)` | The search query |
| `pageFilter(filter)` | The filter applied to the list |
| `locale(locale)` / `currency(currency)` | Locale and currency context |
| `customEvents(events)` | Custom events to send with this request |
| `cart(cart)` | The cart, used for checkout success |

#### Cart and wishlist

Both are stateful: set them on the client and the SDK stores them and syncs the changes. The cart clears itself after a checkout.

```
// Keep the cart in step with the app
relevaClient.setCart(Cart.active(cartProducts))

relevaClient.setWishlist(productIds.map { WishlistProduct(id = it) })

// Checkout success - attach the paid cart to a request
val orderedCart = Cart.paid(cartProducts, orderId = orderId)
relevaClient.push(
    PushRequest()
        .url("myapp://checkout/success")
        .cart(orderedCart)
)
```

#### Custom events

For a single event, call `trackCustomEvent` directly. To send events together with a screen view, attach them to a `PushRequest` with `.customEvents(...)`.

```
val event = CustomEvent(
    action = "product_added_to_cart",
    products = listOf(CustomEventProduct(id = "product-123", quantity = 1.0)),
    tags = listOf("promo"),
    custom = CustomFields.empty()
)

relevaClient.trackCustomEvent(event)
```

### On-screen content

Banners, stories, NPS surveys and the App Inbox are configured in the Releva dashboard and delivered in the response to a push. Each needs a little wiring, because the SDK never navigates on your behalf.

#### Banners

Popup, bar, flyout and static banners are rendered by the SDK from the design you build in Releva. The `onLinkTap` callback is **required** — the SDK hands you the tapped URL and your app decides what to do with it.

```
val bannerManager = BannerDisplayManager(
    client = relevaClient,
    targetSelector = "#home-content",
    onLinkTap = { url ->
        val uri = Uri.parse(url)
        if (uri.scheme == "myapp") {
            when (uri.host) {
                "cart" -> navController.navigate(R.id.cartFragment)
                "product" -> { /* navigate with the product id */ }
            }
        } else {
            startActivity(Intent(Intent.ACTION_VIEW, uri))
        }
    }
)
bannerManager.attach(fragment)   // or attach(activity)
```

#### Stories

Set the link handler **before** attaching, or taps inside a slide go nowhere:

```
StoryDisplayManager.setClient(relevaClient)
StoryDisplayManager.setOnLinkTap { url ->
    val uri = Uri.parse(url)
    if (uri.scheme in listOf("http", "https")) {
        startActivity(Intent(Intent.ACTION_VIEW, uri))
    }
}
StoryDisplayManager.attach(activity)
```

Stories also need their triggers driven. `StoryManagerService` evaluates them, but the SDK does not construct one for you — initialise it from the stories in a push response, and call the matching method from wherever your app already knows about the change:

```
val storyManager = StoryManagerService()
storyManager.initialize(relevaResponse.stories)

storyManager.onCartChanged()
storyManager.onWishlistChanged()
storyManager.onScrollPercentageReached(percentage)

storyManager.dispose()   // cancels pending delaySeconds timers
```

`immediately` and `delaySeconds` triggers fire on their own once `initialize()` is called. The other three wait for those calls. Leave-intent triggers are a web-only feature and never fire on mobile.

#### NPS surveys

Set the callbacks first, then attach from your Activity's `onCreate`. The SDK renders the whole survey — score selection, follow-up question and thank-you screen — so you build no UI:

```
NpsDisplayManager.setOnSubmit { token, score, comment ->
    relevaClient.submitNpsResponse(token, score, comment)
}
NpsDisplayManager.setOnSkip {
    // optional: analytics
}
NpsDisplayManager.attach(this)   // requires a FragmentActivity
```

Surveys can also be triggered or cancelled by your own events with `relevaClient.trackEvent("checkout_complete")`.

#### App Inbox

The inbox exposes its state as a `StateFlow`, so collect it lifecycle-aware and render from it:

```
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        relevaClient.inbox.state.collect { state ->
            renderMessages(state.messages)
            updateBadge(state.unreadCount)
            loadMoreButton.isVisible = state.hasMore
            progressBar.isVisible = state.isLoading
        }
    }
}
```

Call `refresh()` to fetch the first page, `loadMore()` for the next, and `refreshIfStale()` on resume — it only goes to the network if the cache is more than five minutes old. `markAsRead`, `markAllAsRead` and `deleteMessage` update the UI immediately and roll back if the server rejects them.

### Configuration options

Start from a preset, or build your own:

```
RelevaConfig.full()          // everything on
RelevaConfig.trackingOnly()  // no push notifications
RelevaConfig.pushOnly()      // no tracking

RelevaConfig(
    enableTracking = true,
    enableScreenTracking = true,
    enableInAppMessaging = false,
    enablePushNotifications = true,
    enableAnalytics = true
)
```

### Backend calls you still need

The app covers what the user does. Your catalogue, your orders and your consent changes still have to reach Releva from your backend:

1. Call the [Product Create / Update API](https://releva.ai/documentation/catalog/product-update/) whenever your catalogue changes.
2. Implement the [Periodical Product Sync](https://releva.ai/documentation/catalog/periodical-product-sync/) as a fallback, so a missed update cannot leave Releva stale.
3. Call the [Cart Paid API](https://releva.ai/documentation/orders-refunds/cart-paid/) when a customer places an order, and the [Refund API](https://releva.ai/documentation/orders-refunds/order-refund/) when one is refunded.
4. Call the [Subscribe](https://releva.ai/documentation/consent-status/subscribe/) and [Unsubscribe](https://releva.ai/documentation/consent-status/unsubscribe/) APIs when a user changes their marketing preferences.
5. Call the [Profile Register API](https://releva.ai/documentation/profiles-2/profile-register/) when a new user registers.

### All set!

Your app is integrated. Explore the [API Specification](https://releva.ai/documentation/developer-documentation/server-side-integration/) for the server-side calls that let you send custom product and profile data, which can then be used in personalization.
