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.
- 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
- 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
- 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
- iOS 15+
- Swift tools 5.9+
- Xcode with Swift Package Manager support
File->Add Package Dependencies...- Enter:
https://github.com/dappros/ethora-sdk-swift
- Add products to your app target:
XMPPChatCoreXMPPChatUI
// 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")
]
)
])Copy:
Sources/XMPPChatCoreSources/XMPPChatUI
If you do this, ensure dependency parity for core transport (Starscream).
This path is fastest for production embedding.
import SwiftUI
import XMPPChatCore
import XMPPChatUIprivate 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
}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)
}
}
}- Set
config.jwtLogin = JWTLoginConfig(token: ..., enabled: true) ChatWrapperViewModelperforms autologin throughAuthAPI.loginViaJwt(clientToken:)
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)var config = ChatConfig()
config.userLogin = UserLoginConfig(enabled: true, user: preAuthenticatedUser)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.
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
)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).
import XMPPChatCore
func appLogout() async {
// ...your own host-app logout (revoke server session, clear keychain, etc.)...
await LogoutManager.shared.logout()
}LogoutManager.shared.logout(client: nil) {
// Chat logout finished — navigate to your login screen.
}LogoutManager.shared.logoutWithConfirmation(
client: nil,
showConfirmation: { confirm in
// Present an alert; call confirm(true) on OK, confirm(false) on cancel.
},
onCompletion: {
// Chat logout finished.
}
)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
)
)- 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).
ChatConfig has many options. Key groups below.
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
googleLogin: GoogleLoginConfigjwtLogin: JWTLoginConfiguserLogin: UserLoginConfigcustomLogin: CustomLoginConfigrefreshTokens: RefreshTokensConfig
disableHeader,disableMedia,disableRoomsdisableInteractions,disableRoomMenu,disableRoomConfig,disableNewChatButtondisableProfilesInteractions,disableUserCount,disableTypingIndicatordisableChatInfo: DisableChatInfoConfigchatHeaderSettings: ChatHeaderSettingsConfigenableRoomsRetry: EnableRoomsRetryConfig
colors: ChatColors(primary,secondary)backgroundChat: BackgroundChatConfigbubleMessage: MessageBubbleStyleroomListStyles,chatRoomStyles(dynamic dictionaries)headerLogo
messageTextFilter: MessageTextFilterConfigsecondarySendButton: SecondarySendButtonConfigcustomTypingIndicator: CustomTypingIndicatorConfigblockMessageSendingWhenProcessing: BlockMessageSendingConfigeventHandlers: ChatEventHandlersmessageNotifications: MessageNotificationConfigcustomComponents: CustomComponentsProtocol
defaultRooms,customRoomsforceSetRoom,setRoomJidInPath,chatHeaderBurgerMenuclearStoreBeforeInit,disableSentLogic,initBeforeLoad,newArchbotMessageAutoScrollwhitelistSystemMessagetranslates: TranslationsConfigpush: PushNotificationConfig
ConfigStore persists only codable fields. Closure- and AnyView-based options are runtime-only and are not serialized.
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:)
RoomsAPI.getRooms(baseURL:appId:conferenceDomain:)RoomsAPI.postRoom(...)RoomsAPI.postPrivateRoom(...)RoomsAPI.getRoomByName(...)RoomsAPI.postAddRoomMember(...)RoomsAPI.deleteRoomMember(...)RoomsAPI.postReportRoom(...)RoomsAPI.postReportMessage(...)RoomsAPI.deleteRoom(...)
XMPPClient.checkOnline()XMPPClient.checkConnecting()XMPPClient.isFullyConnected()XMPPClient.ensureConnected(timeout:)XMPPClient.sendGlobalPresence()XMPPClient.sendPresenceToRoom(roomJID:)XMPPClient.joinRoomsAndWait(roomJIDs:timeout:)XMPPClient.disconnect()
sendTextMessage(...)sendMediaMessage(...)sendGetHistory(chatJID:max:before:otherId:)editMessage(chatId:messageId:text:)deleteMessage(room:msgId:)sendMessageReaction(...)sendTypingRequest(chatId:fullName:start:)
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"
)- Holds current user, token, refresh token, auth state
- Persists under
UserDefaults - Call
clearUser()on logout
- Holds room dictionary, active room, unread data, edit state
- Persists room data in cache
- Message cache is bounded (keeps recent messages per room)
- Global chat config singleton
mergeConfig(_:)for partial updatesupdateConfig(_:)for full replacement- Persists codable part of config
Examples/ChatAppExample– app integration exampleExamples/XMPPChatCoreMockiOSApp– core-focused demoExamples/SDKPlayground– interactive playground with setup/chat/log tabs
Also see:
INSTALLATION.mdINTEGRATION.mdfeatures.md
- 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.
Same two-layer split as the Android SDK (reference).
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.
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 |
- 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.
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.
xcodebuild -scheme XMPPChatSwift-Package -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO buildOptional package resolution check:
swift package resolve