feat(ota): OTA APK 下载更新模块
REQ-20260430-0041 新增 UpdateManager: - POST newAppVersion/queryWatch 检查版本(5分钟最小间隔) - OkHttp 下载 APK(进度回调,独立超时配置) - FileProvider + Intent ACTION_VIEW 触发系统安装 - 非 .apk 链接自动跳过 新增 UpdateDialogView: - 全屏覆盖弹窗,三种状态(发现新版本/下载中/下载失败) - 进度条支持百分比和未知大小两种模式 配置变更: - AndroidManifest: REQUEST_INSTALL_PACKAGES + FileProvider - CommonApi.checkVersion: GET→POST,传 version 参数 集成点: - HomeFragment.onResume → MainActivity.checkForUpdate() - MainActivity 管理弹窗交互和下载流程 - 更新时停止蓝牙扫描 + 保持屏幕常亮 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package com.xiaoqu.watch.service.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import com.xiaoqu.watch.network.ApiResult
|
||||
import com.xiaoqu.watch.network.api.CommonApi
|
||||
import com.xiaoqu.watch.network.safeApiCall
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* OTA 更新管理器
|
||||
* 负责版本检查、APK 下载、触发系统安装
|
||||
*
|
||||
* 流程:
|
||||
* 1. checkUpdate() → POST newAppVersion/queryWatch 查询是否有新版本
|
||||
* 2. downloadApk() → OkHttp 下载 APK 到本地,回调进度
|
||||
* 3. installApk() → FileProvider + Intent ACTION_VIEW 触发系统安装界面
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val commonApi: CommonApi
|
||||
) {
|
||||
companion object {
|
||||
/** 最小检查间隔(毫秒),5 分钟内不重复检查 */
|
||||
private const val MIN_CHECK_INTERVAL_MS = 5 * 60 * 1000L
|
||||
/** 下载文件名 */
|
||||
private const val APK_FILE_NAME = "update.apk"
|
||||
/** FileProvider authority */
|
||||
private const val FILE_PROVIDER_AUTHORITY_SUFFIX = ".fileprovider"
|
||||
}
|
||||
|
||||
/** 上次检查时间 */
|
||||
private var lastCheckTime = 0L
|
||||
|
||||
/** 是否正在更新中(下载或安装) */
|
||||
@Volatile
|
||||
var isUpdating = false
|
||||
|
||||
/** 独立的下载用 OkHttpClient(不复用业务 API 的 client,避免签名拦截器干扰) */
|
||||
private val downloadClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 检查版本更新(5 分钟最小间隔)
|
||||
* @return 有更新返回 UpdateInfo,无更新或检查失败返回 null
|
||||
*/
|
||||
suspend fun checkUpdate(): UpdateInfo? {
|
||||
// 最小间隔检查
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastCheckTime < MIN_CHECK_INTERVAL_MS) {
|
||||
Timber.d("OTA: 距上次检查不足5分钟,跳过")
|
||||
return null
|
||||
}
|
||||
lastCheckTime = now
|
||||
|
||||
// 获取当前版本号
|
||||
val versionName = try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
|
||||
} catch (e: Exception) {
|
||||
"1.0.0"
|
||||
}
|
||||
|
||||
Timber.d("OTA: 检查更新,当前版本 %s", versionName)
|
||||
|
||||
// 调用 API
|
||||
val params = hashMapOf<String, Any>("version" to versionName)
|
||||
val result = safeApiCall { commonApi.checkVersion(params) }
|
||||
|
||||
return when (result) {
|
||||
is ApiResult.Success -> {
|
||||
val data = result.data
|
||||
if (data != null) {
|
||||
val yesONo = data["yesONo"]?.toString() ?: ""
|
||||
val url = data["url"]?.toString() ?: ""
|
||||
|
||||
if (yesONo == "YES" && url.isNotBlank()) {
|
||||
// 只处理 APK 格式的更新
|
||||
if (!url.lowercase().endsWith(".apk")) {
|
||||
Timber.w("OTA: 更新 URL 不是 APK 格式,跳过: %s", url)
|
||||
return null
|
||||
}
|
||||
Timber.d("OTA: 发现新版本,下载地址: %s", url)
|
||||
UpdateInfo(url = url)
|
||||
} else {
|
||||
Timber.d("OTA: 当前已是最新版本")
|
||||
null
|
||||
}
|
||||
} else null
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Timber.w("OTA: 版本检查失败 - %s", result.message)
|
||||
null
|
||||
}
|
||||
is ApiResult.NetworkError -> {
|
||||
Timber.w(result.exception, "OTA: 版本检查网络异常")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 APK
|
||||
* @param url 下载地址
|
||||
* @param onProgress 进度回调:正数=百分比(0~100),-1=总大小未知(只报告已下载字节数)
|
||||
* @return 成功返回文件,失败返回 null
|
||||
*/
|
||||
suspend fun downloadApk(
|
||||
url: String,
|
||||
onProgress: (progress: Int, downloadedBytes: Long) -> Unit
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 清理旧文件
|
||||
val file = File(context.getExternalFilesDir(null), APK_FILE_NAME)
|
||||
if (file.exists()) file.delete()
|
||||
|
||||
Timber.d("OTA: 开始下载 APK: %s", url)
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = downloadClient.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Timber.w("OTA: 下载失败,HTTP %d", response.code)
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body ?: return@withContext null
|
||||
val total = body.contentLength()
|
||||
|
||||
file.outputStream().use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var downloaded = 0L
|
||||
var read: Int
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
// 进度回调(切回主线程由调用方处理)
|
||||
if (total > 0) {
|
||||
onProgress((downloaded * 100 / total).toInt(), downloaded)
|
||||
} else {
|
||||
onProgress(-1, downloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("OTA: 下载完成,文件大小 %d bytes", file.length())
|
||||
file
|
||||
} catch (e: IOException) {
|
||||
Timber.w(e, "OTA: APK 下载异常")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "OTA: APK 下载未知异常")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 APK(触发系统安装界面)
|
||||
* 使用 FileProvider 共享文件,通过 Intent ACTION_VIEW 启动安装器
|
||||
*/
|
||||
fun installApk(file: File) {
|
||||
// API 26+ 检查是否允许安装未知来源
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
Timber.w("OTA: 未允许安装未知来源,引导到设置")
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}${FILE_PROVIDER_AUTHORITY_SUFFIX}",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Timber.d("OTA: 已触发系统安装界面")
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "OTA: 触发安装失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新信息 */
|
||||
data class UpdateInfo(
|
||||
val url: String
|
||||
)
|
||||
Reference in New Issue
Block a user