View as Markdownllms.txt

RevRag Android SDK: AI Agent Integration Master Prompt

How to use: paste this entire document into your AI coding agent (Copilot, Cursor, Claude Code, etc.) inside the Android app you want to integrate. The agent will inspect your app, add and configure ai.revrag:embed-android, ask you the few decisions only you can make, then validate the integration end-to-end and report.

Every rule below is derived from the SDK's source (v1.2.0). Where this prompt and a generic Android tutorial disagree, this prompt is correct for this SDK. This SDK is for native Android apps (Kotlin/Java, Jetpack Compose or XML Views). React Native and Flutter apps use embed-react-native and embed_flutter instead.


You are an expert Android integration agent. Take this application from zero → working → validated RevRag SDK integration with minimal manual work.

Prime directives

  1. Inspect first, modify second. Never assume the AGP/Kotlin version, UI toolkit, navigation library, or Activity structure. Read the project.
  2. Preserve host behavior. Merge into existing config. Never blindly overwrite AndroidManifest.xml, settings.gradle(.kts), build.gradle(.kts), the Application class, or any Activity.
  3. Never claim success without running the validation in §19–§22 and observing the log lines and on-device behavior named there.
  4. Ask the developer for the decisions in §12. Do not guess them.
  5. Never hide a crash or error. Report SDK-side issues you cannot fix.
  6. Do not add dependencies, permissions, or config the SDK does not require. In particular: no BLUETOOTH_CONNECT, no SYSTEM_ALERT_WINDOW, no ProGuard rules, no runtime-permission code, no Ktor/OkHttp/LiveKit dependencies of your own.
  7. Public API only. Import from ai.revrag.embed.android.*. Never import ai.revrag.embed.core.*; it is internal and changes without notice.

1. Inspect the existing application

Record each of these before changing anything:

WhatHow to find it
Toolchaingradle/libs.versions.toml, root + app/build.gradle(.kts): AGP, Kotlin, compileSdk, minSdk, Java/JVM target, gradle/wrapper/gradle-wrapper.properties
UI toolkitCompose (setContent {}, buildFeatures.compose), XML Views (setContentView(R.layout…)), or hybrid (ComposeView in XML / AndroidView in Compose)
Activity structuresingle Activity, or several Activities: list every one in the manifest, including splash, login, MPIN, deep-link and trampoline Activities
NavigationCompose NavHost + NavController, NavHostFragment (XML nav graph), manual FragmentTransactions, Activity-per-screen, ViewPager2, bottom tabs, Compose state tabs inside one route, several NavHosts
Application classis there one, and is it registered with android:name on <application>?
Login gatewhere auth succeeds, and where the user id becomes known; where logout happens
Dialogs & sheetsDialogFragment, BottomSheetDialogFragment, Compose Dialog / ModalBottomSheet, full-screen dialog "screens"
WebViewsany screen that hosts a WebView (KYC, DigiLocker, eSign, payment, partner pages)
Third-party ActivitiesCustom Tabs, payment / OAuth / biometric SDK screens
Existing voice/WebRTCgrep -rn "livekit|webrtc|twilio|agora|zego" --include=*.gradle* --include=*.toml ., which shows another VoIP stack (conflict risk, §22)
Shared peersCompose BOM version, lottie / lottie-compose, navigation-compose, activity-compose
Process modelany android:process attribute (multi-process, §22)
Release buildisMinifyEnabled / R8, FLAG_SECURE usage, an SLF4J binding (logback-android, slf4j-android)
App version sourceversionName in defaultConfig, or a CI-stamped value

VALIDATE: you can state, in one sentence each: the UI toolkit, the Activity structure, the navigation type, where the Application class is, where login and logout happen, and whether there are WebViews, dialogs, or third-party Activities.


2. Determine compatibility

TargetFloorNotes
minSdk24Hard floor.
compileSdk34Enforced by AAR metadata; the build fails with a clear message below it.
AGP8.1.1Needed for compileSdk 34.
Gradle8.0
Kotlin2.0Kotlin 1.9 fails with "binary version of its metadata is 2.0.0"; there is no flag around it.
JDK / JVM target17compileOptions + kotlin { jvmToolchain(17) }.
Activities that host the widgetComponentActivityAppCompatActivity and FragmentActivity qualify. A plain android.app.Activity cannot host it.
Processsingle processOnly the process that calls initialize has an agent.

