SDK · Kotlin/Android

Kotlin/JVM client SDK for the imcore IM server. WebSocket + JSON over coroutines; consumable by Android apps and JVM services. Default WebSocket via OkHttp.

Versioning & compatibility: this SDK (current release v1.10.0) is versioned on its own line (clients/SDK_VERSION), independent of the imcore server release tag. It targets the imcore wire protocol as of server v1.2.0; because the protocol is additive/forward-compatible, it works with any server ≥ v1.2.0.

Add it (Gradle)

dependencies {
    implementation(project(":imcore-sdk")) // or a published coordinate
}

Runtime dependencies it brings: kotlinx-coroutines-core, kotlinx-serialization-json, okhttp.

Android Compose UIKit

The :uikit Android library is an installable Compose AAR (iOS-equivalent SwiftUI package is ImcoreUIKit). It provides ConversationList, MessageList, ImcoreComposer, ImcoreChatScreen, and ImcoreCallControls. The components use stable UI models so an app can map its own cache and rich message protocol without coupling the view to wire JSON:

implementation(project(":uikit")) // or im.balala:imcore-uikit:<version>

ImcoreCallClient turns rtc_* events into a lifecycle state flow and delegates camera/microphone/PeerConnection work to your ImcoreCallMediaEngine (WebRTC, LiveKit, Agora, or another provider).

Android

The SDK is pure JVM (no separate Android artifact needed) — an Android app consumes it directly; add kotlinx-coroutines-android for the Main dispatcher. A full Compose sample (login → conversations → chat + OEM push registration) lives in android-sample/: it is the CI-proven AGP harness for the SDK (AGP 9.3 + built-in Kotlin, compileSdk 36) and the reference for vendor push registration against the server’s direct providers (docs/PUSH_PROVIDERS_ZH.md). The module is auto-included only when an Android SDK is present (ANDROID_HOME/sdk.dir), so JVM-only contributors keep a plain ./gradlew test.

Usage

import im.balala.sdk.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.*

val im = ImcoreClient(
    url = "wss://your-host/acc",
    auth = AuthConfig(token = "<jwt>", login = LoginPayload(userId = "1001", userName = "alice")),
    connectTimeoutMs = 10_000,
    heartbeatTimeoutMs = 75_000, // reset by any inbound frame
    reconnectJitter = true,
    // maxRequestBytes = 262144 — local pre-send size check → RequestError 1011; <=0 disables
)

// Observe pushes and status with Flows (collect in your own scope):
scope.launch { im.events("chat_message").collect { msg -> println("msg $msg") } }
scope.launch { im.status.collect { s -> println("status $s") } } // Idle/Connecting/Open/Authenticated/Closed/ReauthRequired
scope.launch { im.reconnected.collect { /* re-pull conversations + cursor history */ } }

// connect() returns AFTER the server acknowledges login, and THROWS if login is
// refused (e.g. bad token) — always guard it.
try {
    im.connect()
    // Reads the deployment's effective contract and applies maxRequestBytes
    // when no local override was configured.
    val limits = im.refreshMessageLimits()
    println("request limit: ${limits.maxRequestBytes} bytes")
    im.dm.send(targetUserId = "1002", text = "hi")
    val queued = im.dm.sendQueued(targetUserId = "1002", text = "works offline")
    println("${queued.status} ${queued.tempId}")

    val history = im.dm.history(targetUserId = "1002", limit = 20)
    val page = im.dm.historyPage(targetUserId = "1002", limit = 20)
    // Older: historyPage(targetUserId = "1002", beforeId = page.nextCursor)
    // Newer/reconnect: historyPage(targetUserId = "1002", afterId = page.prevCursor)
    val cached = im.cache.getMessages("dm:1001:1002")

    im.push.register(
        deviceId = "<stable-device-id>",
        platform = "android",
        pushToken = "<fcm-token>",
        // iOS only: optional PushKit token, used for RTC call alerts.
        // voipToken = "<pushkit-voip-token>",
        vendor = "fcm",
        appId = "<application-id>",
    )

    // Escape hatch for any command not yet wrapped:
    im.request("chat_group_create", buildJsonObject { put("name", "team") })
    im.send("chat_dm_typing", buildJsonObject { put("targetUserID", "1002") })
} catch (e: RequestError) {
    println("imcore error: ${e.message}")
}

