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) /** 忽略指定版本,下次检测到该版本时不再提示(强制更新版本不受此影响) */ 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 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 } } }.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()) return 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) } 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) } }