feat(agent): add configurable collection interval (#102)
This commit is contained in:
+3
-1
@@ -18,7 +18,9 @@
|
||||
|
||||
设备执行任务时,状态页显示当前任务,设置页会禁用服务器地址、设备名称、测试和保存操作。返回系统无障碍设置后,状态页和设置页会自动刷新真实就绪状态。
|
||||
|
||||
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒冷却。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。任务执行和冷却期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
|
||||
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒自动返回窗口。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。
|
||||
|
||||
采集结果被服务端安全接收后还会按设置页的“采集间隔”等待下一次采集,默认 15 秒、范围 0~600 秒;采购仍可优先执行,手动检查和重新采集不能绕过。该间隔持久化到设备本地,与自动返回窗口相互独立。任务执行、自动返回窗口和采集间隔期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
|
||||
|
||||
## T05 真机验证记录
|
||||
|
||||
|
||||
+125
-8
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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、不修改地址、不创建订单、不支付。
|
||||
|
||||
@@ -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 有界持有并在到期或服务停止后释放。
|
||||
- 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。
|
||||
|
||||
Reference in New Issue
Block a user