If any target is below its floor, explain it, raise it, and re-validate. Do not upgrade or downgrade unrelated dependencies. XML-only apps need no Compose toolchain (no compose compiler plugin, no buildFeatures.compose): the Compose runtime arrives as an ordinary library dependency.


3. Add the repositories and the SDK

settings.gradle(.kts): JitPack is required. LiveKit's audioswitch dependency is published only there. Without it sync fails with Could not find com.github.davidliu:audioswitch.

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

app/build.gradle(.kts):

dependencies {
    implementation("ai.revrag:embed-android:1.2.0")
}

VALIDATE: Gradle sync succeeds and ./gradlew :app:assembleDebug passes before any SDK code is written.


4. Dependency conflicts

The SDK brings: LiveKit Android 2.27.0 (WebRTC), Compose BOM 2024.09.02, activity-compose, navigation-compose, lottie-compose 6.4.0, and Ktor (shaded into ai.revrag.shaded.ktor; it never appears in the host's dependency tree and needs no handling).

./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -E "livekit|compose-bom|lottie|audioswitch"

Rules:

  • Gradle resolving a higher version is fine. Never force / strictly LiveKit below 2.27.0, because that breaks the voice transport.
  • A host on a newer Compose BOM may see a constraint conflict. Do not downgrade the host; let Gradle pick the highest.
  • A host that already ships another WebRTC/VoIP SDK (Twilio, Agora, an older LiveKit): stop and report it (§22). Two WebRTC stacks can clash at runtime and must be tested together.

5. Android manifest

  1. Permissions. The AAR merges nothing into the host manifest, not even these. Add to app/src/main/AndroidManifest.xml, inside <manifest>, above <application>:
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    A missing RECORD_AUDIO is auto-denied by the OS with no dialog: the widget shows and every call fails.
  2. Do not add BLUETOOTH_CONNECT (headset routing is OS-managed; declaring it causes a spurious "Nearby devices" prompt) or SYSTEM_ALERT_WINDOW (the SDK never uses one).
  3. Do not copy android:usesCleartextTraffic="true" from RevRag example apps. They target a dev backend. Production is https://embed.revrag.ai.
  4. Application class registered: <application android:name=".MyApp" …>. Create one if the app has none.
  5. LiveKit's transitive manifest entries. The merged manifest will also contain ACCESS_NETWORK_STATE, MODIFY_AUDIO_SETTINGS, BLUETOOTH (maxSdk 30), CAMERA, FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PROJECTION and a ScreenCaptureService. The SDK never uses the camera or screen capture. Ask before removing any with tools:node="remove", and re-test a call afterwards.

VALIDATE: read the merged manifest, not the source one: app/build/intermediates/merged_manifest/debug/processDebugMainManifest/AndroidManifest.xml contains INTERNET and RECORD_AUDIO.


6. Build validation

./gradlew :app:assembleDebug
./gradlew :app:assembleRelease   # if the app has a release config

VALIDATE: both compile. Fix any error introduced by the integration here, not later.


7. SDK initialization (exact contract)

Call EmbedSDK.initialize once, in Application.onCreate. Not in a post-login Activity: screens created before initialize never get the agent.

import ai.revrag.embed.android.EmbedSDK

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        EmbedSDK.initialize(
            context = this,
            apiKey = BuildConfig.REVRAG_API_KEY,   // REQUIRED, from secure build config
            // embedUrl = "…",                    // OMIT for production; only for a RevRag-provided non-prod host
            // appVersion = "2.4.0",              // OPTIONAL, see §9
        ) { result ->
            if (!result.success) Log.e("Embed", "init failed: ${result.error}")
        }
    }
}
  • Signature: initialize(context, apiKey, embedUrl = null, autoTrackActivities = true, onResult), plus an overload with appVersion: String. Use named arguments.
  • onResult(InitResult): InitResult(success: Boolean, error: String?). This is the only place init failure surfaces in a release build, so always log it.
  • Initialization is fail-open: the app keeps running; the widget simply won't appear until config arrives.
  • autoTrackActivities = true (default) lets the SDK follow Activities on its own. Leave it on.
  • Do not pass an agentTriggerMode here; there is none. Call mode is chosen per call (§17).

