Skip to content

Kotlin Client Setup ​

The dev.cocoar:signalarrr package is a native client for Android and the JVM. It is a full SignalR client built from scratch — no dependency on Microsoft's Java client — with coroutines and Flow as its async model.

Requirements

Kotlin 2.0+, JVM 8+ bytecode; Android API 21+. Dependencies: kotlinx-coroutines, kotlinx-serialization-json, OkHttp 5 and msgpack-core. No Android SDK dependency, so the same artifact serves server-side Kotlin and desktop apps.

Installation ​

kotlin
// build.gradle.kts
plugins {
    kotlin("plugin.serialization") version "2.4.20"
    id("com.google.devtools.ksp") version "2.3.12"   // only for @HubProxy typed proxies
}

dependencies {
    implementation("dev.cocoar:signalarrr:5.2.0")
    ksp("dev.cocoar:signalarrr-ksp:5.2.0")            // only for @HubProxy typed proxies
}

The serialization plugin is what makes your own data classes @Serializable; the client encodes arguments and decodes results with kotlinx.serialization.

Create a connection ​

kotlin
import dev.cocoar.signalarrr.HARRRConnection

val connection = HARRRConnection.create("https://localhost:5001/apphub")

Authentication ​

A connection has two credentials. Usually they are the same token, so one option sets both:

kotlin
val connection = HARRRConnection.create("https://localhost:5001/apphub") {
    credential = { tokenStore.currentToken() }
}

To give them different credentials — a single-use connection ticket, say, which has no business being resent with every message — set them separately:

kotlin
val connection = HARRRConnection.create("https://localhost:5001/apphub") {
    connectionCredential = { connectionTicket() }
    messageCredential = { tokenStore.currentToken() }
}
OptionAuthenticatesTravels asChecked byWhen it expires
connectionCredentialnegotiate and the transportAuthorization header[Authorize] on the hub class, .RequireAuthorization() on the mappingfetched again on every connect and reconnect
messageCredentialevery message, the answer to a challenge, file transfersthe Authorization field of the message; a header on file transfers[Authorize] on a method or a ServerMethods classfetched on every use, so a refreshed token is sent from the next call on
credentialboth of the above

The options are named the same in every SignalARRR client, and nothing is coupled implicitly: messageCredential alone leaves the connection anonymous, connectionCredential alone sends no message credential. A connectionCredential or messageCredential next to credential takes its part over. A value without a space is sent as a bearer token; one with a space carries its own scheme. See Authorization for the same table across all clients.

The connection credential travels as a header on the negotiate request and on every request of the transport — the WebSocket upgrade, the SSE stream and its posts, every Long Polling request — never in a URL, where proxy logs and error reports would keep it. For a server that reads it only from the URL, set transportCredential = TransportCredential.QUERY; see where the connection token travels.

Token challenges are handled automatically — while a stream is running the server may send a ChallengeAuthentication message, and the client calls the message credential to answer it.

A credential set in two places fails at create with an IllegalArgumentException: accessTokenProvider (the SignalR-level connection token) next to connectionCredential or credential, or the former messageAccessTokenProvider next to any of the new options.

Former name

messageAccessTokenProvider is the message credential under its former name, and it also covered the connection when accessTokenProvider was not set. It still works that way and is marked deprecated; replace it with credential — or with messageCredential if the connection should stay anonymous.

All options ​

kotlin
val connection = HARRRConnection.create("https://localhost:5001/apphub") {
    hubProtocol = HubProtocolKind.JSON                  // or MESSAGE_PACK
    credential = { tokenStore.currentToken() }         // or connectionCredential / messageCredential
    serverTimeout = 30.seconds
    keepAliveInterval = 15.seconds
    handshakeTimeout = 15.seconds
    reconnectPolicy = ReconnectPolicy.Default           // immediate, 2s, 10s, 30s — then give up
    allowedTransports = listOf(TransportType.WEB_SOCKETS, TransportType.SERVER_SENT_EVENTS, TransportType.LONG_POLLING)
    transportCredential = TransportCredential.HEADER    // or QUERY: connection token in the transport URL
    headers = mapOf("X-Api-Key" to apiKey)              // extra headers on negotiate and transport requests
    httpClient = sharedOkHttpClient                     // reuse the app's OkHttp client
    json = Json { ignoreUnknownKeys = true }            // the kotlinx.serialization configuration
    logger = LogcatLogger(LogLevel.INFO)                // your SignalARRRLogger implementation
}

Start and stop ​

kotlin
connection.start()

// ... use the connection ...

connection.stop()

Both are suspend functions. stop() fails pending calls with DisconnectedException and fires onClosed without an error.

Invoke (call with return value) ​

kotlin
val history: List<String> = connection.invoke("ChatMethods.GetHistory")
val user = connection.invoke<User>("UserMethods.GetUser", userId)

The result type is reified, so generic results like List<Item> or Map<String, Int> decode directly. Arguments are encoded structurally: primitives, UUID, Instant, collections and maps need nothing, and any other class must be @Serializable.

Send (fire-and-forget) ​

kotlin
connection.send("ChatMethods.SendMessage", "Alice", "Hello!")

Stream ​

kotlin
connection.stream<String>("ChatMethods.StreamMessages").collect { msg ->
    println(msg)
}

stream returns a cold Flow. Cancelling the collector — leaving the scope, take(n), first() — sends CancelInvocation so the server stops producing.

Errors ​

A failed call throws HARRRException carrying the server's structured error. Branch on code, never on the message:

