Ethora SDK for Swift (ethora-sdk-swift)
May 10, 2026 · View on GitHub
Production-ready iOS chat SDK with:
XMPPChatCorefor auth, API, XMPP transport, stores, and messaging operationsXMPPChatUIfor ready-made SwiftUI chat UI on top of the core
This repository ships as a Swift Package with both products.
Table of Contents
- What You Get
- Requirements
- Installation
- Quick Start (Recommended)
- Authentication Flows
- Using
XMPPChatCoreWithout UI - Logging Out from Your Host App
- Configuration Reference (
ChatConfig) - Core API Reference
- Push Notifications (FCM)
- Persistence and Stores
- Examples in This Repo
- Production Notes and Pitfalls
- Build and Validation
What You Get
XMPPChatCore
- XMPP over WebSocket (
Starscream) - Connection lifecycle and reconnect logic
- Presence handling (global + per-room)
- Message operations: text, media metadata, reactions, edit, delete, typing, history (MAM)
- REST APIs:
- auth (
loginWithEmail,loginViaJwt, token refresh) - rooms (fetch/create/private/create member actions/report/delete)
- file upload
- push registration
- auth (
- Global stores (
ConfigStore,UserStore,RoomStore) - Push subscription orchestration for backend + room-level subscriptions
XMPPChatUI
- Drop-in
ChatWrapperView - Room list and single-room mode
- Chat room screen with message list, media rendering, typing indicator, status views
- Modals for profile/settings/report/member actions
- Empty/loading/error states and retry surfaces
- Unread count callback for host-app badge sync
Requirements
- iOS 15+
- Swift tools 5.9+
- Xcode with Swift Package Manager support
Installation
Option 1: Xcode UI (most common)
File->Add Package Dependencies...- Enter:
https://github.com/dappros/ethora-sdk-swift
- Add products to your app target:
XMPPChatCoreXMPPChatUI
Option 2: Package.swift
// Package.swift
.dependencies([
.package(url: "https://github.com/dappros/ethora-sdk-swift", branch: "main")
]),
.targets([
.target(
name: "YourAppTarget",
dependencies: [
.product(name: "XMPPChatCore", package: "ethora-sdk-swift"),
.product(name: "XMPPChatUI", package: "ethora-sdk-swift")
]
)
])
Option 3: Manual source copy (enterprise/offline)
Copy:
Sources/XMPPChatCoreSources/XMPPChatUI
If you do this, ensure dependency parity for core transport (Starscream).
Quick Start (Recommended)
This path is fastest for production embedding.
1. Import modules
import SwiftUI
import XMPPChatCore
import XMPPChatUI
2. Build config
private func makeChatConfig() -> ChatConfig {
var config = ChatConfig()
// API/XMPP
config.baseUrl = "https://api.chat.ethora.com/v1"
config.appId = "YOUR_APP_ID"
config.customAppToken = "YOUR_ETHORA_APP_TOKEN"
config.xmppSettings = XMPPSettings(
xmppServerUrl: "wss://xmpp.chat.ethora.com:5443/ws",
host: "xmpp.chat.ethora.com",
conference: "conference.xmpp.chat.ethora.com"
)
// Auth (JWT autologin via /users/client)
config.jwtLogin = JWTLoginConfig(token: "YOUR_CLIENT_JWT", enabled: true)
// Optional: single-room mode
config.disableRooms = true
return config
}
3. Render chat
struct ChatScreen: View {
private let roomJID = "my-room@conference.xmpp.chat.ethora.com"
var body: some View {
let config = makeChatConfig()
ChatWrapperView(
config: config,
initialRoomJID: roomJID,
onUnreadCountChanged: { totalUnread in
// Sync tab badge / app badge
print("Unread: \(totalUnread)")
}
)
.onAppear {
// Keep global config synced for other stores/components
ConfigStore.shared.mergeConfig(config)
}
}
}
Authentication Flows
A) JWT login (recommended for host apps)
- Set
config.jwtLogin = JWTLoginConfig(token: ..., enabled: true) ChatWrapperViewModelperforms autologin throughAuthAPI.loginViaJwt(clientToken:)
B) Email/password
let response = try await AuthAPI.loginWithEmail(
email: email,
password: password,
baseURL: URL(string: "https://api.chat.ethora.com/v1")!,
appToken: "YOUR_ETHORA_APP_TOKEN"
)
await UserStore.shared.setUser(from: response)
C) Preloaded user
var config = ChatConfig()
config.userLogin = UserLoginConfig(enabled: true, user: preAuthenticatedUser)
Headless Unread (no chat UI mounted)
Use this when the host app needs a live unread badge while the chat
screen is closed — equivalent to a useChatHeadless() + useUnreadCount()
pair in React.
import XMPPChatCore
@MainActor
final class UnreadHost: ObservableObject {
@Published private(set) var totalUnread: Int = 0
private let bridge = UnreadStateBridge()
private var cancellable: AnyCancellable?
init() {
cancellable = bridge.$totalUnreadCount
.receive(on: RunLoop.main)
.assign(to: \.totalUnread, on: self)
}
func startSession(config: ChatConfig) {
ChatHeadlessSession.shared.start(config: config)
}
func stopSession() async {
await ChatHeadlessSession.shared.stop()
}
}
ChatHeadlessSession.shared.start(config:) runs the same auth → XMPP →
rooms-sync → MUC presence → unread recompute pipeline that
ChatWrapperView does internally, but without rendering anything. The
created XMPPClient is registered with ClientRegistry, so a later
ChatWrapperView mount reuses the same socket — no duplicate
presences/subscriptions.
Call stop() on logout. Do not call it while ChatWrapperView is on
screen.
Using XMPPChatCore Without UI
Use this when you need a custom UI while reusing transport + operations.
import XMPPChatCore
let settings = XMPPSettings(
xmppServerUrl: "wss://xmpp.chat.ethora.com:5443/ws",
host: "xmpp.chat.ethora.com",
conference: "conference.xmpp.chat.ethora.com"
)
let client = XMPPClient(
username: "user@xmpp.chat.ethora.com",
password: "xmppPassword",
settings: settings
)
client.delegate = self
// Wait until fully connected if needed
while !client.isFullyConnected() {
try? await Task.sleep(nanoseconds: 300_000_000)
}
await client.sendPresenceToRoom(roomJID: "room@conference.xmpp.chat.ethora.com")
client.operations.sendTextMessage(
roomJID: "room@conference.xmpp.chat.ethora.com",
firstName: "John",
lastName: "Doe",
photo: "",
walletAddress: "",
userMessage: "Hello"
)
client.operations.sendGetHistory(
chatJID: "room@conference.xmpp.chat.ethora.com",
max: 20,
before: nil
)
Logging Out from Your Host App
LogoutManager is a public, idempotent entry point you can call from
anywhere in your app — most commonly from your own logout button so that
signing out of your app also signs out of chat.
It runs the full chat teardown in a fixed order:
- Resets push (FCM token + mucsub room subscriptions) while the XMPP stream is still alive.
- Disconnects XMPP.
- Clears the global
XMPPClientfromClientRegistry. - Clears caches:
RoomStore,MessageCache, unread / last-read keys, pending push JID. - Clears the user and tokens from
UserStore. - Optionally resets
ChatConfig(off by default — host apps typically keep their API/XMPP settings for the next login).
Async/await (recommended)
import XMPPChatCore
func appLogout() async {
// ...your own host-app logout (revoke server session, clear keychain, etc.)...
await LogoutManager.shared.logout()
}
Callback API
LogoutManager.shared.logout(client: nil) {
// Chat logout finished — navigate to your login screen.
}
With confirmation
LogoutManager.shared.logoutWithConfirmation(
client: nil,
showConfirmation: { confirm in
// Present an alert; call confirm(true) on OK, confirm(false) on cancel.
},
onCompletion: {
// Chat logout finished.
}
)
Fine-grained control
Options lets you skip individual steps. Defaults perform a full
logout while keeping ChatConfig.
await LogoutManager.shared.logout(
options: .init(
disconnectXMPP: true,
resetPush: true,
clearUser: true,
clearCaches: true,
resetConfig: false // set to true to also wipe ChatConfig
)
)
Notes
- The
clientparameter is optional. When omitted,LogoutManagerpicks up the current client fromClientRegistry. - Safe to call when the user is already logged out — every step is a no-op in that case.
- If you used
ChatHeadlessSession, callawait ChatHeadlessSession.shared.stop()before invoking logout (or rely on logout to disconnect XMPP — it will still tear down correctly, butstop()is the cleaner pairing).
Configuration Reference (ChatConfig)
ChatConfig has many options. Key groups below.
Core connectivity
baseUrl: REST base URLappId: app id used in room/push requestscustomAppToken: app token for app-scoped auth requestsxmppSettings:XMPPSettings(WebSocket URL, host, conference)
XMPPSettings fields:
xmppServerUrl: preferred server URL keydevServer: legacy alias, kept for compatibilityhostconferencexmppPingOnSendEnabled
Login/auth config
googleLogin: GoogleLoginConfigjwtLogin: JWTLoginConfiguserLogin: UserLoginConfigcustomLogin: CustomLoginConfigrefreshTokens: RefreshTokensConfig
UI/UX toggles
disableHeader,disableMedia,disableRoomsdisableInteractions,disableRoomMenu,disableRoomConfig,disableNewChatButtondisableProfilesInteractions,disableUserCount,disableTypingIndicatordisableChatInfo: DisableChatInfoConfigchatHeaderSettings: ChatHeaderSettingsConfigenableRoomsRetry: EnableRoomsRetryConfig
Styling
colors: ChatColors(primary,secondary)backgroundChat: BackgroundChatConfigbubleMessage: MessageBubbleStyleroomListStyles,chatRoomStyles(dynamic dictionaries)headerLogo
Message pipeline and behavior hooks
messageTextFilter: MessageTextFilterConfigsecondarySendButton: SecondarySendButtonConfigcustomTypingIndicator: CustomTypingIndicatorConfigblockMessageSendingWhenProcessing: BlockMessageSendingConfigeventHandlers: ChatEventHandlersmessageNotifications: MessageNotificationConfigcustomComponents: CustomComponentsProtocol
Rooms and data behavior
defaultRooms,customRoomsforceSetRoom,setRoomJidInPath,chatHeaderBurgerMenuclearStoreBeforeInit,disableSentLogic,initBeforeLoad,newArchbotMessageAutoScrollwhitelistSystemMessagetranslates: TranslationsConfigpush: PushNotificationConfig
Important persistence detail
ConfigStore persists only codable fields. Closure- and AnyView-based options are runtime-only and are not serialized.
Core API Reference
Auth
AuthAPI.loginWithEmail(email:password:baseURL:appToken:useEthoraJwtWordPrefix:)AuthAPI.loginViaJwt(clientToken:baseURL:)AuthAPI.refreshToken(refreshToken:baseURL:appToken:)AuthAPI.checkEmailExist(email:baseURL:appToken:)AuthAPI.uploadFile(fileData:fileName:mimeType:baseURL:token:)
Rooms
RoomsAPI.getRooms(baseURL:appId:conferenceDomain:)RoomsAPI.postRoom(...)RoomsAPI.postPrivateRoom(...)RoomsAPI.getRoomByName(...)RoomsAPI.postAddRoomMember(...)RoomsAPI.deleteRoomMember(...)RoomsAPI.postReportRoom(...)RoomsAPI.postReportMessage(...)RoomsAPI.deleteRoom(...)
XMPP client
XMPPClient.checkOnline()XMPPClient.checkConnecting()XMPPClient.isFullyConnected()XMPPClient.ensureConnected(timeout:)XMPPClient.sendGlobalPresence()XMPPClient.sendPresenceToRoom(roomJID:)XMPPClient.joinRoomsAndWait(roomJIDs:timeout:)XMPPClient.disconnect()
Message operations (client.operations)
sendTextMessage(...)sendMediaMessage(...)sendGetHistory(chatJID:max:before:otherId:)editMessage(chatId:messageId:text:)deleteMessage(room:msgId:)sendMessageReaction(...)sendTypingRequest(chatId:fullName:start:)
Push Notifications (FCM)
The SDK supports:
- backend push token registration (
PushAPI) - room-level push subscriptions (
PushSubscriptionService)
Typical sequence:
- Complete auth (
UserStoremust have user token) - Attach live XMPP client
- Provide/update FCM token
PushNotificationManager.shared.attachClient(client)
PushNotificationManager.shared.updateFCMToken(fcmToken)
Optional config:
config.push = PushNotificationConfig(
enabled: true,
appId: "YOUR_APP_ID",
pushBaseURL: "https://api.chat.ethora.com/v1"
)
Persistence and Stores
UserStore
- Holds current user, token, refresh token, auth state
- Persists under
UserDefaults - Call
clearUser()on logout
RoomStore
- Holds room dictionary, active room, unread data, edit state
- Persists room data in cache
- Message cache is bounded (keeps recent messages per room)
ConfigStore
- Global chat config singleton
mergeConfig(_:)for partial updatesupdateConfig(_:)for full replacement- Persists codable part of config
Examples in This Repo
Examples/ChatAppExample– app integration exampleExamples/XMPPChatCoreMockiOSApp– core-focused demoExamples/SDKPlayground– interactive playground with setup/chat/log tabs
Also see:
INSTALLATION.mdINTEGRATION.mdfeatures.md
Production Notes and Pitfalls
- Do not rely on bundled dev defaults from
AppConfigin production. ConfigStoreinitialization applies Ethora dev defaults; always merge/update your ownbaseUrl,appId, andxmppSettingsbefore presenting chat.- Ensure your auth flow provides
xmppUsername/xmppPasswordbefore showing chat. ChatWrapperViewshows a blocking auth message when user credentials are missing.- For single-room mode, use full room JID:
room@conference.domain. RoomsAPIrequires user auth inUserStore; call login first.- Non-codable config entries (closures/custom views) must be reapplied each app launch.
Testing
Same two-layer split as the Android SDK (reference).
Layer 1 — XCTest unit tests (this repo)
Pure-Swift, hermetic. No XMPP, no API, no FCM. Run with swift test
or via the test diamond in Xcode.
swift test
| Where | What | What's covered today |
|---|---|---|
Tests/XMPPChatCoreTests/ | Unit tests against XMPPChatCore types (stores, models, networking helpers) | UnreadStateBridgeTests covering total-unread derivation, missing-room behavior, store reset isolation |
Gaps to fill at this layer (file an issue + a test in the same PR when you tackle one):
RoomStoreadd/update/clear semantics- JID parsing / normalization helpers
- Push-notification payload decoding
MessageStoreinsertion + de-duplicationXMPPClientBIND-result handling (testable with a stubbed transport)
For SwiftUI view-rendering tests, add either an XCUITest target (lives on the sample side, not here, since hermetic SwiftUI testing in pure XCTest needs ViewInspector or similar third-party deps the SDK doesn't ship). Practical view coverage flows through Layer 2 instead.
Layer 2 — End-to-end Maestro flows (ethora-sample-swift)
Maestro runs cross-platform; the same YAML flows in
ethora-sample-swift/.maestro/
that drive the Android sample drive the iOS Simulator too. They
resolve UI nodes by accessibility identifier, not by text, so they're
robust against localization and theme changes.
Stable identifiers for resolution live in
Sources/XMPPChatUI/AccessibilityIdentifiers.swift.
String values intentionally match Android's *TestTags constants so
a single Maestro flow exercises the same intent on either platform.
| Identifier | View | Mirrors Android |
|---|---|---|
chat_input | ChatInputView text field | ChatInputTestTags.INPUT_FIELD |
chat_send_button | ChatInputView send button (both states) | ChatInputTestTags.SEND_BUTTON |
chat_attach_button | ChatInputView paperclip | ChatInputTestTags.ATTACH_BUTTON |
chat_message_image | MessageBubble MediaMessageView | MessageBubbleTestTags.MEDIA_CONTENT |
rooms_list | RoomListView List | RoomListViewTestTags.ROOMS_LIST |
room_row | RoomListView NavigationLink | RoomListViewTestTags.ROOM_ROW |
create_room_button | RoomListView toolbar "+" | RoomListViewTestTags.CREATE_ROOM_BUTTON |
Adding a test for a fix or new feature
- Behavior bug in
XMPPChatCore→ add an XCTest in this repo, in the same PR as the fix. - Behavior bug in
XMPPChatUIrendering / interaction → add or extend a Maestro flow inethora-sample-swift/.maestro/, in a paired PR to that repo. - Cross-platform parity gap → make sure the matching Android Compose test, Web Vitest test, or Maestro flow exists too.
Cross-platform testing overview
This iOS SDK is one of four runtime targets that share a single selector contract. The same string IDs power Maestro flows on iOS + Android and Playwright tests on Web.
| Layer 1 (hermetic) | Layer 2 (E2E) |
|---|---|
ethora-sdk-swift — XCTest in Tests/XMPPChatCoreTests/ + accessibilityIdentifier markers in XMPPChatUI/ (this repo) | ethora-sample-swift/.maestro/ — 19 Maestro flows on iOS Simulator |
ethora-sdk-android — Compose UI tests in chat-ui/src/androidTest/ | ethora-sample-android/.maestro/ — same 19 Maestro flows on Android emulator |
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 | iOS (*AccessibilityID) | Android (*TestTags) | Web (*TestIds) |
|---|---|---|---|
chat_input | ChatInputAccessibilityID.inputField | ChatInputTestTags.INPUT_FIELD | ChatInputTestIds.inputField |
chat_send_button | ChatInputAccessibilityID.sendButton | ChatInputTestTags.SEND_BUTTON | ChatInputTestIds.sendButton |
chat_attach_button | ChatInputAccessibilityID.attachButton | ChatInputTestTags.ATTACH_BUTTON | ChatInputTestIds.attachButton |
chat_message_image | MessageBubbleAccessibilityID.mediaContent | MessageBubbleTestTags.MEDIA_CONTENT | MessageBubbleTestIds.mediaContent |
rooms_list | RoomListAccessibilityID.roomsList | RoomListViewTestTags.ROOMS_LIST | RoomListTestIds.roomsList |
room_row | RoomListAccessibilityID.roomRow | RoomListViewTestTags.ROOM_ROW | RoomListTestIds.roomRow |
create_room_button | RoomListAccessibilityID.createRoomButton | RoomListViewTestTags.CREATE_ROOM_BUTTON | RoomListTestIds.createRoomButton |
Changing any value above is a 4-repo change — keep them in sync.
Build and Validation
xcodebuild -scheme XMPPChatSwift-Package -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
Optional package resolution check:
swift package resolve