feat(android): implement #89 idle return policy

This commit is contained in:
QiuSW
2026-08-25 17:53:06 +08:00
parent cb2c34afbb
commit 21fc14988d
10 changed files with 401 additions and 26 deletions
+3 -1
View File
@@ -8,7 +8,7 @@
- 自动注册和已认证的版本信息更新,无注册码输入页。
- Android Keystore AES-GCM 加密保存 Device Token,界面和日志不显示明文。
- 前台服务、15 秒心跳、网络恢复触发重连。
- Agent 0.3.0 的状态、采集、采购、设置四 Tab 外壳;默认进入状态页。
- Agent 0.3.1 的状态、采集、采购、设置四 Tab 外壳;默认进入状态页。
- 状态页区分无障碍未开启、系统已开启但服务未绑定、已绑定就绪三态。
- 设置页可测试服务器连接、保存服务器地址和设备名称并重新连接;任务执行中禁止修改。
- 设置页只显示 Device Token 是否配置,不显示 Token 明文;采集和采购 Tab 在 #90 接入当前设备任务历史。
@@ -18,6 +18,8 @@
设备执行任务时,状态页显示当前任务,设置页会禁用服务器地址、设备名称、测试和保存操作。返回系统无障碍设置后,状态页和设置页会自动刷新真实就绪状态。
任务结果成功提交(采购结果也可以先安全写入本地 Outbox)后,Agent 进入 15 秒冷却。它沿用现有任务轮询依次确认采购、采集队列都为空,并确认用户没有离开本次自动化打开的 PDD/浏览器页面,才返回 Agent 状态页。新任务、网络异常、待恢复的不可逆采购边界或前台应用变化都会取消自动返回。任务执行和冷却期间有界保持亮屏,结束后释放;不会自动解锁 PIN、图案或密码。
## T05 真机验证记录
- 一加 PKG110 / Android 16:首次注册、15 秒心跳、前台通知和在线空闲状态已验证。
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 3
versionName = "0.3.0"
versionCode = 4
versionName = "0.3.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
@@ -18,15 +19,44 @@ import com.google.android.material.bottomnavigation.BottomNavigationView
class MainActivity : AppCompatActivity() {
private var selectedTab = TAB_STATUS
private lateinit var navigation: BottomNavigationView
private val screenPolicyHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val screenPolicyRefresh = object : Runnable {
override fun run() {
applyScreenPolicy()
screenPolicyHandler.postDelayed(this, SCREEN_POLICY_REFRESH_MILLIS)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
selectedTab = savedInstanceState?.getInt(STATE_SELECTED_TAB) ?: TAB_STATUS
selectedTab = if (intent.getBooleanExtra(EXTRA_SHOW_STATUS, false)) {
TAB_STATUS
} else {
savedInstanceState?.getInt(STATE_SELECTED_TAB) ?: TAB_STATUS
}
setContentView(buildContent())
requestNotificationPermission()
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.getBooleanExtra(EXTRA_SHOW_STATUS, false)) navigation.selectedItemId = TAB_STATUS
}
override fun onResume() {
super.onResume()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
}
override fun onPause() {
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
super.onPause()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(STATE_SELECTED_TAB, selectedTab)
super.onSaveInstanceState(outState)
@@ -43,7 +73,7 @@ class MainActivity : AppCompatActivity() {
0,
1f,
))
val navigation = BottomNavigationView(this).apply {
navigation = BottomNavigationView(this).apply {
setBackgroundColor(getColor(R.color.agent_surface))
itemIconTintList = getColorStateList(R.color.agent_navigation_item)
itemTextColor = getColorStateList(R.color.agent_navigation_item)
@@ -94,11 +124,22 @@ class MainActivity : AppCompatActivity() {
}
}
private companion object {
private fun applyScreenPolicy() {
val keepScreenOn = cn.ilapage.goauto.agent.service.AgentStateStore(this).read().keepScreenOn
if (keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
companion object {
const val EXTRA_SHOW_STATUS = "cn.ilapage.goauto.agent.SHOW_STATUS"
private const val SCREEN_POLICY_REFRESH_MILLIS = 1_000L
const val TAB_STATUS = 1
const val TAB_COLLECTION = 2
const val TAB_PURCHASE = 3
const val TAB_SETTINGS = 4
const val STATE_SELECTED_TAB = "selected_tab"
private const val TAB_COLLECTION = 2
private const val TAB_PURCHASE = 3
private const val TAB_SETTINGS = 4
private const val STATE_SELECTED_TAB = "selected_tab"
}
}
@@ -17,8 +17,11 @@ import android.view.accessibility.AccessibilityNodeInfo
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver, PurchaseUiDriver {
private val foregroundRevision = AtomicLong(0L)
@Volatile private var lastForegroundPackage: String? = null
private val activityTracker by lazy {
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
}
@@ -34,7 +37,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
activityTracker.observe(event.packageName?.toString(), event.className?.toString())
val packageName = event.packageName?.toString()
if (packageName != null && packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
foregroundRevision.incrementAndGet()
}
activityTracker.observe(packageName, event.className?.toString())
}
}
override fun onInterrupt() = Unit
@@ -47,6 +55,18 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun currentPackage(): String? = rootInActiveWindow?.packageName?.toString()
override fun currentActivity(): String? = activityTracker.current()
fun currentForegroundRevision(): Long = foregroundRevision.get()
fun openAgentStatus(): Boolean = runCatching {
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(cn.ilapage.goauto.agent.MainActivity.EXTRA_SHOW_STATUS, true)
},
)
true
}.getOrDefault(false)
override fun visibleTexts(): List<String> {
val root = rootInActiveWindow ?: return emptyList()
val texts = mutableListOf<String>()
@@ -15,6 +15,7 @@ import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.MainActivity
@@ -63,6 +64,10 @@ class AgentForegroundService : Service() {
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
private val registeredThisProcess = AtomicBoolean(false)
private val taskWakeActive = AtomicBoolean(false)
private val idleReturn = IdleReturnCoordinator()
private var taskWakeLock: PowerManager.WakeLock? = null
private var cooldownWakeLock: PowerManager.WakeLock? = null
private lateinit var identityStore: SecureDeviceStore
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
@@ -80,6 +85,8 @@ class AgentForegroundService : Service() {
identityStore = SecureDeviceStore(this)
settingsStore = AgentSettingsStore(this)
stateStore = AgentStateStore(this)
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this)
val restoredPurchaseTaskId = purchaseStore.activeTaskId()
runningTaskId.set(restoredPurchaseTaskId)
@@ -100,6 +107,8 @@ class AgentForegroundService : Service() {
override fun onDestroy() {
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
releaseTaskWakeLock()
executor.shutdownNow()
taskExecutor.shutdownNow()
purchaseStore.close()
@@ -162,6 +171,7 @@ class AgentForegroundService : Service() {
updateNotification(if (heartbeat.busy) "在线 · 执行中" else "在线 · 空闲")
scheduleTask(api, activeCredentials.token)
} catch (error: AgentApiException) {
cancelIdleReturn("网络或服务端请求失败")
val authenticationError = error.code in setOf(
"DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED",
)
@@ -174,6 +184,7 @@ class AgentForegroundService : Service() {
)
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
} catch (error: Exception) {
cancelIdleReturn("Agent 运行异常")
val configured = settingsStore.serverUrl().isNotBlank()
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
stateStore.update(
@@ -192,10 +203,16 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val purchaseTask = api.nextPurchaseTask(token)
if (purchaseTask != null) {
cancelIdleReturn("收到新的采购任务")
schedulePurchaseTask(api, purchaseTask, token)
return
}
val task = api.nextTask(token) ?: return
val task = api.nextTask(token)
if (task == null) {
evaluateIdleReturn()
return
}
cancelIdleReturn("收到新的采集任务")
if (!taskMutex.tryAcquire(task.taskId)) return
if (task.status == "running") runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
@@ -206,6 +223,7 @@ class AgentForegroundService : Service() {
runningTaskId.compareAndSet(task.taskId, null)
stateStore.clearActiveTask(task.taskId)
taskMutex.release(task.taskId)
triggerSync()
}
}
}
@@ -221,12 +239,14 @@ class AgentForegroundService : Service() {
taskMutex.release(task.taskId)
stateStore.clearActiveTask(task.taskId)
runningTaskId.set(purchaseStore.activeTaskId())
triggerSync()
}
}
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
val wakeLock = acquireTaskWakeLock()
acquireTaskWakeLock()
var resultSafelyStored = false
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -301,6 +321,8 @@ class AgentForegroundService : Service() {
val requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
@@ -310,7 +332,8 @@ class AgentForegroundService : Service() {
} catch (error: Exception) {
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (wakeLock.isHeld) wakeLock.release()
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
releaseTaskWakeLock()
}
}
@@ -403,16 +426,17 @@ class AgentForegroundService : Service() {
private fun executeTask(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) {
val wakeLock = acquireTaskWakeLock()
acquireTaskWakeLock()
try {
executeTaskWhileAwake(api, initialTask, token)
} finally {
if (wakeLock.isHeld) wakeLock.release()
releaseTaskWakeLock()
}
}
private fun executeTaskWhileAwake(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) {
var started = initialTask.status == "running"
var resultSafelySubmitted = false
try {
val claimed = if (initialTask.status == "pending") {
api.claimTask(initialTask.taskId, UUID.randomUUID().toString(), token)
@@ -449,30 +473,134 @@ class AgentForegroundService : Service() {
CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted)
}
api.submitResult(task.taskId, UUID.randomUUID().toString(), result, token)
resultSafelySubmitted = true
beginIdleReturnCooldown()
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
} catch (error: TaskFailure) {
if (started) failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginIdleReturnCooldown()
}
} catch (error: AgentApiException) {
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) {
if (started) failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
if (resultSafelySubmitted) beginIdleReturnCooldown()
}
} finally {
if (!resultSafelySubmitted) cancelIdleReturn("采集结果未安全提交")
}
}
@Suppress("DEPRECATION")
private fun acquireTaskWakeLock(): PowerManager.WakeLock =
getSystemService(PowerManager::class.java).newWakeLock(
private fun acquireTaskWakeLock() {
val wakeLock = getSystemService(PowerManager::class.java).newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"$packageName:collection-task",
).apply {
setReferenceCounted(false)
acquire(TASK_WAKE_LOCK_TIMEOUT_MILLIS)
}
taskWakeLock = wakeLock
taskWakeActive.set(true)
publishScreenPolicy()
}
private fun failSafely(api: AgentApiClient, taskId: Long, token: String, code: String, message: String) {
runCatching { api.submitFailure(taskId, UUID.randomUUID().toString(), code, message, token) }
private fun releaseTaskWakeLock() {
taskWakeLock?.let { lock -> if (lock.isHeld) lock.release() }
taskWakeLock = null
taskWakeActive.set(false)
publishScreenPolicy()
}
private fun failSafely(api: AgentApiClient, taskId: Long, token: String, code: String, message: String): Boolean {
val submitted = runCatching {
api.submitFailure(taskId, UUID.randomUUID().toString(), code, message, token)
}.isSuccess
stateStore.update("TASK_ERROR", "$code:$message", tokenStored = true)
updateNotification("任务 #$taskId 失败")
return submitted
}
private fun beginIdleReturnCooldown() {
val accessibility = GoAutoAccessibilityService.instance ?: return
val armed = idleReturn.arm(
nowMillis = SystemClock.elapsedRealtime(),
packageName = accessibility.currentPackage(),
foregroundRevision = accessibility.currentForegroundRevision(),
)
if (!armed) return
acquireCooldownWakeLock()
stateStore.update("ONLINE", "任务已结束,15 秒内无新任务将返回 Agent", tokenStored = true)
updateNotification("任务已结束 · 等待新任务")
publishScreenPolicy()
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
}
private fun evaluateIdleReturn() {
if (!idleReturn.isArmed()) return
val accessibility = GoAutoAccessibilityService.instance
if (accessibility == null) {
cancelIdleReturn("无障碍服务未就绪")
return
}
val localBoundarySafe = purchaseStore.interruptedAttempts().isEmpty() && taskMutex.currentTaskId() == null
when (idleReturn.evaluate(
nowMillis = SystemClock.elapsedRealtime(),
queuesEmpty = true,
localBoundarySafe = localBoundarySafe,
packageName = accessibility.currentPackage(),
foregroundRevision = accessibility.currentForegroundRevision(),
)) {
IdleReturnDecision.READY -> requestReturnToAgent(accessibility)
IdleReturnDecision.CANCELLED -> finishIdleReturn("已取消自动返回")
IdleReturnDecision.INACTIVE -> finishIdleReturn(null)
IdleReturnDecision.WAITING -> Unit
}
}
private fun requestReturnToAgent(accessibility: GoAutoAccessibilityService) {
val launched = accessibility.openAgentStatus()
if (launched) SystemClock.sleep(RETURN_CONFIRM_DELAY_MILLIS)
val returned = launched && accessibility.currentPackage() == packageName
finishIdleReturn(
if (returned) "15 秒内无新任务,已返回 Agent"
else "任务已结束;系统限制自动返回,请手动打开 Agent",
)
}
private fun cancelIdleReturn(reason: String) {
if (idleReturn.cancel()) Log.i("GoAutoAgent", "idle return cancelled: $reason")
finishIdleReturn(null)
}
private fun finishIdleReturn(message: String?) {
cooldownWakeLock?.let { lock -> if (lock.isHeld) lock.release() }
cooldownWakeLock = null
publishScreenPolicy()
message?.let {
stateStore.update("ONLINE", it, tokenStored = true)
updateNotification("在线 · 空闲")
}
}
@Suppress("DEPRECATION")
private fun acquireCooldownWakeLock() {
cooldownWakeLock?.let { lock -> if (lock.isHeld) lock.release() }
cooldownWakeLock = getSystemService(PowerManager::class.java).newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"$packageName:idle-return",
).apply {
setReferenceCounted(false)
acquire(IDLE_RETURN_WAKE_LOCK_TIMEOUT_MILLIS)
}
}
private fun publishScreenPolicy() {
stateStore.setKeepScreenOn(
ScreenAwakeResolver.shouldKeepScreenOn(taskWakeActive.get(), idleReturn.isArmed()),
)
}
private fun deviceInfo(): DeviceInfo = DeviceInfo(
@@ -544,6 +672,8 @@ class AgentForegroundService : Service() {
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 RETURN_CONFIRM_DELAY_MILLIS = 750L
fun start(context: Context, reconnect: Boolean = false) {
val intent = Intent(context, AgentForegroundService::class.java).apply {
@@ -12,6 +12,7 @@ data class AgentState(
val tokenStored: Boolean,
val currentTaskId: Long?,
val currentTaskType: String?,
val keepScreenOn: Boolean,
)
class AgentSettingsStore(context: Context) {
@@ -69,8 +70,13 @@ class AgentStateStore(context: Context) {
tokenStored = preferences.getBoolean(TOKEN_STORED, false),
currentTaskId = preferences.getLong(CURRENT_TASK_ID, 0L).takeIf { it > 0L },
currentTaskType = preferences.getString(CURRENT_TASK_TYPE, null),
keepScreenOn = preferences.getBoolean(KEEP_SCREEN_ON, false),
)
fun setKeepScreenOn(enabled: Boolean) {
preferences.edit().putBoolean(KEEP_SCREEN_ON, enabled).apply()
}
fun setActiveTask(taskId: Long, taskType: String) {
require(taskId > 0L)
require(taskType == "collection" || taskType == "purchase")
@@ -98,5 +104,6 @@ class AgentStateStore(context: Context) {
const val TOKEN_STORED = "token_stored"
const val CURRENT_TASK_ID = "current_task_id"
const val CURRENT_TASK_TYPE = "current_task_type"
const val KEEP_SCREEN_ON = "keep_screen_on"
}
}
@@ -0,0 +1,87 @@
package cn.ilapage.goauto.agent.service
enum class IdleReturnDecision {
INACTIVE,
WAITING,
READY,
CANCELLED,
}
/** Pure state machine for the post-task return window. Queue I/O stays in the service poller. */
class IdleReturnCoordinator(
private val cooldownMillis: Long = DEFAULT_COOLDOWN_MILLIS,
) {
private var ticket: Ticket? = null
init {
require(cooldownMillis > 0L)
}
@Synchronized
fun arm(nowMillis: Long, packageName: String?, foregroundRevision: Long): Boolean {
if (!IdleReturnForegroundPolicy.isAutomationPackage(packageName)) {
ticket = null
return false
}
ticket = Ticket(nowMillis, requireNotNull(packageName), foregroundRevision)
return true
}
@Synchronized
fun evaluate(
nowMillis: Long,
queuesEmpty: Boolean,
localBoundarySafe: Boolean,
packageName: String?,
foregroundRevision: Long,
): IdleReturnDecision {
val current = ticket ?: return IdleReturnDecision.INACTIVE
if (!queuesEmpty || !localBoundarySafe) {
ticket = null
return IdleReturnDecision.CANCELLED
}
if (packageName != current.packageName || foregroundRevision != current.foregroundRevision) {
ticket = null
return IdleReturnDecision.CANCELLED
}
if (nowMillis - current.startedAtMillis < cooldownMillis) return IdleReturnDecision.WAITING
ticket = null
return IdleReturnDecision.READY
}
@Synchronized
fun cancel(): Boolean {
val wasArmed = ticket != null
ticket = null
return wasArmed
}
@Synchronized
fun isArmed(): Boolean = ticket != null
private data class Ticket(
val startedAtMillis: Long,
val packageName: String,
val foregroundRevision: Long,
)
companion object {
const val DEFAULT_COOLDOWN_MILLIS = 15_000L
}
}
object IdleReturnForegroundPolicy {
private val automationPackages = setOf(
"com.xunmeng.pinduoduo",
"com.heytap.browser",
"com.android.chrome",
"com.android.browser",
)
fun isAutomationPackage(packageName: String?): Boolean = packageName in automationPackages
}
object ScreenAwakeResolver {
fun shouldKeepScreenOn(taskRunning: Boolean, cooldownArmed: Boolean): Boolean =
taskRunning || cooldownArmed
}
@@ -0,0 +1,67 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.service.IdleReturnCoordinator
import cn.ilapage.goauto.agent.service.IdleReturnDecision
import cn.ilapage.goauto.agent.service.ScreenAwakeResolver
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class IdleReturnCoordinatorTest {
@Test
fun `returns only after cooldown and successful empty queue confirmation`() {
val coordinator = IdleReturnCoordinator()
assertTrue(coordinator.arm(1_000L, "com.xunmeng.pinduoduo", 7L))
assertEquals(
IdleReturnDecision.WAITING,
coordinator.evaluate(15_999L, true, true, "com.xunmeng.pinduoduo", 7L),
)
assertEquals(
IdleReturnDecision.READY,
coordinator.evaluate(16_000L, true, true, "com.xunmeng.pinduoduo", 7L),
)
assertFalse(coordinator.isArmed())
}
@Test
fun `foreground change cancels even when user comes back to same app`() {
val coordinator = IdleReturnCoordinator()
coordinator.arm(0L, "com.xunmeng.pinduoduo", 3L)
assertEquals(
IdleReturnDecision.CANCELLED,
coordinator.evaluate(15_000L, true, true, "com.xunmeng.pinduoduo", 5L),
)
}
@Test
fun `active queue or unsafe local boundary cancels`() {
val withTask = IdleReturnCoordinator().apply { arm(0L, "com.android.chrome", 1L) }
assertEquals(
IdleReturnDecision.CANCELLED,
withTask.evaluate(15_000L, false, true, "com.android.chrome", 1L),
)
val unsafe = IdleReturnCoordinator().apply { arm(0L, "com.android.chrome", 1L) }
assertEquals(
IdleReturnDecision.CANCELLED,
unsafe.evaluate(15_000L, true, false, "com.android.chrome", 1L),
)
}
@Test
fun `does not arm outside automation apps`() {
val coordinator = IdleReturnCoordinator()
assertFalse(coordinator.arm(0L, "com.tencent.mm", 1L))
assertEquals(
IdleReturnDecision.INACTIVE,
coordinator.evaluate(20_000L, true, true, "com.tencent.mm", 1L),
)
}
@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))
}
}
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: d076a86d9282a98c8bb3f00deb0edee68041f2ec
synchronized_at: 2026-08-25T08:52:07Z
wiki_revision: 1c9841a942b5d5682ddada2ee7202e93adc422e4
synchronized_at: 2026-08-25T09:44:21Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -158,3 +158,11 @@ Android Portal/Agent
- 设置页维护服务器地址和设备名称,测试连接只访问 `GET /api/v1/health`;保存后沿用当前 installId / Device Token 重新连接。执行任务或测试连接期间表单与按钮禁用。
- 设置页只显示 Token 是否配置,不返回或显示明文;不包含 PDD 账号、采购写操作或支付入口。
- `AgentStateStore` 保存界面所需的当前任务类型/编号摘要;任务结束后由执行服务清除。它不保存控件树、截图、完整规则或凭据。
## Android Agent 任务结束冷却与返回(#89)
- `AgentForegroundService` 在采集结果已提交,或采购结果与 Outbox 已在同一事务安全保存后,建立 15 秒任务结束冷却;不新增第二套队列轮询器。
- 冷却到期仍由现有任务轮询按“采购优先、再采集”依次确认两个队列都为空。任何新任务、网络/服务端异常或本地未解除的不可逆采购边界都会取消返回。
- `IdleReturnCoordinator` 只保存冷却时间、自动化前台包名与无障碍前台切换序号,不保存控件树或截图。用户切换到其他 App 后,即使又返回 PDD,也不会被强制拉回 Agent。
- 返回请求只允许从 Agent 本次自动化停留的 PDD 或受支持浏览器页面发起,并由无障碍服务打开 `MainActivity` 状态 Tab;Android 后台 Activity 启动限制导致失败时只提示手动打开,不重复强拉。
- 任务执行和冷却分别使用带超时的亮屏锁;`MainActivity` 仅在运行态标记要求时设置 `FLAG_KEEP_SCREEN_ON`。任务、新任务接管、取消、返回或服务销毁后统一释放,不绕过设备 PIN、图案或密码。
+15 -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: f79711e63e200dc8f7bdc0b04bf5a87f35e2a405
synchronized_at: 2026-08-25T08:52:22Z
wiki_revision: dc484e1aa48172d502939e8ebe0ad0e6bd83c7ea
synchronized_at: 2026-08-25T09:44:34Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -199,3 +199,16 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
- 任务执行中服务器地址、设备名称、测试和保存均禁用;Token 只显示“已配置/未配置”。
- 采集/采购 Tab 在 #90 前只显示明确占位,不应出现采购写操作、PDD 凭据或支付入口。
- Release 仍只接受 HTTPS;Debug 可使用 HTTP 进行局域网联调。安装前必须确认设备空闲,避免重启 Agent 中断任务。
### Android Agent 0.3.1 空闲返回与亮屏检查
#89 起 Debug APK 版本为 0.3.1(versionCode 4)。先确认设备空闲,再覆盖安装;安装后系统可能关闭无障碍服务,需要人工重新开启。
真机验证应至少覆盖:
- 完成一个不会支付的采集或演练任务,确认结果已提交后约 15 秒返回 Agent“状态”Tab;记录 ROM 是否限制后台 Activity 启动。
- 冷却 15 秒内创建任一类型新任务,确认 Agent 取消返回并继续按采购优先、采集其次执行。
- 冷却期间断网,确认不会把网络失败当成空队列,也不会返回。
- 冷却期间人工切换到其他 App,确认 Agent 不会强拉回;即使再切回 PDD,前台切换序号变化也会取消本次返回。
- 使用 `adb shell dumpsys power` 或屏幕实际状态确认任务执行与冷却期间亮屏;返回、取消或服务停止后不再持有 `cn.ilapage.goauto.agent:collection-task` / `:idle-return` WakeLock。
- 该流程不自动解锁安全锁屏,不点击创建订单或支付。