View as Markdownllms.txtRelease Notes

Android Integration Guide

The complete guide for adding the Revrag AI voice agent to an Android app.

What you getOne floating button that follows your user across every screen, keeps its position, and keeps a live call running while they navigate
Current version1.2.1
minSdk24
JDK17
LanguageKotlin 2.0+
API keyGet it from https://app.revrag.ai

Every app integrates with the same three calls — XML or Compose, one Activity or fifty:

StepCallWhereDetails
1. InitializeEmbedSDK.initializeOnce, in Application.onCreate — not in an Activity§2
2. MountEmbedProvider.attachOverlaySafe from every Activity's onResume§3
3. IdentifyEmbedSDK.event(EventKeys.USER_DATA, …)After login, before the first callIdentify your user

Section 5 shows the exact setup for your app. Everything before it applies to every app.


Table of contents

  1. Install — dependency and permissions
  2. Initialize — one call, in Application
  3. Mount — one call, and the SDK follows the user
  4. Decide where it appears — the visibility config
  5. Your app's shape — pick one, complete code
  6. Dialogs and bottom sheets · Position the button · Control it from your code · Events and analytics · A drop-in event logger
  7. How screens get their names
  8. Verify it works
  9. Troubleshooting
  10. Checklist
  11. Upgrading

1. Install

Dependency

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()                  // hosts ai.revrag:embed-android
        maven("https://jitpack.io")     // required — see below
    }
}

// app/build.gradle.kts
dependencies {
    implementation("ai.revrag:embed-android:1.2.1")
}

Add the JitPack repository. The SDK's voice transport (io.livekit:livekit-android) depends on com.github.davidliu:audioswitch, which is published only on JitPack. Without it, Gradle sync fails with Could not find com.github.davidliu:audioswitch.

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.

No Bluetooth permission is needed: headset routing is OS-managed, and call audio goes to a connected headset automatically.

Requirements

minSdk24 (Android 7.0)
compileSdk34 or newer
JDK17
Kotlin2.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 typeComponentActivity or any subclass — AppCompatActivity qualifies
API keyfrom 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. No ProGuard/R8 rules of your own are needed — the AAR ships what R8 needs.

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

Optional parameters

ParameterDefaultMeaning
embedUrlproductionBackend URL. Must match the environment your key belongs to.
autoTrackActivitiestrueThe widget follows whichever Activity is resumed, and every Activity reports a screen name.
appVersionyour versionNameNew in 1.2.0. Overrides the app version the SDK reports.
EmbedSDK.initialize(context = this, apiKey = "YOUR_API_KEY", appVersion = "2.4.0") { result -> }

Use named arguments when you pass any of these.

Identify your user

EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123"))

Import only from ai.revrag.embed.android.*. Never import ai.revrag.embed.core.* — those are internal packages and can move between releases.


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

val handle: EmbedOverlayHandle = 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.

The returned EmbedOverlayHandle is how you name screens yourself: setCurrentScreen(name) for a whole screen, and setSubScreen(name) for pages inside one (5G).


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 doesWidget does
Moves between screens in one CONTINUOUS groupStays visible — no blink, no re-delay; a live call keeps running
Moves between setSubScreen pages of one screenStays visible — no blink; a live call keeps running
Crosses into another group, or between two standalone allowedScreensOne clean entrance with that screen's delay; the previous call ends by design
Opens a screen not in allowedScreensHides, and an active call ends
Opens a screen in excludedScreensHides, and an active call ends
Rotates, or the system recreates an ActivityReappears immediately — delays are never replayed
Backgrounds and returnsSame state, same position

A call can only run where the widget shows. EmbedSDK.startCall() on a screen where the widget is hidden is refused. Set endCallWhenHiddenByVisibility = false only if you need a call to continue off allowed screens.

All options

EmbedButtonVisibilityConfig

FieldDefaultMeaning
allowedScreensemptyList()Screens where the widget may appear. Empty means everywhere.
excludedScreensemptyList()Screens where it never appears. Wins over allowedScreens.
showDelay0LDefault entrance delay in ms, when a group does not set one.
groupsemptyList()Journeys — see below.
defaultInsetSDK defaultStarting position of the button, in dp from each edge (section 6B).
endCallWhenHiddenByVisibilitytrueEnd a live call when the widget hides, and refuse startCall where it is hidden. Leave on unless you know why not.

EmbedButtonGroupConfig

