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