Skip to main content

Android Integration Guide

SDK version: 1.0.6
Platform: Android (Kotlin / Jetpack Compose)
Min SDK: API 24 (Android 7.0)
Build system: Gradle (Kotlin DSL)
Get your API key from https://app.revrag.ai.

Table of Contents

  1. Requirements
  2. How it works
  3. Step 1 — Add the dependency
  4. Step 2 — Manifest permissions
  5. Step 3 — Initialize the SDK
  6. Step 4 — Identify the user
  7. Step 5 — Add the floating button
  8. Routing scenarios
  9. Button visibility control
  10. Events
  11. Analytics helpers
  12. Cleanup on logout
  13. Configuration reference
  14. Troubleshooting
  15. Pre-ship checklist

Requirements

RequirementValue
Min OSAndroid 7.0 (API 24)
LanguageKotlin 1.9+
UI frameworkJetpack Compose
Build systemGradle (Kotlin DSL)

Step 1 — Add the dependency

In your module-level build.gradle.kts:
dependencies {
    implementation("ai.revrag:embed-android:1.0.6")
}
Sync Gradle. No extra repository setup needed — mavenCentral() is already in every Android project. The SDK transitively pulls in:
  • LiveKit Android SDK (WebRTC)
  • Lottie for Android
  • Coil (image loading)
  • Kotlin Coroutines

Step 2 — Manifest permissions

The SDK’s AndroidManifest.xml declares these permissions — they are auto-merged into your app via manifest merger. You do not need to add them manually.
<!-- Auto-added by the SDK via manifest merger -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
RECORD_AUDIO and BLUETOOTH_CONNECT (API 31+) are runtime permissions. The SDK requests them automatically when the user taps the call button — you do not trigger them yourself.

Step 3 — Initialize the SDK

Create an Application class and call initialize() in onCreate(). This must run once, as early as possible.
// MyApplication.kt
import android.app.Application
import ai.revrag.embed.android.EmbedSDK

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        EmbedSDK.initialize(
            context = this,
            apiKey  = "YOUR_REVRAG_API_KEY"
        ) { result ->
            if (result.success) Log.d("Revrag", "SDK ready")
            else                Log.e("Revrag", "Init failed: ${result.error}")
        }
    }
}
Register the Application class in AndroidManifest.xml:
<application
    android:name=".MyApplication"
    ... >
What initialize() does:
  1. Calls GET /embedded-agent/initialize with your API key
  2. Downloads and caches your widget configuration (colors, agent name, avatar)
  3. Pre-warms the Lottie animation cache to avoid a first-render flash
  4. Pre-fetches static icon assets via Coil
  5. Sets EmbedSDK.isInitialized = true — the button appears automatically once this is true

Step 4 — Identify the user

Call this right after your login flow completes:
import ai.revrag.embed.android.EmbedSDK
import ai.revrag.embed.core.events.EventKeys

EmbedSDK.event(
    EventKeys.USER_DATA,
    mapOf(
        "app_user_id" to "user_123",       // required
        "name"        to "Jane Doe",        // optional
        "email"       to "jane@email.com"   // optional
    )
)
Send USER_DATA before the user taps the call button. Without app_user_id, the agent cannot identify the user and conversation context will not be attributed.

Step 5 — Add the floating button

Wrap your root composable with EmbedProvider.attach() in MainActivity. This overlays the draggable button on top of all your existing Compose content automatically.
// MainActivity.kt
import ai.revrag.embed.android.EmbedProvider
import androidx.activity.ComponentActivity

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        EmbedProvider.attach(
            activity   = this,
            appUserId  = "user_123",
            appVersion = BuildConfig.VERSION_NAME
        ) {
            // Your existing root composable — no changes needed
            MyAppTheme {
                AppNavigation()
            }
        }
    }
}

Alternative — EmbedProviderComposable

If you already call setContent {} yourself or use a multi-Activity architecture:
setContent {
    MyAppTheme {
        Box(modifier = Modifier.fillMaxSize()) {
            YourMainScreen()

            EmbedProviderComposable(
                currentScreen = "HomeScreen",
                appUserId     = "user_123",
                appVersion    = BuildConfig.VERSION_NAME
            )
        }
    }
}
EmbedProviderComposable must be placed at the root of the Compose tree so BoxWithConstraints receives full-screen bounds. If it’s nested inside a sized container the button will be stuck at the top-left corner.

EmbedProvider.attach() props

