diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cc27403..9d010d6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 40 - versionName = "0.9.27" + versionCode = 41 + versionName = "0.9.28" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd3b37a..6542581 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -36,6 +37,15 @@ android:name=".service.AgentForegroundService" android:exported="false" android:foregroundServiceType="dataSync" /> + + + Unit, + ) { + val connection = (URL(serverUrl + release.downloadUrl).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 10_000 + readTimeout = 30_000 + useCaches = false + setRequestProperty("Authorization", "Bearer $token") + setRequestProperty("Accept", "application/vnd.android.package-archive") + } + try { + val status = connection.responseCode + if (status !in 200..299) { + val body = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + val json = runCatching { JSONObject(body) }.getOrElse { JSONObject() } + throw AgentApiException(status, json.optString("code", "HTTP_$status"), json.optString("message", "Agent APK 下载失败"), status >= 500) + } + target.parentFile?.mkdirs() + val digest = MessageDigest.getInstance("SHA-256") + var written = 0L + connection.inputStream.use { input -> + FileOutputStream(target).use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + if (cancelled.get()) throw InterruptedException("下载已取消") + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + written += count + if (release.byteSize > 0) onProgress((written * 100 / release.byteSize).coerceIn(0, 100).toInt()) + } + output.fd.sync() + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (written != release.byteSize || !actual.equals(release.sha256, ignoreCase = true)) { + target.delete() + error("APK 完整性校验失败,已删除下载文件") + } + onProgress(100) + } catch (error: Throwable) { + target.delete() + throw error + } finally { + connection.disconnect() + } + } + fun nextTask(token: String): AgentTask? { val response = request("GET", "/api/agent/v1/tasks/next", null, token) ?: return null return task(response.getJSONObject("data")) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt index 84a3101..4c14e75 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt @@ -19,6 +19,7 @@ import cn.ilapage.goauto.agent.BuildConfig import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.identity.SecureDeviceStore import cn.ilapage.goauto.agent.network.AgentApiClient +import cn.ilapage.goauto.agent.network.AgentAppRelease import cn.ilapage.goauto.agent.network.CollectionHistoryItem import cn.ilapage.goauto.agent.network.PurchaseHistoryItem import cn.ilapage.goauto.agent.network.ServerUrlPolicy @@ -29,11 +30,15 @@ import cn.ilapage.goauto.agent.service.AgentStateStore import cn.ilapage.goauto.agent.service.CollectionIntervalRange import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy import cn.ilapage.goauto.agent.service.HistoryRangePolicy +import cn.ilapage.goauto.agent.update.AgentAppUpdateManager +import cn.ilapage.goauto.agent.update.UpdateCheckResult import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -63,6 +68,13 @@ class AgentSettingsFragment : Fragment() { private lateinit var historyDaysInput: TextInputEditText private lateinit var historySyncButton: MaterialButton private lateinit var historySyncFeedback: TextView + private lateinit var updateButton: MaterialButton + private lateinit var updateFeedback: TextView + private var availableRelease: AgentAppRelease? = null + private var downloadedAPK: File? = null + private var updateBusy = false + private var updateDownloading = false + private var updateCancelled = AtomicBoolean(false) private var testing = false private var syncingHistory = false private val refresh = object : Runnable { @@ -296,6 +308,19 @@ class AgentSettingsFragment : Fragment() { historySyncFeedback.setPadding(0, context.dp(8), 0, 0) addView(historySyncFeedback) })) + addView(context.card(context.cardColumn().apply { + addView(context.label("Agent 应用更新", 18f, context.getColor(R.color.agent_text), true)) + updateFeedback = context.label("当前版本 ${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})", 14f, context.getColor(R.color.agent_text_muted)) + updateFeedback.setPadding(0, context.dp(8), 0, 0) + addView(updateFeedback) + updateButton = MaterialButton(context).apply { + text = "检查更新" + minHeight = context.dp(48) + contentDescription = "检查 Agent 应用更新" + setOnClickListener { handleUpdateAction() } + } + addView(updateButton, fullWidth(12)) + })) addView(context.card(context.cardColumn().apply { addView(context.label("设备与诊断", 18f, context.getColor(R.color.agent_text), true)) diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text)) @@ -308,6 +333,13 @@ class AgentSettingsFragment : Fragment() { override fun onResume() { super.onResume() + if (::updateFeedback.isInitialized && !updateBusy) { + AgentAppUpdateManager(requireContext()).cachedAvailable()?.let { + availableRelease = it + updateFeedback.text = "发现新版本 ${it.versionName}(${it.versionCode}),可手动下载并安装。" + } + refreshUpdateButton() + } handler.removeCallbacks(refresh) handler.post(refresh) } @@ -323,6 +355,7 @@ class AgentSettingsFragment : Fragment() { } override fun onDestroy() { + updateCancelled.set(true) executor.shutdownNow() super.onDestroy() } @@ -350,6 +383,106 @@ class AgentSettingsFragment : Fragment() { } } + private fun handleUpdateAction() { + if (updateDownloading) { + updateCancelled.set(true) + updateFeedback.text = "正在取消下载…" + return + } + if (updateBusy) return + val apk = downloadedAPK + if (apk?.isFile == true) { + val manager = AgentAppUpdateManager(requireContext()) + if (!manager.canInstallPackages()) { + updateFeedback.text = "请在系统页面允许此来源安装应用,返回后点击“安装已下载版本”。" + manager.openInstallPermission() + } else { + runCatching { manager.install(apk) }.onFailure { showUpdateError(it) } + } + return + } + val release = availableRelease + if (release != null) downloadUpdate(release) else checkUpdate() + } + + private fun checkUpdate() { + val state = stateStore.read() + if (state.code == "BUSY" || state.currentTaskId != null) { + updateFeedback.text = "任务执行中,暂时不能检查或安装更新。" + updateFeedback.setTextColor(requireContext().getColor(R.color.agent_warning)) + return + } + updateBusy = true + updateDownloading = true + refreshUpdateButton() + updateFeedback.text = "正在检查更新…" + executor.execute { + val result = runCatching { AgentAppUpdateManager(requireContext()).check() } + activity?.runOnUiThread { + if (!isAdded || view == null) return@runOnUiThread + updateBusy = false + updateDownloading = false + result.onSuccess { checked -> + when (checked) { + UpdateCheckResult.Busy -> updateFeedback.text = "任务执行中,暂时不能更新 Agent。" + UpdateCheckResult.NotRegistered -> updateFeedback.text = "设备尚未注册或服务器地址未配置。" + UpdateCheckResult.UpToDate -> updateFeedback.text = "当前已是最新版本 ${BuildConfig.VERSION_NAME}。" + is UpdateCheckResult.Available -> { + availableRelease = checked.release + updateFeedback.text = "发现新版本 ${checked.release.versionName}(${checked.release.versionCode})。\n${checked.release.releaseNotes}" + MaterialAlertDialogBuilder(requireContext()).setTitle("发现 Agent 新版本").setMessage(updateFeedback.text).setNegativeButton("稍后", null).setPositiveButton("下载") { _, _ -> downloadUpdate(checked.release) }.show() + } + } + }.onFailure(::showUpdateError) + refreshUpdateButton() + } + } + } + + private fun downloadUpdate(release: AgentAppRelease) { + val state = stateStore.read() + if (state.code == "BUSY" || state.currentTaskId != null) { + updateFeedback.text = "任务执行中,下载已阻止。" + return + } + updateBusy = true + updateCancelled = AtomicBoolean(false) + refreshUpdateButton() + executor.execute { + val result = runCatching { + AgentAppUpdateManager(requireContext()).download(release, updateCancelled) { progress -> + handler.post { if (isAdded && view != null) updateFeedback.text = "正在下载 ${release.versionName}:$progress%" } + } + } + activity?.runOnUiThread { + if (!isAdded || view == null) return@runOnUiThread + updateBusy = false + result.onSuccess { apk -> + downloadedAPK = apk + updateFeedback.text = "下载及 SHA-256 校验完成。安装需要在系统确认页面手动确认。" + }.onFailure(::showUpdateError) + refreshUpdateButton() + } + } + } + + private fun refreshUpdateButton() { + if (!::updateButton.isInitialized) return + updateButton.isEnabled = !updateBusy || updateDownloading + updateButton.text = when { + updateDownloading -> "取消下载" + updateBusy -> "处理中…" + downloadedAPK?.isFile == true -> "安装已下载版本" + availableRelease != null -> "下载新版本" + else -> "检查更新" + } + } + + private fun showUpdateError(error: Throwable) { + updateFeedback.text = error.message?.takeIf { it.isNotBlank() } ?: "更新失败,请稍后重试。" + updateFeedback.setTextColor(requireContext().getColor(R.color.agent_error)) + } + private fun confirmSave() { val normalizedUrl = validateInputs(requireName = true) ?: return val deviceName = nameInput.text?.toString()?.trim().orEmpty() @@ -526,6 +659,7 @@ class AgentSettingsFragment : Fragment() { val context = requireContext() val state = stateStore.read() val busy = state.code == "BUSY" || state.currentTaskId != null + if (busy && updateDownloading) updateCancelled.set(true) val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing) serverInput.isEnabled = editable nameInput.isEnabled = editable diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/update/AgentAppUpdateManager.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/update/AgentAppUpdateManager.kt new file mode 100644 index 0000000..1441cd3 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/update/AgentAppUpdateManager.kt @@ -0,0 +1,96 @@ +package cn.ilapage.goauto.agent.update + +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 cn.ilapage.goauto.agent.BuildConfig +import cn.ilapage.goauto.agent.identity.SecureDeviceStore +import cn.ilapage.goauto.agent.network.AgentApiClient +import cn.ilapage.goauto.agent.network.AgentAppRelease +import cn.ilapage.goauto.agent.service.AgentSettingsStore +import cn.ilapage.goauto.agent.service.AgentStateStore +import org.json.JSONObject +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +sealed class UpdateCheckResult { + data object NotRegistered : UpdateCheckResult() + data object Busy : UpdateCheckResult() + data object UpToDate : UpdateCheckResult() + data class Available(val release: AgentAppRelease) : UpdateCheckResult() +} + +class AgentAppUpdateManager(private val context: Context) { + private val appContext = context.applicationContext + + fun check(): UpdateCheckResult { + val state = AgentStateStore(appContext).read() + if (AgentUpdatePolicy.blockedByTask(state.code, state.currentTaskId)) return UpdateCheckResult.Busy + val credentials = SecureDeviceStore(appContext).credentials() ?: return UpdateCheckResult.NotRegistered + val serverUrl = AgentSettingsStore(appContext).serverUrl() + if (serverUrl.isBlank()) return UpdateCheckResult.NotRegistered + val release = AgentApiClient(serverUrl).latestAgentAppRelease(credentials.token) + cacheLatest(release) + return if (release == null || !AgentUpdatePolicy.updateAvailable(BuildConfig.VERSION_CODE.toLong(), release.versionCode)) UpdateCheckResult.UpToDate else UpdateCheckResult.Available(release) + } + + fun cachedAvailable(): AgentAppRelease? = preferences().getString(CACHED_RELEASE, null)?.let { raw -> + runCatching { + val data = JSONObject(raw) + AgentAppRelease(data.getLong("id"), data.getLong("versionCode"), data.getString("versionName"), data.getString("sha256"), data.getLong("byteSize"), data.optString("releaseNotes"), data.getString("downloadUrl")) + }.getOrNull()?.takeIf { it.versionCode > BuildConfig.VERSION_CODE } + } + + fun download(release: AgentAppRelease, cancelled: AtomicBoolean, onProgress: (Int) -> Unit): File { + check(!AgentStateStore(appContext).read().let { AgentUpdatePolicy.blockedByTask(it.code, it.currentTaskId) }) { "任务执行中,不能更新 Agent" } + val credentials = checkNotNull(SecureDeviceStore(appContext).credentials()) { "设备尚未注册" } + val serverUrl = AgentSettingsStore(appContext).serverUrl() + check(serverUrl.isNotBlank()) { "服务器地址未配置" } + val target = File(File(appContext.cacheDir, "agent-updates"), "agent-${release.versionCode}.apk") + AgentApiClient(serverUrl).downloadAgentAppRelease(release, credentials.token, target, cancelled, onProgress) + return target + } + + fun canInstallPackages(): Boolean = Build.VERSION.SDK_INT < 26 || appContext.packageManager.canRequestPackageInstalls() + + fun openInstallPermission() { + if (Build.VERSION.SDK_INT >= 26) { + context.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${appContext.packageName}"))) + } + } + + fun install(apk: File) { + check(!AgentStateStore(appContext).read().let { AgentUpdatePolicy.blockedByTask(it.code, it.currentTaskId) }) { "任务执行中,不能安装更新" } + check(apk.isFile) { "已下载的 APK 不存在,请重新下载" } + check(canInstallPackages()) { "请先允许此来源安装应用" } + val uri = FileProvider.getUriForFile(appContext, "${appContext.packageName}.fileprovider", apk) + context.startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + + private fun cacheLatest(release: AgentAppRelease?) { + val value = release?.let { JSONObject().put("id", it.id).put("versionCode", it.versionCode).put("versionName", it.versionName).put("sha256", it.sha256).put("byteSize", it.byteSize).put("releaseNotes", it.releaseNotes).put("downloadUrl", it.downloadUrl).toString() } + preferences().edit().putString(CACHED_RELEASE, value).apply() + } + + private fun preferences() = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + companion object { + private const val PREFERENCES = "goauto_agent_update" + private const val CACHED_RELEASE = "cached_release" + private val startupExecutor = Executors.newSingleThreadExecutor() + + fun checkOnceAtStartup(context: Context) { + val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + if (preferences.getBoolean("startup_checked_${BuildConfig.VERSION_CODE}", false)) return + preferences.edit().putBoolean("startup_checked_${BuildConfig.VERSION_CODE}", true).apply() + startupExecutor.execute { runCatching { AgentAppUpdateManager(context).check() } } + } + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/update/AgentUpdatePolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/update/AgentUpdatePolicy.kt new file mode 100644 index 0000000..fb7a8a6 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/update/AgentUpdatePolicy.kt @@ -0,0 +1,6 @@ +package cn.ilapage.goauto.agent.update + +object AgentUpdatePolicy { + fun blockedByTask(stateCode: String, currentTaskId: Long?): Boolean = stateCode == "BUSY" || currentTaskId != null + fun updateAvailable(currentVersionCode: Long, latestVersionCode: Long): Boolean = latestVersionCode > currentVersionCode +} diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..2b4375b --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/update/AgentUpdatePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/update/AgentUpdatePolicyTest.kt new file mode 100644 index 0000000..7db62ef --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/update/AgentUpdatePolicyTest.kt @@ -0,0 +1,19 @@ +package cn.ilapage.goauto.agent.update + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AgentUpdatePolicyTest { + @Test fun onlyHigherIntegerVersionCodeIsAnUpdate() { + assertTrue(AgentUpdatePolicy.updateAvailable(40, 41)) + assertFalse(AgentUpdatePolicy.updateAvailable(41, 41)) + assertFalse(AgentUpdatePolicy.updateAvailable(41, 40)) + } + + @Test fun anyActiveTaskBlocksUpdate() { + assertTrue(AgentUpdatePolicy.blockedByTask("BUSY", null)) + assertTrue(AgentUpdatePolicy.blockedByTask("ONLINE", 99)) + assertFalse(AgentUpdatePolicy.blockedByTask("ONLINE", null)) + } +}