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