Ethora SDK for Swift (ethora-sdk-swift)

May 10, 2026 · View on GitHub

Production-ready iOS chat SDK with:

  • XMPPChatCore for auth, API, XMPP transport, stores, and messaging operations
  • XMPPChatUI for ready-made SwiftUI chat UI on top of the core

This repository ships as a Swift Package with both products.

Table of Contents

  1. What You Get
  2. Requirements
  3. Installation
  4. Quick Start (Recommended)
  5. Authentication Flows
  6. Using XMPPChatCore Without UI
  7. Logging Out from Your Host App
  8. Configuration Reference (ChatConfig)
  9. Core API Reference
  10. Push Notifications (FCM)
  11. Persistence and Stores
  12. Examples in This Repo
  13. Production Notes and Pitfalls
  14. 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
  • 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)

  1. File -> Add Package Dependencies...
  2. Enter:
https://github.com/dappros/ethora-sdk-swift
  1. Add products to your app target:
  • XMPPChatCore
  • XMPPChatUI

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/XMPPChatCore
  • Sources/XMPPChatUI

If you do this, ensure dependency parity for core transport (Starscream).

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

  • Set config.jwtLogin = JWTLoginConfig(token: ..., enabled: true)
  • ChatWrapperViewModel performs autologin through AuthAPI.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:

  1. Resets push (FCM token + mucsub room subscriptions) while the XMPP stream is still alive.
  2. Disconnects XMPP.
  3. Clears the global XMPPClient from ClientRegistry.
  4. Clears caches: RoomStore, MessageCache, unread / last-read keys, pending push JID.
  5. Clears the user and tokens from UserStore.
  6. 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()
}

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 client parameter is optional. When omitted, LogoutManager picks up the current client from ClientRegistry.
  • Safe to call when the user is already logged out — every step is a no-op in that case.
  • If you used ChatHeadlessSession, call await ChatHeadlessSession.shared.stop() before invoking logout (or rely on logout to disconnect XMPP — it will still tear down correctly, but stop() is the cleaner pairing).

Configuration Reference (ChatConfig)

ChatConfig has many options. Key groups below.

Core connectivity

  • baseUrl: REST base URL
  • appId: app id used in room/push requests
  • customAppToken: app token for app-scoped auth requests
  • xmppSettings: XMPPSettings (WebSocket URL, host, conference)

XMPPSettings fields:

  • xmppServerUrl: preferred server URL key
  • devServer: legacy alias, kept for compatibility
  • host
  • conference
  • xmppPingOnSendEnabled

Login/auth config

  • googleLogin: GoogleLoginConfig
  • jwtLogin: JWTLoginConfig
  • userLogin: UserLoginConfig
  • customLogin: CustomLoginConfig
  • refreshTokens: RefreshTokensConfig

UI/UX toggles

  • disableHeader, disableMedia, disableRooms
  • disableInteractions, disableRoomMenu, disableRoomConfig, disableNewChatButton
  • disableProfilesInteractions, disableUserCount, disableTypingIndicator
  • disableChatInfo: DisableChatInfoConfig
  • chatHeaderSettings: ChatHeaderSettingsConfig
  • enableRoomsRetry: EnableRoomsRetryConfig

Styling

  • colors: ChatColors (primary, secondary)
  • backgroundChat: BackgroundChatConfig
  • bubleMessage: MessageBubbleStyle
  • roomListStyles, chatRoomStyles (dynamic dictionaries)
  • headerLogo

Message pipeline and behavior hooks

  • messageTextFilter: MessageTextFilterConfig
  • secondarySendButton: SecondarySendButtonConfig
  • customTypingIndicator: CustomTypingIndicatorConfig
  • blockMessageSendingWhenProcessing: BlockMessageSendingConfig
  • eventHandlers: ChatEventHandlers
  • messageNotifications: MessageNotificationConfig
  • customComponents: CustomComponentsProtocol

Rooms and data behavior

  • defaultRooms, customRooms
  • forceSetRoom, setRoomJidInPath, chatHeaderBurgerMenu
  • clearStoreBeforeInit, disableSentLogic, initBeforeLoad, newArch
  • botMessageAutoScroll
  • whitelistSystemMessage
  • translates: TranslationsConfig
  • push: 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:

  1. Complete auth (UserStore must have user token)
  2. Attach live XMPP client
  3. 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 updates
  • updateConfig(_:) for full replacement
  • Persists codable part of config

Examples in This Repo

  • Examples/ChatAppExample – app integration example
  • Examples/XMPPChatCoreMockiOSApp – core-focused demo
  • Examples/SDKPlayground – interactive playground with setup/chat/log tabs

Also see:

  • INSTALLATION.md
  • INTEGRATION.md
  • features.md

Production Notes and Pitfalls

  • Do not rely on bundled dev defaults from AppConfig in production.
  • ConfigStore initialization applies Ethora dev defaults; always merge/update your own baseUrl, appId, and xmppSettings before presenting chat.
  • Ensure your auth flow provides xmppUsername/xmppPassword before showing chat.
  • ChatWrapperView shows a blocking auth message when user credentials are missing.
  • For single-room mode, use full room JID: room@conference.domain.
  • RoomsAPI requires user auth in UserStore; 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
WhereWhatWhat'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):

  • RoomStore add/update/clear semantics
  • JID parsing / normalization helpers
  • Push-notification payload decoding
  • MessageStore insertion + de-duplication
  • XMPPClient BIND-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.

IdentifierViewMirrors Android
chat_inputChatInputView text fieldChatInputTestTags.INPUT_FIELD
chat_send_buttonChatInputView send button (both states)ChatInputTestTags.SEND_BUTTON
chat_attach_buttonChatInputView paperclipChatInputTestTags.ATTACH_BUTTON
chat_message_imageMessageBubble MediaMessageViewMessageBubbleTestTags.MEDIA_CONTENT
rooms_listRoomListView ListRoomListViewTestTags.ROOMS_LIST
room_rowRoomListView NavigationLinkRoomListViewTestTags.ROOM_ROW
create_room_buttonRoomListView 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 XMPPChatUI rendering / interaction → add or extend a Maestro flow in ethora-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 attrsethora-app-reactjs/tests/e2e/ — Playwright on chromium

Selector parity (a Maestro id: "chat_input" matches all of these):

StringiOS (*AccessibilityID)Android (*TestTags)Web (*TestIds)
chat_inputChatInputAccessibilityID.inputFieldChatInputTestTags.INPUT_FIELDChatInputTestIds.inputField
chat_send_buttonChatInputAccessibilityID.sendButtonChatInputTestTags.SEND_BUTTONChatInputTestIds.sendButton
chat_attach_buttonChatInputAccessibilityID.attachButtonChatInputTestTags.ATTACH_BUTTONChatInputTestIds.attachButton
chat_message_imageMessageBubbleAccessibilityID.mediaContentMessageBubbleTestTags.MEDIA_CONTENTMessageBubbleTestIds.mediaContent
rooms_listRoomListAccessibilityID.roomsListRoomListViewTestTags.ROOMS_LISTRoomListTestIds.roomsList
room_rowRoomListAccessibilityID.roomRowRoomListViewTestTags.ROOM_ROWRoomListTestIds.roomRow
create_room_buttonRoomListAccessibilityID.createRoomButtonRoomListViewTestTags.CREATE_ROOM_BUTTONRoomListTestIds.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