A robust, type-safe, and idiomatic Kotlin client for Pusher-compatible WebSocket services.
Built for Laravel Reverb and any Pusher Channels–compatible backend.
- Kotlin-first — Coroutines, Flow, StateFlow, and structured concurrency throughout
- Pusher Protocol v7 — Full compatibility with public, private, and presence channels
- Laravel Reverb ready — First-class support for self-hosted Reverb servers
- Pluggable architecture — Swap WebSocket engines and serializers via the DSL
- Robust connection lifecycle — Mutex-guarded state machine with automatic reconnection, exponential backoff, and terminal suspended/failed states
- Protocol-level ping/pong — Configurable keep-alive with 30 s pong timeout
- Global & Per-Channel errors — Typed
EchoErrorsealed hierarchy exposed globally and per-channel - Zero-downtime token rotation — Proactive, non-disruptive channel token refreshing
- Presence updates — Support for live member metadata updating on presence channels
- Backpressure handling — Configurable buffer overflow strategies on all internal flows
- Minimal public API —
internalby default; only consumer-facing types arepublic
- Requirements
- Installation
- Quick Start
- Configuration
- Channels
- Architecture
- API Reference
- Sample App
- Testing
- Contributing
- Roadmap
- License
| Requirement | Version |
|---|---|
| Android minSdk | 30 (Android 11) |
| compileSdk | 36 (Android 16) |
| Kotlin | 2.3.10 |
| Java | 11+ |
| Gradle | 9.1+ |
Add the dependency in your app's build.gradle.kts:
repositories {
google()
mavenCentral()
}
dependencies {
implementation("io.github.adityaa-codes:echo:<latest-version>")
}To check the latest published version on Maven Central:
curl -s https://repo1.maven.org/maven2/io/github/adityaa-codes/echo/maven-metadata.xmlLook at <latest> / <release> in the response and use that version.
- Clone the repository:
git clone https://github.com/adityaacodes/echo-android.git- Include the
:echomodule in your project'ssettings.gradle.kts:
include(":echo")
project(":echo").projectDir = file("../echo-android/echo")- Add the dependency in your app's
build.gradle.kts:
dependencies {
implementation(project(":echo"))
}For local publishing credentials/signing, use user-level ~/.gradle/gradle.properties (never commit secrets in project gradle.properties):
mavenCentralUsername=YOUR_CENTRAL_TOKEN_USERNAME
mavenCentralPassword=YOUR_CENTRAL_TOKEN_PASSWORD
signing.keyId=YOUR_GPG_KEY_ID
signing.password=YOUR_GPG_KEY_PASSPHRASE
signing.secretKey=-----BEGIN PGP PRIVATE KEY BLOCK-----...If your release deployment in Sonatype shows
PUBLISHING, wait for Maven Central indexing (typically several minutes, sometimes longer).
For release automation (.github/workflows/publish-release.yml), configure these repository secrets:
MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD, SIGNING_KEY_ID, SIGNING_PASSWORD, SIGNING_SECRET_KEY.
import io.github.adityaacodes.echo.Echo
// 1. Create a client
val echo = Echo.create {
client {
host = "your-reverb-server.com"
apiKey = "your-app-key"
port = 8080
useTls = false
}
auth {
authenticator = { channelName, socketId ->
// Return auth signature from your backend
Result.success("""{"auth":"$socketId:signature"}""")
}
}
logging {
enabled = true
}
}
// 2. Connect
echo.connect()
// 3. Subscribe to a public channel
val channel = echo.channel("chat-room")
channel.listen("MessageSent") { event ->
println("New message: ${event.data}")
}
// 4. Subscribe to a private channel
val privateChannel = echo.private("orders")
privateChannel.listen("OrderUpdated") { event ->
println("Order update: ${event.data}")
}
// 5. Subscribe to a presence channel
val presenceChannel = echo.presence("online-users")
presenceChannel.here { members -> println("Online: $members") }
presenceChannel.joining { member -> println("Joined: $member") }
presenceChannel.leaving { member -> println("Left: $member") }
// 6. Observe connection state
echo.state.collect { state ->
println("Connection: $state")
}
// 7. Observe errors globally
echo.errors.collect { error ->
println("Error: $error")
}The SDK is configured entirely through a Kotlin DSL:
val echo = Echo.create {
client {
host = "ws.example.com" // WebSocket host
apiKey = "app-key" // Pusher/Reverb app key
cluster = "mt1" // Optional: Pusher cluster (overrides host)
port = 443 // Optional: custom port
useTls = true // Default: true (wss://)
// Pluggable engine (optional — defaults to KtorEchoEngine)
engineFactory = { CustomEchoEngine() }
// Pluggable serializer (optional — defaults to DefaultEchoSerializer)
serializer = CustomEchoSerializer()
}
auth {
authenticator = myAuthenticator // Authenticator for private/presence channels
authEndpoint = "/broadcasting/auth" // Optional: HTTP auth endpoint
tokenProvider = { "Bearer ..." } // Optional: token for HTTP auth
tokenExpiryMs = 60_000 // Optional: hint for proactive token refresh
onAuthFailure = { // Optional: retry callback on auth failure
refreshToken()
}
}
logging {
enabled = true // Enable SDK logging (default: false)
logger = { msg -> Log.d("Echo", msg) } // Optional: custom logger
}
reconnection {
maxAttempts = 10 // Default: 10 attempts
baseDelayMs = 1_000 // Default: 1 second
maxDelayMs = 30_000 // Default: 30 seconds
suspendAfterMs = 120_000 // Default: 2 minutes
}
}val channel = echo.channel("news")
channel.listen("ArticlePublished") { event ->
// handle event
}Private channels require authentication. The private- prefix is added automatically.
val channel = echo.private("user.123")
channel.listen("NotificationSent") { event ->
// handle event
}Presence channels track online members. The presence- prefix is added automatically.
val presence = echo.presence("chat-room")
presence.here { members -> /* initial member list */ }
presence.joining { member -> /* a member joined */ }
presence.leaving { member -> /* a member left */ }
presence.updating { member -> /* a member updated their data */ }echo.leave("chat-room")┌──────────────────────────────────────────────────────────┐
│ EchoClient (API) │
├──────────────────────────────────────────────────────────┤
│ Echo.create { } → EchoBuilder → EchoClientImpl │
├──────────────┬───────────────────┬───────────────────────┤
│ EventRouter │ ReconnectionMgr │ ChannelImpl(s) │
├──────────────┴───────────────────┴───────────────────────┤
│ KtorEchoConnection │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ EchoEngine │ │ EchoSerializer│ │
│ │ (pluggable) │ │ (pluggable) │ │
│ └──────┬──────┘ └──────────────┘ │
│ │ │
│ KtorEchoEngine (default) │
│ ┌──────────────────┐ │
│ │ Ktor HttpClient │ │
│ │ + OkHttp Engine │ │
│ │ + WebSockets │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────┘
Disconnected ──► Connecting ──► Connected
▲ │
│ ▼
└──── Disconnected ◄── Reconnecting
The SDK exposes StateFlow<ConnectionState> with six states:
Disconnected— not connected (optionally includes disconnect reason)Connecting— WebSocket handshake in progressConnected— handshake complete,socketIdavailableReconnecting— lost connection, attempting automatic reconnect with exponential backoffSuspended— degraded connection after repeated reconnection failuresFailed— unrecoverable protocol error (e.g., bad app key)
sealed class EchoError {
data class Network(...) // Network/IO failures
data class Auth(...) // Authentication failures
data class Protocol(...) // Pusher protocol errors (4000–4299)
data class Serialization(...) // JSON parsing failures
}| Member | Description |
|---|---|
Echo.create { } |
Create a configured EchoClient instance |
| Member | Type | Description |
|---|---|---|
state |
StateFlow<ConnectionState> |
Current connection state |
errors |
SharedFlow<EchoError> |
Global error stream |
globalEvents |
Flow<EchoEvent> |
All incoming events |
socketId |
String? |
Current socket ID (when connected) |
activeChannels |
List<EchoChannel> |
Currently subscribed channels |
connect() |
suspend |
Initiate WebSocket connection |
disconnect() |
suspend |
Gracefully close the connection |
ping(timeoutMillis) |
suspend → Boolean |
Send a manual ping; returns true if pong received |
channel(name) |
EchoChannel |
Subscribe to a public channel |
private(name) |
EchoChannel |
Subscribe to a private channel |
presence(name) |
PresenceChannel |
Subscribe to a presence channel |
leave(name) |
Unit |
Unsubscribe from a channel |
| Member | Description |
|---|---|
incoming: Flow<String> |
Stream of raw incoming text frames |
connect(url) |
Open WebSocket connection |
send(data): Result<Unit> |
Send a text frame |
disconnect() |
Close the connection |
| Member | Description |
|---|---|
deserialize(text): PusherFrame |
Parse raw text into a protocol frame |
serialize(frame): String |
Encode a protocol frame to text |
The sample module provides a fully functional reference app demonstrating:
- Connection lifecycle management
- Public/private/presence channel subscription
- Global error stream collection and toast display
- Manual ping with result feedback
- UDF architecture with
StateFlow<ViewState>andViewIntent
We have built a dedicated test server for this SDK. You can run the echo-server locally to test all features out of the box.
The echo-server is a demo WebSocket backend built with Laravel, Reverb, and DDEV. It continuously broadcasts scheduled fake "tick" events every second to public, private, and presence channels so you can test subscriptions, authentication, and real-time event handling end-to-end. It also provides pre-seeded demo users for the /broadcasting/auth endpoint.
Quick Server Setup:
- Clone:
git clone https://github.com/adityaa-codes/echo-server echo-server - Bootstrap:
ddev setup(Starts Docker containers, runs migrations, and seeds DB) - Start daemons:
ddev demo-up(Runs Reverb, Scheduler, and Queues) - Print SDK info:
ddev demo-info(Outputs the exact Host, Port, App Key, and channel names you need)
Run the Sample App:
./gradlew :sample:installDebugFor local, non-committed sample credentials, set the corresponding Gradle properties in your user-level ~/.gradle/gradle.properties (based on the ddev demo-info output):
ECHO_SAMPLE_HOST=your-ddev-host # e.g. echo-server.ddev.site or 10.0.2.2 for Android Emulator
ECHO_SAMPLE_PORT=8080
ECHO_SAMPLE_USE_TLS=false
ECHO_SAMPLE_APP_KEY=reverb-app-key
ECHO_SAMPLE_AUTH_ENDPOINT=http://your-ddev-host/broadcasting/authTip: If testing on a physical Android device, you can use
ddev share-cloudflaredin the server repo to expose the backend via a public URL, then updateECHO_SAMPLE_HOST, toggleECHO_SAMPLE_USE_TLS=true, and adjust the auth endpoint accordingly.
# Run unit tests
./gradlew :echo:testDebugUnitTest
# Run with coverage report
./gradlew :echo:createDebugUnitTestCoverageReport
# Report: echo/build/reports/coverage/test/debug/index.html
# Lint with ktlint
./gradlew :echo:ktlintCheck :sample:ktlintCheck
# Auto-format
./gradlew :echo:ktlintFormat :sample:ktlintFormat
# Build the library
./gradlew :echo:assembleCurrent coverage: ≥ 80% line coverage (40 tests across 8 test classes).
echo-android/
├── echo/ # Library module
│ └── src/main/java/.../echo/
│ ├── Echo.kt # Entry point & DSL builder
│ ├── EchoClient.kt # Public client interface
│ ├── auth/ # Authenticator interface
│ ├── channel/ # Channel & PresenceChannel interfaces
│ ├── connection/ # Connection, reconnection manager
│ ├── data/protocol/ # Pusher protocol frame models
│ ├── engine/ # Pluggable WebSocket engine
│ ├── error/ # EchoError sealed hierarchy
│ ├── internal/ # Client/channel/router implementations
│ ├── serialization/ # Pluggable serializer
│ ├── state/ # ConnectionState & ChannelState
│ └── utils/ # Logger
├── sample/ # Sample Android app
└── gradle/libs.versions.toml # Centralized dependency versions
Contributions are welcome! Please read the Contributing Guide before submitting a PR.
Distributed under the MIT License. See LICENSE.md for details.
Made with ❤️ for the Android & Laravel communities