refreshMessageLimits() calls authenticated GET /user/message-limits and returns the message-limits.v1 snapshot. The call is explicit so Android and JVM applications control HTTP availability; an explicit maxRequestBytes, including <= 0 to disable local checking, always wins. HTTP, authentication, business-code, or schema failures leave the previous local limit unchanged.

Reconnect catch-up(重连补拉)

im.reconnected emits on every successful reconnect (not the first connect()). The SDK does not auto-reconcile state across a reconnect gap — a typical collector re-pulls what changed while offline:

scope.launch {
    im.reconnected.collect {
        // 1. Re-pull conversation lists — labels/mute/unread may have changed while offline.
        val dms = im.dm.conversations()
        val groups = im.group.conversations()
        val rooms = im.room.conversations()

        // 2. For conversations still open in the UI, backfill with the cursor
        //    you remembered from the last rendered page — afterId, not beforeId.
        val page = im.dm.historyPage(targetUserId = "1002", afterId = lastKnownCursor)

        // 3. Multi-device: pull the cross-device conversation-state snapshot too.
        im.operations.deviceSyncState()
    }
}

historyPage results do not get written into im.cache — reconciling paged history against the local cache is the app’s responsibility. Conversations you’ve been removed from (e.g. kicked from a group) are already purged from the cache by the removed push as it arrives, and the server’s own conversation list won’t include them again after reconnect either — there is nothing left to reconcile for those.

Customer-service workspace

Customer-service commands have typed request/response DTOs under im.customerService:

val workspace = im.customerService.workspaceBootstrap()
val routing = im.customerService.transfer(CSTransferRequestWire(
    targetUserID = "customer-1001",
    toAgentUserID = "agent-2002",
    note = "Billing specialist requested",
))
val profile = im.customerService.profileGet(
    CSProfileGetRequestWire(targetUserID = "customer-1001"),
)
im.customerService.noteAdd(
    CSNoteAddRequestWire(targetUserID = "customer-1001", body = "Requested a refund"),
)
im.customerService.claimGroup(
    CSGroupClaimRequestWire(groupID = 7, customerUserID = "customer-1001"),
)

scope.launch {
    im.customerService.onDMRoutingChanged().collect { event ->
        println("routing changed ${event.conversation_id} ${event.routing_mode}")
    }
}

AI streaming (customer-service bots)

scope.launch {
    im.ai.ask(targetUserId = "bot:cs", text = "退款怎么弄").collect { ev ->
        when (ev) {
            is AiReplyEvent.Delta -> render(ev.fullSoFar)
            is AiReplyEvent.Done  -> finalize(ev.fullText)
            is AiReplyEvent.Error -> showError(ev.code)
        }
    }
}

// 或观察某会话的全部 bot 回复:
im.ai.replies(conversationId = "dm:1001:bot:cs").collect { ev -> /* ... */ }

Done.fullText 为权威全文;取消收集协程即“退订”。

收到首个流帧的 messageId 后,可调用 im.ai.cancel(messageId) 请求服务端停止; 服务端取消以 AiReplyEvent.Error(code = "canceled") 结束。取消收集协程本身仍只影响 本地订阅;首帧前可把 chat_dm_send ACK 中的问题消息 ID 传给 im.ai.cancelRequest(requestMessageId),取消排队中或尚未产生首帧的请求。取消受理 是尽力而为;若最终 DM 已进入落库边界,落库正文和 Done 仍可能胜出。

