feat(update): 添加 API Key 管理和 WebSocket 实时通知功能

- 新增 API Key 管理功能,支持外部工具认证调用平台 API
- 实现 WebSocket 实时通知,版本发布时推送轻量通知给客户端
- 添加 APK 文件哈希校验,支持已下载检测和直接安装
- 支持外部 APK 上传使用 API Key 认证
- 优化私有化部署自动注入 nginx WebSocket 代理配置
- 扩展 SDK 功能包括已下载检测、直接安装和实时通知监听
这个提交包含在:
XuqmGroup 2026-06-11 12:25:16 +08:00
父节点 e5a78b9a52
当前提交 7caf7ed32d
共有 4 个文件被更改,包括 369 次插入6 次删除

查看文件

@ -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) {}
}

查看文件

@ -14,6 +14,7 @@ import com.xuqm.sdk.update.model.UpdateInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.security.MessageDigest
object UpdateSDK { object UpdateSDK {
@ -48,6 +49,73 @@ object UpdateSDK {
private fun isVersionIgnored(context: Context, versionCode: Int): Boolean = private fun isVersionIgnored(context: Context, versionCode: Int): Boolean =
prefs(context).getBoolean("ignored_v$versionCode", false) 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`主动检查忽略记录不生效始终弹出更新对话框无更新时由调用方显示提示 * - `true`主动检查忽略记录不生效始终弹出更新对话框无更新时由调用方显示提示
* *
* userId 通过 [XuqmSDK.login] 设置会话后自动传递无需外部覆盖 * userId 通过 [XuqmSDK.login] 设置会话后自动传递无需外部覆盖
*
* 返回的 [UpdateInfo.alreadyDownloaded] true 可直接调用 [installDownloadedApk] 安装
* 无需再次调用 [downloadAndInstall]
*/ */
suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) { suspend fun checkAppUpdate(context: Context, bypassIgnore: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) {
awaitInitialization() awaitInitialization()
@ -71,13 +142,20 @@ object UpdateSDK {
api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info -> api.checkUpdate(XuqmSDK.appKey, "ANDROID", versionCode, userId).data?.let { info ->
val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl) val normalized = info.copy(downloadUrl = normalizeDownloadUrl(info.downloadUrl) ?: info.downloadUrl)
// 静默检查时跳过已忽略版本;主动检查bypassIgnore=true始终展示 // 静默检查时跳过已忽略版本;主动检查bypassIgnore=true始终展示
if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate val afterIgnore = if (!bypassIgnore && normalized.needsUpdate && !normalized.forceUpdate
&& isVersionIgnored(context, normalized.versionCode) && isVersionIgnored(context, normalized.versionCode)
) { ) {
normalized.copy(needsUpdate = false) normalized.copy(needsUpdate = false)
} else { } else {
normalized normalized
} }
// 检查该版本 APK 是否已下载到本地(含哈希校验)
if (afterIgnore.needsUpdate && afterIgnore.versionCode > 0) {
afterIgnore.copy(alreadyDownloaded = isApkDownloaded(
context, afterIgnore.versionCode, afterIgnore.apkHash))
} else {
afterIgnore
}
} }
}.getOrNull() }.getOrNull()
} }
@ -91,15 +169,86 @@ object UpdateSDK {
} ?: throw IllegalStateException("XuqmSDK not initialized. Check that config.xuqm is present and valid.") } ?: 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( suspend fun downloadAndInstall(
context: Context, context: Context,
downloadUrl: String, downloadUrl: String,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
) = withContext(Dispatchers.IO) { ) = downloadAndInstall(context, downloadUrl, 0, onProgress)
val apkFile = File(context.getExternalFilesDir(null), "update.apk")
FileSDK.downloadToFile(downloadUrl, apkFile, onProgress) // ─────────────────────────────────────────────────────────────────────────
withContext(Dispatchers.Main) { installApk(context, apkFile) } // 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) { private fun installApk(context: Context, apkFile: File) {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {

查看文件

@ -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()
}
}

查看文件

@ -11,4 +11,8 @@ data class UpdateInfo(
val marketUrl: String = "", val marketUrl: String = "",
/** 服务端要求先登录再检测更新时为 true,此时 needsUpdate 通常为 false */ /** 服务端要求先登录再检测更新时为 true,此时 needsUpdate 通常为 false */
val requiresLogin: Boolean = false, val requiresLogin: Boolean = false,
/** 该版本 APK 已下载到本地且哈希校验通过,可直接安装 */
val alreadyDownloaded: Boolean = false,
/** APK 文件的 SHA-256 哈希值,用于校验本地文件完整性 */
val apkHash: String = "",
) )