Production-ready Android chat SDK built with Kotlin + Jetpack Compose.
This package ships a complete chat experience (room list + room view + media + reactions + typing + push hooks) and exposes host-facing APIs for configuration, connection state, unread counters, event interception, and UI extension.
- What You Get
- Architecture
- Requirements
- Installation
- Host App Setup
- Quick Start
- Authentication Modes
- Single Room vs Multi Room
- Public APIs for Host UI
- ChatConfig Reference
- Event and Send Interception
- Custom UI Components
- Push Notifications (FCM)
- Persistence and Offline Behavior
- Chat Visibility (Read Markers and Unread Accounting)
- Logout
- Troubleshooting
- Production Checklist
- Compose chat UI with room list and room screen.
- Real-time messaging over XMPP WebSocket.
- History loading + incremental sync after reconnect.
- Unread counters and host-facing connection status hook.
- Media messages (image/video/audio/files), persistent unsent-media retry queue, full-screen image viewer, and native cached PDF preview.
- Message actions: edit, delete, reply, reactions.
- Typing indicators.
- URL auto-linking + URL preview cards.
- Push integration hooks (FCM token/backend subscription + room MUC-SUB flow).
- Local persistence for user/session metadata, rooms, message cache, and scroll position.
- Extensibility hooks: event stream, send interception, custom composables.
This repository contains:
ethora-component: distributable SDK artifact (published to JitPack).chat-core: networking, XMPP, stores, models, persistence, push manager.chat-ui: Compose UI + hooks (Chat,useUnread,useConnectionState,reconnectChat).sample-chat-app: reference app with full integration.
Important: the published artifact is ethora-component, but it packages code from chat-core and chat-ui via source sets.
- Android
minSdk 26 compileSdk 34,targetSdk 34- Java/Kotlin target
17 - Kotlin
2.3.0, AGP8.7.0, Gradle9.4.1 - Host must apply
id("org.jetbrains.kotlin.plugin.compose")(required since Kotlin 2.0 —composeOptions.kotlinCompilerExtensionVersionis no longer used) - Jetpack Compose app (or host screen using Compose)
- Network permissions in host manifest
Host AndroidManifest.xml minimum:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />If using push on Android 13+:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />- Add JitPack repository in project
settings.gradle.kts:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}- Add dependency in app module:
dependencies {
implementation("com.github.dappros:ethora-sdk-android:<version>")
}Use a release tag (e.g. v1.0.31) or commit SHA for <version>.
Copy these folders into your project root:
ethora-componentchat-corechat-ui
Then:
// settings.gradle.kts
include(":ethora-component")// app/build.gradle.kts
dependencies {
implementation(project(":ethora-component"))
}Initialize the SDK once per app process, preferably from Application.onCreate,
before rendering Chat(...) or starting the background bootstrap:
import android.app.Application
import com.ethora.chat.EthoraChatSdk
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
EthoraChatSdk.initialize(this)
}
}EthoraChatSdk.initialize(...) is idempotent. Calling it defensively more than
once in the same process is safe, but do not make Activity or Composable
lifecycle own SDK persistence setup. Android may destroy and recreate an
Activity while keeping the process alive, and persistence should remain
process-scoped.
If you have an existing integration that manually initializes
RoomStore.initialize(...), UserStore.initialize(...), and
MessageStore.initialize(...), those calls remain supported and idempotent.
New integrations should prefer the single initializer above.
If your host shell (Activity, Service, or Application) needs the unread count
before the user has ever opened the chat tab — or if your chat screen is lazily
instantiated and may not mount for a while — call
EthoraChatBootstrap.initializeAsync right after EthoraChatSdk.initialize(...):
import android.app.Application
import com.ethora.chat.EthoraChatBootstrap
import com.ethora.chat.EthoraChatSdk
import com.ethora.chat.core.config.ChatConfig
import com.ethora.chat.core.config.JWTLoginConfig
import com.ethora.chat.core.config.XMPPSettings
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
EthoraChatSdk.initialize(this)
val config = ChatConfig(
appId = "YOUR_APP_ID",
baseUrl = "https://api.your-domain.com/v1",
customAppToken = "JWT <YOUR_APP_TOKEN>",
xmppSettings = XMPPSettings(
xmppServerUrl = "wss://xmpp.your-domain.com/ws",
host = "xmpp.your-domain.com",
conference = "conference.xmpp.your-domain.com"
),
jwtLogin = JWTLoginConfig(token = "<USER_JWT>", enabled = true)
)
EthoraChatBootstrap.initializeAsync(applicationContext, config)
}
}Once the bootstrap finishes, RoomStore.rooms and all unread APIs reflect real
server state — without the Chat composable ever mounting. The same config and
XMPP socket are reused when the user eventually opens the chat tab, so no second
connection is opened. If the user later leaves the chat tab, the shared
bootstrap socket remains alive until explicit logout or
EthoraChatBootstrap.shutdown(), so EthoraChatBootstrap.addUnreadListener(...)
can keep receiving unread changes while the Chat UI is unmounted.
Config validation: if baseUrl or xmppSettings is missing,
EthoraChatBootstrap.initialize aborts immediately, sets
ChatConnectionStatus.ERROR with a descriptive message, and never attempts an
XMPP connection. It will not fall back to any built-in server.
The SDK has three concentric lifecycles. Mixing them up is the most common source of "duplicate DataStore" errors and disappearing unread callbacks, so this section spells out who owns what.
1. Process scope — EthoraChatSdk (persistence, stores). All of
RoomStore, UserStore, MessageStore, MessageCache, LocalStorage, the
PendingMediaSendQueue, ScrollPositionStore and PushNotificationManager
are process-wide singletons backed by a single DataStore<Preferences> per
file. They must be initialized exactly once per process and must use the
application context:
| Where to initialize | OK? |
|---|---|
Application.onCreate() |
✅ recommended |
First Activity's onCreate() as a fallback, with applicationContext |
EthoraChatSdk.initialize is idempotent — but Activity recreation re-runs onCreate, so this only works because the second call short-circuits |
| Per-Activity setup with the Activity context | ❌ will eventually create a duplicate DataStore and throw IllegalStateException: There are multiple DataStores active for the same file |
Inside a Composable (LaunchedEffect, etc.) |
❌ same as above, plus it ties persistence setup to a recomposition you do not control |
EthoraChatSdk.initialize(...) is @Synchronized and guarded by a
@Volatile initialized flag, so calling it again in the same process is a
cheap no-op. The legacy per-store RoomStore.initialize(...) /
UserStore.initialize(...) / MessageStore.initialize(...) calls remain
supported and are individually idempotent — they are kept for backwards
compatibility, but new integrations should use EthoraChatSdk.initialize.
2. Session scope — EthoraChatBootstrap (XMPP client, unread listeners).
The shared XMPP socket and the unread-listener registry live on
EthoraChatBootstrap, not on the Chat composable. A typical app shape:
EthoraChatSdk.initialize(applicationContext) // step 1, once
EthoraChatBootstrap.initializeAsync(appCtx, chatConfig) // step 2, per session
val reg = EthoraChatBootstrap.addUnreadListener { hasUnread ->
updateBadge(hasUnread)
}The unread listener fires regardless of whether the Chat composable is on
screen. The only caller responsible for that lifecycle is the host — when
you no longer need the listener (e.g. on logout) call reg.close().
3. UI scope — the Chat composable. The composable consumes
EthoraChatBootstrap's shared client when one is available; if it is not
available it falls back to creating a connection of its own. On dispose, the
composable disconnects only the client it created itself. The
bootstrap-owned client is left running so unread callbacks continue to fire
while the chat tab is unmounted. This decision is made automatically — there
is no disconnectOnDispose flag to set — and is implemented in
ChatXMPPClientOwnership.shouldDisconnectOnDispose.
To tear down a session — for example on user-driven logout, or in tests between cases — call:
EthoraChatSdk.shutdown()shutdown():
- disconnects the shared bootstrap XMPP client and clears it,
- resets the
InitBeforeLoadFlowandMessageLoadersync flags so the next login re-runs the first-pass history preload, - clears the cached fallback client in
XMPPClientRegistry, - flips
EthoraChatSdk'sinitializedflag so a subsequentEthoraChatSdk.initialize(...)re-runs the real setup.
shutdown() does not delete persisted data: the Room database, DataStore
preferences, encrypted token storage, pending-media files and scroll
positions all survive. This is intentional — pending offline messages and
saved tokens must outlive a logout/login round-trip on the same device.
If you need a fully-awaited teardown (e.g. before exiting a test), use the suspend variant from a coroutine:
EthoraChatBootstrap.shutdownBlocking()
EthoraChatSdk.shutdown() // flips the initialized flagFor a clean wipe of persisted data the host app is responsible for clearing
its own storage (e.g. context.getSharedPreferences(...),
context.deleteDatabase(...)); the SDK does not expose a destructive "wipe
all" entry point because it cannot tell what part of that state is yours and
what part is shared with another logged-in user.
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.fillMaxSize
import com.ethora.chat.Chat
import com.ethora.chat.core.config.ChatConfig
import com.ethora.chat.core.config.ChatHeaderSettingsConfig
import com.ethora.chat.core.config.JWTLoginConfig
import com.ethora.chat.core.config.XMPPSettings
@Composable
fun ChatScreen() {
val config = ChatConfig(
appId = "YOUR_APP_ID",
baseUrl = "https://api.your-domain.com/v1",
customAppToken = "JWT <YOUR_APP_TOKEN>",
disableRooms = false,
chatHeaderSettings = ChatHeaderSettingsConfig(),
xmppSettings = XMPPSettings(
xmppServerUrl = "wss://xmpp.your-domain.com/ws",
host = "xmpp.your-domain.com",
conference = "conference.xmpp.your-domain.com"
),
jwtLogin = JWTLoginConfig(
token = "<USER_JWT>",
enabled = true
)
)
Chat(
config = config,
modifier = Modifier.fillMaxSize()
)
}Current Android Chat(...) init order:
userparam inChat(config, user = ...)config.userLogin(if enabled)config.jwtLogin(if enabled)- persisted JWT token
defaultLoginplaceholder (no built-in email/password UI in SDK)
ChatConfig(
jwtLogin = JWTLoginConfig(token = userJwt, enabled = true)
)ChatConfig(
userLogin = UserLoginConfig(enabled = true, user = user)
)ChatConfig(disableRooms = true)Then pass room JID:
Chat(config = config, roomJID = "roomname@conference.example.com")Behavior:
- SDK opens room directly.
- Header back button is hidden automatically in single-room flow.
ChatConfig(disableRooms = false)Room list is shown first, then chat room screen.
Compose:
import com.ethora.chat.useUnread
val unread = useUnread(maxCount = 99)
// unread.totalCount: Int
// unread.displayCount: String (e.g. "99+")Kotlin / Java UI outside Compose:
import com.ethora.chat.EthoraChatBootstrap
import kotlinx.coroutines.flow.Flow
// Boolean: true whenever any room has at least one unread message.
val hasUnreadFlow: Flow<Boolean> = EthoraChatBootstrap.hasUnread()
val registration = EthoraChatBootstrap.addUnreadListener { hasUnread ->
// toggle native tab dot / icon state
}
registration.close()Java (from Activity, Service, or any non-Compose context):
// Register — listener receives boolean (true = at least one unread, false = none).
AutoCloseable reg = EthoraChatBootstrap.addUnreadListener(hasUnread -> updateChatBadge(hasUnread));
// Unregister (e.g. in onDestroy or on logout)
reg.close();The boolean shape matches typical host integrations — a tab dot, an icon
state-list, or a setVisibility — that only need "is there anything to see"
rather than a precise number. If you do need the actual count from outside
Compose, observe RoomStore.rooms directly and sum unreadMessages.
Observe whether the background bootstrap has finished:
// StateFlow<Boolean> — true once EthoraChatBootstrap.initialize() completes
EthoraChatBootstrap.isInitializedimport com.ethora.chat.reconnectChat
import com.ethora.chat.useConnectionState
val connection = useConnectionState()
// connection.status: OFFLINE | CONNECTING | ONLINE | DEGRADED | ERROR
// connection.reason: String?
// connection.isRecovering: Boolean
reconnectChat()ChatConfig contains many fields for cross-platform parity. Not every field is fully wired in this Android package version.
baseUrl and xmppSettings (with xmppServerUrl, host, conference) are required by the SDK itself — without them HTTP and XMPP cannot be wired. If any of these is absent or invalid, the Chat composable renders a ConfigErrorScreen with the validation reason and EthoraChatBootstrap.initialize aborts with ChatConnectionStatus.ERROR. The SDK does not fall back to any built-in or Ethora-hosted endpoint — a missing baseUrl is a hard failure, not a redirect.
appId is forwarded as the x-app-id header to /users/login, /users/client, /chats/my, and /push/subscription/{appId}. Whether it is required depends on your server — set it if your backend enforces it, omit it otherwise.
appId,baseUrl,customAppTokenxmppSettings,dnsFallbackOverridesjwtLogin,userLogin,defaultLogindisableRooms,defaultRoomsdisableHeader,disableMediachatHeaderSettings.roomTitleOverrideschatHeaderSettings.chatInfoButtonDisabledchatHeaderSettings.backButtonDisabledcolors,bubleMessage,backgroundChatdisableProfilesInteractionseventHandlers,onChatEvent,onBeforeSendcustomComponentsinitBeforeLoad— whentrue, the SDK runs the web-parity bootstrap (user fetch → rooms fetch → XMPP connect → private-store sync → per-room history preload) souseUnread()reports real counts before theChatcomposable mounts. Drive it viaEthoraChatProvider(wrap your app root) orEthoraChatBootstrap.initializeAsync(context, config)fromApplication.onCreate.retryConfig—RetryConfig(autoRetry: Boolean = false, maxAttempts: Int = 3). Controls whether failed text/media sends are silently retried in the background. Default isautoRetry = false— failed messages stay in the "Sending failed. Tap to retry or delete." state until the user acts. Manual user-initiated retry via the message context menu is always allowed regardless of this flag. PassRetryConfig(autoRetry = true)to restore the legacy silent-retry behaviour.
These exist in model/API for parity, but Android behavior may be partial or no-op depending on release:
googleLogin,customLoginchatHeaderBurgerMenu,forceSetRoom,setRoomJidInPathdisableRoomMenu,disableRoomConfig,disableNewChatButtoncustomRooms,roomListStyles,chatRoomStylesheaderLogo,headerMenu,headerChatMenurefreshTokens,translatesdisableUserCount,clearStoreBeforeInit,disableSentLogic,newArch,qrUrlsecondarySendButton,enableRoomsRetry,chatHeaderAdditionalbotMessageAutoScroll,messageTextFilter,whitelistSystemMessage,customSystemMessagedisableTypingIndicator,customTypingIndicatorblockMessageSendingWhenProcessing,disableChatInfouseStoreConsoleEnabled,messageNotifications
If you need guaranteed support for one of these parity fields, validate against your target SDK tag/commit and test in your integration.
import com.ethora.chat.core.config.OutgoingSendInput
import com.ethora.chat.core.config.SendDecision
val config = ChatConfig(
onBeforeSend = { input: OutgoingSendInput ->
val blocked = input.text?.contains("forbidden-word", ignoreCase = true) == true
if (blocked) SendDecision.Cancel else SendDecision.Proceed(input)
}
)import com.ethora.chat.core.config.ChatEvent
val config = ChatConfig(
onChatEvent = { event ->
when (event) {
is ChatEvent.MessageSent -> { /* analytics */ }
is ChatEvent.MessageFailed -> { /* observability */ }
is ChatEvent.ConnectionChanged -> { /* host banner */ }
else -> Unit
}
}
)ChatEvent types include message sent/failed/edited/deleted, reaction, media upload result, connection state changes.
Provide custom composables through customComponents to override:
- message rendering
- message actions
- input area
- room list item
- scrollable area/day separator/new message label
See com.ethora.chat.core.models.CustomComponents for exact signatures.
SDK handles subscription flows, but host app must provide Firebase setup and token lifecycle.
- Add
google-services.jsonto your app module. - Apply
com.google.gms.google-servicesplugin in host app.
Create a service extending FirebaseMessagingService, then forward token and JID payload:
PushNotificationManager.setFcmToken(token)
PushNotificationManager.setPendingNotificationJid(jid)Call once at app start:
PushNotificationManager.initialize(context)Request POST_NOTIFICATIONS permission from host app.
Notes:
Chat(...)consumesPushNotificationManager.fcmTokenand subscribes backend/rooms when user + XMPP are ready.- Opening notification with
notification_jidallows SDK to navigate to the room when rooms are loaded.
- Rooms/user/tokens persisted via DataStore (
ChatPersistenceManager). - Messages persisted in Room DB (
chat_database, tablemessages) viaMessageStore+MessageCache. - Unsent media/file messages are persisted separately from chat history via
PendingMediaSendQueue.- queued items keep their optimistic message visible
- queued items also persist
captionandreplyToMessageId, so attachment+text sends can resume after process restart without losing the follow-up text or reply target - upload/XMPP failures show an inline warning with Retry/Delete actions
- uploaded payloads are reused when only the XMPP send step failed
- app-private pending files are removed after send success, user discard, or logout cleanup
- Failed-send behaviour is governed by
RetryConfig(see ChatConfig Reference):autoRetry = false(default): a failed text or media send is marked permanently failed immediately. The bubble shows "⚠ Sending failed. Tap to retry or delete." and the failure persists across reconnects until the user explicitly retries or deletes. TheMessage.sendFailed: Boolean?flag carries this state.autoRetry = true: silent background retries up tomaxAttempts, with exponential backoff for media. After the limit, the same persistent failed state takes over.- Manual user retry via the message context menu is always available (Copy + Retry + Delete) regardless of
autoRetry. Editing is intentionally hidden for unsent messages — they were never on the server.
- Text sends attempted while offline stay visible as failed bubbles so users can retry or delete them instead of losing the draft silently. Deleting a never-sent message (pending or send-failed) removes it locally only, no XMPP delete is dispatched.
- Combo send: when
ChatInputhas both an attachment and text staged, one Send tap dispatches two messages (media first, then text). Each appears as an optimistic bubble immediately and confirms or fails independently. - Loader behavior:
- cache-first rooms/messages for fast startup
- API refresh for rooms
- initial XMPP history load per room
- incremental sync after reconnect
- DNS fallback map supported via
dnsFallbackOverridesfor emulator/network edge cases. Only explicit host-provided overrides are used — there is no built-in fallback to any Ethora-hosted server.
The SDK keeps lastViewedTimestamp per-room in the XMPP private store
(XEP-0049 <chatjson xmlns="chatjson:store"/>). It's read once at bootstrap
to seed the unread baseline, and written whenever the user stops actively
viewing a room so other devices (web, iOS) reconcile against the same
"last read at" marker.
The Chat composable combines three Compose-native signals to decide whether
the user is actively viewing:
Modifier.onGloballyPositionedon the chat root — clipped bounds drop to zero when the host hides the composable inside a 0-sizeBox, an off-screenHorizontalPagerpage, etc.LocalWindowInfo.isWindowFocused— flips false on Dialog, BottomSheet, notification shade and other modal-focus takeovers.Lifecycle.State >= RESUMED— false when the app is backgrounded (Home, Recents, screen lock, kill).
When all three are true → room is active, unread is zero, no server traffic.
Any of them false → local lastViewedTimestamp is bumped to now,
currentRoom is cleared (so new messages count toward the badge), and the
read marker is flushed to the XMPP private store on a long-lived SDK scope.
On return to true the active-room state is restored.
Some host integrations don't trigger any of the auto-detected signals. The two cases that need an explicit call:
- Bottom-nav / tab swap inside one Activity that keeps
Chatcomposed in another tab as a badge listener — the Activity stays RESUMED, the window stays focused, and Compose only knows the composable is still in the layout tree. - Host renders an opaque composable on top of
Chatin the same window (Compose has no z-order awareness for visibility).
For those, route your own visibility events through:
import com.ethora.chat.core.ChatService
// Host's bottom-nav handler:
override fun onTabSelected(tab: Tab) {
if (tab == Tab.CHAT) ChatService.lifecycle.onChatResumed()
else ChatService.lifecycle.onChatPaused()
}
// Or a Fragment with onHiddenChanged:
override fun onHiddenChanged(hidden: Boolean) {
if (hidden) ChatService.lifecycle.onChatPaused()
else ChatService.lifecycle.onChatResumed()
}onChatPaused() writes the read marker for RoomStore.currentRoom to the
server, drops the active-room shortcut, and remembers the room jid so
onChatResumed() can restore it. Both calls are idempotent and a no-op when
no room is active.
Badge-listener integrations that never mount the Chat composable on the
listener path (so RoomStore.currentRoom is always null from the SDK's
perspective) can pass the room JID explicitly:
val listenerRoomJid = "abc..._roomid@conference.xmpp.example.com"
// Host knows its listener room — call with the JID so the SDK doesn't
// need RoomStore.currentRoom to be set.
ChatService.lifecycle.onChatPaused(listenerRoomJid)
ChatService.lifecycle.onChatResumed(listenerRoomJid)Either form (no-arg + currentRoom, or explicit roomJid) flushes through
InitBeforeLoadFlow.writeCurrentTimestampAsync on a long-lived SDK scope
and reaches the XMPP socket reliably even if the caller's own scope is
about to be torn down.
You do not need to also pass roomJID = null to the Chat composable —
that renders "No room configured" and tears down the listener you wanted to
keep running.
Use public service:
import com.ethora.chat.core.ChatService
ChatService.logout.performLogout()On logout the SDK first flushes the active room's lastViewedTimestamp to
the XMPP private store (in parallel with in-memory store clearing so the
unread badge drops instantly), then disconnects the XMPP socket, then clears
persistence. The early-bootstrap on next cold start is gated on the host
having persisted isConnected=true; a clean Disconnect therefore does not
re-fetch rooms or re-render the unread badge until the host explicitly logs
in again.
Optional callback:
import com.ethora.chat.core.service.LogoutService
LogoutService.setOnLogoutCallback {
// navigate to logged-out host screen
}- Ensure stores are initialized before rendering
Chat(...). - Ensure user is authenticated (
jwtLoginoruserLogin). - Ensure
baseUrl,appId, and token values are valid.
- Verify
xmppSettings(xmppServerUrl,host,conference). - Confirm websocket endpoint reachable from device/emulator.
- Use
dnsFallbackOverridesif DNS resolution fails in emulator.
google-services.jsonpackage name must match hostapplicationId.- Ensure FCM token is received and forwarded to
PushNotificationManager.setFcmToken. - Ensure notification permission is granted (Android 13+).
- Ensure user token and refresh token are valid.
- Ensure
customAppTokenis set for your backend app.
- The SDK keeps unsent media visible and offers Retry/Delete on failed bubbles.
- Check
useConnectionState()and XMPP logs if items remain failed. - If a queued local file was deleted by host cleanup, the message can be discarded by the user.
- Current builds keep pagination alive until the room is explicitly marked
historyComplete=true. - A transient empty MAM page no longer flips the UI into a permanent "no more history" state.
- If older messages still do not load, verify the XMPP connection is fully established before the scroll-to-top pagination path runs.
- Current builds use native
PdfRenderer; no hosted PDF.js page is required. - Confirm the file URL returns valid PDF bytes and is reachable from the device.
The SDK uses a two-layer testing strategy, with each layer pinned to the codebase it tests so changes ship in the same PR as the test that exercises them.
This Android SDK is one of four runtime targets that share a single
selector contract — Compose testTag strings here match SwiftUI
accessibilityIdentifier strings on iOS and data-testid attributes
on Web, so a Maestro YAML flow drives all three platforms by the same
ID. See Cross-platform testing overview
at the end of this section.
Live alongside the source they exercise; surfaced under Studio's "Tests" tab so they don't clutter main source.
| Where | What | Run with |
|---|---|---|
chat-core/src/test/ |
Pure-JVM unit tests — JID parsing, message serializers, store reducers, networking helpers | ./gradlew :chat-core:test |
chat-ui/src/androidTest/ |
Compose UI tests using androidx.compose.ui.test — render a composable in isolation, drive it with callbacks, assert behavior |
./gradlew :chat-ui:connectedDebugAndroidTest |
ethora-component/src/test/ |
Aggregate-module unit tests covering cross-module behavior (UnreadObserver, single-room support, XMPP client ownership, file size formatting, DNS fallback, pending-media queue, pagination edge cases, optimistic-send reconciliation, log export formatter) | ./gradlew :ethora-component:test |
The Compose UI tests run on a connected emulator or device (API 26+). They're hermetic — no network, no XMPP, no FCM. End-to-end flows that need a real server go in Layer 2.
chat-ui/src/androidTest/java/com/ethora/chat/ui/components/ChatInputTest.kt
is the canonical example to copy when adding a new Compose UI test.
| Component | Test | Asserts |
|---|---|---|
ChatInput |
rendersInputAndFiresCallbackOnSend |
Type → tap Send → onSendMessage callback fires with the typed text |
ChatInput |
emptyInputShowsSendIconWithoutFiringCallback |
Empty field shows a disabled Send icon; tap is a no-op |
ChatInput |
editModePrePopulatesText |
editText="..." prop is rendered in the field on first composition |
ChatInput |
replyPreviewShowsAndCancelFiresCallback |
replyingToMessage → preview body visible → "Cancel reply" fires onReplyCancel |
LogsView |
rendersFilterFieldAndLogEntries |
Entries pushed via LogStore.info(...) render with the "Filter logs" field visible |
LogsView |
queryFilterHidesNonMatchingEntries |
Typing into the filter hides non-matching entries |
MessageBubble |
rendersBodyText |
Outgoing bubble renders its body text |
MessageBubble |
rendersAuthorNameForIncomingMessage |
Incoming bubble (isUser=false) shows author + body |
MessageBubble |
rendersDeletedTombstone |
Bubble composes without crashing for isDeleted=true |
MessageBubble |
rendersSendFailedState |
Bubble composes without crashing for sendFailed=true |
MessageContextMenu |
rendersNothingWhenInvisible |
visible=false short-circuits — no Copy/Edit/Delete labels in composition |
MessageContextMenu |
rendersNothingForDeletedMessage |
isDeleted=true short-circuits even with visible=true |
MessageContextMenu |
ownMessageShowsCopyEditDelete |
isUser=true + non-pending → Copy, Edit, Delete; no Retry |
MessageContextMenu |
receivedMessageShowsCopyOnly |
isUser=false → Copy only, no Edit/Delete |
MessageContextMenu |
pendingOwnMessageWithResendHandlerOffersRetry |
pending=true + onResend!=null → Retry replaces Edit |
MessageContextMenu |
sendFailedOwnMessageOffersRetry |
sendFailed=true → Retry replaces Edit (auto-timer path) |
MessageContextMenu |
pendingMessageWithoutResendHandlerOffersEditNotRetry |
Missing onResend → falls back to Edit |
MessageContextMenu |
tappingCopyFiresOnCopyAndOnDismiss |
Tap Copy → both onCopy and onDismiss callbacks fire (auto-close) |
MessageContextMenu |
ownMediaMessageHidesEditButKeepsCopyAndDelete |
Sent media bubbles stay non-editable while still exposing Copy/Delete |
MessageContextMenu |
pendingMediaMessageOffersRetryNotEdit |
Pending media bubble shows Retry instead of Edit |
MessageContextMenu |
ownMessageRendersExactlyThreeMenuItems |
Regression guard: own-message menu has exactly 3 items (Copy, Edit, Delete) |
Pure-JVM, no emulator. Run with ./gradlew :chat-core:test.
| Module | Test class | Asserts |
|---|---|---|
chat-core |
TimestampUtilsTest |
14 tests covering the s/ms/µs/ns ladder, ISO-8601 + XEP-0091 string parsing, embedded-digit extraction, null/Date/Number/String type dispatch, and zero-clamping on invalid input |
chat-core |
XmppXmlUtilsTest |
Expanded parser coverage for extractDataElement, extractAttribute, extractBody, and unwrapMucSubInnerMessage, including XML-entity decoding, <body> tags with attributes / namespaces, and MUC-SUB pubsub wrappers |
chat-core |
RoomStoreTest |
Covers setRooms, addRoom, updateRoom, removeRoom, setCurrentRoom, getRoomById / getRoomByJid, upsertRoom, clear, plus parked lastViewedTimestamp application when room metadata arrives later |
chat-core |
MessageStoreTest |
Covers per-room isolation, append vs dedup behavior, bidirectional optimistic/server id reconciliation, bulk insert ordering, deleted-message tombstones, and clear paths |
ethora-component |
MessageSpamTest |
Regression coverage for rapid optimistic sends, out-of-order echoes, late-echo recovery for sendFailed messages, and duplicate suppression once delivery is already confirmed |
ethora-component |
PendingMediaSendQueueTest |
Queue codec coverage for retry transitions, filename sanitization, and persistence of caption / replyToMessageId across encode-decode round trips |
ethora-component |
ChatRoomViewModelTest |
Pagination guard: an empty MAM page before historyComplete=true is treated as transient, not as hard end-of-history |
Gaps still to cover at this layer (file an issue + a test in the same PR when you tackle one):
RoomListView— search behavior, active-room highlight, badge countsFullScreenImageViewer— zoom + pan + closePDFViewer— page navigation + render-on-low-memory fallbackChatInfoScreen— participants list, leave-room flowchat-coreXMPPClientstate machines — BIND-result handling, MAM subscription, reconnect on socket drop (testable with a stubbed transport)chat-coresend-failed timeout path inMessageStore.schedulePendingTimeout(useskotlinx.coroutines.delay— needs a coroutine TestScheduler)- Tombstone / failed-state explicit string assertions on
MessageBubble(TODOs inMessageBubbleTest.ktflag where to tighten once the SDK exposes stable strings)
Maestro YAML scenarios that drive the sample app on a real
emulator/device against chat-qa.ethora.com: login → list rooms → send
text → receive text → reconnect → push intent → logout. Live in
ethora-sample-android/.maestro/
because they need a built APK, not SDK source.
These run on the sample's CI on every release tag of the SDK — that's the gate that catches integration regressions like config drift, preset URL breakage, or feature parity gaps with iOS/Web.
- Behavior bug in
chat-coreorchat-ui→ add a unit / Compose UI test in this repo, in the same PR as the fix. - Integration bug (something the SDK exposes but the sample
consumes) → add a Maestro flow in
ethora-sample-android/.maestro/, in a paired PR to that repo. - Cross-platform parity gap → add the matching test to all three (Android Maestro, iOS Maestro, Web Playwright). The selector contract below makes the test bodies near-identical across platforms.
Four runtime targets, one selector contract. Same test intent runs against any of them via Maestro (mobile) or Playwright (web).
| Layer 1 (hermetic) | Layer 2 (E2E) |
|---|---|
ethora-sdk-android — Compose UI tests in chat-ui/src/androidTest/ (this repo) |
ethora-sample-android/.maestro/ — 19 Maestro flows on Android emulator |
ethora-sdk-swift — XCTest in Tests/XMPPChatCoreTests/ + accessibilityIdentifier markers in XMPPChatUI/ |
ethora-sample-swift/.maestro/ — same 19 Maestro flows on iOS Simulator |
ethora-chat-component — Vitest + RTL in src/**/*.test.tsx with data-testid attrs |
ethora-app-reactjs/tests/e2e/ — Playwright on chromium |
Selector parity (a Maestro id: "chat_input" matches all of these):
| String | Android (*TestTags) |
iOS (*AccessibilityID) |
Web (*TestIds) |
|---|---|---|---|
chat_input |
ChatInputTestTags.INPUT_FIELD |
ChatInputAccessibilityID.inputField |
ChatInputTestIds.inputField |
chat_send_button |
ChatInputTestTags.SEND_BUTTON |
ChatInputAccessibilityID.sendButton |
ChatInputTestIds.sendButton |
chat_attach_button |
ChatInputTestTags.ATTACH_BUTTON |
ChatInputAccessibilityID.attachButton |
ChatInputTestIds.attachButton |
chat_message_image |
MessageBubbleTestTags.MEDIA_CONTENT |
MessageBubbleAccessibilityID.mediaContent |
MessageBubbleTestIds.mediaContent |
rooms_list |
RoomListViewTestTags.ROOMS_LIST |
RoomListAccessibilityID.roomsList |
RoomListTestIds.roomsList |
room_row |
RoomListViewTestTags.ROOM_ROW |
RoomListAccessibilityID.roomRow |
RoomListTestIds.roomRow |
rooms_search_input |
RoomListViewTestTags.SEARCH_INPUT |
(system search bar, no ID) | RoomListTestIds.searchInput |
create_room_button |
RoomListViewTestTags.CREATE_ROOM_BUTTON |
RoomListAccessibilityID.createRoomButton |
RoomListTestIds.createRoomButton |
Changing any value above is a 4-repo change. The cost of that coupling is the benefit — a renamed tag breaks four CI runs the same week, not silently rotting in one of them.
- Always provide your own
baseUrl,appId,xmppSettings, and production token values. - The SDK does not redirect to built-in Ethora endpoints when configuration is missing.
- Pin SDK dependency to a tag/commit you have tested.
- Add host analytics via
onChatEvent. - Add host moderation/compliance hooks via
onBeforeSend. - Validate push flow end-to-end on real devices.
If you need this README split into docs per audience (integration, config reference, push, migration), keep this as root overview and move deep dives into docs/.