Skip to content

Android 空白工程接入晞晗IM

这份文档面向 Kotlin + Jetpack Compose 项目。XHIM Android 由两个可独立选择的 包组成:

text
xhim-sdk           Headless Kotlin/JNI Facade
xhim-ui-compose    会话、聊天、通话占位组件和 Lucide VectorDrawable

本文只处理 Android 客户端。Development 联调只需向服务端负责人领取 Server URL、App ID、测试 User ID 和测试 Conversation ID;公开部署配置由 SDK 自动 发现。你不需要数据库、服务端密钥、C++ 工具链或手工 Token。Production 再由 业务账号系统提供一个鉴权回调。不要在 Android 工程中执行服务端操作;运维入口仅见 XHIM Server 文档

跑通第一条消息后,所有公开方法、Flow 事件、模型和异常请查 SDK API Reference

普通接入顺序只有:

text
添加 Maven/AAR → 配置网络权限 → XHIMClient.connect → sendText

先确认发行包

只使用 Release Manifest 对应的签名 Maven 包或离线 AAR。发行包必须按声明 ABI 包含 Core 与 JNI Bridge,并同时提供 checksum、LICENSE、NOTICE 和 SBOM; 客户项目不编译也不编辑 C++ Core。

1. 新建工程

  1. Android Studio 选择 New Project → Empty Activity
  2. Language 选择 Kotlin,UI 使用 Compose。
  3. Minimum SDK 选择 API 24 或更高。
  4. 使用 JDK 17、compileSdk 34 或更高版本;版本必须以当次 XHIM 发布清单为准。

2. 添加依赖

正式内部 Maven 仓库:

kotlin
dependencies {
    implementation("com.xihansoftware.xhim:xhim-sdk:<version>")
    implementation("com.xihansoftware.xhim:xhim-ui-compose:<version>")
}

如果发行团队给的是离线包,优先保留包内 android/maven/ 目录结构,并在 settings.gradle.kts 指向它:

kotlin
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("/absolute/path/to/xhim/android/maven") }
    }
}

依赖声明仍使用上面的 Maven 坐标。这样 Gradle 会读取 XHIM 发布的 POM 和 .module 元数据,自动带入 coroutine、Compose 与 Activity 的传递依赖。

只有无法使用 Maven 仓库时才直接放置 AAR。文件依赖不会读取 POM,必须由客户 同时声明传递依赖:

kotlin
dependencies {
    implementation(files("libs/xhim-sdk-<version>.aar"))
    implementation(files("libs/xhim-ui-compose-<version>.aar"))
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("androidx.activity:activity-compose:1.9.0")
    implementation(platform("androidx.compose:compose-bom:2024.06.00"))
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")
}

最终 xhimsdk AAR 必须包含发布清单承诺的 ABI,例如 arm64-v8a/libxhim_core_v1.so 和 JNI Bridge,并携带 LICENSE/NOTICE。不要让 客户 App 自行编译 C++ Core。

3. 配置网络

AndroidManifest.xml

xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

Production SDK 固定使用 HTTPS/WSS,不需要 cleartext。局域网 HTTP 只允许专门 的 Development 变体通过 networkSecurityConfig 精确允许测试主机;不要对 Release 设置全局 usesCleartextTraffic="true"

4. 一次调用连接

XHIMClient 应由 Application 级账号容器或 Repository 持有,不要放进 Activity/Composable。所有连接方法都是挂起函数,应从已有业务协程调用。

Development 联调

服务端已显式开启 Development Easy Login 时,只需要 Server URL 和 User ID:

kotlin
import com.xihansoftware.xhim.XHIMClient

val client = XHIMClient.connect(
    context = applicationContext,
    server = "https://im.customer.example",
    userId = "alice",
)

connect 映射到底层最小配置:

Core 配置Android 来源
appId显式 appId,否则 Bootstrap 默认值
storagePathstorageRoot 下按 App ID + User ID 隔离
deployment.bootstrapUrlserver
Endpoint Key ID + Public KeyBootstrap 公开配置

这一行会依次完成:

  1. GET /v1/sdk/config 发现公开 App ID 和 Endpoint 验签公钥;
  2. App ID + User ID 创建不可预测的账号隔离数据库目录;
  3. 仅在服务端返回 environment=development 且明确启用免密登录时, POST /v1/sdk/development:login
  4. 创建 Core、执行 start/login 并返回已登录 Client。

