package com.xuqm.sdk.update import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build 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) /** * 外部设置的 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 digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { input -> val buffer = ByteArray(8192) var read: Int while (input.read(buffer).also { read = it } != -1) { digest.update(buffer, 0, read) } } return digest.joinToString("") { "%02x".format(it) } } /** * 直接安装已下载的 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() } 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),跳过下载时不会调用 */ suspend fun downloadAndInstall( context: Context, downloadUrl: String, versionCode: Int = 0, 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) } } /** * @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) // ───────────────────────────────────────────────────────────────────────── // 安装 // ───────────────────────────────────────────────────────────────────────── 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) } }