XuqmGroup-AndroidSDK/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt
XuqmGroup a36097fcdb fix(sdk-update): awaitInitialization 同时等待远程平台配置完成
确保 bugCollectEnabled 等远程配置字段在 checkAppUpdate 执行前已生效,
避免 captureError 因 isReady()=false 静默丢弃异常。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:30:37 +08:00

320 行
15 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 android.util.Log
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)
private fun resolveUserId(): String? = XuqmSDK.getUserId()
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)
private fun getCachedUpdateInfo(prefs: android.content.SharedPreferences, cacheKey: String): UpdateInfo? {
val ts = prefs.getLong("${cacheKey}_ts", 0)
if (System.currentTimeMillis() - ts > 30 * 60 * 1000) return null
val json = prefs.getString("${cacheKey}_data", null) ?: return null
return runCatching { com.google.gson.Gson().fromJson(json, UpdateInfo::class.java) }.getOrNull()
}
private fun putCachedUpdateInfo(prefs: android.content.SharedPreferences, cacheKey: String, info: UpdateInfo) {
prefs.edit()
.putString("${cacheKey}_data", com.google.gson.Gson().toJson(info))
.putLong("${cacheKey}_ts", System.currentTimeMillis())
.apply()
}
/** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */
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 md = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
md.update(buffer, 0, read)
}
}
val bytes = md.digest()
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
sb.append(String.format("%02x", b.toInt() and 0xFF))
}
return sb.toString()
}
/**
* 直接安装已下载的 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 优先使用 [XuqmSDK.setUserInfo] 设置的值,其次从 [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()
val url = ServiceEndpointRegistry.updateBaseUrl
val prefs = prefs(context)
val cacheKey = "${XuqmSDK.appKey}_${versionCode}_${userId.orEmpty()}"
// Try cache first (skip cache when bypassIgnore=true to force fresh check)
if (!bypassIgnore) {
val cached = getCachedUpdateInfo(prefs, cacheKey)
if (cached != null) {
Log.d("UpdateSDK", "Using cached update info for cacheKey=$cacheKey")
val afterIgnore = if (cached.needsUpdate && !cached.forceUpdate && isVersionIgnored(context, cached.versionCode)) {
cached.copy(needsUpdate = false)
} else {
cached
}
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
return@withContext afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
}
return@withContext afterIgnore
}
}
runCatching {
val response = api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId)
if (response.code == 40404) {
throw IllegalStateException("更新服务未开通 (code=40404) [appKey=${XuqmSDK.appKey}]")
}
response.data?.toUpdateInfo()?.let { info ->
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
&& isVersionIgnored(context, normalized.versionCode)
) {
normalized.copy(needsUpdate = false)
} else {
normalized
}
val result = if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
} else {
afterIgnore
}
// Cache the result (30 min TTL)
putCachedUpdateInfo(prefs, cacheKey, result)
result
}
}.onFailure { e ->
Log.e("UpdateSDK", "checkUpdate failed [url=$url appKey=${XuqmSDK.appKey} versionCode=$versionCode userId=$userId]: ${e.message}", e)
}.getOrNull()
}
private suspend fun awaitInitialization() {
if (!XuqmSDK.isInitialized()) {
Log.w("UpdateSDK", "XuqmSDK not yet initialized, waiting up to 15s...")
kotlinx.coroutines.withTimeoutOrNull(15_000L) {
while (!XuqmSDK.isInitialized()) {
kotlinx.coroutines.delay(100)
}
} ?: run {
val msg = "XuqmSDK init timeout (15s). Check that config.xuqm is present and package name matches."
Log.e("UpdateSDK", msg)
throw IllegalStateException(msg)
}
}
// 等待远程平台配置拉取完成bugCollectEnabled 等字段在此之后才生效)
kotlinx.coroutines.withTimeoutOrNull(10_000L) {
runCatching { XuqmSDK.awaitInitialization() }
}
Log.d("UpdateSDK", "XuqmSDK initialized, proceeding with update check")
}
// ─────────────────────────────────────────────────────────────────────────
// 下载与安装
// ─────────────────────────────────────────────────────────────────────────
/**
* 下载 APK 并调起系统安装器(对应 spec 中的 downloadAndInstallApk
* 如果该版本的 APK 已下载到本地,将跳过下载直接安装。
*
* @param downloadUrl APK 下载地址(来自 [UpdateInfo.downloadUrl]
* @param versionCode 版本号(来自 [UpdateInfo.versionCode]),用于本地文件命名和已下载检测
* @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 = {},
) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) })
// ─────────────────────────────────────────────────────────────────────────
// 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)
}
}