VALIDATE (debug build): adb logcat | grep RevragEmbed shows, in order: [RevragEmbed][Init] 🚀 Stage 1 — initialize() called → Stage 3 — calling GET /embedded-agent/initialize … → Stage 4 — /initialize response received → ✅ Stage 5 — EmbedEvent configured, isInitialized = true → 🎉 Initialization complete — SDK is ready. Use grep RevragEmbed, not logcat -s RevragEmbed: init lines are printed under the System.out tag.


8. API key & environment validation

  • The key and the backend environment must be a matching pair. A dev key on the production backend (or the reverse) returns no widget config: the button never appears and no error is raised.
  • Never hardcode a key copied from RevRag's example apps; use the client's own key from secure build config (BuildConfig field / local.properties / CI secret). If none is available, stop, report "API key missing", and ask the developer.
  • A blank key fails init (Stage 0 — apiKey must not be empty).

VALIDATE: EmbedSDK.widgetConfig (a StateFlow<WidgetConfig?>) becomes non-null after init. Do not judge readiness on isInitializedFlow: it flips true from having a key, before any network call. A null widgetConfig after a successful request means provisioning (wrong key/environment, or no config for this platform and app version). The SDK latches that answer until initialize runs again.


9. App version

  • Optional. Omitted or blank → the SDK reports the host's versionName automatically. Pass appVersion = "x.y.z" to initialize only when the developer needs a different reported value. An explicit init value wins over any later setter.
  • The widget config and the avatar are provisioned per platform and per app version on the RevRag dashboard. A version bump can come back with no config (widget disappears) or no avatar (generic animation). Tell the developer to re-verify on the dashboard at every version bump.

VALIDATE: the dashboard has an Android widget config (and avatar) for exactly the version this build reports.


10. User identity (app_user_id) & logout

No call can connect until the SDK knows the user. After login, send:

import ai.revrag.embed.android.EmbedSDK
import ai.revrag.embed.android.EventKeys

EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to userId))   // stable id, required

or pass appUserId = userId to attachOverlay(...) (§13). For a no-auth/demo app, send it on the first screen.

On every logout:

EmbedSDK.clearStorageCache()

Otherwise one user's identity and context carry into the next user's session.

  • Without app_user_id, startCall is refused (debug log: [StartCall] refused — app_user_id is not set — send an EventKeys.USER_DATA event carrying 'app_user_id', or pass appUserId to attachOverlay()) and the UI-graph cache cannot upload.

Required order: initialize → init success → USER_DATA → widget config → widget shown on an allowed screen → call.


11. Widget configuration (dashboard-driven)

Position, colors, avatar, collapsed view, media mode (audio / video / chat), nudges and action intelligence come from the dashboard, not host code. Malformed backend values fall back to defaults and never crash the host.

CaseExpected
Config existsWidget appears on allowed screens; agent_visible analytics event fires.
Config nullNo widget; app fully functional. See §8 for the cause.
Avatar missing for this app versionWidget shows a generic pulse animation instead of the client's avatar.
Video tenant not provisionedPermanent "Connecting avatar…" placeholder during a call.
Slow first launchHTTP timeout 15 s; init retries 3× (~47 s), then refetches on the next screen change. The widget is late, not lost.

If the developer does not want the agent to act on screens (tap/type/scroll), that is a dashboard switch: action_config.flags.actionIntelligence = false. There is no client flag.


12. Ask the developer (do not guess)

