diff --git a/TEST_REPORT.md b/TEST_REPORT.md
new file mode 100644
index 0000000..cf55eb9
--- /dev/null
+++ b/TEST_REPORT.md
@@ -0,0 +1,147 @@
+# Android SDK 测试报告
+
+> **生成时间**: 2026-05-01
+> **版本**: 0.4.x(UserSig 鉴权)
+> **测试状态**: 部分功能待测试
+
+---
+
+## 测试环境
+
+| 项目 | 版本/配置 |
+|------|-----------|
+| Android Studio | Android Studio Ladybug \| 2024.2.1 |
+| Android Gradle Plugin | 8.7.0 |
+| Gradle | 8.9 |
+| JDK | OpenJDK 21 |
+| 模拟器 1 | emulator-5556(Pixel 8 API 35) |
+| 模拟器 2 | emulator-5558(Pixel 8 API 35) |
+| compileSdk | 35 |
+| minSdk | 24(Android 7.0) |
+| Kotlin | 2.0.21 |
+
+---
+
+## 测试用例清单
+
+### TC-01 SDK 初始化测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 SDK 初始化流程及模块依赖注入 |
+| **测试步骤** | 1. 在 `Application.onCreate()` 中调用 `XuqmSDK.initialize(context, appKey, logLevel)`
2. 确认 `XuqmSDK.config`、`tokenStore` 已赋值
3. 确认 `ApiClient` 已初始化 |
+| **预期结果** | 1. 初始化成功,无异常抛出
2. `XuqmSDK.requireInit()` 不抛异常
3. `ServiceEndpointRegistry` 默认使用内置生产环境地址 |
+| **实际结果** | 通过 |
+| **通过状态** | ✅ |
+
+---
+
+### TC-02 IM 登录/登出测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 UserSig 鉴权模式下的登录与登出 |
+| **测试步骤** | 1. 调用 `XuqmSDK.login(userId, userSig, nickname, avatar, userSigExpiresAt)`
2. 观察 `ImSDK.onSdkLogin` 是否自动触发 WebSocket 连接
3. 监听 `ImEventListener.onConnected()`
4. 调用 `XuqmSDK.logout()`
5. 确认 `ImSDK.onSdkLogout` 断开 WebSocket 并清空 Token |
+| **预期结果** | 1. 登录返回 `XuqmLoginSession`
2. WebSocket 建立 101 连接并 STOMP CONNECTED
3. `onConnected()` 回调触发
4. 登出后 `connectionState` 变为 `Disconnected`
5. `TokenStore` 被清空 |
+| **实际结果** | 通过 |
+| **通过状态** | ✅ |
+
+---
+
+### TC-03 单聊消息收发测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证单聊文本消息的发送、接收、历史与已读 |
+| **测试步骤** | 1. user_a(emulator-5556)发送文本消息给 user_b
2. user_b(emulator-5558)通过 `ImEventListener.onMessage()` 接收实时推送
3. user_b 调用 `fetchHistory("user_a")` 查询历史
4. user_b 进入会话调用 `markRead("user_a")`
5. user_a 查询历史,确认消息状态变为 `READ` |
+| **预期结果** | 1. `sendTextMessage` 返回 `ImMessage`(status=SENDING 或 SENT)
2. user_b 实时收到消息,会话列表未读角标 +1
3. 历史消息正确分页返回
4. `markRead` 返回 200,未读归零
5. user_a 历史消息中对应消息 status=READ |
+| **实际结果** | 通过 |
+| **通过状态** | ✅ |
+
+---
+
+### TC-04 群聊消息收发测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证群创建、群消息收发、群历史加载 |
+| **测试步骤** | 1. user_a 调用 `createGroup("TestGroup", listOf("user_b"))`
2. user_a 调用 `subscribeGroup(groupId)` 并发送群消息
3. user_b 调用 `subscribeGroup(groupId)` 并接收 `onGroupMessage()`
4. 双端调用 `fetchGroupHistory(groupId)`
5. 双端调用 `listConversations()` 确认群会话出现 |
+| **预期结果** | 1. 群创建成功,返回 `ImGroup`
2. user_a 发送群消息成功
3. user_b 实时收到群消息
4. 群历史消息分页正确
5. 群会话出现在会话列表中 |
+| **实际结果** | 通过(群会话聚合 Bug 已修复并复验) |
+| **通过状态** | ✅ |
+
+---
+
+### TC-05 会话列表/置顶/静音测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证会话列表查询、置顶、静音、草稿、删除 |
+| **测试步骤** | 1. 发送消息后调用 `listConversations()`
2. 对目标会话调用 `setConversationPinned(targetId, "SINGLE", true)`
3. 调用 `setConversationMuted(targetId, "SINGLE", true)`
4. 调用 `setDraft(targetId, "SINGLE", "草稿内容")`
5. 调用 `deleteConversation(targetId, "SINGLE")` 后再次查询列表 |
+| **预期结果** | 1. 返回包含目标会话的列表,`unreadCount` 正确
2. `isPinned=true`
3. `isMuted=true`
4. 草稿保存成功
5. 目标会话从列表中移除 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-06 Push 设备注册测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 Push SDK 设备 Token 注册与绑定 IM 用户 |
+| **测试步骤** | 1. 登录后 `PushSDK.onSdkLogin` 自动触发
2. 观察 `PushSDK.initializeVendors()` 检测厂商
3. 确认 `registerDevice()` 调用 Push API
4. 调用 `PushSDK.setReceivePush(context, enabled=false)`
5. 登出后确认 `unregisterDevice()` 调用 |
+| **预期结果** | 1. 登录后自动初始化 Push
2. 正确检测厂商(如 XIAOMI / HUAWEI / FCM)
3. `/api/push/register` 返回 200
4. `/api/push/receive` 设置为 false
5. `/api/push/unregister` 返回 200 |
+| **实际结果** | 待测试(模拟器无 Firebase,FCM 会等待回调) |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-07 版本更新检查测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 UpdateSDK 检查更新与下载安装流程 |
+| **测试步骤** | 1. 调用 `UpdateSDK.checkAppUpdate(context)`
2. 若 `needsUpdate=true`,获取 `downloadUrl`
3. 调用 `UpdateSDK.downloadAndInstall(context, downloadUrl)`
4. 观察 APK 下载进度与安装意图跳转 |
+| **预期结果** | 1. 返回 `UpdateInfo`,字段完整
2. `downloadUrl` 不为空
3. APK 下载成功并触发系统安装弹窗
4. `FileProvider` URI 权限正确,无 `FileUriExposedException` |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-08 UserSig 续签测试(新增)
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 UserSig 即将过期时的静默续签回调机制 |
+| **测试步骤** | 1. 登录时传入 `userSigExpiresAt`(如当前时间 + 6 分钟)
2. 设置 `XuqmSDK.setUserSigRefreshListener { ... }`
3. 等待 1 分钟后观察 `UserSigRefresher` 检查逻辑
4. 在回调中获取新 UserSig 并重新调用 `XuqmSDK.login()`
5. 验证旧定时器被停止,新定时器启动 |
+| **预期结果** | 1. `UserSigRefresher.start(expiryTimeMs)` 启动
2. 到期前 5 分钟触发 `onUserSigRefreshRequired()`
3. 回调在主线程执行
4. 重新登录后 WebSocket 使用新 Token 连接
5. 无内存泄漏,定时器正确替换 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-09 多厂商 Push 检测测试(新增)
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 `PushSDK.detectVendor()` 在多台设备上的厂商识别准确性 |
+| **测试步骤** | 1. 在华为/小米/OPPO/vivo/荣耀/其他模拟器或真机上运行
2. 调用 `PushSDK.detectVendor()`
3. 检查 `Build.MANUFACTURER` 与返回的 `PushVendor` 映射
4. 未知厂商回退到 `FCM`
5. 验证 `initializeVendors()` 仅初始化匹配厂商服务 |
+| **预期结果** | 1. 华为 → `HUAWEI`
2. 小米 → `XIAOMI`
3. OPPO → `OPPO`
4. 未知品牌 → `FCM`
5. 非匹配厂商服务不被注册,无 ClassNotFoundException |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+## 测试汇总
+
+| 用例编号 | 用例名称 | 状态 |
+|---------|---------|------|
+| TC-01 | SDK 初始化测试 | ✅ 通过 |
+| TC-02 | IM 登录/登出测试 | ✅ 通过 |
+| TC-03 | 单聊消息收发测试 | ✅ 通过 |
+| TC-04 | 群聊消息收发测试 | ✅ 通过 |
+| TC-05 | 会话列表/置顶/静音测试 | ⬜ 待测试 |
+| TC-06 | Push 设备注册测试 | ⬜ 待测试 |
+| TC-07 | 版本更新检查测试 | ⬜ 待测试 |
+| TC-08 | UserSig 续签测试 | ⬜ 待测试 |
+| TC-09 | 多厂商 Push 检测测试 | ⬜ 待测试 |
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt
index 44980aa..772b122 100644
--- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt
@@ -184,7 +184,7 @@ fun ChatScreen(
}
}
- LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
+ LaunchedEffect(targetId, chatType) { viewModel.init(targetId, chatType) }
LaunchedEffect(Unit) {
viewModel.events.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt
index 7f8720b..92cc841 100644
--- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt
+++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt
@@ -101,11 +101,13 @@ class ChatViewModel : ViewModel() {
if (initialized && this.chatType == "GROUP") {
ImSDK.unsubscribeGroup(this.targetId)
}
+ pendingDeliveryTimeouts.values.forEach { it.cancel() }
+ pendingDeliveryTimeouts.clear()
this.targetId = targetId
this.chatType = chatType
nextHistoryPage = 0
initialized = true
- _messages.value = cache.getHistoryPage(targetId, chatType, 0, HISTORY_PAGE_SIZE)
+ _messages.value = emptyList()
_hasMoreHistory.value = true
_isLoadingMore.value = false
_searchQuery.value = ""
@@ -400,7 +402,7 @@ class ChatViewModel : ViewModel() {
}
private fun mergeHistory(messages: List): List =
- messages.distinctBy { it.id }.sortedByDescending { it.createdAt }
+ sortMessages(messages)
private fun requestScrollToBottom() {
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
@@ -646,7 +648,7 @@ class ChatViewModel : ViewModel() {
}
merged.add(message)
}
- return merged.distinctBy { it.id }.sortedByDescending { it.createdAt }
+ return sortMessages(merged)
}
private fun mergeMessageRecord(existing: ImMessage, incoming: ImMessage): ImMessage {
@@ -661,14 +663,15 @@ class ChatViewModel : ViewModel() {
mentionedUserIds = incoming.mentionedUserIds ?: existing.mentionedUserIds,
groupReadCount = incoming.groupReadCount ?: existing.groupReadCount,
revoked = incoming.revoked ?: existing.revoked,
- createdAt = existing.createdAt,
+ createdAt = incoming.createdAt.takeIf { it > 0 } ?: existing.createdAt,
+ editedAt = incoming.editedAt ?: existing.editedAt,
)
}
private fun updateMessageStatus(messageId: String, status: String) {
- val updated = _messages.value.map { message ->
+ val updated = sortMessages(_messages.value.map { message ->
if (message.id == messageId) message.copy(status = status) else message
- }
+ })
_messages.value = updated
cache.mergeHistory(targetId, chatType, updated)
}
@@ -703,4 +706,13 @@ class ChatViewModel : ViewModel() {
const val SEARCH_PAGE_SIZE = 50
const val LOCATE_MAX_PAGES = 30
}
+
+ private fun sortMessages(messages: List): List {
+ return messages
+ .distinctBy { it.id }
+ .sortedWith(
+ compareByDescending { it.createdAt }
+ .thenByDescending { it.id }
+ )
+ }
}
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmLoginSession.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmLoginSession.kt
index 58dba2d..d52a064 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmLoginSession.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmLoginSession.kt
@@ -6,4 +6,5 @@ data class XuqmLoginSession(
val userSig: String,
val nickname: String? = null,
val avatar: String? = null,
+ val expiresAt: Long? = null,
)
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
index ce32a16..2fd4050 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt
@@ -2,6 +2,7 @@ package com.xuqm.sdk
import android.content.Context
import com.xuqm.sdk.auth.TokenStore
+import com.xuqm.sdk.auth.UserSigRefresher
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.ServiceEndpoints
@@ -25,6 +26,8 @@ object XuqmSDK {
@Volatile
private var loginSession: XuqmLoginSession? = null
+ private val userSigRefresher = UserSigRefresher()
+
fun initialize(
context: Context,
appKey: String,
@@ -60,11 +63,16 @@ object XuqmSDK {
val currentLoginSession: XuqmLoginSession?
get() = loginSession
+ fun setUserSigRefreshListener(listener: UserSigRefresher.UserSigRefreshListener?) {
+ userSigRefresher.setRefreshListener(listener)
+ }
+
suspend fun login(
userId: String,
userSig: String,
nickname: String? = null,
avatar: String? = null,
+ userSigExpiresAt: Long? = null,
): XuqmLoginSession = withContext(Dispatchers.IO) {
requireInit()
val session = XuqmLoginSession(
@@ -73,9 +81,13 @@ object XuqmSDK {
userSig = userSig,
nickname = nickname,
avatar = avatar,
+ expiresAt = userSigExpiresAt,
)
loginSession = session
tokenStore.saveToken(userSig)
+ userSigExpiresAt?.let {
+ userSigRefresher.start(it)
+ }
notifyOptionalModules("onSdkLogin", session)
session
}
@@ -84,6 +96,7 @@ object XuqmSDK {
val session = loginSession
loginSession = null
tokenStore.clear()
+ userSigRefresher.stop()
if (session != null) {
notifyOptionalModulesSync("onSdkLogout")
}
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/auth/UserSigRefresher.kt b/sdk-core/src/main/java/com/xuqm/sdk/auth/UserSigRefresher.kt
new file mode 100644
index 0000000..6b26171
--- /dev/null
+++ b/sdk-core/src/main/java/com/xuqm/sdk/auth/UserSigRefresher.kt
@@ -0,0 +1,93 @@
+package com.xuqm.sdk.auth
+
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * UserSig 静默续签定时器
+ *
+ * 在 [login] 时启动,在 [logout] 时停止。
+ * 当检测到 UserSig 距离过期不足 5 分钟时,触发 [UserSigRefreshListener.onUserSigRefreshRequired] 回调,
+ * 业务层应在回调中异步获取新的 userSig,然后调用 [com.xuqm.sdk.XuqmSDK.login] 重新登录以刷新定时器。
+ */
+class UserSigRefresher {
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val isRunning = AtomicBoolean(false)
+ private var refreshListener: UserSigRefreshListener? = null
+ private var currentExpiryTimeMs: Long = 0L
+
+ /**
+ * UserSig 续签监听器
+ */
+ fun interface UserSigRefreshListener {
+ /**
+ * 当 UserSig 即将过期时触发(到期前 5 分钟)。
+ * 业务层应在此回调中异步获取新的 userSig,然后调用 XuqmSDK.login() 重新登录。
+ */
+ fun onUserSigRefreshRequired()
+ }
+
+ /**
+ * 设置续签监听器
+ */
+ fun setRefreshListener(listener: UserSigRefreshListener?) {
+ this.refreshListener = listener
+ }
+
+ /**
+ * 启动续签检测定时器
+ *
+ * @param expiryTimeMs UserSig 过期时间戳(毫秒)
+ */
+ fun start(expiryTimeMs: Long) {
+ stop()
+ currentExpiryTimeMs = expiryTimeMs
+ isRunning.set(true)
+ scheduleCheck()
+ }
+
+ /**
+ * 停止续签检测定时器
+ */
+ fun stop() {
+ isRunning.set(false)
+ mainHandler.removeCallbacksAndMessages(null)
+ }
+
+ private fun scheduleCheck() {
+ if (!isRunning.get()) return
+
+ val now = System.currentTimeMillis()
+ val timeUntilExpiry = currentExpiryTimeMs - now
+ val timeUntilRefresh = timeUntilExpiry - REFRESH_THRESHOLD_MS
+
+ if (timeUntilRefresh <= 0) {
+ notifyRefreshRequired()
+ if (isRunning.get()) {
+ mainHandler.postDelayed({ scheduleCheck() }, CHECK_INTERVAL_MS)
+ }
+ } else {
+ mainHandler.postDelayed(
+ { if (isRunning.get()) scheduleCheck() },
+ timeUntilRefresh.coerceAtMost(CHECK_INTERVAL_MS),
+ )
+ }
+ }
+
+ private fun notifyRefreshRequired() {
+ runCatching {
+ refreshListener?.onUserSigRefreshRequired()
+ }.onFailure { error ->
+ Log.w(TAG, "UserSig refresh callback failed: ${error.message}")
+ }
+ }
+
+ companion object {
+ private const val TAG = "UserSigRefresher"
+ private const val REFRESH_THRESHOLD_MS = 5 * 60 * 1000L // 到期前 5 分钟
+ private const val CHECK_INTERVAL_MS = 60 * 1000L // 每分钟检查一次
+ }
+}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt
index 3274373..cee600a 100644
--- a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt
@@ -1,6 +1,7 @@
package com.xuqm.sdk.push
import android.content.Context
+import android.os.Build
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.xuqm.sdk.XuqmLoginSession
@@ -11,6 +12,12 @@ import com.xuqm.sdk.push.api.PushApi
import com.xuqm.sdk.push.model.PushRegistrationSnapshot
import com.xuqm.sdk.push.model.PushVendor
import com.xuqm.sdk.push.storage.PushRegistrationStore
+import com.xuqm.sdk.push.vendor.HonorPushService
+import com.xuqm.sdk.push.vendor.HuaweiPushService
+import com.xuqm.sdk.push.vendor.OppoPushService
+import com.xuqm.sdk.push.vendor.PushVendorInterface
+import com.xuqm.sdk.push.vendor.VivoPushService
+import com.xuqm.sdk.push.vendor.XiaomiPushService
import com.xuqm.sdk.utils.DeviceUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -121,9 +128,43 @@ object PushSDK {
}
}
+ private val vendorServices: List = listOf(
+ HuaweiPushService(),
+ XiaomiPushService(),
+ OppoPushService(),
+ VivoPushService(),
+ HonorPushService(),
+ )
+
+ fun detectVendor(): PushVendor {
+ return when (Build.MANUFACTURER.uppercase()) {
+ "HUAWEI" -> PushVendor.HUAWEI
+ "XIAOMI" -> PushVendor.XIAOMI
+ "OPPO" -> PushVendor.OPPO
+ "VIVO" -> PushVendor.VIVO
+ "HONOR" -> PushVendor.HONOR
+ else -> PushVendor.FCM
+ }
+ }
+
+ fun initializeVendors(context: Context) {
+ val detectedVendor = detectVendor()
+ Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
+ vendorServices.forEach { service ->
+ if (service.vendor == detectedVendor && service.isAvailable(context)) {
+ Log.i("XuqmPushSDK", "Initializing push vendor: ${service.vendor.name}")
+ service.register(context)
+ }
+ }
+ if (detectedVendor == PushVendor.FCM) {
+ ensureNativePushToken(context)
+ }
+ }
+
fun onSdkLogin(session: XuqmLoginSession) {
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
if (registeredUserId.get() == session.userId) return
+ initializeVendors(context)
bindImUser(context, session.userId)
}
@@ -132,8 +173,6 @@ object PushSDK {
unregisterDevice(userId)
}
- private fun detectVendor(): PushVendor = PushVendor.FCM
-
private fun store(context: Context): PushRegistrationStore =
PushRegistrationStore(context.applicationContext)
@@ -145,27 +184,58 @@ object PushSDK {
private fun ensureNativePushToken(context: Context) {
val vendor = detectVendor()
- if (vendor != PushVendor.FCM) return
val registration = currentRegistration(context)
if (registration?.pushToken?.isNotBlank() == true) return
- runCatching { FirebaseMessaging.getInstance() }
- .onSuccess { messaging ->
- messaging.token
- .addOnSuccessListener { token ->
- if (token.isNotBlank()) {
- store(context).save(PushVendor.FCM, token)
- val sessionUserId = XuqmSDK.currentLoginSession?.userId
- if (sessionUserId != null) {
- registerDevice(context, sessionUserId)
+
+ if (vendor == PushVendor.FCM) {
+ runCatching { FirebaseMessaging.getInstance() }
+ .onSuccess { messaging ->
+ messaging.token
+ .addOnSuccessListener { token ->
+ if (token.isNotBlank()) {
+ store(context).save(PushVendor.FCM, token)
+ val sessionUserId = XuqmSDK.currentLoginSession?.userId
+ if (sessionUserId != null) {
+ registerDevice(context, sessionUserId)
+ }
}
}
+ .addOnFailureListener { error ->
+ Log.w("XuqmPushSDK", "Unable to fetch FCM token: ${error.message}")
+ }
+ }
+ .onFailure { error ->
+ Log.w("XuqmPushSDK", "Firebase Messaging not available: ${error.message}")
+ }
+ } else {
+ val service = vendorServices.firstOrNull { it.vendor == vendor }
+ if (service != null && service.isAvailable(context)) {
+ service.register(context)
+ } else {
+ Log.w(
+ "XuqmPushSDK",
+ "Vendor ${vendor.name} service not available, falling back to FCM",
+ )
+ runCatching { FirebaseMessaging.getInstance() }
+ .onSuccess { messaging ->
+ messaging.token
+ .addOnSuccessListener { token ->
+ if (token.isNotBlank()) {
+ store(context).save(PushVendor.FCM, token)
+ val sessionUserId = XuqmSDK.currentLoginSession?.userId
+ if (sessionUserId != null) {
+ registerDevice(context, sessionUserId)
+ }
+ }
+ }
+ .addOnFailureListener { error ->
+ Log.w("XuqmPushSDK", "Unable to fetch FCM token: ${error.message}")
+ }
}
- .addOnFailureListener { error ->
- Log.w("XuqmPushSDK", "Unable to fetch FCM token: ${error.message}")
+ .onFailure { error ->
+ Log.w("XuqmPushSDK", "Firebase Messaging not available: ${error.message}")
}
}
- .onFailure { error ->
- Log.w("XuqmPushSDK", "Firebase Messaging not available: ${error.message}")
- }
+ }
}
}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt
new file mode 100644
index 0000000..6e0ee84
--- /dev/null
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HonorPushService.kt
@@ -0,0 +1,61 @@
+package com.xuqm.sdk.push.vendor
+
+import android.content.Context
+import android.util.Log
+import com.xuqm.sdk.push.PushSDK
+import com.xuqm.sdk.push.model.PushVendor
+
+/**
+ * 荣耀推送集成框架
+ *
+ * 需要添加 Honor Push SDK 依赖:
+ * ```groovy
+ * implementation 'com.hihonor.mcs:push:7.x.x.xxx'
+ * ```
+ *
+ * 接入方需在 `Application.onCreate()` 中调用 `HonorPushClient.getInstance().init()`,
+ * 并在自定义 `HonorMessageService` 的 `onNewToken(String token)` 回调中获取 token,
+ * 然后调用:
+ * ```kotlin
+ * PushSDK.updateNativePushToken(context, PushVendor.HONOR, token)
+ * ```
+ */
+class HonorPushService : PushVendorInterface {
+
+ override val vendor: PushVendor = PushVendor.HONOR
+
+ override fun isAvailable(context: Context): Boolean {
+ return runCatching {
+ Class.forName("com.hihonor.push.sdk.HonorPushClient")
+ true
+ }.getOrDefault(false)
+ }
+
+ override fun register(context: Context) {
+ runCatching {
+ val honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient")
+ val instance = honorPushClass.getMethod("getInstance").invoke(null)
+ // HonorPushClient.getInstance().init(context, true)
+ honorPushClass.getMethod("init", Context::class.java, Boolean::class.javaPrimitiveType)
+ .invoke(instance, context, true)
+ Log.i(TAG, "Honor push registration requested")
+ }.onFailure { error ->
+ Log.w(TAG, "Honor push registration failed: ${error.message}")
+ }
+ }
+
+ override fun unregister(context: Context) {
+ runCatching {
+ val honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient")
+ val instance = honorPushClass.getMethod("getInstance").invoke(null)
+ honorPushClass.getMethod("turnOffPush").invoke(instance)
+ Log.i(TAG, "Honor push unregistered")
+ }.onFailure { error ->
+ Log.w(TAG, "Honor push unregistration failed: ${error.message}")
+ }
+ }
+
+ companion object {
+ private const val TAG = "HonorPushService"
+ }
+}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt
new file mode 100644
index 0000000..f42bee4
--- /dev/null
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/HuaweiPushService.kt
@@ -0,0 +1,95 @@
+package com.xuqm.sdk.push.vendor
+
+import android.content.Context
+import android.util.Log
+import com.xuqm.sdk.push.PushSDK
+import com.xuqm.sdk.push.model.PushVendor
+
+/**
+ * 华为推送集成框架
+ *
+ * 需要添加 HMS Push SDK 依赖:
+ * ```groovy
+ * implementation 'com.huawei.hms:push:6.x.x.xxx'
+ * ```
+ *
+ * 接入方需在 `AndroidManifest.xml` 中注册 `HmsMessageService` 子类,
+ * 并在 `onNewToken(String token)` 回调中调用:
+ * ```kotlin
+ * PushSDK.updateNativePushToken(context, PushVendor.HUAWEI, token)
+ * ```
+ */
+class HuaweiPushService : PushVendorInterface {
+
+ override val vendor: PushVendor = PushVendor.HUAWEI
+
+ override fun isAvailable(context: Context): Boolean {
+ return runCatching {
+ Class.forName("com.huawei.hms.aaid.HmsInstanceId")
+ val availabilityClass = Class.forName("com.huawei.hms.api.HuaweiApiAvailability")
+ val instance = availabilityClass.getMethod("getInstance").invoke(null)
+ val result = availabilityClass.getMethod(
+ "isHuaweiMobileServicesAvailable",
+ Context::class.java,
+ ).invoke(instance, context) as Int
+ result == 0 // com.huawei.hms.api.ConnectionResult.SUCCESS
+ }.getOrDefault(false)
+ }
+
+ override fun register(context: Context) {
+ runCatching {
+ val hmsClass = Class.forName("com.huawei.hms.aaid.HmsInstanceId")
+ val instance = hmsClass.getMethod("getInstance", Context::class.java)
+ .invoke(null, context)
+ val appId = getAppId(context)
+ if (appId.isNotBlank()) {
+ val token = hmsClass.getMethod(
+ "getToken",
+ String::class.java,
+ String::class.java,
+ ).invoke(instance, appId, "HCM") as? String
+ if (!token.isNullOrBlank()) {
+ PushSDK.updateNativePushToken(context, vendor, token)
+ Log.i(TAG, "Huawei push token acquired")
+ }
+ } else {
+ Log.w(TAG, "Huawei appId not found, skipping registration")
+ }
+ }.onFailure { error ->
+ Log.w(TAG, "Huawei push registration failed: ${error.message}")
+ }
+ }
+
+ override fun unregister(context: Context) {
+ runCatching {
+ val hmsClass = Class.forName("com.huawei.hms.aaid.HmsInstanceId")
+ val instance = hmsClass.getMethod("getInstance", Context::class.java)
+ .invoke(null, context)
+ val appId = getAppId(context)
+ if (appId.isNotBlank()) {
+ hmsClass.getMethod(
+ "deleteToken",
+ String::class.java,
+ String::class.java,
+ ).invoke(instance, appId, "HCM")
+ Log.i(TAG, "Huawei push unregistered")
+ }
+ }.onFailure { error ->
+ Log.w(TAG, "Huawei push unregistration failed: ${error.message}")
+ }
+ }
+
+ private fun getAppId(context: Context): String {
+ return runCatching {
+ val configClass = Class.forName("com.huawei.agconnect.config.AGConnectServicesConfig")
+ val config = configClass.getMethod("fromContext", Context::class.java)
+ .invoke(null, context)
+ configClass.getMethod("getString", String::class.java)
+ .invoke(config, "client/app_id") as String
+ }.getOrDefault("")
+ }
+
+ companion object {
+ private const val TAG = "HuaweiPushService"
+ }
+}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt
new file mode 100644
index 0000000..67cdaf4
--- /dev/null
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/OppoPushService.kt
@@ -0,0 +1,61 @@
+package com.xuqm.sdk.push.vendor
+
+import android.content.Context
+import android.util.Log
+import com.xuqm.sdk.push.PushSDK
+import com.xuqm.sdk.push.model.PushVendor
+
+/**
+ * OPPO 推送集成框架
+ *
+ * 需要添加 OPPO Push SDK 依赖:
+ * ```groovy
+ * implementation 'com.heytap.mcs:push:3.x.x'
+ * ```
+ *
+ * 接入方需在 `Application.onCreate()` 中调用 `PushManager.getInstance().register()`,
+ * 并在自定义 `PushCallback` 的 `onRegister()` 回调中获取 token,
+ * 然后调用:
+ * ```kotlin
+ * PushSDK.updateNativePushToken(context, PushVendor.OPPO, token)
+ * ```
+ */
+class OppoPushService : PushVendorInterface {
+
+ override val vendor: PushVendor = PushVendor.OPPO
+
+ override fun isAvailable(context: Context): Boolean {
+ return runCatching {
+ Class.forName("com.heytap.mcssdk.PushManager")
+ true
+ }.getOrDefault(false)
+ }
+
+ override fun register(context: Context) {
+ runCatching {
+ val pushManagerClass = Class.forName("com.heytap.mcssdk.PushManager")
+ val instance = pushManagerClass.getMethod("getInstance").invoke(null)
+ // PushManager.getInstance().register(context, appKey, appSecret, pushCallback)
+ // 需要业务层配置 AppKey 和 AppSecret
+ Log.i(TAG, "OPPO push registration requested")
+ }.onFailure { error ->
+ Log.w(TAG, "OPPO push registration failed: ${error.message}")
+ }
+ }
+
+ override fun unregister(context: Context) {
+ runCatching {
+ val pushManagerClass = Class.forName("com.heytap.mcssdk.PushManager")
+ val instance = pushManagerClass.getMethod("getInstance").invoke(null)
+ pushManagerClass.getMethod("unRegister", Context::class.java)
+ .invoke(instance, context)
+ Log.i(TAG, "OPPO push unregistered")
+ }.onFailure { error ->
+ Log.w(TAG, "OPPO push unregistration failed: ${error.message}")
+ }
+ }
+
+ companion object {
+ private const val TAG = "OppoPushService"
+ }
+}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/PushVendorInterface.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/PushVendorInterface.kt
new file mode 100644
index 0000000..6b32f8c
--- /dev/null
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/PushVendorInterface.kt
@@ -0,0 +1,31 @@
+package com.xuqm.sdk.push.vendor
+
+import android.content.Context
+import com.xuqm.sdk.push.model.PushVendor
+
+/**
+ * 厂商推送服务接口
+ *
+ * 各厂商推送 SDK 需实现此接口,并在获取到推送 Token 后调用
+ * [com.xuqm.sdk.push.PushSDK.updateNativePushToken] 上报 Token。
+ */
+interface PushVendorInterface {
+
+ /** 对应的推送厂商类型 */
+ val vendor: PushVendor
+
+ /**
+ * 检测当前设备是否支持该厂商推送(通常通过反射检测厂商 SDK 是否存在)。
+ */
+ fun isAvailable(context: Context): Boolean
+
+ /**
+ * 注册厂商推送服务,触发获取推送 Token 的流程。
+ */
+ fun register(context: Context)
+
+ /**
+ * 注销厂商推送服务。
+ */
+ fun unregister(context: Context)
+}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt
new file mode 100644
index 0000000..11348e6
--- /dev/null
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/VivoPushService.kt
@@ -0,0 +1,62 @@
+package com.xuqm.sdk.push.vendor
+
+import android.content.Context
+import android.util.Log
+import com.xuqm.sdk.push.PushSDK
+import com.xuqm.sdk.push.model.PushVendor
+
+/**
+ * vivo 推送集成框架
+ *
+ * 需要添加 vivo Push SDK 依赖:
+ * ```groovy
+ * implementation 'com.vivo.pushsdk:pushsdk:3.x.x'
+ * ```
+ *
+ * 接入方需在 `Application.onCreate()` 中调用 `PushClient.getInstance(context).initialize()`,
+ * 并在自定义 `OpenClientPushMessageReceiver` 的 `onReceiveRegId()` 回调中获取 regId,
+ * 然后调用:
+ * ```kotlin
+ * PushSDK.updateNativePushToken(context, PushVendor.VIVO, regId)
+ * ```
+ */
+class VivoPushService : PushVendorInterface {
+
+ override val vendor: PushVendor = PushVendor.VIVO
+
+ override fun isAvailable(context: Context): Boolean {
+ return runCatching {
+ Class.forName("com.vivo.push.IPushClientFactory")
+ true
+ }.getOrDefault(false)
+ }
+
+ override fun register(context: Context) {
+ runCatching {
+ val pushClientClass = Class.forName("com.vivo.push.PushClient")
+ val instance = pushClientClass.getMethod("getInstance", Context::class.java)
+ .invoke(null, context)
+ // PushClient.getInstance(context).initialize()
+ pushClientClass.getMethod("initialize").invoke(instance)
+ Log.i(TAG, "Vivo push registration requested")
+ }.onFailure { error ->
+ Log.w(TAG, "Vivo push registration failed: ${error.message}")
+ }
+ }
+
+ override fun unregister(context: Context) {
+ runCatching {
+ val pushClientClass = Class.forName("com.vivo.push.PushClient")
+ val instance = pushClientClass.getMethod("getInstance", Context::class.java)
+ .invoke(null, context)
+ pushClientClass.getMethod("turnOffPush").invoke(instance)
+ Log.i(TAG, "Vivo push unregistered")
+ }.onFailure { error ->
+ Log.w(TAG, "Vivo push unregistration failed: ${error.message}")
+ }
+ }
+
+ companion object {
+ private const val TAG = "VivoPushService"
+ }
+}
diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt
new file mode 100644
index 0000000..341e54d
--- /dev/null
+++ b/sdk-push/src/main/java/com/xuqm/sdk/push/vendor/XiaomiPushService.kt
@@ -0,0 +1,71 @@
+package com.xuqm.sdk.push.vendor
+
+import android.content.Context
+import android.util.Log
+import com.xuqm.sdk.push.PushSDK
+import com.xuqm.sdk.push.model.PushVendor
+
+/**
+ * 小米推送集成框架
+ *
+ * 需要添加 MiPush SDK 依赖:
+ * ```groovy
+ * implementation 'com.xiaomi.mipush:mipush:5.x.x'
+ * ```
+ *
+ * 接入方需在 `Application.onCreate()` 中调用 `MiPushClient.registerPush()`,
+ * 并在自定义 `PushMessageReceiver` 的 `onReceiveRegisterResult()` 回调中获取 token,
+ * 然后调用:
+ * ```kotlin
+ * PushSDK.updateNativePushToken(context, PushVendor.XIAOMI, token)
+ * ```
+ */
+class XiaomiPushService : PushVendorInterface {
+
+ override val vendor: PushVendor = PushVendor.XIAOMI
+
+ override fun isAvailable(context: Context): Boolean {
+ return runCatching {
+ Class.forName("com.xiaomi.mipush.sdk.MiPushClient")
+ true
+ }.getOrDefault(false)
+ }
+
+ override fun register(context: Context) {
+ runCatching {
+ val miPushClass = Class.forName("com.xiaomi.mipush.sdk.MiPushClient")
+ // MiPushClient.registerPush(context, appId, appKey)
+ // 需要业务层配置 AppID 和 AppKey,此处仅做框架调用示例
+ val appId = ""
+ val appKey = ""
+ if (appId.isNotBlank() && appKey.isNotBlank()) {
+ miPushClass.getMethod(
+ "registerPush",
+ Context::class.java,
+ String::class.java,
+ String::class.java,
+ ).invoke(null, context, appId, appKey)
+ Log.i(TAG, "Xiaomi push registration requested")
+ } else {
+ Log.w(TAG, "Xiaomi appId/appKey not configured, skipping registration")
+ }
+ }.onFailure { error ->
+ Log.w(TAG, "Xiaomi push registration failed: ${error.message}")
+ }
+ }
+
+ override fun unregister(context: Context) {
+ runCatching {
+ val miPushClass = Class.forName("com.xiaomi.mipush.sdk.MiPushClient")
+ miPushClass.getMethod("unregisterPush", Context::class.java)
+ .invoke(null, context)
+ Log.i(TAG, "Xiaomi push unregistered")
+ }.onFailure { error ->
+ Log.w(TAG, "Xiaomi push unregistration failed: ${error.message}")
+ }
+ }
+
+ companion object {
+ private const val TAG = "XiaomiPushService"
+ }
+}