Skip to content

晞晗IM(XHIM)Android 接入指南

第一次在空白 Android 工程接入? 请先看 Android 从零快速接入。本页保留 JNI、C ABI、发布和高级 二次开发细节。 listDeviceSessionsrevokeDeviceSession、请求取消及同步只读策略示例见 Android QuickStart 的登录设备管理

当前状态:Gradle Library、动态注册 JNI Bridge、Kotlin coroutine/Flow Facade、Compose 会话/聊天组件、可选通话控件和 QuickStart 已提交。JNI 已通过严格 C++ 语法门禁;正式售卖前仍需在固定 Android SDK/NDK 上构建多 ABI AAR, 完成真机与 Maven 消费测试。

1. 商用交付边界

Android 客户最终只依赖 AAR/Maven 包:

text
xhim-sdk.aar
├── classes.jar                    Kotlin Headless Facade
├── jni/arm64-v8a/libxhim_jni.so
├── jni/armeabi-v7a/libxhim_jni.so        仅在支持矩阵声明时提供
├── jni/x86_64/libxhim_jni.so              Emulator
├── prefab/                        可选,仅供 NDK 深度客户
├── R.txt / AndroidManifest.xml
├── consumer-rules.pro
└── classes.jar/META-INF/
    ├── XHIM-LICENSE.txt
    └── XHIM-NOTICE.txt

建议拆分 Maven artifact:

text
<group>:xhim-sdk:<version>          Headless Kotlin Facade + native core
<group>:xhim-ui-compose:<version>   Compose UI,可选
<group>:xhim-ui-view:<version>      View UI,可选
<group>:xhim-call:<version>         系统通话桥/RTC,可选

真实 group ID、namespace、Maven 仓库和最低 API Level 尚未冻结,发布前必须进入 品牌与兼容性清单。

Android 官方支持在 AAR 中携带 native libraries,并可通过 Prefab 向 C/C++ 消费者导出头和库。参考: AAR Native Dependencies 与 Prefab

2. 客户项目接入(目标发行包)

正式发布后在 settings.gradle.kts 配置企业 Maven 仓库,然后:

kotlin
dependencies {
    implementation("<group>:xhim-sdk:<version>")
    // 可选:
    implementation("<group>:xhim-ui-compose:<version>")
}

App 不应再声明 externalNativeBuild 编译 XHIM,也不应复制 .so 到业务仓库。 SDK AAR 必须自己提供所有声明支持的 ABI,并避免把同名不同版本 libc++_shared.so 与其他三方 SDK 冲突地打进 APK。

使用 abiFilters 时必须覆盖 XHIM 发布矩阵:

kotlin
android {
    defaultConfig {
        ndk {
            abiFilters += setOf("arm64-v8a", "x86_64")
        }
    }
}

是否保留 32 位 ABI 由产品支持矩阵决定,不能只构建成功一个 ABI 就宣称 “支持 Android”。

3. 当前源码验证

当前仓库的 Android Library 已按下列结构建立:

text
platforms/android/xhim-sdk/
├── build.gradle.kts
├── src/main/kotlin/...            Kotlin Facade
├── src/main/cpp/xhim_jni.cpp
└── src/main/cpp/CMakeLists.txt

JNI .so 推荐静态链接 XHIM Core 和 SQLite,只向 JVM 暴露一个 native library。 Android NDK 环境不能误用主机 SQLite;产品构建应提供经过许可证审计的官方 SQLite amalgamation,并使用 XHIM_SQLITE_PROVIDER=BUNDLED,或注入经过验证的 目标 ABI SQLite::SQLite3

构建必须锁定 NDK、CMake、Android Gradle Plugin、JDK 和 STL 策略。升级任何 一项都单独跑全 ABI 与真机回归,不与普通业务版本捆绑升级。 当前仓库还没有 androidTest 真机消费套件;它属于正式 AAR 发布前必须补齐的 验收门禁,不能由 JNI 主机语法检查替代。

4. Kotlin Facade API

