From 3fe411738d1e00182c1b1ee618fdaf0f2b1e6b53 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 15 Jun 2026 15:51:58 +0800 Subject: [PATCH] =?UTF-8?q?docs(sdk):=20=E6=9B=B4=E6=96=B0=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0SDK=E8=AE=BE=E8=AE=A1=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E8=87=B3v1.1=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新版本号至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组件中的推送设置调用方式 --- .../com/xuqm/sdk/sample/CrossDeviceTest.kt | 11 +- .../xuqm/sdk/sample/NetworkResilienceTest.kt | 9 +- .../java/com/xuqm/sdk/sample/PushSdkTest.kt | 28 +- .../com/xuqm/sdk/sample/SdkIntegrationTest.kt | 15 +- .../sdk/sample/data/repo/AuthRepository.kt | 39 ++- .../ui/environment/EnvironmentScreen.kt | 4 +- .../com/xuqm/sdk/sample/ui/main/MainScreen.kt | 6 +- .../xuqm/sdk/sample/ui/update/UpdateScreen.kt | 6 +- .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 280 +++++++++++----- .../main/java/com/xuqm/sdk/XuqmUserInfo.kt | 10 +- .../xuqm/sdk/network/SdkPlatformConfigApi.kt | 29 ++ sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt | 13 + .../java/com/xuqm/sdk/license/LicenseSDK.kt | 14 +- .../main/java/com/xuqm/sdk/push/PushSDK.kt | 317 ++++++++---------- .../sdk/push/storage/PushRegistrationStore.kt | 16 + .../java/com/xuqm/sdk/update/UpdateSDK.kt | 75 ++--- 16 files changed, 510 insertions(+), 362 deletions(-) create mode 100644 sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt index b538d59..3a227ea 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/CrossDeviceTest.kt @@ -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 发送的单聊消息 diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt index 80b1031..3d5b74d 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/NetworkResilienceTest.kt @@ -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) } diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt index 7978794..f683889 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/PushSdkTest.kt @@ -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) } -} +} \ No newline at end of file diff --git a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt index 73052c0..cf79a2e 100644 --- a/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt +++ b/sample-app/src/androidTest/java/com/xuqm/sdk/sample/SdkIntegrationTest.kt @@ -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) { diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt index 9fea73c..2c31198 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt @@ -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() } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt index a31ef58..8ad8e92 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt @@ -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(null) } var snapshot by remember { mutableStateOf(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 "已关闭接收推送" }, diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt index 58b01eb..f328f66 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt @@ -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 diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt index 22367ee..5ff2a5c 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt @@ -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()) + }) } } } diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt index c44bd05..fee08df 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmSDK.kt @@ -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? = 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 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() 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) { + 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:" */ + 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", diff --git a/sdk-core/src/main/java/com/xuqm/sdk/XuqmUserInfo.kt b/sdk-core/src/main/java/com/xuqm/sdk/XuqmUserInfo.kt index 619cad7..c454c1f 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/XuqmUserInfo.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/XuqmUserInfo.kt @@ -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, -) +) \ No newline at end of file diff --git a/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt b/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt new file mode 100644 index 0000000..9e5ecae --- /dev/null +++ b/sdk-core/src/main/java/com/xuqm/sdk/network/SdkPlatformConfigApi.kt @@ -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, +) diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index 38bb440..a7187af 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -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 diff --git a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt index 8463b7f..9a773cc 100644 --- a/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt +++ b/sdk-license/src/main/java/com/xuqm/sdk/license/LicenseSDK.kt @@ -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( diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt index e017254..3fa22db 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/PushSDK.kt @@ -35,10 +35,95 @@ object PushSDK { private val registeredUserId = AtomicReference(null) private val registeringDeviceKey = AtomicReference(null) private val lastRegisteredDeviceKey = AtomicReference(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 = 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 = 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 @@ -338,4 +313,4 @@ object PushSDK { } private const val CONFIG_CACHE_TTL_MS = 5 * 60 * 1000L -} +} \ No newline at end of file diff --git a/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt b/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt index 81bde02..301cf00 100644 --- a/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt +++ b/sdk-push/src/main/java/com/xuqm/sdk/push/storage/PushRegistrationStore.kt @@ -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" } } diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt index 77fb6b4..8a6a4a3 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -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 实时通知