feat(android): add Pinduoduo search probe
This commit is contained in:
@@ -64,7 +64,13 @@ android {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,23 @@ import com.roubao.autopilot.vlm.GUIOwlClient
|
||||
import com.roubao.autopilot.vlm.MAIUIClient
|
||||
import com.roubao.autopilot.vlm.VLMClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import rikka.shizuku.Shizuku
|
||||
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"
|
||||
|
||||
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 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 Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
|
||||
}
|
||||
@@ -65,6 +73,11 @@ class MainActivity : ComponentActivity() {
|
||||
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
|
||||
private var shizukuAvailable = mutableStateOf(false)
|
||||
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(用于停止任务)
|
||||
private var currentExecutionJob: kotlinx.coroutines.Job? = null
|
||||
@@ -172,21 +185,19 @@ class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
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 showShizukuHelpDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val settings by settingsManager.settings.collectAsState()
|
||||
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 isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission()
|
||||
val executing by remember { isExecuting }
|
||||
val navigateToRecord by remember { shouldNavigateToRecord }
|
||||
val recordId by remember { currentRecordId }
|
||||
val readiness by remember { readinessSnapshot }
|
||||
val probeState by remember { searchProbeState }
|
||||
val probeStepId by remember { searchProbeStepId }
|
||||
val probeReport by remember { searchProbeReport }
|
||||
|
||||
// 监听跳转事件
|
||||
LaunchedEffect(navigateToRecord, recordId) {
|
||||
@@ -211,7 +222,7 @@ class MainActivity : ComponentActivity() {
|
||||
contentColor = colors.textPrimary,
|
||||
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
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
@@ -270,35 +281,14 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onOpenPinduoduo = { openPinduoduo() }
|
||||
)
|
||||
Screen.Home -> {
|
||||
// 每次进入首页都检测 Shizuku 状态
|
||||
LaunchedEffect(Unit) {
|
||||
checkAndUpdateShizukuStatus()
|
||||
}
|
||||
HomeScreen(
|
||||
agentState = agentState,
|
||||
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.Home -> SearchProbeScreen(
|
||||
readiness = readiness,
|
||||
state = probeState,
|
||||
currentStepId = probeStepId,
|
||||
report = probeReport,
|
||||
onStart = { startSearchProbe() },
|
||||
onStop = { stopSearchProbe() }
|
||||
)
|
||||
Screen.History -> HistoryScreen(
|
||||
records = records,
|
||||
onRecordClick = { record -> selectedRecord = record },
|
||||
@@ -340,10 +330,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Shizuku 帮助对话框
|
||||
if (showShizukuHelpDialog) {
|
||||
ShizukuHelpDialog(onDismiss = { showShizukuHelpDialog = false })
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteRecord(id: String) {
|
||||
@@ -357,10 +343,15 @@ class MainActivity : ComponentActivity() {
|
||||
super.onResume()
|
||||
if (::readinessChecker.isInitialized) {
|
||||
refreshReadiness()
|
||||
lifecycleScope.launch {
|
||||
delay(READINESS_RECHECK_DELAY_MS)
|
||||
refreshReadiness()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
searchProbeRunner?.requestStop()
|
||||
super.onDestroy()
|
||||
Shizuku.removeBinderReceivedListener(binderReceivedListener)
|
||||
Shizuku.removeBinderDeadListener(binderDeadListener)
|
||||
@@ -382,6 +373,48 @@ class MainActivity : ComponentActivity() {
|
||||
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 {
|
||||
return try {
|
||||
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(
|
||||
instruction: String,
|
||||
apiKey: String,
|
||||
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+176
-7
@@ -1,6 +1,7 @@
|
||||
package com.roubao.autopilot.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
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.LoginBlockerDetector
|
||||
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
|
||||
|
||||
class BuyerAccessibilityService : AccessibilityService() {
|
||||
private var lastPinduoduoScanAt = 0L
|
||||
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
DeviceObservationStore.setAccessibilityConnected(true)
|
||||
BuyerAccessibilityBridge.attach(this)
|
||||
Log.i(TAG, "Buyer accessibility service connected")
|
||||
}
|
||||
|
||||
@@ -52,10 +60,140 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
BuyerAccessibilityBridge.detach(this)
|
||||
DeviceObservationStore.setAccessibilityConnected(false)
|
||||
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 =
|
||||
eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
|
||||
eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED ||
|
||||
@@ -63,6 +201,18 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
|
||||
private fun collectVisibleTexts(root: AccessibilityNodeInfo): List<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>()
|
||||
queue.add(root)
|
||||
var visited = 0
|
||||
@@ -70,23 +220,42 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
while (queue.isNotEmpty() && visited < MAX_NODES) {
|
||||
val node = queue.removeFirst()
|
||||
visited += 1
|
||||
|
||||
node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add)
|
||||
node.contentDescription
|
||||
?.toString()
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let(texts::add)
|
||||
nodes += node
|
||||
|
||||
for (index in 0 until node.childCount) {
|
||||
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 {
|
||||
private const val TAG = "BuyerAccessibility"
|
||||
private const val MAX_NODES = 300
|
||||
private const val MAX_CLICK_ANCESTORS = 4
|
||||
private const val MIN_SCAN_INTERVAL_MS = 300L
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+114
@@ -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) }
|
||||
}
|
||||
+143
@@ -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 {
|
||||
TIMEOUT,
|
||||
TRANSIENT_AUTOMATION,
|
||||
AUTOMATION_EXCEPTION
|
||||
AUTOMATION_EXCEPTION,
|
||||
TARGET_NOT_READY
|
||||
}
|
||||
|
||||
enum class SafetyStopReason {
|
||||
|
||||
@@ -17,6 +17,7 @@ class WorkflowRunner(
|
||||
) {
|
||||
private val running = AtomicBoolean(false)
|
||||
private val mutableState = MutableStateFlow(WorkflowState.IDLE)
|
||||
private val mutableCurrentStepId = MutableStateFlow<String?>(null)
|
||||
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
@@ -25,12 +26,14 @@ class WorkflowRunner(
|
||||
private var currentExecution: Job? = null
|
||||
|
||||
val state: StateFlow<WorkflowState> = mutableState.asStateFlow()
|
||||
val currentStepId: StateFlow<String?> = mutableCurrentStepId.asStateFlow()
|
||||
|
||||
suspend fun run(steps: List<WorkflowStep>): WorkflowReport {
|
||||
require(steps.isNotEmpty()) { "Workflow must contain at least one step" }
|
||||
check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" }
|
||||
|
||||
stopRequested = false
|
||||
mutableCurrentStepId.value = null
|
||||
val completedSteps = mutableListOf<String>()
|
||||
val attempts = linkedMapOf<String, Int>()
|
||||
val transitions = mutableListOf<WorkflowTransition>()
|
||||
@@ -197,6 +200,7 @@ class WorkflowRunner(
|
||||
) {
|
||||
val previous = mutableState.value
|
||||
mutableState.value = next
|
||||
mutableCurrentStepId.value = stepId
|
||||
transitions += WorkflowTransition(
|
||||
from = previous,
|
||||
to = next,
|
||||
|
||||
+135
@@ -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
|
||||
)
|
||||
}
|
||||
+131
@@ -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(listOf("open_app", "verify_page"), report.completedStepIds)
|
||||
assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep)
|
||||
assertEquals(null, runner.currentStepId.value)
|
||||
assertEquals(
|
||||
listOf(
|
||||
WorkflowState.RUNNING,
|
||||
@@ -79,6 +80,7 @@ class WorkflowRunnerTest {
|
||||
assertEquals(WorkflowState.FAILED, report.state)
|
||||
assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode)
|
||||
assertEquals(2, report.attemptsByStep["wait_for_page"])
|
||||
assertEquals("wait_for_page", runner.currentStepId.value)
|
||||
assertEquals(200, currentTime)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,9 @@
|
||||
|
||||
## 当前阶段与优先路径
|
||||
|
||||
当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。
|
||||
下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。
|
||||
当前已完成 Phase 0 和 T-101:Android 可运行、设备就绪、workflow、私有样本导入
|
||||
以及固定脱敏词拼多多搜索均已在真机验证。下一步是 T-102,只浏览并采集最多 5 个
|
||||
候选,继续禁止订单提交和支付。
|
||||
|
||||
严格按以下顺序推进:
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| --- | --- |
|
||||
| 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、描述、图片、数量和预算。 |
|
||||
| 第一层样本来源 | 本机目录中的蝦皮订单文本和参考图;二者以蝦皮订单号作为同名文件名。 |
|
||||
| 执行方式 | 采购人员使用 Android App 操作拼多多;当前没有可运行实现。 |
|
||||
| 执行方式 | 采购人员使用 Android App 操作拼多多;固定词搜索探针已可运行,候选浏览和真实任务尚未实现。 |
|
||||
| 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 |
|
||||
| 验证范围 | 一台设备、一个管理身份、一个采购执行人员、拼多多单平台。 |
|
||||
| 资金边界 | MVP 不提交订单、不支付,只验证到人工确认位置。 |
|
||||
@@ -140,7 +140,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
|
||||
`T-001` 完成。
|
||||
- 首份蝦皮样本已由 T-004 真实导入验证;当前只支持单 SKU 和 JPEG,扩展格式需新
|
||||
样例和独立任务。
|
||||
- 拼多多版本、页面结构、账号登录状态和测试设备尚未形成可复现基线。
|
||||
- OnePlus PKG110、Android 16/API 36、拼多多 8.17.0 的首页、搜索输入和结果页已形成
|
||||
可复现基线;候选列表、详情页及账号风控差异仍待 T-102 验证。
|
||||
- VLM 厂商、模型、成本上限、数据留存地区和图片隐私规则待确认。
|
||||
- 拼多多平台条款、自动化允许范围和账号风控需要业务方确认;项目不实现绕过措施。
|
||||
- 后续若允许提交订单,必须先明确 SKU、收货地址、运费、优惠、发票、金额审批、
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 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 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,默认构建会清除私有资产。 |
|
||||
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
|
||||
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
|
||||
@@ -27,7 +27,7 @@
|
||||
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 |
|
||||
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
|
||||
| 后端测试 | 标准库 `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 或受控测试网络访问。 |
|
||||
|
||||
## Roubao 上游版本基线
|
||||
@@ -134,7 +134,7 @@ docs/
|
||||
| --- | --- | --- |
|
||||
| Android 标准验证 | `.\init.ps1` | 已验证 |
|
||||
| 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 真机验证 |
|
||||
| 后端依赖 | `go mod download`(在 `backend-api/`) | 待 `T-201` |
|
||||
| 后端测试 | `go test ./...`(在 `backend-api/`) | 待 `T-201` |
|
||||
|
||||
+21
-1
@@ -152,6 +152,23 @@ Debug 构建或测试装载。导入必须:
|
||||
私有 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 业务闭环
|
||||
|
||||
```text
|
||||
@@ -306,7 +323,10 @@ IDLE
|
||||
|
||||
- 只允许目标拼多多包处于前台时发送动作。
|
||||
- 关键动作前后都校验包名、页面特征和任务取消标志。
|
||||
- 节点选择优先资源 ID、可访问性文本和稳定层级;文本版本变化要集中配置。
|
||||
- 节点选择要求可见、启用且唯一匹配;优先可访问性语义、稳定文本和受限祖先层级,
|
||||
不能因资源 ID 重复而任取第一个节点。
|
||||
- 已验证的首页、搜索输入和结果页路径禁止坐标降级;新页面必须先取得版本化真机
|
||||
证据并在独立任务中定义识别条件。
|
||||
- 截图发送给模型前裁剪无关区域并按配置脱敏。
|
||||
- 每个动作记录抽象步骤,不默认记录完整输入文本。
|
||||
- 发现验证码、风险控制、支付、生物识别或系统权限页面立即停止。
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
## 4. Android 自动化规则
|
||||
|
||||
- 动作前确认目标 App 包名和预期页面,动作后确认状态确实变化。
|
||||
- 优先使用资源 ID、可访问性语义和稳定文本;坐标只能作为被验证的局部降级。
|
||||
- 节点必须可见、启用且唯一匹配;资源 ID 重复时不得选择第一个结果。
|
||||
- 首页、搜索输入和结果页的已验证路径只用可访问性语义和稳定文本,禁止坐标降级。
|
||||
其他页面如需坐标必须另立任务、固定版本并提供语义方案确实不可用的证据。
|
||||
- 文本写入动作返回成功不等于完成;必须从新的根节点精确回读后再推进。
|
||||
- 每个步骤定义 timeout、有限 retry、成功条件和停止条件。
|
||||
- 禁止无界循环、无限滑动、无限候选遍历和随机点击。
|
||||
- 默认最多检查 5 个候选;扩大范围必须先改需求。
|
||||
|
||||
+13
-11
@@ -5,7 +5,7 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-25
|
||||
- 阶段:Phase 0 完成,准备执行 T-101 拼多多关键词搜索探针
|
||||
- 阶段:T-101 已完成;准备 T-102 候选浏览和采集
|
||||
- Git:当前分支为 `main`;T-001 至 T-004 均已纳入 Git 历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||
@@ -14,21 +14,21 @@
|
||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:`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、安全阻塞、
|
||||
用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作
|
||||
用户停止和单 runner 并发拒绝;T-101 已接入真实拼多多固定词搜索四步
|
||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
|
||||
`8.17.0 (81700)`
|
||||
- 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、
|
||||
验证码或风控文案,但该结果不等于账号已确认登录
|
||||
- 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入和固定词结果页已通过
|
||||
8.17.0 真机验证,结果页精确词、搜索头和 4 个排序控件均确认
|
||||
- 版本控制内测试数据:仅有脱敏、运行时生成的单元测试 fixture;没有真实订单内容
|
||||
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
|
||||
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
||||
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
||||
- 标准验证路径:`.\init.ps1`
|
||||
- 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认;
|
||||
当前只支持单 SKU/JPEG;VLM 供应商和测试凭证未确认
|
||||
- 当前 blocker:候选列表/详情页语义与有界返回路径尚待 T-102 验证;当前只支持单
|
||||
SKU/JPEG;VLM 供应商、模型和测试凭证未确认
|
||||
|
||||
## 当前目录
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
| `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 |
|
||||
| `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner |
|
||||
| `docs/tasks/T-004.md` | DONE | 私有蝦皮文本/JPEG 严格导入和 Debug TaskSource |
|
||||
| `docs/tasks/T-101.md` | DONE | 固定脱敏词拼多多搜索和结果页真机验证 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
| `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
|
||||
```
|
||||
|
||||
2026-07-25 已在 PKG110、Android 16/API 36 上完成 Debug APK 更新安装和启动;
|
||||
首屏为设备就绪检查,肉包采购无障碍服务已绑定,进程存活且 logcat 无崩溃。
|
||||
2026-07-25 已在 PKG110、Android 16/API 36、拼多多 8.17.0 上完成 Debug APK
|
||||
固定词搜索。默认首屏为搜索探针,用户点击后四步全部完成并确认结果页;设备页保留
|
||||
就绪检查入口,肉包采购无障碍服务已绑定,logcat 无崩溃或 ANR。
|
||||
|
||||
## 维护规则
|
||||
|
||||
|
||||
@@ -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` 安全阻塞,不猜测返回路径。
|
||||
@@ -69,3 +69,11 @@
|
||||
Debug fixture 注入,真实样本逐字段与图片哈希验证通过。
|
||||
- 影响:Phase 0 完成;T-101 可先使用固定搜索词验证真实拼多多操作,T-103 可直接
|
||||
消费同一私有任务契约接入 VLM。
|
||||
|
||||
## 2026-07-25 拼多多固定词搜索探针
|
||||
|
||||
- 类型:阶段完成
|
||||
- 内容:完成 T-101;用纯无障碍语义节点跑通固定脱敏词的打开搜索、精确输入、提交
|
||||
和结果页确认,并在拼多多 8.17.0 真机验证四步 workflow。
|
||||
- 影响:最高风险的基础搜索动作已证实可行;下一步 T-102 只扩展最多 5 个候选的
|
||||
有界浏览和证据采集,继续禁止下单与支付。
|
||||
|
||||
Reference in New Issue
Block a user