ParameterTypeDefaultDescription
appUserIdString""Identifies the logged-in user
appVersionString"1.0.0"Your app’s version string — included in all events
accentColorColor#6C63FFFallback gradient color before config loads
visibilityConfigEmbedButtonVisibilityConfigshow everywhereScreen allow/exclude list and groups
navControllerNavController?nullEnables automatic Jetpack Navigation screen tracking

Routing scenarios

Screen tracking lets the agent know which screen the user is on. Choose the scenario that matches your navigation setup.

Scenario A — Single NavHost (most common)

Pass the NavController to EmbedProvider. The SDK tracks screen changes automatically — no additional code required.
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        EmbedProvider.attach(
            activity   = this,
            appUserId  = "user_123",
            appVersion = BuildConfig.VERSION_NAME
        ) {
            val navController = rememberNavController()

            NavHost(navController = navController, startDestination = "home") {
                composable("home")         { HomeScreen() }
                composable("search")       { SearchScreen() }
                composable("product/{id}") { ProductScreen() }
                composable("cart")         { CartScreen() }
                composable("checkout")     { CheckoutScreen() }
                composable("profile")      { ProfileScreen() }
            }
        }
    }
}
Screen names reported to Revrag are the route strings: "home", "search", "product/{id}", etc.

Scenario B — Bottom navigation with multiple NavHosts

Each tab has its own NavController. Pass the active one to EmbedProvider:
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        EmbedProvider.attach(
            activity   = this,
            appUserId  = "user_123",
            appVersion = BuildConfig.VERSION_NAME
        ) {
            val homeNavController    = rememberNavController()
            val exploreNavController = rememberNavController()
            val profileNavController = rememberNavController()

            var selectedTab by remember { mutableStateOf(0) }

            Scaffold(
                bottomBar = {
                    BottomNavBar(
                        selected = selectedTab,
                        onSelect = { selectedTab = it }
                    )
                }
            ) { padding ->
                Box(Modifier.padding(padding)) {
                    when (selectedTab) {
                        0 -> TabNavHost(navController = homeNavController,    startRoute = "home")
                        1 -> TabNavHost(navController = exploreNavController, startRoute = "explore")
                        2 -> TabNavHost(navController = profileNavController, startRoute = "profile")
                    }
                }
            }
        }
    }
}
The SDK reports whichever screen is active inside the currently visible tab’s NavHost.

Scenario C — No NavController (manual screen tracking)

If you manage navigation yourself (custom back stack, Fragments, or plain Compose without NavController), fire SCREEN_STATE events manually:
// Call this whenever the screen changes
EmbedSDK.event(
    EventKeys.SCREEN_STATE,
    mapOf("screen" to "ProductDetail", "action" to "enter")
)
In a Fragment:
class ProductFragment : Fragment() {
    override fun onResume() {
        super.onResume()
        EmbedSDK.event(EventKeys.SCREEN_STATE, mapOf("screen" to "ProductDetail"))
    }
}
In a Compose screen without NavController:
@Composable
fun CheckoutScreen() {
    LaunchedEffect(Unit) {
        EmbedSDK.event(EventKeys.SCREEN_STATE, mapOf("screen" to "Checkout"))
    }
    // your content
}

Scenario D — Nested navigation (checkout flow inside tabs)

NavHost(navController = rootNavController, startDestination = "main") {

    // Tab container
    composable("main") {
        MainTabsScreen()
    }

    // Checkout flow — nested graph
    navigation(startDestination = "cart", route = "checkout_flow") {
        composable("cart")         { CartScreen() }
        composable("payment")      { PaymentScreen() }
        composable("confirmation") { ConfirmationScreen() }
    }

    // Settings — separate stack
    composable("settings") { SettingsScreen() }
}
The SDK automatically picks up "cart", "payment", "confirmation", etc. as the screen names from the nested graph.

Button visibility control

By default the button shows on every screen. Use EmbedButtonVisibilityConfig to control this.

Show only on specific screens

EmbedProvider.attach(
    activity         = this,
    appUserId        = "user_123",
    appVersion       = BuildConfig.VERSION_NAME,
    visibilityConfig = EmbedButtonVisibilityConfig(
        allowedScreens  = listOf("home", "product", "cart"),
        excludedScreens = listOf("login", "splash", "onboarding")
    )
) { /* content */ }

Keep button visible across a flow (e.g. checkout)