多租户应用可显式传 appId = "your-app-id";不传时使用服务端公开配置中的 默认 App ID。企业存储策略可传应用私有目录中的 storageRoot。Development 登录若未启用会直接抛出 XHIMConnectionException,且 reason == XHIMConnectionException.Reason.DEVELOPMENT_LOGIN_DISABLED,不会匿名降级。

Production 业务鉴权回调

Production 不允许仅凭 User ID 登录。把业务 App 已有登录态封装成一个 XHIMCredentialProvider 回调:

kotlin
import com.xihansoftware.xhim.XHIMAuthentication
import com.xihansoftware.xhim.XHIMCredentialProvider

val client = XHIMClient.connect(
    context = applicationContext,
    server = BuildConfig.XHIM_SERVER_URL,
    userId = businessSession.currentUserId,
    authentication = XHIMAuthentication.Business(
        XHIMCredentialProvider {
            // 使用宿主 App 已登录的业务 Session 调自己的后端。
            accountApi.fetchXHIMCredential() // 返回 String
        },
    ),
)

Provider 会在需要登录时被 SDK 调用;SDK 自动处理票据续期和并发请求,页面和 ViewModel 不参与。新项目使用业务 Provider,不把固定登录票据写入源码或配置。

Android 客户端只通过回调取得购买方业务后端签发的当前用户登录票据。Server URL 必须是无 userinfo、query、fragment 的绝对 HTTP/HTTPS URL;Bootstrap 不跟随重定向, 单个响应最多 256 KiB。HTTP 只在公开配置确认 environment=development 后 继续使用,Production 必须 HTTPS。 实际 API/WSS/上传/下载地址仍只来自验签且未过期的 Endpoint Bundle。

Production Release 不允许 Local Preview、Development Easy Login 或固定测试 账号。Provider 失败时由账号 Session 恢复业务登录态,页面不保存 XHIM 登录 票据。

5. 收发和映射 UI

kotlin
import com.xihansoftware.xhim.XHIMEvent
import com.xihansoftware.xhim.XHIMMessageState
import java.util.UUID
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

val stateJob = viewModelScope.launch {
    client.events.collect { event ->
        if (event is XHIMEvent.StateChanged) {
            println("XHIM state: ${event.state}")
        }
    }
}

viewModelScope.launch {
    val clientMessageId = UUID.randomUUID().toString()
    client.sendText(
        conversationId = conversationId,
        clientMessageId = clientMessageId,
        text = draft,
    )

    val conversations = client.conversations(limit = 50)
    val page = client.messages(conversationId, limit = 50)
    page.messages.mapNotNull { it.serverSequence }.maxOrNull()?.let { through ->
        client.markConversationRead(conversationId, through)
    }

    val persisted = client.message(clientMessageId)
    if (persisted.state == XHIMMessageState.FAILED ||
        persisted.state == XHIMMessageState.CANCELLED) {
        client.retryMessage(clientMessageId)
    }
}

sendText 返回表示可靠 Outbox 已受理,不代表对端已经展示。最终状态以查询到的 SERVER_ACCEPTED 为准;页面由事件触发重新调用 messages/conversationsnextCursor 是不透明 ByteArray,下一页只能原样传回对应查询。

在线状态与“正在输入”

Client 进入 READY 后直接调用,不要在业务层拼 HTTP、Token 或 WebSocket:

kotlin
import com.xihansoftware.xhim.XHIMPresenceStatus

client.publishPresence(XHIMPresenceStatus.ONLINE)
client.publishTyping(conversationId, isTyping = true)

// 输入框清空、发送成功或页面退出时立即清除。
client.publishTyping(conversationId, isTyping = false)

对端仍从 events 收取:

kotlin
when (event) {
    is XHIMEvent.PresenceChanged ->
        showPresence(
            event.update.userId,
            event.update.status,
            event.update.expiresAtMilliseconds,
        )
    is XHIMEvent.TypingChanged ->
        showTyping(
            event.update.conversationId,
            event.update.userId,
            event.update.isTyping,
            event.update.expiresAtMilliseconds,
        )
    else -> Unit
}

