docs(sdk): 添加 Android SDK 文档和 API 设计规范

- 新增 Android SDK 使用文档,包含模块结构、集成方式和快速开始指南
- 添加 SDK API 重设计规范,统一初始化和登录接口设计
- 补充安全设计规范,完善 UserSig 鉴权和敏感数据处理方案
- 创建平台 REST API 规范,定义服务端到服务端的调用接口
- 添加离线推送架构设计,集成各大厂商推送服务与 IM 联动方案
这个提交包含在:
XuqmGroup 2026-04-29 15:46:39 +08:00
父节点 48ddea9f68
当前提交 a1614840e5
共有 10 个文件被更改,包括 121 次插入64 次删除

查看文件

@ -55,7 +55,7 @@ dependencies {
```kotlin
XuqmSDK.initialize(
context = this,
appId = "ak_your_app_id",
appKey = "ak_your_app_key",
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN
)
```
@ -102,7 +102,7 @@ sample-app 里也提供了一个“环境设置”页面,可以直接在外网
| 参数 | 类型 | 说明 |
|------|------|------|
| `appId` | String | 应用标识(租户平台获取) |
| `appKey` | String | 应用标识(租户平台获取) |
| `logLevel` | LogLevel | 日志等级 |
### TokenStore

查看文件

@ -1,11 +1,14 @@
import org.gradle.api.publish.PublishingExtension
apply(plugin = "maven-publish")
afterEvaluate {
(extensions.findByType(com.android.build.gradle.LibraryExtension::class.java))?.let {
if (extensions.findByName("android") != null) {
val releaseComponent = components.findByName("release") ?: return@afterEvaluate
extensions.configure<PublishingExtension> {
publications {
register<MavenPublication>("release") {
from(components["release"])
from(releaseComponent)
groupId = rootProject.group.toString()
artifactId = project.name
version = rootProject.version.toString()
@ -13,10 +16,10 @@ afterEvaluate {
}
repositories {
maven {
url = uri(rootProject.ext["nexusUrl"] as String)
url = uri(rootProject.extra["nexusUrl"] as String)
credentials {
username = rootProject.ext["nexusUser"] as String
password = rootProject.ext["nexusPassword"] as String
username = rootProject.extra["nexusUser"] as String
password = rootProject.extra["nexusPassword"] as String
}
}
}

查看文件

@ -18,7 +18,7 @@ class XuqmSampleApp : Application() {
AppDependencies.init(this)
XuqmSDK.initialize(
context = this,
appId = "ak_demo_chat",
appKey = "ak_demo_chat",
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
)
appScope.launch {

查看文件

@ -1,7 +1,7 @@
package com.xuqm.sdk
data class XuqmLoginSession(
val appId: String,
val appKey: String,
val userId: String,
val userSig: String,
val nickname: String? = null,

查看文件

@ -27,10 +27,10 @@ object XuqmSDK {
fun initialize(
context: Context,
appId: String,
appKey: String,
logLevel: LogLevel = LogLevel.WARN,
) {
config = SDKConfig(appId, logLevel)
config = SDKConfig(appKey, logLevel)
appContext = context.applicationContext
tokenStore = TokenStore(context.applicationContext)
ApiClient.init(config, tokenStore)
@ -55,7 +55,7 @@ object XuqmSDK {
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
}
val appId: String get() = config.appId
val appKey: String get() = config.appKey
val currentLoginSession: XuqmLoginSession?
get() = loginSession
@ -68,7 +68,7 @@ object XuqmSDK {
): XuqmLoginSession = withContext(Dispatchers.IO) {
requireInit()
val session = XuqmLoginSession(
appId = appId,
appKey = appKey,
userId = userId,
userSig = userSig,
nickname = nickname,

查看文件

@ -1,7 +1,7 @@
package com.xuqm.sdk.core
data class SDKConfig(
val appId: String,
val appKey: String,
val logLevel: LogLevel = LogLevel.WARN,
)

查看文件

@ -144,10 +144,10 @@ object ImSDK {
}
suspend fun editMessage(messageId: String, content: String): ImMessage =
withContext(Dispatchers.IO) { api.editMessage(messageId, XuqmSDK.appId, EditMessageRequest(content)).data ?: throw IllegalStateException("edit message failed") }
withContext(Dispatchers.IO) { api.editMessage(messageId, XuqmSDK.appKey, EditMessageRequest(content)).data ?: throw IllegalStateException("edit message failed") }
suspend fun revokeMessage(messageId: String): ImMessage =
withContext(Dispatchers.IO) { api.revokeMessage(messageId, XuqmSDK.appId).data ?: throw IllegalStateException("revoke message failed") }
withContext(Dispatchers.IO) { api.revokeMessage(messageId, XuqmSDK.appKey).data ?: throw IllegalStateException("revoke message failed") }
fun sendImageMessage(
toId: String,
@ -407,7 +407,7 @@ object ImSDK {
withContext(Dispatchers.IO) {
api.fetchHistory(
toId,
XuqmSDK.appId,
XuqmSDK.appKey,
msgType,
keyword,
startTime?.toString(),
@ -432,7 +432,7 @@ object ImSDK {
withContext(Dispatchers.IO) {
api.fetchGroupHistory(
groupId,
XuqmSDK.appId,
XuqmSDK.appKey,
msgType,
keyword,
startTime?.toString(),
@ -485,30 +485,30 @@ object ImSDK {
}
suspend fun listGroups(): List<ImGroup> =
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appId).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listGroups(XuqmSDK.appKey).data ?: emptyList() }
suspend fun createGroup(name: String, memberIds: List<String>, groupType: String = "WORK"): ImGroup? =
withContext(Dispatchers.IO) {
api.createGroup(XuqmSDK.appId, CreateGroupRequest(name, memberIds, groupType)).data
api.createGroup(XuqmSDK.appKey, CreateGroupRequest(name, memberIds, groupType)).data
}
suspend fun getGroupInfo(groupId: String): ImGroup? =
withContext(Dispatchers.IO) { api.getGroupInfo(groupId).data }
suspend fun listGroupMembers(groupId: String): List<UserProfile> =
withContext(Dispatchers.IO) { api.listGroupMembers(groupId, XuqmSDK.appId).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listGroupMembers(groupId, XuqmSDK.appKey).data ?: emptyList() }
suspend fun searchGroupMembers(groupId: String, keyword: String, size: Int = 20): List<UserProfile> =
withContext(Dispatchers.IO) { api.searchGroupMembers(groupId, XuqmSDK.appId, keyword, size).data ?: emptyList() }
withContext(Dispatchers.IO) { api.searchGroupMembers(groupId, XuqmSDK.appKey, keyword, size).data ?: emptyList() }
suspend fun listPublicGroups(keyword: String? = null): List<ImGroup> =
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appId, keyword).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listPublicGroups(XuqmSDK.appKey, keyword).data ?: emptyList() }
suspend fun searchUsers(keyword: String, size: Int = 20): List<UserProfile> =
withContext(Dispatchers.IO) { api.searchUsers(XuqmSDK.appId, keyword, size).data ?: emptyList() }
withContext(Dispatchers.IO) { api.searchUsers(XuqmSDK.appKey, keyword, size).data ?: emptyList() }
suspend fun searchGroups(keyword: String, size: Int = 20): List<ImGroup> =
withContext(Dispatchers.IO) { api.searchGroups(XuqmSDK.appId, keyword, size).data ?: emptyList() }
withContext(Dispatchers.IO) { api.searchGroups(XuqmSDK.appKey, keyword, size).data ?: emptyList() }
suspend fun searchMessages(
keyword: String? = null,
@ -521,7 +521,7 @@ object ImSDK {
): PageResult<ImMessage> =
withContext(Dispatchers.IO) {
api.searchMessages(
XuqmSDK.appId,
XuqmSDK.appKey,
keyword,
chatType,
msgType,
@ -554,49 +554,49 @@ object ImSDK {
withContext(Dispatchers.IO) { api.dismissGroup(groupId) }
suspend fun sendGroupJoinRequest(groupId: String, remark: String? = null): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.sendGroupJoinRequest(groupId, XuqmSDK.appId, remark).data }
withContext(Dispatchers.IO) { api.sendGroupJoinRequest(groupId, XuqmSDK.appKey, remark).data }
suspend fun listGroupJoinRequests(groupId: String): List<GroupJoinRequest> =
withContext(Dispatchers.IO) { api.listGroupJoinRequests(groupId, XuqmSDK.appId).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listGroupJoinRequests(groupId, XuqmSDK.appKey).data ?: emptyList() }
suspend fun acceptGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.acceptGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data }
withContext(Dispatchers.IO) { api.acceptGroupJoinRequest(groupId, requestId, XuqmSDK.appKey).data }
suspend fun rejectGroupJoinRequest(groupId: String, requestId: String): GroupJoinRequest? =
withContext(Dispatchers.IO) { api.rejectGroupJoinRequest(groupId, requestId, XuqmSDK.appId).data }
withContext(Dispatchers.IO) { api.rejectGroupJoinRequest(groupId, requestId, XuqmSDK.appKey).data }
suspend fun listFriends(): List<String> =
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appId).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listFriends(XuqmSDK.appKey).data ?: emptyList() }
suspend fun addFriend(friendId: String) =
withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appId, friendId) }
withContext(Dispatchers.IO) { api.addFriend(XuqmSDK.appKey, friendId) }
suspend fun removeFriend(friendId: String) =
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appId) }
withContext(Dispatchers.IO) { api.removeFriend(friendId, XuqmSDK.appKey) }
suspend fun listFriendRequests(direction: String = "incoming"): List<FriendRequest> =
withContext(Dispatchers.IO) { api.listFriendRequests(XuqmSDK.appId, direction).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listFriendRequests(XuqmSDK.appKey, direction).data ?: emptyList() }
suspend fun sendFriendRequest(friendId: String, remark: String? = null): FriendRequest? =
withContext(Dispatchers.IO) { api.sendFriendRequest(XuqmSDK.appId, friendId, remark).data }
withContext(Dispatchers.IO) { api.sendFriendRequest(XuqmSDK.appKey, friendId, remark).data }
suspend fun acceptFriendRequest(requestId: String): FriendRequest? =
withContext(Dispatchers.IO) { api.acceptFriendRequest(requestId, XuqmSDK.appId).data }
withContext(Dispatchers.IO) { api.acceptFriendRequest(requestId, XuqmSDK.appKey).data }
suspend fun rejectFriendRequest(requestId: String): FriendRequest? =
withContext(Dispatchers.IO) { api.rejectFriendRequest(requestId, XuqmSDK.appId).data }
withContext(Dispatchers.IO) { api.rejectFriendRequest(requestId, XuqmSDK.appKey).data }
suspend fun listBlacklist(): List<BlacklistEntry> =
withContext(Dispatchers.IO) { api.listBlacklist(XuqmSDK.appId).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listBlacklist(XuqmSDK.appKey).data ?: emptyList() }
suspend fun addToBlacklist(blockedUserId: String): BlacklistEntry? =
withContext(Dispatchers.IO) { api.addToBlacklist(XuqmSDK.appId, blockedUserId).data }
withContext(Dispatchers.IO) { api.addToBlacklist(XuqmSDK.appKey, blockedUserId).data }
suspend fun removeFromBlacklist(blockedUserId: String) =
withContext(Dispatchers.IO) { api.removeFromBlacklist(XuqmSDK.appId, blockedUserId) }
withContext(Dispatchers.IO) { api.removeFromBlacklist(XuqmSDK.appKey, blockedUserId) }
suspend fun getProfile(userId: String) =
withContext(Dispatchers.IO) { api.getProfile(userId, XuqmSDK.appId).data }
withContext(Dispatchers.IO) { api.getProfile(userId, XuqmSDK.appKey).data }
suspend fun updateProfile(
userId: String,
@ -604,11 +604,11 @@ object ImSDK {
avatar: String? = null,
gender: String? = null,
) = withContext(Dispatchers.IO) {
api.updateProfile(userId, XuqmSDK.appId, nickname, avatar, gender).data
api.updateProfile(userId, XuqmSDK.appKey, nickname, avatar, gender).data
}
suspend fun listConversations(): List<ConversationData> =
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appKey).data ?: emptyList() }
suspend fun getTotalUnreadCount(): Int =
withContext(Dispatchers.IO) {
@ -626,13 +626,13 @@ object ImSDK {
}
suspend fun markRead(targetId: String, chatType: String = "SINGLE") =
withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) }
withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appKey, chatType) }
suspend fun setDraft(targetId: String, chatType: String, draft: String) =
withContext(Dispatchers.IO) { api.setDraft(targetId, XuqmSDK.appId, chatType, draft) }
withContext(Dispatchers.IO) { api.setDraft(targetId, XuqmSDK.appKey, chatType, draft) }
suspend fun deleteConversation(targetId: String, chatType: String) =
withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) }
withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appKey, chatType) }
fun addListener(listener: ImEventListener) {
Log.d(TAG, "addListener listener=${listener.javaClass.name}")
@ -669,7 +669,7 @@ object ImSDK {
_connectionState.value = ImConnectionState.Connecting
currentToken = token
Log.d(TAG, "connectWithToken userId=$currentUserId activeGroups=${activeGroupSubscriptions.size}")
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appKey)
client?.addListener(connectionListener)
listeners.forEach { client?.addListener(it) }
reconnectEnabled = true
@ -742,7 +742,7 @@ object ImSDK {
): ImMessage {
return ImMessage(
id = messageId,
appId = XuqmSDK.appId,
appId = XuqmSDK.appKey,
fromId = currentUserId,
toId = toId,
chatType = chatType,
@ -769,7 +769,7 @@ object ImSDK {
reconnectAttempts += 1
_connectionState.value = ImConnectionState.Connecting
Log.d(TAG, "reconnect attempt=$reconnectAttempts")
client = ImClient(ServiceEndpointRegistry.imWsUrl, currentToken, XuqmSDK.appId)
client = ImClient(ServiceEndpointRegistry.imWsUrl, currentToken, XuqmSDK.appKey)
client?.addListener(connectionListener)
listeners.forEach { client?.addListener(it) }
client?.connect()

查看文件

@ -67,7 +67,7 @@ object PushSDK {
scope.launch {
runCatching {
api.setReceivePush(
appId = XuqmSDK.appId,
appId = XuqmSDK.appKey,
userId = resolvedUserId,
enabled = enabled,
)
@ -95,7 +95,7 @@ object PushSDK {
scope.launch {
runCatching {
api.registerDevice(
appId = XuqmSDK.appId,
appId = XuqmSDK.appKey,
userId = userId,
vendor = vendor.name,
token = pushToken,
@ -114,7 +114,7 @@ object PushSDK {
XuqmSDK.requireInit()
scope.launch {
runCatching {
api.unregisterDevice(XuqmSDK.appId, userId)
api.unregisterDevice(XuqmSDK.appKey, userId)
registeredUserId.compareAndSet(userId, null)
store(XuqmSDK.appContext).updateLastUserId(null)
}

查看文件

@ -10,8 +10,13 @@
* Config: xuqm.properties in the project root (or module root)
* ---
* xuqm.serverUrl=https://update.dev.xuqinmin.com
* xuqm.appId=your-app-id
* xuqm.appKey=your-app-key
* xuqm.apiToken=your-api-token
* xuqm.storeTargets=HUAWEI,MI,OPPO # optional
* xuqm.autoPublishAfterReview=false
* xuqm.publishImmediately=false # optional: publish update-service record immediately
* xuqm.scheduledPublishAt= # optional ISO datetime
* xuqm.webhookUrl= # optional webhook for review status changes
* ---
*
* The task:
@ -31,7 +36,6 @@ import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.util.Properties
import java.util.UUID
@ -101,6 +105,35 @@ fun parseJson(json: String, key: String): String? =
fun parseJsonInt(json: String, key: String): Int? =
Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
fun promptLine(message: String): String? {
val console = System.console() ?: return null
print(message)
return console.readLine()
}
fun promptYesNo(message: String, default: Boolean = false): Boolean {
val suffix = if (default) " [Y/n]: " else " [y/N]: "
val answer = promptLine(message + suffix)?.trim().orEmpty()
return when {
answer.isBlank() -> default
answer.equals("y", ignoreCase = true) || answer.equals("yes", ignoreCase = true) -> true
answer.equals("n", ignoreCase = true) || answer.equals("no", ignoreCase = true) -> false
else -> default
}
}
fun promptReleaseMode(): String {
val answer = promptLine(
"Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review]: "
)?.trim().orEmpty()
return when (answer) {
"2" -> "NOW"
"3" -> "SCHEDULED"
"4" -> "AUTO_REVIEW"
else -> "MANUAL"
}
}
// ── Task registration ─────────────────────────────────────────────────────
tasks.register("xuqmRelease") {
@ -113,12 +146,20 @@ tasks.register("xuqmRelease") {
doLast {
val cfg = loadXuqmConfig(projectDir)
val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
val appId = cfg.getProperty("xuqm.appId") ?: throw GradleException("xuqm.appId missing")
val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
val storeTargets = cfg.getProperty("xuqm.storeTargets", "") // e.g. "HUAWEI,MI,OPPO"
val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean()
val scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
val publishImmediately = cfg.getProperty("xuqm.publishImmediately", "false").toBoolean()
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "")
var publishMode = cfg.getProperty("xuqm.publishMode", "").trim().uppercase()
if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) {
publishMode = promptReleaseMode()
if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) {
scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty()
}
}
// ── 1. Read local version ──────────────────────────────────────────
val android = project.extensions.findByName("android")
@ -129,10 +170,10 @@ tasks.register("xuqmRelease") {
val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int)
?: throw GradleException("versionCode not set")
val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: ""
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId")
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId, appKey: $appKey")
// ── 2. Check server latest ─────────────────────────────────────────
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appId&platform=ANDROID", apiToken)
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=ANDROID", apiToken)
// Find highest published versionCode
val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp)
.mapNotNull { it.groupValues[1].toIntOrNull() }
@ -154,7 +195,7 @@ tasks.register("xuqmRelease") {
// ── 4. Upload to update service ───────────────────────────────────
val parts = mutableMapOf<String, Any>(
"appId" to appId,
"appId" to appKey,
"platform" to "ANDROID",
"versionName" to versionName,
"versionCode" to versionCode,
@ -166,6 +207,7 @@ tasks.register("xuqmRelease") {
if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]"
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
if (publishMode == "NOW") parts["publishImmediately"] = "true"
println("[xuqm] Uploading APK...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
@ -187,12 +229,24 @@ tasks.register("xuqmRelease") {
println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
}
println("[xuqm] Done. Version is in DRAFT state.")
if (scheduledAt.isNotBlank()) {
println("[xuqm] Will auto-publish at: $scheduledAt")
if (publishImmediately || publishMode == "NOW") {
println("[xuqm] Published immediately in update service.")
} else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) {
println("[xuqm] Will auto-publish at: ${scheduledAt.ifBlank { "(use update service scheduled publish config)" }}")
} else if (autoPublish) {
println("[xuqm] Will auto-publish after all store reviews pass.")
} else {
} else if (System.console() != null && promptYesNo("[xuqm] Publish now?", false)) {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish"))
.header("Authorization", "Bearer $apiToken")
.POST(HttpRequest.BodyPublishers.noBody())
.build()
val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Publish HTTP ${publishResp.statusCode()}")
}
println("[xuqm] Done. Version is in DRAFT state.")
if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) {
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
}
}

查看文件

@ -36,7 +36,7 @@ object UpdateSDK {
val versionCode = context.packageManager
.getPackageInfo(context.packageName, 0).longVersionCode.toInt()
runCatching {
api.checkUpdate(XuqmSDK.appId, "ANDROID", versionCode).data?.let {
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode).data?.let {
it.copy(downloadUrl = normalizeDownloadUrl(it.downloadUrl) ?: it.downloadUrl)
}
}.getOrNull()