feat(agent): add configurable collection interval (#102)

This commit is contained in:
QiuSW
2026-08-26 22:16:27 +08:00
parent 8c51754291
commit 31646758d1
12 changed files with 449 additions and 29 deletions
+3 -1
View File
@@ -18,7 +18,9 @@
设备执行任务时,状态页显示当前任务,设置页会禁用服务器地址、设备名称、测试和保存操作。返回系统无障碍设置后,状态页和设置页会自动刷新真实就绪状态。
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒冷却。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。任务执行和冷却期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒自动返回窗口。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。
采集结果被服务端安全接收后还会按设置页的“采集间隔”等待下一次采集,默认 15 秒、范围 0~600 秒;采购仍可优先执行,手动检查和重新采集不能绕过。该间隔持久化到设备本地,与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
## T05 真机验证记录
@@ -52,6 +52,7 @@ import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.ExecutorService
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
@@ -66,9 +67,12 @@ class AgentForegroundService : Service() {
private val manualCheckRequested = AtomicBoolean(false)
private val registeredThisProcess = AtomicBoolean(false)
private val taskWakeActive = AtomicBoolean(false)
private val collectionCooldownWakeActive = AtomicBoolean(false)
private val idleReturn = IdleReturnCoordinator()
private var taskWakeLock: PowerManager.WakeLock? = null
private var cooldownWakeLock: PowerManager.WakeLock? = null
private var collectionCooldownWakeLock: PowerManager.WakeLock? = null
private val collectionCooldownFuture = AtomicReference<ScheduledFuture<*>?>(null)
private lateinit var identityStore: SecureDeviceStore
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
@@ -97,6 +101,7 @@ class AgentForegroundService : Service() {
createNotificationChannel()
startForeground(NOTIFICATION_ID, notification("正在启动"))
registerNetworkCallback()
resumeCollectionCooldown()
executor.scheduleWithFixedDelay(::triggerSync, 0, HEARTBEAT_SECONDS, TimeUnit.SECONDS)
}
@@ -110,6 +115,8 @@ class AgentForegroundService : Service() {
override fun onDestroy() {
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
collectionCooldownFuture.getAndSet(null)?.cancel(false)
releaseCollectionCooldownWakeLock()
releaseTaskWakeLock()
executor.shutdownNow()
taskExecutor.shutdownNow()
@@ -208,11 +215,22 @@ class AgentForegroundService : Service() {
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
recoverInterruptedPurchases(api, token)
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
if (purchaseTask != null) {
cancelIdleReturn("收到新的采购任务")
schedulePurchaseTask(api, purchaseTask, token)
return MANUAL_PURCHASE_TASK
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
TaskDispatchDecision.RUN_PURCHASE -> {
cancelIdleReturn("收到新的采购任务")
releaseCollectionCooldownWakeLock()
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
return MANUAL_PURCHASE_TASK
}
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
val ticket = requireNotNull(collectionCooldown)
showCollectionCooldown(ticket)
evaluateIdleReturn()
return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}"
}
TaskDispatchDecision.CHECK_COLLECTION -> Unit
}
val task = api.nextTask(token)
if (task == null) {
@@ -254,6 +272,7 @@ class AgentForegroundService : Service() {
taskMutex.release(task.taskId)
stateStore.clearActiveTask(task.taskId)
runningTaskId.set(purchaseStore.activeTaskId())
resumeCollectionCooldown()
triggerSync()
}
}
@@ -490,19 +509,19 @@ class AgentForegroundService : Service() {
}
api.submitResult(task.taskId, UUID.randomUUID().toString(), result, token)
resultSafelySubmitted = true
beginIdleReturnCooldown()
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
beginPostCollectionCooldowns()
} catch (error: TaskFailure) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginIdleReturnCooldown()
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
} catch (error: AgentApiException) {
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
if (resultSafelySubmitted) beginIdleReturnCooldown()
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
} finally {
if (!resultSafelySubmitted) cancelIdleReturn("采集结果未安全提交")
@@ -554,6 +573,76 @@ class AgentForegroundService : Service() {
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
}
private fun beginPostCollectionCooldowns() {
beginIdleReturnCooldown()
val ticket = CollectionCooldownPolicy.arm(
System.currentTimeMillis(),
settingsStore.collectionIntervalSeconds(),
)
if (ticket == null) {
stateStore.clearCollectionCooldown()
collectionCooldownFuture.getAndSet(null)?.cancel(false)
releaseCollectionCooldownWakeLock()
return
}
stateStore.saveCollectionCooldown(ticket)
activateCollectionCooldown(ticket)
}
private fun activeCollectionCooldown(): CollectionCooldownTicket? =
stateStore.activeCollectionCooldown(System.currentTimeMillis())
private fun resumeCollectionCooldown() {
val ticket = activeCollectionCooldown()
if (ticket == null) {
collectionCooldownFuture.getAndSet(null)?.cancel(false)
releaseCollectionCooldownWakeLock()
return
}
activateCollectionCooldown(ticket)
}
private fun activateCollectionCooldown(ticket: CollectionCooldownTicket) {
val now = System.currentTimeMillis()
val remainingMillis = ticket.untilEpochMillis - now
if (remainingMillis <= 0L) {
finishCollectionCooldown(ticket.untilEpochMillis)
return
}
acquireCollectionCooldownWakeLock(remainingMillis)
showCollectionCooldown(ticket)
collectionCooldownFuture.getAndSet(
executor.schedule(
{ finishCollectionCooldown(ticket.untilEpochMillis) },
remainingMillis,
TimeUnit.MILLISECONDS,
),
)?.cancel(false)
}
private fun showCollectionCooldown(ticket: CollectionCooldownTicket) {
val remainingSeconds = CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)
if (remainingSeconds <= 0) return
stateStore.update("COLLECTION_COOLDOWN", "采集间隔中 · 还剩 $remainingSeconds 秒", tokenStored = true)
updateNotification("在线 · 采集间隔中")
}
private fun finishCollectionCooldown(expectedUntilEpochMillis: Long) {
val active = activeCollectionCooldown()
if (active != null && active.untilEpochMillis != expectedUntilEpochMillis) {
activateCollectionCooldown(active)
return
}
if (active != null) {
activateCollectionCooldown(active)
return
}
stateStore.clearCollectionCooldown()
collectionCooldownFuture.getAndSet(null)?.cancel(false)
releaseCollectionCooldownWakeLock()
triggerSync()
}
private fun evaluateIdleReturn() {
if (!idleReturn.isArmed()) return
val accessibility = GoAutoAccessibilityService.instance
@@ -613,9 +702,34 @@ class AgentForegroundService : Service() {
}
}
@Suppress("DEPRECATION")
private fun acquireCollectionCooldownWakeLock(remainingMillis: Long) {
collectionCooldownWakeLock?.let { lock -> if (lock.isHeld) lock.release() }
collectionCooldownWakeLock = getSystemService(PowerManager::class.java).newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"$packageName:collection-cooldown",
).apply {
setReferenceCounted(false)
acquire(remainingMillis.coerceAtMost(COLLECTION_COOLDOWN_MAX_MILLIS) + COOLDOWN_WAKE_GRACE_MILLIS)
}
collectionCooldownWakeActive.set(true)
publishScreenPolicy()
}
private fun releaseCollectionCooldownWakeLock() {
collectionCooldownWakeLock?.let { lock -> if (lock.isHeld) lock.release() }
collectionCooldownWakeLock = null
collectionCooldownWakeActive.set(false)
publishScreenPolicy()
}
private fun publishScreenPolicy() {
stateStore.setKeepScreenOn(
ScreenAwakeResolver.shouldKeepScreenOn(taskWakeActive.get(), idleReturn.isArmed()),
ScreenAwakeResolver.shouldKeepScreenOn(
taskWakeActive.get(),
idleReturn.isArmed(),
collectionCooldownWakeActive.get(),
),
)
}
@@ -695,11 +809,14 @@ class AgentForegroundService : Service() {
const val MANUAL_AUTH_ERROR = "auth_error"
const val MANUAL_NETWORK_ERROR = "network_error"
const val MANUAL_ERROR = "error"
const val MANUAL_COLLECTION_COOLDOWN_PREFIX = "collection_cooldown:"
private const val CHANNEL_ID = "agent_connection"
private const val NOTIFICATION_ID = 1001
private const val HEARTBEAT_SECONDS = 15L
private const val TASK_WAKE_LOCK_TIMEOUT_MILLIS = 5 * 60 * 1000L
private const val IDLE_RETURN_WAKE_LOCK_TIMEOUT_MILLIS = 30_000L
private const val COLLECTION_COOLDOWN_MAX_MILLIS = CollectionIntervalPolicy.MAX_SECONDS * 1_000L
private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L
private const val RETURN_CONFIRM_DELAY_MILLIS = 750L
fun start(context: Context, reconnect: Boolean = false) {
@@ -27,6 +27,66 @@ internal object HistoryRangePolicy {
fun stored(value: Int): Int = value.takeIf { it in validRange } ?: 7
}
internal object CollectionIntervalPolicy {
const val DEFAULT_SECONDS = 15
const val MAX_SECONDS = 600
private val validRange = 0..MAX_SECONDS
fun parse(raw: String): Int? {
val normalized = raw.trim()
if (!normalized.matches(Regex("[0-9]+"))) return null
return normalized.toIntOrNull()?.takeIf { it in validRange }
}
fun stored(value: Int): Int = value.takeIf { it in validRange } ?: DEFAULT_SECONDS
}
internal data class CollectionCooldownTicket(
val untilEpochMillis: Long,
val durationSeconds: Int,
)
internal object CollectionCooldownPolicy {
fun arm(nowEpochMillis: Long, intervalSeconds: Int): CollectionCooldownTicket? {
val seconds = CollectionIntervalPolicy.stored(intervalSeconds)
if (seconds == 0) return null
return CollectionCooldownTicket(
untilEpochMillis = nowEpochMillis + seconds * 1_000L,
durationSeconds = seconds,
)
}
fun normalize(nowEpochMillis: Long, ticket: CollectionCooldownTicket?): CollectionCooldownTicket? {
ticket ?: return null
val duration = ticket.durationSeconds.takeIf { it in 1..CollectionIntervalPolicy.MAX_SECONDS } ?: return null
val remaining = ticket.untilEpochMillis - nowEpochMillis
if (remaining <= 0L) return null
val durationMillis = duration * 1_000L
return if (remaining > durationMillis) {
ticket.copy(untilEpochMillis = nowEpochMillis + durationMillis)
} else ticket
}
fun remainingSeconds(nowEpochMillis: Long, ticket: CollectionCooldownTicket?): Int {
val active = normalize(nowEpochMillis, ticket) ?: return 0
return ((active.untilEpochMillis - nowEpochMillis + 999L) / 1_000L).toInt()
}
}
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
}
class AgentSettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
@@ -37,6 +97,15 @@ class AgentSettingsStore(context: Context) {
fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7))
fun collectionIntervalSeconds(): Int = CollectionIntervalPolicy.stored(
preferences.getInt(COLLECTION_INTERVAL_SECONDS, CollectionIntervalPolicy.DEFAULT_SECONDS),
)
fun saveCollectionIntervalSeconds(seconds: Int) {
require(CollectionIntervalPolicy.stored(seconds) == seconds) { "采集间隔无效" }
check(preferences.edit().putInt(COLLECTION_INTERVAL_SECONDS, seconds).commit()) { "无法保存采集间隔" }
}
fun saveHistoryDays(days: Int) {
require(HistoryRangePolicy.stored(days) == days) { "记录范围无效" }
check(preferences.edit().putInt(HISTORY_DAYS, days).commit()) { "无法保存记录范围" }
@@ -72,6 +141,7 @@ class AgentSettingsStore(context: Context) {
const val SERVER_URL = "server_url"
const val DEVICE_NAME = "device_name"
const val HISTORY_DAYS = "history_days"
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
const val LAST_HISTORY_SYNC_AT = "last_history_sync_at"
}
}
@@ -122,6 +192,37 @@ class AgentStateStore(context: Context) {
preferences.edit().remove(CURRENT_TASK_ID).remove(CURRENT_TASK_TYPE).apply()
}
@Synchronized
internal fun activeCollectionCooldown(nowEpochMillis: Long = System.currentTimeMillis()): CollectionCooldownTicket? {
val stored = preferences.getLong(COLLECTION_COOLDOWN_UNTIL, 0L).takeIf { it > 0L }?.let {
CollectionCooldownTicket(it, preferences.getInt(COLLECTION_COOLDOWN_DURATION_SECONDS, 0))
}
val normalized = CollectionCooldownPolicy.normalize(nowEpochMillis, stored)
when {
normalized == null && stored != null -> clearCollectionCooldown()
normalized != null && normalized != stored -> saveCollectionCooldown(normalized)
}
return normalized
}
@Synchronized
internal fun saveCollectionCooldown(ticket: CollectionCooldownTicket) {
require(ticket.durationSeconds in 1..CollectionIntervalPolicy.MAX_SECONDS)
check(preferences.edit()
.putLong(COLLECTION_COOLDOWN_UNTIL, ticket.untilEpochMillis)
.putInt(COLLECTION_COOLDOWN_DURATION_SECONDS, ticket.durationSeconds)
.commit()
) { "无法保存采集间隔状态" }
}
@Synchronized
fun clearCollectionCooldown() {
preferences.edit()
.remove(COLLECTION_COOLDOWN_UNTIL)
.remove(COLLECTION_COOLDOWN_DURATION_SECONDS)
.apply()
}
private companion object {
const val PREFERENCES = "goauto_agent_runtime"
const val STATE_CODE = "state_code"
@@ -132,5 +233,7 @@ class AgentStateStore(context: Context) {
const val CURRENT_TASK_ID = "current_task_id"
const val CURRENT_TASK_TYPE = "current_task_type"
const val KEEP_SCREEN_ON = "keep_screen_on"
const val COLLECTION_COOLDOWN_UNTIL = "collection_cooldown_until"
const val COLLECTION_COOLDOWN_DURATION_SECONDS = "collection_cooldown_duration_seconds"
}
}
@@ -82,6 +82,6 @@ object IdleReturnForegroundPolicy {
}
object ScreenAwakeResolver {
fun shouldKeepScreenOn(taskRunning: Boolean, cooldownArmed: Boolean): Boolean =
taskRunning || cooldownArmed
fun shouldKeepScreenOn(taskRunning: Boolean, idleReturnArmed: Boolean, collectionCooldownArmed: Boolean): Boolean =
taskRunning || idleReturnArmed || collectionCooldownArmed
}
@@ -26,6 +26,7 @@ import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy
import cn.ilapage.goauto.agent.service.HistoryRangePolicy
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -52,6 +53,9 @@ class AgentSettingsFragment : Fragment() {
private lateinit var disabledReason: TextView
private lateinit var diagnostics: TextView
private lateinit var accessibilityText: TextView
private lateinit var collectionIntervalLayout: TextInputLayout
private lateinit var collectionIntervalInput: TextInputEditText
private lateinit var collectionIntervalFeedback: TextView
private lateinit var historyDaysLayout: TextInputLayout
private lateinit var historyDaysInput: TextInputEditText
private lateinit var historySyncButton: MaterialButton
@@ -158,6 +162,60 @@ class AgentSettingsFragment : Fragment() {
setOnClickListener { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }
}, fullWidth(12))
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("任务执行", 18f, context.getColor(R.color.agent_text), true))
val intervalRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(context.label("采集间隔", 14f, context.getColor(R.color.agent_text), true).apply {
gravity = Gravity.CENTER_VERTICAL
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply {
marginEnd = context.dp(8)
})
collectionIntervalLayout = TextInputLayout(context).apply {
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
boxStrokeColor = context.getColor(R.color.agent_primary_light)
isErrorEnabled = true
collectionIntervalInput = TextInputEditText(context).apply {
setText(settingsStore.collectionIntervalSeconds().toString())
setTextColor(context.getColor(R.color.agent_text))
textSize = 16f
gravity = Gravity.CENTER
setSingleLine(true)
inputType = InputType.TYPE_CLASS_NUMBER
imeOptions = EditorInfo.IME_ACTION_DONE
minHeight = context.dp(48)
contentDescription = "采集任务间隔秒数,范围 0 到 600"
}
addView(collectionIntervalInput, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
context.dp(48),
))
}
addView(collectionIntervalLayout, LinearLayout.LayoutParams(context.dp(80), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = context.dp(8)
})
addView(context.label("秒", 14f, context.getColor(R.color.agent_text), true).apply {
gravity = Gravity.CENTER_VERTICAL
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply {
marginEnd = context.dp(8)
})
addView(MaterialButton(context).apply {
text = "保存"
minHeight = context.dp(48)
contentDescription = "保存采集任务间隔"
setOnClickListener { saveCollectionInterval() }
}, LinearLayout.LayoutParams(0, context.dp(48), 1f))
}
addView(intervalRow, fullWidth(10))
collectionIntervalFeedback = context.label(
"0 秒表示不等待,最多 600 秒",
14f,
context.getColor(R.color.agent_text_muted),
)
collectionIntervalFeedback.setPadding(0, context.dp(8), 0, 0)
addView(collectionIntervalFeedback)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true))
val syncRow = LinearLayout(context).apply {
@@ -347,6 +405,25 @@ class AgentSettingsFragment : Fragment() {
}
}
private fun saveCollectionInterval() {
collectionIntervalLayout.error = null
val seconds = CollectionIntervalPolicy.parse(collectionIntervalInput.text?.toString().orEmpty())
if (seconds == null) {
collectionIntervalLayout.error = "请输入 0~600 的整数"
collectionIntervalInput.requestFocus()
return
}
val saved = runCatching { settingsStore.saveCollectionIntervalSeconds(seconds) }
collectionIntervalFeedback.text = saved.fold(
onSuccess = { "已保存,下次采集任务结束后生效" },
onFailure = { "保存失败:${friendlyError(it)}" },
)
collectionIntervalFeedback.setTextColor(requireContext().getColor(
if (saved.isSuccess) R.color.agent_primary_light else R.color.agent_error,
))
collectionIntervalFeedback.announceForAccessibility(collectionIntervalFeedback.text)
}
private fun fetchCollectionHistory(api: AgentApiClient, token: String, days: Int): List<CollectionHistoryItem> {
val items = mutableListOf<CollectionHistoryItem>()
var page = 1
@@ -420,8 +497,8 @@ class AgentSettingsFragment : Fragment() {
append("Device Token:${if (state.tokenStored) "已配置" else "未配置"}\n")
append("注册状态:${if (state.deviceId > 0) "已注册(设备 ${state.deviceId})" else "未注册"}\n")
append("Agent 版本:${BuildConfig.VERSION_NAME}\n")
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY")) "已连接" else "未连接"}\n")
append("保持屏幕常亮:${if (busy) "任务执行中已开启" else "仅在任务执行时开启"}")
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")) "已连接" else "未连接"}\n")
append("保持屏幕常亮:${if (state.keepScreenOn) "已开启" else "仅在任务执行或采集间隔时开启"}")
}
accessibilityText.text = when (AccessibilityReadinessDetector.current(context)) {
AccessibilityReadiness.READY -> "已开启并就绪,可以执行任务。"
@@ -29,14 +29,19 @@ internal object ManualTaskCheckPolicy {
else -> null
}
fun resultMessage(result: String): String = when (result) {
AgentForegroundService.MANUAL_EMPTY -> "暂无新任务,Agent 会继续自动检查"
AgentForegroundService.MANUAL_COLLECTION_TASK -> "已领取采集任务"
AgentForegroundService.MANUAL_PURCHASE_TASK -> "已领取采购任务"
AgentForegroundService.MANUAL_BUSY -> "当前任务执行中,无需重复检查"
AgentForegroundService.MANUAL_CONFIG_REQUIRED -> "请先到“设置”配置服务地址"
AgentForegroundService.MANUAL_AUTH_ERROR -> "设备身份校验失败,请先检查设置"
AgentForegroundService.MANUAL_NETWORK_ERROR -> "检查失败,Agent 将继续自动重试"
fun resultMessage(result: String): String = when {
result.startsWith(AgentForegroundService.MANUAL_COLLECTION_COOLDOWN_PREFIX) -> {
val seconds = result.removePrefix(AgentForegroundService.MANUAL_COLLECTION_COOLDOWN_PREFIX)
.toIntOrNull()?.coerceAtLeast(1) ?: 1
"采集间隔中,还需 $seconds 秒"
}
result == AgentForegroundService.MANUAL_EMPTY -> "暂无新任务,Agent 会继续自动检查"
result == AgentForegroundService.MANUAL_COLLECTION_TASK -> "已领取采集任务"
result == AgentForegroundService.MANUAL_PURCHASE_TASK -> "已领取采购任务"
result == AgentForegroundService.MANUAL_BUSY -> "当前任务执行中,无需重复检查"
result == AgentForegroundService.MANUAL_CONFIG_REQUIRED -> "请先到“设置”配置服务地址"
result == AgentForegroundService.MANUAL_AUTH_ERROR -> "设备身份校验失败,请先检查设置"
result == AgentForegroundService.MANUAL_NETWORK_ERROR -> "检查失败,Agent 将继续自动重试"
else -> "检查失败,Agent 将继续自动重试"
}
}
@@ -217,10 +222,11 @@ class AgentStatusFragment : Fragment() {
if (!isAdded || view == null) return
val context = requireContext()
val state = stateStore.read()
val connected = state.code in setOf("ONLINE", "BUSY")
val connected = state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")
connectionTitle.text = when (state.code) {
"ONLINE" -> "在线 · 空闲"
"BUSY" -> "在线 · 执行中"
"COLLECTION_COOLDOWN" -> "在线 · 采集间隔中"
"CONNECTING" -> "正在连接"
"CONFIG_REQUIRED" -> "等待配置"
"AUTH_ERROR" -> "身份校验失败"
@@ -27,6 +27,8 @@ import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
@@ -429,6 +431,14 @@ class TaskHistoryFragment : Fragment() {
}
private fun confirmReset(task: CollectionHistoryItem) {
collectionCooldownMessage()?.let { message ->
MaterialAlertDialogBuilder(requireContext())
.setTitle("暂时不能重新采集")
.setMessage(message)
.setPositiveButton("知道了", null)
.show()
return
}
MaterialAlertDialogBuilder(requireContext())
.setTitle("重新采集 #${task.taskId}?")
.setMessage(CollectionResetPolicy.confirmationMessage(task.status))
@@ -439,6 +449,10 @@ class TaskHistoryFragment : Fragment() {
private fun resetCollectionTask(taskId: Long) {
val context = requireContext()
collectionCooldownMessage()?.let { message ->
showMessage("暂时不能重新采集", message, "返回任务详情") { loadCollectionDetail(taskId) }
return
}
val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull()
val serverUrl = AgentSettingsStore(context).serverUrl()
if (credentials == null || serverUrl.isBlank()) {
@@ -470,6 +484,13 @@ class TaskHistoryFragment : Fragment() {
}.start()
}
private fun collectionCooldownMessage(): String? {
val now = System.currentTimeMillis()
val ticket = AgentStateStore(requireContext()).activeCollectionCooldown(now) ?: return null
val seconds = CollectionCooldownPolicy.remainingSeconds(now, ticket)
return seconds.takeIf { it > 0 }?.let { "采集间隔中,还需 $it 秒" }
}
private fun renderPurchaseDetail(detail: PurchaseHistoryDetail) {
val context = requireContext()
val task = detail.task
@@ -57,6 +57,7 @@ class AccessibilityReadinessTest {
assertEquals("暂无新任务,Agent 会继续自动检查", ManualTaskCheckPolicy.resultMessage("empty"))
assertEquals("已领取采集任务", ManualTaskCheckPolicy.resultMessage("collection_task"))
assertEquals("已领取采购任务", ManualTaskCheckPolicy.resultMessage("purchase_task"))
assertEquals("采集间隔中,还需 12 秒", ManualTaskCheckPolicy.resultMessage("collection_cooldown:12"))
assertEquals("检查失败,Agent 将继续自动重试", ManualTaskCheckPolicy.resultMessage("network_error"))
}
}
@@ -0,0 +1,73 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy
import cn.ilapage.goauto.agent.service.CollectionCooldownTicket
import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy
import cn.ilapage.goauto.agent.service.TaskDispatchDecision
import cn.ilapage.goauto.agent.service.TaskDispatchPolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CollectionCooldownPolicyTest {
@Test
fun `accepts integer interval from zero to six hundred seconds`() {
assertEquals(0, CollectionIntervalPolicy.parse("0"))
assertEquals(15, CollectionIntervalPolicy.parse(" 15 "))
assertEquals(600, CollectionIntervalPolicy.parse("600"))
assertNull(CollectionIntervalPolicy.parse("-1"))
assertNull(CollectionIntervalPolicy.parse("1.5"))
assertNull(CollectionIntervalPolicy.parse("601"))
}
@Test
fun `zero disables cooldown and positive interval creates a bounded ticket`() {
assertNull(CollectionCooldownPolicy.arm(1_000L, 0))
assertEquals(
CollectionCooldownTicket(16_000L, 15),
CollectionCooldownPolicy.arm(1_000L, 15),
)
}
@Test
fun `remaining seconds round up and expired ticket is cleared`() {
val ticket = CollectionCooldownTicket(16_000L, 15)
assertEquals(15, CollectionCooldownPolicy.remainingSeconds(1_000L, ticket))
assertEquals(1, CollectionCooldownPolicy.remainingSeconds(15_999L, ticket))
assertEquals(0, CollectionCooldownPolicy.remainingSeconds(16_000L, ticket))
assertNull(CollectionCooldownPolicy.normalize(16_000L, ticket))
}
@Test
fun `clock rollback is clamped to the original interval`() {
val ticket = CollectionCooldownTicket(20_000L, 15)
assertEquals(
CollectionCooldownTicket(5_000L, 15),
CollectionCooldownPolicy.normalize(-10_000L, ticket),
)
}
@Test
fun `ticket keeps original duration when setting changes later`() {
val ticket = requireNotNull(CollectionCooldownPolicy.arm(10_000L, 20))
assertEquals(20, ticket.durationSeconds)
assertEquals(15, CollectionIntervalPolicy.stored(-1))
assertEquals(20, CollectionCooldownPolicy.remainingSeconds(10_000L, ticket))
}
@Test
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
)
}
}
@@ -60,8 +60,9 @@ class IdleReturnCoordinatorTest {
@Test
fun `screen stays awake only during task or cooldown`() {
assertFalse(ScreenAwakeResolver.shouldKeepScreenOn(false, false))
assertTrue(ScreenAwakeResolver.shouldKeepScreenOn(true, false))
assertTrue(ScreenAwakeResolver.shouldKeepScreenOn(false, true))
assertFalse(ScreenAwakeResolver.shouldKeepScreenOn(false, false, false))
assertTrue(ScreenAwakeResolver.shouldKeepScreenOn(true, false, false))
assertTrue(ScreenAwakeResolver.shouldKeepScreenOn(false, true, false))
assertTrue(ScreenAwakeResolver.shouldKeepScreenOn(false, false, true))
}
}
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 77490befcdc5fdb3fb58adbe8f94578e5eba9336
synchronized_at: 2026-08-26T09:33:12Z
wiki_revision: d234ec2be1a4f6d01a9a46a90a901abdebd229be
synchronized_at: 2026-08-26T14:04:38Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -262,3 +262,13 @@ synchronized_at: 2026-08-26T09:33:12Z
- 两类记录可以独立成功;界面必须明确显示同步完成、范围内无记录、部分完成或失败以及最近同步时间。
- 本地仅保存列表摘要和同步时间,详情仍按需读取;不缓存 Token、完整规则快照、PDD URL、地址、控件树或截图。
- 记录同步是服务端到 Agent 的单向只读能力,不打开 PDD、不修改地址、不创建订单、不支付。
## Agent 采集任务间隔
- Agent 设置页提供设备本地“采集间隔”,允许 0~600 秒整数,默认 15 秒;0 表示不等待。设置只影响之后结束的采集任务,已开始的间隔不随设置修改。
- 只有采集任务已经开始且成功向服务端提交完成、部分完成或失败结果后才开始间隔;领取前失败、配置/认证错误或结果未安全提交不开始间隔。采购任务结束不开始采集间隔。
- 间隔只阻止下一次采集任务的请求、领取和开始;心跳、采购任务优先领取与执行、采购 Outbox、历史刷新和同步继续运行。间隔结束只触发现有调度器一次,不新增轮询器。
- 状态页下拉检查和采集记录“重新采集”不得绕过间隔,统一提示“采集间隔中,还需 N 秒”。
- 间隔状态持久保存在设备本地,Agent 进程或前台服务重启后继续等待;系统时间回拨时最多按该次原始间隔重新计算,避免无限等待。
- 间隔期间保持屏幕常亮;既有“任务结束 15 秒后返回 Agent”是独立机制,返回 Agent 不清除采集间隔。采购任务接管执行时不重复持有间隔亮屏锁,采购完成后若间隔尚未结束则恢复。
- 该设置不上传服务端,不改变采集任务状态、租约、规则快照或服务端接口,不打开 PDD、不修改地址、不创建订单、不支付。
+11 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: dabfb23f64bc19bee61f104cb59b21cd000f8d51
synchronized_at: 2026-08-26T08:05:18Z
wiki_revision: c31d953495bb18ebfb1c7de0015e987c42dd43e4
synchronized_at: 2026-08-26T14:05:00Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -240,3 +240,12 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
- 设置页默认 7 天,可切换 1/3/7/15/30 天;分别检查完整成功、范围内无记录、单类失败和全部失败,按钮在同步中禁用并在结束后恢复。
- 断网后打开有缓存的同范围列表,应显示最近同步摘要;任务详情仍需联网读取。检查应用数据中不存在 Device Token 明文、完整规则、PDD URL、地址、控件树或截图。
- 只读刷新和同步无需创建正式任务;验收不得借此触发重新采集、采购重试、PDD、创建订单或支付。
### Android Agent 采集任务间隔检查(#102)
- 运行 `cd android && .\\gradlew.bat testDebugUnitTest assembleDebug assembleRelease`;单元测试至少覆盖 0/15/600 秒、越界输入、倒计时向上取整、过期清理、系统时间回拨截断和亮屏策略。
- 真机覆盖安装前确认设备无活动任务。设置页检查默认 15 秒、0~600 整数校验、保存反馈,以及修改设置不改变已经开始的倒计时。
- 分别以成功、部分完成和失败的采集任务确认:结果被服务端接收后进入“在线 · 采集间隔中”;状态页下拉和采集记录“重新采集”均显示剩余秒数且不能绕过;间隔结束后现有调度器继续领取下一条采集任务。
- 间隔期间创建采购任务,确认采购仍优先执行;采购结束后若原间隔未到期,采集继续等待。心跳、采购 Outbox、记录刷新与同步不受影响。
- 间隔期间重启 Agent 前台服务,确认倒计时按设备本地状态恢复;使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。