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.
Usage
import com.imcore.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>",
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 "com.imcore.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