输入状态只在开始/停止时上报,不要每次按键调用。UI 必须按服务端返回的 expiresAtMilliseconds 自动清除过期状态;省略 TTL 时由服务端使用统一默认值。

5.1 用户资料、单聊、Push 和协程取消

这些能力直接走 SDK 的强类型 API,业务端不需要自己拼请求或保存用户凭证:

kotlin
import com.xihansoftware.xhim.XHIMPushDevice
import com.xihansoftware.xhim.XHIMPushPlatform
import com.xihansoftware.xhim.XHIMUserProfileUpdate

viewModelScope.launch {
    val me = client.currentUserProfile()
    val profiles = client.userProfiles(listOf(me.userId, "bob"))

    // null 表示保持不变;空字符串表示明确清空。
    client.updateCurrentUserProfile(
        XHIMUserProfileUpdate(
            displayName = "Alice Android",
            avatarUrl = null,
            bio = "",
        ),
    )

    val direct = client.directConversation(peerUserId = "bob")
    client.sendText(
        conversationId = direct.conversationId,
        text = "Hello from Android",
    )
}

从 FCM/Huawei Push Kit 回调取得 token 后,使用保存在加密存储中的安装级 deviceId 注册。输入模型的 toString() 会脱敏,结果也不会包含 token; 业务日志仍不得打印 provider token:

kotlin
client.registerPushDevice(
    XHIMPushDevice(
        platform = XHIMPushPlatform.FCM,
        deviceId = pushInstallationId,
        token = providerToken,
        environment = "production",
        locale = resources.configuration.locales[0].toLanguageTag(),
    ),
)

// 退出账号或关闭推送:
client.disablePushDevice(pushInstallationId)

所有挂起 API 都跟随协程取消,Job.cancel() 会触发 Core 的通用 request cancel, 不会关闭账号级 Client:

kotlin
val lookup = viewModelScope.launch {
    client.userProfiles(listOf("alice", "bob"))
}
lookup.cancel()

ViewModel 用投影事件触发 SDK 重查,不轮询、不直接读取 SQLite:

kotlin
private val projectionRefresh = XHIMProjectionRequeryController(
    client = client,
    scope = viewModelScope,
) { change ->
    // null 是订阅后的首次查询;其他值是可合并的失效通知。
    reloadTimeline()
}

override fun onCleared() {
    projectionRefresh.close()
}

MessageUpserted/MessageStateChanged/ConversationChanged/SocialChanged/ SyncApplied 都携带 origin、scope、账号 fence、IDs、revision 和 sequence。 未知 kind/schema 映射成 ProjectionInvalidated,执行宽范围重查。JNI 在 native 回调返回前复制全部字段;Flow collector 在 viewModelScope 的调度器恢复。

Compose 页面只接收复制后的展示模型:

kotlin
XHIMTheme {
    XHIMChat(
        messages = state.messages,
        onSend = viewModel::send,
        onAttachment = viewModel::chooseAttachment,
    )
}

XHIMConversationListXHIMChat 已使用同一套 XHIM 设计令牌语义与 Lucide VectorDrawable。XHIMTheme 提供明暗色默认主题;宿主也可直接使用自己的 Material 3 MaterialTheme 改颜色/字体。组件不读取数据库、不直接请求 HTTP/WSS。

ViewModel 加载时间线:

kotlin
val page = client.messages(conversationId, limit = 50)
val ui = page.messages.asReversed().map { message ->
    MessageItem(
        id = message.clientMessageId,
        text = message.payload.toString(Charsets.UTF_8)
            .ifEmpty { message.fallbackText },
        outgoing = message.senderUserId == currentUserId,
    )
}

服务端历史和当前账号的可见范围使用独立的强类型 API:

kotlin
val history = client.getMessageHistory(
    conversationId = conversationId,
    limit = 50,
)
history.continuation?.let { continuation ->
    client.getMessageHistory(
        conversationId = conversationId,
        continuation = continuation,
        limit = 50,
    )
}

history.messages.firstOrNull()?.serverMessageId?.let { serverMessageId ->
    client.deleteMessageForSelf(
        conversationId = conversationId,
        serverMessageId = serverMessageId,
        mutationId = UUID.randomUUID().toString(),
    )
}

