docs(sdk): 更新跨平台SDK设计规范至v1.1版本

- 更新版本号至v1.1,状态调整为Android已落地
- 添加私有化与公有平台完全独立的原则说明
- 增加Android配置文件路径规范
- 修改platformUrl为可选参数,默认使用内置公有平台地址
- 添加awaitInitialization和retryInitialization方法
- 调整API响应格式,统一返回data结构
- 优化各子SDK配置流程和错误处理机制
- 更新ImSDK登录逻辑,支持userSig为空时跳过连接
- 添加platformType和platformConfig属性
- 标记login/logout方法为已废弃,合并至setUserInfo
- 修改PushSDK方法签名,将Promise改为void
- 添加PushSDK.currentRegistration和detectVendor方法
- 更新Android Kotlin代码示例和完整示例
- 重构服务端接口要求章节,明确各子SDK独立配置接口
- 更新测试代码,替换login/logout为setUserInfo
- 调整UI组件中的推送设置调用方式
这个提交包含在:
XuqmGroup 2026-06-15 15:51:58 +08:00
父节点 20d1654e4f
当前提交 3fe411738d
共有 16 个文件被更改,包括 510 次插入362 次删除

查看文件

@ -4,6 +4,7 @@ import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImConnectionState import com.xuqm.sdk.im.model.ImConnectionState
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
@ -46,9 +47,9 @@ private const val POLL_TOTAL_MS = 40_000L
private fun initSdkOnce(ctx: Context) { private fun initSdkOnce(ctx: Context) {
XuqmSDK.initialize(ctx, DEMO_APP_ID) XuqmSDK.initialize(ctx, DEMO_APP_ID)
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(1_500) Thread.sleep(1_500)
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
SampleEnvironmentConfig.useExternal() SampleEnvironmentConfig.useExternal()
Thread.sleep(500) Thread.sleep(500)
} }
@ -57,7 +58,7 @@ private suspend fun loginAndConnect(userId: String, ctx: Context): String {
val api = DemoApiFactory.create(BASE_URL) { null } val api = DemoApiFactory.create(BASE_URL) { null }
val res = api.login(LoginRequest(DEMO_APP_ID, userId, "123456")) val res = api.login(LoginRequest(DEMO_APP_ID, userId, "123456"))
val token = requireNotNull(res.data?.imToken) { "Login failed for $userId: ${res.message}" } val token = requireNotNull(res.data?.imToken) { "Login failed for $userId: ${res.message}" }
XuqmSDK.login(userId, token) XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = token))
withTimeoutOrNull(CONNECT_TIMEOUT_MS) { withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
ImSDK.connectionState.first { it is ImConnectionState.Connected } ImSDK.connectionState.first { it is ImConnectionState.Connected }
} ?: error("WebSocket 未在 ${CONNECT_TIMEOUT_MS}ms 内连接") } ?: error("WebSocket 未在 ${CONNECT_TIMEOUT_MS}ms 内连接")
@ -84,7 +85,7 @@ class CrossDeviceSenderTest {
fun setUp() { runBlocking { loginAndConnect("user_a", appCtx) } } fun setUp() { runBlocking { loginAndConnect("user_a", appCtx) } }
@After @After
fun tearDown() { XuqmSDK.logout(); Thread.sleep(500) } fun tearDown() { XuqmSDK.setUserInfo(null); Thread.sleep(500) }
/** /**
* TC-03 发送方: user_a user_b 发送带有唯一标识的单聊消息 * TC-03 发送方: user_a user_b 发送带有唯一标识的单聊消息
@ -148,7 +149,7 @@ class CrossDeviceReceiverTest {
fun setUp() { runBlocking { loginAndConnect("user_b", appCtx) } } fun setUp() { runBlocking { loginAndConnect("user_b", appCtx) } }
@After @After
fun tearDown() { XuqmSDK.logout(); Thread.sleep(500) } fun tearDown() { XuqmSDK.setUserInfo(null); Thread.sleep(500) }
/** /**
* TC-03 接收方: user_b 通过轮询 fetchHistory 验证收到 user_a 发送的单聊消息 * TC-03 接收方: user_b 通过轮询 fetchHistory 验证收到 user_a 发送的单聊消息

查看文件

@ -4,6 +4,7 @@ import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImConnectionState import com.xuqm.sdk.im.model.ImConnectionState
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
@ -48,9 +49,9 @@ class NetworkResilienceTest {
fun initSdk() { fun initSdk() {
appCtx = InstrumentationRegistry.getInstrumentation().targetContext appCtx = InstrumentationRegistry.getInstrumentation().targetContext
XuqmSDK.initialize(appCtx, DEMO_APP_ID) XuqmSDK.initialize(appCtx, DEMO_APP_ID)
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(1_500) Thread.sleep(1_500)
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
SampleEnvironmentConfig.useExternal() SampleEnvironmentConfig.useExternal()
Thread.sleep(500) Thread.sleep(500)
} }
@ -62,7 +63,7 @@ class NetworkResilienceTest {
val res = DemoApiFactory.create(BASE_URL) { null } val res = DemoApiFactory.create(BASE_URL) { null }
.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)) .login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" } val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
XuqmSDK.login(USER_A, token) XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
withTimeoutOrNull(CONNECT_TIMEOUT_MS) { withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
ImSDK.connectionState.first { it is ImConnectionState.Connected } ImSDK.connectionState.first { it is ImConnectionState.Connected }
} ?: error("Initial connection timed out") } ?: error("Initial connection timed out")
@ -71,7 +72,7 @@ class NetworkResilienceTest {
@After @After
fun tearDown() { fun tearDown() {
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(500) Thread.sleep(500)
} }

查看文件

@ -5,6 +5,7 @@ import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.push.PushSDK import com.xuqm.sdk.push.PushSDK
import com.xuqm.sdk.push.model.PushVendor import com.xuqm.sdk.push.model.PushVendor
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
@ -12,7 +13,6 @@ import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
import com.xuqm.sdk.sample.data.api.DemoApiFactory import com.xuqm.sdk.sample.data.api.DemoApiFactory
import com.xuqm.sdk.sample.data.api.LoginRequest import com.xuqm.sdk.sample.data.api.LoginRequest
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
@ -25,7 +25,7 @@ import org.junit.runner.RunWith
* *
* 说明: * 说明:
* - 模拟器不具备真实 Firebase 环境FCM token 回调不会触发 * - 模拟器不具备真实 Firebase 环境FCM token 回调不会触发
* - TC-06 重点验证: vendor 检测initializeVendors 不崩溃setReceivePush 接口调用正常 * - TC-06 重点验证: vendor 检测setOfflinePushEnabled 接口调用正常
* - TC-09 重点验证: MANUFACTURER 映射逻辑及 emulator 默认回退到 FCM * - TC-09 重点验证: MANUFACTURER 映射逻辑及 emulator 默认回退到 FCM
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -43,9 +43,9 @@ class PushSdkTest {
fun initSdk() { fun initSdk() {
appCtx = InstrumentationRegistry.getInstrumentation().targetContext appCtx = InstrumentationRegistry.getInstrumentation().targetContext
XuqmSDK.initialize(appCtx, DEMO_APP_ID) XuqmSDK.initialize(appCtx, DEMO_APP_ID)
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(1_500) Thread.sleep(1_500)
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
SampleEnvironmentConfig.useExternal() SampleEnvironmentConfig.useExternal()
Thread.sleep(500) Thread.sleep(500)
} }
@ -56,13 +56,13 @@ class PushSdkTest {
runBlocking { runBlocking {
val res = DemoApiFactory.create(BASE_URL) { null }.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)) val res = DemoApiFactory.create(BASE_URL) { null }.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" } val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
XuqmSDK.login(USER_A, token) XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
} }
} }
@After @After
fun tearDown() { fun tearDown() {
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
} }
/** /**
@ -96,16 +96,12 @@ class PushSdkTest {
* TC-06: Push 设备注册流程模拟器场景 * TC-06: Push 设备注册流程模拟器场景
* *
* 模拟器无 FCM token流程: * 模拟器无 FCM token流程:
* 1. initializeVendors 不崩溃 * 1. detectVendor 不崩溃
* 2. currentRegistration pushToken null Firebase * 2. currentRegistration pushToken null Firebase
* 3. setReceivePush(false) API 调用正常不抛异常 * 3. setOfflinePushEnabled(false) API 调用正常不抛异常
*/ */
@Test @Test
fun tc06_pushRegistrationEmulator() = runBlocking { fun tc06_pushRegistrationEmulator() = runBlocking {
// 1. initializeVendors 不应抛出任何异常
PushSDK.initializeVendors(appCtx)
// 2. 模拟器上 FCM token 通常为 null;真机只校验厂商映射和流程不崩溃
val vendor = PushSDK.detectVendor() val vendor = PushSDK.detectVendor()
val expected = when (Build.MANUFACTURER.uppercase()) { val expected = when (Build.MANUFACTURER.uppercase()) {
"HUAWEI" -> PushVendor.HUAWEI "HUAWEI" -> PushVendor.HUAWEI
@ -117,10 +113,8 @@ class PushSdkTest {
} }
assertEquals("MANUFACTURER=${Build.MANUFACTURER} 应检测到 $expected", expected, vendor) assertEquals("MANUFACTURER=${Build.MANUFACTURER} 应检测到 $expected", expected, vendor)
// 3. setReceivePush 调用不应崩溃 PushSDK.setOfflinePushEnabled(false)
PushSDK.setReceivePush(appCtx, USER_A, enabled = false)
// 恢复推送设置
Thread.sleep(500) Thread.sleep(500)
PushSDK.setReceivePush(appCtx, USER_A, enabled = true) PushSDK.setOfflinePushEnabled(true)
} }
} }

