feat(android): implement #89 idle return policy
This commit is contained in:
+3
-1
@@ -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 秒心跳、前台通知和在线空闲状态已验证。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -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>()
|
||||
|
||||
+141
-11
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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、图案或密码。
|
||||
|
||||
@@ -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。
|
||||
- 该流程不自动解锁安全锁屏,不点击创建订单或支付。
|
||||
|
||||
Reference in New Issue
Block a user