feat(android): add Pinduoduo search probe
This commit is contained in:
@@ -42,15 +42,23 @@ import com.roubao.autopilot.vlm.GUIOwlClient
|
||||
import com.roubao.autopilot.vlm.MAIUIClient
|
||||
import com.roubao.autopilot.vlm.VLMClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import rikka.shizuku.Shizuku
|
||||
import android.util.Log
|
||||
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoUiDriver
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
|
||||
import com.roubao.autopilot.workflow.WorkflowReport
|
||||
import com.roubao.autopilot.workflow.WorkflowRunner
|
||||
import com.roubao.autopilot.workflow.WorkflowState
|
||||
|
||||
private const val TAG = "MainActivity"
|
||||
|
||||
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
|
||||
object Home : Screen("home", "肉包", Icons.Outlined.Home, Icons.Filled.Home)
|
||||
object Home : Screen("home", "探针", Icons.Outlined.Search, Icons.Filled.Search)
|
||||
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
|
||||
object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
|
||||
}
|
||||
@@ -65,6 +73,11 @@ class MainActivity : ComponentActivity() {
|
||||
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
|
||||
private var shizukuAvailable = mutableStateOf(false)
|
||||
private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty())
|
||||
private val searchProbeState = mutableStateOf(WorkflowState.IDLE)
|
||||
private val searchProbeStepId = mutableStateOf<String?>(null)
|
||||
private val searchProbeReport = mutableStateOf<WorkflowReport?>(null)
|
||||
private var searchProbeRunner: WorkflowRunner? = null
|
||||
private var searchProbeJob: Job? = null
|
||||
|
||||
// 当前执行的协程 Job(用于停止任务)
|
||||
private var currentExecutionJob: kotlinx.coroutines.Job? = null
|
||||
@@ -172,21 +185,19 @@ class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainApp() {
|
||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.Device) }
|
||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
|
||||
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
|
||||
var showShizukuHelpDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val settings by settingsManager.settings.collectAsState()
|
||||
val colors = BaoziTheme.colors
|
||||
val agent = mobileAgent.value
|
||||
val agentState by agent?.state?.collectAsState() ?: remember { mutableStateOf(null) }
|
||||
val logs by agent?.logs?.collectAsState() ?: remember { mutableStateOf(emptyList<String>()) }
|
||||
val records by remember { executionRecords }
|
||||
val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission()
|
||||
val executing by remember { isExecuting }
|
||||
val navigateToRecord by remember { shouldNavigateToRecord }
|
||||
val recordId by remember { currentRecordId }
|
||||
val readiness by remember { readinessSnapshot }
|
||||
val probeState by remember { searchProbeState }
|
||||
val probeStepId by remember { searchProbeStepId }
|
||||
val probeReport by remember { searchProbeReport }
|
||||
|
||||
// 监听跳转事件
|
||||
LaunchedEffect(navigateToRecord, recordId) {
|
||||
@@ -211,7 +222,7 @@ class MainActivity : ComponentActivity() {
|
||||
contentColor = colors.textPrimary,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
listOf(Screen.Device, Screen.Home, Screen.History, Screen.Settings).forEach { screen ->
|
||||
listOf(Screen.Home, Screen.Device, Screen.History, Screen.Settings).forEach { screen ->
|
||||
val selected = currentScreen == screen
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
@@ -270,35 +281,14 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onOpenPinduoduo = { openPinduoduo() }
|
||||
)
|
||||
Screen.Home -> {
|
||||
// 每次进入首页都检测 Shizuku 状态
|
||||
LaunchedEffect(Unit) {
|
||||
checkAndUpdateShizukuStatus()
|
||||
}
|
||||
HomeScreen(
|
||||
agentState = agentState,
|
||||
logs = logs,
|
||||
onExecute = { instruction ->
|
||||
runAgent(
|
||||
instruction = instruction,
|
||||
apiKey = settings.apiKey,
|
||||
baseUrl = settings.baseUrl,
|
||||
model = settings.model,
|
||||
maxSteps = settings.maxSteps,
|
||||
isGUIAgent = settings.currentProvider.isGUIAgent,
|
||||
providerId = settings.currentProviderId
|
||||
)
|
||||
},
|
||||
onStop = {
|
||||
mobileAgent.value?.stop()
|
||||
},
|
||||
shizukuAvailable = isShizukuAvailable,
|
||||
currentModel = settings.model,
|
||||
onRefreshShizuku = { refreshShizukuStatus() },
|
||||
onShizukuRequired = { showShizukuHelpDialog = true },
|
||||
isExecuting = executing
|
||||
)
|
||||
}
|
||||
Screen.Home -> SearchProbeScreen(
|
||||
readiness = readiness,
|
||||
state = probeState,
|
||||
currentStepId = probeStepId,
|
||||
report = probeReport,
|
||||
onStart = { startSearchProbe() },
|
||||
onStop = { stopSearchProbe() }
|
||||
)
|
||||
Screen.History -> HistoryScreen(
|
||||
records = records,
|
||||
onRecordClick = { record -> selectedRecord = record },
|
||||
@@ -340,10 +330,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Shizuku 帮助对话框
|
||||
if (showShizukuHelpDialog) {
|
||||
ShizukuHelpDialog(onDismiss = { showShizukuHelpDialog = false })
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteRecord(id: String) {
|
||||
@@ -357,10 +343,15 @@ class MainActivity : ComponentActivity() {
|
||||
super.onResume()
|
||||
if (::readinessChecker.isInitialized) {
|
||||
refreshReadiness()
|
||||
lifecycleScope.launch {
|
||||
delay(READINESS_RECHECK_DELAY_MS)
|
||||
refreshReadiness()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
searchProbeRunner?.requestStop()
|
||||
super.onDestroy()
|
||||
Shizuku.removeBinderReceivedListener(binderReceivedListener)
|
||||
Shizuku.removeBinderDeadListener(binderDeadListener)
|
||||
@@ -382,6 +373,48 @@ class MainActivity : ComponentActivity() {
|
||||
startActivity(launchIntent)
|
||||
}
|
||||
|
||||
private fun startSearchProbe() {
|
||||
val readiness = readinessChecker.snapshot()
|
||||
readinessSnapshot.value = readiness
|
||||
if (!readiness.canStartProbe) {
|
||||
Toast.makeText(this, "设备检查存在阻塞项", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (searchProbeJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
val runner = WorkflowRunner(
|
||||
PinduoduoSearchAutomation(AndroidPinduoduoUiDriver(this))
|
||||
)
|
||||
searchProbeRunner = runner
|
||||
searchProbeReport.value = null
|
||||
searchProbeState.value = WorkflowState.IDLE
|
||||
searchProbeStepId.value = null
|
||||
searchProbeJob = lifecycleScope.launch {
|
||||
val stateCollector = launch {
|
||||
runner.state.collect { state -> searchProbeState.value = state }
|
||||
}
|
||||
val stepCollector = launch {
|
||||
runner.currentStepId.collect { stepId -> searchProbeStepId.value = stepId }
|
||||
}
|
||||
try {
|
||||
val report = runner.run(PinduoduoSearchWorkflow.steps())
|
||||
searchProbeReport.value = report
|
||||
searchProbeState.value = report.state
|
||||
} finally {
|
||||
stateCollector.cancel()
|
||||
stepCollector.cancel()
|
||||
searchProbeRunner = null
|
||||
refreshReadiness()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSearchProbe() {
|
||||
searchProbeRunner?.requestStop()
|
||||
}
|
||||
|
||||
private fun checkShizukuPermission(): Boolean {
|
||||
return try {
|
||||
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
@@ -460,6 +493,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READINESS_RECHECK_DELAY_MS = 750L
|
||||
}
|
||||
|
||||
private fun runAgent(
|
||||
instruction: String,
|
||||
apiKey: String,
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.roubao.autopilot.accessibility
|
||||
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoPage
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
|
||||
import com.roubao.autopilot.readiness.DeviceObservationStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object BuyerAccessibilityBridge {
|
||||
@Volatile
|
||||
private var service: BuyerAccessibilityService? = null
|
||||
|
||||
fun attach(connectedService: BuyerAccessibilityService) {
|
||||
service = connectedService
|
||||
}
|
||||
|
||||
fun detach(disconnectedService: BuyerAccessibilityService) {
|
||||
if (service === disconnectedService) {
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshot(): PinduoduoUiSnapshot = withContext(Dispatchers.Main.immediate) {
|
||||
service?.snapshotPinduoduoUi()
|
||||
?: PinduoduoUiSnapshot(
|
||||
foregroundPackage = DeviceObservationStore.snapshot().foregroundPackage,
|
||||
page = PinduoduoPage.UNKNOWN,
|
||||
safetyStopReason = null
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun openSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
|
||||
service?.clickPinduoduoSearchEntry() == true
|
||||
}
|
||||
|
||||
suspend fun setSearchKeyword(keyword: String): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.setPinduoduoSearchKeyword(keyword) == true
|
||||
}
|
||||
|
||||
suspend fun isSearchKeywordSet(keyword: String): Boolean =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
service?.isPinduoduoSearchKeywordSet(keyword) == true
|
||||
}
|
||||
|
||||
suspend fun submitSearch(): Boolean = withContext(Dispatchers.Main.immediate) {
|
||||
service?.submitPinduoduoSearch() == true
|
||||
}
|
||||
}
|
||||
+176
-7
@@ -1,6 +1,7 @@
|
||||
package com.roubao.autopilot.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
@@ -8,14 +9,21 @@ import android.view.accessibility.AccessibilityNodeInfo
|
||||
import com.roubao.autopilot.readiness.DeviceObservationStore
|
||||
import com.roubao.autopilot.readiness.LoginBlockerDetector
|
||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoPageClassifier
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoUiElement
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoUiSnapshot
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoPage
|
||||
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
||||
import java.util.ArrayDeque
|
||||
|
||||
class BuyerAccessibilityService : AccessibilityService() {
|
||||
private var lastPinduoduoScanAt = 0L
|
||||
private var expectedSearchQuery = SEARCH_PROBE_KEYWORD
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
DeviceObservationStore.setAccessibilityConnected(true)
|
||||
BuyerAccessibilityBridge.attach(this)
|
||||
Log.i(TAG, "Buyer accessibility service connected")
|
||||
}
|
||||
|
||||
@@ -52,10 +60,140 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
BuyerAccessibilityBridge.detach(this)
|
||||
DeviceObservationStore.setAccessibilityConnected(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
internal fun snapshotPinduoduoUi(): PinduoduoUiSnapshot {
|
||||
val root = rootInActiveWindow
|
||||
val foregroundPackage = root?.packageName?.toString()
|
||||
?: DeviceObservationStore.snapshot().foregroundPackage
|
||||
if (foregroundPackage == PINDUODUO_PACKAGE && root != null) {
|
||||
return classifyPinduoduoRoot(root)
|
||||
}
|
||||
return PinduoduoPageClassifier.classify(
|
||||
foregroundPackage = foregroundPackage,
|
||||
elements = emptyList(),
|
||||
expectedQuery = expectedSearchQuery
|
||||
)
|
||||
}
|
||||
|
||||
internal fun clickPinduoduoSearchEntry(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.HOME
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
val candidates = collectNodes(root).filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
node.className?.toString()?.endsWith("TextView") == true &&
|
||||
node.contentDescription?.toString()?.trim() == "搜索"
|
||||
}
|
||||
candidates.singleOrNull()?.let(::clickNodeOrAncestor) == true
|
||||
} ?: false
|
||||
|
||||
internal fun setPinduoduoSearchKeyword(keyword: String): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (
|
||||
snapshot.safetyStopReason != null ||
|
||||
snapshot.page != PinduoduoPage.SEARCH_INPUT
|
||||
) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
val inputs = collectNodes(root).filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
(node.isEditable ||
|
||||
node.className?.toString()?.endsWith("EditText") == true) &&
|
||||
node.contentDescription?.toString()?.trim() == "搜索"
|
||||
}
|
||||
val input = inputs.singleOrNull() ?: return@withPinduoduoRoot false
|
||||
|
||||
input.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||
val arguments = Bundle().apply {
|
||||
putCharSequence(
|
||||
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
|
||||
keyword
|
||||
)
|
||||
}
|
||||
val changed = input.performAction(
|
||||
AccessibilityNodeInfo.ACTION_SET_TEXT,
|
||||
arguments
|
||||
)
|
||||
if (changed) {
|
||||
expectedSearchQuery = keyword
|
||||
}
|
||||
changed
|
||||
} ?: false
|
||||
|
||||
internal fun isPinduoduoSearchKeywordSet(keyword: String): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
collectNodes(root)
|
||||
.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
(node.isEditable ||
|
||||
node.className?.toString()?.endsWith("EditText") == true) &&
|
||||
node.contentDescription?.toString()?.trim() == "搜索"
|
||||
}
|
||||
.singleOrNull()
|
||||
?.text
|
||||
?.toString() == keyword
|
||||
} ?: false
|
||||
|
||||
internal fun submitPinduoduoSearch(): Boolean =
|
||||
withPinduoduoRoot { root ->
|
||||
val snapshot = classifyPinduoduoRoot(root)
|
||||
if (snapshot.safetyStopReason != null) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) {
|
||||
return@withPinduoduoRoot true
|
||||
}
|
||||
if (snapshot.page != PinduoduoPage.SEARCH_INPUT) {
|
||||
return@withPinduoduoRoot false
|
||||
}
|
||||
val nodes = collectNodes(root)
|
||||
val submitButtons = nodes.filter { node ->
|
||||
node.isVisibleToUser &&
|
||||
node.isEnabled &&
|
||||
node.isClickable &&
|
||||
node.className?.toString()?.endsWith("TextView") == true &&
|
||||
node.text?.toString()?.trim() == "搜索"
|
||||
}
|
||||
submitButtons.singleOrNull()?.performAction(
|
||||
AccessibilityNodeInfo.ACTION_CLICK
|
||||
) == true
|
||||
} ?: false
|
||||
|
||||
private fun classifyPinduoduoRoot(
|
||||
root: AccessibilityNodeInfo
|
||||
): PinduoduoUiSnapshot {
|
||||
val elements = collectNodes(root).map { node ->
|
||||
PinduoduoUiElement(
|
||||
text = node.text?.toString(),
|
||||
contentDescription = node.contentDescription?.toString(),
|
||||
className = node.className?.toString().orEmpty(),
|
||||
resourceId = node.viewIdResourceName,
|
||||
clickable = node.isClickable,
|
||||
editable = node.isEditable,
|
||||
enabled = node.isEnabled,
|
||||
visibleToUser = node.isVisibleToUser
|
||||
)
|
||||
}
|
||||
return PinduoduoPageClassifier.classify(
|
||||
foregroundPackage = root.packageName?.toString(),
|
||||
elements = elements,
|
||||
expectedQuery = expectedSearchQuery
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldInspect(eventType: Int): Boolean =
|
||||
eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
|
||||
eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED ||
|
||||
@@ -63,6 +201,18 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
|
||||
private fun collectVisibleTexts(root: AccessibilityNodeInfo): List<String> {
|
||||
val texts = ArrayList<String>()
|
||||
collectNodes(root).forEach { node ->
|
||||
node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add)
|
||||
node.contentDescription
|
||||
?.toString()
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let(texts::add)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
private fun collectNodes(root: AccessibilityNodeInfo): List<AccessibilityNodeInfo> {
|
||||
val nodes = ArrayList<AccessibilityNodeInfo>()
|
||||
val queue = ArrayDeque<AccessibilityNodeInfo>()
|
||||
queue.add(root)
|
||||
var visited = 0
|
||||
@@ -70,23 +220,42 @@ class BuyerAccessibilityService : AccessibilityService() {
|
||||
while (queue.isNotEmpty() && visited < MAX_NODES) {
|
||||
val node = queue.removeFirst()
|
||||
visited += 1
|
||||
|
||||
node.text?.toString()?.takeIf(String::isNotBlank)?.let(texts::add)
|
||||
node.contentDescription
|
||||
?.toString()
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let(texts::add)
|
||||
nodes += node
|
||||
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let(queue::addLast)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
return nodes
|
||||
}
|
||||
|
||||
private fun <T> withPinduoduoRoot(block: (AccessibilityNodeInfo) -> T): T? {
|
||||
val root = rootInActiveWindow ?: return null
|
||||
if (root.packageName?.toString() != PINDUODUO_PACKAGE) {
|
||||
return null
|
||||
}
|
||||
return block(root)
|
||||
}
|
||||
|
||||
private fun clickNodeOrAncestor(node: AccessibilityNodeInfo): Boolean {
|
||||
var candidate: AccessibilityNodeInfo? = node
|
||||
repeat(MAX_CLICK_ANCESTORS) {
|
||||
val current = candidate ?: return false
|
||||
if (
|
||||
current.isClickable &&
|
||||
current.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
candidate = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BuyerAccessibility"
|
||||
private const val MAX_NODES = 300
|
||||
private const val MAX_CLICK_ANCESTORS = 4
|
||||
private const val MIN_SCAN_INTERVAL_MS = 300L
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.roubao.autopilot.pinduoduo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge
|
||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class AndroidPinduoduoUiDriver(context: Context) : PinduoduoUiDriver {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override suspend fun openApp(): Boolean {
|
||||
val intent = appContext.packageManager.getLaunchIntentForPackage(PINDUODUO_PACKAGE)
|
||||
?: return false
|
||||
intent.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
|
||||
)
|
||||
appContext.startActivity(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun snapshot(): PinduoduoUiSnapshot =
|
||||
BuyerAccessibilityBridge.snapshot()
|
||||
|
||||
override suspend fun openSearch(): Boolean =
|
||||
BuyerAccessibilityBridge.openSearch()
|
||||
|
||||
override suspend fun setSearchKeyword(keyword: String): Boolean {
|
||||
if (!BuyerAccessibilityBridge.setSearchKeyword(keyword)) {
|
||||
return false
|
||||
}
|
||||
repeat(KEYWORD_VERIFY_ATTEMPTS) {
|
||||
delay(KEYWORD_VERIFY_INTERVAL_MS)
|
||||
if (BuyerAccessibilityBridge.isSearchKeywordSet(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override suspend fun submitSearch(): Boolean =
|
||||
BuyerAccessibilityBridge.submitSearch()
|
||||
|
||||
private companion object {
|
||||
const val KEYWORD_VERIFY_ATTEMPTS = 5
|
||||
const val KEYWORD_VERIFY_INTERVAL_MS = 100L
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.roubao.autopilot.pinduoduo
|
||||
|
||||
import com.roubao.autopilot.readiness.LoginBlocker
|
||||
import com.roubao.autopilot.readiness.LoginBlockerDetector
|
||||
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
|
||||
import com.roubao.autopilot.workflow.SafetyStopReason
|
||||
|
||||
data class PinduoduoUiElement(
|
||||
val text: String?,
|
||||
val contentDescription: String?,
|
||||
val className: String,
|
||||
val resourceId: String?,
|
||||
val clickable: Boolean,
|
||||
val editable: Boolean,
|
||||
val enabled: Boolean,
|
||||
val visibleToUser: Boolean
|
||||
)
|
||||
|
||||
enum class PinduoduoPage {
|
||||
HOME,
|
||||
SEARCH_INPUT,
|
||||
SEARCH_RESULTS,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
data class PinduoduoUiSnapshot(
|
||||
val foregroundPackage: String?,
|
||||
val page: PinduoduoPage,
|
||||
val safetyStopReason: SafetyStopReason?
|
||||
)
|
||||
|
||||
object PinduoduoPageClassifier {
|
||||
private val paymentMarkers = listOf(
|
||||
"确认订单",
|
||||
"提交订单",
|
||||
"确认支付",
|
||||
"立即支付",
|
||||
"收银台"
|
||||
)
|
||||
|
||||
fun classify(
|
||||
foregroundPackage: String?,
|
||||
elements: Collection<PinduoduoUiElement>,
|
||||
expectedQuery: String = SEARCH_PROBE_KEYWORD
|
||||
): PinduoduoUiSnapshot {
|
||||
if (foregroundPackage != PINDUODUO_PACKAGE) {
|
||||
return PinduoduoUiSnapshot(
|
||||
foregroundPackage = foregroundPackage,
|
||||
page = PinduoduoPage.UNKNOWN,
|
||||
safetyStopReason = null
|
||||
)
|
||||
}
|
||||
|
||||
val visibleElements = elements.filter { it.visibleToUser && it.enabled }
|
||||
val visibleTexts = visibleElements.flatMap { element ->
|
||||
listOfNotNull(element.text, element.contentDescription)
|
||||
}
|
||||
val safetyStop = when (LoginBlockerDetector.detect(visibleTexts)) {
|
||||
LoginBlocker.LOGIN_REQUIRED -> SafetyStopReason.LOGIN_REQUIRED
|
||||
LoginBlocker.VERIFICATION_REQUIRED -> SafetyStopReason.VERIFICATION_REQUIRED
|
||||
LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL
|
||||
LoginBlocker.NONE,
|
||||
LoginBlocker.UNKNOWN -> {
|
||||
if (visibleTexts.containsAny(paymentMarkers)) {
|
||||
SafetyStopReason.PAYMENT_BOUNDARY
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val normalized = visibleTexts.map(::normalize)
|
||||
val hasSearchInput = visibleElements.any { element ->
|
||||
(element.editable || element.className.endsWith("EditText")) &&
|
||||
normalize(element.contentDescription.orEmpty()) == "搜索"
|
||||
}
|
||||
val hasSubmitButton = visibleElements.any { element ->
|
||||
element.className.endsWith("TextView") &&
|
||||
element.clickable &&
|
||||
normalize(element.text.orEmpty()) == "搜索"
|
||||
}
|
||||
val hasHomeSearchEntry = visibleElements.any { element ->
|
||||
element.className.endsWith("TextView") &&
|
||||
normalize(element.contentDescription.orEmpty()) == "搜索"
|
||||
}
|
||||
val hasHome = normalized.any { it == "首页" }
|
||||
val hasResultSearchHeader = visibleElements.any { element ->
|
||||
element.className.endsWith("HorizontalScrollView") &&
|
||||
normalize(element.contentDescription.orEmpty()) == "搜索"
|
||||
}
|
||||
val sortControlCount = setOf("综合", "销量", "价格", "筛选")
|
||||
.count { marker -> normalized.any { it == marker } }
|
||||
val hasExactQuery = visibleTexts.any { it.trim() == expectedQuery }
|
||||
|
||||
val page = when {
|
||||
hasExactQuery && hasResultSearchHeader && sortControlCount >= 3 ->
|
||||
PinduoduoPage.SEARCH_RESULTS
|
||||
hasSearchInput && hasSubmitButton -> PinduoduoPage.SEARCH_INPUT
|
||||
hasHomeSearchEntry && hasHome -> PinduoduoPage.HOME
|
||||
else -> PinduoduoPage.UNKNOWN
|
||||
}
|
||||
return PinduoduoUiSnapshot(
|
||||
foregroundPackage = foregroundPackage,
|
||||
page = page,
|
||||
safetyStopReason = safetyStop
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalize(value: String): String =
|
||||
value.lowercase().replace(Regex("\\s+"), "")
|
||||
|
||||
private fun Collection<String>.containsAny(markers: Collection<String>): Boolean =
|
||||
any { text -> markers.any(text::contains) }
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.roubao.autopilot.pinduoduo
|
||||
|
||||
import com.roubao.autopilot.workflow.AutomationGateway
|
||||
import com.roubao.autopilot.workflow.AutomationResult
|
||||
import com.roubao.autopilot.workflow.SafetyStopReason
|
||||
import com.roubao.autopilot.workflow.WorkflowFailureCode
|
||||
import com.roubao.autopilot.workflow.WorkflowStep
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
const val SEARCH_PROBE_KEYWORD = "手机支架"
|
||||
|
||||
interface PinduoduoUiDriver {
|
||||
suspend fun openApp(): Boolean
|
||||
suspend fun snapshot(): PinduoduoUiSnapshot
|
||||
suspend fun openSearch(): Boolean
|
||||
suspend fun setSearchKeyword(keyword: String): Boolean
|
||||
suspend fun submitSearch(): Boolean
|
||||
}
|
||||
|
||||
class PinduoduoSearchAutomation(
|
||||
private val driver: PinduoduoUiDriver,
|
||||
private val keyword: String = SEARCH_PROBE_KEYWORD,
|
||||
private val pollIntervalMillis: Long = 200,
|
||||
private val unknownPageLimit: Int = 10
|
||||
) : AutomationGateway {
|
||||
init {
|
||||
require(keyword.isNotBlank()) { "Search keyword must not be blank" }
|
||||
require(pollIntervalMillis > 0) { "Poll interval must be positive" }
|
||||
require(unknownPageLimit > 0) { "Unknown page limit must be positive" }
|
||||
}
|
||||
|
||||
override suspend fun execute(step: WorkflowStep): AutomationResult =
|
||||
when (step.id) {
|
||||
PinduoduoSearchWorkflow.OPEN_APP -> openApp()
|
||||
PinduoduoSearchWorkflow.ENTER_QUERY -> enterQuery()
|
||||
PinduoduoSearchWorkflow.SUBMIT_QUERY -> submitQuery()
|
||||
PinduoduoSearchWorkflow.VERIFY_RESULTS -> verifyResults()
|
||||
else -> AutomationResult.FatalFailure(WorkflowFailureCode.AUTOMATION_EXCEPTION)
|
||||
}
|
||||
|
||||
private suspend fun openApp(): AutomationResult {
|
||||
if (!driver.openApp()) {
|
||||
return AutomationResult.FatalFailure(WorkflowFailureCode.TARGET_NOT_READY)
|
||||
}
|
||||
return awaitPage {
|
||||
it == PinduoduoPage.HOME ||
|
||||
it == PinduoduoPage.SEARCH_INPUT ||
|
||||
it == PinduoduoPage.SEARCH_RESULTS
|
||||
} ?: AutomationResult.Success
|
||||
}
|
||||
|
||||
private suspend fun enterQuery(): AutomationResult {
|
||||
val ready = awaitPage {
|
||||
it == PinduoduoPage.HOME ||
|
||||
it == PinduoduoPage.SEARCH_INPUT ||
|
||||
it == PinduoduoPage.SEARCH_RESULTS
|
||||
}
|
||||
if (ready != null) {
|
||||
return ready
|
||||
}
|
||||
|
||||
val currentPage = driver.snapshot().page
|
||||
if (currentPage == PinduoduoPage.SEARCH_RESULTS) {
|
||||
return AutomationResult.Success
|
||||
}
|
||||
if (currentPage != PinduoduoPage.SEARCH_INPUT) {
|
||||
if (!driver.openSearch()) {
|
||||
return AutomationResult.RetryableFailure(
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION
|
||||
)
|
||||
}
|
||||
val searchInput = awaitPage { it == PinduoduoPage.SEARCH_INPUT }
|
||||
if (searchInput != null) {
|
||||
return searchInput
|
||||
}
|
||||
}
|
||||
|
||||
return if (driver.setSearchKeyword(keyword)) {
|
||||
AutomationResult.Success
|
||||
} else {
|
||||
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun submitQuery(): AutomationResult {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
if (snapshot.page == PinduoduoPage.SEARCH_RESULTS) {
|
||||
return AutomationResult.Success
|
||||
}
|
||||
return if (driver.submitSearch()) {
|
||||
AutomationResult.Success
|
||||
} else {
|
||||
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verifyResults(): AutomationResult =
|
||||
awaitPage { it == PinduoduoPage.SEARCH_RESULTS } ?: AutomationResult.Success
|
||||
|
||||
private suspend fun awaitPage(
|
||||
expected: (PinduoduoPage) -> Boolean
|
||||
): AutomationResult? {
|
||||
var stableUnknownObservations = 0
|
||||
while (true) {
|
||||
val snapshot = driver.snapshot()
|
||||
safetyResult(snapshot)?.let { return it }
|
||||
if (expected(snapshot.page)) {
|
||||
return null
|
||||
}
|
||||
|
||||
stableUnknownObservations = if (
|
||||
snapshot.foregroundPackage == com.roubao.autopilot.readiness.PINDUODUO_PACKAGE &&
|
||||
snapshot.page == PinduoduoPage.UNKNOWN
|
||||
) {
|
||||
stableUnknownObservations + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (stableUnknownObservations >= unknownPageLimit) {
|
||||
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
|
||||
}
|
||||
delay(pollIntervalMillis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun safetyResult(snapshot: PinduoduoUiSnapshot): AutomationResult.Blocked? =
|
||||
snapshot.safetyStopReason?.let(AutomationResult::Blocked)
|
||||
}
|
||||
|
||||
object PinduoduoSearchWorkflow {
|
||||
const val OPEN_APP = "pdd_open_app"
|
||||
const val ENTER_QUERY = "pdd_enter_query"
|
||||
const val SUBMIT_QUERY = "pdd_submit_query"
|
||||
const val VERIFY_RESULTS = "pdd_verify_results"
|
||||
|
||||
fun steps(): List<WorkflowStep> = listOf(
|
||||
WorkflowStep(OPEN_APP, timeoutMillis = 8_000, maxRetries = 1),
|
||||
WorkflowStep(ENTER_QUERY, timeoutMillis = 10_000, maxRetries = 1),
|
||||
WorkflowStep(SUBMIT_QUERY, timeoutMillis = 6_000, maxRetries = 1),
|
||||
WorkflowStep(VERIFY_RESULTS, timeoutMillis = 12_000, maxRetries = 1)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.roubao.autopilot.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
|
||||
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||
import com.roubao.autopilot.ui.theme.BaoziTheme
|
||||
import com.roubao.autopilot.workflow.SafetyStopReason
|
||||
import com.roubao.autopilot.workflow.WorkflowFailureCode
|
||||
import com.roubao.autopilot.workflow.WorkflowReport
|
||||
import com.roubao.autopilot.workflow.WorkflowState
|
||||
|
||||
private data class ProbeStepUi(
|
||||
val id: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
private val probeSteps = listOf(
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.OPEN_APP, "打开拼多多"),
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.ENTER_QUERY, "输入固定关键词"),
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.SUBMIT_QUERY, "提交搜索"),
|
||||
ProbeStepUi(PinduoduoSearchWorkflow.VERIFY_RESULTS, "确认结果页")
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SearchProbeScreen(
|
||||
readiness: DeviceReadinessSnapshot,
|
||||
state: WorkflowState,
|
||||
currentStepId: String?,
|
||||
report: WorkflowReport?,
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
val active = state == WorkflowState.RUNNING || state == WorkflowState.RETRYING
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "拼多多搜索探针",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = stateLabel(state, report),
|
||||
fontSize = 14.sp,
|
||||
color = stateColor(state)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = colors.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Text(
|
||||
text = "固定关键词",
|
||||
fontSize = 13.sp,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = SEARCH_PROBE_KEYWORD,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider(color = colors.surfaceVariant)
|
||||
}
|
||||
|
||||
items(probeSteps.size) { index ->
|
||||
val step = probeSteps[index]
|
||||
ProbeStepRow(
|
||||
label = step.label,
|
||||
state = stepState(
|
||||
stepId = step.id,
|
||||
workflowState = state,
|
||||
currentStepId = currentStepId,
|
||||
report = report
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (active) {
|
||||
OutlinedButton(
|
||||
onClick = onStop,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("停止探针")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onStart,
|
||||
enabled = readiness.canStartProbe,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = colors.primary)
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("开始搜索探针")
|
||||
}
|
||||
}
|
||||
if (!readiness.canStartProbe) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "设备检查存在阻塞项",
|
||||
fontSize = 13.sp,
|
||||
color = colors.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ProbeStepState {
|
||||
WAITING,
|
||||
ACTIVE,
|
||||
COMPLETE,
|
||||
FAILED
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProbeStepRow(label: String, state: ProbeStepState) {
|
||||
val colors = BaoziTheme.colors
|
||||
val icon: ImageVector
|
||||
val tint: Color
|
||||
val status: String
|
||||
when (state) {
|
||||
ProbeStepState.WAITING -> {
|
||||
icon = Icons.Default.Info
|
||||
tint = colors.textHint
|
||||
status = "等待"
|
||||
}
|
||||
ProbeStepState.ACTIVE -> {
|
||||
icon = Icons.Default.PlayArrow
|
||||
tint = colors.primary
|
||||
status = "执行中"
|
||||
}
|
||||
ProbeStepState.COMPLETE -> {
|
||||
icon = Icons.Default.CheckCircle
|
||||
tint = colors.success
|
||||
status = "完成"
|
||||
}
|
||||
ProbeStepState.FAILED -> {
|
||||
icon = Icons.Default.Warning
|
||||
tint = colors.error
|
||||
status = "停止"
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(58.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = status,
|
||||
fontSize = 13.sp,
|
||||
color = tint
|
||||
)
|
||||
}
|
||||
Divider(color = colors.surfaceVariant)
|
||||
}
|
||||
|
||||
private fun stepState(
|
||||
stepId: String,
|
||||
workflowState: WorkflowState,
|
||||
currentStepId: String?,
|
||||
report: WorkflowReport?
|
||||
): ProbeStepState = when {
|
||||
report?.completedStepIds?.contains(stepId) == true -> ProbeStepState.COMPLETE
|
||||
stepId == currentStepId &&
|
||||
workflowState in setOf(
|
||||
WorkflowState.FAILED,
|
||||
WorkflowState.BLOCKED,
|
||||
WorkflowState.STOPPED
|
||||
) -> ProbeStepState.FAILED
|
||||
stepId == currentStepId -> ProbeStepState.ACTIVE
|
||||
else -> ProbeStepState.WAITING
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun stateColor(state: WorkflowState): Color {
|
||||
val colors = BaoziTheme.colors
|
||||
return when (state) {
|
||||
WorkflowState.SUCCEEDED -> colors.success
|
||||
WorkflowState.FAILED,
|
||||
WorkflowState.BLOCKED -> colors.error
|
||||
WorkflowState.RETRYING -> colors.warning
|
||||
WorkflowState.RUNNING -> colors.primary
|
||||
WorkflowState.IDLE,
|
||||
WorkflowState.STOPPED -> colors.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateLabel(state: WorkflowState, report: WorkflowReport?): String =
|
||||
when (state) {
|
||||
WorkflowState.IDLE -> "等待开始"
|
||||
WorkflowState.RUNNING -> "正在执行"
|
||||
WorkflowState.RETRYING -> "正在重试"
|
||||
WorkflowState.SUCCEEDED -> "搜索结果页已确认"
|
||||
WorkflowState.STOPPED -> "已由用户停止"
|
||||
WorkflowState.FAILED -> failureLabel(report?.failureCode)
|
||||
WorkflowState.BLOCKED -> blockedLabel(report?.safetyStopReason)
|
||||
}
|
||||
|
||||
private fun failureLabel(code: WorkflowFailureCode?): String = when (code) {
|
||||
WorkflowFailureCode.TIMEOUT -> "执行超时"
|
||||
WorkflowFailureCode.TRANSIENT_AUTOMATION -> "页面操作失败"
|
||||
WorkflowFailureCode.AUTOMATION_EXCEPTION -> "自动化执行异常"
|
||||
WorkflowFailureCode.TARGET_NOT_READY -> "拼多多无法启动"
|
||||
null -> "执行失败"
|
||||
}
|
||||
|
||||
private fun blockedLabel(reason: SafetyStopReason?): String = when (reason) {
|
||||
SafetyStopReason.LOGIN_REQUIRED -> "检测到登录要求,已停止"
|
||||
SafetyStopReason.VERIFICATION_REQUIRED -> "检测到验证码,已停止"
|
||||
SafetyStopReason.RISK_CONTROL -> "检测到风控,已停止"
|
||||
SafetyStopReason.UNKNOWN_PAGE -> "页面无法确认,已停止"
|
||||
SafetyStopReason.PAYMENT_BOUNDARY -> "检测到支付边界,已停止"
|
||||
null -> "安全检查阻塞"
|
||||
}
|
||||
@@ -29,7 +29,8 @@ enum class WorkflowState {
|
||||
enum class WorkflowFailureCode {
|
||||
TIMEOUT,
|
||||
TRANSIENT_AUTOMATION,
|
||||
AUTOMATION_EXCEPTION
|
||||
AUTOMATION_EXCEPTION,
|
||||
TARGET_NOT_READY
|
||||
}
|
||||
|
||||
enum class SafetyStopReason {
|
||||
|
||||
@@ -17,6 +17,7 @@ class WorkflowRunner(
|
||||
) {
|
||||
private val running = AtomicBoolean(false)
|
||||
private val mutableState = MutableStateFlow(WorkflowState.IDLE)
|
||||
private val mutableCurrentStepId = MutableStateFlow<String?>(null)
|
||||
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
@@ -25,12 +26,14 @@ class WorkflowRunner(
|
||||
private var currentExecution: Job? = null
|
||||
|
||||
val state: StateFlow<WorkflowState> = mutableState.asStateFlow()
|
||||
val currentStepId: StateFlow<String?> = mutableCurrentStepId.asStateFlow()
|
||||
|
||||
suspend fun run(steps: List<WorkflowStep>): WorkflowReport {
|
||||
require(steps.isNotEmpty()) { "Workflow must contain at least one step" }
|
||||
check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" }
|
||||
|
||||
stopRequested = false
|
||||
mutableCurrentStepId.value = null
|
||||
val completedSteps = mutableListOf<String>()
|
||||
val attempts = linkedMapOf<String, Int>()
|
||||
val transitions = mutableListOf<WorkflowTransition>()
|
||||
@@ -197,6 +200,7 @@ class WorkflowRunner(
|
||||
) {
|
||||
val previous = mutableState.value
|
||||
mutableState.value = next
|
||||
mutableCurrentStepId.value = stepId
|
||||
transitions += WorkflowTransition(
|
||||
from = previous,
|
||||
to = next,
|
||||
|
||||
Reference in New Issue
Block a user