# Android Native > Android SDK Integration Guide - Voice-enabled AI agent with real-time communication capabilities (Kotlin, XML & Jetpack Compose) URL: /embed/integration/android Markdown: /embed/integration/android.md # Android Integration Guide [#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](https://app.revrag.ai). *** ## Table of contents [#table-of-contents] 1. [Install](#1-install) — dependency and permissions 2. [Initialize](#2-initialize) — one call, in `Application` 3. [Mount](#3-mount) — one call, and the SDK follows the user 4. [Decide where it appears](#4-decide-where-it-appears) — the visibility config 5. [Your app's shape](#5-your-apps-shape) — **pick one**, complete code 6. [Dialogs and bottom sheets](#6-dialogs-and-bottom-sheets) · [Position the button](#6b-position-the-button) · [Control it from your code](#6c-control-it-from-your-own-code) · [Events and analytics](#6d-events-and-analytics) · [A drop-in event logger](#6e-a-drop-in-event-logger) 7. [How screens get their names](#7-how-screens-get-their-names) 8. [Verify it works](#8-verify-it-works) 9. [Troubleshooting](#9-troubleshooting) 10. [Checklist](#10-checklist) 11. [Upgrading](#11-upgrading) *** ## 1. Install [#1-install] ### Dependency [#dependency] ```kotlin // 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 [#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: ```xml ``` 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 [#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](#9-troubleshooting)) | | 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](#9-troubleshooting) starts here. *** ## 2. Initialize [#2-initialize] Once, in `Application.onCreate` — not in an Activity. Initializing in an Activity means screens shown before it can never display the agent. ```kotlin 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: ```xml ``` ### Identify your user [#identify-your-user] ```kotlin EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123")) ``` *** ## 3. Mount [#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. ```kotlin // 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 [#full-parameter-list] ```kotlin 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 [#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](#7-how-screens-get-their-names)). ```kotlin 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 [#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 [#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](#6b-position-the-button)). | | `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](#6b-position-the-button)). | | `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](#8-verify-it-works) shows how to see the name the SDK actually has. *** ## 5. Your app's shape [#5-your-apps-shape] Find your app below. Each is complete and copy-pasteable. | Your app | Go to | | ------------------------------------------------------ | ----------------------------------------------------------- | | XML, one Activity per screen | [5A](#5a--xml-one-activity-per-screen) | | XML, one Activity hosting fragments / tabs | [5B](#5b--xml-one-activity-many-screens) | | XML, several Activities that each host several screens | [5C](#5c--xml-several-activities-each-with-several-screens) | | Jetpack Compose, single Activity + NavController | [5D](#5d--jetpack-compose-single-activity) | | Jetpack Compose, several Activities | [5E](#5e--jetpack-compose-several-activities) | | Bottom tabs with one NavHost per tab | [5F](#5f--bottom-tabs-with-one-navhost-per-tab) | *** ### 5A — XML, one Activity per screen [#5a--xml-one-activity-per-screen] The classic case. **Your Activities need zero Embed code**; the whole integration is one file. ```kotlin // 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: ```kotlin 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 [#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`. ```kotlin 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: ```xml ``` ```kotlin allowedScreens = listOf("Home", "Plans") // Account omitted → hidden there ``` **No NavController?** If you swap fragments manually, tell the SDK yourself: ```kotlin 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 [#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. ```kotlin // 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 ) ) ) } ``` ```kotlin // MyApp.onCreate — mount from every resume registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) = EmbedIntegration.attach(activity) /* other overrides empty */ }) ``` ```kotlin // 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](https://github.com/revrag-ai/embed-android). *** ### 5D — Jetpack Compose, single Activity [#5d--jetpack-compose-single-activity] Wrap your content in `EmbedProviderComposable` and pass your `NavController`. ```kotlin 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: ```kotlin allowedScreens = listOf("home", "plans") ``` **No NavController?** Drive it from your own state — any string works: ```kotlin var screen by remember { mutableStateOf("home") } EmbedProviderComposable(currentScreen = screen, ... ) { /* content */ } ``` *** ### 5E — Jetpack Compose, several Activities [#5e--jetpack-compose-several-activities] Use the XML mount (`EmbedProvider.attachOverlay`) from `Application`, exactly as in [5A](#5a--xml-one-activity-per-screen) — 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. ```kotlin // 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 [#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: ```kotlin // 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 [#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: ```kotlin // 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`: ```kotlin EmbedProvider.attachOverlay(window, activity) ``` *** ## 6B. Position the button [#6b-position-the-button] The widget is draggable, but you choose where it starts. Insets are in **dp** from each edge: ```kotlin 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 [#6c-control-it-from-your-own-code] The widget drives itself, but everything is available programmatically. ### Start and end calls [#start-and-end-calls] ```kotlin 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` [#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: ```kotlin 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 `startCall` only — never at `initialize`.** 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=false ``` One 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 [#the-widget-card] ```kotlin EmbedSDK.collapseWidget() // dismiss the card, keep the call EmbedSDK.isWidgetExpanded // StateFlow EmbedSDK.minimizeAvatar() // leave full-screen avatar mode EmbedSDK.isAvatarOpen // StateFlow ``` ### Send text to the agent [#send-text-to-the-agent] ```kotlin EmbedSDK.sendText("Show me my EMI schedule") ``` ### Know when a call starts and ends [#know-when-a-call-starts-and-ends] ```kotlin 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 [#readiness] ```kotlin EmbedSDK.isInitialized() // Boolean, right now EmbedSDK.isInitializedFlow // StateFlow, to observe EmbedSDK.widgetConfig // StateFlow — non-null = can render ``` `isInitializedFlow` means "the SDK has credentials", which cannot fail. To know the **button can actually be drawn**, observe `widgetConfig` — see [section 9](#9-troubleshooting). ### On logout [#on-logout] ```kotlin 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 [#6d-events-and-analytics] Everything the widget does is observable, and you can push your own context in. ### Sending events — `EmbedSDK.event(key, data)` [#sending-events--embedsdkeventkey-data] Four keys, each with a shape the backend expects. **`USER_DATA`** — who the user is. Send after login, before the first call. ```kotlin 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). ```kotlin 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. ```kotlin EmbedSDK.event( EventKeys.ANALYTICS_DATA, mapOf("event_name" to "checkout_started") ) ``` **`CUSTOM_EVENT`** — free-form host events. ### Listening — `on` / `off` [#listening--on--off] ```kotlin 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 unhook ``` `off` 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` [#call-lifecycle--onagent--offagent] ```kotlin 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 [#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 | ```kotlin 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 [#context-on-every-event] ```kotlin EmbedSDK.setCurrentFlow("loan_application") // attached to every subsequent event EmbedSDK.setAppVersion("4.2.0") // only if yours differs from PackageInfo ``` ### Microphone permission, on demand [#microphone-permission-on-demand] ```kotlin 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 [#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. ```kotlin 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) -> 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 } } ``` ```kotlin // 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 [#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 [#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 [#9-troubleshooting] ### The button never appears [#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 [#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 [#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 [#the-call-does-not-start] `RECORD_AUDIO` missing from your manifest ([section 1](#1-install)). The SDK requests the runtime permission but cannot grant itself one you never declared. ### "Class was compiled with an incompatible version of Kotlin" [#class-was-compiled-with-an-incompatible-version-of-kotlin] ``` The binary version of its metadata is 2.0.0, expected version is 1.9.0 ``` Your 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-widgets-appear] Two mount mechanisms at once — usually `EmbedProviderComposable` *and* `attachOverlay`. Pick one. ### Something else [#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 [#10-checklist] * [ ] `implementation("ai.revrag:embed-android:1.1.0")` * [ ] `INTERNET` and `RECORD_AUDIO` in your manifest * [ ] `EmbedSDK.initialize(...)` in `Application.onCreate` * [ ] `Application` class registered in the manifest * [ ] One mount mechanism — `attachOverlay` from every resume, **or** `EmbedProviderComposable`, never both * [ ] `appUserId` set 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 `CONTINUOUS` group * [ ] Nothing in `onPause` / `onDestroy` **for the widget itself** — but do deregister any `onAgent` / `onAvatarState` listeners you registered * [ ] `EmbedSDK.clearStorageCache()` on logout * [ ] `USER_DATA` sent with a valid `app_user_id` before the first call * [ ] Any `on` / `onAgent` listener is deregistered with the **same instance** * [ ] `defaultInset` set 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 — showing` on an allowed screen, `NOT allowed — hidden` on an excluded one *** ## 11. Upgrading [#11-upgrading] ### 1.0.8 → 1.1.0 [#108--110] 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](#5a--xml-one-activity-per-screen). Your existing explicit names still win. * **Automatic screen names** from Activity class and fragment class, so `allowedScreens` works 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 [#107--108] Adds call-control APIs, widget control and `EmbedAnalyticsEvents`. No changes required. ### 1.0.6 → 1.0.7 [#106--107] Fixes Ktor class conflicts by shading. No changes required. *** ## Reference [#reference] | | | | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Artifact | `ai.revrag:embed-android:1.1.0` | | Maven Central | [https://central.sonatype.com/artifact/ai.revrag/embed-android](https://central.sonatype.com/artifact/ai.revrag/embed-android) | *** ## Support [#support] * Issues: [GitHub Issues](https://github.com/revrag-ai/embed-android/issues) * Docs: [Revrag Documentation](https://docs.revrag.ai) * Dashboard: [app.revrag.ai](https://app.revrag.ai)