feat(agent): add authenticated in-app updates (#144)

This commit is contained in:
QiuSW
2026-08-29 21:35:14 +08:00
parent f0f5f5bd14
commit 56845104d1
9 changed files with 353 additions and 2 deletions
+2 -2
View File
@@ -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"
+10
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<queries>
<package android:name="com.xunmeng.pinduoduo" />
@@ -36,6 +37,15 @@
android:name=".service.AgentForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<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>
<service
android:name=".automation.GoAutoAccessibilityService"
android:exported="true"
@@ -15,6 +15,7 @@ import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.ui.AgentSettingsFragment
import cn.ilapage.goauto.agent.ui.AgentStatusFragment
import cn.ilapage.goauto.agent.ui.TaskHistoryFragment
import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
import com.google.android.material.bottomnavigation.BottomNavigationView
class MainActivity : AppCompatActivity() {
@@ -38,6 +39,7 @@ class MainActivity : AppCompatActivity() {
setContentView(buildContent())
requestNotificationPermission()
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
AgentAppUpdateManager.checkOnceAtStartup(this)
}
override fun onNewIntent(intent: android.content.Intent) {
@@ -8,6 +8,10 @@ import java.net.URL
import java.net.URLEncoder
import java.util.UUID
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicBoolean
data class DeviceInfo(
val installId: String,
@@ -33,6 +37,16 @@ data class HeartbeatResult(
val heartbeatIntervalSeconds: Int,
)
data class AgentAppRelease(
val id: Long,
val versionCode: Long,
val versionName: String,
val sha256: String,
val byteSize: Long,
val releaseNotes: String,
val downloadUrl: String,
)
data class AgentTask(
val taskId: Long,
val attemptNumber: Int,
@@ -219,6 +233,72 @@ class AgentApiClient(private val serverUrl: String) {
)
}
fun latestAgentAppRelease(token: String): AgentAppRelease? {
val response = request("GET", "/api/agent/v1/app/latest", null, token) ?: return null
if (response.isNull("data")) return null
val data = response.getJSONObject("data")
return AgentAppRelease(
id = data.getLong("id"), versionCode = data.getLong("versionCode"),
versionName = data.getString("versionName"), sha256 = data.getString("sha256"),
byteSize = data.getLong("byteSize"), releaseNotes = data.optString("releaseNotes"),
downloadUrl = data.getString("downloadUrl"),
)
}
fun downloadAgentAppRelease(
release: AgentAppRelease,
token: String,
target: File,
cancelled: AtomicBoolean,
onProgress: (Int) -> 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"))
@@ -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
@@ -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() } }
}
}
}
@@ -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
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="agent_updates" path="agent-updates/" />
</paths>
@@ -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))
}
}