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.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImConnectionState
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
@ -46,9 +47,9 @@ private const val POLL_TOTAL_MS = 40_000L
private fun initSdkOnce(ctx: Context) {
XuqmSDK.initialize(ctx, DEMO_APP_ID)
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
Thread.sleep(1_500)
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
SampleEnvironmentConfig.useExternal()
Thread.sleep(500)
}
@ -57,7 +58,7 @@ private suspend fun loginAndConnect(userId: String, ctx: Context): String {
val api = DemoApiFactory.create(BASE_URL) { null }
val res = api.login(LoginRequest(DEMO_APP_ID, userId, "123456"))
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) {
ImSDK.connectionState.first { it is ImConnectionState.Connected }
} ?: error("WebSocket 未在 ${CONNECT_TIMEOUT_MS}ms 内连接")
@ -84,7 +85,7 @@ class CrossDeviceSenderTest {
fun setUp() { runBlocking { loginAndConnect("user_a", appCtx) } }
@After
fun tearDown() { XuqmSDK.logout(); Thread.sleep(500) }
fun tearDown() { XuqmSDK.setUserInfo(null); Thread.sleep(500) }
/**
* TC-03 发送方: user_a user_b 发送带有唯一标识的单聊消息
@ -148,7 +149,7 @@ class CrossDeviceReceiverTest {
fun setUp() { runBlocking { loginAndConnect("user_b", appCtx) } }
@After
fun tearDown() { XuqmSDK.logout(); Thread.sleep(500) }
fun tearDown() { XuqmSDK.setUserInfo(null); Thread.sleep(500) }
/**
* 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.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImConnectionState
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
@ -48,9 +49,9 @@ class NetworkResilienceTest {
fun initSdk() {
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
Thread.sleep(1_500)
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
SampleEnvironmentConfig.useExternal()
Thread.sleep(500)
}
@ -62,7 +63,7 @@ class NetworkResilienceTest {
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}" }
XuqmSDK.login(USER_A, token)
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
ImSDK.connectionState.first { it is ImConnectionState.Connected }
} ?: error("Initial connection timed out")
@ -71,7 +72,7 @@ class NetworkResilienceTest {
@After
fun tearDown() {
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
Thread.sleep(500)
}

查看文件

@ -5,6 +5,7 @@ import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.push.PushSDK
import com.xuqm.sdk.push.model.PushVendor
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.LoginRequest
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
@ -25,7 +25,7 @@ import org.junit.runner.RunWith
*
* 说明:
* - 模拟器不具备真实 Firebase 环境FCM token 回调不会触发
* - TC-06 重点验证: vendor 检测initializeVendors 不崩溃setReceivePush 接口调用正常
* - TC-06 重点验证: vendor 检测setOfflinePushEnabled 接口调用正常
* - TC-09 重点验证: MANUFACTURER 映射逻辑及 emulator 默认回退到 FCM
*/
@RunWith(AndroidJUnit4::class)
@ -43,9 +43,9 @@ class PushSdkTest {
fun initSdk() {
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
Thread.sleep(1_500)
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
SampleEnvironmentConfig.useExternal()
Thread.sleep(500)
}
@ -56,13 +56,13 @@ class PushSdkTest {
runBlocking {
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}" }
XuqmSDK.login(USER_A, token)
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
}
}
@After
fun tearDown() {
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
}
/**
@ -96,16 +96,12 @@ class PushSdkTest {
* TC-06: Push 设备注册流程模拟器场景
*
* 模拟器无 FCM token流程:
* 1. initializeVendors 不崩溃
* 1. detectVendor 不崩溃
* 2. currentRegistration pushToken null Firebase
* 3. setReceivePush(false) API 调用正常不抛异常
* 3. setOfflinePushEnabled(false) API 调用正常不抛异常
*/
@Test
fun tc06_pushRegistrationEmulator() = runBlocking {
// 1. initializeVendors 不应抛出任何异常
PushSDK.initializeVendors(appCtx)
// 2. 模拟器上 FCM token 通常为 null;真机只校验厂商映射和流程不崩溃
val vendor = PushSDK.detectVendor()
val expected = when (Build.MANUFACTURER.uppercase()) {
"HUAWEI" -> PushVendor.HUAWEI
@ -117,10 +113,8 @@ class PushSdkTest {
}
assertEquals("MANUFACTURER=${Build.MANUFACTURER} 应检测到 $expected", expected, vendor)
// 3. setReceivePush 调用不应崩溃
PushSDK.setReceivePush(appCtx, USER_A, enabled = false)
// 恢复推送设置
PushSDK.setOfflinePushEnabled(false)
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.platform.app.InstrumentationRegistry
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImConnectionState
@ -66,9 +67,9 @@ class SdkIntegrationTest {
XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG)
// logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin
// 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
XuqmSDK.logout() // second safety net
XuqmSDK.setUserInfo(null) // second safety net
SampleEnvironmentConfig.useExternal()
Thread.sleep(500)
@ -79,7 +80,7 @@ class SdkIntegrationTest {
val api = DemoApiFactory.create(BASE_URL) { null }
val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
resA.data?.imToken?.let { tokenA ->
XuqmSDK.login(USER_A, tokenA)
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA))
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
ImSDK.connectionState.first { it is ImConnectionState.Connected }
}
@ -90,7 +91,7 @@ class SdkIntegrationTest {
)
testGroupId = group?.id
}
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
Thread.sleep(800)
}
}
@ -101,7 +102,7 @@ class SdkIntegrationTest {
private suspend fun loginAs(userId: String): String {
val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD))
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
}
@ -121,7 +122,7 @@ class SdkIntegrationTest {
@After
fun tearDown() {
XuqmSDK.logout()
XuqmSDK.setUserInfo(null)
Thread.sleep(500)
}
@ -216,7 +217,7 @@ class SdkIntegrationTest {
// 不登出,直接重新获取 token 并调用 login()
val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken
requireNotNull(newToken) { "重登录 API 失败" }
XuqmSDK.login(USER_A, newToken)
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken))
// 等待连接稳定(若 token 相同则跳过重连,若不同则重连)
val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) {

查看文件

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

查看文件

@ -207,7 +207,7 @@ fun EnvironmentScreen(
@Composable
private fun PushRegistrationSection() {
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 snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) }
@ -254,7 +254,7 @@ private fun PushRegistrationSection() {
Switch(
checked = snapshot?.receivePush ?: true,
onCheckedChange = { enabled ->
PushSDK.setReceivePush(context, currentUserId, enabled)
PushSDK.setOfflinePushEnabled(enabled)
snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot
statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送"
},

查看文件

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

查看文件

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

查看文件

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

查看文件

@ -2,9 +2,13 @@ package com.xuqm.sdk
data class XuqmUserInfo(
/** 用户唯一标识(必填),用于灰度发布、精准推送等 */
val id: String,
val userId: String,
/** 登录凭证(可选) */
val userSig: String? = null,
/** 用户显示名称(可选) */
val name: String? = null,
/** 用户头像 URL可选 */
/** 手机号(可选) */
val phone: String? = null,
/** 头像 URL可选 */
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)
}
/**
* 刷新 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(
toId: String,
chatType: String,
@ -807,6 +819,7 @@ object ImSDK {
fun onSdkLogin(session: XuqmLoginSession) {
XuqmSDK.requireInit()
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接Push/Update 仍生效)
if (currentUserId == session.userId && currentUserSig == session.userSig) return
currentUserId = session.userId
currentUserSig = session.userSig

查看文件

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

查看文件

@ -35,10 +35,95 @@ object PushSDK {
private val registeredUserId = AtomicReference<String?>(null)
private val registeringDeviceKey = AtomicReference<String?>(null)
private val lastRegisteredDeviceKey = AtomicReference<String?>(null)
@Volatile
private var cachedVendorConfig: PushVendorConfig? = null
@Volatile
private var cachedConfigAt: Long = 0L
@Volatile private var cachedVendorConfig: PushVendorConfig? = null
@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? {
XuqmSDK.requireInit()
@ -47,89 +132,60 @@ object PushSDK {
val registration = store(context).load(deviceId = deviceId, fallbackVendor = detectedVendor)
?: return null
if (registration.vendor != detectedVendor) {
Log.i(
"XuqmPushSDK",
"Clearing cached ${registration.vendor.name} push token on ${detectedVendor.name} device",
)
Log.i("XuqmPushSDK", "Clearing cached ${registration.vendor.name} push token on ${detectedVendor.name} device")
store(context).clearToken()
return null
}
return registration
}
fun notificationChannelIdFor(context: Context, routeType: String): String? =
PushNotificationChannelManager.channelIdFor(context.applicationContext, routeType)
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 updateNativePushToken(
context: Context,
vendor: PushVendor,
pushToken: String,
) {
XuqmSDK.requireInit()
private val vendorServices: List<PushVendorInterface> = listOf(
HuaweiPushService(),
XiaomiPushService(),
OppoPushService(),
VivoPushService(),
HonorPushService(),
FcmPushService(),
)
private fun initializeVendorsInternal(context: Context) {
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.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) {
Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
scope.launch {
runCatching {
api.setReceivePush(
appKey = XuqmSDK.appKey,
userId = resolvedUserId,
deviceId = DeviceUtils.getDeviceId(context),
enabled = enabled,
)
if (enabled) {
bindImUser(context, resolvedUserId)
val config = loadVendorConfig()
vendorServices.forEach { service ->
if (service.vendor == detectedVendor && service.isAvailable(context)) {
Log.i("XuqmPushSDK", "Initializing push vendor: ${service.vendor.name}")
service.register(context, config)
}
}
if (detectedVendor == PushVendor.FCM) {
ensureNativePushTokenInternal(context)
}
}
}
fun registerDevice(
context: Context,
userId: String,
) {
private fun bindImUserInternal(context: Context, userId: String) {
if (!isReceivePushEnabled(context)) return
ensureNativePushTokenInternal(context)
registerDeviceInternal(context, userId)
}
private fun registerDeviceInternal(context: Context, userId: String) {
XuqmSDK.requireInit()
val registration = currentRegistration(context)
val vendor = registration?.vendor ?: detectVendor()
@ -137,10 +193,9 @@ object PushSDK {
val deviceId = DeviceUtils.getDeviceId(context)
if (pushToken.isNullOrBlank()) {
Log.w("XuqmPushSDK", "Native push token not ready yet, waiting for onNewToken()")
ensureNativePushToken(context)
ensureNativePushTokenInternal(context)
return
}
Log.e(">>>>>>>>>>>>>>>>", pushToken)
val registrationKey = listOf(userId, vendor.name, pushToken, deviceId).joinToString("|")
if (lastRegisteredDeviceKey.get() == registrationKey) {
Log.d("XuqmPushSDK", "Skipping duplicate push device registration for userId=$userId vendor=${vendor.name}")
@ -168,17 +223,14 @@ object PushSDK {
registeredUserId.set(userId)
lastRegisteredDeviceKey.set(registrationKey)
store(context).updateLastUserId(userId)
Log.i(
"XuqmPushSDK",
"Registered push device for userId=$userId vendor=${vendor.name}",
)
Log.i("XuqmPushSDK", "Registered push device for userId=$userId vendor=${vendor.name}")
}.also {
registeringDeviceKey.compareAndSet(registrationKey, null)
}
}
}
fun unregisterDevice(userId: String) {
private fun unregisterDeviceInternal(userId: String) {
XuqmSDK.requireInit()
scope.launch {
runCatching {
@ -198,59 +250,21 @@ object PushSDK {
}
}
private val vendorServices: List<PushVendorInterface> = listOf(
HuaweiPushService(),
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
}
private fun ensureNativePushTokenInternal(context: Context) {
ensureNativePushTokenInternal(context, detectVendor())
}
fun initializeVendors(context: Context) {
val detectedVendor = detectVendor()
Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
private fun ensureNativePushTokenInternal(context: Context, vendor: PushVendor) {
val registration = currentRegistration(context)
if (registration?.pushToken?.isNotBlank() == true) return
val service = vendorServices.firstOrNull { it.vendor == vendor }
if (service != null && service.isAvailable(context)) {
scope.launch {
val config = 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)
service.register(context, loadVendorConfig())
}
} 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 =
@ -262,41 +276,6 @@ object PushSDK {
fallbackVendor = detectVendor(),
)?.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? =
runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
@ -308,15 +287,11 @@ object PushSDK {
}
}.getOrNull()
@Volatile
private var cachedPushConfig: com.google.gson.JsonObject? = null
private suspend fun loadVendorConfig(): PushVendorConfig {
val now = System.currentTimeMillis()
cachedVendorConfig?.takeIf { now - cachedConfigAt < CONFIG_CACHE_TTL_MS }?.let { return it }
val loaded = runCatching {
val pushConfig = configApi.sdkConfig(XuqmSDK.appKey).data?.pushConfig
cachedPushConfig = pushConfig
PushNotificationChannelManager.apply(XuqmSDK.appContext, pushConfig)
PushVendorConfig(
huaweiAppId = pushConfig?.getAsJsonObject("huawei")?.get("appId")?.asString.orEmpty(),
@ -329,7 +304,7 @@ object PushSDK {
honorAppId = pushConfig?.getAsJsonObject("honor")?.get("appId")?.asString.orEmpty(),
)
}.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()
}
cachedVendorConfig = loaded

查看文件

@ -53,6 +53,20 @@ internal class PushRegistrationStore(context: Context) {
.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() {
prefs.edit()
.remove(KEY_VENDOR)
@ -66,5 +80,7 @@ internal class PushRegistrationStore(context: Context) {
private const val KEY_PUSH_TOKEN = "push_token"
private const val KEY_RECEIVE_PUSH = "receive_push"
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)
/**
* 获取当前生效的 userId
* 优先级[XuqmSDK.userInfo]?.userId > [XuqmSDK.currentLoginSession]?.userId
*/
private fun resolveUserId(): String? {
return XuqmSDK.userInfo?.id ?: XuqmSDK.currentLoginSession?.userId
}
private fun resolveUserId(): String? = XuqmSDK.getUserId()
private fun normalizeDownloadUrl(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return rawUrl
@ -198,52 +192,47 @@ object UpdateSDK {
// ─────────────────────────────────────────────────────────────────────────
/**
* 下载并安装 APK
* 下载 APK 并调起系统安装器对应 spec 中的 downloadAndInstallApk
* 如果该版本的 APK 已下载到本地将跳过下载直接安装
*
* @param downloadUrl APK 下载地址来自 [UpdateInfo.downloadUrl]
* @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(
context: Context,
downloadUrl: String,
versionCode: Int = 0,
onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) {
// 如果已下载,跳过下载直接安装
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)
) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) })
// ─────────────────────────────────────────────────────────────────────────
// WebSocket 实时通知