feat(android): add Pinduoduo search probe

This commit is contained in:
QiuSW
2026-07-25 19:40:19 +08:00
parent 1c1c158149
commit f226d0f2ff
21 changed files with 1356 additions and 70 deletions
+7 -1
View File
@@ -64,7 +64,13 @@ android {
} }
into(generatedProbeAssets.map { it.dir("probe-fixtures") }) into(generatedProbeAssets.map { it.dir("probe-fixtures") })
} }
tasks.matching { it.name == "mergeDebugAssets" }.configureEach { tasks.matching {
it.name == "mergeDebugAssets" ||
(
it.name.contains("Debug") &&
it.name.contains("lint", ignoreCase = true)
)
}.configureEach {
dependsOn(syncProbeFixtures) dependsOn(syncProbeFixtures)
} }
} }
@@ -42,15 +42,23 @@ import com.roubao.autopilot.vlm.GUIOwlClient
import com.roubao.autopilot.vlm.MAIUIClient import com.roubao.autopilot.vlm.MAIUIClient
import com.roubao.autopilot.vlm.VLMClient import com.roubao.autopilot.vlm.VLMClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku import rikka.shizuku.Shizuku
import android.util.Log import android.util.Log
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoUiDriver
import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) { sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build) object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
object Home : Screen("home", "肉包", Icons.Outlined.Home, Icons.Filled.Home) object Home : Screen("home", "探针", Icons.Outlined.Search, Icons.Filled.Search)
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List) object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings) object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
} }
@@ -65,6 +73,11 @@ class MainActivity : ComponentActivity() {
private val mobileAgent = mutableStateOf<MobileAgent?>(null) private val mobileAgent = mutableStateOf<MobileAgent?>(null)
private var shizukuAvailable = mutableStateOf(false) private var shizukuAvailable = mutableStateOf(false)
private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty()) private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty())
private val searchProbeState = mutableStateOf(WorkflowState.IDLE)
private val searchProbeStepId = mutableStateOf<String?>(null)
private val searchProbeReport = mutableStateOf<WorkflowReport?>(null)
private var searchProbeRunner: WorkflowRunner? = null
private var searchProbeJob: Job? = null
// 当前执行的协程 Job(用于停止任务) // 当前执行的协程 Job(用于停止任务)
private var currentExecutionJob: kotlinx.coroutines.Job? = null private var currentExecutionJob: kotlinx.coroutines.Job? = null
@@ -172,21 +185,19 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainApp() { fun MainApp() {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Device) } var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) } var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
var showShizukuHelpDialog by remember { mutableStateOf(false) }
val settings by settingsManager.settings.collectAsState() val settings by settingsManager.settings.collectAsState()
val colors = BaoziTheme.colors val colors = BaoziTheme.colors
val agent = mobileAgent.value
val agentState by agent?.state?.collectAsState() ?: remember { mutableStateOf(null) }
val logs by agent?.logs?.collectAsState() ?: remember { mutableStateOf(emptyList<String>()) }
val records by remember { executionRecords } val records by remember { executionRecords }
val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission() val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission()
val executing by remember { isExecuting }
val navigateToRecord by remember { shouldNavigateToRecord } val navigateToRecord by remember { shouldNavigateToRecord }
val recordId by remember { currentRecordId } val recordId by remember { currentRecordId }
val readiness by remember { readinessSnapshot } val readiness by remember { readinessSnapshot }
val probeState by remember { searchProbeState }
val probeStepId by remember { searchProbeStepId }
val probeReport by remember { searchProbeReport }
// 监听跳转事件 // 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) { LaunchedEffect(navigateToRecord, recordId) {
@@ -211,7 +222,7 @@ class MainActivity : ComponentActivity() {
contentColor = colors.textPrimary, contentColor = colors.textPrimary,
tonalElevation = 0.dp tonalElevation = 0.dp
) { ) {
listOf(Screen.Device, Screen.Home, Screen.History, Screen.Settings).forEach { screen -> listOf(Screen.Home, Screen.Device, Screen.History, Screen.Settings).forEach { screen ->
val selected = currentScreen == screen val selected = currentScreen == screen
NavigationBarItem( NavigationBarItem(
icon = { icon = {
@@ -270,35 +281,14 @@ class MainActivity : ComponentActivity() {
}, },
onOpenPinduoduo = { openPinduoduo() } onOpenPinduoduo = { openPinduoduo() }
) )
Screen.Home -> { Screen.Home -> SearchProbeScreen(
// 每次进入首页都检测 Shizuku 状态 readiness = readiness,
LaunchedEffect(Unit) { state = probeState,
checkAndUpdateShizukuStatus() currentStepId = probeStepId,
} report = probeReport,
HomeScreen( onStart = { startSearchProbe() },
agentState = agentState, onStop = { stopSearchProbe() }
logs = logs, )
onExecute = { instruction ->
runAgent(
instruction = instruction,
apiKey = settings.apiKey,
baseUrl = settings.baseUrl,
model = settings.model,
maxSteps = settings.maxSteps,
isGUIAgent = settings.currentProvider.isGUIAgent,
providerId = settings.currentProviderId
)
},
onStop = {
mobileAgent.value?.stop()
},
shizukuAvailable = isShizukuAvailable,
currentModel = settings.model,
onRefreshShizuku = { refreshShizukuStatus() },
onShizukuRequired = { showShizukuHelpDialog = true },
isExecuting = executing
)
}
Screen.History -> HistoryScreen( Screen.History -> HistoryScreen(
records = records, records = records,
onRecordClick = { record -> selectedRecord = record }, onRecordClick = { record -> selectedRecord = record },
@@ -340,10 +330,6 @@ class MainActivity : ComponentActivity() {
} }
} }
// Shizuku 帮助对话框
if (showShizukuHelpDialog) {
ShizukuHelpDialog(onDismiss = { showShizukuHelpDialog = false })
}
} }
private fun deleteRecord(id: String) { private fun deleteRecord(id: String) {
@@ -357,10 +343,15 @@ class MainActivity : ComponentActivity() {
super.onResume() super.onResume()
if (::readinessChecker.isInitialized) { if (::readinessChecker.isInitialized) {
refreshReadiness() refreshReadiness()
lifecycleScope.launch {
delay(READINESS_RECHECK_DELAY_MS)
refreshReadiness()
}
} }
} }
override fun onDestroy() { override fun onDestroy() {
searchProbeRunner?.requestStop()
super.onDestroy() super.onDestroy()
Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderReceivedListener(binderReceivedListener)
Shizuku.removeBinderDeadListener(binderDeadListener) Shizuku.removeBinderDeadListener(binderDeadListener)
@@ -382,6 +373,48 @@ class MainActivity : ComponentActivity() {
startActivity(launchIntent) startActivity(launchIntent)
} }
private fun startSearchProbe() {
val readiness = readinessChecker.snapshot()
readinessSnapshot.value = readiness
if (!readiness.canStartProbe) {
Toast.makeText(this, "设备检查存在阻塞项", Toast.LENGTH_SHORT).show()
return
}
if (searchProbeJob?.isActive == true) {
return
}
val runner = WorkflowRunner(
PinduoduoSearchAutomation(AndroidPinduoduoUiDriver(this))
)
searchProbeRunner = runner
searchProbeReport.value = null
searchProbeState.value = WorkflowState.IDLE
searchProbeStepId.value = null
searchProbeJob = lifecycleScope.launch {
val stateCollector = launch {
runner.state.collect { state -> searchProbeState.value = state }
}
val stepCollector = launch {
runner.currentStepId.collect { stepId -> searchProbeStepId.value = stepId }
}
try {
val report = runner.run(PinduoduoSearchWorkflow.steps())
searchProbeReport.value = report
searchProbeState.value = report.state
} finally {
stateCollector.cancel()
stepCollector.cancel()
searchProbeRunner = null
refreshReadiness()
}
}
}
private fun stopSearchProbe() {
searchProbeRunner?.requestStop()
}
private fun checkShizukuPermission(): Boolean { private fun checkShizukuPermission(): Boolean {
return try { return try {
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
@@ -460,6 +493,10 @@ class MainActivity : ComponentActivity() {
} }
} }
private companion object {
const val READINESS_RECHECK_DELAY_MS = 750L
}
private fun runAgent( private fun runAgent(
instruction: String, instruction: String,
apiKey: String, apiKey: String,
@@ -0,0 +1,49 @@
package com.roubao.autopilot.accessibility
import com.roubao.autopilot.pinduoduo.PinduoduoPage
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
import com.roubao.autopilot.readiness.DeviceObservationStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object BuyerAccessibilityBridge {
@Volatile
private var service: BuyerAccessibilityService? = null
fun attach(connectedService: BuyerAccessibilityService) {
service = connectedService
}
fun detach(disconnectedService: BuyerAccessibilityService) {
if (service === disconnectedService) {
service = null
}
}
suspend fun snapshot(): PinduoduoUiSnapshot = withContext(Dispatchers.Main.immediate) {
service?.snapshotPinduoduoUi()
?: PinduoduoUiSnapshot(
foregroundPackage = DeviceObservationStore.snapshot().foregroundPackage,
page = PinduoduoPage.UNKNOWN,
safetyStopReason = null
)
}
suspend fun openSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
service?.clickPinduoduoSearchEntry() == true
}
suspend fun setSearchKeyword(keyword: String): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.setPinduoduoSearchKeyword(keyword) == true
}
suspend fun isSearchKeywordSet(keyword: String): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.isPinduoduoSearchKeywordSet(keyword) == true
}
suspend fun submitSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
service?.submitPinduoduoSearch() == true
}
}
@@ -1,6 +1,7 @@
package com.roubao.autopilot.accessibility package com.roubao.autopilot.accessibility
import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityService
import android.os.Bundle
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
@@ -8,14 +9,21 @@ import android.view.accessibility.AccessibilityNodeInfo
import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.DeviceObservationStore
import com.roubao.autopilot.readiness.LoginBlockerDetector import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.pinduoduo.PinduoduoPageClassifier
import com.roubao.autopilot.pinduoduo.PinduoduoUiElement
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
import com.roubao.autopilot.pinduoduo.PinduoduoPage
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import java.util.ArrayDeque import java.util.ArrayDeque
class BuyerAccessibilityService : AccessibilityService() { class BuyerAccessibilityService : AccessibilityService() {
private var lastPinduoduoScanAt = 0L private var lastPinduoduoScanAt = 0L
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
override fun onServiceConnected() { override fun onServiceConnected() {
super.onServiceConnected() super.onServiceConnected()
DeviceObservationStore.setAccessibilityConnected(true) DeviceObservationStore.setAccessibilityConnected(true)
BuyerAccessibilityBridge.attach(this)
Log.i(TAG, "Buyer accessibility service connected") Log.i(TAG, "Buyer accessibility service connected")
} }
@@ -52,10 +60,140 @@ class BuyerAccessibilityService : AccessibilityService() {
} }
override fun onDestroy() { override fun onDestroy() {
BuyerAccessibilityBridge.detach(this)
DeviceObservationStore.setAccessibilityConnected(false) DeviceObservationStore.setAccessibilityConnected(false)
super.onDestroy() super.onDestroy()
} }
internal fun snapshotPinduoduoUi(): PinduoduoUiSnapshot {
val root = rootInActiveWindow
val foregroundPackage = root?.packageName?.toString()
?: DeviceObservationStore.snapshot().foregroundPackage
if (foregroundPackage == PINDUODUO_PACKAGE && root != null) {
return classifyPinduoduoRoot(root)
}
return PinduoduoPageClassifier.classify(
foregroundPackage = foregroundPackage,
elements = emptyList(),
expectedQuery = expectedSearchQuery
)
}
internal fun clickPinduoduoSearchEntry(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.safetyStopReason != null ||
snapshot.page != PinduoduoPage.HOME
) {
return@withPinduoduoRoot false
}
val candidates = collectNodes(root).filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
node.className?.toString()?.endsWith("TextView") == true &&
node.contentDescription?.toString()?.trim() == "搜索"
}
candidates.singleOrNull()?.let(::clickNodeOrAncestor) == true
} ?: false
internal fun setPinduoduoSearchKeyword(keyword: String): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.safetyStopReason != null ||
snapshot.page != PinduoduoPage.SEARCH_INPUT
) {
return@withPinduoduoRoot false
}
val inputs = collectNodes(root).filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
(node.isEditable ||
node.className?.toString()?.endsWith("EditText") == true) &&
node.contentDescription?.toString()?.trim() == "搜索"
}
val input = inputs.singleOrNull() ?: return@withPinduoduoRoot false
input.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
val arguments = Bundle().apply {
putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
keyword
)
}
val changed = input.performAction(
AccessibilityNodeInfo.ACTION_SET_TEXT,
arguments
)
if (changed) {
expectedSearchQuery = keyword
}
changed
} ?: false
internal fun isPinduoduoSearchKeywordSet(keyword: String): Boolean =
withPinduoduoRoot { root ->
collectNodes(root)
.filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
(node.isEditable ||
node.className?.toString()?.endsWith("EditText") == true) &&
node.contentDescription?.toString()?.trim() == "搜索"
}
.singleOrNull()
?.text
?.toString() == keyword
} ?: false
internal fun submitPinduoduoSearch(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (snapshot.safetyStopReason != null) {
return@withPinduoduoRoot false
}
if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) {
return@withPinduoduoRoot true
}
if (snapshot.page != PinduoduoPage.SEARCH_INPUT) {
return@withPinduoduoRoot false
}
val nodes = collectNodes(root)
val submitButtons = nodes.filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
node.isClickable &&
node.className?.toString()?.endsWith("TextView") == true &&
node.text?.toString()?.trim() == "搜索"
}
submitButtons.singleOrNull()?.performAction(
AccessibilityNodeInfo.ACTION_CLICK
) == true
} ?: false
private fun classifyPinduoduoRoot(
root: AccessibilityNodeInfo
): PinduoduoUiSnapshot {
val elements = collectNodes(root).map { node ->
PinduoduoUiElement(
text = node.text?.toString(),
contentDescription = node.contentDescription?.toString(),
className = node.className?.toString().orEmpty(),
resourceId = node.viewIdResourceName,
clickable = node.isClickable,
editable = node.isEditable,
enabled = node.isEnabled,
visibleToUser = node.isVisibleToUser
)
}
return PinduoduoPageClassifier.classify(
foregroundPackage = root.packageName?.toString(),
elements = elements,
expectedQuery = expectedSearchQuery
)
}
private fun shouldInspect(eventType: Int): Boolean = private fun shouldInspect(eventType: Int): Boolean =
eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED || eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED ||
@@ -63,6 +201,18 @@ class BuyerAccessibilityService : AccessibilityService() {
private fun collectVisibleTexts(root: AccessibilityNodeInfo): List<String> { private fun collectVisibleTexts(root: AccessibilityNodeInfo): List<String> {
val texts = ArrayList<String>() val texts = ArrayList<String>()
collectNodes(root).forEach { node ->
node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add)
node.contentDescription
?.toString()
?.takeIf(String::isNotBlank)
?.let(texts::add)
}
return texts
}
private fun collectNodes(root: AccessibilityNodeInfo): List<AccessibilityNodeInfo> {
val nodes = ArrayList<AccessibilityNodeInfo>()
val queue = ArrayDeque<AccessibilityNodeInfo>() val queue = ArrayDeque<AccessibilityNodeInfo>()
queue.add(root) queue.add(root)
var visited = 0 var visited = 0
@@ -70,23 +220,42 @@ class BuyerAccessibilityService : AccessibilityService() {
while (queue.isNotEmpty() && visited < MAX_NODES) { while (queue.isNotEmpty() && visited < MAX_NODES) {
val node = queue.removeFirst() val node = queue.removeFirst()
visited += 1 visited += 1
nodes += node
node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add)
node.contentDescription
?.toString()
?.takeIf(String::isNotBlank)
?.let(texts::add)
for (index in 0 until node.childCount) { for (index in 0 until node.childCount) {
node.getChild(index)?.let(queue::addLast) node.getChild(index)?.let(queue::addLast)
} }
} }
return texts return nodes
}
private fun <T> withPinduoduoRoot(block: (AccessibilityNodeInfo) -> T): T? {
val root = rootInActiveWindow ?: return null
if (root.packageName?.toString() != PINDUODUO_PACKAGE) {
return null
}
return block(root)
}
private fun clickNodeOrAncestor(node: AccessibilityNodeInfo): Boolean {
var candidate: AccessibilityNodeInfo? = node
repeat(MAX_CLICK_ANCESTORS) {
val current = candidate ?: return false
if (
current.isClickable &&
current.performAction(AccessibilityNodeInfo.ACTION_CLICK)
) {
return true
}
candidate = current.parent
}
return false
} }
companion object { companion object {
private const val TAG = "BuyerAccessibility" private const val TAG = "BuyerAccessibility"
private const val MAX_NODES = 300 private const val MAX_NODES = 300
private const val MAX_CLICK_ANCESTORS = 4
private const val MIN_SCAN_INTERVAL_MS = 300L private const val MIN_SCAN_INTERVAL_MS = 300L
} }
} }
@@ -0,0 +1,49 @@
package com.roubao.autopilot.pinduoduo
import android.content.Context
import android.content.Intent
import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import kotlinx.coroutines.delay
class AndroidPinduoduoUiDriver(context: Context) : PinduoduoUiDriver {
private val appContext = context.applicationContext
override suspend fun openApp(): Boolean {
val intent = appContext.packageManager.getLaunchIntentForPackage(PINDUODUO_PACKAGE)
?: return false
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
)
appContext.startActivity(intent)
return true
}
override suspend fun snapshot(): PinduoduoUiSnapshot =
BuyerAccessibilityBridge.snapshot()
override suspend fun openSearch(): Boolean =
BuyerAccessibilityBridge.openSearch()
override suspend fun setSearchKeyword(keyword: String): Boolean {
if (!BuyerAccessibilityBridge.setSearchKeyword(keyword)) {
return false
}
repeat(KEYWORD_VERIFY_ATTEMPTS) {
delay(KEYWORD_VERIFY_INTERVAL_MS)
if (BuyerAccessibilityBridge.isSearchKeywordSet(keyword)) {
return true
}
}
return false
}
override suspend fun submitSearch(): Boolean =
BuyerAccessibilityBridge.submitSearch()
private companion object {
const val KEYWORD_VERIFY_ATTEMPTS = 5
const val KEYWORD_VERIFY_INTERVAL_MS = 100L
}
}
@@ -0,0 +1,114 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.LoginBlocker
import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.SafetyStopReason
data class PinduoduoUiElement(
val text: String?,
val contentDescription: String?,
val className: String,
val resourceId: String?,
val clickable: Boolean,
val editable: Boolean,
val enabled: Boolean,
val visibleToUser: Boolean
)
enum class PinduoduoPage {
HOME,
SEARCH_INPUT,
SEARCH_RESULTS,
UNKNOWN
}
data class PinduoduoUiSnapshot(
val foregroundPackage: String?,
val page: PinduoduoPage,
val safetyStopReason: SafetyStopReason?
)
object PinduoduoPageClassifier {
private val paymentMarkers = listOf(
"确认订单",
"提交订单",
"确认支付",
"立即支付",
"收银台"
)
fun classify(
foregroundPackage: String?,
elements: Collection<PinduoduoUiElement>,
expectedQuery: String = SEARCH_PROBE_KEYWORD
): PinduoduoUiSnapshot {
if (foregroundPackage != PINDUODUO_PACKAGE) {
return PinduoduoUiSnapshot(
foregroundPackage = foregroundPackage,
page = PinduoduoPage.UNKNOWN,
safetyStopReason = null
)
}
val visibleElements = elements.filter { it.visibleToUser && it.enabled }
val visibleTexts = visibleElements.flatMap { element ->
listOfNotNull(element.text, element.contentDescription)
}
val safetyStop = when (LoginBlockerDetector.detect(visibleTexts)) {
LoginBlocker.LOGIN_REQUIRED -> SafetyStopReason.LOGIN_REQUIRED
LoginBlocker.VERIFICATION_REQUIRED -> SafetyStopReason.VERIFICATION_REQUIRED
LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL
LoginBlocker.NONE,
LoginBlocker.UNKNOWN -> {
if (visibleTexts.containsAny(paymentMarkers)) {
SafetyStopReason.PAYMENT_BOUNDARY
} else {
null
}
}
}
val normalized = visibleTexts.map(::normalize)
val hasSearchInput = visibleElements.any { element ->
(element.editable || element.className.endsWith("EditText")) &&
normalize(element.contentDescription.orEmpty()) == "搜索"
}
val hasSubmitButton = visibleElements.any { element ->
element.className.endsWith("TextView") &&
element.clickable &&
normalize(element.text.orEmpty()) == "搜索"
}
val hasHomeSearchEntry = visibleElements.any { element ->
element.className.endsWith("TextView") &&
normalize(element.contentDescription.orEmpty()) == "搜索"
}
val hasHome = normalized.any { it == "首页" }
val hasResultSearchHeader = visibleElements.any { element ->
element.className.endsWith("HorizontalScrollView") &&
normalize(element.contentDescription.orEmpty()) == "搜索"
}
val sortControlCount = setOf("综合", "销量", "价格", "筛选")
.count { marker -> normalized.any { it == marker } }
val hasExactQuery = visibleTexts.any { it.trim() == expectedQuery }
val page = when {
hasExactQuery && hasResultSearchHeader && sortControlCount >= 3 ->
PinduoduoPage.SEARCH_RESULTS
hasSearchInput && hasSubmitButton -> PinduoduoPage.SEARCH_INPUT
hasHomeSearchEntry && hasHome -> PinduoduoPage.HOME
else -> PinduoduoPage.UNKNOWN
}
return PinduoduoUiSnapshot(
foregroundPackage = foregroundPackage,
page = page,
safetyStopReason = safetyStop
)
}
private fun normalize(value: String): String =
value.lowercase().replace(Regex("\\s+"), "")
private fun Collection<String>.containsAny(markers: Collection<String>): Boolean =
any { text -> markers.any(text::contains) }
}
@@ -0,0 +1,143 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.workflow.AutomationGateway
import com.roubao.autopilot.workflow.AutomationResult
import com.roubao.autopilot.workflow.SafetyStopReason
import com.roubao.autopilot.workflow.WorkflowFailureCode
import com.roubao.autopilot.workflow.WorkflowStep
import kotlinx.coroutines.delay
const val SEARCH_PROBE_KEYWORD = "手机支架"
interface PinduoduoUiDriver {
suspend fun openApp(): Boolean
suspend fun snapshot(): PinduoduoUiSnapshot
suspend fun openSearch(): Boolean
suspend fun setSearchKeyword(keyword: String): Boolean
suspend fun submitSearch(): Boolean
}
class PinduoduoSearchAutomation(
private val driver: PinduoduoUiDriver,
private val keyword: String = SEARCH_PROBE_KEYWORD,
private val pollIntervalMillis: Long = 200,
private val unknownPageLimit: Int = 10
) : AutomationGateway {
init {
require(keyword.isNotBlank()) { "Search keyword must not be blank" }
require(pollIntervalMillis > 0) { "Poll interval must be positive" }
require(unknownPageLimit > 0) { "Unknown page limit must be positive" }
}
override suspend fun execute(step: WorkflowStep): AutomationResult =
when (step.id) {
PinduoduoSearchWorkflow.OPEN_APP -> openApp()
PinduoduoSearchWorkflow.ENTER_QUERY -> enterQuery()
PinduoduoSearchWorkflow.SUBMIT_QUERY -> submitQuery()
PinduoduoSearchWorkflow.VERIFY_RESULTS -> verifyResults()
else -> AutomationResult.FatalFailure(WorkflowFailureCode.AUTOMATION_EXCEPTION)
}
private suspend fun openApp(): AutomationResult {
if (!driver.openApp()) {
return AutomationResult.FatalFailure(WorkflowFailureCode.TARGET_NOT_READY)
}
return awaitPage {
it == PinduoduoPage.HOME ||
it == PinduoduoPage.SEARCH_INPUT ||
it == PinduoduoPage.SEARCH_RESULTS
} ?: AutomationResult.Success
}
private suspend fun enterQuery(): AutomationResult {
val ready = awaitPage {
it == PinduoduoPage.HOME ||
it == PinduoduoPage.SEARCH_INPUT ||
it == PinduoduoPage.SEARCH_RESULTS
}
if (ready != null) {
return ready
}
val currentPage = driver.snapshot().page
if (currentPage == PinduoduoPage.SEARCH_RESULTS) {
return AutomationResult.Success
}
if (currentPage != PinduoduoPage.SEARCH_INPUT) {
if (!driver.openSearch()) {
return AutomationResult.RetryableFailure(
WorkflowFailureCode.TRANSIENT_AUTOMATION
)
}
val searchInput = awaitPage { it == PinduoduoPage.SEARCH_INPUT }
if (searchInput != null) {
return searchInput
}
}
return if (driver.setSearchKeyword(keyword)) {
AutomationResult.Success
} else {
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION)
}
}
private suspend fun submitQuery(): AutomationResult {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) {
return AutomationResult.Success
}
return if (driver.submitSearch()) {
AutomationResult.Success
} else {
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION)
}
}
private suspend fun verifyResults(): AutomationResult =
awaitPage { it == PinduoduoPage.SEARCH_RESULTS } ?: AutomationResult.Success
private suspend fun awaitPage(
expected: (PinduoduoPage) -> Boolean
): AutomationResult? {
var stableUnknownObservations = 0
while (true) {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
if (expected(snapshot.page)) {
return null
}
stableUnknownObservations = if (
snapshot.foregroundPackage == com.roubao.autopilot.readiness.PINDUODUO_PACKAGE &&
snapshot.page == PinduoduoPage.UNKNOWN
) {
stableUnknownObservations + 1
} else {
0
}
if (stableUnknownObservations >= unknownPageLimit) {
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
}
delay(pollIntervalMillis)
}
}
private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? =
snapshot.safetyStopReason?.let(AutomationResult::Blocked)
}
object PinduoduoSearchWorkflow {
const val OPEN_APP = "pdd_open_app"
const val ENTER_QUERY = "pdd_enter_query"
const val SUBMIT_QUERY = "pdd_submit_query"
const val VERIFY_RESULTS = "pdd_verify_results"
fun steps(): List<WorkflowStep> = listOf(
WorkflowStep(OPEN_APP, timeoutMillis = 8_000, maxRetries = 1),
WorkflowStep(ENTER_QUERY, timeoutMillis = 10_000, maxRetries = 1),
WorkflowStep(SUBMIT_QUERY, timeoutMillis = 6_000, maxRetries = 1),
WorkflowStep(VERIFY_RESULTS, timeoutMillis = 12_000, maxRetries = 1)
)
}
@@ -0,0 +1,292 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.workflow.SafetyStopReason
import com.roubao.autopilot.workflow.WorkflowFailureCode
import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowState
private data class ProbeStepUi(
val id: String,
val label: String
)
private val probeSteps = listOf(
ProbeStepUi(PinduoduoSearchWorkflow.OPEN_APP, "打开拼多多"),
ProbeStepUi(PinduoduoSearchWorkflow.ENTER_QUERY, "输入固定关键词"),
ProbeStepUi(PinduoduoSearchWorkflow.SUBMIT_QUERY, "提交搜索"),
ProbeStepUi(PinduoduoSearchWorkflow.VERIFY_RESULTS, "确认结果页")
)
@Composable
fun SearchProbeScreen(
readiness: DeviceReadinessSnapshot,
state: WorkflowState,
currentStepId: String?,
report: WorkflowReport?,
onStart: () -> Unit,
onStop: () -> Unit
) {
val colors = BaoziTheme.colors
val active = state == WorkflowState.RUNNING || state == WorkflowState.RETRYING
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(colors.background),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
item {
Text(
text = "拼多多搜索探针",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary
)
Text(
text = stateLabel(state, report),
fontSize = 14.sp,
color = stateColor(state)
)
Spacer(modifier = Modifier.height(24.dp))
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = colors.primary,
modifier = Modifier.size(24.dp)
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = "固定关键词",
fontSize = 13.sp,
color = colors.textSecondary
)
Text(
text = SEARCH_PROBE_KEYWORD,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = colors.textPrimary
)
}
}
Divider(color = colors.surfaceVariant)
}
items(probeSteps.size) { index ->
val step = probeSteps[index]
ProbeStepRow(
label = step.label,
state = stepState(
stepId = step.id,
workflowState = state,
currentStepId = currentStepId,
report = report
)
)
}
item {
Spacer(modifier = Modifier.height(24.dp))
if (active) {
OutlinedButton(
onClick = onStop,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("停止探针")
}
} else {
Button(
onClick = onStart,
enabled = readiness.canStartProbe,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = colors.primary)
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("开始搜索探针")
}
}
if (!readiness.canStartProbe) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "设备检查存在阻塞项",
fontSize = 13.sp,
color = colors.error
)
}
}
}
}
private enum class ProbeStepState {
WAITING,
ACTIVE,
COMPLETE,
FAILED
}
@Composable
private fun ProbeStepRow(label: String, state: ProbeStepState) {
val colors = BaoziTheme.colors
val icon: ImageVector
val tint: Color
val status: String
when (state) {
ProbeStepState.WAITING -> {
icon = Icons.Default.Info
tint = colors.textHint
status = "等待"
}
ProbeStepState.ACTIVE -> {
icon = Icons.Default.PlayArrow
tint = colors.primary
status = "执行中"
}
ProbeStepState.COMPLETE -> {
icon = Icons.Default.CheckCircle
tint = colors.success
status = "完成"
}
ProbeStepState.FAILED -> {
icon = Icons.Default.Warning
tint = colors.error
status = "停止"
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(58.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp)
)
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
)
Text(
text = status,
fontSize = 13.sp,
color = tint
)
}
Divider(color = colors.surfaceVariant)
}
private fun stepState(
stepId: String,
workflowState: WorkflowState,
currentStepId: String?,
report: WorkflowReport?
): ProbeStepState = when {
report?.completedStepIds?.contains(stepId) == true -> ProbeStepState.COMPLETE
stepId == currentStepId &&
workflowState in setOf(
WorkflowState.FAILED,
WorkflowState.BLOCKED,
WorkflowState.STOPPED
) -> ProbeStepState.FAILED
stepId == currentStepId -> ProbeStepState.ACTIVE
else -> ProbeStepState.WAITING
}
@Composable
private fun stateColor(state: WorkflowState): Color {
val colors = BaoziTheme.colors
return when (state) {
WorkflowState.SUCCEEDED -> colors.success
WorkflowState.FAILED,
WorkflowState.BLOCKED -> colors.error
WorkflowState.RETRYING -> colors.warning
WorkflowState.RUNNING -> colors.primary
WorkflowState.IDLE,
WorkflowState.STOPPED -> colors.textSecondary
}
}
private fun stateLabel(state: WorkflowState, report: WorkflowReport?): String =
when (state) {
WorkflowState.IDLE -> "等待开始"
WorkflowState.RUNNING -> "正在执行"
WorkflowState.RETRYING -> "正在重试"
WorkflowState.SUCCEEDED -> "搜索结果页已确认"
WorkflowState.STOPPED -> "已由用户停止"
WorkflowState.FAILED -> failureLabel(report?.failureCode)
WorkflowState.BLOCKED -> blockedLabel(report?.safetyStopReason)
}
private fun failureLabel(code: WorkflowFailureCode?): String = when (code) {
WorkflowFailureCode.TIMEOUT -> "执行超时"
WorkflowFailureCode.TRANSIENT_AUTOMATION -> "页面操作失败"
WorkflowFailureCode.AUTOMATION_EXCEPTION -> "自动化执行异常"
WorkflowFailureCode.TARGET_NOT_READY -> "拼多多无法启动"
null -> "执行失败"
}
private fun blockedLabel(reason: SafetyStopReason?): String = when (reason) {
SafetyStopReason.LOGIN_REQUIRED -> "检测到登录要求,已停止"
SafetyStopReason.VERIFICATION_REQUIRED -> "检测到验证码,已停止"
SafetyStopReason.RISK_CONTROL -> "检测到风控,已停止"
SafetyStopReason.UNKNOWN_PAGE -> "页面无法确认,已停止"
SafetyStopReason.PAYMENT_BOUNDARY -> "检测到支付边界,已停止"
null -> "安全检查阻塞"
}
@@ -29,7 +29,8 @@ enum class WorkflowState {
enum class WorkflowFailureCode { enum class WorkflowFailureCode {
TIMEOUT, TIMEOUT,
TRANSIENT_AUTOMATION, TRANSIENT_AUTOMATION,
AUTOMATION_EXCEPTION AUTOMATION_EXCEPTION,
TARGET_NOT_READY
} }
enum class SafetyStopReason { enum class SafetyStopReason {
@@ -17,6 +17,7 @@ class WorkflowRunner(
) { ) {
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private val mutableState = MutableStateFlow(WorkflowState.IDLE) private val mutableState = MutableStateFlow(WorkflowState.IDLE)
private val mutableCurrentStepId = MutableStateFlow<String?>(null)
@Volatile @Volatile
private var stopRequested = false private var stopRequested = false
@@ -25,12 +26,14 @@ class WorkflowRunner(
private var currentExecution: Job? = null private var currentExecution: Job? = null
val state: StateFlow<WorkflowState> = mutableState.asStateFlow() val state: StateFlow<WorkflowState> = mutableState.asStateFlow()
val currentStepId: StateFlow<String?> = mutableCurrentStepId.asStateFlow()
suspend fun run(steps: List<WorkflowStep>): WorkflowReport { suspend fun run(steps: List<WorkflowStep>): WorkflowReport {
require(steps.isNotEmpty()) { "Workflow must contain at least one step" } require(steps.isNotEmpty()) { "Workflow must contain at least one step" }
check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" } check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" }
stopRequested = false stopRequested = false
mutableCurrentStepId.value = null
val completedSteps = mutableListOf<String>() val completedSteps = mutableListOf<String>()
val attempts = linkedMapOf<String, Int>() val attempts = linkedMapOf<String, Int>()
val transitions = mutableListOf<WorkflowTransition>() val transitions = mutableListOf<WorkflowTransition>()
@@ -197,6 +200,7 @@ class WorkflowRunner(
) { ) {
val previous = mutableState.value val previous = mutableState.value
mutableState.value = next mutableState.value = next
mutableCurrentStepId.value = stepId
transitions += WorkflowTransition( transitions += WorkflowTransition(
from = previous, from = previous,
to = next, to = next,
@@ -0,0 +1,135 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.SafetyStopReason
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PinduoduoPageClassifierTest {
@Test
fun `home page requires search entry and home marker`() {
val snapshot = classify(
element(contentDescription = "搜索"),
element(text = "首页")
)
assertEquals(PinduoduoPage.HOME, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `editable search page is recognized`() {
val snapshot = classify(
element(
contentDescription = "搜索",
className = "android.widget.EditText",
editable = true
),
element(text = "搜索", clickable = true)
)
assertEquals(PinduoduoPage.SEARCH_INPUT, snapshot.page)
}
@Test
fun `result controls identify search results`() {
val snapshot = classify(
element(
contentDescription = "搜索",
className = "android.widget.HorizontalScrollView"
),
element(text = SEARCH_PROBE_KEYWORD),
element(text = "综合"),
element(text = "销量"),
element(text = "价格"),
element(text = "筛选")
)
assertEquals(PinduoduoPage.SEARCH_RESULTS, snapshot.page)
}
@Test
fun `login verification and risk markers remain distinct`() {
assertEquals(
SafetyStopReason.LOGIN_REQUIRED,
classify(element(text = "请先登录")).safetyStopReason
)
assertEquals(
SafetyStopReason.VERIFICATION_REQUIRED,
classify(element(text = "请输入验证码")).safetyStopReason
)
assertEquals(
SafetyStopReason.RISK_CONTROL,
classify(element(text = "账号存在风险,请完成安全验证")).safetyStopReason
)
}
@Test
fun `payment boundary blocks even on otherwise unknown page`() {
val snapshot = classify(element(text = "确认订单"))
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertEquals(SafetyStopReason.PAYMENT_BOUNDARY, snapshot.safetyStopReason)
}
@Test
fun `hidden and disabled lookalikes do not classify a page`() {
val snapshot = classify(
element(contentDescription = "搜索", visibleToUser = false),
element(text = "首页", enabled = false)
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `results require the exact expected query`() {
val snapshot = classify(
element(
contentDescription = "搜索",
className = "android.widget.HorizontalScrollView"
),
element(text = "$SEARCH_PROBE_KEYWORD 推荐"),
element(text = "综合"),
element(text = "销量"),
element(text = "价格")
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
}
@Test
fun `other foreground package is unknown and has no inferred blocker`() {
val snapshot = PinduoduoPageClassifier.classify(
foregroundPackage = "com.roubao.autopilot",
elements = listOf(element(text = "请先登录"))
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
private fun classify(vararg elements: PinduoduoUiElement): PinduoduoUiSnapshot =
PinduoduoPageClassifier.classify(PINDUODUO_PACKAGE, elements.toList())
private fun element(
text: String? = null,
contentDescription: String? = null,
className: String = "android.widget.TextView",
editable: Boolean = false,
clickable: Boolean = false,
enabled: Boolean = true,
visibleToUser: Boolean = true
) = PinduoduoUiElement(
text = text,
contentDescription = contentDescription,
className = className,
resourceId = null,
clickable = clickable,
editable = editable,
enabled = enabled,
visibleToUser = visibleToUser
)
}
@@ -0,0 +1,131 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.AutomationResult
import com.roubao.autopilot.workflow.SafetyStopReason
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoSearchAutomationTest {
@Test
fun `fixed keyword workflow reaches verified results`() = runTest {
val driver = FakeDriver(page = PinduoduoPage.HOME)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(SEARCH_PROBE_KEYWORD, driver.enteredKeyword)
assertTrue(driver.searchSubmitted)
assertEquals(PinduoduoPage.SEARCH_RESULTS, driver.page)
}
@Test
fun `transient missing search entry uses bounded workflow retry`() = runTest {
val driver = FakeDriver(
page = PinduoduoPage.HOME,
failedOpenSearchAttempts = 1
)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(2, driver.openSearchCalls)
assertEquals(2, report.attemptsByStep[PinduoduoSearchWorkflow.ENTER_QUERY])
}
@Test
fun `login blocker stops before any search action`() = runTest {
val driver = FakeDriver(
page = PinduoduoPage.UNKNOWN,
safetyStopReason = SafetyStopReason.LOGIN_REQUIRED
)
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.BLOCKED, report.state)
assertEquals(SafetyStopReason.LOGIN_REQUIRED, report.safetyStopReason)
assertEquals(0, driver.openSearchCalls)
}
@Test
fun `stable unknown Pinduoduo page blocks safely`() = runTest {
val driver = FakeDriver(page = PinduoduoPage.UNKNOWN)
val automation = PinduoduoSearchAutomation(
driver = driver,
pollIntervalMillis = 10,
unknownPageLimit = 3
)
val result = automation.execute(PinduoduoSearchWorkflow.steps().first())
assertEquals(
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE),
result
)
}
private class FakeDriver(
var page: PinduoduoPage,
private val safetyStopReason: SafetyStopReason? = null,
private var failedOpenSearchAttempts: Int = 0
) : PinduoduoUiDriver {
var enteredKeyword: String? = null
var searchSubmitted = false
var openSearchCalls = 0
override suspend fun openApp(): Boolean = true
override suspend fun snapshot(): PinduoduoUiSnapshot =
PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
)
override suspend fun openSearch(): Boolean {
openSearchCalls += 1
if (failedOpenSearchAttempts > 0) {
failedOpenSearchAttempts -= 1
return false
}
page = PinduoduoPage.SEARCH_INPUT
return true
}
override suspend fun setSearchKeyword(keyword: String): Boolean {
enteredKeyword = keyword
return true
}
override suspend fun submitSearch(): Boolean {
searchSubmitted = true
page = PinduoduoPage.SEARCH_RESULTS
return true
}
}
}
@@ -32,6 +32,7 @@ class WorkflowRunnerTest {
assertEquals(WorkflowState.SUCCEEDED, report.state) assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(listOf("open_app", "verify_page"), report.completedStepIds) assertEquals(listOf("open_app", "verify_page"), report.completedStepIds)
assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep) assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep)
assertEquals(null, runner.currentStepId.value)
assertEquals( assertEquals(
listOf( listOf(
WorkflowState.RUNNING, WorkflowState.RUNNING,
@@ -79,6 +80,7 @@ class WorkflowRunnerTest {
assertEquals(WorkflowState.FAILED, report.state) assertEquals(WorkflowState.FAILED, report.state)
assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode) assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode)
assertEquals(2, report.attemptsByStep["wait_for_page"]) assertEquals(2, report.attemptsByStep["wait_for_page"])
assertEquals("wait_for_page", runner.currentStepId.value)
assertEquals(200, currentTime) assertEquals(200, currentTime)
} }
+3 -2
View File
@@ -51,8 +51,9 @@
## 当前阶段与优先路径 ## 当前阶段与优先路径
当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。 当前已完成 Phase 0 和 T-101:Android 可运行、设备就绪、workflow、私有样本导入
下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。 以及固定脱敏词拼多多搜索均已在真机验证。下一步是 T-102,只浏览并采集最多 5 个
候选,继续禁止订单提交和支付。
严格按以下顺序推进: 严格按以下顺序推进:
+3 -2
View File
@@ -8,7 +8,7 @@
| --- | --- | | --- | --- |
| 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、描述、图片、数量和预算。 | | 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、描述、图片、数量和预算。 |
| 第一层样本来源 | 本机目录中的蝦皮订单文本和参考图;二者以蝦皮订单号作为同名文件名。 | | 第一层样本来源 | 本机目录中的蝦皮订单文本和参考图;二者以蝦皮订单号作为同名文件名。 |
| 执行方式 | 采购人员使用 Android App 操作拼多多;当前没有可运行实现。 | | 执行方式 | 采购人员使用 Android App 操作拼多多;固定词搜索探针已可运行,候选浏览和真实任务尚未实现。 |
| 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 | | 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 |
| 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 | | 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 |
| 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 | | 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 |
@@ -140,7 +140,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
`T-001` 完成。 `T-001` 完成。
- 首份蝦皮样本已由 T-004 真实导入验证;当前只支持单 SKU 和 JPEG,扩展格式需新 - 首份蝦皮样本已由 T-004 真实导入验证;当前只支持单 SKU 和 JPEG,扩展格式需新
样例和独立任务。 样例和独立任务。
- 拼多多版本、页面结构、账号登录状态和测试设备尚未形成可复现基线。 - OnePlus PKG110、Android 16/API 36、拼多多 8.17.0 的首页、搜索输入和结果页已形成
可复现基线;候选列表、详情页及账号风控差异仍待 T-102 验证。
- VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。 - VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。
- 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。 - 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。
- 后续若允许提交订单,必须先明确 SKU、收货地址、运费、优惠、发票、金额审批、 - 后续若允许提交订单,必须先明确 SKU、收货地址、运费、优惠、发票、金额审批、
+3 -3
View File
@@ -11,7 +11,7 @@
| Android 构建链 | Gradle 8.2;AGP 8.2.0;Kotlin 1.9.20;JVM target 17 | 已验证 | 已补齐上游缺失的 `gradlew.bat`,不依赖全局 Gradle。 | | Android 构建链 | Gradle 8.2;AGP 8.2.0;Kotlin 1.9.20;JVM target 17 | 已验证 | 已补齐上游缺失的 `gradlew.bat`,不依赖全局 Gradle。 |
| Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 已验证 | 支持 Android 8.0+;本机使用 Command-line Tools 22.0。 | | Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 已验证 | 支持 Android 8.0+;本机使用 Command-line Tools 22.0。 |
| Android UI | Jetpack Compose + Material 3;Compose Compiler 1.5.5 | 上游已核实 | Compose BOM 为 2023.10.01。 | | Android UI | Jetpack Compose + Material 3;Compose Compiler 1.5.5 | 上游已核实 | Compose BOM 为 2023.10.01。 |
| Android 自动化 | 项目目标以 `AccessibilityService` 为主,Shizuku 为兼容/增强路径 | 就绪观察基线已实现,动作待实现 | T-002 已实现只读前台/登录阻塞观察;搜索、点击和安全动作门禁由 T-101 开始实现。上游 `main` 仍保留 Shizuku 13.1.5。 | | Android 自动化 | `AccessibilityService` 语义节点动作;Shizuku 保留为上游兼容路径 | 固定词搜索已真机验证 | T-101 已用可见、启用、唯一节点完成打开搜索、`ACTION_SET_TEXT` 精确回读、提交和结果页确认;没有坐标或 shell 降级。上游 `main` 仍保留 Shizuku 13.1.5。 |
| 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 | | 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 |
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 | | Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 | | 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
@@ -27,7 +27,7 @@
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 | | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
| 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 | | 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | workflow 基线已实现 | T-003 已覆盖成功、timeout、有限 retry、安全阻塞、用户停止和并发拒绝;真实 UI 动作仍需真机 smoke。 | | Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | 搜索探针已验证 | T-003 runner 与 T-101 页面分类/Fake driver 已覆盖;OnePlus PKG110 + 拼多多 8.17.0 固定词结果页 smoke 成功。 |
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
## Roubao 上游版本基线 ## Roubao 上游版本基线
@@ -134,7 +134,7 @@ docs/
| --- | --- | --- | | --- | --- | --- |
| Android 标准验证 | `.\init.ps1` | 已验证 | | Android 标准验证 | `.\init.ps1` | 已验证 |
| Android 构建 | `android-buyer\gradlew.bat assembleDebug --no-daemon` | 已验证 | | Android 构建 | `android-buyer\gradlew.bat assembleDebug --no-daemon` | 已验证 |
| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;无测试源 | | Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 62 次测试通过 |
| Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 | | Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
| 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` | | 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` |
| 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` | | 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` |
+21 -1
View File
@@ -152,6 +152,23 @@ Debug 构建或测试装载。导入必须:
私有 fixture 只有在构建显式设置 `-PprobeFixturesDir` 时注入 Debug APK;普通构建 私有 fixture 只有在构建显式设置 `-PprobeFixturesDir` 时注入 Debug APK;普通构建
必须同步清空生成资产,防止上一次真实样本残留。 必须同步清空生成资产,防止上一次真实样本残留。
T-101 的固定词搜索已落在以下运行边界:
```text
SearchProbeScreen
-> WorkflowRunner(timeout / retry / stop)
-> PinduoduoSearchAutomation(纯 Kotlin)
-> AndroidPinduoduoUiDriver
-> BuyerAccessibilityBridge
-> BuyerAccessibilityService
-> 拼多多可见、启用、唯一语义节点
```
结果页成功不是“点击已发送”,而是同时确认精确查询词、搜索结果头和至少 3 个排序
控件。`ACTION_SET_TEXT` 后必须重新读取当前根节点并精确比对输入;任何稳定未知页以及
登录、验证码、风控、订单或支付边界都终止 workflow。该路径不提供坐标、ADB、
Shizuku shell、OCR 或 VLM 动作降级。
### 3.2 MVP 业务闭环 ### 3.2 MVP 业务闭环
```text ```text
@@ -306,7 +323,10 @@ IDLE
- 只允许目标拼多多包处于前台时发送动作。 - 只允许目标拼多多包处于前台时发送动作。
- 关键动作前后都校验包名、页面特征和任务取消标志。 - 关键动作前后都校验包名、页面特征和任务取消标志。
- 节点选择优先资源 ID、可访问性文本和稳定层级;文本版本变化要集中配置。 - 节点选择要求可见、启用且唯一匹配;优先可访问性语义、稳定文本和受限祖先层级,
不能因资源 ID 重复而任取第一个节点。
- 已验证的首页、搜索输入和结果页路径禁止坐标降级;新页面必须先取得版本化真机
证据并在独立任务中定义识别条件。
- 截图发送给模型前裁剪无关区域并按配置脱敏。 - 截图发送给模型前裁剪无关区域并按配置脱敏。
- 每个动作记录抽象步骤,不默认记录完整输入文本。 - 每个动作记录抽象步骤,不默认记录完整输入文本。
- 发现验证码、风险控制、支付、生物识别或系统权限页面立即停止。 - 发现验证码、风险控制、支付、生物识别或系统权限页面立即停止。
+4 -1
View File
@@ -30,7 +30,10 @@
## 4. Android 自动化规则 ## 4. Android 自动化规则
- 动作前确认目标 App 包名和预期页面,动作后确认状态确实变化。 - 动作前确认目标 App 包名和预期页面,动作后确认状态确实变化。
- 优先使用资源 ID、可访问性语义和稳定文本;坐标只能作为被验证的局部降级。 - 节点必须可见、启用且唯一匹配;资源 ID 重复时不得选择第一个结果。
- 首页、搜索输入和结果页的已验证路径只用可访问性语义和稳定文本,禁止坐标降级。
其他页面如需坐标必须另立任务、固定版本并提供语义方案确实不可用的证据。
- 文本写入动作返回成功不等于完成;必须从新的根节点精确回读后再推进。
- 每个步骤定义 timeout、有限 retry、成功条件和停止条件。 - 每个步骤定义 timeout、有限 retry、成功条件和停止条件。
- 禁止无界循环、无限滑动、无限候选遍历和随机点击。 - 禁止无界循环、无限滑动、无限候选遍历和随机点击。
- 默认最多检查 5 个候选;扩大范围必须先改需求。 - 默认最多检查 5 个候选;扩大范围必须先改需求。
+13 -11
View File
@@ -5,7 +5,7 @@
## 当前快照 ## 当前快照
- 日期:2026-07-25 - 日期:2026-07-25
- 阶段:Phase 0 完成,准备执行 T-101 拼多多关键词搜索探针 - 阶段:T-101 已完成;准备 T-102 候选浏览和采集
- Git:当前分支为 `main`;T-001 至 T-004 均已纳入 Git 历史 - Git:当前分支为 `main`;T-001 至 T-004 均已纳入 Git 历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
@@ -14,21 +14,21 @@
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
共执行 38 次测试,0 failure、0 error、0 skipped 共执行 62 次测试,0 failure、0 error、0 skipped
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作 用户停止和单 runner 并发拒绝;T-101 已接入真实拼多多固定词搜索四步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多 - 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
`8.17.0 (81700)` `8.17.0 (81700)`
- 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、 - 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入和固定词结果页已通过
验证码或风控文案,但该结果不等于账号已确认登录 8.17.0 真机验证,结果页精确词、搜索头和 4 个排序控件均确认
- 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容 - 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG; - 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1` - 标准验证路径:`.\init.ps1`
- 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认; - 当前 blocker:候选列表/详情页语义与有界返回路径尚待 T-102 验证;当前只支持单
当前只支持单 SKU/JPEG;VLM 供应商和测试凭证未确认 SKU/JPEG;VLM 供应商、模型和测试凭证未确认
## 当前目录 ## 当前目录
@@ -40,6 +40,7 @@
| `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 | | `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 |
| `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner | | `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner |
| `docs/tasks/T-004.md` | DONE | 私有蝦皮文本/JPEG 严格导入和 Debug TaskSource | | `docs/tasks/T-004.md` | DONE | 私有蝦皮文本/JPEG 严格导入和 Debug TaskSource |
| `docs/tasks/T-101.md` | DONE | 固定脱敏词拼多多搜索和结果页真机验证 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | | `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource |
@@ -49,9 +50,9 @@
## 任务摘要 ## 任务摘要
- 已完成:T-001 至 T-004,Phase 0 可运行和输入基线。 - 已完成:T-001 至 T-004,以及 T-101 固定词搜索探针。
- 正在进行:无。 - 正在进行:无。
- 下一个可领取任务:T-101 固定任务跑通拼多多关键词搜索。 - 下一个可领取任务:T-102 浏览并采集最多 5 个候选。
## 当前可运行内容 ## 当前可运行内容
@@ -62,8 +63,9 @@ $env:RUN_START_COMMAND = "1"
.\init.ps1 .\init.ps1
``` ```
2026-07-25 已在 PKG110、Android 16/API 36 上完成 Debug APK 更新安装和启动; 2026-07-25 已在 PKG110、Android 16/API 36、拼多多 8.17.0 上完成 Debug APK
首屏为设备就绪检查,肉包采购无障碍服务已绑定,进程存活且 logcat 无崩溃。 固定词搜索。默认首屏为搜索探针,用户点击后四步全部完成并确认结果页;设备页保留
就绪检查入口,肉包采购无障碍服务已绑定,logcat 无崩溃或 ANR。
## 维护规则 ## 维护规则
+119
View File
@@ -0,0 +1,119 @@
---
id: T-101
title: 固定任务跑通拼多多关键词搜索
phase: 1
deps:
- T-002
- T-003
status: DONE
created: 2026-07-25
context_ref: 1c1c1581493af2d04433012064f44f58c1f1d56b
work_branch: main
write_paths:
- android-buyer/app/build.gradle.kts
- android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
- android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/**
- android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/**
- android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/SearchProbeScreen.kt
- android-buyer/app/src/main/java/com/roubao/autopilot/workflow/**
- android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/**
- android-buyer/app/src/test/java/com/roubao/autopilot/workflow/WorkflowRunnerTest.kt
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/current-state.md
- docs/tasks/T-101.md
- progress.md
---
## 问题 / 背景
Phase 0 已证明设备、无障碍、workflow 和任务输入可用,但尚未让肉包执行任何真实
拼多多动作。最高风险假设是:在拼多多 8.17.0 上,能否只依赖可访问性语义稳定打开
搜索、输入固定关键词并确认进入结果页,同时对异常页面安全停止。
## 关联需求与交互
- 功能:F-004 的关键词搜索部分、F-007 安全失败。
- 用户故事:US-004、US-006。
- 交互:Android“探针”页,用户显式点击开始和停止。
- 架构:`WorkflowRunner` + `PinduoduoSearchAutomation` + 无障碍 driver。
## 方案
1. 使用固定脱敏关键词,不读取真实订单标题,也不调用 VLM。
2. 把页面文本分类和搜索 workflow adapter 保持为纯 Kotlin 可测边界。
3. 无障碍 driver 只对 `com.xunmeng.pinduoduo` 操作,使用 resource ID、editable、
文本和 content description,不使用屏幕坐标。
4. 步骤为打开 App、进入搜索、输入并提交、验证结果页;每步有 timeout 和最多一次
retry。
5. 登录、验证码、风控、支付边界立即 `BLOCKED`;未知页面连续观察后安全停止。
6. Android“探针”页展示当前步骤、终态和停止按钮;不执行商品点击或下单。
## 验收要点
- [x] 用户显式点击后打开拼多多并提交固定关键词。
- [x] 成功条件是识别到搜索结果页,不以“点击已发出”冒充成功。
- [x] 全流程不用坐标,不进入商品详情、不提交订单、不支付。
- [x] 登录、验证码、风控、未知页和支付边界产生可区分的 `BLOCKED`。
- [x] 每步 timeout、retry 和用户停止由 T-003 runner 执行。
- [x] 页面分类和 adapter 使用 Fake driver 有自动化测试。
- [x] 拼多多 8.17.0 真机 smoke 通过并保存不含私有内容的证据。
- [x] `lintDebug test assembleDebug` 通过。
## 边界
- 不从真实 Shopee 标题生成搜索词;固定词只验证页面自动化。
- 不浏览候选、不点击商品、不滚动结果列表。
- 不使用 Shizuku shell、ADB 坐标、OCR 或 VLM 作为降级。
- 不绕过登录、验证码、风控或平台提示。
## 执行记录
### 2026-07-25:任务开始
- 基于 T-004 提交 `1c1c158` 开始。
- 测试基线为 OnePlus PKG110、Android 16/API 36、拼多多 8.17.0;肉包采购无障碍
已启用并连接。
### 2026-07-25:实现
- 增加纯 Kotlin 页面分类器和 `PinduoduoSearchAutomation`。页面节点必须同时可见、
启用并唯一匹配;结果页必须同时出现精确固定关键词、搜索头和至少 3 个排序控件。
- 增加 Android 无障碍 driver。打开搜索只点击语义节点或其可点击祖先,输入只使用
`ACTION_SET_TEXT`,并从新的根节点重新读取精确文本后才算成功;没有坐标或 shell
降级。
- workflow 固定执行打开 App、输入、提交和验证四步,沿用 T-003 的 timeout、有限
retry、停止和单实例约束。登录、验证码、风控、未知页面及订单/支付边界直接阻塞。
- 新增“拼多多搜索探针”默认工作页,显示固定词、当前步骤、终态和开始/停止操作。
Activity 恢复后延迟复检无障碍连接,避免系统服务晚连接造成按钮状态滞后。
- 修正 Debug fixture 资产与 lint model 的 Gradle 任务依赖,使完整验证不受增量任务
顺序影响。
### 2026-07-25:自动化验证
- `gradlew.bat lintDebug test assembleDebug --no-daemon` 成功。
- App Debug/Release、task contract 和导入器共 12 份报告、62 次测试,0 failure、
0 error、0 skipped。
- 页面分类覆盖首页、输入页、精确结果页、隐藏/禁用伪节点以及登录、验证码、风控、
未知页和订单/支付边界;Fake driver 覆盖成功、瞬态 retry 和安全阻塞。
- 普通 Debug APK 中 `assets/probe-fixtures/` 条目数为 0。
### 2026-07-25:真机 smoke
- 从肉包“开始搜索探针”按钮显式启动;App 使用固定脱敏词执行,未读取或显示私有
蝦皮样本。
- 拼多多 8.17.0 结果页采集到 174 个可访问节点:精确关键词存在、结果页搜索头存在、
4 个排序控件存在、搜索输入框为 0、订单/支付边界为 0。
- `ACTION_SET_TEXT` 后的新根节点精确回读通过,肉包最终显示 `SUCCEEDED` 且四步均
完成;logcat 未发现肉包崩溃或 ANR。
- smoke XML/PNG 只保存在被忽略的 `.local/t101-smoke/`,未纳入 Git。重装后 ColorOS
出现一次系统无障碍“保持开启”确认,确认后服务正常绑定;产品代码没有绕过该权限。
## 后续
- T-102 从当前结果页浏览并采集最多 5 个候选;仍不得无界滚动、点击下单或支付。
- 从不属于已识别首页/输入页/当前固定词结果页的状态启动时,当前实现按
`UNKNOWN_PAGE` 安全阻塞,不猜测返回路径。
+8
View File
@@ -69,3 +69,11 @@
Debug fixture 注入,真实样本逐字段与图片哈希验证通过。 Debug fixture 注入,真实样本逐字段与图片哈希验证通过。
- 影响:Phase 0 完成;T-101 可先使用固定搜索词验证真实拼多多操作,T-103 可直接 - 影响:Phase 0 完成;T-101 可先使用固定搜索词验证真实拼多多操作,T-103 可直接
消费同一私有任务契约接入 VLM。 消费同一私有任务契约接入 VLM。
## 2026-07-25 拼多多固定词搜索探针
- 类型:阶段完成
- 内容:完成 T-101;用纯无障碍语义节点跑通固定脱敏词的打开搜索、精确输入、提交
和结果页确认,并在拼多多 8.17.0 真机验证四步 workflow。
- 影响:最高风险的基础搜索动作已证实可行;下一步 T-102 只扩展最多 5 个候选的
有界浏览和证据采集,继续禁止下单与支付。