After inspecting, list the actual screen names the SDK will report (§14: route patterns, nav labels, Fragment / Activity class names), then ask:

  1. Which screens should the widget appear on? → allowedScreens (empty = everywhere).
  2. Which screens must never show it (login, OTP, MPIN, payment, regulated data)? → excludedScreens. Entering one ends a live call.
  3. Which journeys must keep a live call alive across screens? → one CONTINUOUS group per journey (§15). Two allowed screens not in the same continuous group end the call on every hop between them.
  4. Should the widget appear after a delay on some screens? → showDelay / group delayMs
    • EmbedButtonDelayPolicy.
  5. Any bottom bar, host FAB, or sticky CTA the widget would cover? → defaultInset / group inset.
  6. WebView screens with several pages: should each page be its own screen on the backend? (→ setSubScreen, §14.4.)
  7. Who starts calls: only the user via the widget, or also the app (§17)? Co-pilot or full avatar (WORKFLOW) experience?
  8. Is a call ending acceptable when the user backgrounds the app, opens Custom Tabs / a payment SDK, or leaves for an excluded screen? (It always does; confirm they accept it.)
  9. Compliance: FLAG_SECURE screens? Corporate network / VPN users? Minified release?
  10. App version source if versionName is not what should be reported; API key location.

Wait for answers before writing screen configuration.


13. Mount the widget

Exactly one mount mechanism in the whole app: attachOverlay or EmbedProviderComposable, never both (two mounts = two widgets that drift apart). No teardown: never call detach() in onPause / onStop / onDestroy.

In Application.onCreate, after initialize:

registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
    override fun onActivityResumed(activity: Activity) {
        val host = activity as? ComponentActivity ?: return
        EmbedProvider.attachOverlay(
            activity = host,
            appUserId = Session.userIdOrEmpty(),
            visibilityConfig = EMBED_VISIBILITY,          // one shared val, §15
        )
    }
    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
})
  • attachOverlay is idempotent per Activity: the first call mounts, later calls move the widget or update the screen. Arguments on repeat calls are ignored: visibilityConfig, accentColor, appUserId are fixed at first mount. Define them once.
  • It must run after setContentView. onResume always satisfies this.
  • accentColor is a @ColorInt Int, not a Compose Color.
  • Keep the returned EmbedOverlayHandle if you need setCurrentScreen / setSubScreen.
  • Never use EmbedProvider.attach(...) on an XML Activity: it calls setContent and wipes the View hierarchy.

13.2 Compose single-Activity app with a NavController

Attach inside setContent, keyed on the controller, so route tracking starts on the first frame (from onCreate the controller would not exist yet):

setContent {
    val navController = rememberNavController()
    var embedHandle by remember { mutableStateOf<EmbedOverlayHandle?>(null) }
    LaunchedEffect(navController) {
        embedHandle = EmbedProvider.attachOverlay(
            activity = this@MainActivity,
            appUserId = userId,
            visibilityConfig = EMBED_VISIBILITY,
            navController = navController,
        )
    }
    NavHost(navController, startDestination = "home") { /* … */ }
}

Use 13.1 or 13.2 for a given Activity, not both.

13.3 Existing Compose hierarchy (alternative to attachOverlay)

EmbedProviderComposable(
    currentScreen = "home",
    appUserId = userId,
    visibilityConfig = EMBED_VISIBILITY,
    navController = navController,
)

VALIDATE: rotate the mounting Activity: the widget reappears immediately, exactly once.


14. Screen names: how the SDK identifies screens

Visibility config is matched against the name the SDK reports. Matching is exact and case-sensitive. A config entry no screen ever reports hides the widget silently.

14.1 The naming ladder (highest wins)

  1. Host name: handle.setCurrentScreen("…") or attachOverlay(currentScreen = "…")
  2. NavController destination: Compose: the android:label if set, else the route truncated at the first / (receipt/{orderId} → receipt; the full template also matches with alias matching on). XML nav graphs: android:label. Every XML destination needs android:label, or it reports a numeric id no config can match.
  3. Fragment class name (top-level resumed Fragment)
  4. Activity class name

Once the host names one screen by hand (rung 1), automatic Fragment naming stands down for the session: name them all, or a silent screen keeps the previous name.

14.2 Matching flags (optional)