FieldDefaultMeaning
id—Any stable string.
screens—The screens in this journey.
continuityPER_SCREENCONTINUOUS keeps the widget and the call alive across the group.
insetnullPosition override for this group's screens (section 6B).
delayMs0LEntrance delay for this group.
delayPolicyPER_SCREENPER_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 appGo to
XML, one Activity per screen5A
XML, one Activity hosting fragments / tabs5B
XML, several Activities that each host several screens5C
Jetpack Compose, single Activity + NavController5D
Jetpack Compose, several Activities5E
Bottom tabs with one NavHost per tab5F
A WebView with several pages on one screen (KYC, DigiLocker, eSign)5G — combine with any of the above

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 there

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

Smarter name matching (optional, recommended for tabs and nested screens). By default only a screen's primary name matches. Turn on alias, ancestor and fingerprint matching so "details" also matches "details/{id}", tabs inherit their parent's allow, and Compose state tabs and pagers get names of their own:

EmbedSDK.setScreenMatching(EmbedScreenMatching.ALL)   // any time; survives config refreshes

Your setting wins over the tenant's dashboard flags; setScreenMatching(null) hands control back to the dashboard.

No NavController? Drive it from your own state — any string works:

var screen by remember { mutableStateOf("home") }
EmbedProviderComposable(currentScreen = screen, ... ) { /* content */ }

EmbedProviderComposable cannot use setSubScreen (5G) or move the widget into dialogs (section 6). If you need either, mount with attachOverlay as in 5E — it works for single-Activity Compose apps too.


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.


5G — WebView with several pages on one screen

New in 1.2.0. For a WebView that moves through several pages on one screen or nav route — KYC, DigiLocker, eSign, a partner page. Each web page reaches the backend as its own screen, while the widget follows the screen that hosts the WebView.

1. List the WebView's screen in visibilityConfig, the same way you list other screens. Don't list the web pages themselves:

EmbedButtonVisibilityConfig(
    allowedScreens = listOf("home", "kyc/digilocker")   // the route, not page names
)

2. Name each web page with setSubScreen, using the handle attachOverlay returns:

webViewClient = object : WebViewClient() {
    override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
        if (!isReload) embedHandle?.setSubScreen(pageNameFor(url))
    }
}

3. Remove any setCurrentScreen(...) calls you added for web pages. setCurrentScreen replaces the screen, so an unlisted page name hides the widget.

What you get:

  • The widget shows on every page of an allowed screen.
  • Moving between web pages doesn't flicker the widget or end a live call.
  • Leaving the screen clears the page name automatically — there's nothing to reset. To return to the parent screen while staying on it, call setSubScreen(null).
  • A page name that is listed in allowedScreens, excludedScreens or a group follows its own rule; an unlisted one follows its parent screen.

No tokens, session IDs, user IDs or personal data in page names — the name is sent to the backend. Keep names stable: the same page should always get the same name. Derive them from the URL path, not the full URL with its query string.

setSubScreen needs the attachOverlay mount. The agent cannot yet read or fill content inside a WebView.


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 (a Compose Dialog, a PopupWindow):

EmbedProvider.attachOverlay(window, activity)

This needs the attachOverlay mount; EmbedProviderComposable hosts have no widget view to move.


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 */ }
}

startCall is refused on a screen where the widget is hidden (section 4).

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 -> }
ModeWho draws the UI
CO_PILOTYour 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.
WORKFLOWRevrag. 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:

agentTriggerModeCO_PILOT — your app owns the UI
mediaModeAUDIO, unless the backend's widget config says otherwise
avatar surfacefull 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 (debug builds, section 8):

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

EmbedSDK.expandWidget()                 // open the 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")

The text goes to the call that is already running; it does not start one.

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 render

isInitializedFlow 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 / setSubScreen 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 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.

From 1.2.0, an exception thrown inside your own listener is caught and isolated — it cannot crash the SDK or stop other listeners from running.

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_OPENthe user taps the collapsed button
AGENT_TAP_TO_CLOSEthe user collapses the card
AGENT_VISIBLEthe button finishes fading in
AGENT_CONVERSATION_STARTEDthe call connects
AGENT_CONVERSATION_ENDEDthe call ends
AVATAR_MODE_OPENEDthe full-screen avatar opens
POPUP_MESSAGE_VISIBLEan inactivity nudge appears
GEN_TOOL_TRIGGEREDthe first data-channel message arrives
MICROPHONE_PERMISSION_ALLOWmic permission is granted
RAGE_CLICKrapid repeated taps are detected
FORM_EVENTa form interaction is tracked
ERRORthe 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 PackageInfo

A version passed to initialize(appVersion = …) takes priority over setAppVersion.

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

SourceYou write
1attachOverlay(currentScreen = …) or handle.setCurrentScreen(…)a name, if you want prettier ones
2NavController destination android:label or routepass the controller once
3Fragment class namenothing
4Activity class namenothing — the floor, never absent

