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
|
2026-04-30 16:54:10 +08:00
|
|
|
|
import android.os.Build
|
2026-06-11 19:30:59 +08:00
|
|
|
|
import android.util.Log
|
2026-04-21 22:07:29 +08:00
|
|
|
|
import androidx.core.content.FileProvider
|
|
|
|
|
|
import com.xuqm.sdk.XuqmSDK
|
2026-04-28 20:11:38 +08:00
|
|
|
|
import com.xuqm.sdk.file.FileSDK
|
2026-04-27 19:30:06 +08:00
|
|
|
|
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
|
2026-06-11 12:25:16 +08:00
|
|
|
|
import java.security.MessageDigest
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
|
|
object UpdateSDK {
|
|
|
|
|
|
|
2026-04-27 19:30:06 +08:00
|
|
|
|
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
2026-06-15 15:51:58 +08:00
|
|
|
|
private fun resolveUserId(): String? = XuqmSDK.getUserId()
|
2026-06-11 12:42:59 +08:00
|
|
|
|
|
2026-04-27 19:00:54 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 13:14:02 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-11 12:25:16 +08:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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 {
|
2026-06-11 13:30:41 +08:00
|
|
|
|
val md = MessageDigest.getInstance("SHA-256")
|
2026-06-11 12:25:16 +08:00
|
|
|
|
file.inputStream().use { input ->
|
|
|
|
|
|
val buffer = ByteArray(8192)
|
|
|
|
|
|
var read: Int
|
|
|
|
|
|
while (input.read(buffer).also { read = it } != -1) {
|
2026-06-11 13:30:41 +08:00
|
|
|
|
md.update(buffer, 0, read)
|
2026-06-11 12:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-11 13:30:41 +08:00
|
|
|
|
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()
|
2026-06-11 12:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 直接安装已下载的 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 检测更新
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-04 13:14:02 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 检测应用更新。
|
2026-06-04 13:35:59 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param bypassIgnore 是否绕过"忽略版本"过滤。
|
|
|
|
|
|
* - `false`(默认,静默检查):用户已忽略的版本不再弹窗,适合启动时后台检查。
|
|
|
|
|
|
* - `true`(主动检查):忽略记录不生效,始终弹出更新对话框;无更新时由调用方显示提示。
|
|
|
|
|
|
*
|
2026-06-11 15:11:53 +08:00
|
|
|
|
* userId 优先使用 [XuqmSDK.setUserInfo] 设置的值,其次从 [XuqmSDK.currentLoginSession] 获取。
|
2026-06-11 12:42:59 +08:00
|
|
|
|
* 灰度发布需要 userId 来判断用户是否命中灰度范围。
|
2026-06-11 12:25:16 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 返回的 [UpdateInfo.alreadyDownloaded] 为 true 时,可直接调用 [installDownloadedApk] 安装,
|
|
|
|
|
|
* 无需再次调用 [downloadAndInstall]。
|
2026-06-04 13:14:02 +08:00
|
|
|
|
*/
|
2026-06-04 13:35:59 +08:00
|
|
|
|
suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) {
|
2026-05-22 17:56:42 +08:00
|
|
|
|
awaitInitialization()
|
2026-04-30 16:54:10 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-06-11 12:42:59 +08:00
|
|
|
|
val userId = resolveUserId()
|
2026-06-11 19:30:59 +08:00
|
|
|
|
val url = ServiceEndpointRegistry.updateBaseUrl
|
2026-04-27 17:18:55 +08:00
|
|
|
|
runCatching {
|
2026-06-04 13:14:02 +08:00
|
|
|
|
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
|
|
|
|
|
|
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
|
2026-06-04 13:35:59 +08:00
|
|
|
|
// 静默检查时跳过已忽略版本;主动检查(bypassIgnore=true)始终展示
|
2026-06-11 12:25:16 +08:00
|
|
|
|
val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
|
2026-06-04 13:35:59 +08:00
|
|
|
|
&& isVersionIgnored(context, normalized.versionCode)
|
|
|
|
|
|
) {
|
2026-06-04 13:14:02 +08:00
|
|
|
|
normalized.copy(needsUpdate = false)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
normalized
|
|
|
|
|
|
}
|
2026-06-11 12:25:16 +08:00
|
|
|
|
// 检查该版本 APK 是否已下载到本地(含哈希校验)
|
|
|
|
|
|
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
|
|
|
|
|
|
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
|
2026-06-11 20:24:39 +08:00
|
|
|
|
context, afterIgnore.versionCode, afterIgnore.apkHash ?: ""))
|
2026-06-11 12:25:16 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
afterIgnore
|
|
|
|
|
|
}
|
2026-04-27 19:00:54 +08:00
|
|
|
|
}
|
2026-06-11 19:30:59 +08:00
|
|
|
|
}.onFailure { e ->
|
|
|
|
|
|
Log.e("UpdateSDK", "checkUpdate failed [url=$url appKey=${XuqmSDK.appKey} versionCode=$versionCode userId=$userId]: ${e.message}", e)
|
2026-04-27 17:18:55 +08:00
|
|
|
|
}.getOrNull()
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 13:14:02 +08:00
|
|
|
|
private suspend fun awaitInitialization() {
|
|
|
|
|
|
if (XuqmSDK.isInitialized()) return
|
2026-06-11 19:30:59 +08:00
|
|
|
|
Log.w("UpdateSDK", "XuqmSDK not yet initialized, waiting up to 15s...")
|
2026-06-04 13:14:02 +08:00
|
|
|
|
kotlinx.coroutines.withTimeoutOrNull(15_000L) {
|
|
|
|
|
|
while (!XuqmSDK.isInitialized()) {
|
|
|
|
|
|
kotlinx.coroutines.delay(100)
|
|
|
|
|
|
}
|
2026-06-11 19:30:59 +08:00
|
|
|
|
} ?: run {
|
|
|
|
|
|
val msg = "XuqmSDK init timeout (15s). Check that config.xuqm is present and package name matches."
|
|
|
|
|
|
Log.e("UpdateSDK", msg)
|
|
|
|
|
|
throw IllegalStateException(msg)
|
|
|
|
|
|
}
|
|
|
|
|
|
Log.d("UpdateSDK", "XuqmSDK initialized, proceeding with update check")
|
2026-05-22 17:56:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 12:25:16 +08:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 下载与安装
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-15 15:51:58 +08:00
|
|
|
|
* 下载 APK 并调起系统安装器(对应 spec 中的 downloadAndInstallApk)。
|
2026-06-11 12:25:16 +08:00
|
|
|
|
* 如果该版本的 APK 已下载到本地,将跳过下载直接安装。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param downloadUrl APK 下载地址(来自 [UpdateInfo.downloadUrl])
|
|
|
|
|
|
* @param versionCode 版本号(来自 [UpdateInfo.versionCode]),用于本地文件命名和已下载检测
|
2026-06-15 15:51:58 +08:00
|
|
|
|
* @param onProgress 下载进度回调 (0~1),跳过下载时不会调用
|
|
|
|
|
|
* @param sha256 APK 文件的 SHA-256 校验值(可选),有则验证
|
2026-06-11 12:25:16 +08:00
|
|
|
|
*/
|
2026-06-15 15:51:58 +08:00
|
|
|
|
suspend fun downloadAndInstallApk(
|
2026-04-21 22:07:29 +08:00
|
|
|
|
context: Context,
|
|
|
|
|
|
downloadUrl: String,
|
2026-06-11 12:25:16 +08:00
|
|
|
|
versionCode: Int = 0,
|
2026-06-15 15:51:58 +08:00
|
|
|
|
onProgress: ((Float) -> Unit)? = null,
|
|
|
|
|
|
sha256: String? = null,
|
2026-04-21 22:07:29 +08:00
|
|
|
|
) = withContext(Dispatchers.IO) {
|
2026-06-15 15:51:58 +08:00
|
|
|
|
if (versionCode > 0 && isApkDownloaded(context, versionCode, sha256.orEmpty())) {
|
2026-06-11 12:25:16 +08:00
|
|
|
|
val apkFile = resolveDownloadedApk(context, versionCode)!!
|
|
|
|
|
|
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
|
|
|
|
|
return@withContext
|
|
|
|
|
|
}
|
2026-06-15 15:51:58 +08:00
|
|
|
|
val apkFile = if (versionCode > 0) versionedApkFile(context, versionCode) else legacyApkFile(context)
|
|
|
|
|
|
FileSDK.downloadToFile(downloadUrl, apkFile) { progressInt ->
|
|
|
|
|
|
onProgress?.invoke(progressInt / 100f)
|
2026-06-11 12:25:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (versionCode > 0) {
|
|
|
|
|
|
val legacy = legacyApkFile(context)
|
|
|
|
|
|
if (legacy.exists()) runCatching { legacy.delete() }
|
|
|
|
|
|
}
|
2026-04-27 17:18:55 +08:00
|
|
|
|
withContext(Dispatchers.Main) { installApk(context, apkFile) }
|
2026-04-21 22:07:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:51:58 +08:00
|
|
|
|
@Deprecated(
|
|
|
|
|
|
"Use downloadAndInstallApk instead.",
|
|
|
|
|
|
ReplaceWith("downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress(it.toInt()) })")
|
|
|
|
|
|
)
|
2026-06-11 12:25:16 +08:00
|
|
|
|
suspend fun downloadAndInstall(
|
|
|
|
|
|
context: Context,
|
|
|
|
|
|
downloadUrl: String,
|
2026-06-15 15:51:58 +08:00
|
|
|
|
versionCode: Int = 0,
|
2026-06-11 12:25:16 +08:00
|
|
|
|
onProgress: (Int) -> Unit = {},
|
2026-06-15 15:51:58 +08:00
|
|
|
|
) = downloadAndInstallApk(context, downloadUrl, versionCode, { onProgress((it * 100).toInt()) })
|
2026-06-11 12:25:16 +08:00
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|