Use a group with .CONTINUOUS continuity so the button doesn’t flash between screens:
visibilityConfig = EmbedButtonVisibilityConfig(
    groups = listOf(
        EmbedButtonGroupConfig(
            id          = "checkout-flow",
            screens     = listOf("cart", "payment", "confirmation"),
            continuity  = EmbedButtonContinuity.CONTINUOUS,
            delayMs     = 500L,
            delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY
        )
    )
)

Visibility rules (evaluated in priority order)

1. Screen is in excludedScreens               →  always HIDDEN  (strongest rule)
2. allowedScreens and groups are both empty   →  always VISIBLE
3. Screen is in allowedScreens or any group   →  VISIBLE
4. Otherwise                                  →  HIDDEN

Events

EventKeys reference

KeyValueAuto-firedHost-callableNotes
USER_DATAuser_dataNoYesSend after login; must include app_user_id
SCREEN_STATEscreen_stateYesEmbedProviderYesFired on every screen enter and exit
ANALYTICS_DATAanalytics_dataYes — SDK UIYesSDK fires built-in events; host may also fire custom events
CUSTOM_EVENTcustom_eventNoYesFree-form host-app events

Sending events

// User identity (required before first call)
EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user_123"))

// Custom analytics event
EmbedSDK.event(EventKeys.ANALYTICS_DATA, mapOf("event_name" to "checkout_started"))

// Manual screen state
EmbedSDK.event(EventKeys.SCREEN_STATE, mapOf("screen" to "CheckoutScreen", "action" to "enter"))

Listen for agent call events

// Register in onCreate or onStart
val onConnected: AgentEventCallback = { _ ->
    // e.g. pause background music
    Log.d("Revrag", "Agent call started")
}
val onDisconnected: AgentEventCallback = { payload ->
    val duration = (payload["metadata"] as? Map<*, *>)?.get("callDuration") as? Int ?: 0
    Log.d("Revrag", "Call lasted ${duration}s")
}

EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED,    onConnected)
EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED, onDisconnected)

// Always de-register in onDestroy
override fun onDestroy() {
    super.onDestroy()
    EmbedSDK.offAgent(AgentEvent.AGENT_CONNECTED,    onConnected)
    EmbedSDK.offAgent(AgentEvent.AGENT_DISCONNECTED, onDisconnected)
}

Agent lifecycle events reference

EventPayloadUse case
AGENT_CONNECTEDtimestamp, metadata.callDuration: 0, server_urlPause background audio, start a timer
AGENT_DISCONNECTEDtimestamp, metadata.callDuration (seconds)Resume audio, log call length
POPUP_MESSAGE_VISIBLETrack tooltip impressions

Analytics helpers

These helpers fire ANALYTICS_DATA events with standardized payloads.
// Track a custom event
EmbedSDK.trackEvent("product_viewed", mapOf("product_id" to "SKU-123"))

// Track a form interaction
EmbedSDK.trackFormEvent(
    formId    = "checkout_form",
    eventType = "submit",
    formData  = mapOf("step" to "payment")
)

// Track a rage-click
EmbedSDK.trackRageClick(
    elementId   = "btn_add_to_cart",
    coordinates = Pair(x, y),
    clickCount  = 5,
    elementType = "Button"
)

// Check microphone permission status
EmbedSDK.checkPermissions(context) { granted ->
    if (!granted) showMicEducationDialog()
}

Events auto-fired by the SDK

event_nameTrigger
agent_tap_to_openUser taps the collapsed FAB
agent_tap_to_closeUser taps avatar to close the card
agent_visibleEmbedProvider show-delay completes
popup_message_visibleInactivity nudge fires the tooltip
gen_tool_triggeredFirst data-channel message received
agent_conversation_startedLiveKit connect() succeeds
microphone_permission_allowOn every call start (status: already_granted or newly_granted)

Cleanup on logout

Call this when the user logs out or switches accounts to prevent stale data from leaking into the next session.
fun onUserLogout() {
    EmbedSDK.clearStorageCache()
}

Configuration reference

EmbedButtonVisibilityConfig

FieldTypeDefaultDescription
allowedScreensList<String>[] (all screens)Button shows only on these screens
excludedScreensList<String>[]Button always hidden on these screens
showDelayLong ms0Delay before button fades in on allowed screens
groupsList<EmbedButtonGroupConfig>[]Per-group override rules
defaultInsetEmbedButtonInset?SDK defaultDefault edge snap position

EmbedButtonGroupConfig

