Android Integration Guide
The complete guide for adding the Revrag AI voice agent to an Android app. One floating button that follows your user across every screen, keeps its position, and keeps a live call running while they navigate.
Current version: 1.1.0 · minSdk 24 · JDK 17 · Kotlin
Whatever your app looks like — XML or Compose, one Activity or fifty — the integration is the same three calls. Section 5 shows the exact shape for your app; everything before it applies to everyone.
Get your API key from https://app.revrag.ai.
Table of contents
- Install — dependency and permissions
- Initialize — one call, in
Application - Mount — one call, and the SDK follows the user
- Decide where it appears — the visibility config
- Your app's shape — pick one, complete code
- Dialogs and bottom sheets · Position the button · Control it from your code · Events and analytics · A drop-in event logger
- How screens get their names
- Verify it works
- Troubleshooting
- Checklist
- Upgrading
1. Install
Dependency
// app/build.gradle.kts — mavenCentral() is already in every Android project
dependencies {
implementation("ai.revrag:embed-android:1.1.0")
}No Compose toolchain is required. If your app is pure XML you do not need the Compose compiler plugin, buildFeatures.compose, or any Compose dependency — the SDK brings its own UI.
Permissions — do not skip this
The SDK ships no permissions of its own. Nothing is merged into your manifest, by design: an SDK should not silently add a microphone permission to your app. You declare them:
<!-- app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />Without RECORD_AUDIO the widget appears and the call fails to start. The SDK requests the runtime permission itself when the user first starts a call — you do not need to write a permission flow.
Requirements
minSdk | 24 (Android 7.0) |
| JDK | 17 |
| Kotlin | 2.0 or newer — the SDK is compiled with 2.0.21 and its classes carry Kotlin metadata 2.0, which a 1.9.x compiler refuses to read (section 9) |
| Activity type | ComponentActivity or any subclass — AppCompatActivity qualifies |
| API key | from Revrag, per environment |
What it pulls in. LiveKit (WebRTC), Lottie, Coil and Kotlin Coroutines arrive transitively. Ktor is shaded into ai.revrag.shaded.ktor.* and does not appear in your dependency tree, so it cannot clash with your own Ktor version.
Your API key and environment are a pair. A widget is provisioned per key per environment. A key from the dev backend returns no widget on production and the button will never appear — with no error, because "this key has no widget here" is a valid answer. If the button never shows, section 9 starts here.
2. Initialize
Once, in Application.onCreate — not in an Activity. Initializing in an Activity means screens shown before it can never display the agent.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// Third argument is embedUrl — omit it for production, or pass your
// environment's URL. It must match the environment your key belongs to.
EmbedSDK.initialize(this, "YOUR_API_KEY") { result ->
if (!result.success) Log.e("Embed", "init failed: ${result.error}")
}
}
}Register it in the manifest:
<application android:name=".MyApp" ... >Identify your user
EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123"))3. Mount
EmbedProvider.attachOverlay is the only integration call, and it is safe from every Activity's onResume:
- the first call mounts the widget;
- a call from a new Activity moves the same widget into that window — position, expanded card and live call intact;
- a repeat call in the same window just updates the screen name.
The SDK registers its own Activity observer, so it moves the widget and names screens on its own. The one thing it deliberately will not do is the first mount — mounting carries your appUserId and visibilityConfig, and doing it for you would use neither.
// In MyApp.onCreate(), alongside initialize()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
EmbedProvider.attachOverlay(
activity = activity as? ComponentActivity ?: return,
appUserId = "user-123",
visibilityConfig = EMBED_VISIBILITY // section 4
)
}
// all other overrides empty
})Attaching from every resume is simply the cheapest way to say "whichever Activity is first" — the second call onward costs nothing.
Do not detach in onPause or onDestroy. The incoming Activity claims the widget before the outgoing one dies, and the SDK owns the hand-off. Teardown code is the most common cause of a widget that vanishes mid-flow.
attachOverlay must run after setContentView(). onResume always satisfies this.
Full parameter list
EmbedProvider.attachOverlay(
activity = this, // ComponentActivity — required
appUserId = "user-123", // your stable user id
visibilityConfig = EMBED_VISIBILITY, // where the widget may appear
navController = null, // pass it if this Activity has one
currentScreen = null, // an explicit name, if you want one
accentColor = 0xFF6C63FF.toInt(), // @ColorInt
chatPanelUrl = null // your own chat page, optional
)All parameters except activity have defaults. Use named arguments — the list grows over time and positional calls are how integrations break on upgrade.
4. Decide where it appears
You do not name your screens; you decide about them. Names come from your Activity or destination automatically (section 7).
val EMBED_VISIBILITY = EmbedButtonVisibilityConfig(
// Where the widget MAY appear. Anywhere else it is hidden — and a live call
// ends, because leaving the allowlist is leaving the agent.
allowedScreens = listOf("LoginActivity", "Home", "Plans"),
// Where it must NOT appear. Use this rather than "skip the attach there":
// it also ends an active call, which skipping does not.
excludedScreens = listOf("SplashActivity"),
groups = listOf(
EmbedButtonGroupConfig(
id = "main_tabs",
screens = listOf("Home", "Plans"),
// CONTINUOUS = one journey: the widget stays up across these screens
// and a voice call SURVIVES every move inside the group.
continuity = EmbedButtonContinuity.CONTINUOUS,
delayMs = 500L, // entrance delay
delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY
)
)
)What this buys you, with no further code
| User does | Widget does |
|---|---|
| Moves between screens in one CONTINUOUS group | Stays visible — no blink, no re-delay; a live call keeps running |
| Crosses into another group | One clean entrance with that group's delayMs; the previous call ends by design |
Opens a screen not in allowedScreens | Hides, and an active call ends |
Opens a screen in excludedScreens | Hides, and an active call ends |
| Rotates, or the system recreates an Activity | Reappears immediately — delays are never replayed |
| Backgrounds and returns | Same state, same position |
All options
EmbedButtonVisibilityConfig
| Field | Default | Meaning |
|---|---|---|
allowedScreens | emptyList() | Screens where the widget may appear. Empty means everywhere. |
excludedScreens | emptyList() | Screens where it never appears. Wins over allowedScreens. |
showDelay | 0L | Default entrance delay in ms, when a group does not set one. |
groups | emptyList() | Journeys — see below. |
defaultInset | SDK default | Starting position of the button, in dp from each edge (section 6B). |
endCallWhenHiddenByVisibility | true | End a live call when the widget hides. Leave on unless you know why not. |
EmbedButtonGroupConfig
| Field | Default | Meaning |
|---|---|---|
id | — | Any stable string. |
screens | — | The screens in this journey. |
continuity | PER_SCREEN | CONTINUOUS keeps the widget and the call alive across the group. |
inset | null | Position override for this group's screens (section 6B). |
delayMs | 0L | Entrance delay for this group. |
delayPolicy | PER_SCREEN | PER_SCREEN, ONCE_PER_GROUP_ENTRY, or ONCE_PER_APP_SESSION. |
An allowedScreens entry that no screen ever reports does not crash — it silently hides the widget. Section 8 shows how to see the name the SDK actually has.
5. Your app's shape
Find your app below. Each is complete and copy-pasteable.
| Your app | Go to |
|---|---|
| XML, one Activity per screen | 5A |
| XML, one Activity hosting fragments / tabs | 5B |
| XML, several Activities that each host several screens | 5C |
| Jetpack Compose, single Activity + NavController | 5D |
| Jetpack Compose, several Activities | 5E |
| Bottom tabs with one NavHost per tab | 5F |
5A — XML, one Activity per screen
The classic case. Your Activities need zero Embed code; the whole integration is one file.
// MyApp.kt — the entire integration
package com.example.bank
import ai.revrag.embed.android.*
import android.app.Activity
import android.app.Application
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// 1) Initialize once, before any Activity.
EmbedSDK.initialize(this, "YOUR_API_KEY") { result ->
if (!result.success) Log.e("Embed", "init failed: ${result.error}")
}
EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123"))
// 2) Mount. The SDK follows the user from here on and names every
// screen after its Activity class.
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
EmbedProvider.attachOverlay(
activity = activity as? ComponentActivity ?: return,
appUserId = "user-123",
visibilityConfig = EMBED_VISIBILITY
)
}
override fun onActivityCreated(a: Activity, b: Bundle?) = Unit
override fun onActivityStarted(a: Activity) = Unit
override fun onActivityPaused(a: Activity) = Unit
override fun onActivityStopped(a: Activity) = Unit
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit
override fun onActivityDestroyed(a: Activity) = Unit
})
}
companion object {
// 3) Screen names ARE your Activity class names.
val EMBED_VISIBILITY = EmbedButtonVisibilityConfig(
allowedScreens = listOf(
"LoginActivity", "HomeActivity", "PlansActivity"
),
excludedScreens = listOf("SplashActivity"),
groups = listOf(
EmbedButtonGroupConfig(
id = "main",
screens = listOf("HomeActivity", "PlansActivity"),
continuity = EmbedButtonContinuity.CONTINUOUS,
delayMs = 500L,
delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY
)
)
)
}
}Your Activities stay exactly what they are:
class HomeActivity : AppCompatActivity() { // zero Embed code
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
}
}5B — XML, one Activity, many screens
One Activity swapping fragments or nav-graph destinations. Hand the SDK your NavController once and each destination names itself from its android:label.
class MainActivity : AppCompatActivity() {
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// FragmentContainerView-safe lookup — findNavController() from onCreate
// is the classic crash.
val navHost = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHost.navController
// Re-attach on each destination change: same window, so this is just a
// screen update. No name is passed — the controller is authoritative.
navController.addOnDestinationChangedListener { _, _, _ ->
EmbedProvider.attachOverlay(
activity = this,
appUserId = "user-123",
visibilityConfig = MyApp.EMBED_VISIBILITY,
navController = navController
)
}
}
}Your nav graph supplies the names:
<fragment android:id="@+id/homeFragment" android:label="Home" />
<fragment android:id="@+id/plansFragment" android:label="Plans" />
<fragment android:id="@+id/accountFragment" android:label="Account" />allowedScreens = listOf("Home", "Plans") // Account omitted → hidden thereNo NavController? If you swap fragments manually, tell the SDK yourself:
EmbedProvider.attachOverlay(
activity = this,
appUserId = "user-123",
visibilityConfig = MyApp.EMBED_VISIBILITY,
currentScreen = "Home"
)Naming even one screen by hand takes ownership: automatic fragment naming stands down for the whole session. If you start naming, name them all.
5C — XML, several Activities each with several screens
Combine 5A and 5B: mount from Application for the Activities that are whole screens, and hand over the NavController from the Activity that has one.
// MyApp.kt
object EmbedIntegration {
fun attach(activity: Activity) {
val host = activity as? ComponentActivity ?: return
EmbedProvider.attachOverlay(
activity = host,
appUserId = "user-123",
visibilityConfig = EMBED_VISIBILITY,
// Only the tabbed Activity has one. Everything else passes null,
// which hands naming back to the SDK.
navController = (activity as? MainActivity)?.embedNavController,
currentScreen = null
)
}
val EMBED_VISIBILITY = EmbedButtonVisibilityConfig(
allowedScreens = listOf(
"LoginActivity", "VerifyPanActivity", // Activity class names
"Home", "Plans" // nav-graph labels
),
excludedScreens = listOf("SplashActivity"),
groups = listOf(
EmbedButtonGroupConfig(
id = "onboarding",
screens = listOf("LoginActivity", "VerifyPanActivity"),
continuity = EmbedButtonContinuity.CONTINUOUS,
delayMs = 500L,
delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY
),
EmbedButtonGroupConfig(
id = "main_tabs",
screens = listOf("Home", "Plans"),
continuity = EmbedButtonContinuity.CONTINUOUS,
delayMs = 500L,
delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY
)
)
)
}// MyApp.onCreate — mount from every resume
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) = EmbedIntegration.attach(activity)
/* other overrides empty */
})// MainActivity — expose the controller and re-attach on destination change
class MainActivity : AppCompatActivity() {
private lateinit var navController: NavController
val embedNavController: NavController?
get() = if (::navController.isInitialized) navController else null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
navController = (supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment).navController
navController.addOnDestinationChangedListener { _, _, _ ->
EmbedIntegration.attach(this)
}
}
}Naming ownership follows whoever holds the widget: it moves to the controller on the way into the tabs, and back to the SDK on the way out. Working example: examples/android-xml in the SDK repository.
5D — Jetpack Compose, single Activity
Wrap your content in EmbedProviderComposable and pass your NavController.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
val backStack by navController.currentBackStackEntryAsState()
EmbedProviderComposable(
currentScreen = backStack?.destination?.route ?: "Home",
appUserId = "user-123",
visibilityConfig = EMBED_VISIBILITY,
navController = navController
) {
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("plans") { PlansScreen() }
}
}
}
}
}Screen names are your routes ("home", "plans"), so spell allowedScreens the same way:
allowedScreens = listOf("home", "plans")No NavController? Drive it from your own state — any string works:
var screen by remember { mutableStateOf("home") }
EmbedProviderComposable(currentScreen = screen, ... ) { /* content */ }5E — Jetpack Compose, several Activities
Use the XML mount (EmbedProvider.attachOverlay) from Application, exactly as in 5A — it works for Compose Activities too, and one mount serves all of them. Then, inside any Activity that has its own NavController, hand it over as in 5C.
// Application — mount once, SDK follows
override fun onActivityResumed(activity: Activity) {
EmbedProvider.attachOverlay(
activity = activity as? ComponentActivity ?: return,
appUserId = "user-123",
visibilityConfig = EMBED_VISIBILITY,
navController = (activity as? MainActivity)?.embedNavController
)
}Do not also wrap that Activity's content in EmbedProviderComposable — that would mount a second widget. Pick one mechanism per app.
5F — Bottom tabs with one NavHost per tab
If each tab owns its own NavController, pass the active one. Re-attach when the tab changes as well as when a destination inside it changes:
// whenever the selected tab OR its destination changes
EmbedProvider.attachOverlay(
activity = this,
appUserId = "user-123",
visibilityConfig = EMBED_VISIBILITY,
navController = controllerForSelectedTab
)Nested graphs need nothing extra — a nested destination reports its own label/route.
6. Dialogs and bottom sheets
A dialog gets its own window, which always paints above the Activity's — so an untouched widget would be covered by it. Lift it in for as long as the dialog shows:
// e.g. in DialogFragment.onStart() / BottomSheetDialogFragment.onStart()
EmbedProvider.attachOverlay(dialog!!, requireActivity())The widget returns to the Activity automatically on every dismissal path — you do not write teardown. For a raw Window:
EmbedProvider.attachOverlay(window, activity)6B. Position the button
The widget is draggable, but you choose where it starts. Insets are in dp from each edge:
EmbedButtonVisibilityConfig(
defaultInset = EmbedButtonInset(right = 24, bottom = 80),
groups = listOf(
EmbedButtonGroupConfig(
id = "checkout",
screens = listOf("Cart", "Payment"),
// lift it above this flow's sticky "Pay" bar
inset = EmbedButtonInset(right = 24, bottom = 160)
)
)
)EmbedButtonInset(right, bottom, left, top) — a group's inset overrides defaultInset for its screens. Use it wherever a bottom bar, FAB or sticky CTA would otherwise sit under the widget.
6C. Control it from your own code
The widget drives itself, but everything is available programmatically.
Start and end calls
EmbedSDK.startCall(activity) // requests RECORD_AUDIO if needed
EmbedSDK.endCall()
EmbedSDK.isCallActive() // Boolean
// with a completion callback
EmbedSDK.startCall(activity) { started ->
if (!started) { /* could not start — the reason is already reported */ }
}Who owns the UI — agentTriggerMode
A call can run in one of two modes, and this is the host's decision, not the backend's — whether Revrag may draw its own screen over your app is your integration's call, so it lives in your code:
EmbedSDK.startCall(activity, AgentTriggerMode.CO_PILOT)
EmbedSDK.startCall(activity, AgentTriggerMode.WORKFLOW) { started -> }| Mode | Who draws the UI |
|---|---|
CO_PILOT | Your app. The agent works inside your own screens and dialogs; the SDK draws nothing but its button. The natural state is the expanded button with your app visible. |
WORKFLOW | Revrag. The agent drives a panel the SDK renders — the avatar screen with its Voice / Chat / Avatar tabs. |
Three things worth knowing:
- It is passed at
startCallonly — never atinitialize. There is no app-level default to declare. - A call the USER starts by tapping the button carries no mode, because there is no host frame to supply one, so it takes the default below. Only calls your code starts can carry this value. If you need every call in one mode, start calls yourself rather than relying on the button.
- It does not turn video on. Video is the backend's half — it needs
media_mode: "video"in the widget config. Two declarations, from two places, because they answer different questions: who owns the screen and what media the call uses.
The default is CO_PILOT. Omitting the argument, passing null, or letting the user start the call from the button all resolve to:
agentTriggerMode | CO_PILOT — your app owns the UI |
mediaMode | AUDIO, unless the backend's widget config says otherwise |
| avatar surface | full page, when the user opens the avatar |
So the SDK never draws its own screen unless you ask for WORKFLOW or the user opens the avatar themselves. If co-pilot is what you want — and for most apps it is — you do not need to pass anything.
Every call logs which mode it actually ran in:
call mode LATCHED action=CO_PILOT media=AUDIO surface=FULL_PAGE video=falseOne quirk worth knowing: co-pilot has no chat surface, so a co-pilot call whose backend config says media_mode: "chat" runs as audio and logs media_mode=chat has no co-pilot surface — running as audio. It downgrades rather than failing the call.
The widget card
EmbedSDK.collapseWidget() // dismiss the card, keep the call
EmbedSDK.isWidgetExpanded // StateFlow<Boolean>
EmbedSDK.minimizeAvatar() // leave full-screen avatar mode
EmbedSDK.isAvatarOpen // StateFlow<Boolean>Send text to the agent
EmbedSDK.sendText("Show me my EMI schedule")Know when a call starts and ends
private val onConnected: AgentEventCallback = { /* … */ }
EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED, onConnected)
EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED) { data ->
// data carries call duration
}
EmbedSDK.onAvatarState { isOpen -> /* full-screen avatar opened/closed */ }Deregister with offAgent(...) / offAvatarState(...) when your listener's owner dies — a listener held by a destroyed screen is a leak.
AgentEvent: AGENT_CONNECTED, AGENT_DISCONNECTED, POPUP_MESSAGE_VISIBLE.
Readiness
EmbedSDK.isInitialized() // Boolean, right now
EmbedSDK.isInitializedFlow // StateFlow<Boolean>, to observe
EmbedSDK.widgetConfig // StateFlow<WidgetConfig?> — non-null = can renderisInitializedFlow means "the SDK has credentials", which cannot fail. To know the button can actually be drawn, observe widgetConfig — see section 9.
On logout
EmbedSDK.clearStorageCache()Call this whenever the user signs out. Without it, stored identity and conversation context carry into the next user's session on a shared device.
6D. Events and analytics
Everything the widget does is observable, and you can push your own context in.
Sending events — EmbedSDK.event(key, data)
Four keys, each with a shape the backend expects.
USER_DATA — who the user is. Send after login, before the first call.
EmbedSDK.event(
EventKeys.USER_DATA,
mapOf(
"app_user_id" to "user_123", // your stable id — the important one
"name" to "Jane Doe",
"email" to "jane@email.com"
)
)SCREEN_STATE — manual screen tracking, for hosts with no NavController. This enriches events; it does not drive widget visibility (use currentScreen / setCurrentScreen for that).
EmbedSDK.event(
EventKeys.SCREEN_STATE,
mapOf("screen" to "ProductDetail", "action" to "enter")
)ANALYTICS_DATA — analytics, keyed by event_name. This is also the key the SDK fires its own events on, which is what makes them observable.
EmbedSDK.event(
EventKeys.ANALYTICS_DATA,
mapOf("event_name" to "checkout_started")
)CUSTOM_EVENT — free-form host events.
Listening — on / off
val cb: EventCallback = { data -> Log.d("Revrag", "data: $data") }
EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb)
EmbedSDK.off(EventKeys.ANALYTICS_DATA, cb) // same instance, or it won't unhookoff matches on the callback instance. Hold it in a property — a lambda written inline at the off call site is a different object and removes nothing.
Call lifecycle — onAgent / offAgent
class MainActivity : AppCompatActivity() {
private val onConnected: AgentEventCallback = { _ ->
Log.d("Revrag", "agent call started")
}
private val onDisconnected: AgentEventCallback = { payload ->
val duration = (payload["metadata"] as? Map<*, *>)
?.get("callDuration") as? Int ?: 0
Log.d("Revrag", "call lasted ${duration}s")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED, onConnected)
EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED, onDisconnected)
}
override fun onDestroy() {
super.onDestroy()
EmbedSDK.offAgent(AgentEvent.AGENT_CONNECTED, onConnected)
EmbedSDK.offAgent(AgentEvent.AGENT_DISCONNECTED, onDisconnected)
}
}AgentEvent: AGENT_CONNECTED, AGENT_DISCONNECTED, POPUP_MESSAGE_VISIBLE.
This is the one place the "never write teardown" rule does not apply. The widget needs no teardown; a listener you registered does, or it holds a destroyed Activity for the life of the process.
What the SDK fires on its own
All arrive on ANALYTICS_DATA with event_name set to one of:
EmbedAnalyticsEvents. | Fires when |
|---|---|
AGENT_TAP_TO_OPEN | the user taps the collapsed button |
AGENT_TAP_TO_CLOSE | the user collapses the card |
AGENT_VISIBLE | the button finishes fading in |
AGENT_CONVERSATION_STARTED | the call connects |
AGENT_CONVERSATION_ENDED | the call ends |
AVATAR_MODE_OPENED | the full-screen avatar opens |
POPUP_MESSAGE_VISIBLE | an inactivity nudge appears |
GEN_TOOL_TRIGGERED | the first data-channel message arrives |
MICROPHONE_PERMISSION_ALLOW | mic permission is granted |
RAGE_CLICK | rapid repeated taps are detected |
FORM_EVENT | a form interaction is tracked |
ERROR | the SDK hits an error |
EmbedSDK.on(EventKeys.ANALYTICS_DATA) { data ->
when (data["event_name"]) {
EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> myAnalytics.track("call_start")
EmbedAnalyticsEvents.ERROR -> Log.e("Revrag", "sdk error: $data")
}
}Compare against the constants, never against string literals — the wire values are not part of the public contract.
Context on every event
EmbedSDK.setCurrentFlow("loan_application") // attached to every subsequent event
EmbedSDK.setAppVersion("4.2.0") // only if yours differs from PackageInfoMicrophone permission, on demand
EmbedSDK.checkPermissions(activity) { granted ->
if (!granted) showYourOwnRationale()
}Optional — the SDK requests it at call start anyway. Use this to ask earlier, at a moment that makes sense in your flow.
6E. A drop-in event logger
Paste this once and every SDK event prints with a readable label. It is the fastest way to see the widget working, and the hook where you forward events to your own analytics.
import ai.revrag.embed.android.EmbedAnalyticsEvents
import ai.revrag.embed.android.EmbedSDK
import ai.revrag.embed.android.EventCallback
import ai.revrag.embed.android.EventKeys
import android.util.Log
object SdkLifecycleListener {
private const val TAG = "SdkLifecycle"
private var callback: EventCallback? = null
/**
* @param onEvent optional — forward every SDK event to your own analytics.
*/
fun register(onEvent: ((name: String, data: Map<String, Any?>) -> Unit)? = null) {
if (callback != null) return // idempotent
val cb: EventCallback = { data ->
val name = data["event_name"] as? String ?: "unknown"
Log.d(TAG, "${label(name)} → $data")
onEvent?.invoke(name, data)
}
callback = cb
EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb)
}
fun unregister() {
callback?.let { EmbedSDK.off(EventKeys.ANALYTICS_DATA, it) }
callback = null
}
private fun label(name: String): String = when (name) {
EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN -> "Widget expanded"
EmbedAnalyticsEvents.AGENT_TAP_TO_CLOSE -> "Widget collapsed"
EmbedAnalyticsEvents.AGENT_VISIBLE -> "Widget visible"
EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> "Call started"
EmbedAnalyticsEvents.AGENT_CONVERSATION_ENDED -> "Call ended"
EmbedAnalyticsEvents.AVATAR_MODE_OPENED -> "Avatar opened"
EmbedAnalyticsEvents.POPUP_MESSAGE_VISIBLE -> "Popup shown"
EmbedAnalyticsEvents.MICROPHONE_PERMISSION_ALLOW -> "Mic permission granted"
EmbedAnalyticsEvents.GEN_TOOL_TRIGGERED -> "Tool triggered"
EmbedAnalyticsEvents.RAGE_CLICK -> "Rage click"
EmbedAnalyticsEvents.FORM_EVENT -> "Form event"
EmbedAnalyticsEvents.ERROR -> "SDK error"
else -> name
}
}// Application.onCreate, after initialize
SdkLifecycleListener.register()
// or forward to your analytics
SdkLifecycleListener.register { name, data -> myAnalytics.track(name, data) }
// on logout / teardown
SdkLifecycleListener.unregister()Because it holds the callback instance itself, unregister() actually unhooks — the mistake this helper exists to prevent.
7. How screens get their names
Four sources, highest wins:
| Source | You write | |
|---|---|---|
| 1 | attachOverlay(currentScreen = …) or handle.setCurrentScreen(…) | a name, if you want prettier ones |
| 2 | NavController destination android:label or route | pass the controller once |
| 3 | Fragment class name | nothing |
| 4 | Activity class name | nothing — the floor, never absent |
Two rules worth knowing:
- Naming one screen by hand takes ownership. Rung 3 stands down for the rest of the session. If you start naming, name them all.
- Names are matched exactly and are case-sensitive.
"Home"≠"home".
8. Verify it works
Run with logcat filtered to RevragEmbed. Every visibility decision prints its inputs, so you can watch the config work screen by screen:
[Screen] SplashActivity#1 → LoginActivity#2
[Visibility] 'SplashActivity' NOT allowed — hidden
[Visibility] 'LoginActivity' group='onboarding' delayMs=500 waitMs=500 …
[Visibility] 'LoginActivity' delay elapsed — showing
[Visibility] 'Plans' same CONTINUOUS group 'main_tabs' — stay visible| Filter | Shows |
|---|---|
[Screen] | every screen change, and the name the SDK has |
[Visibility] | every show/hide decision and why |
[Init] | the handshake and whether a widget config arrived |
A good first run shows [Screen] changing as you navigate, NOT allowed on your excluded screens, and delay elapsed — showing on your allowed ones.
9. Troubleshooting
The button never appears
Check these in order — the first two account for most cases.
1. Did a widget config arrive? Filter logcat for Init:
[Init] widgetConfig: null
backend returned NO widget_config for this API key — the widget cannot render.That is provisioning, not your code: the key has no widget configured for that environment. Confirm your key and embedUrl are for the same environment, and ask Revrag to provision the key.
2. Does the name in the config match the name the SDK has? Filter for [Visibility] and read the quoted name. A name in allowedScreens that no screen ever reports hides the widget silently. Case matters.
3. Is the screen excluded? excludedScreens beats allowedScreens.
4. Did attachOverlay run after setContentView()? From onResume it always does. A call in onCreate before setContentView is removed by it.
5. Is there an entrance delay still running? delayMs is real — a 2500ms group delay looks like "not working" for two and a half seconds.
The button disappears mid-flow
Almost always teardown code. Remove any detach() in onPause/onDestroy and let the SDK own the hand-off.
It appears on screens it should not
An empty allowedScreens means everywhere. Either list your screens, or use excludedScreens for the ones to suppress. Not attaching on a screen is not a way to hide it — and it leaves a live call running.
The call does not start
RECORD_AUDIO missing from your manifest (section 1). The SDK requests the runtime permission but cannot grant itself one you never declared.
"Class was compiled with an incompatible version of Kotlin"
The binary version of its metadata is 2.0.0, expected version is 1.9.0Your project is on Kotlin 1.9.x. The SDK is compiled with 2.0.21 and its classes carry metadata 2.0, which older compilers refuse to read. Move your project to Kotlin 2.0 or newer; there is no flag that makes 1.9 accept it.
Your own Ktor version is not part of this. The SDK's Ktor is relocated to ai.revrag.shaded.ktor.*, so it neither constrains nor conflicts with yours.
Two widgets appear
Two mount mechanisms at once — usually EmbedProviderComposable and attachOverlay. Pick one.
Something else
Capture logcat filtered to RevragEmbed from app start and send it to Revrag with your screen names — it records every decision the SDK made.
10. Checklist
-
implementation("ai.revrag:embed-android:1.1.0") -
INTERNETandRECORD_AUDIOin your manifest -
EmbedSDK.initialize(...)inApplication.onCreate -
Applicationclass registered in the manifest - One mount mechanism —
attachOverlayfrom every resume, orEmbedProviderComposable, never both -
appUserIdset to your stable user id - Config names match what the SDK reports (Activity class names, nav labels, or Compose routes)
- Splash and trampolines in
excludedScreens— not simply un-attached - Screens that share a journey share a
CONTINUOUSgroup - Nothing in
onPause/onDestroyfor the widget itself — but do deregister anyonAgent/onAvatarStatelisteners you registered -
EmbedSDK.clearStorageCache()on logout -
USER_DATAsent with a validapp_user_idbefore the first call - Any
on/onAgentlistener is deregistered with the same instance -
defaultInsetset if a bottom bar or sticky CTA would sit under the button - Tested on a physical device — emulators mishandle microphone and audio routing
- Verified in logcat:
[Visibility] … delay elapsed — showingon an allowed screen,NOT allowed — hiddenon an excluded one
11. Upgrading
1.0.8 → 1.1.0
Drop-in: no code changes required. What you gain:
- The SDK follows Activity changes on its own (
autoTrackActivities, default on). Multi-Activity apps no longer need to name every Activity — see 5A. Your existing explicit names still win. - Automatic screen names from Activity class and fragment class, so
allowedScreensworks on screens you never wired up. - Widget lifecycle fixes across Activity hand-overs, dialog windows and configuration changes.
If you were relying on not calling attachOverlay on a screen as a way to hide the widget, that no longer hides it — the SDK finds the Activity by itself. Move those screens to excludedScreens, which is better anyway: it also ends a live call, which non-attachment never did.
1.0.7 → 1.0.8
Adds call-control APIs, widget control and EmbedAnalyticsEvents. No changes required.
1.0.6 → 1.0.7
Fixes Ktor class conflicts by shading. No changes required.
Reference
| Artifact | ai.revrag:embed-android:1.1.0 |
| Maven Central | https://central.sonatype.com/artifact/ai.revrag/embed-android |
Support
- Issues: GitHub Issues
- Docs: Revrag Documentation
- Dashboard: app.revrag.ai