if (history.latestServerSequence > 0) {
    val cleared = client.clearConversation(
        conversationId = conversationId,
        throughServerSequence = history.latestServerSequence,
        mutationId = UUID.randomUUID().toString(),
        expectedRevision = history.view?.revision ?: 0,
    )
    client.hideConversation(
        conversationId = conversationId,
        mutationId = UUID.randomUUID().toString(),
        expectedRevision = cleared.view.revision,
    )
}

历史按服务端序号从新到旧返回,continuation 只能原样回传。删除、清空和隐藏 只影响当前账号视图;一次逻辑写入的 mutationId 在失败重试时必须复用。下一次 清空/隐藏使用最新 view.revision 做 CAS。取消调用所在协程会取消 native request。

编辑与撤回只接受已有 serverMessageId 的服务端消息。一次用户操作生成一个 mutationId;协程或网络重试必须复用它。CAS 版本来自当前消息的 mutationRevision

kotlin
val serverMessageId = message.serverMessageId ?: return
val edited = client.editText(
    conversationId = message.conversationId,
    serverMessageId = serverMessageId,
    mutationId = UUID.randomUUID().toString(),
    expectedRevision = message.mutationRevision,
    text = "修改后的内容",
)
val recalled = client.recall(
    conversationId = edited.conversationId,
    serverMessageId = serverMessageId,
    mutationId = UUID.randomUUID().toString(),
    expectedRevision = edited.mutationRevision,
)

返回值是服务端确认后的完整 XHIMMessage。监听 XHIMEvent.MessageMutated 后刷新对应时间线;未知 mutation 枚举会保留在 nativeMutationKind。取消调用所在的协程会取消对应 native request。

置顶和免打扰使用服务端 CAS,同步版本来自 conversation.preferenceRevision;草稿只保存到当前账号的加密本地库:

kotlin
client.setConversationPreference(
    conversationId = conversation.conversationId,
    isPinned = true,
    notificationsMuted = false,
    mutationId = UUID.randomUUID().toString(),
    expectedRevision = conversation.preferenceRevision,
)

val text = "尚未发送的内容"
client.setLocalDraft(
    conversation.conversationId,
    XHIMOutgoingMessage(
        contentType = "text/plain",
        contentVersion = 1,
        payload = text.toByteArray(),
        fallbackText = text,
    ),
)
val draft = client.localDraft(conversation.conversationId)
client.clearLocalDraft(conversation.conversationId)

draft.isPresent == false 表示没有草稿;本地草稿不会跨设备同步。

6. 拍照、相册、视频和文件

Compose 包提供 XHIMAttachmentPicker。Android 13+ 使用系统 Photo Picker, 旧版本由 AndroidX 自动回退;文件使用 OpenDocument,不会申请广泛存储权限。 拍照 URI 由宿主的 FileProvider 提供,因为 SDK 不应猜测客户的 applicationId

AndroidManifest.xml<application> 内加入:

xml
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.xhim.files"
    android:exported="false"
    android:grantUriPermissions="true">
  <meta-data
      android:name="android.support.FILE_PROVIDER_PATHS"
      android:resource="@xml/xhim_file_paths" />
</provider>

新建 res/xml/xhim_file_paths.xml

xml
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
  <cache-path name="xhim_camera" path="xhim-camera/" />
</paths>

页面接入:

kotlin
XHIMAttachmentPicker(
    visible = state.showAttachmentPicker,
    onDismiss = viewModel::dismissAttachmentPicker,
    cameraUriProvider = {
        viewModel.createCameraContentUri() // FileProvider.getUriForFile(...)
    },
    onPicked = viewModel::sendAttachment,
)

Picker 返回的是临时授权 Uri。先复制到账号隔离的 filesDir 持久目录,再把 File 交给 Core;宿主不创建 uploader,也不处理短期媒体凭证:

kotlin
val sourceFile = attachmentStore.persist(
    contentResolver = contentResolver,
    source = picked.uri,
)

val accepted = client.sendImage(
    conversationId = conversationId,
    sourceFile = sourceFile,
    mimeType = contentResolver.getType(picked.uri) ?: "image/jpeg",
    width = imageWidth,
    height = imageHeight,
)

