View as Markdownllms.txt

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

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

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

minSdk24 (Android 7.0)
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.

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 doesWidget does
Moves between screens in one CONTINUOUS groupStays visible — no blink, no re-delay; a live call keeps running
Crosses into another groupOne clean entrance with that group's delayMs; 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

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. Leave on unless you know why not.

EmbedButtonGroupConfig

FieldDefaultMeaning
idAny stable string.
screensThe 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

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

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

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

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

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

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

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

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")
  • 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 excludedScreensnot 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

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.


Reference

Artifactai.revrag:embed-android:1.1.0
Maven Centralhttps://central.sonatype.com/artifact/ai.revrag/embed-android

Support