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

@@ -33,6 +33,9 @@
<!-- 相机(二维码扫描备用) --> <!-- 相机(二维码扫描备用) -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- APK 安装OTA 更新API 26+ -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".app.WatchApplication" android:name=".app.WatchApplication"
android:allowBackup="true" android:allowBackup="true"
@@ -59,6 +62,17 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FileProviderOTA APK 安装时共享文件) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- 开机自启广播接收器 --> <!-- 开机自启广播接收器 -->
<receiver <receiver
android:name=".app.BootReceiver" android:name=".app.BootReceiver"

View File

@@ -9,9 +9,13 @@ import androidx.appcompat.app.AppCompatActivity
import com.xiaoqu.watch.databinding.ActivityMainBinding import com.xiaoqu.watch.databinding.ActivityMainBinding
import com.xiaoqu.watch.event.AppEvent import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.device.screen.ScreenController
import com.xiaoqu.watch.device.sensor.AccelerometerWakeController import com.xiaoqu.watch.device.sensor.AccelerometerWakeController
import com.xiaoqu.watch.service.manager.BluetoothScanManager
import com.xiaoqu.watch.service.manager.NotificationManager import com.xiaoqu.watch.service.manager.NotificationManager
import com.xiaoqu.watch.service.manager.SystemStateMonitor import com.xiaoqu.watch.service.manager.SystemStateMonitor
import com.xiaoqu.watch.service.manager.UpdateManager
import com.xiaoqu.watch.ui.widget.UpdateDialogView
import com.xiaoqu.watch.ui.widget.NotificationBannerView import com.xiaoqu.watch.ui.widget.NotificationBannerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -37,6 +41,12 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var eventBus: EventBus @Inject lateinit var eventBus: EventBus
/** 加速度计抬手亮屏控制器 */ /** 加速度计抬手亮屏控制器 */
@Inject lateinit var accelerometerWake: AccelerometerWakeController @Inject lateinit var accelerometerWake: AccelerometerWakeController
/** OTA 更新管理器 */
@Inject lateinit var updateManager: UpdateManager
@Inject lateinit var screenController: ScreenController
@Inject lateinit var bluetoothScanManager: BluetoothScanManager
/** OTA 更新弹窗 */
lateinit var updateDialog: UpdateDialogView
lateinit var notificationBanner: NotificationBannerView lateinit var notificationBanner: NotificationBannerView
private val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -70,6 +80,10 @@ class MainActivity : AppCompatActivity() {
// 初始化通知横幅 // 初始化通知横幅
notificationBanner = binding.notificationBanner notificationBanner = binding.notificationBanner
// 初始化 OTA 更新弹窗
updateDialog = binding.updateDialog
setupUpdateDialog()
// 监听 MQTT type=1 → 处理通知 + 显示横幅 // 监听 MQTT type=1 → 处理通知 + 显示横幅
observeMqttMessages() observeMqttMessages()
@@ -166,4 +180,64 @@ class MainActivity : AppCompatActivity() {
} }
}) })
} }
// ===== OTA 更新 =====
/** 设置更新弹窗按钮回调 */
private fun setupUpdateDialog() {
updateDialog.onActionClick = {
startDownloadAndInstall()
}
}
/**
* 检查版本更新(由 HomeFragment.onResume 调用)
* 有更新时显示弹窗,停止蓝牙扫描
*/
fun checkForUpdate() {
if (updateManager.isUpdating) return
activityScope.launch {
val info = updateManager.checkUpdate() ?: return@launch
// 停止蓝牙扫描
bluetoothScanManager.stop()
// 显示更新弹窗
updateDialog.showDiscover()
// 保存下载地址
pendingUpdateUrl = info.url
}
}
/** 待下载的更新 URL */
private var pendingUpdateUrl: String? = null
/** 开始下载并安装 APK */
private fun startDownloadAndInstall() {
val url = pendingUpdateUrl ?: return
updateManager.isUpdating = true
// 保持屏幕常亮
screenController.turnOn()
// 切换到下载状态
updateDialog.showDownloading()
activityScope.launch {
val file = updateManager.downloadApk(url) { progress, bytes ->
// 进度回调在 IO 线程,切回主线程更新 UI
launch(kotlinx.coroutines.Dispatchers.Main) {
updateDialog.updateProgress(progress, bytes)
}
}
// 回到主线程处理结果
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
if (file != null) {
// 下载成功 → 触发安装
updateManager.installApk(file)
} else {
// 下载失败 → 显示错误
updateManager.isUpdating = false
updateDialog.showError()
}
}
}
}
} }

View File

@@ -21,9 +21,9 @@ interface CommonApi {
@GET("watch/getWatchByImei") @GET("watch/getWatchByImei")
suspend fun getWatchByImei(@Query("imei") imei: String): ApiResponse<WatchBindInfo> suspend fun getWatchByImei(@Query("imei") imei: String): ApiResponse<WatchBindInfo>
/** 检查版本更新 */ /** 检查版本更新POST传当前版本号返回是否有更新+下载地址) */
@GET("newAppVersion/queryWatch") @POST("newAppVersion/queryWatch")
suspend fun checkVersion(@Query("imei") imei: String): ApiResponse<Any> suspend fun checkVersion(@Body params: HashMap<String, Any>): ApiResponse<Map<String, Any>>
/** 上报设备状态电量、蓝牙、NFC等 */ /** 上报设备状态电量、蓝牙、NFC等 */
@POST("watch/setWatchStatusByImeiFormWatch") @POST("watch/setWatchStatusByImeiFormWatch")

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
)