client.sendFile(
    conversationId = conversationId,
    sourceFile = sourceFile,
    mimeType = contentResolver.getType(picked.uri)
        ?: "application/octet-stream",
    displayName = queryDisplayName(picked.uri),
)

视频和语音分别调用 sendVideosendAudio。返回值只表示任务已持久化受理, 不表示传输或消息发送完成。收到 XHIMEvent.MediaTaskUpdated 后按 taskId 调用 mediaTask() 查询终态;协程取消只取消当前请求等待,持久业务取消必须 调用 cancelMediaTask()

收到消息里的 XHIMMediaRef 后可创建私有缓存下载:

kotlin
val download = client.acceptMediaDownload(
    XHIMDurableMediaDownloadIntent(
        cacheKey = UUID.randomUUID().toString(),
        mediaRef = mediaRef,
    ),
)

cacheKey 是扁平标识而不是路径;不得拼接 storage path 猜测缓存文件。 client.diagnostics() 是同步、只读、纯内存健康快照,可在任意 App 线程读取, 且不包含账号、消息、用户凭证、本地路径或签名 URL。

下载任务到达 COMPLETED 后,通过 Reader 消费校验后的字节:

kotlin
val reader = client.openMediaCacheReader(cacheKey, mediaRef)
try {
    reader.chunks().collect { bytes ->
        decoder.append(bytes)
    }
} finally {
    reader.close()
}

open、完整 SHA-256 校验和 read 都在 Dispatchers.IO;同一 Reader 的读取与 关闭串行,Reader 可晚于 Client 关闭。停止 Flow 收集就是取消,不调用通用 cancelRequest

ConnectivityManager.NetworkCallback.onAvailable 中调用:

kotlin
client.notifyNetworkAvailable()

它是同步 best-effort 提示,没有 callback/request ID,只用于提前唤醒重连; Core 原有退避定时器仍然保底。

6.1 位置、名片、回复、合并转发与本地搜索

kotlin
import com.xihansoftware.xhim.XHIMMessageSearchQuery
import com.xihansoftware.xhim.XHIMStandardMessageFactory

val location = XHIMStandardMessageFactory.location(
    latitude = 31.2304,
    longitude = 121.4737,
    name = "人民广场",
    address = "上海市黄浦区",
)
client.sendMessage(conversationId, location)

val card = XHIMStandardMessageFactory.contactCard(
    userId = "bob",
    displayName = "Bob",
)
client.sendMessage(conversationId, card)

val result = client.searchMessages(
    XHIMMessageSearchQuery(
        text = "合同",
        conversationId = conversationId,
        contentTypes = listOf("text/plain", "xhim.media.file"),
    ),
)
result.messages.forEach { message -> println(message.fallbackText) }

搜索只读取当前账号本机已经同步的安全文本投影,不向服务端上传搜索词。继续翻页 时原样传回 result.nextCursor,不要解析 Cursor。

6.2 自定义消息

kotlin
import com.xihansoftware.xhim.XHIMOutgoingMessage

val custom = XHIMOutgoingMessage(
    contentType = "com.customer.message.order-card",
    contentVersion = 1,
    payload = """{"orderId":"order-1001","title":"待付款订单"}"""
        .toByteArray(Charsets.UTF_8),
    fallbackText = "[订单] 待付款订单",
)
client.sendMessage(
    conversationId = conversationId,
    message = custom,
)

接收端按 contentType + contentVersion 解码,未知版本展示 fallbackText。 需要统一验证、会话预览和 Compose Renderer 时注册 XHIMMessagePluginRegistry;插件失败必须回退,不能阻塞时间线。

7. 生命周期

  • 一个登录账号一个 XHIMClient
  • Activity/Composable 重建不能重建 Client;
  • 退出账号先 logout();它会撤销自动续凭 Provider,并阻止旧账号的在途 刷新结果跨账号提交;
  • Application 结束或账号容器销毁时 shutdown()/close()
  • 不同账号使用不同数据库目录;
  • 不在主线程阻塞等待 JNI Callback。

推荐账号容器的退出顺序:

kotlin
client.disablePushDevice(pushInstallationId)
client.logout()
client.shutdown()
stateJob.cancel()
projectionRefresh.close()