EmbedSDK.setScreenMatching(EmbedScreenMatching(
    aliasMatching = true,       // a screen answers to all its names (route template, label, class)
    ancestorMatching = false,   // children inherit a parent's allow; global; leave off unless asked
    fingerprintScreens = false, // auto-name unannounced sub-screens from content; off when the host names them
))

null leaves each flag to the dashboard. EmbedScreenMatching.ALL turns all three on.

14.3 Shapes that need host code

ShapeDo this
Manual FragmentTransactions / Activity-per-screenWorks automatically (rungs 3–4). Name screens with handle.setCurrentScreen only if class names are not what the config should use.
Compose state tabs inside one route (BottomNavigation switching a when)On every tab switch: handle.setCurrentScreen(tabName). Also re-apply it when returning to that route (key the effect on the current route and the tab).
Several NavHosts / nested graphsRe-attach with the active controller on each tab switch. One controller at a time; a static currentScreen on an attach that also passes a controller is ignored.
Fragments shared across ActivitiesComposite names (landing/Details) via setCurrentScreen from the Fragment's onResume.
Two screens with the same nameGive them distinct names, or they merge into one entry.
Splash / trampoline ActivitiesLet them be named, then put them in excludedScreens. Don't "not attach" there.
EmbedSDK.setCurrentScreen(...)Event enrichment only. Never moves the widget. Use handle.setCurrentScreen.

14.4 WebView screens (multi-page web flows)

The SDK cannot see inside a WebView (the page is one opaque node), and navigation never announces web page changes. To give each web page its own screen on the backend while the widget follows the route that hosts the WebView, call setSubScreen:

webViewClient = object : WebViewClient() {
    // Fires on full loads AND single-page-app route changes (pushState)
    override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
        if (!isReload) embedHandle?.setSubScreen(webPageName(url))
    }
}

/** Stable, privacy-safe name: no query string, no IDs, no session hashes. */
fun webPageName(url: String?): String {
    val segments = url?.let { Uri.parse(it).pathSegments }.orEmpty()
        .filterNot { it.length > 24 || it.any(Char::isDigit) }
    return "web/" + segments.joinToString("/").ifEmpty { "home" }
}
  • List the route / screen that hosts the WebView in allowedScreens, not page names. No EmbedScreenMatching flag is needed.
  • Page hops don't re-run the widget's entrance and don't end a live call. The next navigation clears the page name automatically. setSubScreen(null) returns to the route.
  • Never use setCurrentScreen for web pages. It replaces the route, so an unlisted page name hides the widget on a route that allows it.
  • Never put tokens, session IDs, user IDs or personal data in the name. Ignore a WebView "title" that is just the URL echoed back.
  • The agent cannot read or fill web forms; native chrome around the WebView is captured.

VALIDATE (debug): each navigation logs [Screen] A#n → B#n+1 (nav|host|fragment|activity|sub). Write down every reported name, then write the config against that list. A hidden screen logs [Visibility] '<name>' NOT allowed — hidden.


15. Visibility, groups & the call licence

val EMBED_VISIBILITY = EmbedButtonVisibilityConfig(
    allowedScreens  = listOf("home", "loan_details"),
    excludedScreens = listOf("login", "mpin", "payment"),
    showDelay = 0L,
    groups = listOf(
        EmbedButtonGroupConfig(
            id = "kyc_journey",
            screens = listOf("kyc_start", "kyc_upload", "kyc_review"),
            continuity = EmbedButtonContinuity.CONTINUOUS,       // call survives hops inside
            delayMs = 0L,
            delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY,
        )
    ),
    defaultInset = EmbedButtonInset(bottom = 96),                // dp; clear a bottom bar
    // endCallWhenHiddenByVisibility = true   // default; see below
)

Imports: EmbedButtonVisibilityConfig, EmbedButtonGroupConfig, EmbedButtonContinuity, EmbedButtonDelayPolicy are in ai.revrag.embed.android; EmbedButtonInset is in ai.revrag.embed.android.ui (fields right, bottom, left, top, in dp).

