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:
dongliang
2026-04-30 21:20:06 +09:30
parent 76997f4830
commit 6e8b210f8e
9 changed files with 521 additions and 3 deletions

View File

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