8. Alice/Bob 验证

  1. 服务端创建 Alice、Bob 和共同会话;
  2. Development 环境两台设备分别调用 XHIMClient.connect(..., userId = "alice"/"bob");Production 则使用各自业务登录态的 Provider;
  3. 两端都达到 READY
  4. Alice/Bob 互发并查询 messages()
  5. 服务端确认 server_sequence 单调增长;
  6. 再测试断网重连、进程杀死恢复、自动保持登录和账号切换。

9. 常见问题

  • UnsatisfiedLinkError:确认 AAR 含当前设备 ABI,且没有混入另一版本 JNI/Core;
  • DEVELOPMENT_LOGIN_DISABLED:当前地址不允许 Easy Login,改用业务 Provider;
  • 一直未到 READY:检查 INTERNET 权限、Bootstrap HTTPS、系统时间和业务会话;
  • 能发送但页面不刷新:确认已收集事件并重新调用 messages,不要缓存旧 Page;
  • 媒体失败:确认 Picker 内容已复制到 App 私有持久目录且 MIME/尺寸有效。

连接错误按 XHIMConnectionException.reason 分支;运行时按 XHIMException.code/domain/stableCode/retryable 分支。不要解析可读 message 决定重试,也不要记录凭证、完整 Payload 或本地附件路径。

10. Production 上线检查

  • 使用签名 AAR 和受控 Maven 仓库;
  • 校验 arm64-v8a/x86_64 等声明 ABI;
  • 使用业务鉴权回调,禁用 Local Preview 和 Easy Login;
  • Release 禁止 cleartext 和动态信任绕过;
  • R8/ProGuard consumer rules、JNI symbol、native debug symbols 齐全;
  • 真机覆盖目标 Android 版本和主要厂商后台策略;
  • 验证会话/历史分页、文本/媒体/自定义消息、已读、编辑撤回和社交治理;
  • 验证请求取消、失败幂等重试、前后台、断网、推送和账号切换;
  • AAR 包含 Lucide/XHIM/Native 依赖许可证、NOTICE、SBOM 和 checksum。

JNI、分页、错误模型和发布细节见 Android 完整指南

附录 A:好友、群组和黑名单写入

kotlin
val request = client.sendFriendRequest(
    toUserId = "bob",
    mutationId = UUID.randomUUID().toString(),
)
val resolved = client.resolveFriendRequest(
    request.requestId,
    XHIMFriendRequestDecision.ACCEPT,
    UUID.randomUUID().toString(),
)
val deleteMutationId = UUID.randomUUID().toString()
client.deleteFriendship(
    peerUserId = resolved.friendship!!.peerUserId,
    mutationId = deleteMutationId,
)
val group = client.createGroup(
    "项目群", listOf("alice", "bob"), UUID.randomUUID().toString())
val roster = client.changeGroupMembers(
    conversationId = group.group.conversationId,
    addUserIds = listOf("carol"),
    expectedRevision = group.group.revision,
    mutationId = UUID.randomUUID().toString(),
)
client.setBlock("spam-user", true, UUID.randomUUID().toString())
val join = client.requestGroupJoin(
    roster.group.conversationId,
    mutationId = UUID.randomUUID().toString(),
)
client.resolveGroupJoin(
    join.request.requestId,
    XHIMGroupJoinDecision.ACCEPT,
    UUID.randomUUID().toString(),
)
val governed = client.changeGroupGovernance(
    conversationId = roster.group.conversationId,
    expectedRevision = roster.group.revision,
    mutationId = UUID.randomUUID().toString(),
    change = XHIMGroupGovernanceChange.SetJoinApprovalRequired(true),
)

// 普通成员退出;revision 必须来自该群最后一次权威查询或写入结果。
val memberGroup = client.groups().items.first {
    it.conversationId == "member-group-id"
}
val leaveMutationId = UUID.randomUUID().toString()
client.leaveGroup(
    conversationId = memberGroup.conversationId,
    expectedRevision = memberGroup.revision,
    mutationId = leaveMutationId,
)

// 群主解散另一个群。解散与退出是两个互斥业务操作,不要对同一个群连调。
val dismissMutationId = UUID.randomUUID().toString()
client.dismissGroup(
    conversationId = governed.group.conversationId,
    expectedRevision = governed.group.revision,
    mutationId = dismissMutationId,
)

