From 6e8b210f8ece34dc65baa4398840df3afaf22ccd Mon Sep 17 00:00:00 2001 From: dongliang Date: Thu, 30 Apr 2026 21:20:06 +0930 Subject: [PATCH] =?UTF-8?q?feat(ota):=20OTA=20APK=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/src/main/AndroidManifest.xml | 14 ++ .../java/com/xiaoqu/watch/app/MainActivity.kt | 74 ++++++ .../com/xiaoqu/watch/network/api/CommonApi.kt | 6 +- .../watch/service/manager/UpdateManager.kt | 216 ++++++++++++++++++ .../com/xiaoqu/watch/ui/home/HomeFragment.kt | 3 + .../watch/ui/widget/UpdateDialogView.kt | 110 +++++++++ app/src/main/res/layout/activity_main.xml | 7 + .../main/res/layout/view_update_dialog.xml | 89 ++++++++ app/src/main/res/xml/file_paths.xml | 5 + 9 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/xiaoqu/watch/service/manager/UpdateManager.kt create mode 100644 app/src/main/java/com/xiaoqu/watch/ui/widget/UpdateDialogView.kt create mode 100644 app/src/main/res/layout/view_update_dialog.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 520ccb4..91af49a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,9 @@ + + + + + + + + + // 进度回调在 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() + } + } + } + } } diff --git a/app/src/main/java/com/xiaoqu/watch/network/api/CommonApi.kt b/app/src/main/java/com/xiaoqu/watch/network/api/CommonApi.kt index a9f03d4..45b24ff 100644 --- a/app/src/main/java/com/xiaoqu/watch/network/api/CommonApi.kt +++ b/app/src/main/java/com/xiaoqu/watch/network/api/CommonApi.kt @@ -21,9 +21,9 @@ interface CommonApi { @GET("watch/getWatchByImei") suspend fun getWatchByImei(@Query("imei") imei: String): ApiResponse - /** 检查版本更新 */ - @GET("newAppVersion/queryWatch") - suspend fun checkVersion(@Query("imei") imei: String): ApiResponse + /** 检查版本更新(POST,传当前版本号,返回是否有更新+下载地址) */ + @POST("newAppVersion/queryWatch") + suspend fun checkVersion(@Body params: HashMap): ApiResponse> /** 上报设备状态(电量、蓝牙、NFC等) */ @POST("watch/setWatchStatusByImeiFormWatch") diff --git a/app/src/main/java/com/xiaoqu/watch/service/manager/UpdateManager.kt b/app/src/main/java/com/xiaoqu/watch/service/manager/UpdateManager.kt new file mode 100644 index 0000000..3632e51 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/service/manager/UpdateManager.kt @@ -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("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 +) diff --git a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt index acb1dfc..bd01388 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt @@ -146,6 +146,9 @@ class HomeFragment : BaseFragment() { override fun onResume() { super.onResume() + // 检查版本更新(5分钟最小间隔) + (activity as? com.xiaoqu.watch.app.MainActivity)?.checkForUpdate() + if (notificationManager.pendingCount <= 0) { activeDotCards.clear() renderDots() diff --git a/app/src/main/java/com/xiaoqu/watch/ui/widget/UpdateDialogView.kt b/app/src/main/java/com/xiaoqu/watch/ui/widget/UpdateDialogView.kt new file mode 100644 index 0000000..87cda86 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/ui/widget/UpdateDialogView.kt @@ -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 + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index cff7b2d..17806ef 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -33,4 +33,11 @@ android:layout_height="match_parent" android:visibility="gone" /> + + + diff --git a/app/src/main/res/layout/view_update_dialog.xml b/app/src/main/res/layout/view_update_dialog.xml new file mode 100644 index 0000000..820162d --- /dev/null +++ b/app/src/main/res/layout/view_update_dialog.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..64f0383 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + +