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

294 行
14 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
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
/**
* 外部设置的 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 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 优先使用 [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()
2026-04-21 22:07:29 +08:00
}
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)跳过下载时不会调用
*/
2026-04-21 22:07:29 +08:00
suspend fun downloadAndInstall(
context: Context,
downloadUrl: String,
versionCode: Int = 0,
2026-04-21 22:07:29 +08:00
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) }
2026-04-21 22:07:29 +08:00
}
/**
* @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)
// ─────────────────────────────────────────────────────────────────────────
// 安装
// ─────────────────────────────────────────────────────────────────────────
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)
}
}