mutationId 必须在发请求前生成并持久化:每个逻辑写入只生成一次,超时、断网 或进程恢复后的精确重试复用同一个值;修改任何参数就是新的逻辑写入,必须生成 新值。同一个 ID 搭配不同参数会得到 IDEMPOTENCY_CONFLICT

leaveGroupdismissGroup、成员和治理变更的 expectedRevision 必须取自最新 权威 group.revision。CAS 冲突后先重查群资料,根据新状态重新确认用户意图, 再用新 revision 提交。协程取消复用通用 native request cancel,只取消等待和 结果回调,不回滚服务端已经提交的删除、退出或解散。

错误处理只根据 XHIMException.code/domain/stableCode 分支,不能解析 message。日志不得记录用户凭证、申请附言、群资料、用户 ID 或完整 mutation 参数。

附录 B:离线只读数据库

已有账号数据库需要被诊断、导出或只读展示时,使用 XHIMOfflineReader.open。业务层不用开线程,也不直接接触 JNI:

kotlin
val reader = XHIMOfflineReader.open(
    XHIMOfflineReaderConfiguration(
        databasePath = accountDatabase.absolutePath,
        accountId = currentUserId,
    ),
    XHIMOfflineDatabaseKeyProvider {
        // 读取由 Android Keystore 包装的随机数据库 Key。
        secureDatabaseKeys.unwrap(currentUserId)
    },
)

try {
    val first = reader.messages(conversationId)
    val next = first.nextCursor?.let { cursor ->
        reader.messages(conversationId, cursor)
    }
    reader.searchMessages(XHIMMessageSearchQuery(text = "合同"))
    reader.conversations()
    reader.friendRequests()
    reader.friendships()
    reader.groups()
    reader.groupMembers(groupId)
    reader.blocks()
    reader.groupJoinRequests()
} finally {
    reader.close()
}

close() 是 suspend 且幂等。所有同步 SQLite/JNI 调用在 Dispatchers.IO 执行, 同一句柄由 Mutex 串行;JNI 会在 C ABI 返回后立即把 borrowed page 深拷贝成 独立 ByteArraynextCursor 是不透明二进制值,不能转字符串或自行解析。 数据库 Key 不得硬编码到 Kotlin、resources、BuildConfig 或 SharedPreferences;SDK 会清理自己的临时副本。

附录 C:登录设备管理

kotlin
val page = client.listDeviceSessions()
val target = page.sessions.firstOrNull { !it.current && it.active }
if (target != null) {
    val result = client.revokeDeviceSession(
        sessionId = target.sessionId,
        mutationId = UUID.randomUUID().toString(),
    )
    // false 表示此前已过期/失活,仍是成功的幂等结果。
    check(result.changed || !result.session.active)
}

// 同步只读、调用方持有;不依赖先调用 list,也不发起网络或磁盘 I/O。
val policy = client.deviceSessionPolicySnapshot()

同一次精确重试复用 mutationId,新操作生成新值。协程取消自动使用通用 native request cancel。自撤销返回 current == trueactive == false,App 应回到 登录态。登录完成前或登出后读取策略会抛出 XHIMException(code = 2),即原生 INVALID_STATE;不要用空策略推断登录态。toString() 已脱敏,业务日志也 不得输出 session/device ID 或撤销原因。

附录 D:协议兼容快照(仅诊断/灰度)

kotlin
val compatibility = client.compatibilitySnapshot()
Log.i(
    "XHIM",
    "protocol=${compatibility.clientProtocolVersion}/" +
        "${compatibility.serverProtocolVersion}",
)

普通业务通常不需要处理协议协商,登录已对不兼容服务端 fail-closed。该同步 getter 只读取登录时严格验证并深拷贝的内存快照,不发起网络或磁盘 I/O,仅用于 诊断、客服支持包和受控灰度观测。登录前或登出后会保留 XHIMException(code = 2)(原生 INVALID_STATE)。不要用 capability 绕过 登录结果,也不要记录 SDK/Server 版本或 capability 值;toString() 只输出 协议号和数量。

XHIM 客户端 SDK 与服务端文档