Rules:

  • Empty allowedScreens = everywhere. A non-empty groups list is itself an allowlist (group screens show the widget). excludedScreens beats everything.
  • Hide a screen with excludedScreens, never by not attaching there: the SDK follows Activities on its own.
  • Call licence: a call starts only where the widget shows, ends when the user lands where it doesn't (endCallWhenHiddenByVisibility = true by default), ends when the app goes to background (no foreground service), and ends on any hop between allowed screens that are not in the same CONTINUOUS group.
  • EmbedButtonDelayPolicy: PER_SCREEN (default), ONCE_PER_GROUP_ENTRY, ONCE_PER_APP_SESSION. Explain delays to the developer: a 2500 ms delay looks like "not working" for 2.5 s.
  • The dashboard's position, if set, wins over defaultInset / group inset.

VALIDATE: during a live call, navigate inside a CONTINUOUS group: the call persists. Hop to an ungrouped or excluded screen: the call ends (agent_conversation_ended).


16. Dialogs, bottom sheets, other windows

A dialog has its own window above the Activity; elevation/zIndex can't cross it. The SDK moves its single overlay into the topmost dialog window and back.

  • Automatic adoption (default) covers DialogFragment, BottomSheetDialogFragment, plain Dialog, Compose Dialog and ModalBottomSheet. Adoption of non-DialogFragment windows uses a hidden API that fails on some OEM builds (e.g. ColorOS 13).
  • Guaranteed path where adoption can't be trusted: EmbedProvider.attachOverlay(dialog, requireActivity()) synchronously from onStart() (no post, no teardown).
  • A dialog that hosts the widget needs a full-screen, transparent window with animations off:
    dialog.window?.apply {
        setLayout(MATCH_PARENT, MATCH_PARENT)
        setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
        setDimAmount(0f)
        setWindowAnimations(0)
    }
    On Android 14+, prefer a transparent windowBackground in the dialog theme. A dialog created opaque and made transparent later leaves visual trails/stuck ripples behind the widget.
  • A full-screen DialogFragment used as a screen (MPIN, PAN verify) needs both the dialog attach and handle.setCurrentScreen(...). Adoption never renames.

VALIDATE: open every dialog/sheet while the widget is visible: it rises above it and returns on dismiss, cancel, back, and rotation.


17. Calls (programmatic API)

The SDK requests RECORD_AUDIO itself on the first call and shows its own explainer after a permanent denial. Write no permission code. (Optional early ask: EmbedSDK.checkPermissions(context) { granted -> }.)

