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.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 = "",
|
||||||
)
|
)
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户