Android Integration Guide
| Requirement | Value |
|---|---|
| SDK version | 1.0.8 |
| Min Android version | API 24 (Android 7.0) |
| Language | Kotlin |
| UI toolkit | Jetpack Compose |
| Build system | Gradle (Kotlin DSL) |
| Network | Internet access at runtime |
| Maven repo | mavenCentral() (already in every Android project) |
Get your API key from https://app.revrag.ai.
Table of Contents
- Requirements
- 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
- Voice calls & widget control
- Call behavior
- Events
- Analytics helpers
- Observing all SDK events
- Cleanup on logout
- Configuration reference
- Troubleshooting
- Pre-ship checklist
Requirements
| Requirement | Value |
|---|---|
| Min OS | Android 7.0 (API 24) |
| Language | Kotlin 2.0+ (2.3+ if you use Ktor 3.4+ in your app) |
| UI framework | Jetpack Compose |
| Build system | Gradle (Kotlin DSL) |
Kotlin version: The SDK is compiled with Kotlin 2.0.21, which emits Kotlin metadata 2.x. Consumers on Kotlin 1.9 or below will see Class 'X' was compiled with an incompatible version of Kotlin — bump your project to Kotlin 2.0+. If your own app pulls in Ktor 3.4+, its transitive stdlib forces Kotlin 2.3+.
HTTP client isolation: The SDK bundles a private, relocated copy of Ktor under ai.revrag.shaded.ktor.*. You can use any version of Ktor (or none at all) in your own app without conflict — past, present, or future.
Step 1 — Add the dependency
In your module-level build.gradle.kts:
dependencies {
implementation("ai.revrag:embed-android:1.0.8")
}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
Public package surface: import everything from ai.revrag.embed.android. The one exception is EmbedButtonInset (button positioning), which lives in ai.revrag.embed.android.ui. Other internal packages may change without notice.
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" android:maxSdkVersion="30" />
<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",
embedUrl = null // optional — custom backend URL; leave null for default
) { 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/initializewith 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.android.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
| 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.
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 → HIDDENVoice calls & widget control
The floating button (FAB) and its call card handle everything on their own. These functions let you drive the same widget programmatically — start or end a call, check call state, and collapse or observe the card — without waiting for a user tap.
Call control
// Identity comes from the stored app_user_id — send USER_DATA first.
EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user_123"))
// Start a call: handles mic + Bluetooth permission, connects, installs the reconnect provider.
EmbedSDK.startCall(activity) { started ->
// true once the call started; false if it could not.
// Failure reasons are reported to Revrag (analytics "error"), not returned here.
}
EmbedSDK.endCall() // End the active call. Emits AGENT_DISCONNECTED.
EmbedSDK.isCallActive() // Boolean — is a call currently connected?startCall returns false if no app_user_id is stored. Always send a USER_DATA event before starting a call.
Widget control
EmbedSDK.collapseWidget() // Collapse the card. Fires agent_tap_to_close. Does NOT end a call.
EmbedSDK.isWidgetExpanded // StateFlow<Boolean> — reactive expanded/collapsed state.Expanding is not exposed — the widget opens on a user tap or via a dashboard-configured auto_trigger. Your app can collapse it and observe its state.
Call behavior
Behavior to know about while a call is active:
| Situation | What happens |
|---|---|
| App sent to background | The active call auto-disconnects (the mic is unavailable in the background). |
| Navigate to an excluded screen | The widget hides and the active call ends. |
| Bluetooth headset connects mid-call | Audio routes to the headset automatically. |
| Bluetooth disconnects mid-call | Audio falls back to the speaker (not the earpiece). |
| Call connects | After a configurable delay, the card auto-collapses to the button (the call keeps running) and shows a pulsing "live call" halo. |
| Agent ends the call | The call ends cleanly and does not reconnect. |
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 |
Listen to data events locally
Use on() / off() to observe any EventKeys event locally — for example to mirror USER_DATA into your own state. Pass the same callback reference to off() when removing it.
val cb: EventCallback = { data -> Log.d("Revrag", "User data: $data") }
EmbedSDK.on(EventKeys.USER_DATA, cb)
EmbedSDK.off(EventKeys.USER_DATA, cb)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) |
Matching event names with EmbedAnalyticsEvents
The SDK fires its built-in events as ANALYTICS_DATA carrying an event_name. Match against these constants instead of hardcoding strings:
EmbedAnalyticsEvents.GEN_TOOL_TRIGGERED // "gen_tool_triggered"
EmbedAnalyticsEvents.POPUP_MESSAGE_VISIBLE // "popup_message_visible"
EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN // "agent_tap_to_open"
EmbedAnalyticsEvents.AGENT_TAP_TO_CLOSE // "agent_tap_to_close"
EmbedAnalyticsEvents.AGENT_VISIBLE // "agent_visible"
EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED // "agent_conversation_started"
EmbedAnalyticsEvents.RAGE_CLICK // "rage_click"
EmbedAnalyticsEvents.MICROPHONE_PERMISSION_ALLOW // "microphone_permission_allow"
EmbedAnalyticsEvents.FORM_EVENT // "form_event"
EmbedAnalyticsEvents.ERROR // "error"EmbedSDK.on(EventKeys.ANALYTICS_DATA) { data ->
when (data["event_name"]) {
EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN -> { /* widget opened */ }
EmbedAnalyticsEvents.ERROR -> { /* SDK error */ }
}
}Utility methods
EmbedSDK.setAppVersion("2.0.0") // override the reported app version
EmbedSDK.setCurrentFlow("onboarding") // tag events with a logical flow name
EmbedSDK.isInitialized() // Boolean snapshot
EmbedSDK.isInitializedFlow // StateFlow<Boolean> — collect in Composables
EmbedSDK.widgetConfig // StateFlow<WidgetConfig?> — reactive dashboard configObserving all SDK events
The SDK emits its own analytics / lifecycle events (widget taps, call start, mic permission, rage-clicks, tool triggers, errors…) — all as ANALYTICS_DATA events carrying an event_name. Drop this helper into your app to log them all in one place or forward them to your analytics.
Usage
// after EmbedSDK.initialize:
SdkLifecycleListener.register() // log every SDK event to Logcat
SdkLifecycleListener.register { name, data -> // or handle them yourself
myAnalytics.track(name, data)
}
// on teardown:
SdkLifecycleListener.unregister()Full helper
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
/**
* Listens to the lifecycle / analytics events the SDK fires on its own (widget taps,
* call start, permission prompts, rage-clicks, etc.).
*
* All of these are emitted as `ANALYTICS_DATA` events carrying an `event_name`, so a single
* `EmbedSDK.on(EventKeys.ANALYTICS_DATA)` listener receives them all — we dispatch by `event_name`.
*/
object SdkLifecycleListener {
private const val TAG = "SdkLifecycle"
/** Held so we can pass the SAME reference to off() when unregistering. */
private var callback: EventCallback? = null
/**
* Start listening. Idempotent — calling twice keeps a single registration.
* @param onEvent optional handler for every event (name + full payload); defaults to logging.
*/
fun register(onEvent: ((name: String, data: Map<String, Any?>) -> Unit)? = null) {
if (callback != null) return
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)
Log.d(TAG, "Registered — listening for SDK lifecycle events")
}
/** Stop listening. Safe to call when not registered. */
fun unregister() {
callback?.let { EmbedSDK.off(EventKeys.ANALYTICS_DATA, it) }
callback = null
Log.d(TAG, "Unregistered")
}
/** Human-readable label for a known SDK event name (falls back to the raw name). */
private fun label(name: String): String = when (name) {
EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN -> "🔵 Widget expanded (agent_tap_to_open)"
EmbedAnalyticsEvents.AGENT_TAP_TO_CLOSE -> "⚪ Widget collapsed (agent_tap_to_close)"
EmbedAnalyticsEvents.AGENT_VISIBLE -> "👁️ Widget became visible (agent_visible)"
EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> "🎙️ Call started (agent_conversation_started)"
EmbedAnalyticsEvents.POPUP_MESSAGE_VISIBLE -> "💬 Popup/tooltip shown (popup_message_visible)"
EmbedAnalyticsEvents.MICROPHONE_PERMISSION_ALLOW -> "🎤 Mic permission granted (microphone_permission_allow)"
EmbedAnalyticsEvents.GEN_TOOL_TRIGGERED -> "🛠️ Agent tool triggered (gen_tool_triggered)"
EmbedAnalyticsEvents.RAGE_CLICK -> "😡 Rage click (rage_click)"
EmbedAnalyticsEvents.FORM_EVENT -> "📝 Form event (form_event)"
EmbedAnalyticsEvents.ERROR -> "❌ SDK error (error)"
else -> "ℹ️ $name"
}
}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
| 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 |
EmbedButtonGroupConfig
| 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 |
EmbedButtonDelayPolicy
| 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 |
EmbedButton direct props
| 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
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 exactlyUpgrading from 1.0.7 → 1.0.8
Drop-in replacement. No code changes required — bump the version string only. 1.0.8 adds the host-callable call-control API (startCall / endCall / isCallActive), widget control (collapseWidget / isWidgetExpanded), and the EmbedAnalyticsEvents constants; all existing 1.0.7 code keeps working unchanged.
Upgrading from 1.0.6 → 1.0.7
Drop-in replacement. No code changes required — bump the version string only.
If you were blocked on 1.0.6 by errors like:
NoClassDefFoundError: io.ktor.client.plugins.contentnegotiation.ContentNegotiation…or any other Ktor classpath conflict, 1.0.7 fixes it. The SDK now ships its own Ktor under ai.revrag.shaded.ktor.*, so your app's Ktor version no longer matters.
Class 'X' was compiled with an incompatible version of Kotlin
Cause: Your project is on Kotlin 1.9 or earlier. The SDK is compiled with Kotlin 2.0.21 and the K1 compiler cannot read Kotlin metadata 2.x.
Fix: bump your project's Kotlin version to 2.0+ in your root build.gradle.kts:
plugins {
kotlin("android") version "2.0.21" apply false
}If your app also pulls in Ktor 3.4+, you'll need Kotlin 2.3+ (Ktor 3.4's transitive stdlib forces this).
Could not resolve ai.revrag:embed-android:1.0.8
Cause: Dependency not cached, or stale Gradle cache.
# Clear cache and retry
./gradlew --refresh-dependenciesVerify 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:
USER_DATAwas sent with a validapp_user_idbefore other eventsinitialize()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
-
ai.revrag:embed-android:1.0.8added and Gradle synced -
Applicationclass created and registered inAndroidManifest.xml -
EmbedSDK.initialize()called inApplication.onCreate() -
USER_DATAevent sent withapp_user_idimmediately after login -
EmbedProvider.attach()wraps the root composable inMainActivity -
clearStorageCache()called on logout
Screen tracking
-
NavControllerpassed toEmbedProvider(Scenario A/B), orSCREEN_STATEfired manually (Scenario C) - Nested graphs tracked correctly (Scenario D if applicable)
Visibility
-
visibilityConfigconfigured 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()onResultcallback - Agent event listeners registered and de-registered in
onDestroy - If driving calls from your own UI,
USER_DATAis sent beforestartCall() - Tested on a physical device — microphone permission dialog does not appear in emulator
Support
- Issues: GitHub Issues
- Docs: Revrag Documentation
- Dashboard: app.revrag.ai