Ethora SDK for Android (ethora-component)
May 11, 2026 · View on GitHub
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.
Table of Contents
- 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
- Logout
- Troubleshooting
- Production Checklist
What You Get
- 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.
Architecture
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.
Requirements
- 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" />
Installation
Option A: JitPack (recommended)
- 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>.
Option B: Source module integration
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"))
}
Host App Setup
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.
Pre-loading data before the Chat tab opens
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.
SDK lifecycle
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 | ⚠️ tolerated — 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.
Logout / shutdown
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 flag
For 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.
Quick Start
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()
)
}
Authentication Modes
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)
JWT login (recommended)
ChatConfig(
jwtLogin = JWTLoginConfig(token = userJwt, enabled = true)
)
Pre-authenticated user
ChatConfig(
userLogin = UserLoginConfig(enabled = true, user = user)
)
Single Room vs Multi Room
Single room mode
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.
Multi-room mode
ChatConfig(disableRooms = false)
Room list is shown first, then chat room screen.
Public APIs for Host UI
Unread state
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.isInitialized
Connection state + reconnect
import 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 Reference
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.
Core fields (actively used)
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.
Fields present but not guaranteed as active behavior
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.
Event and Send Interception
Send interception
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)
}
)
Event stream
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.
Custom UI Components
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.
Push Notifications (FCM)
SDK handles subscription flows, but host app must provide Firebase setup and token lifecycle.
1. Firebase files and plugin
- Add
google-services.jsonto your app module. - Apply
com.google.gms.google-servicesplugin in host app.
2. Register Firebase messaging service
Create a service extending FirebaseMessagingService, then forward token and JID payload:
PushNotificationManager.setFcmToken(token)
PushNotificationManager.setPendingNotificationJid(jid)
3. Initialize push manager
Call once at app start:
PushNotificationManager.initialize(context)
4. Runtime permission (Android 13+)
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.
Persistence and Offline Behavior
- 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.
Logout
Use public service:
import com.ethora.chat.core.ChatService
ChatService.logout.performLogout()
Optional callback:
import com.ethora.chat.core.service.LogoutService
LogoutService.setOnLogoutCallback {
// navigate to logged-out host screen
}
Troubleshooting
Room list empty / unread always 0
- Ensure stores are initialized before rendering
Chat(...). - Ensure user is authenticated (
jwtLoginoruserLogin). - Ensure
baseUrl,appId, and token values are valid.
Chat stuck offline
- Verify
xmppSettings(xmppServerUrl,host,conference). - Confirm websocket endpoint reachable from device/emulator.
- Use
dnsFallbackOverridesif DNS resolution fails in emulator.
Push not delivered
google-services.jsonpackage name must match hostapplicationId.- Ensure FCM token is received and forwarded to
PushNotificationManager.setFcmToken. - Ensure notification permission is granted (Android 13+).
Upload/auth 401 issues
- Ensure user token and refresh token are valid.
- Ensure
customAppTokenis set for your backend app.
File/PDF send stays pending
- 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.
Older history stops loading too early
- 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.
PDF preview fails but download works
- 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.
Testing
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.
Layer 1 — Unit + Compose UI tests (this repo)
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.
Current Compose UI coverage
| 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) |
chat-core unit tests
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)
Layer 2 — End-to-end smoke flows (ethora-sample-android)
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.
Adding a test for a fix or new feature
- 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.
Cross-platform testing overview
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.
Production Checklist
- 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/.