ask() 具备断线恢复:重连(AiHostClient.reconnected)后若本次 ask() 仍未终态,会做一次历史核对——命中则以落库全文交付 AiReplyEvent.Done; 若空闲超时内始终没有匹配帧,则以 AiReplyEvent.Error(code = "stream_timeout") 结束,不再永久挂起。

ask() 使用流帧的 request_message_id 与问题消息精确关联;早于 chat_dm_send ACK 到达的帧会暂存,ACK 返回后只回放本请求帧。旧服务端缺少该字段时 仍兼容按首帧绑定。

scope.launch {
    im.ai.ask(targetUserId = "bot:cs", text = "退款怎么弄", idleTimeoutMs = 15_000L)
        .collect { ev ->
            when (ev) {
                is AiReplyEvent.Delta -> render(ev.fullSoFar)
                is AiReplyEvent.Done  -> finalize(ev.fullText)
                is AiReplyEvent.Error ->
                    if (ev.code == "stream_timeout") retry() else showError(ev.code)
            }
        }
}
  • 空闲超时:每次收到匹配的 delta/done/error 帧都会重置计时;ask() 的 idleTimeoutMs 参数按次覆盖默认值(30000,<= 0 关闭计时器)。标准 im.ai(经 Client.kt 构造)固定用该默认值——如需改客户端级默认,需 自行以 AiApi(rt, defaultIdleTimeoutMs = ...) 构造。
  • 重连恢复:bot 回复无条件落库,重连时若本次 ask() 仍在途,会做一次 范围历史读取;命中则直接从历史全文交付 Done,未命中则重置空闲窗口 继续等。
  • im.ai.replies(...)(开放观察者)不带上述超时/恢复,只是被动订阅。

Message lifecycle (edit / recall / delete / typing / receipts)

dm addresses the peer explicitly (targetUserId); group/room act on the active conversation (no id argument — server infers it from the connection). receipts (dm/group only) reports read/delivery status for a batch of message ids.

// dm
im.dm.edit(targetUserId = "1002", messageId = "42", text = "fixed typo")
im.dm.recall(targetUserId = "1002", messageId = "42")
im.dm.delete(targetUserId = "1002", messageId = "42")
im.dm.setTyping(targetUserId = "1002", isTyping = true)
im.dm.receipts(targetUserId = "1002", messageIds = listOf("40", "41", "42"))

// group / room — active conversation, no id
im.group.edit(messageId = "42", text = "fixed typo")
im.group.recall(messageId = "42")
im.group.delete(messageId = "42")
im.group.setTyping(isTyping = true)
im.group.receipts(groupId = 7, messageIds = listOf("40", "41"))

im.room.edit(messageId = "42", text = "fixed typo")
im.room.recall(messageId = "42")
im.room.delete(messageId = "42")
im.room.setTyping(isTyping = true)

// observe pushes (each scoped to its own scene; typing/mutation flows are
// pre-filtered by scene so a group observer never sees a dm/room event)
scope.launch { im.dm.onTyping().collect { /* { scene, userID, isTyping } */ } }
scope.launch { im.dm.onMutation().collect { /* { scene, messageID, type: edit|recall|delete, ... } */ } }
scope.launch { im.dm.onReadReceipt().collect { /* { messageID, readerUserID } */ } }
scope.launch { im.dm.onDeliveryReceipt().collect { /* { messageID, receiverUserID } */ } }
scope.launch { im.group.onReadReceipt().collect { /* { messageID, readerUserID } */ } }

Message expansion KV (polls / sign-up chains / task status)

给任意消息附加可增改删的 Key/Value:u:<userID>: 前缀的 key 只有本人能写删(投票/接龙的防篡改槽位),其余 key 只有消息发送者能写删(任务状态等)。value 是字符串,结构化数据自行 JSON 编码。

// 投票:发一条"投票"消息,各人把票写进自己的私有槽位
val poll = im.group.send(groupId = 7, text = "午饭吃什么?A 火锅 / B 烧烤")
val messageId = poll.jsonObject["messageID"]!!.jsonPrimitive.content
im.group.setExpansion(messageId = messageId, entries = mapOf("u:$myUserId:vote" to "A")) // 每人只能写自己的 u:<uid>:*

