fix(agent): restore PDD before current-page collection (#107)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+75
-8
@@ -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(')', ']', '}', ')', '】', '」', '』', '》', ',', ',', '.', '。', ';', ';')
|
||||
|
||||
+11
-13
@@ -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(
|
||||
|
||||
+74
-15
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<String>()
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -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 最新档案写回、任务结束返回和采集间隔,不调用浏览器导航,不进入任何采购、地址、创建订单或支付代码。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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` 能力。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -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 最新档案一致。
|
||||
- 断开网络、离开详情页、制造重复分享入口或剪贴板不可用时,应得到普通人可理解的失败原因并释放设备槽;原始分享文案、剪贴板、控件树和截图不得出现在数据库或日志。
|
||||
- 验证期间不得自动搜索或选择相似商品,不得修改虾皮关联,不创建采购任务、不修改地址、不创建订单,永久禁止支付。
|
||||
|
||||
Reference in New Issue
Block a user