diff --git a/android/README.md b/android/README.md index 77c590a..b89ad1b 100644 --- a/android/README.md +++ b/android/README.md @@ -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 秒心跳、前台通知和在线空闲状态已验证。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ddff439..00998c9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt index 3a6c5a0..29d9908 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/MainActivity.kt @@ -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" } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index e68a139..ae37247 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -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 { val root = rootInActiveWindow ?: return emptyList() val texts = mutableListOf() 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 f1b90ad..d1d9716 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 @@ -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(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 { 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 f3d6cc7..27d245b 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 @@ -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" } } 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 new file mode 100644 index 0000000..8cbb52b --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/IdleReturnCoordinator.kt @@ -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 +} 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 new file mode 100644 index 0000000..8e454c0 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/IdleReturnCoordinatorTest.kt @@ -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)) + } +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index cac0059..a627d8a 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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、图案或密码。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index f101eaf..38ddacc 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: f79711e63e200dc8f7bdc0b04bf5a87f35e2a405 -synchronized_at: 2026-08-25T08:52:22Z +wiki_revision: dc484e1aa48172d502939e8ebe0ad0e6bd83c7ea +synchronized_at: 2026-08-25T09:44:34Z # 本地开发与验证 @@ -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。 +- 该流程不自动解锁安全锁屏,不点击创建订单或支付。