- 新增 externalUserId 属性用于存储外部设置的用户ID - 添加 setUserId 方法允许外部设置用户ID用于灰度发布筛选 - 实现 resolveUserId 方法确定当前生效的用户ID优先级 - 更新 checkAppUpdate 方法使用 resolveUserId 替代直接获取会话用户ID - 修改文档说明用户ID优先级:externalUserId > XuqmSDK.currentLoginSession.userId - 适配应用自有登录体系场景下的灰度发布需求
289 行
14 KiB
Kotlin
289 行
14 KiB
Kotlin
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)
|
||
}
|
||
}
|