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
- Requirements
- How it works
- Step 1 — Add the dependency
- Step 2 — Manifest permissions
- Step 3 — Initialize the SDK
- Step 4 — Identify the user
- Step 5 — Add the floating button
- Routing scenarios
- Button visibility control
- Events
- Analytics helpers
- Cleanup on logout
- Configuration reference
- Troubleshooting
- Pre-ship checklist
Requirements
| Requirement | Value |
|---|
| Min OS | Android 7.0 (API 24) |
| Language | Kotlin 1.9+ |
| UI framework | Jetpack Compose |
| Build system | Gradle (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:
- Calls
GET /embedded-agent/initialize with your API key
- Downloads and caches your widget configuration (colors, agent name, avatar)
- Pre-warms the Lottie animation cache to avoid a first-render flash
- Pre-fetches static icon assets via Coil
- 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.
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
| Parameter | Type | Default | Description |
|---|
appUserId | String | "" | Identifies the logged-in user |
appVersion | String | "1.0.0" | Your app’s version string — included in all events |
accentColor | Color | #6C63FF | Fallback gradient color before config loads |
visibilityConfig | EmbedButtonVisibilityConfig | show everywhere | Screen allow/exclude list and groups |
navController | NavController? | null | Enables 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.
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 */ }
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
| Key | Value | Auto-fired | Host-callable | Notes |
|---|
USER_DATA | user_data | No | Yes | Send after login; must include app_user_id |
SCREEN_STATE | screen_state | Yes — EmbedProvider | Yes | Fired on every screen enter and exit |
ANALYTICS_DATA | analytics_data | Yes — SDK UI | Yes | SDK fires built-in events; host may also fire custom events |
CUSTOM_EVENT | custom_event | No | Yes | Free-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
| Event | Payload | Use case |
|---|
AGENT_CONNECTED | timestamp, metadata.callDuration: 0, server_url | Pause background audio, start a timer |
AGENT_DISCONNECTED | timestamp, metadata.callDuration (seconds) | Resume audio, log call length |
POPUP_MESSAGE_VISIBLE | — | Track 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_name | Trigger |
|---|
agent_tap_to_open | User taps the collapsed FAB |
agent_tap_to_close | User taps avatar to close the card |
agent_visible | EmbedProvider show-delay completes |
popup_message_visible | Inactivity nudge fires the tooltip |
gen_tool_triggered | First data-channel message received |
agent_conversation_started | LiveKit connect() succeeds |
microphone_permission_allow | On 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
| Field | Type | Default | Description |
|---|
allowedScreens | List<String> | [] (all screens) | Button shows only on these screens |
excludedScreens | List<String> | [] | Button always hidden on these screens |
showDelay | Long ms | 0 | Delay before button fades in on allowed screens |
groups | List<EmbedButtonGroupConfig> | [] | Per-group override rules |
defaultInset | EmbedButtonInset? | SDK default | Default edge snap position |
| Field | Type | Default | Description |
|---|
id | String | required | Unique group identifier |
screens | List<String> | required | Screens that belong to this group |
continuity | EmbedButtonContinuity | PER_SCREEN | CONTINUOUS keeps the button mounted across group screens without flash |
inset | EmbedButtonInset? | null | Per-group snap position override |
delayMs | Long ms | 0 | Show delay for this group |
delayPolicy | EmbedButtonDelayPolicy | PER_SCREEN | Controls when the delay resets |
| Value | Behaviour |
|---|
PER_SCREEN | Delay applies every time the screen becomes active |
ONCE_PER_GROUP_ENTRY | Delay only on the first entry into a group |
ONCE_PER_APP_SESSION | Delay only on the very first view of the session |
| Parameter | Type | Default | Description |
|---|
appUserId | String | "" | User identifier passed to the voice agent |
accentColor | Color | #6C63FF | Fallback gradient |
isVisible | Boolean | true | Hides the button without unmounting it |
containerInset | EmbedButtonInset | right=24, bottom=80 | Snap position from screen edges |
Troubleshooting
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
}
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:
USER_DATA was sent with a valid app_user_id before other events
initialize() completed successfully (isInitialized == true)
- Device has internet connectivity
- 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
Screen tracking
Visibility
Production readiness
Support