Android Chatbot UI Kit
dialogs_ChatbotAV_Android is a pre-built, fully managed chat and video-calling UI
for Android — unlike the Chatbot SDK, there is no
UI to build yourself. It ships three integration levels — drop-in
EnxChatClient view, a EnxChatFragment wrapper, and a headless
EnxChatSession — plus a Compose-native EnxChat composable. Audio
and video calling is powered by the vendored Enx_UIKit_Android and
Enx-Rtc-Android native libraries.
First release of the Android Chatbot UI Kit. See Release Notes for what's included.
Download Android Chatbot UI Kit v1.0.0Requirements
| Requirement | Value |
|---|---|
| minSdkVersion | 24 |
| compileSdk / targetSdk | 36 |
| Kotlin | 2.0.21 |
| Android Gradle Plugin | 9.0.1 |
| Compose BOM | 2024.09.00 |
| Java source/target compatibility | 11 |
Installation
Option A — Source Module
// settings.gradle.kts
include(":dialogs_ChatbotAV_Android")
// app/build.gradle.kts
dependencies {
implementation(project(":dialogs_ChatbotAV_Android"))
}
Option B — Standalone AAR
dependencies {
implementation(files("libs/dialogs_ChatbotAV_Android-1.0.0.aar"))
}
Required Permissions
Add these to your app's AndroidManifest.xml. They also merge in automatically from the SDK and its bundled AARs, but you should still be aware of them since several require runtime consent.
<!-- Always required — plain chat -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Required only if you enable audio/video call actions (EnxChatConfig(isCallView = true)) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
Before starting a call, request the dangerous runtime permissions yourself:
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
REQUEST_CODE_CALL_PERMISSIONS
)
SYSTEM_ALERT_WINDOW (“display over other apps”) cannot be requested via a runtime dialog — direct users to Settings.ACTION_MANAGE_OVERLAY_PERMISSION if your target devices need the call overlay. On Android 12+, also request BLUETOOTH_CONNECT/BLUETOOTH_SCAN if you want Bluetooth headset routing during calls.
Pattern 1 — Jetpack Compose (Quick Start)
@Composable
fun ChatScreen(botId: String, host: String) {
val config = remember { EnxChatConfig(botId = botId, host = host) }
AndroidView(
factory = { context ->
val activity = context as ComponentActivity
EnxChatClient(context).apply {
bindToLifecycle(owner = activity)
start(config, object : EnxChatBotListener() {
override fun onBotConnected(botConnectedInfo: BotInfo) { /* update your header, etc. */ }
override fun onClose() { /* pop the screen */ }
})
}
}
)
}
Pattern 2 — Compose with a Custom App-Owned Header
var botName by remember { mutableStateOf("Assistant") }
var enxChatClient by remember { mutableStateOf<EnxChatClient?>(null) }
Column {
Row {
Text(botName)
EnxCallView(iconTint = Color(0xFF111B21)) // shows call icons only if isCallView = true
IconButton(onClick = { enxChatClient?.resetConversation() }) { /* refresh icon */ }
}
AndroidView(
factory = { context ->
EnxChatClient(context).apply {
bindToLifecycle(this@MyActivity)
start(
EnxChatConfig(botId = botId, host = host, isCallView = true),
object : EnxChatBotListener() {
override fun onBotConnected(botInfo: BotInfo) {
botName = botInfo.name.ifBlank { "Assistant" }
}
override fun onClose() { finish() }
}
)
enxChatClient = this
}
}
)
}
Pattern 3 — Kotlin (Plain View / Activity)
class ChatActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val config = EnxChatConfig(botId = "YOUR_BOT_ID", host = "https://your-host.example.com")
val chatClient = EnxChatClient(this)
setContentView(chatClient)
chatClient.bindToLifecycle(owner = this)
chatClient.start(config, object : EnxChatBotListener() {
override fun onBotConnected(botConnectedInfo: BotInfo) {}
override fun onClose() { finish() }
})
}
}
Pattern 4 — Java
public class ChatActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EnxChatClient chatClient = new EnxChatClient(this);
setContentView(chatClient);
EnxChatConfig config = new EnxChatConfig("YOUR_BOT_ID", "https://your-host.example.com");
chatClient.bindToLifecycle(this);
chatClient.start(config, new EnxChatBotListener() {
@Override
public void onBotConnected(BotInfo botConnectedInfo) {}
@Override
public void onClose() { finish(); }
});
}
}
Pattern 5 — Fragment-First Apps
val fragment = EnxChatFragment.newInstance(botId = "YOUR_BOT_ID", host = "https://your-host.example.com")
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.commit()
// After the fragment's view is created:
fragment.setUiListener(object : EnxChatBotListener() {
override fun onBotConnected(botConnectedInfo: BotInfo) {}
override fun onClose() {}
})
fragment.bindToHostLifecycle()
fragment.start()
Listening to Richer Events
For anything beyond “connected”/“closed”, use setSessionListener(...) in addition to (or instead of) setUiListener(...):
chatClient.setSessionListener(object : EnxChatSessionListenerAdapter() {
override fun onBotMessage(chatMessage: ChatMessage) { /* render/log */ }
override fun onTyping(isTyping: Boolean) { /* show typing indicator */ }
override fun onError(error: String) { /* surface to user / log */ }
override fun onVideoCallRequested(iFrameUrl: String) { /* SDK auto-launches the call screen; observe only if needed */ }
override fun onNativeUIKitCallRequested(roomToken: String) { /* same — native call path */ }
})
Both call-request callbacks are informational — EnxChatClient/EnxChat already launch EnxVideoCallActivity automatically when the bot requests a call. You do not need to start it yourself.
Sending Messages, Attachments, and Files
// Plain text (via EnxChatSession, if using the headless API)
session.sendMessage("Hello!")
// With a file attachment (image/document/video picked via a system picker Uri)
session.sendMessage(
text = "Here's my document",
attachments = listOf(ChatAttachment(uri = pickedUri, type = AttachmentType.DOCUMENT))
)
EnxChatClient's built-in chat UI (EnxChatScreen) already wires up the attachment picker (camera, gallery, document, emoji) for you — most apps never need to call sendMessage/sendFile directly unless they're building a fully custom UI on top of EnxChatSession/EnxChat.
Resetting a Conversation
chatClient.resetConversation() // EnxChatClient
fragment.resetConversation() // EnxChatFragment
session.resetConversation() // EnxChatSession
controller.resetConversation() // EnxChatController, from EnxChat composable
Tearing Down Correctly
Always prefer bindToLifecycle(owner) so disconnect()/release() happen automatically. If managing lifecycle manually:
override fun onDestroy() {
chatClient.disconnect()
chatClient.release()
super.onDestroy()
}
EnxChatClient also calls release() automatically in onDetachedFromWindow() as a safety net against leaked WebViews.
EnxChatConfig data class · com.dialogs_chatbotav_android.core
data class EnxChatConfig @JvmOverloads constructor(
val botId: String,
val host: String,
val path: String = "",
val isCallView: Boolean = false,
val enableHistory: Boolean = false
)
| Field | Required | Description |
|---|---|---|
botId | Yes | EnableX chatbot identifier. |
host | Yes | Base API host. Must start with http:// or https:// or the SDK reports ENX_CONFIG_INVALID: host must start with http:// or https://. |
path | No | Reserved/advanced routing path. |
isCallView | No | Enables the built-in audio/video call action icons in the chat UI header (see EnxCallView). |
enableHistory | No | Enables conversation history restoration. |
Validation errors reported via the active listener's onError(String): ENX_CONFIG_INVALID: botId is required, ENX_CONFIG_INVALID: host is required, ENX_CONFIG_INVALID: host must start with http:// or https://.
EnxChatState enum
CREATED · INITIALIZING · INITIALIZED · CONNECTING · CONNECTED · DISCONNECTED · ERROR · RELEASED
EnxChatSessionListener interface — all methods have default no-op bodies
interface EnxChatSessionListener {
fun onStateChanged(state: EnxChatState) {}
fun onChatReady() {}
fun onConnected(sessionId: String) {}
fun onConversationReady(conversationId: String) {}
fun onMessageSent(message: String) {}
fun onBotResponse(message: String) {}
fun onBotMessage(chatMessage: ChatMessage) {}
fun onTyping(isTyping: Boolean) {}
fun onDisconnected() {}
fun onBotInfo(botInfo: BotInfo) {}
fun onClear(dataJson: String) {}
fun onConversationReset(dataJson: String) {}
fun onAudioCallClick() {}
fun onVideoCallClick() {}
fun onError(error: String) {}
fun onVideoCallRequested(iFrameUrl: String) {}
fun onNativeUIKitCallRequested(roomToken: String) {}
fun onVideoCallEnded() {}
}
Use EnxChatSessionListenerAdapter (an open class implementing this interface with empty overrides) from Java to override only what you need.
EnxChatSession Level 3 — headless API
class EnxChatSession {
companion object {
@JvmStatic fun create(activity: Activity, config: EnxChatConfig): EnxChatSession
@JvmStatic @JvmOverloads fun create(
activity: Activity, botId: String, host: String,
path: String = "", isCallView: Boolean = false, enableHistory: Boolean = false
): EnxChatSession
}
val state: StateFlow<EnxChatState>
fun setSessionListener(listener: EnxChatSessionListener?)
fun addSessionListener(listener: EnxChatSessionListener)
fun removeSessionListener(listener: EnxChatSessionListener)
fun initialize()
fun connect()
@JvmOverloads fun initializeAndConnect(forceConnectState: Boolean = true)
fun start() // = initializeAndConnect(true)
@JvmOverloads fun sendMessage(text: String, attachments: List<ChatAttachment> = emptyList())
fun disconnect()
fun resetConversation()
fun release()
fun getWebView(): WebView
fun saveState(): Bundle?
fun restoreState(savedState: Bundle?)
fun getCurrentState(): EnxChatState
}
Use this only for headless lifecycle control or custom UI orchestration; standard apps should use EnxChatClient.
EnxChatClient extends android.widget.FrameLayout — Level 1 & 2, recommended API
The default, recommended integration point — usable directly as a Kotlin/Java View, or wrapped in Jetpack Compose via AndroidView.
class EnxChatClient @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
fun setConfig(config: EnxChatConfig)
@JvmOverloads fun start(config: EnxChatConfig, listener: EnxChatBotListener? = null)
@JvmOverloads fun startWithBot(
botId: String, host: String, path: String = "",
isCallView: Boolean = false, enableHistory: Boolean = false,
listener: EnxChatBotListener? = null
)
@JvmOverloads fun bindToLifecycle(
owner: LifecycleOwner, disconnectOnStop: Boolean = false, releaseOnDestroy: Boolean = true
)
fun unbindFromLifecycle()
fun initialize()
fun connect()
fun disconnect()
fun release()
fun getState(): EnxChatState
fun setProfilePictureUrl(url: String?)
fun setHeaderColor(@ColorInt colorInt: Int)
fun setUiListener(listener: EnxChatBotListener?)
fun setSessionListener(listener: EnxChatSessionListener?)
fun resetConversation()
@Deprecated("Use startWithBot(...) or start(EnxChatConfig, listener)")
@JvmOverloads fun setBotIdAndHost(
botId: String, host: String, path: String = "",
isCallView: Boolean = false, enableHistory: Boolean = false
)
}
Standard lifecycle (ready-made mode): start(...) → disconnect() → release()
Lifecycle-safe mode (recommended): bindToLifecycle(owner) + start(...) — disconnect/release handled automatically on onStop/onDestroy
Explicit lifecycle (advanced): initialize() → connect() → disconnect() → release()
EnxChatClient automatically persists/restores WebView state across configuration changes (e.g. screen rotation) via onSaveInstanceState/onRestoreInstanceState, and calls release() automatically in onDetachedFromWindow() as a leak-safety net.
EnxChatBotListener abstract class — simplified, user-facing callbacks
abstract class EnxChatBotListener {
open fun onBotConnected(botConnectedInfo: BotInfo) {}
open fun onClose() {}
}
This is intentionally the smallest listener surface for apps that just want “chat connected” + “user closed chat” signals. For richer events (messages, typing, errors, call requests), use EnxChatSessionListener via setSessionListener(...).
EnxChatFragment extends androidx.fragment.app.Fragment — Level 2
class EnxChatFragment : Fragment() {
companion object {
@JvmStatic @JvmOverloads fun newInstance(
botId: String, host: String, path: String = "",
isCallView: Boolean = false, enableHistory: Boolean = false
): EnxChatFragment
}
fun setUiListener(listener: EnxChatBotListener?)
fun setSessionListener(listener: EnxChatSessionListener?)
@JvmOverloads fun bindToHostLifecycle(disconnectOnStop: Boolean = false, releaseOnDestroy: Boolean = true)
@JvmOverloads fun bindToLifecycle(owner: LifecycleOwner, disconnectOnStop: Boolean = false, releaseOnDestroy: Boolean = true)
fun start()
fun initialize()
fun connect()
fun disconnect()
fun release()
fun resetConversation()
fun getState(): EnxChatState?
}
Use only when your host architecture is fragment-first; internally it just creates and delegates to an EnxChatClient.
Compose-Native API com.dialogs_chatbotav_android.ui.compose
EnxChat (composable)
@Composable
fun EnxChat(
enxChatConfig: EnxChatConfig,
session: EnxChatSession? = null,
sessionListener: EnxChatSessionListener? = null,
controller: EnxChatController? = null,
profilePictureUrl: String? = null,
headerColor: Color = Color.White,
onSdkReady: (() -> Unit)? = null,
onBotInfo: ((BotInfo) -> Unit)? = null,
onAudioCallClick: (() -> Unit)? = null,
onVideoCallClick: (() -> Unit)? = null,
onMessageSent: ((String, List<ChatAttachment>) -> Unit)? = null,
onBotResponse: ((String, List<ChatAttachment>) -> Unit)? = null,
onClose: (() -> Unit)? = null,
modifier: Modifier = Modifier
)
If session is omitted, EnxChat creates and owns its own EnxChatSession internally (recreated automatically if enxChatConfig changes, released on composable disposal). Prefer EnxChatClient via AndroidView unless you specifically need Compose-native customization.
EnxChatController
class EnxChatController {
fun resetConversation()
}
Pass into EnxChat(controller = ...) to trigger a conversation reset imperatively from outside the composable.
EnxCallView (composable)
@Composable
fun EnxCallView(
onAudioCallClick: (() -> Unit)? = null,
onVideoCallClick: (() -> Unit)? = null,
iconTint: Color = Color.Black,
modifier: Modifier = Modifier
)
Renders audio/video call icon buttons for a custom app-owned header; renders nothing unless EnxChatConfig.isCallView = true was used for the active session. If click handlers are omitted, falls back to the handlers registered by the active EnxChat/EnxChatClient instance.
Models
com.dialogs_chatbotav_android.model.chat
enum class AttachmentType { IMAGE, VIDEO, DOCUMENT, EMOJI }
data class ChatAttachment(
val uri: Uri? = null,
val url: String? = null,
val type: AttachmentType,
val fileName: String? = null,
val fileSize: Long? = null,
val mimeType: String? = null
)
data class QuickReply(
val title: String,
val payload: String? = null,
val type: String? = null // "postback" | "url" | "phone_number" | "calendar" ...
)
enum class MessageType {
TEXT, QUICK_REPLY, BUTTONS, IMAGE, VIDEO, AUDIO, FILE, CARD, CAROUSEL, SYSTEM, UNKNOWN
}
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
val isFromUser: Boolean,
val timestamp: Long = System.currentTimeMillis(),
val attachments: List<ChatAttachment> = emptyList(),
val messageType: MessageType = MessageType.TEXT,
val quickReplies: List<QuickReply> = emptyList(),
val buttons: List<QuickReply> = emptyList(),
val allowMultiple: Boolean = false,
val rawData: Map<String, Any>? = null
)
ChatAttachment.type and ChatMessage.isFromUser have no default value — when constructing these from Java or by position, supply them explicitly.
com.dialogs_chatbotav_android.model.botinfo
data class BotInfo(
val showBotInfoPage: Boolean = false,
val name: String = "",
val description: String = "",
val details: BotDetails = BotDetails(),
val startupMessage: String = "",
val selfTriggeredBot: Boolean = false,
val embeddedMediaIframe: String? = null,
val clickToCall: String = "text", // "text" = full chat UI, else minimal click-to-call UI
val agentHuntingStatusMessage: AgentHuntingStatusMessage? = null,
val chatTheme: ChatTheme = ChatTheme(),
val startupForm: StartupForm = StartupForm(),
val languages: List<String> = emptyList(),
val disableNotificationSound: Boolean = false,
val security: Security = Security(),
val lazySocket: Boolean = false,
val maxMessageLength: Int = 1000,
val alwaysScrollDownOnMessages: Boolean = false
)
BotInfo.clickToCall == "text" (default) renders the full chat UI (EnxChatScreen); any other value renders the minimal click-to-call UI (EnxClickToCallMinimalScreen) — a single “Start call” button plus status line, themed from chatTheme.theme.
Nested types: BotDetails (contact/legal URLs, avatar/cover images), AgentHuntingStatusMessage (status copy while waiting for a live agent), ChatTheme/UserTheme/BotTheme/WidgetTheme (colors, bubble style, widget position), StartupForm (pre-chat form field toggles), Security (escapeHTML).
BotInfoParser (internal object) — fun parse(jsonString: String): BotInfo? — parses the raw JSON delivered by Android.onBotInfo(...); returns null on malformed JSON (caught internally, logged, onError is not raised for this specific case).
Error / Event Reference
| Event | Source | Meaning |
|---|---|---|
onError("ENX_CONFIG_INVALID: ...") | EnxChatClient/EnxChatSession | Config validation failed before session start. |
onError("WebView error: <description>") | EnxChatBot (WebViewClient.onReceivedError) | Main-frame WebView load failure. |
onError("Failed to send file: <message>") | EnxChatBot.sendFile | File read/base64/JS-bridge failure while sending an attachment. |
onError("Conversation reset function is not available in web client") | EnxChatBot.resetConversation | Loaded web client version doesn't expose any known reset function. |
onDisconnected() | EnxChatBot.disconnect | Always fires after a disconnect() call, regardless of whether the web client exposed a disconnect function. |
Feature Support (v1.0.0)
| Feature | Supported | Notes |
|---|---|---|
| Three integration levels | Yes | EnxChatClient (View), EnxChatFragment (Fragment), EnxChatSession (headless), plus a Compose-native EnxChat composable. |
Full chat screen (EnxChatScreen) | Yes | Date separators, typing indicator, WhatsApp-style attachment sheet (camera/gallery/document/emoji), quick-reply single- and multi-choice selection, calendar/date-picker quick replies. |
| Rich in-message attachment previews | Yes | Image/video fullscreen viewers; document viewer supporting PDF (paginated), JSON (pretty-printed + syntax highlighting), CSV (table view), HTML, Markdown, and plain/code text, with an “open externally” fallback for unsupported types. |
| Markdown rendering | Yes | Via Markwon (tables + strikethrough extensions). |
| Message de-duplication | Yes | JS bridge safeguards (processedBotMessageKeys/processedHistoryEntryKeys) avoid duplicate bubbles from history replay. |
| Conversation reset detection | Yes | Free-text bot copy (e.g. “reset conversation”) in addition to explicit quick-reply/button reset actions. |
| Response timeout fallback | Yes | A 30-second timeout injects a friendly “having trouble responding” system message if the bot doesn't answer in time. |
| Message queueing while disconnected | Yes | Messages sent before CONNECTED are queued and flushed automatically on connect. |
| Two independent call paths | Yes | Browser/iFrame-based calls (onVideoCallRequested, rendered in a dedicated WebView) and native calls via the EnableX UIKit Android SDK (onNativeUIKitCallRequested, rendered using EnxVideoView/EnxSetting/EnxAudioViewConfig) — both launched via the internal EnxVideoCallActivity. |
| Picture-in-Picture during native calls | Yes | 9:16 aspect ratio, triggered on back-press or onUserLeaveHint. |
| Automatic return-to-chat navigation | Yes | closeAndReturnToChat() brings the host chat Activity back to the foreground when a call ends. |
| File & attachment sending | Yes | Accepts Uri, File, ByteArray, or a raw base64 String; auto-detects file name/MIME type; base64-encodes off the main thread. |
| Config-change survival | Yes | onSaveInstanceState/onRestoreInstanceState persist WebView state across rotation. |
| Audio-only native calls | No | EnxAudioViewConfig is wired but not currently reachable — isAudioOnly is hardcoded false. |
| Automatic JS-bridge re-registration | No | EnxChatBot.setEnxChatListener(...) does not re-register the JS bridge if called after initialization — set your listener before start()/initialize(). |
Architecture Notes
The UI Kit vendors three native AARs directly under libs/ (not resolved via Maven): Enx_UIKit_Android-2.2.3.aar, Enx-Rtc-Android-release.aar, and libwebrtc.aar. Practical consequences for anyone maintaining an integration against a newer UI Kit build:
- No automatic version resolution — upgrading the UI Kit means manually downloading/copying a new
.aarand bumping the referenced filename. - No transitive dependency conflict detection the way Gradle gives you for a
mavenCentral()-hosted artifact — a future UI Kit version bumping its own dependency minimums (e.g. ExoPlayer/Gson) must be discovered and reconciled manually. - A UI Kit upgrade can silently add new required permissions or components — re-verify the merged manifest after any AAR swap.
Threading Model
- All
@JavascriptInterfacecallback methods on the internal JS bridge are invoked by the WebView on a background thread (per Android's WebView JS-bridge contract) — they parse JSON synchronously, then call into your listener. Anything that touches Compose/View state downstream must marshal back to the main thread itself (Compose'smutableStateOf/StateFlowcollectors handle this automatically; raw listener implementations that touch Views directly mustrunOnUiThread/postthemselves). - File sending explicitly launches on
Dispatchers.IOfor file reading/base64 encoding, then switches toDispatchers.Mainbefore calling into the WebView (required — WebView APIs are main-thread-only). EnxChatSession's listener fan-out takes a synchronized snapshot of the listener set before iterating, and wraps each callback in a try/catch so one misbehaving listener can't break delivery to the others or crash the bridge thread.
Known Limitations
- Chat WebView URL is hardcoded to a fixed EnableX-hosted URL; the bundled offline assets are currently unreferenced.
- SSL error handling differs between the chat WebView (blocks on error) and the call WebView (bypasses).
EnxChatBot.setEnxChatListener(...)doesn't re-register the JS interface if called afterinitialize()— only the first-set listener chain reliably receives callbacks post-init.- Audio-only native call UI (
EnxAudioViewConfig) is currently dead code —isAudioOnlyis hardcodedfalse.
Common Configuration Errors
Error (delivered via onError) | Cause | Fix |
|---|---|---|
ENX_CONFIG_INVALID: botId is required | Empty botId | Provide a non-blank botId. |
ENX_CONFIG_INVALID: host is required | Empty host | Provide a non-blank host. |
ENX_CONFIG_INVALID: host must start with http:// or https:// | Missing scheme | Prefix host with https:// (recommended) or http://. |
WebView error: <description> | Network/WebView load failure | Check device connectivity; the chat client itself is loaded from a fixed EnableX-hosted URL, unrelated to your host value. |
| Call button does nothing | isCallView not set, or CAMERA/RECORD_AUDIO not granted | Set EnxChatConfig(isCallView = true) and confirm runtime permissions were granted before the bot requests a call. |