On top of any of these, handle.setSubScreen(…) names a page inside the current screen (5G). The backend sees the page name; visibility follows the screen underneath, and the next screen change clears it.

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. (setSubScreen does not take ownership.)
  • Names are matched exactly and are case-sensitive. "Home" ≠ "home".

8. Verify it works

From 1.2.0, the SDK logs its lifecycle when your app is a debug build — initialization, screen changes, visibility decisions and call steps. Release builds print nothing. The logs never include on-screen text or user data.

Run a debug build with logcat filtered to RevragEmbed:

adb logcat | grep 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
FilterShows
[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.

No logs at all

You're running a release build. The SDK only logs in debug builds of your app.

The button disappears mid-flow

Almost always teardown code. Remove any detach() in onPause/onDestroy and let the SDK own the hand-off.

The button hides, or the call ends, on WebView pages

You're naming web pages with setCurrentScreen, so each page counts as a new, unlisted screen. Switch to setSubScreen and list the WebView's screen in allowedScreens (5G).

The call ends when moving between two screens

Both screens are standalone entries in allowedScreens, so each is its own session. Put screens that form one journey in a CONTINUOUS group (section 4).

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.

Could not find com.github.davidliu:audioswitch

The JitPack repository is missing from settings.gradle.kts (section 1).

"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 mount mechanisms at once — usually EmbedProviderComposable and attachOverlay. Pick one.

Lottie or SLF4J warnings in release logs

"You have N images…" (Lottie, from the avatar animation) and three SLF4J lines at startup come from third-party libraries. They are not SDK faults and are safe to ignore.

Something else

Capture logcat from a debug build, 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.2.1")
  • mavenCentral() and maven("https://jitpack.io") in your repositories
  • Kotlin 2.0+ and JDK 17
  • 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
  • WebView pages named with setSubScreen (no personal data), and the hosting screen listed in allowedScreens
  • Dialogs and bottom sheets call attachOverlay(dialog, activity) in onStart()
  • 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 (debug build): [Visibility] … delay elapsed — showing on an allowed screen, NOT allowed — hidden on an excluded one

11. Upgrading

1.2.0 → 1.2.1

Drop-in: nothing was removed or changed in the public API, the dependencies are the same, and so are the requirements. No code changes are needed. What you gain:

  • Crash fix — a KotlinReflectionInternalError … requestFocus crash when a call started, hit by R8-minified release builds that include kotlin-reflect and use Compose foundation 1.9+ (Compose BOM 2025.08.00 or later).
  • Failures stay inside the SDK — the SDK's navigation listener, lifecycle callbacks, tap tracking, background work, mounting and detach() can no longer throw into your app, and JVM errors such as NoSuchMethodError are contained too. Only that SDK feature stops; your app keeps running.
  • Calls recover — a failed connection resets the SDK instead of leaving it stuck in "connecting" until the app restarted.
  • Sliders — the on-screen agent can now set a slider by typing a number into it.
  • Click analytics element_id — a view's tag is used only when it is a String; other tag objects fall through to the resource ID name.

Full details, including a known R8 issue with RecyclerView, are in the release notes.

1.1.0 → 1.2.0

Drop-in: nothing was removed or changed in the public API, and the requirements are the same. What you gain:

  • WebView pages as separate screens — setSubScreen on the overlay handle (5G).
  • App version at initialization — EmbedSDK.initialize(..., appVersion = "2.4.0"). Optional; without it the SDK keeps reading your versionName.
  • Logs in debug builds — the SDK now prints RevragEmbed lifecycle logs when your app is a debug build (section 8). 1.1.0 printed nothing. Release builds are unchanged and stay silent.
  • A smarter on-screen agent — it no longer targets controls hidden behind a bottom sheet or dialog, skips disabled buttons and fields, sees custom views that expose themselves through accessibility, and links field labels to their fields.
  • Stability — bad configuration values from the backend can no longer crash your app, a failure inside the SDK removes the widget instead of taking your app down, and exceptions in your own listeners are isolated.
  • UI — the microphone-permission explainer uses its own floating dialog theme, in light and dark.

If you named WebView pages with setCurrentScreen, switch to setSubScreen. setCurrentScreen replaces the screen, so an unlisted page name hid the widget and could end a live call.

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

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.


Known limitations

  • setSubScreen is Android only; it is not on iOS yet.
  • The agent cannot read or fill content inside a WebView.

Reference

Artifactai.revrag:embed-android:1.2.1
Maven Centralhttps://central.sonatype.com/artifact/ai.revrag/embed-android
Release notesAndroid SDK release notes

Support