feat(android): add Pinduoduo search probe

This commit is contained in:
QiuSW
2026-07-25 19:40:19 +08:00
parent 1c1c158149
commit f226d0f2ff
21 changed files with 1356 additions and 70 deletions
@@ -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,
@@ -0,0 +1,49 @@
package com.roubao.autopilot.accessibility
import com.roubao.autopilot.pinduoduo.PinduoduoPage
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
import com.roubao.autopilot.readiness.DeviceObservationStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object BuyerAccessibilityBridge {
@Volatile
private var service: BuyerAccessibilityService? = null
fun attach(connectedService: BuyerAccessibilityService) {
service = connectedService
}
fun detach(disconnectedService: BuyerAccessibilityService) {
if (service === disconnectedService) {
service = null
}
}
suspend fun snapshot(): PinduoduoUiSnapshot = withContext(Dispatchers.Main.immediate) {
service?.snapshotPinduoduoUi()
?: PinduoduoUiSnapshot(
foregroundPackage = DeviceObservationStore.snapshot().foregroundPackage,
page = PinduoduoPage.UNKNOWN,
safetyStopReason = null
)
}
suspend fun openSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
service?.clickPinduoduoSearchEntry() == true
}
suspend fun setSearchKeyword(keyword: String): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.setPinduoduoSearchKeyword(keyword) == true
}
suspend fun isSearchKeywordSet(keyword: String): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.isPinduoduoSearchKeywordSet(keyword) == true
}
suspend fun submitSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
service?.submitPinduoduoSearch() == true
}
}
@@ -1,6 +1,7 @@
package com.roubao.autopilot.accessibility
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
}
}
@@ -0,0 +1,49 @@
package com.roubao.autopilot.pinduoduo
import android.content.Context
import android.content.Intent
import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import kotlinx.coroutines.delay
class AndroidPinduoduoUiDriver(context: Context) : PinduoduoUiDriver {
private val appContext = context.applicationContext
override suspend fun openApp(): Boolean {
val intent = appContext.packageManager.getLaunchIntentForPackage(PINDUODUO_PACKAGE)
?: return false
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
)
appContext.startActivity(intent)
return true
}
override suspend fun snapshot(): PinduoduoUiSnapshot =
BuyerAccessibilityBridge.snapshot()
override suspend fun openSearch(): Boolean =
BuyerAccessibilityBridge.openSearch()
override suspend fun setSearchKeyword(keyword: String): Boolean {
if (!BuyerAccessibilityBridge.setSearchKeyword(keyword)) {
return false
}
repeat(KEYWORD_VERIFY_ATTEMPTS) {
delay(KEYWORD_VERIFY_INTERVAL_MS)
if (BuyerAccessibilityBridge.isSearchKeywordSet(keyword)) {
return true
}
}
return false
}
override suspend fun submitSearch(): Boolean =
BuyerAccessibilityBridge.submitSearch()
private companion object {
const val KEYWORD_VERIFY_ATTEMPTS = 5
const val KEYWORD_VERIFY_INTERVAL_MS = 100L
}
}
@@ -0,0 +1,114 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.LoginBlocker
import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.workflow.SafetyStopReason
data class PinduoduoUiElement(
val text: String?,
val contentDescription: String?,
val className: String,
val resourceId: String?,
val clickable: Boolean,
val editable: Boolean,
val enabled: Boolean,
val visibleToUser: Boolean
)
enum class PinduoduoPage {
HOME,
SEARCH_INPUT,
SEARCH_RESULTS,
UNKNOWN
}
data class PinduoduoUiSnapshot(
val foregroundPackage: String?,
val page: PinduoduoPage,
val safetyStopReason: SafetyStopReason?
)
object PinduoduoPageClassifier {
private val paymentMarkers = listOf(
"确认订单",
"提交订单",
"确认支付",
"立即支付",
"收银台"
)
fun classify(
foregroundPackage: String?,
elements: Collection<PinduoduoUiElement>,
expectedQuery: String = SEARCH_PROBE_KEYWORD
): PinduoduoUiSnapshot {
if (foregroundPackage != PINDUODUO_PACKAGE) {
return PinduoduoUiSnapshot(
foregroundPackage = foregroundPackage,
page = PinduoduoPage.UNKNOWN,
safetyStopReason = null
)
}
val visibleElements = elements.filter { it.visibleToUser && it.enabled }
val visibleTexts = visibleElements.flatMap { element ->
listOfNotNull(element.text, element.contentDescription)
}
val safetyStop = when (LoginBlockerDetector.detect(visibleTexts)) {
LoginBlocker.LOGIN_REQUIRED -> SafetyStopReason.LOGIN_REQUIRED
LoginBlocker.VERIFICATION_REQUIRED -> SafetyStopReason.VERIFICATION_REQUIRED
LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL
LoginBlocker.NONE,
LoginBlocker.UNKNOWN -> {
if (visibleTexts.containsAny(paymentMarkers)) {
SafetyStopReason.PAYMENT_BOUNDARY
} else {
null
}
}
}
val normalized = visibleTexts.map(::normalize)
val hasSearchInput = visibleElements.any { element ->
(element.editable || element.className.endsWith("EditText")) &&
normalize(element.contentDescription.orEmpty()) == "搜索"
}
val hasSubmitButton = visibleElements.any { element ->
element.className.endsWith("TextView") &&
element.clickable &&
normalize(element.text.orEmpty()) == "搜索"
}
val hasHomeSearchEntry = visibleElements.any { element ->
element.className.endsWith("TextView") &&
normalize(element.contentDescription.orEmpty()) == "搜索"
}
val hasHome = normalized.any { it == "首页" }
val hasResultSearchHeader = visibleElements.any { element ->
element.className.endsWith("HorizontalScrollView") &&
normalize(element.contentDescription.orEmpty()) == "搜索"
}
val sortControlCount = setOf("综合", "销量", "价格", "筛选")
.count { marker -> normalized.any { it == marker } }
val hasExactQuery = visibleTexts.any { it.trim() == expectedQuery }
val page = when {
hasExactQuery && hasResultSearchHeader && sortControlCount >= 3 ->
PinduoduoPage.SEARCH_RESULTS
hasSearchInput && hasSubmitButton -> PinduoduoPage.SEARCH_INPUT
hasHomeSearchEntry && hasHome -> PinduoduoPage.HOME
else -> PinduoduoPage.UNKNOWN
}
return PinduoduoUiSnapshot(
foregroundPackage = foregroundPackage,
page = page,
safetyStopReason = safetyStop
)
}
private fun normalize(value: String): String =
value.lowercase().replace(Regex("\\s+"), "")
private fun Collection<String>.containsAny(markers: Collection<String>): Boolean =
any { text -> markers.any(text::contains) }
}
@@ -0,0 +1,143 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.workflow.AutomationGateway
import com.roubao.autopilot.workflow.AutomationResult
import com.roubao.autopilot.workflow.SafetyStopReason
import com.roubao.autopilot.workflow.WorkflowFailureCode
import com.roubao.autopilot.workflow.WorkflowStep
import kotlinx.coroutines.delay
const val SEARCH_PROBE_KEYWORD = "手机支架"
interface PinduoduoUiDriver {
suspend fun openApp(): Boolean
suspend fun snapshot(): PinduoduoUiSnapshot
suspend fun openSearch(): Boolean
suspend fun setSearchKeyword(keyword: String): Boolean
suspend fun submitSearch(): Boolean
}
class PinduoduoSearchAutomation(
private val driver: PinduoduoUiDriver,
private val keyword: String = SEARCH_PROBE_KEYWORD,
private val pollIntervalMillis: Long = 200,
private val unknownPageLimit: Int = 10
) : AutomationGateway {
init {
require(keyword.isNotBlank()) { "Search keyword must not be blank" }
require(pollIntervalMillis > 0) { "Poll interval must be positive" }
require(unknownPageLimit > 0) { "Unknown page limit must be positive" }
}
override suspend fun execute(step: WorkflowStep): AutomationResult =
when (step.id) {
PinduoduoSearchWorkflow.OPEN_APP -> openApp()
PinduoduoSearchWorkflow.ENTER_QUERY -> enterQuery()
PinduoduoSearchWorkflow.SUBMIT_QUERY -> submitQuery()
PinduoduoSearchWorkflow.VERIFY_RESULTS -> verifyResults()
else -> AutomationResult.FatalFailure(WorkflowFailureCode.AUTOMATION_EXCEPTION)
}
private suspend fun openApp(): AutomationResult {
if (!driver.openApp()) {
return AutomationResult.FatalFailure(WorkflowFailureCode.TARGET_NOT_READY)
}
return awaitPage {
it == PinduoduoPage.HOME ||
it == PinduoduoPage.SEARCH_INPUT ||
it == PinduoduoPage.SEARCH_RESULTS
} ?: AutomationResult.Success
}
private suspend fun enterQuery(): AutomationResult {
val ready = awaitPage {
it == PinduoduoPage.HOME ||
it == PinduoduoPage.SEARCH_INPUT ||
it == PinduoduoPage.SEARCH_RESULTS
}
if (ready != null) {
return ready
}
val currentPage = driver.snapshot().page
if (currentPage == PinduoduoPage.SEARCH_RESULTS) {
return AutomationResult.Success
}
if (currentPage != PinduoduoPage.SEARCH_INPUT) {
if (!driver.openSearch()) {
return AutomationResult.RetryableFailure(
WorkflowFailureCode.TRANSIENT_AUTOMATION
)
}
val searchInput = awaitPage { it == PinduoduoPage.SEARCH_INPUT }
if (searchInput != null) {
return searchInput
}
}
return if (driver.setSearchKeyword(keyword)) {
AutomationResult.Success
} else {
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION)
}
}
private suspend fun submitQuery(): AutomationResult {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) {
return AutomationResult.Success
}
return if (driver.submitSearch()) {
AutomationResult.Success
} else {
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION)
}
}
private suspend fun verifyResults(): AutomationResult =
awaitPage { it == PinduoduoPage.SEARCH_RESULTS } ?: AutomationResult.Success
private suspend fun awaitPage(
expected: (PinduoduoPage) -> Boolean
): AutomationResult? {
var stableUnknownObservations = 0
while (true) {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
if (expected(snapshot.page)) {
return null
}
stableUnknownObservations = if (
snapshot.foregroundPackage == com.roubao.autopilot.readiness.PINDUODUO_PACKAGE &&
snapshot.page == PinduoduoPage.UNKNOWN
) {
stableUnknownObservations + 1
} else {
0
}
if (stableUnknownObservations >= unknownPageLimit) {
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
}
delay(pollIntervalMillis)
}
}
private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? =
snapshot.safetyStopReason?.let(AutomationResult::Blocked)
}
object PinduoduoSearchWorkflow {
const val OPEN_APP = "pdd_open_app"
const val ENTER_QUERY = "pdd_enter_query"
const val SUBMIT_QUERY = "pdd_submit_query"
const val VERIFY_RESULTS = "pdd_verify_results"
fun steps(): List<WorkflowStep> = listOf(
WorkflowStep(OPEN_APP, timeoutMillis = 8_000, maxRetries = 1),
WorkflowStep(ENTER_QUERY, timeoutMillis = 10_000, maxRetries = 1),
WorkflowStep(SUBMIT_QUERY, timeoutMillis = 6_000, maxRetries = 1),
WorkflowStep(VERIFY_RESULTS, timeoutMillis = 12_000, maxRetries = 1)
)
}
@@ -0,0 +1,292 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.workflow.SafetyStopReason
import com.roubao.autopilot.workflow.WorkflowFailureCode
import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowState
private data class ProbeStepUi(
val id: String,
val label: String
)
private val probeSteps = listOf(
ProbeStepUi(PinduoduoSearchWorkflow.OPEN_APP, "打开拼多多"),
ProbeStepUi(PinduoduoSearchWorkflow.ENTER_QUERY, "输入固定关键词"),
ProbeStepUi(PinduoduoSearchWorkflow.SUBMIT_QUERY, "提交搜索"),
ProbeStepUi(PinduoduoSearchWorkflow.VERIFY_RESULTS, "确认结果页")
)
@Composable
fun SearchProbeScreen(
readiness: DeviceReadinessSnapshot,
state: WorkflowState,
currentStepId: String?,
report: WorkflowReport?,
onStart: () -> Unit,
onStop: () -> Unit
) {
val colors = BaoziTheme.colors
val active = state == WorkflowState.RUNNING || state == WorkflowState.RETRYING
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(colors.background),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
item {
Text(
text = "拼多多搜索探针",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary
)
Text(
text = stateLabel(state, report),
fontSize = 14.sp,
color = stateColor(state)
)
Spacer(modifier = Modifier.height(24.dp))
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = colors.primary,
modifier = Modifier.size(24.dp)
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = "固定关键词",
fontSize = 13.sp,
color = colors.textSecondary
)
Text(
text = SEARCH_PROBE_KEYWORD,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = colors.textPrimary
)
}
}
Divider(color = colors.surfaceVariant)
}
items(probeSteps.size) { index ->
val step = probeSteps[index]
ProbeStepRow(
label = step.label,
state = stepState(
stepId = step.id,
workflowState = state,
currentStepId = currentStepId,
report = report
)
)
}
item {
Spacer(modifier = Modifier.height(24.dp))
if (active) {
OutlinedButton(
onClick = onStop,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("停止探针")
}
} else {
Button(
onClick = onStart,
enabled = readiness.canStartProbe,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = colors.primary)
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("开始搜索探针")
}
}
if (!readiness.canStartProbe) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "设备检查存在阻塞项",
fontSize = 13.sp,
color = colors.error
)
}
}
}
}
private enum class ProbeStepState {
WAITING,
ACTIVE,
COMPLETE,
FAILED
}
@Composable
private fun ProbeStepRow(label: String, state: ProbeStepState) {
val colors = BaoziTheme.colors
val icon: ImageVector
val tint: Color
val status: String
when (state) {
ProbeStepState.WAITING -> {
icon = Icons.Default.Info
tint = colors.textHint
status = "等待"
}
ProbeStepState.ACTIVE -> {
icon = Icons.Default.PlayArrow
tint = colors.primary
status = "执行中"
}
ProbeStepState.COMPLETE -> {
icon = Icons.Default.CheckCircle
tint = colors.success
status = "完成"
}
ProbeStepState.FAILED -> {
icon = Icons.Default.Warning
tint = colors.error
status = "停止"
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(58.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp)
)
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
)
Text(
text = status,
fontSize = 13.sp,
color = tint
)
}
Divider(color = colors.surfaceVariant)
}
private fun stepState(
stepId: String,
workflowState: WorkflowState,
currentStepId: String?,
report: WorkflowReport?
): ProbeStepState = when {
report?.completedStepIds?.contains(stepId) == true -> ProbeStepState.COMPLETE
stepId == currentStepId &&
workflowState in setOf(
WorkflowState.FAILED,
WorkflowState.BLOCKED,
WorkflowState.STOPPED
) -> ProbeStepState.FAILED
stepId == currentStepId -> ProbeStepState.ACTIVE
else -> ProbeStepState.WAITING
}
@Composable
private fun stateColor(state: WorkflowState): Color {
val colors = BaoziTheme.colors
return when (state) {
WorkflowState.SUCCEEDED -> colors.success
WorkflowState.FAILED,
WorkflowState.BLOCKED -> colors.error
WorkflowState.RETRYING -> colors.warning
WorkflowState.RUNNING -> colors.primary
WorkflowState.IDLE,
WorkflowState.STOPPED -> colors.textSecondary
}
}
private fun stateLabel(state: WorkflowState, report: WorkflowReport?): String =
when (state) {
WorkflowState.IDLE -> "等待开始"
WorkflowState.RUNNING -> "正在执行"
WorkflowState.RETRYING -> "正在重试"
WorkflowState.SUCCEEDED -> "搜索结果页已确认"
WorkflowState.STOPPED -> "已由用户停止"
WorkflowState.FAILED -> failureLabel(report?.failureCode)
WorkflowState.BLOCKED -> blockedLabel(report?.safetyStopReason)
}
private fun failureLabel(code: WorkflowFailureCode?): String = when (code) {
WorkflowFailureCode.TIMEOUT -> "执行超时"
WorkflowFailureCode.TRANSIENT_AUTOMATION -> "页面操作失败"
WorkflowFailureCode.AUTOMATION_EXCEPTION -> "自动化执行异常"
WorkflowFailureCode.TARGET_NOT_READY -> "拼多多无法启动"
null -> "执行失败"
}
private fun blockedLabel(reason: SafetyStopReason?): String = when (reason) {
SafetyStopReason.LOGIN_REQUIRED -> "检测到登录要求,已停止"
SafetyStopReason.VERIFICATION_REQUIRED -> "检测到验证码,已停止"
SafetyStopReason.RISK_CONTROL -> "检测到风控,已停止"
SafetyStopReason.UNKNOWN_PAGE -> "页面无法确认,已停止"
SafetyStopReason.PAYMENT_BOUNDARY -> "检测到支付边界,已停止"
null -> "安全检查阻塞"
}
@@ -29,7 +29,8 @@ enum class WorkflowState {
enum class WorkflowFailureCode {
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,