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 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:
- Call the Product Create / Update API whenever your catalogue changes.
- Implement the Periodical Product Sync as a fallback, so a missed update cannot leave Releva stale.
- Call the Cart Paid API when a customer places an order, and the Refund API when one is refunded.
- Call the Subscribe and Unsubscribe APIs when a user changes their marketing preferences.
- Call the Profile Register API when a new user registers.
All set! #
Your app is integrated. Explore the API Specification for the server-side calls that let you send custom product and profile data, which can then be used in personalization.