EmbedSDK.expandWidget()                                           // show the in-call UI first
EmbedSDK.startCall(activity) { ok -> }                            // default voice co-pilot
EmbedSDK.startCall(activity, AgentTriggerMode.WORKFLOW) { ok -> } // full avatar surface
EmbedSDK.endCall()
EmbedSDK.isCallActive()                                           // Boolean
EmbedSDK.collapseWidget()
EmbedSDK.sendText("…") { result -> }                              // typed message into the call
  • AgentTriggerMode: CO_PILOT (agent works on the host's screens) or WORKFLOW (RevRag-owned avatar surface). Passed per call only. A call started by tapping the widget is always the default co-pilot. To get WORKFLOW on every call, the app must start calls itself.
  • Host-started calls: call expandWidget() before startCall(), on a real user gesture (it fires the tap-to-open analytics event). startCall alone leaves a live mic behind a collapsed button.
  • startCall's result is a plain Boolean. Refusal reasons (mic denied, no user id, token failure) go to the backend as an error analytics event and, in debug builds, to logcat as [StartCall] refused — <reason>.
  • Video is a dashboard setting (media_mode), not a client flag.

VALIDATE (physical device, never an emulator): tap the widget on a fresh install → system mic prompt → call connects (agent_conversation_started) → end → second call starts cleanly. Deny twice, tap again → the SDK's own explainer with "Open Settings" appears.


18. Agent readiness (only if action intelligence is on)

The agent reads the accessibility/semantics tree, the same data TalkBack uses.

  • Give every actionable element a stable identity: Modifier.testTag("checkout.pay") in Compose, android:id in XML. Without one, ids derive from text/position and shift.
  • Icon-only buttons need contentDescription. Custom-drawn / Canvas controls need semantics (contentDescription, range info, a setter). Self-test: if TalkBack can read and adjust it, so can the agent.
  • Selection by style only (custom chips/tabs/radios) → expose selected / toggleableState.
  • One toolkit per screen: in hybrid screens only one substrate is captured (XML chrome inside a Compose screen, or AndroidView content inside Compose, is invisible).
  • Password/secure fields are redacted at capture and refused at dispatch.
  • FLAG_SECURE screens are captured (screen text leaves the device). Confirm with whoever set the flag.
  • The UI-graph cache captures every screen landing, including excludedScreens, while the tenant's cache flags are on. Disclose this to the developer; turning off actionIntelligence does not stop it.

VALIDATE: ask the agent to tap the floating widget itself; it must refuse (the SDK's own UI is never a target).


19. Logging & diagnostics

BuildWhat you see
Debug build of the host appPII-safe lifecycle logs: adb logcat | grep RevragEmbed. Screen changes, visibility decisions, call steps, capture counts. Never screen text or payload contents.
Release buildNothing from the SDK. A silent logcat is normal, not a fault. Diagnose through onResult, EmbedSDK.widgetConfig, and the ANALYTICS_DATA listener.

Key debug lines: [Init] … Stage 1…5, [Screen] A#n → B#m (source), [Visibility] '<name>' NOT allowed — hidden, [Visibility] '<name>' delay elapsed — showing, [EmbedProvider] Route change: 'A' → 'B' — endCall(), [StartCall] refused — …, [LiveKit] Room connected, [UITree] capture: N nodes (…).

Observe SDK analytics from the host (works in release too):

EmbedSDK.on(EventKeys.ANALYTICS_DATA) { data -> Log.d("Embed", "${data["event_name"]}") }
EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED) { /* … */ }

Events to expect: agent_visible, agent_conversation_started, agent_conversation_ended, microphone_permission_allow / microphone_permission_denied. Gate any host-side event logging on BuildConfig.DEBUG; payloads can carry user data.


20. Event ordering validation

Confirm on a healthy first session:

  1. Stage 3 … GET /embedded-agent/initialize → Stage 5 → Initialization complete
  2. USER_DATA sent after login
  3. [Screen] … → <allowed screen> → widget visible → agent_visible
  4. Tap / startCall → [LiveKit] Room connected → agent_conversation_started
  5. End → agent_conversation_ended

Invalid patterns: a call before USER_DATA ([StartCall] refused — app_user_id is not set …), agent_visible never firing (re-check §8, §10, §14), two widgets on screen (two mounts, §13).


21. Release build

  • No ProGuard/R8 rules needed. The AAR ships consumer rules for its classes, shaded Ktor, and the Compose semantics it relies on.
  • Smoke-test the minified release build, not only debug (R8 has silently broken capture layers before).
  • If the host ships an SLF4J binding (logback-android, slf4j-android), the SDK's HTTP client logs request URLs at INFO in release, and some carry app_user_id. Report it to RevRag; there is no host switch.
  • Ship an App Bundle (LiveKit/WebRTC native libs are large).
  • Two library warnings are expected even in release and are not SDK faults: Lottie (You have N images…, from the avatar animation) and 3 SLF4J "no binding" lines at startup.

22. Failure conditions & unknown-unknowns (detect → why → do)

ConditionExpected / what to do
Wrong key or key/environment mismatchNo widget, no crash, widgetConfig stays null. Fix the pair.
RECORD_AUDIO missing from merged manifestNo mic prompt, every call fails. Add it (§5).
app_user_id never setstartCall refused; cache never uploads. §10.
Widget never appears on a screenReported name ≠ config string (§14), or excluded, or delay, or null config (§8).
Widget vanishes mid-flowTeardown code (detach()), or the screen's name changed (host named one screen but not this one).
Two widgetsTwo mount mechanisms (§13).
WebView page hides the widgetsetCurrentScreen used for pages; switch to setSubScreen (§14.4).
Call ends on navigationScreens not in one CONTINUOUS group, or next screen hidden/excluded (§15).
Call ends on background / Custom Tabs / payment SDKBy design (no foreground service; widget not shown there). Confirm acceptance.
Incoming phone call during an agent callNot handled: the agent keeps talking. Disclose.
Another VoIP/WebRTC SDK in the appAsk; test both together. No coexistence guarantee.
Corporate network / VPNAllowlist on 443: embed.revrag.ai, revrag-dev.s3.ap-south-1.amazonaws.com (in-call icons), and the LiveKit server + TURN from the token response. If UDP is blocked, TURN over TCP/TLS is needed.
Multi-window / split screenSecond pane has no widget. Picture-in-picture untested. Set expectations.
android:processOnly the initializing process has an agent.
Bluetooth headsetAudio routes to it when connected for calls; on disconnect it falls back to the speaker. Verify on device.
Host overrides the overlay with its own top-level window/portalVerify z-order on device.
Shared device, user switchclearStorageCache() on logout (§10).
16 KB page-size devices (Android 15)Not certified for LiveKit 2.27.0 natives. Ask RevRag before certifying.

23. Final checklist & report

[ ] App shape, toolchain, navigation, Activities, WebViews, dialogs detected
[ ] Toolchain at floor: minSdk 24, compileSdk 34, AGP 8.1.1+, Kotlin 2.0+, JDK 17
[ ] JitPack + mavenCentral + google repositories; ai.revrag:embed-android:1.2.0; sync + assembleDebug clean
[ ] No LiveKit force-downgrade; no second WebRTC stack (or flagged)
[ ] Merged manifest has INTERNET + RECORD_AUDIO; no BLUETOOTH_CONNECT / SYSTEM_ALERT_WINDOW / cleartext
[ ] EmbedSDK.initialize in Application.onCreate; onResult logged; widgetConfig non-null on device
[ ] Key/environment pair verified; dashboard config + avatar exist for this app version
[ ] USER_DATA (app_user_id) after login; clearStorageCache() on logout
[ ] Exactly one mount mechanism; no detach/teardown; NavController attach inside setContent (Compose)
[ ] Reported screen names listed; config written against them; tabs/multi-NavHost/shared fragments handled
[ ] WebView pages via setSubScreen (not setCurrentScreen); route listed in allowedScreens
[ ] allowed / excluded / CONTINUOUS groups / delays / insets chosen by the developer
[ ] Dialogs and sheets verified on device (incl. rotation)
[ ] Call start / connect / end / restart on a physical device; mic deny path; CONTINUOUS hop keeps the call
[ ] Agent readiness: testTag / android:id / contentDescription on key controls; secure fields redacted
[ ] Minified release build smoke-tested; SDK silent in release logcat
[ ] §22 conditions reviewed and disclosed

Final report format

Integration Status: SUCCESS / PARTIAL / FAILED
SDK version: | AGP: | Kotlin: | compileSdk: | minSdk: | UI toolkit: | Navigation: | Activities:
Dependencies added/changed (+why):
Manifest changes:
Init: placement + onResult handling + widgetConfig observed:
Identity: USER_DATA location | logout clearStorageCache location:
Mount: mechanism + where:
Screens reported by the SDK:
Visibility — allowed: | excluded: | groups (continuity): | delays: | insets:
WebView screens + setSubScreen naming:
Dialogs/sheets verified:
Calls — who starts them | trigger mode | device results:
Validation — Init: | Key/env: | App version: | USER_DATA: | Widget config: | Widget rendering: |
             Screen names: | Call flow: | Dialogs: | Release build: | Logs:
Disclosures (background calls, cache on excluded screens, FLAG_SECURE, SLF4J, VPN):
Remaining issues (app-side or SDK-side):

Do not declare SUCCESS while any critical validation is failing.


Companion docs: docs/INTEGRATION.md (full mounting + navigation examples), docs/ANDROID_SDK_API.md (API reference), docs/FDE_INTEGRATION_CHECKLIST.md (field checklist), docs/ANDROID_1.2.0_INTEGRATION_CHANGES.md (what's new in 1.2.0).