From 31646758d12d61062ec85ccc69397ae3a368eb2d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 26 Aug 2026 22:16:27 +0800 Subject: [PATCH] feat(agent): add configurable collection interval (#102) --- android/README.md | 4 +- .../agent/service/AgentForegroundService.kt | 133 ++++++++++++++++-- .../goauto/agent/service/AgentStateStore.kt | 103 ++++++++++++++ .../agent/service/IdleReturnCoordinator.kt | 4 +- .../goauto/agent/ui/AgentSettingsFragment.kt | 81 ++++++++++- .../goauto/agent/ui/AgentStatusFragment.kt | 24 ++-- .../goauto/agent/ui/TaskHistoryFragment.kt | 21 +++ .../agent/AccessibilityReadinessTest.kt | 1 + .../agent/CollectionCooldownPolicyTest.kt | 73 ++++++++++ .../goauto/agent/IdleReturnCoordinatorTest.kt | 7 +- docs/03-business-rules-and-glossary.md | 14 +- docs/04-local-development-and-verification.md | 13 +- 12 files changed, 449 insertions(+), 29 deletions(-) create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt diff --git a/android/README.md b/android/README.md index b89ad1b..c1fb0fd 100644 --- a/android/README.md +++ b/android/README.md @@ -18,7 +18,9 @@ 设备执行任务时,状态页显示当前任务,设置页会禁用服务器地址、设备名称、测试和保存操作。返回系统无障碍设置后,状态页和设置页会自动刷新真实就绪状态。 -任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒冷却。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。任务执行和冷却期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。 +任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒自动返回窗口。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。 + +采集结果被服务端安全接收后还会按设置页的“采集间隔”等待下一次采集,默认 15 秒、范围 0~600 秒;采购仍可优先执行,手动检查和重新采集不能绕过。该间隔持久化到设备本地,与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。 ## T05 真机验证记录 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 3e9ef63..65165ea 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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?>(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) { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt index ce7bdec..6d0de4f 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt @@ -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" } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/IdleReturnCoordinator.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/IdleReturnCoordinator.kt index 8cbb52b..c4043a4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/IdleReturnCoordinator.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/IdleReturnCoordinator.kt @@ -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 } 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 eb67e2e..fb57304 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 @@ -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 { val items = mutableListOf() 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 -> "已开启并就绪,可以执行任务。" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt index d73114f..e2bbe56 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentStatusFragment.kt @@ -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" -> "身份校验失败" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index ecaaa80..4cb5883 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -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 diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt index 6d5c770..f935f06 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/AccessibilityReadinessTest.kt @@ -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")) } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt new file mode 100644 index 0000000..25f272b --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt @@ -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), + ) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/IdleReturnCoordinatorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/IdleReturnCoordinatorTest.kt index 8e454c0..e93feb9 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/IdleReturnCoordinatorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/IdleReturnCoordinatorTest.kt @@ -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)) } } diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index d70f3f0..f3d2bf3 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -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、不修改地址、不创建订单、不支付。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index bb4b4db..89dd027 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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 # 本地开发与验证 @@ -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 有界持有并在到期或服务停止后释放。 +- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。