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组件中的推送设置调用方式
这个提交包含在:
父节点
20d1654e4f
当前提交
3fe411738d
@ -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(
|
||||
userId = data.profile.userId,
|
||||
userSig = userSig,
|
||||
XuqmSDK.setUserInfo(
|
||||
XuqmUserInfo(
|
||||
userId = data.profile.userId,
|
||||
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(
|
||||
userId = data.profile.userId,
|
||||
userSig = userSig,
|
||||
XuqmSDK.setUserInfo(
|
||||
XuqmUserInfo(
|
||||
userId = data.profile.userId,
|
||||
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(
|
||||
userId = userId,
|
||||
userSig = userSig,
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 userSig(IM 登录凭证过期时调用)。
|
||||
* 等效于用新 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) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.setReceivePush(
|
||||
appKey = XuqmSDK.appKey,
|
||||
userId = resolvedUserId,
|
||||
deviceId = DeviceUtils.getDeviceId(context),
|
||||
enabled = enabled,
|
||||
)
|
||||
if (enabled) {
|
||||
bindImUser(context, resolvedUserId)
|
||||
}
|
||||
Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
|
||||
scope.launch {
|
||||
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,61 +250,23 @@ 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}")
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (detectedVendor == PushVendor.FCM) {
|
||||
ensureNativePushToken(context)
|
||||
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 {
|
||||
service.register(context, loadVendorConfig())
|
||||
}
|
||||
} else {
|
||||
Log.w("XuqmPushSDK", "Vendor ${vendor.name} service not available, skipping native push registration")
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
PushRegistrationStore(context.applicationContext)
|
||||
|
||||
@ -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 实时通知
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户