// 计票:客户端遍历 expansions 聚合(服务端不做统计)
scope.launch {
    im.group.onMutation().collect { m ->
        if (m.jsonObject["operation"]?.jsonPrimitive?.content != "expansion") return@collect
        val expansions = m.jsonObject["message"]?.jsonObject?.get("expansions")?.jsonObject
        val votes = expansions?.entries?.filter { it.key.endsWith(":vote") }
            ?.map { it.value.jsonObject["value"]!!.jsonPrimitive.content }
        // votes => ["A", "B", "A", ...]
    }
}

// 改票 = 再 set(每 key last-write-wins);弃票 = 删除自己的 key
im.group.setExpansion(messageId = messageId, entries = mapOf("u:$myUserId:vote" to "B"))
im.group.deleteExpansion(messageId = messageId, keys = listOf("u:$myUserId:vote"))

// 任务状态:公共 key(无 u: 前缀)仅消息发送者可写
im.dm.setExpansion(targetUserId = "1002", messageId = messageId, entries = mapOf("task.status" to "done"))
im.dm.deleteExpansion(targetUserId = "1002", messageId = messageId, keys = listOf("task.status"))

im.room.setExpansion(messageId = messageId, entries = mapOf("u:$myUserId:signup" to "1"))
im.room.deleteExpansion(messageId = messageId, keys = listOf("u:$myUserId:signup"))

离线端上线后从历史里直接拿到最新 message.expansions(无需补事件);单条消息默认上限 100 个 key、单次最多 20 个。

Relations (friends / blocks / whitelist)

im.relations.friendAdd("1002", "你好")
val list = im.relations.friendList()   // JsonElement: items / userIDs / count
im.relations.friendAccept("1002")
im.relations.blockAdd("1003")

scope.launch {
    im.relations.friendRequestUpdates().collect { req ->
        // req.jsonObject["direction"], ["status"], ["requesterUserID"]
    }
}

Enterprise directory

im.enterprise.createDepartment(name = "Engineering", parentId = "root")
val depts = im.enterprise.listDepartments()
im.enterprise.upsertMember(userId = "1002", departmentId = "d1", title = "Dev")
val members = im.enterprise.listMembers(departmentId = "d1", includeChildren = true)
val hits = im.enterprise.searchMembers(keyword = "王")

Group governance

Owner/admin-only calls to manage a group, plus directed-visibility send params.

// Governance (owner/admin)
im.group.muteAll(groupId = 7, muted = true)
im.group.setRole(groupId = 7, targetUserId = "1002", role = "admin")
im.group.transferOwner(groupId = 7, targetUserId = "1002")
im.group.disband(groupId = 7)

// Join requests: apply to join, list pending requests, approve/reject
im.group.apply(groupId = 7, message = "想加入")
val requests = im.group.joinRequests()
// raw ChatGroupRequestList: incomingInvites/outgoingInvites/incomingApplies/outgoingApplies
im.group.approveRequest(requestId = 123L)
im.group.rejectRequest(requestId = 123L)

im.group.setHistoryPolicy(groupId = 7, allowHistoryBeforeJoin = false)

// send()/sendQueued() also accept mention/target params:
im.group.send(groupId = 7, text = "@all 通知", mentionAll = true)
im.group.send(groupId = 7, text = "@bob", mentionUserIds = listOf("1002"))
im.group.send(groupId = 7, text = "仅这几位可见", targetUserIds = listOf("1002", "1003"))

Realtime signaling (RTC)

im.rtc is a signaling-only wrapper around WebRTC call setup: typed suspend send functions (1-1 call invite / accept / reject / hangup / offer / answer / ICE, plus multi-party mesh join / signal / leave) and typed inbound Flows for the matching pushes. The SDK never creates a peer connection or touches media — pair it with a WebRTC stack you own (e.g. the WebRTC Android SDK); the SDK only carries the signaling to/from your peer connection.