查看文件

@ -4,6 +4,7 @@ import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImConnectionState import com.xuqm.sdk.im.model.ImConnectionState
@ -66,9 +67,9 @@ class SdkIntegrationTest {
XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG) XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG)
// logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin // logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin
// with sample app's cached testuser1 session if loginSession is not null. // with sample app's cached testuser1 session if loginSession is not null.
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked
XuqmSDK.logout() // second safety net XuqmSDK.setUserInfo(null) // second safety net
SampleEnvironmentConfig.useExternal() SampleEnvironmentConfig.useExternal()
Thread.sleep(500) Thread.sleep(500)
@ -79,7 +80,7 @@ class SdkIntegrationTest {
val api = DemoApiFactory.create(BASE_URL) { null } val api = DemoApiFactory.create(BASE_URL) { null }
val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)) val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
resA.data?.imToken?.let { tokenA -> resA.data?.imToken?.let { tokenA ->
XuqmSDK.login(USER_A, tokenA) XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA))
withTimeoutOrNull(CONNECT_TIMEOUT_MS) { withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
ImSDK.connectionState.first { it is ImConnectionState.Connected } ImSDK.connectionState.first { it is ImConnectionState.Connected }
} }
@ -90,7 +91,7 @@ class SdkIntegrationTest {
) )
testGroupId = group?.id testGroupId = group?.id
} }
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(800) Thread.sleep(800)
} }
} }
@ -101,7 +102,7 @@ class SdkIntegrationTest {
private suspend fun loginAs(userId: String): String { private suspend fun loginAs(userId: String): String {
val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD)) val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD))
val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" } val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" }
XuqmSDK.login(userId, data.imToken) XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = data.imToken))
return data.imToken return data.imToken
} }
@ -121,7 +122,7 @@ class SdkIntegrationTest {
@After @After
fun tearDown() { fun tearDown() {
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
Thread.sleep(500) Thread.sleep(500)
} }
@ -216,7 +217,7 @@ class SdkIntegrationTest {
// 不登出,直接重新获取 token 并调用 login() // 不登出,直接重新获取 token 并调用 login()
val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken
requireNotNull(newToken) { "重登录 API 失败" } requireNotNull(newToken) { "重登录 API 失败" }
XuqmSDK.login(USER_A, newToken) XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken))
// 等待连接稳定(若 token 相同则跳过重连,若不同则重连) // 等待连接稳定(若 token 相同则跳过重连,若不同则重连)
val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) {

查看文件

@ -4,6 +4,7 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.sample.data.api.AuthResult import com.xuqm.sdk.sample.data.api.AuthResult
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
@ -76,12 +77,13 @@ class AuthRepository(context: Context) {
val res = api.login(LoginRequest(DEMO_APP_ID, userId, password)) val res = api.login(LoginRequest(DEMO_APP_ID, userId, password))
val data = requireNotNull(res.data) { res.message ?: "Login failed" } val data = requireNotNull(res.data) { res.message ?: "Login failed" }
saveSession(data) saveSession(data)
val userSig = requireNotNull(data.imToken.takeIf { it.isNotBlank() }) { XuqmSDK.setUserInfo(
"Login succeeded but IM credential is missing" XuqmUserInfo(
}
XuqmSDK.login(
userId = data.profile.userId, userId = data.profile.userId,
userSig = userSig, userSig = data.imToken.takeIf { it.isNotBlank() },
name = data.profile.nickname,
avatar = data.profile.avatar,
)
) )
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender) UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
} }
@ -94,12 +96,13 @@ class AuthRepository(context: Context) {
val res = api.register(RegisterRequest(DEMO_APP_ID, userId, password, nickname)) val res = api.register(RegisterRequest(DEMO_APP_ID, userId, password, nickname))
val data = requireNotNull(res.data) { res.message ?: "Register failed" } val data = requireNotNull(res.data) { res.message ?: "Register failed" }
saveSession(data) saveSession(data)
val userSig = requireNotNull(data.imToken.takeIf { it.isNotBlank() }) { XuqmSDK.setUserInfo(
"Register succeeded but IM credential is missing" XuqmUserInfo(
}
XuqmSDK.login(
userId = data.profile.userId, userId = data.profile.userId,
userSig = userSig, userSig = data.imToken.takeIf { it.isNotBlank() },
name = data.profile.nickname,
avatar = data.profile.avatar,
)
) )
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender) UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
} }
@ -145,16 +148,20 @@ class AuthRepository(context: Context) {
logout() logout()
throw IllegalStateException("No cached session") throw IllegalStateException("No cached session")
} }
XuqmSDK.login( XuqmSDK.setUserInfo(
XuqmUserInfo(
userId = userId, userId = userId,
userSig = userSig, userSig = userSig,
name = getCurrentNickname(),
avatar = getCurrentAvatar(),
)
) )
Unit Unit
} }
} }
fun logout() { fun logout() {
XuqmSDK.logout() XuqmSDK.setUserInfo(null)
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }

查看文件

@ -207,7 +207,7 @@ fun EnvironmentScreen(
@Composable @Composable
private fun PushRegistrationSection() { private fun PushRegistrationSection() {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val currentUserId = XuqmSDK.currentLoginSession?.userId ?: AppDependencies.authRepository.getCurrentUserId() val currentUserId = XuqmSDK.getUserId() ?: AppDependencies.authRepository.getCurrentUserId()
var statusMessage by remember { mutableStateOf<String?>(null) } var statusMessage by remember { mutableStateOf<String?>(null) }
var snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) } var snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) }
@ -254,7 +254,7 @@ private fun PushRegistrationSection() {
Switch( Switch(
checked = snapshot?.receivePush ?: true, checked = snapshot?.receivePush ?: true,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
PushSDK.setReceivePush(context, currentUserId, enabled) PushSDK.setOfflinePushEnabled(enabled)
snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot
statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送" statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送"
}, },

查看文件

@ -147,9 +147,9 @@ fun MainScreen(
if (update.downloadUrl.isNotBlank() && !isDownloading) { if (update.downloadUrl.isNotBlank() && !isDownloading) {
isDownloading = true isDownloading = true
scope.launch { scope.launch {
UpdateSDK.downloadAndInstall(context, update.downloadUrl) { progress -> UpdateSDK.downloadAndInstallApk(context, update.downloadUrl, onProgress = { progress ->
downloadProgress = progress downloadProgress = (progress * 100).toInt()
} })
isDownloading = false isDownloading = false
pendingUpdate = null pendingUpdate = null
downloadProgress = -1 downloadProgress = -1

查看文件

@ -56,9 +56,9 @@ class UpdateViewModel : ViewModel() {
fun downloadAndInstall(context: Context, downloadUrl: String) { fun downloadAndInstall(context: Context, downloadUrl: String) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(downloadProgress = 0) _state.value = _state.value.copy(downloadProgress = 0)
UpdateSDK.downloadAndInstall(context, downloadUrl) { progress -> UpdateSDK.downloadAndInstallApk(context, downloadUrl, onProgress = { progress ->
_state.value = _state.value.copy(downloadProgress = progress) _state.value = _state.value.copy(downloadProgress = (progress * 100).toInt())
} })
} }
} }
} }

查看文件

@ -1,6 +1,7 @@
package com.xuqm.sdk package com.xuqm.sdk
import android.content.Context import android.content.Context
import android.util.Log
import com.xuqm.sdk.auth.TokenStore import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
@ -8,11 +9,22 @@ import com.xuqm.sdk.core.ServiceEndpoints
import com.xuqm.sdk.core.SDKConfig import com.xuqm.sdk.core.SDKConfig
import com.xuqm.sdk.internal.ConfigFileReader import com.xuqm.sdk.internal.ConfigFileReader
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.network.SdkPlatformConfig
import com.xuqm.sdk.network.SdkPlatformConfigApi
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
object XuqmSDK { object XuqmSDK {
private const val TAG = "XuqmSDK"
/** 内置默认公有平台地址。使用私有化部署时必须显式传入 platformUrl,不会自动降级到此地址。 */
const val DEFAULT_PLATFORM_URL = "https://www.51szyx.com/"
lateinit var config: SDKConfig lateinit var config: SDKConfig
private set private set
@ -22,29 +34,33 @@ object XuqmSDK {
lateinit var tokenStore: TokenStore lateinit var tokenStore: TokenStore
private set private set
private var initialized = false private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var initializedAppKey: String? = null
@Volatile
private var loginSession: XuqmLoginSession? = null
@Volatile
private var userInfoValue: XuqmUserInfo? = null
private val initLock = Any() private val initLock = Any()
private var initialized = false
@Volatile private var initializedAppKey: String? = null
@Volatile private var resolvedPlatformUrl: String = DEFAULT_PLATFORM_URL
@Volatile private var loginSession: XuqmLoginSession? = null
@Volatile private var userInfoValue: XuqmUserInfo? = null
@Volatile var platformConfig: SdkPlatformConfig? = null
private set
private val pendingInitCallbacks = mutableListOf<() -> Unit>() private val pendingInitCallbacks = mutableListOf<() -> Unit>()
/** /**
* Initializes the SDK automatically from the init config file embedded in assets/xuqm/. * 当前远程配置拉取任务失败时 completeExceptionally[awaitInitialization] 会重新抛出
* Place the config.xuqm file downloaded from the tenant platform into your app's * 调用 [retryInitialization] 时替换为新的 Deferred
* src/main/assets/xuqm/ directory no hardcoded appKey or serverUrl needed. */
@Volatile private var remoteConfigDeferred: CompletableDeferred<Unit>? = null
// ── 初始化 ─────────────────────────────────────────────────────────────────
/**
* 方式 A配置文件自动初始化推荐
* *
* For private deployments the config file contains the server URL and all service * config.xuqm 放入 `src/main/assets/xuqm/`SDK 在模块加载时自动读取并初始化
* endpoints are configured automatically. For public deployments the default endpoints * 配置文件包含 `appKey` 和可选的 `serverUrl`私有化部署平台地址
* (dev.xuqinmin.com) are used. * App 无需调用任何初始化代码
*
* The config file's packageName/bundleId is validated against the local app package name.
* If they don't match, an exception is thrown.
*/ */
fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) { fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) {
val configFile = ConfigFileReader.read(context) val configFile = ConfigFileReader.read(context)
@ -52,38 +68,41 @@ object XuqmSDK {
"No config file found in assets/xuqm/. " + "No config file found in assets/xuqm/. " +
"Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/." "Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
) )
val appKey = configFile.appKey
val serverUrl = configFile.serverUrl
val configPackageName = configFile.packageName val configPackageName = configFile.packageName
if (!configPackageName.isNullOrBlank() && configPackageName != context.packageName) {
val localPackageName = context.packageName
if (!configPackageName.isNullOrBlank() && configPackageName != localPackageName) {
throw IllegalStateException( throw IllegalStateException(
"Config package name mismatch: config=$configPackageName, local=$localPackageName. " + "Config package name mismatch: config=$configPackageName, local=${context.packageName}. " +
"Please download the correct config file for this app." "Please download the correct config file for this app."
) )
} }
initialize(context, configFile.appKey, configFile.serverUrl, logLevel)
initialize(context, appKey, serverUrl, logLevel)
} }
/** /**
* Manual initialization without license file. * 方式 B手动初始化
* The SDK will validate the appKey against the server by calling /api/sdk/config *
* with the local package name. If the package name does not match the registered one, * 调用后立即完成本地初始化并在后台异步拉取平台服务配置
* an exception is thrown. * 使用子 SDK 前建议先 `await XuqmSDK.awaitInitialization()` 确保配置已就绪
*
* @param platformUrl 平台地址可选
* - 不传使用内置公有平台地址[DEFAULT_PLATFORM_URL]
* - 传入使用指定私有化部署平台地址
* **私有化与公有平台互相独立不允许在两者之间自动降级**
* @throws IllegalStateException 若已使用不同 appKey 初始化过 appKey 为空
*/ */
fun initialize( fun initialize(
context: Context, context: Context,
appKey: String, appKey: String,
serverUrl: String? = null, platformUrl: String? = null,
logLevel: LogLevel = LogLevel.WARN, logLevel: LogLevel = LogLevel.WARN,
) { ) {
require(appKey.isNotBlank()) { "appKey must not be blank" }
val applicationContext = context.applicationContext val applicationContext = context.applicationContext
val deferred: CompletableDeferred<Unit>
synchronized(initLock) { synchronized(initLock) {
if (initialized) { if (initialized) {
check(initializedAppKey == appKey) { check(initializedAppKey == appKey) {
"XuqmSDK already initialized with appKey=$initializedAppKey" "XuqmSDK already initialized with appKey=$initializedAppKey; cannot reinitialize with appKey=$appKey"
} }
appContext = applicationContext appContext = applicationContext
return return
@ -94,44 +113,118 @@ object XuqmSDK {
ApiClient.init(config, tokenStore) ApiClient.init(config, tokenStore)
initializedAppKey = appKey initializedAppKey = appKey
initialized = true initialized = true
resolvedPlatformUrl = platformUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_PLATFORM_URL
deferred = CompletableDeferred()
remoteConfigDeferred = deferred
pendingInitCallbacks.forEach { runCatching(it) } pendingInitCallbacks.forEach { runCatching(it) }
pendingInitCallbacks.clear() pendingInitCallbacks.clear()
} }
serverUrl?.takeIf { it.isNotBlank() }?.let { configurePrivateServer(context, appKey, it) } launchRemoteConfigFetch(resolvedPlatformUrl, appKey, deferred)
} }
/** /**
* Execute the given block after initialization is complete. * 等待异步平台配置拉取完成
* If already initialized, executes immediately. *
* If not initialized, waits until initialization completes. * 若配置拉取失败网络错误appKey 无效等此方法将抛出异常
* 可调用 [retryInitialization] 重试后再次 await
*
* @throws Exception 平台配置拉取失败时抛出
*/ */
fun afterInit(block: () -> Unit) { suspend fun awaitInitialization() {
remoteConfigDeferred?.await()
}
/**
* 重试平台配置拉取例如首次因网络问题失败后用户点击重试时调用
*
* 只能在 [initialize] 已调用后使用不会重置 appKey platformUrl
*
* @throws IllegalStateException SDK 尚未调用 [initialize]
* @throws Exception 重试仍失败时抛出
*/
suspend fun retryInitialization() {
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
val deferred = CompletableDeferred<Unit>()
synchronized(initLock) { synchronized(initLock) {
if (initialized) { remoteConfigDeferred = deferred
block() }
} else { runCatching {
pendingInitCallbacks.add(block) fetchAndApplyPlatformConfig(resolvedPlatformUrl, appKey)
deferred.complete(Unit)
}.onFailure { e ->
deferred.completeExceptionally(
platformInitException(resolvedPlatformUrl, appKey, e)
)
}
deferred.await()
}
private fun launchRemoteConfigFetch(platformUrl: String, appKey: String, deferred: CompletableDeferred<Unit>) {
sdkScope.launch {
runCatching {
fetchAndApplyPlatformConfig(platformUrl, appKey)
deferred.complete(Unit)
}.onFailure { e ->
// 不降级,直接将错误传递给 awaitInitialization() 的调用者
deferred.completeExceptionally(platformInitException(platformUrl, appKey, e))
} }
} }
} }
private fun configurePrivateServer(context: Context, appKey: String, serverUrl: String) { private suspend fun fetchAndApplyPlatformConfig(platformUrl: String, appKey: String) {
val base = serverUrl.trimEnd('/') + "/" val base = platformUrl.trimEnd('/') + "/"
val wsBase = serverUrl.trimEnd('/') val api = ApiClient.create(SdkPlatformConfigApi::class.java, base)
val response = api.fetchConfig(appKey)
val cfg = response.data
?: throw IllegalStateException(
"Platform returned empty config for appKey=$appKey at $platformUrl. " +
"Verify the appKey is registered on this platform."
)
platformConfig = cfg
// 各服务地址优先使用平台下发的值,回退到同一平台的 base URL绝不跨平台
val wsBase = platformUrl.trimEnd('/')
.replace("https://", "wss://") .replace("https://", "wss://")
.replace("http://", "ws://") .replace("http://", "ws://")
configureServiceEndpoints( val apiBase = cfg.apiUrl?.trimEnd('/')?.plus("/") ?: base
ServiceEndpointRegistry.configure(
ServiceEndpoints( ServiceEndpoints(
controlBaseUrl = base, controlBaseUrl = apiBase,
fileBaseUrl = base, imApiBaseUrl = apiBase,
imApiBaseUrl = base, pushBaseUrl = apiBase,
imWsUrl = "$wsBase/ws/im", updateBaseUrl = apiBase,
pushBaseUrl = base, imWsUrl = cfg.imWsUrl ?: "$wsBase/ws/im",
updateBaseUrl = base, fileBaseUrl = cfg.fileServiceUrl?.trimEnd('/')?.plus("/") ?: base,
) )
) )
Log.i(
TAG,
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl}"
)
} }
private fun platformInitException(platformUrl: String, appKey: String, cause: Throwable): Throwable {
val kind = if (platformUrl == DEFAULT_PLATFORM_URL) "公有平台" else "私有化平台 $platformUrl"
return IllegalStateException(
"XuqmSDK 初始化失败:无法从$kind获取服务配置appKey=$appKey)。" +
"私有化与公有平台互相独立,不自动降级。原因:${cause.message}",
cause
)
}
// ── 初始化后工具方法 ────────────────────────────────────────────────────────
fun afterInit(block: () -> Unit) {
synchronized(initLock) {
if (initialized) block() else pendingInitCallbacks.add(block)
}
}
/**
* 手动覆盖服务端点用于本地联调
* 注意 [awaitInitialization] 尚未完成此调用的结果会被远程配置覆盖
*/
fun configureServiceEndpoints(endpoints: ServiceEndpoints) { fun configureServiceEndpoints(endpoints: ServiceEndpoints) {
ServiceEndpointRegistry.configure(endpoints) ServiceEndpointRegistry.configure(endpoints)
loginSession?.let { notifyOptionalModules("onSdkLogin", it) } loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
@ -152,59 +245,82 @@ object XuqmSDK {
fun isInitialized(): Boolean = synchronized(initLock) { initialized } fun isInitialized(): Boolean = synchronized(initLock) { initialized }
// ── 公开属性与方法 ──────────────────────────────────────────────────────────
val appKey: String get() = config.appKey val appKey: String get() = config.appKey
/** 当前平台类型:"public" 或 "private:<url>" */
val platformType: String
get() = if (resolvedPlatformUrl == DEFAULT_PLATFORM_URL) "public" else "private:$resolvedPlatformUrl"
val currentLoginSession: XuqmLoginSession? val currentLoginSession: XuqmLoginSession?
get() = loginSession get() = loginSession
/**
* 当前通过 [setUserInfo] 设置的用户信息
* 优先级高于 [currentLoginSession]适用于不使用 XuqmSDK 登录体系的集成场景
*/
val userInfo: XuqmUserInfo? get() = userInfoValue val userInfo: XuqmUserInfo? get() = userInfoValue
fun getUserId(): String? = userInfoValue?.userId
fun getUserInfo(): XuqmUserInfo? = userInfoValue
/** /**
* 设置用户信息 update / push / license 等服务使用灰度发布精准推送等 * 设置用户信息 所有子 SDK 的统一认证入口登录后调用一次即可
* IM 服务需要独立调用 [login] 完成鉴权[setUserInfo] IM socket 连接无效
* *
* @param id 用户唯一标识 null 或空字符串则清除用户信息 * 内部自动触发
* @param name 用户显示名称可选 * - **PushSDK**检测厂商 获取厂商配置 注册设备 上报 token
* @param avatar 用户头像 URL可选 * - **ImSDK** [XuqmUserInfo.userSig] 存在且平台开通了 IM自动登录
* - **UpdateSDK**更新 userId用于灰度/定向更新
* - **LicenseSDK**更新用户上下文
*
* 登出时传 `null`触发所有子 SDK 登出
* ```kotlin
* XuqmSDK.setUserInfo(null)
* ```
*/ */
fun setUserInfo(id: String?, name: String? = null, avatar: String? = null) { fun setUserInfo(info: XuqmUserInfo?) {
userInfoValue = id?.takeIf { it.isNotBlank() }?.let { if (info == null) {
XuqmUserInfo(id = it, name = name, avatar = avatar) val hadSession = loginSession != null
userInfoValue = null
loginSession = null
tokenStore.clear()
if (hadSession) {
notifyOptionalModulesSync("onSdkLogout")
} }
return
}
userInfoValue = info
val session = XuqmLoginSession(
appKey = appKey,
userId = info.userId,
userSig = info.userSig ?: loginSession?.userSig ?: "",
)
loginSession = session
info.userSig?.takeIf { it.isNotBlank() }?.let { tokenStore.saveToken(it) }
notifyOptionalModules("onSdkLogin", session)
} }
suspend fun login( @Deprecated(
userId: String, "Use setUserInfo(XuqmUserInfo) instead — it unifies authentication for all sub-SDKs.",
userSig: String, ReplaceWith("setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))")
): XuqmLoginSession = withContext(Dispatchers.IO) { )
suspend fun login(userId: String, userSig: String): XuqmLoginSession = withContext(Dispatchers.IO) {
requireInit() requireInit()
loginSession?.takeIf { loginSession?.takeIf {
it.appKey == appKey && it.userId == userId && it.userSig == userSig it.appKey == appKey && it.userId == userId && it.userSig == userSig
}?.let { return@withContext it } }?.let { return@withContext it }
val session = XuqmLoginSession( setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))
appKey = appKey, loginSession!!
userId = userId,
userSig = userSig,
)
loginSession = session
tokenStore.saveToken(userSig)
notifyOptionalModules("onSdkLogin", session)
session
} }
@Deprecated(
"Use setUserInfo(null) instead.",
ReplaceWith("setUserInfo(null)")
)
fun logout() { fun logout() {
val session = loginSession setUserInfo(null)
loginSession = null
tokenStore.clear()
if (session != null) {
notifyOptionalModulesSync("onSdkLogout")
}
} }
// ── 内部子 SDK 通知(反射)──────────────────────────────────────────────────
private fun notifyOptionalModules(methodName: String, session: XuqmLoginSession) { private fun notifyOptionalModules(methodName: String, session: XuqmLoginSession) {
listOf( listOf(
"com.xuqm.sdk.im.ImSDK", "com.xuqm.sdk.im.ImSDK",

查看文件

@ -2,9 +2,13 @@ package com.xuqm.sdk
data class XuqmUserInfo( data class XuqmUserInfo(
/** 用户唯一标识(必填),用于灰度发布、精准推送等 */ /** 用户唯一标识(必填),用于灰度发布、精准推送等 */
val id: String, val userId: String,
/** 登录凭证(可选) */
val userSig: String? = null,
/** 用户显示名称(可选) */ /** 用户显示名称(可选) */
val name: String? = null, val name: String? = null,
/** 用户头像 URL可选 */ /** 手机号(可选) */
val phone: String? = null,
/** 头像 URL可选 */
val avatar: String? = null, val avatar: String? = null,
) )

查看文件

@ -0,0 +1,29 @@
package com.xuqm.sdk.network
import retrofit2.http.GET
import retrofit2.http.Query
internal interface SdkPlatformConfigApi {
@GET("api/sdk/config")
suspend fun fetchConfig(
@Query("appKey") appKey: String,
@Query("platform") platform: String = "ANDROID",
): SdkPlatformConfigResponse
}
internal data class SdkPlatformConfigResponse(val data: SdkPlatformConfig? = null)
/**
* 平台服务地址配置 [XuqmSDK.initialize] 从租户平台拉取
* 仅包含各服务的 URL各子 SDK 在初始化完成后自行请求自己的业务配置
*
* [apiUrl] null所有 HTTP 服务地址均从初始化时传入的 platformUrl 推导同一系统内不跨平台
*/
data class SdkPlatformConfig(
/** 通用 API 地址REST 接口、Push、Update 等共用) */
val apiUrl: String? = null,
/** IM WebSocket 地址(未开通 IM 时为 null */
val imWsUrl: String? = null,
/** 文件服务地址 */
val fileServiceUrl: String? = null,
)

查看文件

@ -121,6 +121,18 @@ object ImSDK {
connectWithToken(userSig) connectWithToken(userSig)
} }
/**
* 刷新 userSigIM 登录凭证过期时调用
* 等效于用新 userSig 重新调用 [XuqmSDK.setUserInfo]
*/
suspend fun refreshToken(userSig: String) = withContext(Dispatchers.IO) {
XuqmSDK.requireInit()
if (currentUserId.isBlank()) return@withContext
if (currentUserSig == userSig) return@withContext
currentUserSig = userSig
connectWithToken(userSig)
}
fun sendMessage( fun sendMessage(
toId: String, toId: String,
chatType: String, chatType: String,
@ -807,6 +819,7 @@ object ImSDK {
fun onSdkLogin(session: XuqmLoginSession) { fun onSdkLogin(session: XuqmLoginSession) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接Push/Update 仍生效)
if (currentUserId == session.userId && currentUserSig == session.userSig) return if (currentUserId == session.userId && currentUserSig == session.userSig) return
currentUserId = session.userId currentUserId = session.userId
currentUserSig = session.userSig currentUserSig = session.userSig

查看文件

@ -30,14 +30,16 @@ object LicenseSDK {
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** /**
* Optional manual initialization. Typically not needed call checkLicense() directly * 可选的手动初始化通常不需要调用 直接调用 [checkLicense] 即可
* after XuqmSDK is initialized (via config file or manual init). * LicenseSDK 会在内部等待 XuqmSDK 初始化完成后自动获取配置
* *
* @param context Application context * @deprecated LicenseSDK 现在依赖 XuqmSDK 自动初始化无需单独调用
* @param appKey The company appKey (e.g., "f713d051-0fbe-4f2d-bec9-bf7b96fc9ce4") * 直接调用 [checkLicense] [getStatus] 即可
* @param deviceName Optional device name for identification
* @param baseUrl Optional custom license server base URL. Defaults to https://auth.dev.xuqinmin.com/
*/ */
@Deprecated(
"LicenseSDK auto-initializes from XuqmSDK. Call checkLicense() directly.",
ReplaceWith("checkLicense()")
)
@JvmStatic @JvmStatic
@JvmOverloads @JvmOverloads
fun initialize( fun initialize(

查看文件

@ -35,10 +35,95 @@ object PushSDK {
private val registeredUserId = AtomicReference<String?>(null) private val registeredUserId = AtomicReference<String?>(null)
private val registeringDeviceKey = AtomicReference<String?>(null) private val registeringDeviceKey = AtomicReference<String?>(null)
private val lastRegisteredDeviceKey = AtomicReference<String?>(null) private val lastRegisteredDeviceKey = AtomicReference<String?>(null)
@Volatile @Volatile private var cachedVendorConfig: PushVendorConfig? = null
private var cachedVendorConfig: PushVendorConfig? = null @Volatile private var cachedConfigAt: Long = 0L
@Volatile
private var cachedConfigAt: Long = 0L // ── Public API (spec-aligned) ──────────────────────────────────────────────
/**
* 设置离线推送开关
* 关闭后用户不会收到离线推送但在线消息仍正常接收
*/
fun setOfflinePushEnabled(enabled: Boolean) {
XuqmSDK.requireInit()
val context = XuqmSDK.appContext
store(context).setReceivePush(enabled)
val userId = XuqmSDK.getUserId() ?: registeredUserId.get() ?: return
scope.launch {
runCatching {
api.setReceivePush(
appKey = XuqmSDK.appKey,
userId = userId,
deviceId = DeviceUtils.getDeviceId(context),
enabled = enabled,
)
if (enabled) {
bindImUserInternal(context, userId)
}
}
}
}
/**
* 设置免打扰时间段24 小时制格式 "HH:mm"
* 示例PushSDK.setQuietHours("22:00", "08:00")
*/
fun setQuietHours(start: String, end: String) {
XuqmSDK.requireInit()
store(XuqmSDK.appContext).setQuietHours(start, end)
}
/** 清除免打扰设置。 */
fun clearQuietHours() {
XuqmSDK.requireInit()
store(XuqmSDK.appContext).clearQuietHours()
}
/**
* 登出推送解绑当前设备 token
* 通常不需要手动调用[XuqmSDK.setUserInfo(null)] 会自动触发
*/
fun logout() {
onSdkLogout()
}
/** 返回通知渠道 ID,可用于自定义通知点击行为。 */
fun notificationChannelIdFor(context: Context, routeType: String): String? =
PushNotificationChannelManager.channelIdFor(context.applicationContext, routeType)
// ── Internal hooks called by XuqmSDK via reflection ───────────────────────
fun onSdkLogin(session: XuqmLoginSession) {
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
if (registeredUserId.get() == session.userId) return
initializeVendorsInternal(context)
bindImUserInternal(context, session.userId)
}
fun onSdkLogout() {
val userId = registeredUserId.getAndSet(null) ?: return
lastRegisteredDeviceKey.set(null)
registeringDeviceKey.set(null)
unregisterDeviceInternal(userId)
}
// ── Internal push registration flow ───────────────────────────────────────
internal fun updateNativePushToken(context: Context, vendor: PushVendor, pushToken: String) {
XuqmSDK.requireInit()
val detectedVendor = detectVendor()
if (vendor != detectedVendor) {
Log.i("XuqmPushSDK", "Ignoring ${vendor.name} push token on ${detectedVendor.name} device")
return
}
val normalizedToken = pushToken.trim()
require(normalizedToken.isNotBlank()) { "pushToken must not be blank" }
store(context).save(vendor, normalizedToken)
val sessionUserId = XuqmSDK.getUserId()
if (sessionUserId != null) {
bindImUserInternal(context, sessionUserId)
}
}
fun currentRegistration(context: Context): PushRegistrationSnapshot? { fun currentRegistration(context: Context): PushRegistrationSnapshot? {
XuqmSDK.requireInit() XuqmSDK.requireInit()
@ -47,89 +132,60 @@ object PushSDK {
val registration = store(context).load(deviceId = deviceId, fallbackVendor = detectedVendor) val registration = store(context).load(deviceId = deviceId, fallbackVendor = detectedVendor)
?: return null ?: return null
if (registration.vendor != detectedVendor) { if (registration.vendor != detectedVendor) {
Log.i( Log.i("XuqmPushSDK", "Clearing cached ${registration.vendor.name} push token on ${detectedVendor.name} device")
"XuqmPushSDK",
"Clearing cached ${registration.vendor.name} push token on ${detectedVendor.name} device",
)
store(context).clearToken() store(context).clearToken()
return null return null
} }
return registration return registration
} }
fun notificationChannelIdFor(context: Context, routeType: String): String? = fun detectVendor(): PushVendor {
PushNotificationChannelManager.channelIdFor(context.applicationContext, routeType) return when (Build.MANUFACTURER.uppercase()) {
"HUAWEI" -> PushVendor.HUAWEI
"XIAOMI" -> PushVendor.XIAOMI
"REDMI", "POCO" -> PushVendor.XIAOMI
"OPPO" -> PushVendor.OPPO
"REALME", "ONEPLUS" -> PushVendor.OPPO
"VIVO" -> PushVendor.VIVO
"IQOO" -> PushVendor.VIVO
"HONOR" -> PushVendor.HONOR
else -> PushVendor.FCM
}
}
fun updateNativePushToken( private val vendorServices: List<PushVendorInterface> = listOf(
context: Context, HuaweiPushService(),
vendor: PushVendor, XiaomiPushService(),
pushToken: String, OppoPushService(),
) { VivoPushService(),
XuqmSDK.requireInit() HonorPushService(),
FcmPushService(),
)
private fun initializeVendorsInternal(context: Context) {
val detectedVendor = detectVendor() val detectedVendor = detectVendor()
if (vendor != detectedVendor) { Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
Log.i(
"XuqmPushSDK",
"Ignoring ${vendor.name} push token on ${detectedVendor.name} device",
)
return
}
val normalizedToken = pushToken.trim()
require(normalizedToken.isNotBlank()) { "pushToken must not be blank" }
store(context).save(vendor, normalizedToken)
val sessionUserId = XuqmSDK.currentLoginSession?.userId
if (sessionUserId != null) {
bindImUser(context, sessionUserId)
}
}
fun bindImUser(context: Context, userId: String) {
if (!isReceivePushEnabled(context)) return
ensureNativePushToken(context)
registerDevice(context, userId)
}
fun refreshNativePushToken(
context: Context,
vendor: PushVendor = detectVendor(),
) {
XuqmSDK.requireInit()
ensureNativePushToken(context.applicationContext, vendor)
}
fun unbindImUser(userId: String) {
unregisterDevice(userId)
}
fun setReceivePush(
context: Context,
userId: String? = XuqmSDK.currentLoginSession?.userId,
enabled: Boolean,
) {
XuqmSDK.requireInit()
store(context).setReceivePush(enabled)
val resolvedUserId = userId ?: registeredUserId.get()
if (resolvedUserId != null) {
scope.launch { scope.launch {
runCatching { val config = loadVendorConfig()
api.setReceivePush( vendorServices.forEach { service ->
appKey = XuqmSDK.appKey, if (service.vendor == detectedVendor && service.isAvailable(context)) {
userId = resolvedUserId, Log.i("XuqmPushSDK", "Initializing push vendor: ${service.vendor.name}")
deviceId = DeviceUtils.getDeviceId(context), service.register(context, config)
enabled = enabled,
)
if (enabled) {
bindImUser(context, resolvedUserId)
} }
} }
if (detectedVendor == PushVendor.FCM) {
ensureNativePushTokenInternal(context)
} }
} }
} }
fun registerDevice( private fun bindImUserInternal(context: Context, userId: String) {
context: Context, if (!isReceivePushEnabled(context)) return
userId: String, ensureNativePushTokenInternal(context)
) { registerDeviceInternal(context, userId)
}
private fun registerDeviceInternal(context: Context, userId: String) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
val registration = currentRegistration(context) val registration = currentRegistration(context)
val vendor = registration?.vendor ?: detectVendor() val vendor = registration?.vendor ?: detectVendor()
@ -137,10 +193,9 @@ object PushSDK {
val deviceId = DeviceUtils.getDeviceId(context) val deviceId = DeviceUtils.getDeviceId(context)
if (pushToken.isNullOrBlank()) { if (pushToken.isNullOrBlank()) {
Log.w("XuqmPushSDK", "Native push token not ready yet, waiting for onNewToken()") Log.w("XuqmPushSDK", "Native push token not ready yet, waiting for onNewToken()")
ensureNativePushToken(context) ensureNativePushTokenInternal(context)
return return
} }
Log.e(">>>>>>>>>>>>>>>>", pushToken)
val registrationKey = listOf(userId, vendor.name, pushToken, deviceId).joinToString("|") val registrationKey = listOf(userId, vendor.name, pushToken, deviceId).joinToString("|")
if (lastRegisteredDeviceKey.get() == registrationKey) { if (lastRegisteredDeviceKey.get() == registrationKey) {
Log.d("XuqmPushSDK", "Skipping duplicate push device registration for userId=$userId vendor=${vendor.name}") Log.d("XuqmPushSDK", "Skipping duplicate push device registration for userId=$userId vendor=${vendor.name}")
@ -168,17 +223,14 @@ object PushSDK {
registeredUserId.set(userId) registeredUserId.set(userId)
lastRegisteredDeviceKey.set(registrationKey) lastRegisteredDeviceKey.set(registrationKey)
store(context).updateLastUserId(userId) store(context).updateLastUserId(userId)
Log.i( Log.i("XuqmPushSDK", "Registered push device for userId=$userId vendor=${vendor.name}")
"XuqmPushSDK",
"Registered push device for userId=$userId vendor=${vendor.name}",
)
}.also { }.also {
registeringDeviceKey.compareAndSet(registrationKey, null) registeringDeviceKey.compareAndSet(registrationKey, null)
} }
} }
} }
fun unregisterDevice(userId: String) { private fun unregisterDeviceInternal(userId: String) {
XuqmSDK.requireInit() XuqmSDK.requireInit()
scope.launch { scope.launch {
runCatching { runCatching {
@ -198,59 +250,21 @@ object PushSDK {
} }
} }
private val vendorServices: List<PushVendorInterface> = listOf( private fun ensureNativePushTokenInternal(context: Context) {
HuaweiPushService(), ensureNativePushTokenInternal(context, detectVendor())
XiaomiPushService(),
OppoPushService(),
VivoPushService(),
HonorPushService(),
FcmPushService(),
)
fun detectVendor(): PushVendor {
return when (Build.MANUFACTURER.uppercase()) {
"HUAWEI" -> PushVendor.HUAWEI
"XIAOMI" -> PushVendor.XIAOMI
"REDMI", "POCO" -> PushVendor.XIAOMI
"OPPO" -> PushVendor.OPPO
"REALME", "ONEPLUS" -> PushVendor.OPPO
"VIVO" -> PushVendor.VIVO
"IQOO" -> PushVendor.VIVO
"HONOR" -> PushVendor.HONOR
else -> PushVendor.FCM
}
} }
fun initializeVendors(context: Context) { private fun ensureNativePushTokenInternal(context: Context, vendor: PushVendor) {
val detectedVendor = detectVendor() val registration = currentRegistration(context)
Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}") if (registration?.pushToken?.isNotBlank() == true) return
val service = vendorServices.firstOrNull { it.vendor == vendor }
if (service != null && service.isAvailable(context)) {
scope.launch { scope.launch {
val config = loadVendorConfig() service.register(context, loadVendorConfig())
PushNotificationChannelManager.apply(context.applicationContext, cachedPushConfig)
vendorServices.forEach { service ->
if (service.vendor == detectedVendor && service.isAvailable(context)) {
Log.i("XuqmPushSDK", "Initializing push vendor: ${service.vendor.name}")
service.register(context, config)
} }
} else {
Log.w("XuqmPushSDK", "Vendor ${vendor.name} service not available, skipping native push registration")
} }
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)
}
fun onSdkLogout() {
val userId = registeredUserId.getAndSet(null) ?: return
lastRegisteredDeviceKey.set(null)
registeringDeviceKey.set(null)
unregisterDevice(userId)
} }
private fun store(context: Context): PushRegistrationStore = private fun store(context: Context): PushRegistrationStore =
@ -262,41 +276,6 @@ object PushSDK {
fallbackVendor = detectVendor(), fallbackVendor = detectVendor(),
)?.receivePush ?: true )?.receivePush ?: true
private fun ensureNativePushToken(context: Context) {
ensureNativePushToken(context, detectVendor())
}
private fun ensureNativePushToken(
context: Context,
vendor: PushVendor,
) {
val registration = currentRegistration(context)
if (registration?.pushToken?.isNotBlank() == true) return
if (vendor == PushVendor.FCM) {
val service = vendorServices.firstOrNull { it.vendor == PushVendor.FCM }
if (service != null && service.isAvailable(context)) {
scope.launch {
service.register(context, loadVendorConfig())
}
} else {
Log.w("XuqmPushSDK", "FCM service not available, skipping native push registration")
}
} else {
val service = vendorServices.firstOrNull { it.vendor == vendor }
if (service != null && service.isAvailable(context)) {
scope.launch {
service.register(context, loadVendorConfig())
}
} else {
Log.w(
"XuqmPushSDK",
"Vendor ${vendor.name} service not available, skipping native push registration",
)
}
}
}
private fun appVersion(context: Context): String? = private fun appVersion(context: Context): String? =
runCatching { runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
@ -308,15 +287,11 @@ object PushSDK {
} }
}.getOrNull() }.getOrNull()
@Volatile
private var cachedPushConfig: com.google.gson.JsonObject? = null
private suspend fun loadVendorConfig(): PushVendorConfig { private suspend fun loadVendorConfig(): PushVendorConfig {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
cachedVendorConfig?.takeIf { now - cachedConfigAt < CONFIG_CACHE_TTL_MS }?.let { return it } cachedVendorConfig?.takeIf { now - cachedConfigAt < CONFIG_CACHE_TTL_MS }?.let { return it }
val loaded = runCatching { val loaded = runCatching {
val pushConfig = configApi.sdkConfig(XuqmSDK.appKey).data?.pushConfig val pushConfig = configApi.sdkConfig(XuqmSDK.appKey).data?.pushConfig
cachedPushConfig = pushConfig
PushNotificationChannelManager.apply(XuqmSDK.appContext, pushConfig) PushNotificationChannelManager.apply(XuqmSDK.appContext, pushConfig)
PushVendorConfig( PushVendorConfig(
huaweiAppId = pushConfig?.getAsJsonObject("huawei")?.get("appId")?.asString.orEmpty(), huaweiAppId = pushConfig?.getAsJsonObject("huawei")?.get("appId")?.asString.orEmpty(),
@ -329,7 +304,7 @@ object PushSDK {
honorAppId = pushConfig?.getAsJsonObject("honor")?.get("appId")?.asString.orEmpty(), honorAppId = pushConfig?.getAsJsonObject("honor")?.get("appId")?.asString.orEmpty(),
) )
}.getOrElse { error -> }.getOrElse { error ->
Log.w("XuqmPushSDK", "Unable to load tenant push config: ${error.message}") Log.w("XuqmPushSDK", "Unable to load push vendor config: ${error.message}")
PushVendorConfig() PushVendorConfig()
} }
cachedVendorConfig = loaded cachedVendorConfig = loaded

查看文件

@ -53,6 +53,20 @@ internal class PushRegistrationStore(context: Context) {
.apply() .apply()
} }
fun setQuietHours(start: String, end: String) {
prefs.edit()
.putString(KEY_QUIET_HOURS_START, start)
.putString(KEY_QUIET_HOURS_END, end)
.apply()
}
fun clearQuietHours() {
prefs.edit()
.remove(KEY_QUIET_HOURS_START)
.remove(KEY_QUIET_HOURS_END)
.apply()
}
fun clearToken() { fun clearToken() {
prefs.edit() prefs.edit()
.remove(KEY_VENDOR) .remove(KEY_VENDOR)
@ -66,5 +80,7 @@ internal class PushRegistrationStore(context: Context) {
private const val KEY_PUSH_TOKEN = "push_token" private const val KEY_PUSH_TOKEN = "push_token"
private const val KEY_RECEIVE_PUSH = "receive_push" private const val KEY_RECEIVE_PUSH = "receive_push"
private const val KEY_LAST_USER_ID = "last_user_id" private const val KEY_LAST_USER_ID = "last_user_id"
private const val KEY_QUIET_HOURS_START = "quiet_hours_start"
private const val KEY_QUIET_HOURS_END = "quiet_hours_end"
} }
} }

查看文件

@ -21,13 +21,7 @@ object UpdateSDK {
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl) private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
/** private fun resolveUserId(): String? = XuqmSDK.getUserId()
* 获取当前生效的 userId
* 优先级[XuqmSDK.userInfo]?.userId > [XuqmSDK.currentLoginSession]?.userId
*/
private fun resolveUserId(): String? {
return XuqmSDK.userInfo?.id ?: XuqmSDK.currentLoginSession?.userId
}
private fun normalizeDownloadUrl(rawUrl: String?): String? { private fun normalizeDownloadUrl(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return rawUrl if (rawUrl.isNullOrBlank()) return rawUrl
@ -198,52 +192,47 @@ object UpdateSDK {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/** /**
* 下载并安装 APK * 下载 APK 并调起系统安装器对应 spec 中的 downloadAndInstallApk
* 如果该版本的 APK 已下载到本地将跳过下载直接安装 * 如果该版本的 APK 已下载到本地将跳过下载直接安装
* *
* @param downloadUrl APK 下载地址来自 [UpdateInfo.downloadUrl] * @param downloadUrl APK 下载地址来自 [UpdateInfo.downloadUrl]
* @param versionCode 版本号来自 [UpdateInfo.versionCode]用于本地文件命名和已下载检测 * @param versionCode 版本号来自 [UpdateInfo.versionCode]用于本地文件命名和已下载检测
* @param onProgress 下载进度回调 (0-100)跳过下载时不会调用 * @param onProgress 下载进度回调 (0~1)跳过下载时不会调用
* @param sha256 APK 文件的 SHA-256 校验值可选有则验证
*/ */
suspend fun downloadAndInstallApk(
context: Context,
downloadUrl: String,
versionCode: Int = 0,
onProgress: ((Float) -> Unit)? = null,
sha256: String? = null,
) = withContext(Dispatchers.IO) {
if (versionCode > 0 && isApkDownloaded(context, versionCode, sha256.orEmpty())) {
val apkFile = resolveDownloadedApk(context, versionCode)!!
withContext(Dispatchers.Main) { installApk(context, apkFile) }
return@withContext
}
val apkFile = if (versionCode > 0) versionedApkFile(context, versionCode) else legacyApkFile(context)
FileSDK.downloadToFile(downloadUrl, apkFile) { progressInt ->
onProgress?.invoke(progressInt / 100f)
}
if (versionCode > 0) {
val legacy = legacyApkFile(context)
if (legacy.exists()) runCatching { legacy.delete() }
}
withContext(Dispatchers.Main) { installApk(context, apkFile) }
}
@Deprecated(
"Use downloadAndInstallApk instead.",
ReplaceWith("downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress(it.toInt()) })")
)
suspend fun downloadAndInstall( suspend fun downloadAndInstall(
context: Context, context: Context,
downloadUrl: String, downloadUrl: String,
versionCode: Int = 0, versionCode: Int = 0,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) { ) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) })
// 如果已下载,跳过下载直接安装
if (versionCode > 0 && isApkDownloaded(context, versionCode)) {
val apkFile = resolveDownloadedApk(context, versionCode)!!
withContext(Dispatchers.Main) { installApk(context, apkFile) }
return@withContext
}
val apkFile = if (versionCode > 0) {
versionedApkFile(context, versionCode)
} else {
legacyApkFile(context)
}
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
// 下载成功后清理旧版无版本号文件
if (versionCode > 0) {
val legacy = legacyApkFile(context)
if (legacy.exists()) runCatching { legacy.delete() }
}
withContext(Dispatchers.Main) { installApk(context, apkFile) }
}
/**
* @deprecated 使用带 versionCode 参数的重载版本以支持已下载检测
*/
@Deprecated("Use downloadAndInstall(context, downloadUrl, versionCode, onProgress) instead",
ReplaceWith("downloadAndInstall(context, downloadUrl, 0, onProgress)"))
suspend fun downloadAndInstall(
context: Context,
downloadUrl: String,
onProgress: (Int) -> Unit = {},
) = downloadAndInstall(context, downloadUrl, 0, onProgress)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// WebSocket 实时通知 // WebSocket 实时通知