diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 48ab44f..f614156 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 = 16 - versionName = "0.9.3" + versionCode = 17 + versionName = "0.9.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt index 2aa436d..d34613d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/CurrentPageCollection.kt @@ -23,26 +23,93 @@ data class ClipboardReadOutcome( enum class CurrentPageRestoreDecision { READY, - BACK_FROM_AGENT, - REJECT, + BACK_THEN_LAUNCH, + LAUNCH, +} + +enum class CurrentPageLaunchResult { + STARTED, + START_FAILED, + BACKGROUND_FAILED, +} + +enum class PddLaunchResult { + STARTED, + INTENT_MISSING, + FAILED, +} + +enum class PddForegroundRestoreResult { + READY, + INTENT_MISSING, + LAUNCH_FAILED, + TIMEOUT, } object CurrentPageNavigationPolicy { fun restoreDecision(currentPackage: String?, agentPackage: String, recentPdd: Boolean): CurrentPageRestoreDecision = when { currentPackage == PDD_PACKAGE -> CurrentPageRestoreDecision.READY - currentPackage == agentPackage && recentPdd -> CurrentPageRestoreDecision.BACK_FROM_AGENT - else -> CurrentPageRestoreDecision.REJECT + currentPackage == agentPackage && recentPdd -> CurrentPageRestoreDecision.BACK_THEN_LAUNCH + else -> CurrentPageRestoreDecision.LAUNCH } - fun backgroundAndLaunch(moveTaskToBack: () -> Boolean, launch: () -> Unit): Boolean { - if (!moveTaskToBack()) return false - launch() - return true + fun launchBeforeOptionalBackground( + launch: () -> Unit, + moveTaskToBack: (() -> Boolean)? = null, + ): CurrentPageLaunchResult { + runCatching(launch).getOrElse { return CurrentPageLaunchResult.START_FAILED } + if (moveTaskToBack != null && !moveTaskToBack()) return CurrentPageLaunchResult.BACKGROUND_FAILED + return CurrentPageLaunchResult.STARTED } private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" } +class PddForegroundRestorer( + private val currentPackage: () -> String?, + private val recentPdd: () -> Boolean, + private val backFromAgent: () -> Boolean, + private val launchPdd: () -> PddLaunchResult, + private val now: () -> Long, + private val pause: (Long) -> Unit, +) { + fun restore(agentPackage: String, timeoutMillis: Long): PddForegroundRestoreResult { + val deadline = now() + timeoutMillis + when (CurrentPageNavigationPolicy.restoreDecision(currentPackage(), agentPackage, recentPdd())) { + CurrentPageRestoreDecision.READY -> return PddForegroundRestoreResult.READY + CurrentPageRestoreDecision.BACK_THEN_LAUNCH -> { + if (backFromAgent() && waitForPdd(minOf(deadline, now() + BACK_FALLBACK_MILLIS))) { + return PddForegroundRestoreResult.READY + } + } + CurrentPageRestoreDecision.LAUNCH -> Unit + } + return when (launchPdd()) { + PddLaunchResult.INTENT_MISSING -> PddForegroundRestoreResult.INTENT_MISSING + PddLaunchResult.FAILED -> PddForegroundRestoreResult.LAUNCH_FAILED + PddLaunchResult.STARTED -> if (waitForPdd(deadline)) { + PddForegroundRestoreResult.READY + } else { + PddForegroundRestoreResult.TIMEOUT + } + } + } + + private fun waitForPdd(deadline: Long): Boolean { + do { + if (currentPackage() == PDD_PACKAGE) return true + if (now() >= deadline) return false + pause(POLL_MILLIS) + } while (true) + } + + private companion object { + const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + const val BACK_FALLBACK_MILLIS = 750L + const val POLL_MILLIS = 100L + } +} + object PddShareLinkExtractor { private val urlPattern = Regex("https://[^\\s]+", RegexOption.IGNORE_CASE) private val trailingPunctuation = charArrayOf(')', ']', '}', ')', '】', '」', '』', '》', ',', ',', '.', '。', ';', ';') 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 cdd94ad..a4157c1 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 @@ -99,19 +99,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return seenAt > 0L && SystemClock.elapsedRealtime() - seenAt in 0..maxAgeMillis } - fun restoreRecentPddPage(timeoutMillis: Long = 5_000L): Boolean { - when (CurrentPageNavigationPolicy.restoreDecision(currentPackage(), packageName, hasRecentPddForeground())) { - CurrentPageRestoreDecision.READY -> return true - CurrentPageRestoreDecision.REJECT -> return false - CurrentPageRestoreDecision.BACK_FROM_AGENT -> if (!performGlobalAction(GLOBAL_ACTION_BACK)) return false - } - val deadline = SystemClock.elapsedRealtime() + timeoutMillis - do { - if (currentPackage() == PDD_PACKAGE) return true - Thread.sleep(100) - } while (SystemClock.elapsedRealtime() <= deadline) - return false - } + fun restoreRecentPddPage( + launchPdd: () -> PddLaunchResult, + timeoutMillis: Long = 5_000L, + ): PddForegroundRestoreResult = PddForegroundRestorer( + currentPackage = ::currentPackage, + recentPdd = ::hasRecentPddForeground, + backFromAgent = { performGlobalAction(GLOBAL_ACTION_BACK) }, + launchPdd = launchPdd, + now = SystemClock::elapsedRealtime, + pause = Thread::sleep, + ).restore(packageName, timeoutMillis) fun openAgentStatus(): Boolean = runCatching { startActivity( 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 297c01f..eb8494d 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 @@ -24,7 +24,10 @@ import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.automation.CollectionAssembler import cn.ilapage.goauto.agent.automation.AgentCapabilities import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner +import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService +import cn.ilapage.goauto.agent.automation.PddForegroundRestoreResult +import cn.ilapage.goauto.agent.automation.PddLaunchResult import cn.ilapage.goauto.agent.automation.PddLinkLauncher import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner import cn.ilapage.goauto.agent.automation.PddProductDetailCollector @@ -59,6 +62,7 @@ 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.AtomicLong import java.util.concurrent.atomic.AtomicReference import java.util.UUID @@ -73,6 +77,7 @@ class AgentForegroundService : Service() { private val registeredThisProcess = AtomicBoolean(false) private val taskWakeActive = AtomicBoolean(false) private val collectionCooldownWakeActive = AtomicBoolean(false) + private val currentPageResultNotificationUntil = AtomicLong(0L) private val idleReturn = IdleReturnCoordinator() private var taskWakeLock: PowerManager.WakeLock? = null private var cooldownWakeLock: PowerManager.WakeLock? = null @@ -320,8 +325,15 @@ class AgentForegroundService : Service() { } runningTaskId.set(task.taskId) stateStore.setActiveTask(task.taskId, "collection") - executeTask(api, task, credentials.token) - publishCurrentPageResult(task.taskId, "临时采集任务 #${task.taskId} 已结束,请查看采集记录。") + val outcome = executeTask(api, task, credentials.token) + publishCurrentPageResult( + task.taskId, + if (outcome.successful) { + "临时采集任务 #${task.taskId} 已结束,请查看采集记录。" + } else { + currentPageUserMessage(outcome.code.orEmpty(), outcome.message.orEmpty()) + }, + ) } catch (error: TaskFailure) { publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败")) } catch (error: AgentApiException) { @@ -355,6 +367,8 @@ class AgentForegroundService : Service() { } private fun publishCurrentPageResult(taskId: Long, message: String) { + currentPageResultNotificationUntil.set(SystemClock.elapsedRealtime() + CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS) + updateNotification(message, force = true) sendBroadcast(Intent(ACTION_CURRENT_PAGE_RESULT).apply { setPackage(packageName) putExtra(EXTRA_CURRENT_PAGE_TASK_ID, taskId) @@ -570,19 +584,27 @@ class AgentForegroundService : Service() { .toString() - private fun executeTask(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) { + private fun executeTask( + api: AgentApiClient, + initialTask: cn.ilapage.goauto.agent.network.AgentTask, + token: String, + ): TaskExecutionSummary { acquireTaskWakeLock() - try { + return try { executeTaskWhileAwake(api, initialTask, token) } finally { releaseTaskWakeLock() } } - private fun executeTaskWhileAwake(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) { + private fun executeTaskWhileAwake( + api: AgentApiClient, + initialTask: cn.ilapage.goauto.agent.network.AgentTask, + token: String, + ): TaskExecutionSummary { var started = initialTask.status == "running" var resultSafelySubmitted = false - try { + return try { val claimed = if (initialTask.status == "pending") { api.claimTask(initialTask.taskId, UUID.randomUUID().toString(), token) } else initialTask @@ -603,8 +625,14 @@ class AgentForegroundService : Service() { if (rule.schemaVersion != 2) { throw TaskFailure("RULE_INVALID", "当前页面采集只支持新版采集规则") } - if (!accessibility.restoreRecentPddPage()) { - throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页") + when (accessibility.restoreRecentPddPage(::launchPddToForeground)) { + PddForegroundRestoreResult.READY -> Unit + PddForegroundRestoreResult.INTENT_MISSING -> + throw TaskFailure("PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装") + PddForegroundRestoreResult.LAUNCH_FAILED -> + throw TaskFailure("PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试") + PddForegroundRestoreResult.TIMEOUT -> + throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "拼多多未能切换到前台") } val identityResult = CurrentPageIdentityRunner( driver = accessibility, @@ -653,18 +681,22 @@ class AgentForegroundService : Service() { resultSafelySubmitted = true stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true) beginPostCollectionCooldowns() + TaskExecutionSummary(successful = true) } catch (error: TaskFailure) { if (started) { resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败") if (resultSafelySubmitted) beginPostCollectionCooldowns() } + TaskExecutionSummary(false, error.code, error.message) } catch (error: AgentApiException) { stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true) + TaskExecutionSummary(false, error.code, error.message) } catch (error: Exception) { if (started) { resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常") if (resultSafelySubmitted) beginPostCollectionCooldowns() } + TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message) } finally { if (!resultSafelySubmitted) cancelIdleReturn("采集结果未安全提交") } @@ -934,10 +966,25 @@ class AgentForegroundService : Service() { .build() } - private fun updateNotification(content: String) { + private fun updateNotification(content: String, force: Boolean = false) { + if (!force && SystemClock.elapsedRealtime() < currentPageResultNotificationUntil.get()) return getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(content)) } + private fun launchPddToForeground(): PddLaunchResult { + val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE) + ?: return PddLaunchResult.INTENT_MISSING + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return runCatching { startActivity(launchIntent) } + .fold( + onSuccess = { PddLaunchResult.STARTED }, + onFailure = { + Log.w("GoAutoAgent", "failed to bring PDD to foreground", it) + PddLaunchResult.FAILED + }, + ) + } + companion object { const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT" const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW" @@ -966,6 +1013,8 @@ class AgentForegroundService : Service() { private const val COOLDOWN_WAKE_GRACE_MILLIS = 5_000L private const val RETURN_CONFIRM_DELAY_MILLIS = 750L private const val CURRENT_PAGE_RESERVATION_ID = Long.MAX_VALUE + private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + private const val CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS = 30_000L fun start(context: Context, reconnect: Boolean = false) { val intent = Intent(context, AgentForegroundService::class.java).apply { @@ -989,18 +1038,28 @@ class AgentForegroundService : Service() { } } - fun collectCurrentPage(context: Context, requestId: String) { + fun collectCurrentPage(context: Context, requestId: String): CurrentPageLaunchResult { val intent = Intent(context, AgentForegroundService::class.java).apply { action = ACTION_CURRENT_PAGE_COLLECTION putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } + return cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy.launchBeforeOptionalBackground( + launch = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + }, + ) } } } private class TaskFailure(val code: String, message: String) : Exception(message) + +private data class TaskExecutionSummary( + val successful: Boolean, + val code: String? = null, + val message: String? = null, +) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index 5083ad0..6402849 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -24,7 +24,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import cn.ilapage.goauto.agent.R import cn.ilapage.goauto.agent.identity.SecureDeviceStore import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService -import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy +import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult import cn.ilapage.goauto.agent.network.AgentApiClient import cn.ilapage.goauto.agent.network.CollectionHistoryDetail import cn.ilapage.goauto.agent.network.CollectionHistoryItem @@ -494,11 +494,13 @@ class TaskHistoryFragment : Fragment() { .setNegativeButton("取消", null) .setPositiveButton("开始采集") { _, _ -> toast("正在创建临时采集任务…") - val launched = CurrentPageNavigationPolicy.backgroundAndLaunch( - moveTaskToBack = { requireActivity().moveTaskToBack(true) }, - launch = { AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString()) }, - ) - if (!launched) showCurrentPageBlocked("无法返回拼多多,请重新打开商品后再试。") + when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())) { + CurrentPageLaunchResult.STARTED -> Unit + CurrentPageLaunchResult.START_FAILED -> + showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。") + CurrentPageLaunchResult.BACKGROUND_FAILED -> + showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。") + } } .show() } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt index 772d653..885b06f 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CurrentPageCollectionTest.kt @@ -4,14 +4,17 @@ import cn.ilapage.goauto.agent.automation.CollectionRule import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner +import cn.ilapage.goauto.agent.automation.CurrentPageLaunchResult import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy -import cn.ilapage.goauto.agent.automation.CurrentPageRestoreDecision import cn.ilapage.goauto.agent.automation.FreshActionResult import cn.ilapage.goauto.agent.automation.FreshClickOutcome import cn.ilapage.goauto.agent.automation.FreshClickReason import cn.ilapage.goauto.agent.automation.NodeBounds import cn.ilapage.goauto.agent.automation.NodeSelector import cn.ilapage.goauto.agent.automation.PageEvidence +import cn.ilapage.goauto.agent.automation.PddForegroundRestoreResult +import cn.ilapage.goauto.agent.automation.PddForegroundRestorer +import cn.ilapage.goauto.agent.automation.PddLaunchResult import cn.ilapage.goauto.agent.automation.PddCollectorDriver import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor import cn.ilapage.goauto.agent.automation.SnapshotNode @@ -27,47 +30,95 @@ import org.junit.Test class CurrentPageCollectionTest { @Test - fun backgroundsAgentBeforeLaunchingCurrentPageCollection() { + fun launchesServiceBeforeOptionalBackgroundFallback() { val calls = mutableListOf() - val launched = CurrentPageNavigationPolicy.backgroundAndLaunch( - moveTaskToBack = { calls += "background"; true }, + val result = CurrentPageNavigationPolicy.launchBeforeOptionalBackground( launch = { calls += "launch" }, + moveTaskToBack = { calls += "background"; true }, ) - assertTrue(launched) - assertEquals(listOf("background", "launch"), calls) + assertEquals(CurrentPageLaunchResult.STARTED, result) + assertEquals(listOf("launch", "background"), calls) } @Test - fun doesNotLaunchWhenAgentTaskCannotMoveToBackground() { - var launchCalled = false - val launched = CurrentPageNavigationPolicy.backgroundAndLaunch( + fun distinguishesServiceLaunchFailureFromBackgroundFailure() { + var backgroundCalled = false + val launchFailure = CurrentPageNavigationPolicy.launchBeforeOptionalBackground( + launch = { error("blocked") }, + moveTaskToBack = { backgroundCalled = true; true }, + ) + val backgroundFailure = CurrentPageNavigationPolicy.launchBeforeOptionalBackground( + launch = {}, moveTaskToBack = { false }, - launch = { launchCalled = true }, ) - assertFalse(launched) + assertEquals(CurrentPageLaunchResult.START_FAILED, launchFailure) + assertFalse(backgroundCalled) + assertEquals(CurrentPageLaunchResult.BACKGROUND_FAILED, backgroundFailure) + } + + @Test + fun keepsPddForegroundWithoutBackOrRelaunch() { + var backCalled = false + var launchCalled = false + val result = restorer( + currentPackage = { PDD_PACKAGE }, + back = { backCalled = true; true }, + launch = { launchCalled = true; PddLaunchResult.STARTED }, + ).restore(AGENT_PACKAGE, 5_000) + + assertEquals(PddForegroundRestoreResult.READY, result) + assertFalse(backCalled) assertFalse(launchCalled) } @Test - fun restoresOnlyPddOrOneRecentAgentFallback() { - assertEquals( - CurrentPageRestoreDecision.READY, - CurrentPageNavigationPolicy.restoreDecision(PDD_PACKAGE, "cn.ilapage.goauto.agent", recentPdd = true), - ) - assertEquals( - CurrentPageRestoreDecision.BACK_FROM_AGENT, - CurrentPageNavigationPolicy.restoreDecision("cn.ilapage.goauto.agent", "cn.ilapage.goauto.agent", recentPdd = true), - ) - assertEquals( - CurrentPageRestoreDecision.REJECT, - CurrentPageNavigationPolicy.restoreDecision("com.android.settings", "cn.ilapage.goauto.agent", recentPdd = true), - ) - assertEquals( - CurrentPageRestoreDecision.REJECT, - CurrentPageNavigationPolicy.restoreDecision("cn.ilapage.goauto.agent", "cn.ilapage.goauto.agent", recentPdd = false), - ) + fun restoresRecentPddFromAgentWithOneBackOnly() { + var foreground = AGENT_PACKAGE + var launchCalled = false + val result = restorer( + currentPackage = { foreground }, + back = { foreground = PDD_PACKAGE; true }, + launch = { launchCalled = true; PddLaunchResult.STARTED }, + ).restore(AGENT_PACKAGE, 5_000) + + assertEquals(PddForegroundRestoreResult.READY, result) + assertFalse(launchCalled) + } + + @Test + fun explicitlyLaunchesPddFromThirdPartyForeground() { + var foreground = "com.android.launcher" + val result = restorer( + currentPackage = { foreground }, + launch = { foreground = PDD_PACKAGE; PddLaunchResult.STARTED }, + ).restore(AGENT_PACKAGE, 5_000) + + assertEquals(PddForegroundRestoreResult.READY, result) + } + + @Test + fun reportsMissingPddLaunchIntent() { + val result = restorer( + currentPackage = { "com.android.launcher" }, + launch = { PddLaunchResult.INTENT_MISSING }, + ).restore(AGENT_PACKAGE, 5_000) + + assertEquals(PddForegroundRestoreResult.INTENT_MISSING, result) + } + + @Test + fun reportsPddForegroundTimeoutAfterLaunch() { + var elapsed = 0L + val result = restorer( + currentPackage = { "com.android.launcher" }, + launch = { PddLaunchResult.STARTED }, + now = { elapsed }, + pause = { elapsed += it }, + ).restore(AGENT_PACKAGE, 500) + + assertEquals(PddForegroundRestoreResult.TIMEOUT, result) } @Test @@ -170,8 +221,18 @@ class CurrentPageCollectionTest { private companion object { const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + const val AGENT_PACKAGE = "cn.ilapage.goauto.agent" const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity" + fun restorer( + currentPackage: () -> String?, + recentPdd: () -> Boolean = { true }, + back: () -> Boolean = { false }, + launch: () -> PddLaunchResult, + now: () -> Long = { 0L }, + pause: (Long) -> Unit = {}, + ) = PddForegroundRestorer(currentPackage, recentPdd, back, launch, now, pause) + fun rule() = CollectionRule( schemaVersion = 2, steps = emptyList(), diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index b8c5d47..e206c24 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: ecfe6f881cb937688547b0fe819734813d93dd1b -synchronized_at: 2026-08-27T02:38:43Z +wiki_revision: 7a1281d8873cc6afaf121c13c0562a3caf1d3eb7 +synchronized_at: 2026-08-27T06:25:11Z # 架构与代码地图 @@ -213,7 +213,7 @@ Android Portal/Agent - 服务端入口位于 `server/app/goauto/task/current_page.go`:`CreateCurrentPage` 原子完成设备校验、任务创建、规则快照和租约;`IdentifyCurrentPage` 负责白名单短链解析、PDD 商品复用/创建和任务身份绑定。路由为 `/api/agent/v1/current-page-collection-tasks` 及其 `/{taskId}/identify`。 - 默认规则设置位于 `server/app/goauto/rule/agent_manual_setting.go`,使用单行表 `agent_manual_collection_setting`。迁移 `1787790000000_agent_current_page_collection.go` 增加任务来源、识别幂等字段、可空 PDD 外键和默认规则表,并对旧任务回填 `admin`。 - `collection_task.source` 目前只允许 `admin` / `agent_current_page`。当前页面任务创建时由设备运行槽防并发,识别商品后再参与 PDD 商品活动槽;结构化结果仍写入既有任务及规格子表,不新增临时任务表。 -- Android 入口仍由 `TaskHistoryFragment` 承载;`AgentForegroundService` 先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId,再恢复 PDD、识别身份并调用既有 `PddProductDetailCollector`。 +- Android 入口仍由 `TaskHistoryFragment` 承载;点击确认时先启动 `AgentForegroundService`,不先把 Agent 退到后台。服务先用 `TaskExecutionMutex` 预占本地串行槽,服务端创建成功后把预占转为真实 taskId;前台已是 PDD 时不执行返回,前台仍是 Agent 且近期见过 PDD 时只尝试一次返回,否则使用不清理任务栈、不带深链参数的 PDD 启动 Intent 恢复现有任务,并在有界等待确认 PDD 前台后才识别身份和调用既有 `PddProductDetailCollector`。 - `CurrentPageIdentityRunner` 负责页面证据、唯一分享/复制入口和面板清理;`ClipboardRelayActivity` 使用独立、不可导出的短生命周期任务在前台读取新鲜剪贴板,完成后移除中转任务并露出原 PDD 页面。原始剪贴板不进入日志、缓存或网络请求。 - v2 规则新增可选 `currentPageIdentity`(分享/复制别名和三个有界超时),旧 v2 规则由 Android 使用安全默认值;新设备能力为 `collector.pdd.current-page-share.v1`。 - 该路径复用既有结果/失败接口、PDD 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index f612dd9..21c7c51 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: 579d9b25297d7b404c68aca4a29ef3179555baa9 -synchronized_at: 2026-08-27T02:38:52Z +wiki_revision: fa2a6f6a41290095a2625771715ad28c1c6c6736 +synchronized_at: 2026-08-27T06:25:24Z # 业务规则与术语 @@ -277,7 +277,7 @@ synchronized_at: 2026-08-27T02:38:52Z - 操作员先人工在 PDD App 打开并停留在目标商品详情页,再从 Agent 采集 Tab 点击“采集”;该入口采集当前页面,不是重置历史任务。 - Agent 在请求服务端前取得采集/采购共用的本地互斥锁。服务端原子校验设备在线、空闲、跨域活动任务、默认规则和能力,创建固定当前设备且已进入 `running` 的标准采集任务。 -- Agent 只通过系统返回恢复刚才的 PDD 任务栈,并重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、从桌面重开 PDD、自动搜索或自动选择相似商品。 +- Agent 启动当前页面采集前先保持自身在前台并启动既有前台服务。恢复时若 PDD 已在前台则不返回;若 Agent 仍在前台且近期见过 PDD,只尝试一次系统返回;仍未恢复时只使用 PDD 自身的不带参数启动 Intent 拉回现有任务,不附加清栈/重置标志。恢复后必须重新验证包名、精确 Activity 与页面强证据;不得启动浏览器、构造深链、自动搜索或自动选择相似商品。 - 恢复详情页后先按规则唯一点击“分享”与“复制链接”,只读取本次复制后的单条、有限长度剪贴板文本;Android 只保留唯一白名单 URL,服务端再校验短链并确认 goods_id。原始剪贴板、分享文案、控件树和截图都不保存。 - goods_id 已存在时复用唯一 PDD 商品,成功或部分成功结果按既有最新档案覆盖规则更新;不存在时创建 PDD 商品。该流程不自动停用或替换旧商品,也不修改虾皮到 PDD 的关联。 - 管理员必须配置一条存活的 PDD 商品详情 v2 规则作为 Agent 手动采集默认规则;迁移只做一次安全初选,创建任务时仍实时验证规则与设备 `collector.pdd.current-page-share.v1` 能力。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index dfa6ac0..db9e719 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: 9253f4f7557eeb014032edd7b0645de19a1516f5 -synchronized_at: 2026-08-27T02:39:03Z +wiki_revision: 3dc6c3774b31e28f6ad60f564d673a5cabdbb678 +synchronized_at: 2026-08-27T06:25:36Z # 本地开发与验证 @@ -250,12 +250,12 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity - 将范围设为非固定值并完成一次采集,记录状态页显示的实际剩余秒数;间隔期间重启 Agent 前台服务,确认按同一抽取结果恢复且没有重新随机。使用 `adb shell dumpsys power` 确认 `:collection-cooldown` WakeLock 有界持有并在到期或服务停止后释放。 - 此项验证不要求创建正式采购订单;没有单独授权时不得点击创建订单,永久禁止支付。 -### Android Agent 0.9.0 当前页面临时采集检查(#101) +### Android Agent 0.9.4 当前页面临时采集检查(#101/#107) - 自动化验证运行 `cd android && .\gradlew.bat testDebugUnitTest assembleDebug`,服务端运行 `cd server && go test ./...`;覆盖本地串行预占转移、分享 URL 白名单、唯一分享/复制入口、默认规则、创建/识别幂等、身份冲突和结果写回。 - 本地 MySQL 8.4 必须先在明确授权后运行 `cd server && go run . migrate -c config/settings.yml`,确认迁移版本 `1787790000000` 已应用;重复执行应报告 0 个新增迁移。迁移保留旧任务并回填来源 `admin`。 -- 真机覆盖安装前确认设备空闲、Agent 0.9.0 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。 -- 在 PDD 人工打开已授权的测试商品详情页,切回 Agent 采集 Tab 点击“采集”并确认;验证 Agent 不启动浏览器,只返回原 PDD 页面,依次完成分享、复制链接、goods_id 识别和常规采集。 +- 真机覆盖安装前确认设备空闲、Agent 0.9.4 已上报 `collector.pdd.current-page-share.v1`、无障碍已人工开启,服务端已有 Agent 手动采集默认规则。 +- 在 PDD 人工打开已授权的测试商品详情页,分别经 PDD 直接切换、桌面图标和最近任务进入 Agent,点击“采集”并确认;验证前台服务先启动,PDD 现有任务被拉回前台且仍停在原商品详情页,没有清栈、重置首页或启动浏览器,再依次完成分享、复制链接、goods_id 识别和常规采集。另验证 PDD 未安装、启动 Intent 缺失、前台切换超时和前台服务启动被拒绝时均有可见反馈。 - 分别验证直链和 PDD 短链;成功/部分成功应显示来源“Agent 当前页面”,相同 goods_id 不产生重复商品,任务详情与 PDD 最新档案一致。 - 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。 - 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。