// Caller
val invite = im.rtc.invite(targetUserId = "1002", callType = "video")
val callId = invite.jsonObject["callID"]!!.jsonPrimitive.content
// Wire your own PeerConnection's ICE candidates to sendIce, and its local
// offer to sendOffer:
im.rtc.sendOffer(callId = callId, targetUserId = "1002", callType = "video", sdp = offerSdp)
im.rtc.sendIce(callId = callId, targetUserId = "1002", candidate = candidateSdp, sdpMid = sdpMid, sdpMLineIndex = sdpMLineIndex)

// Callee / peer
scope.launch { im.rtc.invites().collect { s -> /* ring UI: s.jsonObject["callID"], ["fromUserID"] */ } }
scope.launch {
    im.rtc.offers().collect { s ->
        // setRemoteDescription from s.jsonObject["sdp"], create your answer, then im.rtc.sendAnswer(...)
    }
}
scope.launch { im.rtc.iceCandidates().collect { s -> /* feed into your peer connection */ } }

im.rtc.hangup(callId = callId, targetUserId = "1002", reason = "normal")

Multi-party mesh (room/group) uses the same shape: im.rtc.multiJoin(...) / im.rtc.multiSignal(...) / im.rtc.multiLeave(...) plus im.rtc.multiStates() / im.rtc.multiSignals() flows — each participant still runs its own peer connection(s); the SDK only relays the mesh signaling.

Operations (the complete command surface)

im.operations wraps the final 26 registered server commands: conversation removal/labels/unread, DM policy and AI memory deletion, multi-device snapshots, presence/status, room governance and compatibility room history.

im.operations.setDmPolicy("friends_only")
im.operations.subscribePresence(targetUserIds = listOf("1002", "1003"))
im.operations.setConversationLabels("dm", listOf("vip"), targetUserId = "1002")
im.operations.markUnread("dm", true, targetUserId = "1002")
im.operations.setRoomAttributes(7, mapOf("topic" to "launch"))
im.operations.setRoomMuteAll(7, true)

Drafts (cross-device compose sync)

im.drafts persists an in-progress compose (text/segments/reply reference) per conversation so it follows the user across devices.

im.drafts.set(scene = "dm", targetUserId = "1002", text = "还没发送的草稿")
im.drafts.clear(scene = "dm", targetUserId = "1002")

Do-not-disturb (push quiet hours)

im.push.getDnd() / im.push.setDnd() manage a scheduled quiet-hours window for push notifications. days is an opaque numeric bitmask (bit N = day N — interpretation lives with the caller); startMinutes/endMinutes are minutes-of-day, and an end before start (e.g. 22:00→08:00) means the window wraps past midnight.

val dnd = im.push.setDnd(
    DndSettings(
        enabled = true,
        startMinutes = 22 * 60,
        endMinutes = 8 * 60,
        timezone = "Asia/Shanghai",
        days = 0x7f,
    ),
)
println(dnd.updatedAt)

val current = im.push.getDnd()

Scope (v1)

Transport core + seq-correlated request/response + login-ack-gated connect + typed DM/group/room/community send/receive/history + local message/conversation cache + tempID-backed offline send queue + push device registration wrapper + media upload (im.media). Not included: E2EE, Compose wrappers.

Development

cd clients/kotlin
./gradlew build
./gradlew test       # unit tests (fake channel + coroutines-test virtual clock, no server)

# Integration test against a live server:
IMCORE_TEST_WS_URL=wss://host/acc IMCORE_TEST_WS_TOKEN=<jwt> \
IMCORE_TEST_USER_ID=1001 IMCORE_TEST_PEER_ID=1002 ./gradlew test --tests "im.balala.sdk.IntegrationTest"

Mint the test token with the repo’s token CLI (same HS256 signing path as the server):

go run ./cmd/demo_token -user 1001   # secret from $IMCORE_AUTH_JWT_SECRET or -secret; issuer/audience default to the demo config