kotlin
try {
    connection.invoke<Unit>("RoomMethods.Join", roomId)
} catch (e: HARRRException) {
    when (e.code) {
        HARRRErrorCodes.UNAUTHORIZED -> promptLogin()
        HARRRErrorCodes.METHOD_NOT_FOUND -> reportContractMismatch(e)
        else -> when (e.error.code) {           // application codes travel verbatim
            "room_full" -> showRoomFull()
            else -> showGeneric(e.error.message)
        }
    }
}

e.code is the code folded to the set this client knows (unknown codes become internal); e.error.code is the raw wire value, which is where an application's own HARRRException("room_full", ...) codes appear. e.error.innerError nests the cause chain. The codes are the same in every SignalARRR client.

A connection the server rejects at negotiate — 401 or 403 for a missing or invalid connection credential — fails start() with NegotiationFailedException, whose statusCode carries the HTTP status:

kotlin
try {
    connection.start()
} catch (e: NegotiationFailedException) {
    if (e.statusCode == 401) promptLogin() else throw e
}

Connection events ​

kotlin
connection.onClosed { error -> println("Connection closed: ${error?.message ?: "clean"}") }
connection.onReconnecting { error -> println("Reconnecting: ${error?.message}") }
connection.onReconnected { connectionId -> println("Reconnected as $connectionId") }

// Or observe the state as a StateFlow — handy for Compose/ViewModels
connection.client.stateFlow.collect { state -> render(state) }

Connection properties ​

PropertyTypeDescription
connectionIdString?Current connection ID
stateHubConnectionStateDISCONNECTED, CONNECTING, CONNECTED, RECONNECTING
client.stateFlowStateFlow<HubConnectionState>The state as a flow
client.transportTypeTransportType?The transport in use
serverTimeout, keepAliveInterval, handshakeTimeoutDurationThe configured timeouts

MessagePack protocol ​

kotlin
val connection = HARRRConnection.create("https://localhost:5001/apphub") {
    hubProtocol = HubProtocolKind.MESSAGE_PACK
}

MessagePack is built in (msgpack-core); nothing else to install. Server-Sent Events cannot carry binary frames, so the transport list falls back to WebSockets and Long Polling.

Server setup

The server needs .AddMessagePackProtocol(). See MessagePack for details.

Reconnection policy ​

kotlin
// Default: immediate retry, then 2s, 10s, 30s — then give up
reconnectPolicy = ReconnectPolicy.Default

// Custom delays
reconnectPolicy = ReconnectPolicy(listOf(Duration.ZERO, 1.seconds, 5.seconds, 10.seconds, 30.seconds))

// Disable reconnection
reconnectPolicy = ReconnectPolicy.Disabled

A reconnect is a new connection: pending calls fail, onReconnecting fires, and after a successful attempt onReconnected reports the new connection id. Server-to-client handlers registered before stay registered. The server also treats the client as gone when it receives nothing for serverTimeout; the client pings every keepAliveInterval on every transport to prevent that, and closes the connection itself when the server has been silent for serverTimeout.

Transport selection ​

The client tries transports in preference order and connects via the first one the server supports; a transport that fails to connect is skipped for the next:

kotlin
// Default: WebSockets → SSE → Long Polling
allowedTransports = listOf(TransportType.WEB_SOCKETS, TransportType.SERVER_SENT_EVENTS, TransportType.LONG_POLLING)

// Force WebSockets only
allowedTransports = listOf(TransportType.WEB_SOCKETS)

Logging ​

The client has no logging dependency. It writes to System.err by default; on Android, plug in Logcat:

kotlin
class LogcatLogger(override val level: LogLevel) : SignalARRRLogger {
    override fun log(level: LogLevel, message: String, throwable: Throwable?) {
        when (level) {
            LogLevel.DEBUG -> Log.d("SignalARRR", message, throwable)
            LogLevel.INFO -> Log.i("SignalARRR", message, throwable)
            LogLevel.WARNING -> Log.w("SignalARRR", message, throwable)
            LogLevel.ERROR -> Log.e("SignalARRR", message, throwable)
            LogLevel.NONE -> Unit
        }
    }
}

NoopLogger silences the client entirely.

Android notes ​

  • Add <uses-permission android:name="android.permission.INTERNET" />.
  • Cleartext http:// URLs need android:usesCleartextTraffic="true" (or a network security config) on API 28+; emulators reach the host machine at 10.0.2.2.
  • Keep the connection in a ViewModel or a foreground service scope, not an Activity; the connection's own coroutines run on Dispatchers.IO, and handlers are dispatched there too — switch to Dispatchers.Main before touching views.
  • With R8/ProGuard, keep the @Serializable classes' serializers as the kotlinx.serialization guide describes; the client itself needs no rules.

Using SignalRClient directly ​

HARRRConnection is the SignalARRR-specific wrapper. To talk to any standard SignalR hub (without the SignalARRR server library), use SignalRClient directly. It speaks in JsonElements:

kotlin
val client = SignalRClient("https://any-signalr-server.com/hub") {
    hubProtocol = HubProtocolKind.MESSAGE_PACK
}
client.start()

val result = client.invoke("MyMethod", listOf(JsonPrimitive("param")))

client.on("OnMessage") { args ->
    println(args)
    null
}

connection.client exposes the same object underneath a HARRRConnection, and connection.on(...) registers raw SignalR event handlers with typed argument access.

Next steps ​

Released under the Apache-2.0 License.