diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateListener.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateListener.kt new file mode 100644 index 0000000..7a3f5bb --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateListener.kt @@ -0,0 +1,23 @@ +package com.xuqm.sdk.update + +import com.xuqm.sdk.update.model.UpdateInfo + +/** + * 更新事件监听器。 + * + * App 通过 [UpdateSDK.createWebSocket] 注册此监听器后: + * 1. 服务端发布新版本时,SDK 收到 WebSocket 通知 + * 2. SDK 自动调用 checkUpdate 接口(尊重灰度发布规则) + * 3. 如果有可用更新,回调 [onUpdateAvailable] + * 4. 如果没有可用更新(版本已是最新或不在灰度范围内),回调 [onNoUpdate] + */ +interface UpdateListener { + /** 有可用更新,App 应在此回调中展示更新对话框 */ + fun onUpdateAvailable(updateInfo: UpdateInfo) + + /** 无可用更新(版本已是最新、被忽略、不在灰度范围内等) */ + fun onNoUpdate() + + /** 检查更新过程中发生错误(网络异常等) */ + fun onError(error: Throwable) {} +} diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt index 905717e..453f2db 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateSDK.kt @@ -14,6 +14,7 @@ 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 { @@ -48,6 +49,73 @@ object UpdateSDK { 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 + } + + // ───────────────────────────────────────────────────────────────────────── + // 检测更新 + // ───────────────────────────────────────────────────────────────────────── + /** * 检测应用更新。 * @@ -56,6 +124,9 @@ object UpdateSDK { * - `true`(主动检查):忽略记录不生效,始终弹出更新对话框;无更新时由调用方显示提示。 * * userId 通过 [XuqmSDK.login] 设置会话后自动传递,无需外部覆盖。 + * + * 返回的 [UpdateInfo.alreadyDownloaded] 为 true 时,可直接调用 [installDownloadedApk] 安装, + * 无需再次调用 [downloadAndInstall]。 */ suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) { awaitInitialization() @@ -71,13 +142,20 @@ object UpdateSDK { api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info -> val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl) // 静默检查时跳过已忽略版本;主动检查(bypassIgnore=true)始终展示 - if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate + 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() } @@ -91,15 +169,86 @@ object UpdateSDK { } ?: 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 = {}, - ) = withContext(Dispatchers.IO) { - val apkFile = File(context.getExternalFilesDir(null), "update.apk") - FileSDK.downloadToFile(downloadUrl, apkFile, onProgress) - withContext(Dispatchers.Main) { installApk(context, apkFile) } - } + ) = 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 { diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt new file mode 100644 index 0000000..0c036a3 --- /dev/null +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/UpdateWebSocket.kt @@ -0,0 +1,187 @@ +package com.xuqm.sdk.update + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.xuqm.sdk.core.ServiceEndpointRegistry +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.TimeUnit + +/** + * WebSocket 客户端,用于接收版本发布的实时通知。 + * + * 工作流程: + * 1. 服务端发布新版本时,通过 WebSocket 发送轻量通知 + * 2. SDK 收到通知后自动调用 [UpdateSDK.checkAppUpdate](尊重灰度发布规则) + * 3. 根据检查结果回调 [UpdateListener] + * + * App 只需注册监听并调用 [connect],无需关心底层通信细节。 + * + * 使用方式: + * ``` + * // 创建并注册监听 + * val ws = UpdateSDK.createWebSocket(context, object : UpdateListener { + * override fun onUpdateAvailable(updateInfo: UpdateInfo) { + * showUpdateDialog(updateInfo) + * } + * override fun onNoUpdate() { } + * override fun onError(error: Throwable) { } + * }) + * ws.connect() + * + * // 页面销毁时 + * ws.disconnect() + * ``` + */ +class UpdateWebSocket internal constructor( + private val context: Context, + private val appKey: String, + private val listener: UpdateListener, +) { + + companion object { + private const val TAG = "UpdateWebSocket" + private const val RECONNECT_DELAY_MS = 5_000L + private const val MAX_RECONNECT_DELAY_MS = 300_000L // 5 minutes + } + + private val gson = Gson() + private val client = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private var webSocket: WebSocket? = null + @Volatile private var connected = false + @Volatile private var manuallyClosed = false + @Volatile private var reconnectDelay = RECONNECT_DELAY_MS + + fun isConnected(): Boolean = connected + + /** + * 建立 WebSocket 连接并注册监听。 + * 如果已连接,不会重复连接。 + */ + fun connect() { + if (connected) return + manuallyClosed = false + reconnectDelay = RECONNECT_DELAY_MS + doConnect() + } + + /** + * 断开 WebSocket 连接并移除监听。 + * 调用后不会自动重连。 + */ + fun disconnect() { + manuallyClosed = true + webSocket?.close(1000, "Client closing") + webSocket = null + connected = false + } + + private fun doConnect() { + val baseUrl = ServiceEndpointRegistry.updateBaseUrl + .replace("^https://".toRegex(), "wss://") + .replace("^http://".toRegex(), "ws://") + .trimEnd('/') + val url = "$baseUrl/ws/updates?appKey=$appKey" + + Log.d(TAG, "Connecting to $url") + + val request = Request.Builder() + .url(url) + .build() + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "Connected") + connected = true + reconnectDelay = RECONNECT_DELAY_MS + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "Received: $text") + handleMessage(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "Server closing: $code $reason") + webSocket.close(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "Closed: $code $reason") + connected = false + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.w(TAG, "Connection failed: ${t.message}") + connected = false + scheduleReconnect() + } + }) + } + + /** + * 处理服务端推送的消息。 + * 收到 "new_version_available" 事件后,自动调用 checkUpdate 接口。 + */ + private fun handleMessage(text: String) { + try { + val json = gson.fromJson(text, Map::class.java) + val event = json["event"] as? String ?: return + if (event == "new_version_available") { + Log.d(TAG, "New version notification received, checking update...") + checkUpdateAndNotify() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse message: ${e.message}") + } + } + + /** + * 调用 checkUpdate 接口,根据结果回调 listener。 + * 此方法在 IO 线程执行,回调在主线程执行。 + */ + private fun checkUpdateAndNotify() { + Thread { + try { + val updateInfo = kotlinx.coroutines.runBlocking { + UpdateSDK.checkAppUpdate(context, bypassIgnore = false) + } + android.os.Handler(android.os.Looper.getMainLooper()).post { + if (updateInfo != null && updateInfo.needsUpdate) { + Log.d(TAG, "Update available: ${updateInfo.versionName} (${updateInfo.versionCode})") + listener.onUpdateAvailable(updateInfo) + } else { + Log.d(TAG, "No update available") + listener.onNoUpdate() + } + } + } catch (e: Exception) { + Log.w(TAG, "checkUpdate failed: ${e.message}") + android.os.Handler(android.os.Looper.getMainLooper()).post { + listener.onError(e) + } + } + }.start() + } + + private fun scheduleReconnect() { + if (manuallyClosed) return + Log.d(TAG, "Reconnecting in ${reconnectDelay}ms") + Thread { + Thread.sleep(reconnectDelay) + if (!manuallyClosed && !connected) { + reconnectDelay = (reconnectDelay * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS) + doConnect() + } + }.start() + } +} diff --git a/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt b/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt index efa24e3..c37162a 100644 --- a/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt +++ b/sdk-update/src/main/java/com/xuqm/sdk/update/model/UpdateInfo.kt @@ -11,4 +11,8 @@ data class UpdateInfo( val marketUrl: String = "", /** 服务端要求先登录再检测更新时为 true,此时 needsUpdate 通常为 false */ val requiresLogin: Boolean = false, + /** 该版本 APK 已下载到本地且哈希校验通过,可直接安装 */ + val alreadyDownloaded: Boolean = false, + /** APK 文件的 SHA-256 哈希值,用于校验本地文件完整性 */ + val apkHash: String = "", )