feat(android): add Pinduoduo search probe
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
+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
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 {
|
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,
|
||||||
|
|||||||
+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(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,9 @@
|
|||||||
|
|
||||||
## 当前阶段与优先路径
|
## 当前阶段与优先路径
|
||||||
|
|
||||||
当前已完成 Phase 0:Android 可运行、设备就绪、workflow 测试和私有样本导入基线。
|
当前已完成 Phase 0 和 T-101:Android 可运行、设备就绪、workflow、私有样本导入
|
||||||
下一步是 T-101,用固定脱敏搜索词跑通拼多多关键词搜索并验证安全停止。
|
以及固定脱敏词拼多多搜索均已在真机验证。下一步是 T-102,只浏览并采集最多 5 个
|
||||||
|
候选,继续禁止订单提交和支付。
|
||||||
|
|
||||||
严格按以下顺序推进:
|
严格按以下顺序推进:
|
||||||
|
|
||||||
|
|||||||
@@ -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、收货地址、运费、优惠、发票、金额审批、
|
||||||
|
|||||||
@@ -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
@@ -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 重复而任取第一个节点。
|
||||||
|
- 已验证的首页、搜索输入和结果页路径禁止坐标降级;新页面必须先取得版本化真机
|
||||||
|
证据并在独立任务中定义识别条件。
|
||||||
- 截图发送给模型前裁剪无关区域并按配置脱敏。
|
- 截图发送给模型前裁剪无关区域并按配置脱敏。
|
||||||
- 每个动作记录抽象步骤,不默认记录完整输入文本。
|
- 每个动作记录抽象步骤,不默认记录完整输入文本。
|
||||||
- 发现验证码、风险控制、支付、生物识别或系统权限页面立即停止。
|
- 发现验证码、风险控制、支付、生物识别或系统权限页面立即停止。
|
||||||
|
|||||||
@@ -30,7 +30,10 @@
|
|||||||
## 4. Android 自动化规则
|
## 4. Android 自动化规则
|
||||||
|
|
||||||
- 动作前确认目标 App 包名和预期页面,动作后确认状态确实变化。
|
- 动作前确认目标 App 包名和预期页面,动作后确认状态确实变化。
|
||||||
- 优先使用资源 ID、可访问性语义和稳定文本;坐标只能作为被验证的局部降级。
|
- 节点必须可见、启用且唯一匹配;资源 ID 重复时不得选择第一个结果。
|
||||||
|
- 首页、搜索输入和结果页的已验证路径只用可访问性语义和稳定文本,禁止坐标降级。
|
||||||
|
其他页面如需坐标必须另立任务、固定版本并提供语义方案确实不可用的证据。
|
||||||
|
- 文本写入动作返回成功不等于完成;必须从新的根节点精确回读后再推进。
|
||||||
- 每个步骤定义 timeout、有限 retry、成功条件和停止条件。
|
- 每个步骤定义 timeout、有限 retry、成功条件和停止条件。
|
||||||
- 禁止无界循环、无限滑动、无限候选遍历和随机点击。
|
- 禁止无界循环、无限滑动、无限候选遍历和随机点击。
|
||||||
- 默认最多检查 5 个候选;扩大范围必须先改需求。
|
- 默认最多检查 5 个候选;扩大范围必须先改需求。
|
||||||
|
|||||||
+13
-11
@@ -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。
|
||||||
|
|
||||||
## 维护规则
|
## 维护规则
|
||||||
|
|
||||||
|
|||||||
@@ -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 注入,真实样本逐字段与图片哈希验证通过。
|
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 个候选的
|
||||||
|
有界浏览和证据采集,继续禁止下单与支付。
|
||||||
|
|||||||
Reference in New Issue
Block a user