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.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
||||||
@ -46,9 +47,9 @@ private const val POLL_TOTAL_MS = 40_000L
|
|||||||
|
|
||||||
private fun initSdkOnce(ctx: Context) {
|
private fun initSdkOnce(ctx: Context) {
|
||||||
XuqmSDK.initialize(ctx, DEMO_APP_ID)
|
XuqmSDK.initialize(ctx, DEMO_APP_ID)
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
SampleEnvironmentConfig.useExternal()
|
SampleEnvironmentConfig.useExternal()
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
}
|
}
|
||||||
@ -57,7 +58,7 @@ private suspend fun loginAndConnect(userId: String, ctx: Context): String {
|
|||||||
val api = DemoApiFactory.create(BASE_URL) { null }
|
val api = DemoApiFactory.create(BASE_URL) { null }
|
||||||
val res = api.login(LoginRequest(DEMO_APP_ID, userId, "123456"))
|
val res = api.login(LoginRequest(DEMO_APP_ID, userId, "123456"))
|
||||||
val token = requireNotNull(res.data?.imToken) { "Login failed for $userId: ${res.message}" }
|
val token = requireNotNull(res.data?.imToken) { "Login failed for $userId: ${res.message}" }
|
||||||
XuqmSDK.login(userId, token)
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = token))
|
||||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
ImSDK.connectionState.first { it is ImConnectionState.Connected }
|
ImSDK.connectionState.first { it is ImConnectionState.Connected }
|
||||||
} ?: error("WebSocket 未在 ${CONNECT_TIMEOUT_MS}ms 内连接")
|
} ?: error("WebSocket 未在 ${CONNECT_TIMEOUT_MS}ms 内连接")
|
||||||
@ -84,7 +85,7 @@ class CrossDeviceSenderTest {
|
|||||||
fun setUp() { runBlocking { loginAndConnect("user_a", appCtx) } }
|
fun setUp() { runBlocking { loginAndConnect("user_a", appCtx) } }
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() { XuqmSDK.logout(); Thread.sleep(500) }
|
fun tearDown() { XuqmSDK.setUserInfo(null); Thread.sleep(500) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TC-03 发送方: user_a 向 user_b 发送带有唯一标识的单聊消息
|
* TC-03 发送方: user_a 向 user_b 发送带有唯一标识的单聊消息
|
||||||
@ -148,7 +149,7 @@ class CrossDeviceReceiverTest {
|
|||||||
fun setUp() { runBlocking { loginAndConnect("user_b", appCtx) } }
|
fun setUp() { runBlocking { loginAndConnect("user_b", appCtx) } }
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() { XuqmSDK.logout(); Thread.sleep(500) }
|
fun tearDown() { XuqmSDK.setUserInfo(null); Thread.sleep(500) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TC-03 接收方: user_b 通过轮询 fetchHistory 验证收到 user_a 发送的单聊消息
|
* TC-03 接收方: user_b 通过轮询 fetchHistory 验证收到 user_a 发送的单聊消息
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
||||||
@ -48,9 +49,9 @@ class NetworkResilienceTest {
|
|||||||
fun initSdk() {
|
fun initSdk() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
SampleEnvironmentConfig.useExternal()
|
SampleEnvironmentConfig.useExternal()
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
}
|
}
|
||||||
@ -62,7 +63,7 @@ class NetworkResilienceTest {
|
|||||||
val res = DemoApiFactory.create(BASE_URL) { null }
|
val res = DemoApiFactory.create(BASE_URL) { null }
|
||||||
.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
||||||
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
||||||
XuqmSDK.login(USER_A, token)
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
||||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
ImSDK.connectionState.first { it is ImConnectionState.Connected }
|
ImSDK.connectionState.first { it is ImConnectionState.Connected }
|
||||||
} ?: error("Initial connection timed out")
|
} ?: error("Initial connection timed out")
|
||||||
@ -71,7 +72,7 @@ class NetworkResilienceTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import android.os.Build
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.push.PushSDK
|
import com.xuqm.sdk.push.PushSDK
|
||||||
import com.xuqm.sdk.push.model.PushVendor
|
import com.xuqm.sdk.push.model.PushVendor
|
||||||
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
|
||||||
@ -12,7 +13,6 @@ import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
|||||||
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
import com.xuqm.sdk.sample.data.api.DemoApiFactory
|
||||||
import com.xuqm.sdk.sample.data.api.LoginRequest
|
import com.xuqm.sdk.sample.data.api.LoginRequest
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.assertNotNull
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@ -25,7 +25,7 @@ import org.junit.runner.RunWith
|
|||||||
*
|
*
|
||||||
* 说明:
|
* 说明:
|
||||||
* - 模拟器不具备真实 Firebase 环境,FCM token 回调不会触发
|
* - 模拟器不具备真实 Firebase 环境,FCM token 回调不会触发
|
||||||
* - TC-06 重点验证: vendor 检测、initializeVendors 不崩溃、setReceivePush 接口调用正常
|
* - TC-06 重点验证: vendor 检测、setOfflinePushEnabled 接口调用正常
|
||||||
* - TC-09 重点验证: 各 MANUFACTURER 映射逻辑及 emulator 默认回退到 FCM
|
* - TC-09 重点验证: 各 MANUFACTURER 映射逻辑及 emulator 默认回退到 FCM
|
||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@ -43,9 +43,9 @@ class PushSdkTest {
|
|||||||
fun initSdk() {
|
fun initSdk() {
|
||||||
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
appCtx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
XuqmSDK.initialize(appCtx, DEMO_APP_ID)
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
SampleEnvironmentConfig.useExternal()
|
SampleEnvironmentConfig.useExternal()
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
}
|
}
|
||||||
@ -56,13 +56,13 @@ class PushSdkTest {
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
val res = DemoApiFactory.create(BASE_URL) { null }.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
val res = DemoApiFactory.create(BASE_URL) { null }.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
||||||
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
val token = requireNotNull(res.data?.imToken) { "Login failed: ${res.message}" }
|
||||||
XuqmSDK.login(USER_A, token)
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = token))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -96,16 +96,12 @@ class PushSdkTest {
|
|||||||
* TC-06: Push 设备注册流程(模拟器场景)
|
* TC-06: Push 设备注册流程(模拟器场景)
|
||||||
*
|
*
|
||||||
* 模拟器无 FCM token,流程:
|
* 模拟器无 FCM token,流程:
|
||||||
* 1. initializeVendors → 不崩溃
|
* 1. detectVendor → 不崩溃
|
||||||
* 2. currentRegistration → pushToken 为 null(无 Firebase)
|
* 2. currentRegistration → pushToken 为 null(无 Firebase)
|
||||||
* 3. setReceivePush(false) → API 调用正常,不抛异常
|
* 3. setOfflinePushEnabled(false) → API 调用正常,不抛异常
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun tc06_pushRegistrationEmulator() = runBlocking {
|
fun tc06_pushRegistrationEmulator() = runBlocking {
|
||||||
// 1. initializeVendors 不应抛出任何异常
|
|
||||||
PushSDK.initializeVendors(appCtx)
|
|
||||||
|
|
||||||
// 2. 模拟器上 FCM token 通常为 null;真机只校验厂商映射和流程不崩溃
|
|
||||||
val vendor = PushSDK.detectVendor()
|
val vendor = PushSDK.detectVendor()
|
||||||
val expected = when (Build.MANUFACTURER.uppercase()) {
|
val expected = when (Build.MANUFACTURER.uppercase()) {
|
||||||
"HUAWEI" -> PushVendor.HUAWEI
|
"HUAWEI" -> PushVendor.HUAWEI
|
||||||
@ -117,10 +113,8 @@ class PushSdkTest {
|
|||||||
}
|
}
|
||||||
assertEquals("MANUFACTURER=${Build.MANUFACTURER} 应检测到 $expected", expected, vendor)
|
assertEquals("MANUFACTURER=${Build.MANUFACTURER} 应检测到 $expected", expected, vendor)
|
||||||
|
|
||||||
// 3. setReceivePush 调用不应崩溃
|
PushSDK.setOfflinePushEnabled(false)
|
||||||
PushSDK.setReceivePush(appCtx, USER_A, enabled = false)
|
|
||||||
// 恢复推送设置
|
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
PushSDK.setReceivePush(appCtx, USER_A, enabled = true)
|
PushSDK.setOfflinePushEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.core.LogLevel
|
import com.xuqm.sdk.core.LogLevel
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.model.ImConnectionState
|
import com.xuqm.sdk.im.model.ImConnectionState
|
||||||
@ -66,9 +67,9 @@ class SdkIntegrationTest {
|
|||||||
XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG)
|
XuqmSDK.initialize(appCtx, DEMO_APP_ID, LogLevel.DEBUG)
|
||||||
// logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin
|
// logout BEFORE useExternal: configureServiceEndpoints re-triggers onSdkLogin
|
||||||
// with sample app's cached testuser1 session if loginSession is not null.
|
// with sample app's cached testuser1 session if loginSession is not null.
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked
|
Thread.sleep(1_500) // let any in-flight 1s reconnect timers fire and be blocked
|
||||||
XuqmSDK.logout() // second safety net
|
XuqmSDK.setUserInfo(null) // second safety net
|
||||||
SampleEnvironmentConfig.useExternal()
|
SampleEnvironmentConfig.useExternal()
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
|
|
||||||
@ -79,7 +80,7 @@ class SdkIntegrationTest {
|
|||||||
val api = DemoApiFactory.create(BASE_URL) { null }
|
val api = DemoApiFactory.create(BASE_URL) { null }
|
||||||
val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
val resA = api.login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD))
|
||||||
resA.data?.imToken?.let { tokenA ->
|
resA.data?.imToken?.let { tokenA ->
|
||||||
XuqmSDK.login(USER_A, tokenA)
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = tokenA))
|
||||||
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
ImSDK.connectionState.first { it is ImConnectionState.Connected }
|
ImSDK.connectionState.first { it is ImConnectionState.Connected }
|
||||||
}
|
}
|
||||||
@ -90,7 +91,7 @@ class SdkIntegrationTest {
|
|||||||
)
|
)
|
||||||
testGroupId = group?.id
|
testGroupId = group?.id
|
||||||
}
|
}
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(800)
|
Thread.sleep(800)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,7 +102,7 @@ class SdkIntegrationTest {
|
|||||||
private suspend fun loginAs(userId: String): String {
|
private suspend fun loginAs(userId: String): String {
|
||||||
val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD))
|
val res = buildDemoApi().login(LoginRequest(DEMO_APP_ID, userId, PASSWORD))
|
||||||
val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" }
|
val data = requireNotNull(res.data) { "Demo login failed for $userId: ${res.message}" }
|
||||||
XuqmSDK.login(userId, data.imToken)
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = userId, userSig = data.imToken))
|
||||||
return data.imToken
|
return data.imToken
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +122,7 @@ class SdkIntegrationTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,7 +217,7 @@ class SdkIntegrationTest {
|
|||||||
// 不登出,直接重新获取 token 并调用 login()
|
// 不登出,直接重新获取 token 并调用 login()
|
||||||
val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken
|
val newToken = buildDemoApi().login(LoginRequest(DEMO_APP_ID, USER_A, PASSWORD)).data?.imToken
|
||||||
requireNotNull(newToken) { "重登录 API 失败" }
|
requireNotNull(newToken) { "重登录 API 失败" }
|
||||||
XuqmSDK.login(USER_A, newToken)
|
XuqmSDK.setUserInfo(XuqmUserInfo(userId = USER_A, userSig = newToken))
|
||||||
|
|
||||||
// 等待连接稳定(若 token 相同则跳过重连,若不同则重连)
|
// 等待连接稳定(若 token 相同则跳过重连,若不同则重连)
|
||||||
val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKeys
|
import androidx.security.crypto.MasterKeys
|
||||||
import com.xuqm.sdk.XuqmSDK
|
import com.xuqm.sdk.XuqmSDK
|
||||||
|
import com.xuqm.sdk.XuqmUserInfo
|
||||||
import com.xuqm.sdk.sample.data.api.AuthResult
|
import com.xuqm.sdk.sample.data.api.AuthResult
|
||||||
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
|
import com.xuqm.sdk.sample.data.api.ChangePasswordRequest
|
||||||
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
import com.xuqm.sdk.sample.data.api.DEMO_APP_ID
|
||||||
@ -76,12 +77,13 @@ class AuthRepository(context: Context) {
|
|||||||
val res = api.login(LoginRequest(DEMO_APP_ID, userId, password))
|
val res = api.login(LoginRequest(DEMO_APP_ID, userId, password))
|
||||||
val data = requireNotNull(res.data) { res.message ?: "Login failed" }
|
val data = requireNotNull(res.data) { res.message ?: "Login failed" }
|
||||||
saveSession(data)
|
saveSession(data)
|
||||||
val userSig = requireNotNull(data.imToken.takeIf { it.isNotBlank() }) {
|
XuqmSDK.setUserInfo(
|
||||||
"Login succeeded but IM credential is missing"
|
XuqmUserInfo(
|
||||||
}
|
userId = data.profile.userId,
|
||||||
XuqmSDK.login(
|
userSig = data.imToken.takeIf { it.isNotBlank() },
|
||||||
userId = data.profile.userId,
|
name = data.profile.nickname,
|
||||||
userSig = userSig,
|
avatar = data.profile.avatar,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
|
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
|
||||||
}
|
}
|
||||||
@ -94,12 +96,13 @@ class AuthRepository(context: Context) {
|
|||||||
val res = api.register(RegisterRequest(DEMO_APP_ID, userId, password, nickname))
|
val res = api.register(RegisterRequest(DEMO_APP_ID, userId, password, nickname))
|
||||||
val data = requireNotNull(res.data) { res.message ?: "Register failed" }
|
val data = requireNotNull(res.data) { res.message ?: "Register failed" }
|
||||||
saveSession(data)
|
saveSession(data)
|
||||||
val userSig = requireNotNull(data.imToken.takeIf { it.isNotBlank() }) {
|
XuqmSDK.setUserInfo(
|
||||||
"Register succeeded but IM credential is missing"
|
XuqmUserInfo(
|
||||||
}
|
userId = data.profile.userId,
|
||||||
XuqmSDK.login(
|
userSig = data.imToken.takeIf { it.isNotBlank() },
|
||||||
userId = data.profile.userId,
|
name = data.profile.nickname,
|
||||||
userSig = userSig,
|
avatar = data.profile.avatar,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
|
UserData(data.profile.userId, data.profile.nickname, data.profile.avatar, data.profile.gender)
|
||||||
}
|
}
|
||||||
@ -145,16 +148,20 @@ class AuthRepository(context: Context) {
|
|||||||
logout()
|
logout()
|
||||||
throw IllegalStateException("No cached session")
|
throw IllegalStateException("No cached session")
|
||||||
}
|
}
|
||||||
XuqmSDK.login(
|
XuqmSDK.setUserInfo(
|
||||||
userId = userId,
|
XuqmUserInfo(
|
||||||
userSig = userSig,
|
userId = userId,
|
||||||
|
userSig = userSig,
|
||||||
|
name = getCurrentNickname(),
|
||||||
|
avatar = getCurrentAvatar(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
XuqmSDK.logout()
|
XuqmSDK.setUserInfo(null)
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -207,7 +207,7 @@ fun EnvironmentScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PushRegistrationSection() {
|
private fun PushRegistrationSection() {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val currentUserId = XuqmSDK.currentLoginSession?.userId ?: AppDependencies.authRepository.getCurrentUserId()
|
val currentUserId = XuqmSDK.getUserId() ?: AppDependencies.authRepository.getCurrentUserId()
|
||||||
var statusMessage by remember { mutableStateOf<String?>(null) }
|
var statusMessage by remember { mutableStateOf<String?>(null) }
|
||||||
var snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) }
|
var snapshot by remember { mutableStateOf<PushRegistrationSnapshot?>(null) }
|
||||||
|
|
||||||
@ -254,7 +254,7 @@ private fun PushRegistrationSection() {
|
|||||||
Switch(
|
Switch(
|
||||||
checked = snapshot?.receivePush ?: true,
|
checked = snapshot?.receivePush ?: true,
|
||||||
onCheckedChange = { enabled ->
|
onCheckedChange = { enabled ->
|
||||||
PushSDK.setReceivePush(context, currentUserId, enabled)
|
PushSDK.setOfflinePushEnabled(enabled)
|
||||||
snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot
|
snapshot = snapshot?.copy(receivePush = enabled) ?: snapshot
|
||||||
statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送"
|
statusMessage = if (enabled) "已允许接收推送" else "已关闭接收推送"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -147,9 +147,9 @@ fun MainScreen(
|
|||||||
if (update.downloadUrl.isNotBlank() && !isDownloading) {
|
if (update.downloadUrl.isNotBlank() && !isDownloading) {
|
||||||
isDownloading = true
|
isDownloading = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
UpdateSDK.downloadAndInstall(context, update.downloadUrl) { progress ->
|
UpdateSDK.downloadAndInstallApk(context, update.downloadUrl, onProgress = { progress ->
|
||||||
downloadProgress = progress
|
downloadProgress = (progress * 100).toInt()
|
||||||
}
|
})
|
||||||
isDownloading = false
|
isDownloading = false
|
||||||
pendingUpdate = null
|
pendingUpdate = null
|
||||||
downloadProgress = -1
|
downloadProgress = -1
|
||||||
|
|||||||
@ -56,9 +56,9 @@ class UpdateViewModel : ViewModel() {
|
|||||||
fun downloadAndInstall(context: Context, downloadUrl: String) {
|
fun downloadAndInstall(context: Context, downloadUrl: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(downloadProgress = 0)
|
_state.value = _state.value.copy(downloadProgress = 0)
|
||||||
UpdateSDK.downloadAndInstall(context, downloadUrl) { progress ->
|
UpdateSDK.downloadAndInstallApk(context, downloadUrl, onProgress = { progress ->
|
||||||
_state.value = _state.value.copy(downloadProgress = progress)
|
_state.value = _state.value.copy(downloadProgress = (progress * 100).toInt())
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.xuqm.sdk
|
package com.xuqm.sdk
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import com.xuqm.sdk.auth.TokenStore
|
import com.xuqm.sdk.auth.TokenStore
|
||||||
import com.xuqm.sdk.core.LogLevel
|
import com.xuqm.sdk.core.LogLevel
|
||||||
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
import com.xuqm.sdk.core.ServiceEndpointRegistry
|
||||||
@ -8,11 +9,22 @@ import com.xuqm.sdk.core.ServiceEndpoints
|
|||||||
import com.xuqm.sdk.core.SDKConfig
|
import com.xuqm.sdk.core.SDKConfig
|
||||||
import com.xuqm.sdk.internal.ConfigFileReader
|
import com.xuqm.sdk.internal.ConfigFileReader
|
||||||
import com.xuqm.sdk.network.ApiClient
|
import com.xuqm.sdk.network.ApiClient
|
||||||
|
import com.xuqm.sdk.network.SdkPlatformConfig
|
||||||
|
import com.xuqm.sdk.network.SdkPlatformConfigApi
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
object XuqmSDK {
|
object XuqmSDK {
|
||||||
|
|
||||||
|
private const val TAG = "XuqmSDK"
|
||||||
|
|
||||||
|
/** 内置默认公有平台地址。使用私有化部署时必须显式传入 platformUrl,不会自动降级到此地址。 */
|
||||||
|
const val DEFAULT_PLATFORM_URL = "https://www.51szyx.com/"
|
||||||
|
|
||||||
lateinit var config: SDKConfig
|
lateinit var config: SDKConfig
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@ -22,29 +34,33 @@ object XuqmSDK {
|
|||||||
lateinit var tokenStore: TokenStore
|
lateinit var tokenStore: TokenStore
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private var initialized = false
|
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@Volatile
|
|
||||||
private var initializedAppKey: String? = null
|
|
||||||
@Volatile
|
|
||||||
private var loginSession: XuqmLoginSession? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var userInfoValue: XuqmUserInfo? = null
|
|
||||||
|
|
||||||
private val initLock = Any()
|
private val initLock = Any()
|
||||||
|
|
||||||
|
private var initialized = false
|
||||||
|
@Volatile private var initializedAppKey: String? = null
|
||||||
|
@Volatile private var resolvedPlatformUrl: String = DEFAULT_PLATFORM_URL
|
||||||
|
@Volatile private var loginSession: XuqmLoginSession? = null
|
||||||
|
@Volatile private var userInfoValue: XuqmUserInfo? = null
|
||||||
|
@Volatile var platformConfig: SdkPlatformConfig? = null
|
||||||
|
private set
|
||||||
|
|
||||||
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
private val pendingInitCallbacks = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the SDK automatically from the init config file embedded in assets/xuqm/.
|
* 当前远程配置拉取任务。失败时 completeExceptionally,[awaitInitialization] 会重新抛出。
|
||||||
* Place the config.xuqm file downloaded from the tenant platform into your app's
|
* 调用 [retryInitialization] 时替换为新的 Deferred。
|
||||||
* src/main/assets/xuqm/ directory — no hardcoded appKey or serverUrl needed.
|
*/
|
||||||
|
@Volatile private var remoteConfigDeferred: CompletableDeferred<Unit>? = null
|
||||||
|
|
||||||
|
// ── 初始化 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方式 A:配置文件自动初始化(推荐)。
|
||||||
*
|
*
|
||||||
* For private deployments the config file contains the server URL and all service
|
* 将 config.xuqm 放入 `src/main/assets/xuqm/`,SDK 在模块加载时自动读取并初始化。
|
||||||
* endpoints are configured automatically. For public deployments the default endpoints
|
* 配置文件包含 `appKey` 和可选的 `serverUrl`(私有化部署平台地址)。
|
||||||
* (dev.xuqinmin.com) are used.
|
* App 无需调用任何初始化代码。
|
||||||
*
|
|
||||||
* The config file's packageName/bundleId is validated against the local app package name.
|
|
||||||
* If they don't match, an exception is thrown.
|
|
||||||
*/
|
*/
|
||||||
fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) {
|
fun autoInitialize(context: Context, logLevel: LogLevel = LogLevel.WARN) {
|
||||||
val configFile = ConfigFileReader.read(context)
|
val configFile = ConfigFileReader.read(context)
|
||||||
@ -52,38 +68,41 @@ object XuqmSDK {
|
|||||||
"No config file found in assets/xuqm/. " +
|
"No config file found in assets/xuqm/. " +
|
||||||
"Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
|
"Download config.xuqm from the tenant platform and place it in src/main/assets/xuqm/."
|
||||||
)
|
)
|
||||||
val appKey = configFile.appKey
|
|
||||||
val serverUrl = configFile.serverUrl
|
|
||||||
val configPackageName = configFile.packageName
|
val configPackageName = configFile.packageName
|
||||||
|
if (!configPackageName.isNullOrBlank() && configPackageName != context.packageName) {
|
||||||
val localPackageName = context.packageName
|
|
||||||
if (!configPackageName.isNullOrBlank() && configPackageName != localPackageName) {
|
|
||||||
throw IllegalStateException(
|
throw IllegalStateException(
|
||||||
"Config package name mismatch: config=$configPackageName, local=$localPackageName. " +
|
"Config package name mismatch: config=$configPackageName, local=${context.packageName}. " +
|
||||||
"Please download the correct config file for this app."
|
"Please download the correct config file for this app."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
initialize(context, configFile.appKey, configFile.serverUrl, logLevel)
|
||||||
initialize(context, appKey, serverUrl, logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual initialization without license file.
|
* 方式 B:手动初始化。
|
||||||
* The SDK will validate the appKey against the server by calling /api/sdk/config
|
*
|
||||||
* with the local package name. If the package name does not match the registered one,
|
* 调用后立即完成本地初始化,并在后台异步拉取平台服务配置。
|
||||||
* an exception is thrown.
|
* 使用子 SDK 前建议先 `await XuqmSDK.awaitInitialization()` 确保配置已就绪。
|
||||||
|
*
|
||||||
|
* @param platformUrl 平台地址(可选)。
|
||||||
|
* - 不传:使用内置公有平台地址([DEFAULT_PLATFORM_URL])。
|
||||||
|
* - 传入:使用指定私有化部署平台地址。
|
||||||
|
* **私有化与公有平台互相独立,不允许在两者之间自动降级。**
|
||||||
|
* @throws IllegalStateException 若已使用不同 appKey 初始化过,或 appKey 为空。
|
||||||
*/
|
*/
|
||||||
fun initialize(
|
fun initialize(
|
||||||
context: Context,
|
context: Context,
|
||||||
appKey: String,
|
appKey: String,
|
||||||
serverUrl: String? = null,
|
platformUrl: String? = null,
|
||||||
logLevel: LogLevel = LogLevel.WARN,
|
logLevel: LogLevel = LogLevel.WARN,
|
||||||
) {
|
) {
|
||||||
|
require(appKey.isNotBlank()) { "appKey must not be blank" }
|
||||||
val applicationContext = context.applicationContext
|
val applicationContext = context.applicationContext
|
||||||
|
val deferred: CompletableDeferred<Unit>
|
||||||
synchronized(initLock) {
|
synchronized(initLock) {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
check(initializedAppKey == appKey) {
|
check(initializedAppKey == appKey) {
|
||||||
"XuqmSDK already initialized with appKey=$initializedAppKey"
|
"XuqmSDK already initialized with appKey=$initializedAppKey; cannot reinitialize with appKey=$appKey"
|
||||||
}
|
}
|
||||||
appContext = applicationContext
|
appContext = applicationContext
|
||||||
return
|
return
|
||||||
@ -94,44 +113,118 @@ object XuqmSDK {
|
|||||||
ApiClient.init(config, tokenStore)
|
ApiClient.init(config, tokenStore)
|
||||||
initializedAppKey = appKey
|
initializedAppKey = appKey
|
||||||
initialized = true
|
initialized = true
|
||||||
|
resolvedPlatformUrl = platformUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_PLATFORM_URL
|
||||||
|
deferred = CompletableDeferred()
|
||||||
|
remoteConfigDeferred = deferred
|
||||||
pendingInitCallbacks.forEach { runCatching(it) }
|
pendingInitCallbacks.forEach { runCatching(it) }
|
||||||
pendingInitCallbacks.clear()
|
pendingInitCallbacks.clear()
|
||||||
}
|
}
|
||||||
serverUrl?.takeIf { it.isNotBlank() }?.let { configurePrivateServer(context, appKey, it) }
|
launchRemoteConfigFetch(resolvedPlatformUrl, appKey, deferred)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the given block after initialization is complete.
|
* 等待异步平台配置拉取完成。
|
||||||
* If already initialized, executes immediately.
|
*
|
||||||
* If not initialized, waits until initialization completes.
|
* 若配置拉取失败(网络错误、appKey 无效等),此方法将抛出异常。
|
||||||
|
* 可调用 [retryInitialization] 重试后再次 await。
|
||||||
|
*
|
||||||
|
* @throws Exception 平台配置拉取失败时抛出
|
||||||
*/
|
*/
|
||||||
fun afterInit(block: () -> Unit) {
|
suspend fun awaitInitialization() {
|
||||||
|
remoteConfigDeferred?.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试平台配置拉取(例如首次因网络问题失败后,用户点击重试时调用)。
|
||||||
|
*
|
||||||
|
* 只能在 [initialize] 已调用后使用。不会重置 appKey 或 platformUrl。
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException 若 SDK 尚未调用 [initialize]
|
||||||
|
* @throws Exception 重试仍失败时抛出
|
||||||
|
*/
|
||||||
|
suspend fun retryInitialization() {
|
||||||
|
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
|
||||||
|
val deferred = CompletableDeferred<Unit>()
|
||||||
synchronized(initLock) {
|
synchronized(initLock) {
|
||||||
if (initialized) {
|
remoteConfigDeferred = deferred
|
||||||
block()
|
}
|
||||||
} else {
|
runCatching {
|
||||||
pendingInitCallbacks.add(block)
|
fetchAndApplyPlatformConfig(resolvedPlatformUrl, appKey)
|
||||||
|
deferred.complete(Unit)
|
||||||
|
}.onFailure { e ->
|
||||||
|
deferred.completeExceptionally(
|
||||||
|
platformInitException(resolvedPlatformUrl, appKey, e)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchRemoteConfigFetch(platformUrl: String, appKey: String, deferred: CompletableDeferred<Unit>) {
|
||||||
|
sdkScope.launch {
|
||||||
|
runCatching {
|
||||||
|
fetchAndApplyPlatformConfig(platformUrl, appKey)
|
||||||
|
deferred.complete(Unit)
|
||||||
|
}.onFailure { e ->
|
||||||
|
// 不降级,直接将错误传递给 awaitInitialization() 的调用者
|
||||||
|
deferred.completeExceptionally(platformInitException(platformUrl, appKey, e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun configurePrivateServer(context: Context, appKey: String, serverUrl: String) {
|
private suspend fun fetchAndApplyPlatformConfig(platformUrl: String, appKey: String) {
|
||||||
val base = serverUrl.trimEnd('/') + "/"
|
val base = platformUrl.trimEnd('/') + "/"
|
||||||
val wsBase = serverUrl.trimEnd('/')
|
val api = ApiClient.create(SdkPlatformConfigApi::class.java, base)
|
||||||
|
val response = api.fetchConfig(appKey)
|
||||||
|
val cfg = response.data
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"Platform returned empty config for appKey=$appKey at $platformUrl. " +
|
||||||
|
"Verify the appKey is registered on this platform."
|
||||||
|
)
|
||||||
|
platformConfig = cfg
|
||||||
|
|
||||||
|
// 各服务地址优先使用平台下发的值,回退到同一平台的 base URL(绝不跨平台)
|
||||||
|
val wsBase = platformUrl.trimEnd('/')
|
||||||
.replace("https://", "wss://")
|
.replace("https://", "wss://")
|
||||||
.replace("http://", "ws://")
|
.replace("http://", "ws://")
|
||||||
configureServiceEndpoints(
|
val apiBase = cfg.apiUrl?.trimEnd('/')?.plus("/") ?: base
|
||||||
|
ServiceEndpointRegistry.configure(
|
||||||
ServiceEndpoints(
|
ServiceEndpoints(
|
||||||
controlBaseUrl = base,
|
controlBaseUrl = apiBase,
|
||||||
fileBaseUrl = base,
|
imApiBaseUrl = apiBase,
|
||||||
imApiBaseUrl = base,
|
pushBaseUrl = apiBase,
|
||||||
imWsUrl = "$wsBase/ws/im",
|
updateBaseUrl = apiBase,
|
||||||
pushBaseUrl = base,
|
imWsUrl = cfg.imWsUrl ?: "$wsBase/ws/im",
|
||||||
updateBaseUrl = base,
|
fileBaseUrl = cfg.fileServiceUrl?.trimEnd('/')?.plus("/") ?: base,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Platform config applied [${if (platformUrl == DEFAULT_PLATFORM_URL) "public" else "private"}]:" +
|
||||||
|
" apiBase=$apiBase imWsUrl=${cfg.imWsUrl}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun platformInitException(platformUrl: String, appKey: String, cause: Throwable): Throwable {
|
||||||
|
val kind = if (platformUrl == DEFAULT_PLATFORM_URL) "公有平台" else "私有化平台 $platformUrl"
|
||||||
|
return IllegalStateException(
|
||||||
|
"XuqmSDK 初始化失败:无法从$kind获取服务配置(appKey=$appKey)。" +
|
||||||
|
"私有化与公有平台互相独立,不自动降级。原因:${cause.message}",
|
||||||
|
cause
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 初始化后工具方法 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun afterInit(block: () -> Unit) {
|
||||||
|
synchronized(initLock) {
|
||||||
|
if (initialized) block() else pendingInitCallbacks.add(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动覆盖服务端点(用于本地联调)。
|
||||||
|
* 注意:若 [awaitInitialization] 尚未完成,此调用的结果会被远程配置覆盖。
|
||||||
|
*/
|
||||||
fun configureServiceEndpoints(endpoints: ServiceEndpoints) {
|
fun configureServiceEndpoints(endpoints: ServiceEndpoints) {
|
||||||
ServiceEndpointRegistry.configure(endpoints)
|
ServiceEndpointRegistry.configure(endpoints)
|
||||||
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
|
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
|
||||||
@ -152,59 +245,82 @@ object XuqmSDK {
|
|||||||
|
|
||||||
fun isInitialized(): Boolean = synchronized(initLock) { initialized }
|
fun isInitialized(): Boolean = synchronized(initLock) { initialized }
|
||||||
|
|
||||||
|
// ── 公开属性与方法 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
val appKey: String get() = config.appKey
|
val appKey: String get() = config.appKey
|
||||||
|
|
||||||
|
/** 当前平台类型:"public" 或 "private:<url>" */
|
||||||
|
val platformType: String
|
||||||
|
get() = if (resolvedPlatformUrl == DEFAULT_PLATFORM_URL) "public" else "private:$resolvedPlatformUrl"
|
||||||
|
|
||||||
val currentLoginSession: XuqmLoginSession?
|
val currentLoginSession: XuqmLoginSession?
|
||||||
get() = loginSession
|
get() = loginSession
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前通过 [setUserInfo] 设置的用户信息。
|
|
||||||
* 优先级高于 [currentLoginSession],适用于不使用 XuqmSDK 登录体系的集成场景。
|
|
||||||
*/
|
|
||||||
val userInfo: XuqmUserInfo? get() = userInfoValue
|
val userInfo: XuqmUserInfo? get() = userInfoValue
|
||||||
|
|
||||||
|
fun getUserId(): String? = userInfoValue?.userId
|
||||||
|
|
||||||
|
fun getUserInfo(): XuqmUserInfo? = userInfoValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置用户信息,供 update / push / license 等服务使用(灰度发布、精准推送等)。
|
* 设置用户信息 — 所有子 SDK 的统一认证入口,登录后调用一次即可。
|
||||||
* IM 服务需要独立调用 [login] 完成鉴权,[setUserInfo] 对 IM socket 连接无效。
|
|
||||||
*
|
*
|
||||||
* @param id 用户唯一标识,传 null 或空字符串则清除用户信息
|
* 内部自动触发:
|
||||||
* @param name 用户显示名称(可选)
|
* - **PushSDK**:检测厂商 → 获取厂商配置 → 注册设备 → 上报 token
|
||||||
* @param avatar 用户头像 URL(可选)
|
* - **ImSDK**:若 [XuqmUserInfo.userSig] 存在且平台开通了 IM,自动登录
|
||||||
|
* - **UpdateSDK**:更新 userId(用于灰度/定向更新)
|
||||||
|
* - **LicenseSDK**:更新用户上下文
|
||||||
|
*
|
||||||
|
* 登出时传 `null`,触发所有子 SDK 登出:
|
||||||
|
* ```kotlin
|
||||||
|
* XuqmSDK.setUserInfo(null)
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
fun setUserInfo(id: String?, name: String? = null, avatar: String? = null) {
|
fun setUserInfo(info: XuqmUserInfo?) {
|
||||||
userInfoValue = id?.takeIf { it.isNotBlank() }?.let {
|
if (info == null) {
|
||||||
XuqmUserInfo(id = it, name = name, avatar = avatar)
|
val hadSession = loginSession != null
|
||||||
|
userInfoValue = null
|
||||||
|
loginSession = null
|
||||||
|
tokenStore.clear()
|
||||||
|
if (hadSession) {
|
||||||
|
notifyOptionalModulesSync("onSdkLogout")
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
userInfoValue = info
|
||||||
|
val session = XuqmLoginSession(
|
||||||
|
appKey = appKey,
|
||||||
|
userId = info.userId,
|
||||||
|
userSig = info.userSig ?: loginSession?.userSig ?: "",
|
||||||
|
)
|
||||||
|
loginSession = session
|
||||||
|
info.userSig?.takeIf { it.isNotBlank() }?.let { tokenStore.saveToken(it) }
|
||||||
|
notifyOptionalModules("onSdkLogin", session)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(
|
@Deprecated(
|
||||||
userId: String,
|
"Use setUserInfo(XuqmUserInfo) instead — it unifies authentication for all sub-SDKs.",
|
||||||
userSig: String,
|
ReplaceWith("setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))")
|
||||||
): XuqmLoginSession = withContext(Dispatchers.IO) {
|
)
|
||||||
|
suspend fun login(userId: String, userSig: String): XuqmLoginSession = withContext(Dispatchers.IO) {
|
||||||
requireInit()
|
requireInit()
|
||||||
loginSession?.takeIf {
|
loginSession?.takeIf {
|
||||||
it.appKey == appKey && it.userId == userId && it.userSig == userSig
|
it.appKey == appKey && it.userId == userId && it.userSig == userSig
|
||||||
}?.let { return@withContext it }
|
}?.let { return@withContext it }
|
||||||
val session = XuqmLoginSession(
|
setUserInfo(XuqmUserInfo(userId = userId, userSig = userSig))
|
||||||
appKey = appKey,
|
loginSession!!
|
||||||
userId = userId,
|
|
||||||
userSig = userSig,
|
|
||||||
)
|
|
||||||
loginSession = session
|
|
||||||
tokenStore.saveToken(userSig)
|
|
||||||
notifyOptionalModules("onSdkLogin", session)
|
|
||||||
session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated(
|
||||||
|
"Use setUserInfo(null) instead.",
|
||||||
|
ReplaceWith("setUserInfo(null)")
|
||||||
|
)
|
||||||
fun logout() {
|
fun logout() {
|
||||||
val session = loginSession
|
setUserInfo(null)
|
||||||
loginSession = null
|
|
||||||
tokenStore.clear()
|
|
||||||
if (session != null) {
|
|
||||||
notifyOptionalModulesSync("onSdkLogout")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 内部子 SDK 通知(反射)──────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun notifyOptionalModules(methodName: String, session: XuqmLoginSession) {
|
private fun notifyOptionalModules(methodName: String, session: XuqmLoginSession) {
|
||||||
listOf(
|
listOf(
|
||||||
"com.xuqm.sdk.im.ImSDK",
|
"com.xuqm.sdk.im.ImSDK",
|
||||||
|
|||||||
@ -2,9 +2,13 @@ package com.xuqm.sdk
|
|||||||
|
|
||||||
data class XuqmUserInfo(
|
data class XuqmUserInfo(
|
||||||
/** 用户唯一标识(必填),用于灰度发布、精准推送等 */
|
/** 用户唯一标识(必填),用于灰度发布、精准推送等 */
|
||||||
val id: String,
|
val userId: String,
|
||||||
|
/** 登录凭证(可选) */
|
||||||
|
val userSig: String? = null,
|
||||||
/** 用户显示名称(可选) */
|
/** 用户显示名称(可选) */
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
/** 用户头像 URL(可选) */
|
/** 手机号(可选) */
|
||||||
|
val phone: String? = null,
|
||||||
|
/** 头像 URL(可选) */
|
||||||
val avatar: String? = null,
|
val avatar: String? = null,
|
||||||
)
|
)
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.xuqm.sdk.network
|
||||||
|
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
internal interface SdkPlatformConfigApi {
|
||||||
|
@GET("api/sdk/config")
|
||||||
|
suspend fun fetchConfig(
|
||||||
|
@Query("appKey") appKey: String,
|
||||||
|
@Query("platform") platform: String = "ANDROID",
|
||||||
|
): SdkPlatformConfigResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class SdkPlatformConfigResponse(val data: SdkPlatformConfig? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台服务地址配置,由 [XuqmSDK.initialize] 从租户平台拉取。
|
||||||
|
* 仅包含各服务的 URL;各子 SDK 在初始化完成后自行请求自己的业务配置。
|
||||||
|
*
|
||||||
|
* 若 [apiUrl] 为 null,所有 HTTP 服务地址均从初始化时传入的 platformUrl 推导(同一系统内,不跨平台)。
|
||||||
|
*/
|
||||||
|
data class SdkPlatformConfig(
|
||||||
|
/** 通用 API 地址(REST 接口、Push、Update 等共用) */
|
||||||
|
val apiUrl: String? = null,
|
||||||
|
/** IM WebSocket 地址(未开通 IM 时为 null) */
|
||||||
|
val imWsUrl: String? = null,
|
||||||
|
/** 文件服务地址 */
|
||||||
|
val fileServiceUrl: String? = null,
|
||||||
|
)
|
||||||
@ -121,6 +121,18 @@ object ImSDK {
|
|||||||
connectWithToken(userSig)
|
connectWithToken(userSig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 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(
|
fun sendMessage(
|
||||||
toId: String,
|
toId: String,
|
||||||
chatType: String,
|
chatType: String,
|
||||||
@ -807,6 +819,7 @@ object ImSDK {
|
|||||||
|
|
||||||
fun onSdkLogin(session: XuqmLoginSession) {
|
fun onSdkLogin(session: XuqmLoginSession) {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
|
if (session.userSig.isBlank()) return // IM 未提供 userSig,跳过连接(Push/Update 仍生效)
|
||||||
if (currentUserId == session.userId && currentUserSig == session.userSig) return
|
if (currentUserId == session.userId && currentUserSig == session.userSig) return
|
||||||
currentUserId = session.userId
|
currentUserId = session.userId
|
||||||
currentUserSig = session.userSig
|
currentUserSig = session.userSig
|
||||||
|
|||||||
@ -30,14 +30,16 @@ object LicenseSDK {
|
|||||||
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional manual initialization. Typically not needed — call checkLicense() directly
|
* 可选的手动初始化。通常不需要调用 — 直接调用 [checkLicense] 即可,
|
||||||
* after XuqmSDK is initialized (via config file or manual init).
|
* LicenseSDK 会在内部等待 XuqmSDK 初始化完成后自动获取配置。
|
||||||
*
|
*
|
||||||
* @param context Application context
|
* @deprecated LicenseSDK 现在依赖 XuqmSDK 自动初始化,无需单独调用。
|
||||||
* @param appKey The company appKey (e.g., "f713d051-0fbe-4f2d-bec9-bf7b96fc9ce4")
|
* 直接调用 [checkLicense] 或 [getStatus] 即可。
|
||||||
* @param deviceName Optional device name for identification
|
|
||||||
* @param baseUrl Optional custom license server base URL. Defaults to https://auth.dev.xuqinmin.com/
|
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"LicenseSDK auto-initializes from XuqmSDK. Call checkLicense() directly.",
|
||||||
|
ReplaceWith("checkLicense()")
|
||||||
|
)
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
fun initialize(
|
fun initialize(
|
||||||
|
|||||||
@ -35,10 +35,95 @@ object PushSDK {
|
|||||||
private val registeredUserId = AtomicReference<String?>(null)
|
private val registeredUserId = AtomicReference<String?>(null)
|
||||||
private val registeringDeviceKey = AtomicReference<String?>(null)
|
private val registeringDeviceKey = AtomicReference<String?>(null)
|
||||||
private val lastRegisteredDeviceKey = AtomicReference<String?>(null)
|
private val lastRegisteredDeviceKey = AtomicReference<String?>(null)
|
||||||
@Volatile
|
@Volatile private var cachedVendorConfig: PushVendorConfig? = null
|
||||||
private var cachedVendorConfig: PushVendorConfig? = null
|
@Volatile private var cachedConfigAt: Long = 0L
|
||||||
@Volatile
|
|
||||||
private var cachedConfigAt: Long = 0L
|
// ── Public API (spec-aligned) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置离线推送开关。
|
||||||
|
* 关闭后用户不会收到离线推送,但在线消息仍正常接收。
|
||||||
|
*/
|
||||||
|
fun setOfflinePushEnabled(enabled: Boolean) {
|
||||||
|
XuqmSDK.requireInit()
|
||||||
|
val context = XuqmSDK.appContext
|
||||||
|
store(context).setReceivePush(enabled)
|
||||||
|
val userId = XuqmSDK.getUserId() ?: registeredUserId.get() ?: return
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
api.setReceivePush(
|
||||||
|
appKey = XuqmSDK.appKey,
|
||||||
|
userId = userId,
|
||||||
|
deviceId = DeviceUtils.getDeviceId(context),
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
if (enabled) {
|
||||||
|
bindImUserInternal(context, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置免打扰时间段(24 小时制,格式 "HH:mm")。
|
||||||
|
* 示例:PushSDK.setQuietHours("22:00", "08:00")
|
||||||
|
*/
|
||||||
|
fun setQuietHours(start: String, end: String) {
|
||||||
|
XuqmSDK.requireInit()
|
||||||
|
store(XuqmSDK.appContext).setQuietHours(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除免打扰设置。 */
|
||||||
|
fun clearQuietHours() {
|
||||||
|
XuqmSDK.requireInit()
|
||||||
|
store(XuqmSDK.appContext).clearQuietHours()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出推送(解绑当前设备 token)。
|
||||||
|
* 通常不需要手动调用,[XuqmSDK.setUserInfo(null)] 会自动触发。
|
||||||
|
*/
|
||||||
|
fun logout() {
|
||||||
|
onSdkLogout()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回通知渠道 ID,可用于自定义通知点击行为。 */
|
||||||
|
fun notificationChannelIdFor(context: Context, routeType: String): String? =
|
||||||
|
PushNotificationChannelManager.channelIdFor(context.applicationContext, routeType)
|
||||||
|
|
||||||
|
// ── Internal hooks called by XuqmSDK via reflection ───────────────────────
|
||||||
|
|
||||||
|
fun onSdkLogin(session: XuqmLoginSession) {
|
||||||
|
val context = runCatching { XuqmSDK.appContext }.getOrNull() ?: return
|
||||||
|
if (registeredUserId.get() == session.userId) return
|
||||||
|
initializeVendorsInternal(context)
|
||||||
|
bindImUserInternal(context, session.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSdkLogout() {
|
||||||
|
val userId = registeredUserId.getAndSet(null) ?: return
|
||||||
|
lastRegisteredDeviceKey.set(null)
|
||||||
|
registeringDeviceKey.set(null)
|
||||||
|
unregisterDeviceInternal(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal push registration flow ───────────────────────────────────────
|
||||||
|
|
||||||
|
internal fun updateNativePushToken(context: Context, vendor: PushVendor, pushToken: String) {
|
||||||
|
XuqmSDK.requireInit()
|
||||||
|
val detectedVendor = detectVendor()
|
||||||
|
if (vendor != detectedVendor) {
|
||||||
|
Log.i("XuqmPushSDK", "Ignoring ${vendor.name} push token on ${detectedVendor.name} device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val normalizedToken = pushToken.trim()
|
||||||
|
require(normalizedToken.isNotBlank()) { "pushToken must not be blank" }
|
||||||
|
store(context).save(vendor, normalizedToken)
|
||||||
|
val sessionUserId = XuqmSDK.getUserId()
|
||||||
|
if (sessionUserId != null) {
|
||||||
|
bindImUserInternal(context, sessionUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun currentRegistration(context: Context): PushRegistrationSnapshot? {
|
fun currentRegistration(context: Context): PushRegistrationSnapshot? {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
@ -47,89 +132,60 @@ object PushSDK {
|
|||||||
val registration = store(context).load(deviceId = deviceId, fallbackVendor = detectedVendor)
|
val registration = store(context).load(deviceId = deviceId, fallbackVendor = detectedVendor)
|
||||||
?: return null
|
?: return null
|
||||||
if (registration.vendor != detectedVendor) {
|
if (registration.vendor != detectedVendor) {
|
||||||
Log.i(
|
Log.i("XuqmPushSDK", "Clearing cached ${registration.vendor.name} push token on ${detectedVendor.name} device")
|
||||||
"XuqmPushSDK",
|
|
||||||
"Clearing cached ${registration.vendor.name} push token on ${detectedVendor.name} device",
|
|
||||||
)
|
|
||||||
store(context).clearToken()
|
store(context).clearToken()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return registration
|
return registration
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notificationChannelIdFor(context: Context, routeType: String): String? =
|
fun detectVendor(): PushVendor {
|
||||||
PushNotificationChannelManager.channelIdFor(context.applicationContext, routeType)
|
return when (Build.MANUFACTURER.uppercase()) {
|
||||||
|
"HUAWEI" -> PushVendor.HUAWEI
|
||||||
|
"XIAOMI" -> PushVendor.XIAOMI
|
||||||
|
"REDMI", "POCO" -> PushVendor.XIAOMI
|
||||||
|
"OPPO" -> PushVendor.OPPO
|
||||||
|
"REALME", "ONEPLUS" -> PushVendor.OPPO
|
||||||
|
"VIVO" -> PushVendor.VIVO
|
||||||
|
"IQOO" -> PushVendor.VIVO
|
||||||
|
"HONOR" -> PushVendor.HONOR
|
||||||
|
else -> PushVendor.FCM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateNativePushToken(
|
private val vendorServices: List<PushVendorInterface> = listOf(
|
||||||
context: Context,
|
HuaweiPushService(),
|
||||||
vendor: PushVendor,
|
XiaomiPushService(),
|
||||||
pushToken: String,
|
OppoPushService(),
|
||||||
) {
|
VivoPushService(),
|
||||||
XuqmSDK.requireInit()
|
HonorPushService(),
|
||||||
|
FcmPushService(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun initializeVendorsInternal(context: Context) {
|
||||||
val detectedVendor = detectVendor()
|
val detectedVendor = detectVendor()
|
||||||
if (vendor != detectedVendor) {
|
Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
|
||||||
Log.i(
|
scope.launch {
|
||||||
"XuqmPushSDK",
|
val config = loadVendorConfig()
|
||||||
"Ignoring ${vendor.name} push token on ${detectedVendor.name} device",
|
vendorServices.forEach { service ->
|
||||||
)
|
if (service.vendor == detectedVendor && service.isAvailable(context)) {
|
||||||
return
|
Log.i("XuqmPushSDK", "Initializing push vendor: ${service.vendor.name}")
|
||||||
}
|
service.register(context, config)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (detectedVendor == PushVendor.FCM) {
|
||||||
|
ensureNativePushTokenInternal(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerDevice(
|
private fun bindImUserInternal(context: Context, userId: String) {
|
||||||
context: Context,
|
if (!isReceivePushEnabled(context)) return
|
||||||
userId: String,
|
ensureNativePushTokenInternal(context)
|
||||||
) {
|
registerDeviceInternal(context, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerDeviceInternal(context: Context, userId: String) {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
val registration = currentRegistration(context)
|
val registration = currentRegistration(context)
|
||||||
val vendor = registration?.vendor ?: detectVendor()
|
val vendor = registration?.vendor ?: detectVendor()
|
||||||
@ -137,10 +193,9 @@ object PushSDK {
|
|||||||
val deviceId = DeviceUtils.getDeviceId(context)
|
val deviceId = DeviceUtils.getDeviceId(context)
|
||||||
if (pushToken.isNullOrBlank()) {
|
if (pushToken.isNullOrBlank()) {
|
||||||
Log.w("XuqmPushSDK", "Native push token not ready yet, waiting for onNewToken()")
|
Log.w("XuqmPushSDK", "Native push token not ready yet, waiting for onNewToken()")
|
||||||
ensureNativePushToken(context)
|
ensureNativePushTokenInternal(context)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.e(">>>>>>>>>>>>>>>>", pushToken)
|
|
||||||
val registrationKey = listOf(userId, vendor.name, pushToken, deviceId).joinToString("|")
|
val registrationKey = listOf(userId, vendor.name, pushToken, deviceId).joinToString("|")
|
||||||
if (lastRegisteredDeviceKey.get() == registrationKey) {
|
if (lastRegisteredDeviceKey.get() == registrationKey) {
|
||||||
Log.d("XuqmPushSDK", "Skipping duplicate push device registration for userId=$userId vendor=${vendor.name}")
|
Log.d("XuqmPushSDK", "Skipping duplicate push device registration for userId=$userId vendor=${vendor.name}")
|
||||||
@ -168,17 +223,14 @@ object PushSDK {
|
|||||||
registeredUserId.set(userId)
|
registeredUserId.set(userId)
|
||||||
lastRegisteredDeviceKey.set(registrationKey)
|
lastRegisteredDeviceKey.set(registrationKey)
|
||||||
store(context).updateLastUserId(userId)
|
store(context).updateLastUserId(userId)
|
||||||
Log.i(
|
Log.i("XuqmPushSDK", "Registered push device for userId=$userId vendor=${vendor.name}")
|
||||||
"XuqmPushSDK",
|
|
||||||
"Registered push device for userId=$userId vendor=${vendor.name}",
|
|
||||||
)
|
|
||||||
}.also {
|
}.also {
|
||||||
registeringDeviceKey.compareAndSet(registrationKey, null)
|
registeringDeviceKey.compareAndSet(registrationKey, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterDevice(userId: String) {
|
private fun unregisterDeviceInternal(userId: String) {
|
||||||
XuqmSDK.requireInit()
|
XuqmSDK.requireInit()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@ -198,61 +250,23 @@ object PushSDK {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val vendorServices: List<PushVendorInterface> = listOf(
|
private fun ensureNativePushTokenInternal(context: Context) {
|
||||||
HuaweiPushService(),
|
ensureNativePushTokenInternal(context, detectVendor())
|
||||||
XiaomiPushService(),
|
|
||||||
OppoPushService(),
|
|
||||||
VivoPushService(),
|
|
||||||
HonorPushService(),
|
|
||||||
FcmPushService(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun detectVendor(): PushVendor {
|
|
||||||
return when (Build.MANUFACTURER.uppercase()) {
|
|
||||||
"HUAWEI" -> PushVendor.HUAWEI
|
|
||||||
"XIAOMI" -> PushVendor.XIAOMI
|
|
||||||
"REDMI", "POCO" -> PushVendor.XIAOMI
|
|
||||||
"OPPO" -> PushVendor.OPPO
|
|
||||||
"REALME", "ONEPLUS" -> PushVendor.OPPO
|
|
||||||
"VIVO" -> PushVendor.VIVO
|
|
||||||
"IQOO" -> PushVendor.VIVO
|
|
||||||
"HONOR" -> PushVendor.HONOR
|
|
||||||
else -> PushVendor.FCM
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initializeVendors(context: Context) {
|
private fun ensureNativePushTokenInternal(context: Context, vendor: PushVendor) {
|
||||||
val detectedVendor = detectVendor()
|
val registration = currentRegistration(context)
|
||||||
Log.i("XuqmPushSDK", "Detected push vendor: ${detectedVendor.name}")
|
if (registration?.pushToken?.isNotBlank() == true) return
|
||||||
scope.launch {
|
val service = vendorServices.firstOrNull { it.vendor == vendor }
|
||||||
val config = loadVendorConfig()
|
if (service != null && service.isAvailable(context)) {
|
||||||
PushNotificationChannelManager.apply(context.applicationContext, cachedPushConfig)
|
scope.launch {
|
||||||
vendorServices.forEach { service ->
|
service.register(context, loadVendorConfig())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
} 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 =
|
private fun store(context: Context): PushRegistrationStore =
|
||||||
PushRegistrationStore(context.applicationContext)
|
PushRegistrationStore(context.applicationContext)
|
||||||
|
|
||||||
@ -262,41 +276,6 @@ object PushSDK {
|
|||||||
fallbackVendor = detectVendor(),
|
fallbackVendor = detectVendor(),
|
||||||
)?.receivePush ?: true
|
)?.receivePush ?: true
|
||||||
|
|
||||||
private fun ensureNativePushToken(context: Context) {
|
|
||||||
ensureNativePushToken(context, detectVendor())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureNativePushToken(
|
|
||||||
context: Context,
|
|
||||||
vendor: PushVendor,
|
|
||||||
) {
|
|
||||||
val registration = currentRegistration(context)
|
|
||||||
if (registration?.pushToken?.isNotBlank() == true) return
|
|
||||||
|
|
||||||
if (vendor == PushVendor.FCM) {
|
|
||||||
val service = vendorServices.firstOrNull { it.vendor == PushVendor.FCM }
|
|
||||||
if (service != null && service.isAvailable(context)) {
|
|
||||||
scope.launch {
|
|
||||||
service.register(context, loadVendorConfig())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.w("XuqmPushSDK", "FCM service not available, skipping native push registration")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val service = vendorServices.firstOrNull { it.vendor == vendor }
|
|
||||||
if (service != null && service.isAvailable(context)) {
|
|
||||||
scope.launch {
|
|
||||||
service.register(context, loadVendorConfig())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.w(
|
|
||||||
"XuqmPushSDK",
|
|
||||||
"Vendor ${vendor.name} service not available, skipping native push registration",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun appVersion(context: Context): String? =
|
private fun appVersion(context: Context): String? =
|
||||||
runCatching {
|
runCatching {
|
||||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
@ -308,15 +287,11 @@ object PushSDK {
|
|||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedPushConfig: com.google.gson.JsonObject? = null
|
|
||||||
|
|
||||||
private suspend fun loadVendorConfig(): PushVendorConfig {
|
private suspend fun loadVendorConfig(): PushVendorConfig {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
cachedVendorConfig?.takeIf { now - cachedConfigAt < CONFIG_CACHE_TTL_MS }?.let { return it }
|
cachedVendorConfig?.takeIf { now - cachedConfigAt < CONFIG_CACHE_TTL_MS }?.let { return it }
|
||||||
val loaded = runCatching {
|
val loaded = runCatching {
|
||||||
val pushConfig = configApi.sdkConfig(XuqmSDK.appKey).data?.pushConfig
|
val pushConfig = configApi.sdkConfig(XuqmSDK.appKey).data?.pushConfig
|
||||||
cachedPushConfig = pushConfig
|
|
||||||
PushNotificationChannelManager.apply(XuqmSDK.appContext, pushConfig)
|
PushNotificationChannelManager.apply(XuqmSDK.appContext, pushConfig)
|
||||||
PushVendorConfig(
|
PushVendorConfig(
|
||||||
huaweiAppId = pushConfig?.getAsJsonObject("huawei")?.get("appId")?.asString.orEmpty(),
|
huaweiAppId = pushConfig?.getAsJsonObject("huawei")?.get("appId")?.asString.orEmpty(),
|
||||||
@ -329,7 +304,7 @@ object PushSDK {
|
|||||||
honorAppId = pushConfig?.getAsJsonObject("honor")?.get("appId")?.asString.orEmpty(),
|
honorAppId = pushConfig?.getAsJsonObject("honor")?.get("appId")?.asString.orEmpty(),
|
||||||
)
|
)
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
Log.w("XuqmPushSDK", "Unable to load tenant push config: ${error.message}")
|
Log.w("XuqmPushSDK", "Unable to load push vendor config: ${error.message}")
|
||||||
PushVendorConfig()
|
PushVendorConfig()
|
||||||
}
|
}
|
||||||
cachedVendorConfig = loaded
|
cachedVendorConfig = loaded
|
||||||
|
|||||||
@ -53,6 +53,20 @@ internal class PushRegistrationStore(context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setQuietHours(start: String, end: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_QUIET_HOURS_START, start)
|
||||||
|
.putString(KEY_QUIET_HOURS_END, end)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearQuietHours() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_QUIET_HOURS_START)
|
||||||
|
.remove(KEY_QUIET_HOURS_END)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun clearToken() {
|
fun clearToken() {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove(KEY_VENDOR)
|
.remove(KEY_VENDOR)
|
||||||
@ -66,5 +80,7 @@ internal class PushRegistrationStore(context: Context) {
|
|||||||
private const val KEY_PUSH_TOKEN = "push_token"
|
private const val KEY_PUSH_TOKEN = "push_token"
|
||||||
private const val KEY_RECEIVE_PUSH = "receive_push"
|
private const val KEY_RECEIVE_PUSH = "receive_push"
|
||||||
private const val KEY_LAST_USER_ID = "last_user_id"
|
private const val KEY_LAST_USER_ID = "last_user_id"
|
||||||
|
private const val KEY_QUIET_HOURS_START = "quiet_hours_start"
|
||||||
|
private const val KEY_QUIET_HOURS_END = "quiet_hours_end"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,7 @@ object UpdateSDK {
|
|||||||
|
|
||||||
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
|
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
|
||||||
|
|
||||||
/**
|
private fun resolveUserId(): String? = XuqmSDK.getUserId()
|
||||||
* 获取当前生效的 userId。
|
|
||||||
* 优先级:[XuqmSDK.userInfo]?.userId > [XuqmSDK.currentLoginSession]?.userId
|
|
||||||
*/
|
|
||||||
private fun resolveUserId(): String? {
|
|
||||||
return XuqmSDK.userInfo?.id ?: XuqmSDK.currentLoginSession?.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeDownloadUrl(rawUrl: String?): String? {
|
private fun normalizeDownloadUrl(rawUrl: String?): String? {
|
||||||
if (rawUrl.isNullOrBlank()) return rawUrl
|
if (rawUrl.isNullOrBlank()) return rawUrl
|
||||||
@ -198,52 +192,47 @@ object UpdateSDK {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载并安装 APK。
|
* 下载 APK 并调起系统安装器(对应 spec 中的 downloadAndInstallApk)。
|
||||||
* 如果该版本的 APK 已下载到本地,将跳过下载直接安装。
|
* 如果该版本的 APK 已下载到本地,将跳过下载直接安装。
|
||||||
*
|
*
|
||||||
* @param downloadUrl APK 下载地址(来自 [UpdateInfo.downloadUrl])
|
* @param downloadUrl APK 下载地址(来自 [UpdateInfo.downloadUrl])
|
||||||
* @param versionCode 版本号(来自 [UpdateInfo.versionCode]),用于本地文件命名和已下载检测
|
* @param versionCode 版本号(来自 [UpdateInfo.versionCode]),用于本地文件命名和已下载检测
|
||||||
* @param onProgress 下载进度回调 (0-100),跳过下载时不会调用
|
* @param onProgress 下载进度回调 (0~1),跳过下载时不会调用
|
||||||
|
* @param sha256 APK 文件的 SHA-256 校验值(可选),有则验证
|
||||||
*/
|
*/
|
||||||
|
suspend fun downloadAndInstallApk(
|
||||||
|
context: Context,
|
||||||
|
downloadUrl: String,
|
||||||
|
versionCode: Int = 0,
|
||||||
|
onProgress: ((Float) -> Unit)? = null,
|
||||||
|
sha256: String? = null,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
if (versionCode > 0 && isApkDownloaded(context, versionCode, sha256.orEmpty())) {
|
||||||
|
val apkFile = resolveDownloadedApk(context, versionCode)!!
|
||||||
|
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val apkFile = if (versionCode > 0) versionedApkFile(context, versionCode) else legacyApkFile(context)
|
||||||
|
FileSDK.downloadToFile(downloadUrl, apkFile) { progressInt ->
|
||||||
|
onProgress?.invoke(progressInt / 100f)
|
||||||
|
}
|
||||||
|
if (versionCode > 0) {
|
||||||
|
val legacy = legacyApkFile(context)
|
||||||
|
if (legacy.exists()) runCatching { legacy.delete() }
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated(
|
||||||
|
"Use downloadAndInstallApk instead.",
|
||||||
|
ReplaceWith("downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress(it.toInt()) })")
|
||||||
|
)
|
||||||
suspend fun downloadAndInstall(
|
suspend fun downloadAndInstall(
|
||||||
context: Context,
|
context: Context,
|
||||||
downloadUrl: String,
|
downloadUrl: String,
|
||||||
versionCode: Int = 0,
|
versionCode: Int = 0,
|
||||||
onProgress: (Int) -> Unit = {},
|
onProgress: (Int) -> Unit = {},
|
||||||
) = withContext(Dispatchers.IO) {
|
) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) })
|
||||||
// 如果已下载,跳过下载直接安装
|
|
||||||
if (versionCode > 0 && isApkDownloaded(context, versionCode)) {
|
|
||||||
val apkFile = resolveDownloadedApk(context, versionCode)!!
|
|
||||||
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
|
|
||||||
val apkFile = if (versionCode > 0) {
|
|
||||||
versionedApkFile(context, versionCode)
|
|
||||||
} else {
|
|
||||||
legacyApkFile(context)
|
|
||||||
}
|
|
||||||
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress)
|
|
||||||
|
|
||||||
// 下载成功后清理旧版无版本号文件
|
|
||||||
if (versionCode > 0) {
|
|
||||||
val legacy = legacyApkFile(context)
|
|
||||||
if (legacy.exists()) runCatching { legacy.delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 使用带 versionCode 参数的重载版本,以支持已下载检测。
|
|
||||||
*/
|
|
||||||
@Deprecated("Use downloadAndInstall(context, downloadUrl, versionCode, onProgress) instead",
|
|
||||||
ReplaceWith("downloadAndInstall(context, downloadUrl, 0, onProgress)"))
|
|
||||||
suspend fun downloadAndInstall(
|
|
||||||
context: Context,
|
|
||||||
downloadUrl: String,
|
|
||||||
onProgress: (Int) -> Unit = {},
|
|
||||||
) = downloadAndInstall(context, downloadUrl, 0, onProgress)
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// WebSocket 实时通知
|
// WebSocket 实时通知
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户