建议公开 coroutine/Flow,而不是 JNI 指针或 callback:

kotlin
// 实际入口:xhim-sdk/.../XHIMClient.kt
val client = XHIMClient.connect(
    context = applicationContext,
    server = "https://im.customer.example",
    userId = "alice",
)

val policy = XHIMRuntimePolicy(
    requestTimeoutMilliseconds = 15_000,
    syncPageSize = 200,
    reconnectInitialDelayMilliseconds = 1_000,
    reconnectMaxDelayMilliseconds = 30_000,
    maxReconnectAttempts = 8,
)

class XHIMClient : AutoCloseable {
    val events: SharedFlow<XHIMEvent>

    companion object {
        suspend fun connect(
            context: Context,
            server: String,
            userId: String,
            appId: String? = null,
            authentication: XHIMAuthentication =
                XHIMAuthentication.Development,
            storageRoot: File? = null
        ): XHIMClient
    }

    suspend fun start()
    suspend fun login(accountHint: String, accessToken: String)
    suspend fun updateCredential(accessToken: String)
    suspend fun logout()
    suspend fun sendText(
        conversationId: String,
        clientMessageId: String? = null,
        text: String
    ): XHIMSendReceipt
    suspend fun sendMessage(
        conversationId: String,
        message: XHIMOutgoingMessage,
        clientMessageId: String? = null
    ): XHIMSendReceipt
    suspend fun retryMessage(clientMessageId: String): XHIMSendReceipt
    suspend fun cancelMessage(clientMessageId: String): XHIMSendReceipt
    suspend fun message(clientMessageId: String): XHIMMessage
    suspend fun messages(
        conversationId: String,
        cursor: ByteArray? = null,
        limit: Int = 50
    ): XHIMMessagePage
    suspend fun conversations(
        cursor: ByteArray? = null,
        limit: Int = 50
    ): XHIMConversationPage
    suspend fun markConversationRead(
        conversationId: String,
        throughServerSequence: Long
    ): XHIMConversationReadReceipt
    fun state(): XHIMClientState
    suspend fun shutdown()
}

持久 UI 不轮询数据库:XHIMEvent 公开 MessageUpserted/MessageStateChanged/ConversationChanged/SocialChanged/ SyncApplied,统一 change 含 origin、scope、IDs、revision、sequence 和账号 fence。Compose 可用 XHIMProjectionRequeryController(client, viewModelScope) 订阅后先查询、再按失效通知调用公开 SDK 查询。未知 kind/schema 保留为 ProjectionInvalidated 并宽范围重查;JNI 回调内已完成深拷贝,collector 使用 自己的协程上下文,UI 不读取 SQLite。

运行策略和 XHIMDeployment 在 Client 创建时复制,账号生命周期内不可变。 客户可提供 Bootstrap URL、Endpoint Key ID 和 Ed25519 公钥;API/WSS/上传/ 下载地址只能来自验签且未过期的 Endpoint Bundle。TLS Trust 不暴露“关闭校验” 之类的 App 配置。

xhim-ui-compose 已提供 XHIMConversationListXHIMChatXHIMCallControls。它只依赖 xhim-sdk,业务可以完全不引入 UI artifact。

Facade 对业务暴露不可变 Kotlin data class、sealed error 和 ByteArray Cursor。 close() 最终释放 native handle,但正常退出先执行 shutdown()

4.1 Easy Login 与凭证轮换

connect 是推荐的业务入口。它先请求公开的 GET /v1/sdk/config,创建 App ID + User ID 哈希隔离的 noBackupFilesDir 数据库,再复用现有 start/login。调用方不再手工复制 Endpoint Key ID 和 Ed25519 公钥。

Development 服务端明确返回 environment=developmentdevelopment_login_enabled=true 时,默认认证会请求 POST /v1/sdk/development:login。Production 使用宿主业务登录态:

kotlin
val client = XHIMClient.connect(
    context = applicationContext,
    server = BuildConfig.XHIM_SERVER_URL,
    userId = session.userId,
    authentication = XHIMAuthentication.Business(
        XHIMCredentialProvider {
            businessAccountApi.fetchXHIMCredential().accessToken
        },
    ),
)

