feat(update): 添加 API Key 管理和 WebSocket 实时通知功能
- 新增 API Key 管理功能,支持外部工具认证调用平台 API - 实现 WebSocket 实时通知,版本发布时推送轻量通知给客户端 - 添加 APK 文件哈希校验,支持已下载检测和直接安装 - 支持外部 APK 上传使用 API Key 认证 - 优化私有化部署自动注入 nginx WebSocket 代理配置 - 扩展 SDK 功能包括已下载检测、直接安装和实时通知监听
这个提交包含在:
父节点
e5a78b9a52
当前提交
7caf7ed32d
@ -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.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 {
|
||||
|
||||
@ -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 = "",
|
||||
/** 服务端要求先登录再检测更新时为 true,此时 needsUpdate 通常为 false */
|
||||
val requiresLogin: Boolean = false,
|
||||
/** 该版本 APK 已下载到本地且哈希校验通过,可直接安装 */
|
||||
val alreadyDownloaded: Boolean = false,
|
||||
/** APK 文件的 SHA-256 哈希值,用于校验本地文件完整性 */
|
||||
val apkHash: String = "",
|
||||
)
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户