XuqmGroup-AndroidSDK/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt

320 行
15 KiB
Kotlin

2026-04-21 22:07:29 +08:00
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
2026-04-21 22:07:29 +08:00
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.file.FileSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
2026-04-21 22:07:29 +08:00
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
2026-04-21 22:07:29 +08:00
object UpdateSDK {
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
2026-04-21 22:07:29 +08:00
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()
2026-04-21 22:07:29 +08:00
}
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(
2026-04-21 22:07:29 +08:00
context: Context,
downloadUrl: String,
versionCode: Int = 0,
onProgress: ((Float) -> Unit)? = null,
sha256: String? = null,
2026-04-21 22:07:29 +08:00
) = 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) }
2026-04-21 22:07:29 +08:00
}
@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)
// ─────────────────────────────────────────────────────────────────────────
// 安装
// ─────────────────────────────────────────────────────────────────────────
2026-04-21 22:07:29 +08:00
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)
}
}