Core 进入 CREDENTIAL_REQUIRED 后,SDK 在账号级协程中重新调用同一 Provider 并执行 updateCredential;同一时刻只允许一个续凭任务。失败不会隐藏公开状态, 业务仍可观察事件并决定提示或重新登录。AccessToken 模式只用于固定凭证迁移和 受控测试。

Bootstrap 安全合同:

  • Server URL 只接受绝对 HTTP/HTTPS,拒绝 userinfo、query、fragment 和非法端口;
  • 配置与登录请求不跟随 3xx,响应流和 Content-Length 都限制为 256 KiB;
  • HTTP 只在配置确认 environment=development 后允许继续,其他环境 fail closed;
  • Development 认证在 test/production 环境一律拒绝;
  • APK 不保存或请求 Admin Key、Endpoint 私钥及其他账号 Token;
  • Production 只使用业务 XHIMCredentialProvider 或显式固定用户 Token;
  • Provider 只返回凭证字符串,Endpoint 发现和 Core 状态机仍由 SDK 管理。

需要完全手工托管的高级客户仍可使用 XHIMClientConfigurationXHIMDeploymentstart/login/updateCredential;这不是空白工程的默认路径。

4.2 已读调用

messages 返回最新消息在前的强类型页面;nextCursor 是可能包含 NUL 的二进制 游标,只能复制并原样传入下一次同账号、同会话查询。在聊天列表已经展示一批 服务端确认消息后,提交其中最大的正 serverSequence

kotlin
val page = client.messages(conversationId)
val highestVisibleServerSequence = page.messages
    .mapNotNull { it.serverSequence }
    .maxOrNull()
    ?: return

val receipt = client.markConversationRead(
    conversationId = conversationId,
    throughServerSequence = highestVisibleServerSequence,
)
// 以 receipt.unreadCount 刷新 UI。

不要提交 Pending/Sending 消息,不要在挂起函数完成前乐观清零。收到 XHIMEvent.ConversationReadChanged(conversationId) 后重新加载该会话摘要; 该事件也用于同账号其他设备已读后的失效通知。同值或更低值是幂等 no-op。

conversations 返回置顶优先、随后按持久活动排序的摘要页。业务不得通过 JNI 私有入口、SQLite、列表下标或设备时间生成服务端序号。

实时 Presence/Typing(非持久投影)

publishPresence/publishTyping 只使用当前已登录会话,业务层不传 Token。 权威回显和对端 PresenceChanged/TypingChanged 都携带 sequenceexpiresAtMilliseconds。这两类状态不落 SQLite,也不属于可重查的投影事件; UI 到期必须清除,并且只上报开始/停止输入转换,禁止逐按键发送。

4.3 自定义消息与 Compose Renderer

每个业务类型注册一个精确 contentType,发送时先校验插件,再提交相同信封:

kotlin
val plugins = XHIMMessagePluginRegistry().apply {
    register(ProductCardPlugin())
}
val outgoing = XHIMOutgoingMessage(
    contentType = "com.xihan.product-card",
    contentVersion = 1,
    payload = cardJson.encodeToByteArray(),
    fallbackText = "[商品卡片]",
)
plugins.validate(outgoing)
client.sendMessage(conversationId, outgoing)

收到或分页读取消息后调用 plugins.presentation(message)。插件未注册、不支持 更高版本、校验失败或抛异常时,Registry 使用 fallbackText 生成安全的会话/ 通知摘要;Core 仍保存原始 ByteArray,Cursor 可继续推进。

Compose 层可把 rendererKey 注册到 XHIMMessageRendererRegistryXHIMMessageItemRenderer。Renderer 只处理复制后的平台消息模型;异常会退回 基础 MessageItem,不得直接调用 JNI 或读取 SQLite。可运行入口见 sample/.../QuickStart.kt