View File

@@ -146,6 +146,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// 检查版本更新5分钟最小间隔
(activity as? com.xiaoqu.watch.app.MainActivity)?.checkForUpdate()
if (notificationManager.pendingCount <= 0) { if (notificationManager.pendingCount <= 0) {
activeDotCards.clear() activeDotCards.clear()
renderDots() renderDots()

View File

@@ -0,0 +1,110 @@
package com.xiaoqu.watch.ui.widget
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.xiaoqu.watch.R
/**
* OTA 更新弹窗 View全屏覆盖
*
* 三种状态:
* - DISCOVER: "发现新版本" + "立即更新" 按钮
* - DOWNLOADING: "请勿关机!正在升级系统..." + 进度条
* - ERROR: "下载失败" + "重新下载" 按钮
*
* 添加到 activity_main.xml 最顶层,默认 GONE。
* 通过 show(state) 显示,不提供关闭方式(强制更新)。
*/
class UpdateDialogView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val tvIcon: TextView
private val tvTitle: TextView
private val tvDesc: TextView
private val progressContainer: View
private val progressBar: View
private val tvProgress: TextView
private val btnAction: TextView
/** 按钮点击回调("立即更新"或"重新下载" */
var onActionClick: (() -> Unit)? = null
init {
LayoutInflater.from(context).inflate(R.layout.view_update_dialog, this, true)
tvIcon = findViewById(R.id.tvIcon)
tvTitle = findViewById(R.id.tvTitle)
tvDesc = findViewById(R.id.tvDesc)
progressContainer = findViewById(R.id.progressContainer)
progressBar = findViewById(R.id.progressBar)
tvProgress = findViewById(R.id.tvProgress)
btnAction = findViewById(R.id.btnAction)
btnAction.setOnClickListener { onActionClick?.invoke() }
}
/** 显示"发现新版本"状态 */
fun showDiscover() {
visibility = View.VISIBLE
tvIcon.text = "\ue60e"
tvTitle.text = "发现新版本"
tvDesc.visibility = View.GONE
progressContainer.visibility = View.GONE
tvProgress.visibility = View.GONE
btnAction.visibility = View.VISIBLE
btnAction.text = "立即更新"
}
/** 显示"下载中"状态 */
fun showDownloading() {
tvIcon.text = "\ueb2c"
tvTitle.text = "请勿关机!"
tvDesc.visibility = View.VISIBLE
tvDesc.text = "正在升级系统..."
progressContainer.visibility = View.VISIBLE
tvProgress.visibility = View.VISIBLE
btnAction.visibility = View.GONE
updateProgress(0)
}
/**
* 更新下载进度
* @param percent 百分比 0~100-1 表示总大小未知
* @param downloadedBytes 已下载字节数
*/
fun updateProgress(percent: Int, downloadedBytes: Long = 0) {
if (percent >= 0) {
// 已知进度:显示百分比
val params = progressBar.layoutParams
params.width = (progressContainer.width * percent / 100).coerceAtLeast(0)
progressBar.layoutParams = params
tvProgress.text = "${percent}%"
} else {
// 未知进度:显示已下载大小
val mb = downloadedBytes / 1024f / 1024f
tvProgress.text = String.format("已下载 %.1f MB", mb)
}
}
/** 显示"下载失败"状态 */
fun showError(message: String = "下载失败") {
tvIcon.text = "\ue60e"
tvTitle.text = message
tvDesc.visibility = View.GONE
progressContainer.visibility = View.GONE
tvProgress.visibility = View.GONE
btnAction.visibility = View.VISIBLE
btnAction.text = "重新下载"
}
/** 隐藏弹窗 */
fun hide() {
visibility = View.GONE
}
}

View File

@@ -33,4 +33,11 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:visibility="gone" /> android:visibility="gone" />
<!-- Layer 4: OTA 更新弹窗(最顶层,全屏覆盖,默认隐藏) -->
<com.xiaoqu.watch.ui.widget.UpdateDialogView
android:id="@+id/updateDialog"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout> </FrameLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- OTA 更新弹窗(全屏覆盖,阻断用户操作) -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#E6000000"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<!-- 图标 -->
<TextView
android:id="@+id/tvIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/iconfont"
android:text="&#xe60e;"
android:textColor="@android:color/white"
android:textSize="40sp" />
<!-- 标题 -->
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="发现新版本"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
<!-- 描述(下载中显示"请勿关机!正在升级系统..." -->
<TextView
android:id="@+id/tvDesc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#AAFFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<!-- 进度条(下载中显示) -->
<FrameLayout
android:id="@+id/progressContainer"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginTop="16dp"
android:background="#33FFFFFF"
android:visibility="gone">
<View
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="#4CAF50" />
</FrameLayout>
<!-- 进度文字 -->
<TextView
android:id="@+id/tvProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#AAFFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<!-- 按钮(立即更新 / 重新下载) -->
<TextView
android:id="@+id/btnAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_btn_primary"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp"
android:text="立即更新"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider 路径配置(用于 OTA APK 安装) -->
<paths>
<external-files-path name="apk" path="." />
</paths>