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))
+ }
+}