5. JNI Bridge 规则

  • JNI_OnLoad 中保存 JavaVM* 并使用 RegisterNatives;不要依赖易被混淆或 重命名破坏的长 JNI 符号;
  • JNIEnv* 只属于当前线程,不能缓存后跨线程使用;native Callback 线程需要 按 JVM 合同 attach/detach;
  • Kotlin/Java callback owner 使用受控 global reference,并在 subscription 取消或最终请求完成后准确释放;
  • C Callback 内先复制 byte view 和数组,再投递到 Kotlin dispatcher;
  • UTF-8 bytes 与 JVM UTF-16 String 显式转换,不能使用字符数代替字节数;
  • 同一个 native request 只恢复一次 continuation;同步拒绝必须立即清理引用;
  • 协程取消会用 JNI 返回的 request ID 调用 native cancelRequest;这只取消 Completion,不回滚已经提交的消息/已读事务;
  • XHIMException 完整复制 domain/stableCode/nativeCode/retryable/retryAfterMilliseconds/userAction/operationId/traceId,业务不解析 message;
  • CREDENTIAL_REQUIRED 后只调用 updateCredential;不要销毁 profile 或替换 Core 持有的 canonical account;
  • cancelMessage 只取消尚未被 Transport 获取的本地 Outbox,不等于服务端撤回;
  • 已读 Bridge 必须复制 typed receipt,并把会话 ID 作为变更事件;Kotlin 不 维护独立 read sequence,也不在服务端确认前清零;
  • client_destroy 与全部 JNI Client 调用串行化;Cleaner 只能做泄漏兜底;
  • native 异常不能穿过 JNI;C ABI status/error 转为稳定 Kotlin 错误。

R8/ProGuard 规则只保留 JNI 注册和序列化确实需要的类/成员,不使用一条全包 -keep 掩盖错误。release minify 和 debug 都必须跑消费测试。

6. 生命周期和进程模型

Client 由 Application/账号级容器拥有,不由 Activity、Fragment 或 Compose 页面拥有。配置变化、页面重建不能重复创建 Client。

text
Application 进程启动 → create/start
账号登录             → login,等待 Ready
Activity 重建         → 复用同一 Client
账号退出             → logout(清除自动续凭 Provider)
进程受控关闭          → shutdown/close

当前版本不支持多个 Android 进程同时打开一个 XHIM profile。如果 Push、Service 或独立进程需要 IM 数据,必须通过单一进程 Service/IPC 设计,而不是各自创建 Client。WorkManager 不能用来维持永久 WebSocket;后台策略必须符合系统限制。

7. 存储和安全

  • 数据库放在 filesDir 或经产品确认的 noBackupFilesDir 账号隔离子目录, 不放 cacheDir、外置存储或公共 Downloads;
  • Cursor 使用 ByteArray 原样保存,不转 String/Base64 后再作为业务语义;
  • Token、数据库密钥和媒体密钥进入 Android Keystore 支持的安全封装;由于 Keystore AES Key 不可导出,使用它包装随机 SQLCipher 数据库密钥;
  • 日志和 Crash 不记录 Token、消息正文、完整 Cursor、临时 URL 或本地路径;
  • 清理账号数据前必须先 logout/shutdown 并关闭数据库;
  • 备份/换机策略必须明确:是否同步数据库、密钥是否可恢复、恢复失败如何重建。

