XuqmGroup-AndroidSDK/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
XuqmGroup 01bdceec44 feat(update): 添加外部用户ID设置功能支持灰度发布
- 新增 externalUserId 属性用于存储外部设置的用户ID
- 添加 setUserId 方法允许外部设置用户ID用于灰度发布筛选
- 实现 resolveUserId 方法确定当前生效的用户ID优先级
- 更新 checkAppUpdate 方法使用 resolveUserId 替代直接获取会话用户ID
- 修改文档说明用户ID优先级:externalUserId > XuqmSDK.currentLoginSession.userId
- 适配应用自有登录体系场景下的灰度发布需求
2026-06-11 12:42:59 +08:00

289 行
14 KiB
Kotlin

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

package com.xuqm.sdk.update
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.update.api.UpdateApi
import com.xuqm.sdk.update.model.UpdateInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
object UpdateSDK {
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
/**
* 外部设置的 userId,优先于 [XuqmSDK.currentLoginSession] 中的 userId。
* 适用于 App 有自己的登录体系,不走 XuqmSDK.login,但仍需灰度发布按用户筛选的场景。
*/
private var externalUserId: String? = null
/**
* 设置 userId,用于检查更新时的灰度发布筛选。
*
* 设置后,[checkAppUpdate] 会优先使用此 userId,而不是从 [XuqmSDK.currentLoginSession] 获取。
* 适用于 App 有自己的用户体系,不通过 [XuqmSDK.login] 登录的场景。
*
* @param userId 用户标识,传 null 则清除,恢复使用 XuqmSDK 登录会话中的 userId
*/
fun setUserId(userId: String?) {
externalUserId = userId?.takeIf { it.isNotBlank() }
}
/**
* 获取当前生效的 userId。
* 优先级:[externalUserId] > [XuqmSDK.currentLoginSession]?.userId
*/
private fun resolveUserId(): String? {
return externalUserId ?: XuqmSDK.currentLoginSession?.userId
}
private fun normalizeDownloadUrl(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return rawUrl
return runCatching {
val uri = Uri.parse(rawUrl)
if (uri.path?.startsWith("/files/apk/") == true) {
"${uri.scheme}://${uri.authority}/api/v1/updates${uri.path}"
} else {
rawUrl
}
}.getOrDefault(rawUrl)
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences("xuqm_update_prefs", android.content.Context.MODE_PRIVATE)
/** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */
fun ignoreVersion(context: Context, versionCode: Int) {
prefs(context).edit().putBoolean("ignored_v$versionCode", true).apply()
}
/** 清除所有已忽略版本记录 */
fun clearIgnoredVersions(context: Context) {
prefs(context).edit().clear().apply()
}
private fun isVersionIgnored(context: Context, versionCode: Int): Boolean =
prefs(context).getBoolean("ignored_v$versionCode", false)
// ─────────────────────────────────────────────────────────────────────────
// APK 本地文件管理
// ─────────────────────────────────────────────────────────────────────────
/** 版本化 APK 文件路径 */
private fun versionedApkFile(context: Context, versionCode: Int): File =
File(context.getExternalFilesDir(null), "update_$versionCode.apk")
/** 兼容旧版:无版本号的 APK 文件 */
private fun legacyApkFile(context: Context): File =
File(context.getExternalFilesDir(null), "update.apk")
/**
* 检查指定版本的 APK 是否已下载到本地。
* 如果提供了 [expectedHash],还会校验本地文件的 SHA-256 哈希是否一致,
* 确保本地文件与服务端上传的 APK 是同一个文件。
*
* @param context Android Context
* @param versionCode 版本号
* @param expectedHash 服务端返回的 APK 文件 SHA-256 哈希(可为空,为空时仅检查文件存在性)
*/
fun isApkDownloaded(context: Context, versionCode: Int, expectedHash: String = ""): Boolean {
val file = resolveDownloadedApk(context, versionCode) ?: return false
if (expectedHash.isBlank()) return true // 无哈希信息,仅检查文件存在性
return computeFileHash(file) == expectedHash.lowercase()
}
/**
* 计算文件的 SHA-256 哈希值(小写十六进制)。
*/
private fun computeFileHash(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
digest.update(buffer, 0, read)
}
}
return digest.joinToString("") { "%02x".format(it) }
}
/**
* 直接安装已下载的 APK,无需重新下载。
* @throws IllegalStateException 如果 APK 文件不存在
*/
suspend fun installDownloadedApk(context: Context, versionCode: Int) = withContext(Dispatchers.Main) {
val apkFile = resolveDownloadedApk(context, versionCode)
?: throw IllegalStateException("APK for version $versionCode not found locally")
installApk(context, apkFile)
}
/**
* 解析已下载的 APK 文件,返回文件路径或 null。
*/
private fun resolveDownloadedApk(context: Context, versionCode: Int): File? {
val versioned = versionedApkFile(context, versionCode)
if (versioned.exists() && versioned.length() > 0) return versioned
val legacy = legacyApkFile(context)
if (legacy.exists() && legacy.length() > 0) return legacy
return null
}
// ─────────────────────────────────────────────────────────────────────────
// 检测更新
// ─────────────────────────────────────────────────────────────────────────
/**
* 检测应用更新。
*
* @param bypassIgnore 是否绕过"忽略版本"过滤。
* - `false`(默认,静默检查):用户已忽略的版本不再弹窗,适合启动时后台检查。
* - `true`(主动检查):忽略记录不生效,始终弹出更新对话框;无更新时由调用方显示提示。
*
* userId 优先使用 [setUserId] 设置的值,其次从 [XuqmSDK.currentLoginSession] 获取。
* 灰度发布需要 userId 来判断用户是否命中灰度范围。
*
* 返回的 [UpdateInfo.alreadyDownloaded] 为 true 时,可直接调用 [installDownloadedApk] 安装,
* 无需再次调用 [downloadAndInstall]。
*/
suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) {
awaitInitialization()
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode
}
val userId = resolveUserId()
runCatching {
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
// 静默检查时跳过已忽略版本;主动检查bypassIgnore=true始终展示
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
&& isVersionIgnored(context, normalized.versionCode)
) {
normalized.copy(needsUpdate = false)
} else {
normalized
}
// 检查该版本 APK 是否已下载到本地(含哈希校验)
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
context, afterIgnore.versionCode, afterIgnore.apkHash))
} else {
afterIgnore
}
}
}.getOrNull()
}
private suspend fun awaitInitialization() {
if (XuqmSDK.isInitialized()) return
kotlinx.coroutines.withTimeoutOrNull(15_000L) {
while (!XuqmSDK.isInitialized()) {
kotlinx.coroutines.delay(100)
}
} ?: throw IllegalStateException("XuqmSDK not initialized. Check that config.xuqm is present and valid.")
}
// ─────────────────────────────────────────────────────────────────────────
// 下载与安装
// ─────────────────────────────────────────────────────────────────────────
/**
* 下载并安装 APK。
* 如果该版本的 APK 已下载到本地,将跳过下载直接安装。
*
* @param downloadUrl APK 下载地址(来自 [UpdateInfo.downloadUrl]
* @param versionCode 版本号(来自 [UpdateInfo.versionCode]),用于本地文件命名和已下载检测
* @param onProgress 下载进度回调 (0-100),跳过下载时不会调用
*/
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)
// ─────────────────────────────────────────────────────────────────────────
// WebSocket 实时通知
// ─────────────────────────────────────────────────────────────────────────
/**
* 创建 WebSocket 客户端,用于接收版本发布的实时通知。
*
* 工作流程:
* 1. 服务端发布新版本时,通过 WebSocket 发送轻量通知
* 2. SDK 收到通知后自动调用 [checkAppUpdate](尊重灰度发布规则)
* 3. 根据检查结果回调 [UpdateListener.onUpdateAvailable] 或 [UpdateListener.onNoUpdate]
*
* 需要服务端发布配置中启用 `enableRealtimeNotification`。
*
* @param context Android Context
* @param listener 更新事件监听器
* @param appKey 应用标识,默认使用 [XuqmSDK.appKey]
* @return [UpdateWebSocket] 实例,调用 [UpdateWebSocket.connect] 开始监听
*/
fun createWebSocket(
context: Context,
listener: UpdateListener,
appKey: String = XuqmSDK.appKey,
): UpdateWebSocket = UpdateWebSocket(context, appKey, listener)
// ─────────────────────────────────────────────────────────────────────────
// 安装
// ─────────────────────────────────────────────────────────────────────────
private fun installApk(context: Context, apkFile: File) {
val intent = Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
setDataAndType(uri, "application/vnd.android.package-archive")
}
context.startActivity(intent)
}
}