FieldTypeDefaultDescription
idStringrequiredUnique group identifier
screensList<String>requiredScreens that belong to this group
continuityEmbedButtonContinuityPER_SCREENCONTINUOUS keeps the button mounted across group screens without flash
insetEmbedButtonInset?nullPer-group snap position override
delayMsLong ms0Show delay for this group
delayPolicyEmbedButtonDelayPolicyPER_SCREENControls when the delay resets

EmbedButtonDelayPolicy

ValueBehaviour
PER_SCREENDelay applies every time the screen becomes active
ONCE_PER_GROUP_ENTRYDelay only on the first entry into a group
ONCE_PER_APP_SESSIONDelay only on the very first view of the session

EmbedButton direct props

ParameterTypeDefaultDescription
appUserIdString""User identifier passed to the voice agent
accentColorColor#6C63FFFallback gradient
isVisibleBooleantrueHides the button without unmounting it
containerInsetEmbedButtonInsetright=24, bottom=80Snap position from screen edges

Troubleshooting

The button never appears

Cause A — initialize() not called or failed Check Logcat for [EmbedSDK] tags:
EmbedSDK.initialize(context, apiKey = "YOUR_KEY") { result ->
    Log.d("Revrag", "success=${result.success} error=${result.error}")
}
Fix: verify your API key has no leading/trailing spaces and the device has internet.
Cause B — SDK not ready yet initialize() is async. The button is hidden until isInitialized = true and appears automatically — no action required. To observe readiness:
lifecycleScope.launch {
    EmbedSDK.isInitializedFlow.collect { ready ->
        Log.d("Revrag", "SDK ready: $ready")
    }
}

Cause C — Screen excluded by visibility config Check excludedScreens and allowedScreens. Screen names are case-sensitive and must match exactly what’s in your NavGraph routes or what you pass to SCREEN_STATE.
"Home" ≠ "home"  — they must match exactly

Could not resolve ai.revrag:embed-android:1.0.5

Cause: Dependency not cached, or stale Gradle cache.
# Clear cache and retry
./gradlew --refresh-dependencies
Verify the artifact is live: https://central.sonatype.com/artifact/ai.revrag/embed-android Confirm mavenCentral() is in your settings.gradle.kts:
repositories {
    google()
    mavenCentral()   // ← must be present
}

Button stuck at top-left corner

Cause: EmbedProviderComposable is not at the root of the Compose tree.
// ❌ Wrong — nested inside a sized Box
Box(modifier = Modifier.size(200.dp)) {
    EmbedProviderComposable(...)
}

// ✅ Correct — use EmbedProvider.attach() which handles root placement automatically
EmbedProvider.attach(activity = this, appUserId = "user_123") {
    YourContent()
}

Audio plays through speaker instead of Bluetooth headset

Cause: BLUETOOTH_CONNECT runtime permission denied (Android 12+). Direct the user to app settings:
EmbedSDK.checkPermissions(context) { granted ->
    if (!granted) {
        startActivity(
            Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                data = Uri.fromParts("package", packageName, null)
            }
        )
    }
}

Analytics events missing from dashboard

Check in this order:
  1. USER_DATA was sent with a valid app_user_id before other events
  2. initialize() completed successfully (isInitialized == true)
  3. Device has internet connectivity
  4. The SDK rate-limits to 5 req/s — bursts are queued, not dropped

User identity leaks between accounts

Always call clearStorageCache() on logout before the next user logs in:
fun onUserLogout() {
    EmbedSDK.clearStorageCache()
}

Pre-ship checklist

Basic setup

  • ai.revrag:embed-android:1.0.5 added and Gradle synced
  • Application class created and registered in AndroidManifest.xml
  • EmbedSDK.initialize() called in Application.onCreate()
  • USER_DATA event sent with app_user_id immediately after login
  • EmbedProvider.attach() wraps the root composable in MainActivity
  • clearStorageCache() called on logout

Screen tracking

  • NavController passed to EmbedProvider (Scenario A/B), or SCREEN_STATE fired manually (Scenario C)
  • Nested graphs tracked correctly (Scenario D if applicable)

Visibility

  • visibilityConfig configured if the button should not show on all screens
  • Screen names in config match routes exactly (case-sensitive)

Production readiness

  • Error handling in EmbedSDK.initialize() onResult callback
  • Agent event listeners registered and de-registered in onDestroy
  • Tested on a physical device — microphone permission dialog does not appear in emulator

Support