8. 附件消息与可选实时音视频

  • XHIMAttachmentPicker 使用 Activity Result、Photo Picker、OpenDocument 和 宿主 FileProvider 相机 URI;content:// 授权只在上传所需生命周期内持有;
  • sendImage/sendVideo/sendAudio/sendFileFile 重载默认创建 Core 持久化上传任务;Hash、鉴权、传输、校验、恢复和依赖消息入队由 Core 负责;
  • API 返回只表示 durable admission;媒体事件仅提供 task hint,完整状态通过 mediaTask() 查询;cancelMediaTask() 与协程触发的通用 cancelRequest 语义不同;
  • acceptMediaDownload() 使用稳定 MediaRef 和扁平 cacheKey 创建私有 缓存下载;不得从 storage path 推导缓存路径;
  • 完成后只通过 XHIMMediaCacheReaderread/chunks 消费已校验字节; open/read 在 Dispatchers.IO,单 Reader 串行,停止 Flow 即取消;
  • Product Adapter 未提供 no-follow 安全 reader 时,open 明确映射 media_cache_unsupported(103),不退化为 Java 路径访问;
  • task、event、diagnostics() 和网络 Payload 都不含本地路径、Token、临时 URL 或签名 URL;diagnostics 是同步、只读、纯内存快照;
  • ConnectivityManager 报告网络恢复时调用 notifyNetworkAvailable(); 该同步提示没有 request ID,既有 Core 重连定时器仍是保底;
  • XHIMServerMediaUploader/XHIMMediaUploader 仅保留为旧版兼容迁移路径;
  • JNI 不把 Java InputStream 或文件描述符长期悬挂在 C++ 任务里;
  • 实时音视频不阻塞纯 IM 首发;以后以独立 xhim-call 包接入厂商 Provider;
  • AudioFocus、Bluetooth、前台 Service、Telecom/ConnectionService 和厂商 Java/C++ 对象只存在于可选通话 Adapter,不进入消息 Payload 或稳定 C ABI。

9. UI Kit 与二次开发

建议模块:

text
xhim-sdk             Headless
xhim-ui-common       主题、资源、Renderer SPI
xhim-ui-compose      Compose 页面
xhim-ui-view         View/Fragment 页面
xhim-call            RTC 与系统通话桥

UI Kit 只依赖 Kotlin Facade。自定义消息通过 Renderer registry 注入,主题、头像 加载、路由、菜单、通知摘要和国际化均使用显式接口,不允许 UI 层直接 JNI 或 读 SQLite。

xhim-ui-compose 已把同一套 Lucide SVG 转换成 VectorDrawable,通过 XHIMIcon(XHIMIcon.Send, ...) 使用;XHIMTheme 提供默认明暗色,颜色和字体 仍可由宿主 Material 3 MaterialTheme 覆盖。资源版本、SHA-256 和许可证见 五端 UI 资源说明

10. 发布验收

  • 空白 Kotlin App 只通过 Maven 包完成 debug/release 和 minify 构建;
  • arm64 真机、x86_64 Emulator,以及声明支持的其他 ABI 全部验证;
  • libxhim_jni.so、C++ runtime 和所有依赖没有重复/缺失;
  • StrictMode、ASan/HWASan(适用设备)、线程与 JNI 引用压力测试通过;
  • Activity 重建、后台限制、进程重启、网络切换、账号切换无双 Client;
  • AAR 包含 consumer rules、LICENSE、NOTICE、版本清单、可关联的 native Build ID、Hash 和 SBOM;未剥离符号只进入受控内部符号服务;
  • 删除 UI artifact 后 Headless SDK 仍可完整使用。

11. OfflineReader 平台契约

XHIMOfflineReader 直接桥接只读 C ABI,并复用正式 QueryCodec 和公开模型, 覆盖消息、搜索、会话与六类社交分页。

  • Kotlin 通过 Dispatchers.IO 执行同步 JNI/SQLite 调用,以 Mutex 串行同一 native handle;
  • JNI 在调用返回前编码并复制所有 borrowed view,Kotlin 不保存 native 指针;
  • close() 为 suspend、幂等,关闭后的操作映射为带原始 code 的 XHIMException
  • Key 通过 XHIMOfflineDatabaseKeyProvider 注入。SDK 对 Provider 返回值再做 临时副本并在 open 后清零;Provider/安全存储负责其原始数组生命周期;
  • 未知 status 和未知枚举原值可诊断,opaque cursor 以 ByteArray 原样往返。

接入示例见 Android 快速接入。禁止在 resources、BuildConfig、SharedPreferences、日志或测试 fixture 中保存正式 数据库 Key。

XHIM 客户端 SDK 与服务端文档