feat(agent): randomize collection task interval (#102)

This commit is contained in:
QiuSW
2026-08-27 09:33:58 +08:00
parent fc52ec5622
commit f79f824ef4
7 changed files with 163 additions and 47 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒自动返回窗口。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。
采集结果被服务端安全接收后还会按设置页的“采集间隔”等待下一次采集,默认 15 秒、范围 0~600 秒;采购仍可优先执行,手动检查和重新采集不能绕过。该间隔持久化到设备本地,与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
采集结果被服务端安全接收后还会按设置页的“采集任务执行间隔”等待下一次采集;起止值均为 0~600 秒整数,默认 `15~15`,每次在闭区间内随机一次。采购仍可优先执行,手动检查和重新采集不能绕过;本次抽中的秒数、开始时间和截止时间会一起持久化,重启或修改设置不会重新随机。该间隔与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
## T05 真机验证记录
@@ -577,7 +577,7 @@ class AgentForegroundService : Service() {
beginIdleReturnCooldown()
val ticket = CollectionCooldownPolicy.arm(
System.currentTimeMillis(),
settingsStore.collectionIntervalSeconds(),
settingsStore.collectionIntervalRange(),
)
if (ticket == null) {
stateStore.clearCollectionCooldown()
@@ -3,6 +3,7 @@ package cn.ilapage.goauto.agent.service
import android.content.Context
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import kotlin.random.Random
data class AgentState(
val code: String,
@@ -39,18 +40,38 @@ internal object CollectionIntervalPolicy {
}
fun stored(value: Int): Int = value.takeIf { it in validRange } ?: DEFAULT_SECONDS
fun storedRange(startSeconds: Int, endSeconds: Int): CollectionIntervalRange =
if (startSeconds in validRange && endSeconds in validRange && startSeconds <= endSeconds) {
CollectionIntervalRange(startSeconds, endSeconds)
} else {
CollectionIntervalRange(DEFAULT_SECONDS, DEFAULT_SECONDS)
}
}
data class CollectionIntervalRange(
val startSeconds: Int,
val endSeconds: Int,
)
internal data class CollectionCooldownTicket(
val startedAtEpochMillis: Long,
val untilEpochMillis: Long,
val durationSeconds: Int,
)
internal object CollectionCooldownPolicy {
fun arm(nowEpochMillis: Long, intervalSeconds: Int): CollectionCooldownTicket? {
val seconds = CollectionIntervalPolicy.stored(intervalSeconds)
fun arm(
nowEpochMillis: Long,
intervalRange: CollectionIntervalRange,
randomSeconds: (Int, Int) -> Int = { start, end -> Random.Default.nextInt(start, end + 1) },
): CollectionCooldownTicket? {
val range = CollectionIntervalPolicy.storedRange(intervalRange.startSeconds, intervalRange.endSeconds)
val seconds = randomSeconds(range.startSeconds, range.endSeconds)
require(seconds in range.startSeconds..range.endSeconds) { "随机采集间隔超出配置范围" }
if (seconds == 0) return null
return CollectionCooldownTicket(
startedAtEpochMillis = nowEpochMillis,
untilEpochMillis = nowEpochMillis + seconds * 1_000L,
durationSeconds = seconds,
)
@@ -63,7 +84,10 @@ internal object CollectionCooldownPolicy {
if (remaining <= 0L) return null
val durationMillis = duration * 1_000L
return if (remaining > durationMillis) {
ticket.copy(untilEpochMillis = nowEpochMillis + durationMillis)
ticket.copy(
startedAtEpochMillis = nowEpochMillis,
untilEpochMillis = nowEpochMillis + durationMillis,
)
} else ticket
}
@@ -97,13 +121,24 @@ 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 collectionIntervalRange(): CollectionIntervalRange {
val legacy = CollectionIntervalPolicy.stored(
preferences.getInt(COLLECTION_INTERVAL_SECONDS, CollectionIntervalPolicy.DEFAULT_SECONDS),
)
return CollectionIntervalPolicy.storedRange(
preferences.getInt(COLLECTION_INTERVAL_START_SECONDS, legacy),
preferences.getInt(COLLECTION_INTERVAL_END_SECONDS, legacy),
)
}
fun saveCollectionIntervalSeconds(seconds: Int) {
require(CollectionIntervalPolicy.stored(seconds) == seconds) { "采集间隔无效" }
check(preferences.edit().putInt(COLLECTION_INTERVAL_SECONDS, seconds).commit()) { "无法保存采集间隔" }
fun saveCollectionIntervalRange(range: CollectionIntervalRange) {
require(CollectionIntervalPolicy.storedRange(range.startSeconds, range.endSeconds) == range) { "采集间隔范围无效" }
check(preferences.edit()
.putInt(COLLECTION_INTERVAL_START_SECONDS, range.startSeconds)
.putInt(COLLECTION_INTERVAL_END_SECONDS, range.endSeconds)
.remove(COLLECTION_INTERVAL_SECONDS)
.commit()
) { "无法保存采集间隔" }
}
fun saveHistoryDays(days: Int) {
@@ -142,6 +177,8 @@ class AgentSettingsStore(context: Context) {
const val DEVICE_NAME = "device_name"
const val HISTORY_DAYS = "history_days"
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
const val COLLECTION_INTERVAL_END_SECONDS = "collection_interval_end_seconds"
const val LAST_HISTORY_SYNC_AT = "last_history_sync_at"
}
}
@@ -195,7 +232,15 @@ class AgentStateStore(context: Context) {
@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 duration = preferences.getInt(COLLECTION_COOLDOWN_DURATION_SECONDS, 0)
CollectionCooldownTicket(
startedAtEpochMillis = preferences.getLong(
COLLECTION_COOLDOWN_STARTED_AT,
it - duration * 1_000L,
),
untilEpochMillis = it,
durationSeconds = duration,
)
}
val normalized = CollectionCooldownPolicy.normalize(nowEpochMillis, stored)
when {
@@ -209,6 +254,7 @@ class AgentStateStore(context: Context) {
internal fun saveCollectionCooldown(ticket: CollectionCooldownTicket) {
require(ticket.durationSeconds in 1..CollectionIntervalPolicy.MAX_SECONDS)
check(preferences.edit()
.putLong(COLLECTION_COOLDOWN_STARTED_AT, ticket.startedAtEpochMillis)
.putLong(COLLECTION_COOLDOWN_UNTIL, ticket.untilEpochMillis)
.putInt(COLLECTION_COOLDOWN_DURATION_SECONDS, ticket.durationSeconds)
.commit()
@@ -218,6 +264,7 @@ class AgentStateStore(context: Context) {
@Synchronized
fun clearCollectionCooldown() {
preferences.edit()
.remove(COLLECTION_COOLDOWN_STARTED_AT)
.remove(COLLECTION_COOLDOWN_UNTIL)
.remove(COLLECTION_COOLDOWN_DURATION_SECONDS)
.apply()
@@ -233,6 +280,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_STARTED_AT = "collection_cooldown_started_at"
const val COLLECTION_COOLDOWN_UNTIL = "collection_cooldown_until"
const val COLLECTION_COOLDOWN_DURATION_SECONDS = "collection_cooldown_duration_seconds"
}
@@ -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.CollectionIntervalRange
import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy
import cn.ilapage.goauto.agent.service.HistoryRangePolicy
import com.google.android.material.button.MaterialButton
@@ -53,8 +54,10 @@ 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 collectionIntervalStartLayout: TextInputLayout
private lateinit var collectionIntervalStartInput: TextInputEditText
private lateinit var collectionIntervalEndLayout: TextInputLayout
private lateinit var collectionIntervalEndInput: TextInputEditText
private lateinit var collectionIntervalFeedback: TextView
private lateinit var historyDaysLayout: TextInputLayout
private lateinit var historyDaysInput: TextInputEditText
@@ -163,21 +166,45 @@ class AgentSettingsFragment : Fragment() {
}, fullWidth(12))
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("任务执行", 18f, context.getColor(R.color.agent_text), true))
addView(context.label("采集任务执行间隔", 18f, context.getColor(R.color.agent_text), true))
val interval = settingsStore.collectionIntervalRange()
val intervalRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(context.label("采集间隔", 14f, context.getColor(R.color.agent_text), true).apply {
collectionIntervalStartLayout = TextInputLayout(context).apply {
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
boxStrokeColor = context.getColor(R.color.agent_primary_light)
isErrorEnabled = true
collectionIntervalStartInput = TextInputEditText(context).apply {
setText(interval.startSeconds.toString())
setTextColor(context.getColor(R.color.agent_text))
textSize = 16f
gravity = Gravity.CENTER
setSingleLine(true)
inputType = InputType.TYPE_CLASS_NUMBER
imeOptions = EditorInfo.IME_ACTION_NEXT
minHeight = context.dp(48)
contentDescription = "采集任务执行间隔范围起始值,0 到 600 秒"
}
addView(collectionIntervalStartInput, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
context.dp(48),
))
}
addView(collectionIntervalStartLayout, LinearLayout.LayoutParams(context.dp(68), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = context.dp(8)
})
addView(context.label("~", 16f, 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 {
collectionIntervalEndLayout = 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())
collectionIntervalEndInput = TextInputEditText(context).apply {
setText(interval.endSeconds.toString())
setTextColor(context.getColor(R.color.agent_text))
textSize = 16f
gravity = Gravity.CENTER
@@ -185,14 +212,14 @@ class AgentSettingsFragment : Fragment() {
inputType = InputType.TYPE_CLASS_NUMBER
imeOptions = EditorInfo.IME_ACTION_DONE
minHeight = context.dp(48)
contentDescription = "采集任务间隔秒数,范围 0 到 600"
contentDescription = "采集任务执行间隔范围结束值,0 到 600 秒"
}
addView(collectionIntervalInput, LinearLayout.LayoutParams(
addView(collectionIntervalEndInput, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
context.dp(48),
))
}
addView(collectionIntervalLayout, LinearLayout.LayoutParams(context.dp(80), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
addView(collectionIntervalEndLayout, LinearLayout.LayoutParams(context.dp(68), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = context.dp(8)
})
addView(context.label("秒", 14f, context.getColor(R.color.agent_text), true).apply {
@@ -209,7 +236,7 @@ class AgentSettingsFragment : Fragment() {
}
addView(intervalRow, fullWidth(10))
collectionIntervalFeedback = context.label(
"0 秒表示不等待,最多 600 秒",
"",
14f,
context.getColor(R.color.agent_text_muted),
)
@@ -406,14 +433,28 @@ 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()
collectionIntervalStartLayout.error = null
collectionIntervalEndLayout.error = null
val startSeconds = CollectionIntervalPolicy.parse(collectionIntervalStartInput.text?.toString().orEmpty())
if (startSeconds == null) {
collectionIntervalStartLayout.error = "请输入 0~600 的整数"
collectionIntervalStartInput.requestFocus()
return
}
val saved = runCatching { settingsStore.saveCollectionIntervalSeconds(seconds) }
val endSeconds = CollectionIntervalPolicy.parse(collectionIntervalEndInput.text?.toString().orEmpty())
if (endSeconds == null) {
collectionIntervalEndLayout.error = "请输入 0~600 的整数"
collectionIntervalEndInput.requestFocus()
return
}
if (startSeconds > endSeconds) {
collectionIntervalStartLayout.error = "左侧数值不能大于右侧数值"
collectionIntervalStartInput.requestFocus()
return
}
val saved = runCatching {
settingsStore.saveCollectionIntervalRange(CollectionIntervalRange(startSeconds, endSeconds))
}
collectionIntervalFeedback.text = saved.fold(
onSuccess = { "已保存,下次采集任务结束后生效" },
onFailure = { "保存失败:${friendlyError(it)}" },
@@ -2,6 +2,7 @@ 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.CollectionIntervalRange
import cn.ilapage.goauto.agent.service.CollectionIntervalPolicy
import cn.ilapage.goauto.agent.service.TaskDispatchDecision
import cn.ilapage.goauto.agent.service.TaskDispatchPolicy
@@ -18,20 +19,44 @@ class CollectionCooldownPolicyTest {
assertNull(CollectionIntervalPolicy.parse("-1"))
assertNull(CollectionIntervalPolicy.parse("1.5"))
assertNull(CollectionIntervalPolicy.parse("601"))
assertEquals(CollectionIntervalRange(15, 30), CollectionIntervalPolicy.storedRange(15, 30))
assertEquals(CollectionIntervalRange(15, 15), CollectionIntervalPolicy.storedRange(30, 15))
}
@Test
fun `zero disables cooldown and positive interval creates a bounded ticket`() {
assertNull(CollectionCooldownPolicy.arm(1_000L, 0))
fun `zero disables cooldown and fixed interval creates one durable ticket`() {
assertNull(CollectionCooldownPolicy.arm(1_000L, CollectionIntervalRange(0, 0)) { _, _ -> 0 })
assertNull(CollectionCooldownPolicy.arm(1_000L, CollectionIntervalRange(0, 15)) { start, _ -> start })
assertEquals(
CollectionCooldownTicket(16_000L, 15),
CollectionCooldownPolicy.arm(1_000L, 15),
CollectionCooldownTicket(1_000L, 16_000L, 15),
CollectionCooldownPolicy.arm(1_000L, CollectionIntervalRange(15, 15)) { start, end ->
assertEquals(15, start)
assertEquals(15, end)
15
},
)
}
@Test
fun `random range includes both boundaries without probabilistic assertions`() {
var randomCalls = 0
assertEquals(
CollectionCooldownTicket(2_000L, 17_000L, 15),
CollectionCooldownPolicy.arm(2_000L, CollectionIntervalRange(15, 30)) { start, _ ->
randomCalls++
start
},
)
assertEquals(1, randomCalls)
assertEquals(
CollectionCooldownTicket(2_000L, 32_000L, 30),
CollectionCooldownPolicy.arm(2_000L, CollectionIntervalRange(15, 30)) { _, end -> end },
)
}
@Test
fun `remaining seconds round up and expired ticket is cleared`() {
val ticket = CollectionCooldownTicket(16_000L, 15)
val ticket = CollectionCooldownTicket(1_000L, 16_000L, 15)
assertEquals(15, CollectionCooldownPolicy.remainingSeconds(1_000L, ticket))
assertEquals(1, CollectionCooldownPolicy.remainingSeconds(15_999L, ticket))
assertEquals(0, CollectionCooldownPolicy.remainingSeconds(16_000L, ticket))
@@ -40,16 +65,18 @@ class CollectionCooldownPolicyTest {
@Test
fun `clock rollback is clamped to the original interval`() {
val ticket = CollectionCooldownTicket(20_000L, 15)
val ticket = CollectionCooldownTicket(5_000L, 20_000L, 15)
assertEquals(
CollectionCooldownTicket(5_000L, 15),
CollectionCooldownTicket(-10_000L, 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))
val ticket = requireNotNull(
CollectionCooldownPolicy.arm(10_000L, CollectionIntervalRange(20, 40)) { _, _ -> 20 },
)
assertEquals(20, ticket.durationSeconds)
assertEquals(15, CollectionIntervalPolicy.stored(-1))
assertEquals(20, CollectionCooldownPolicy.remainingSeconds(10_000L, ticket))