feat(android): add device readiness checks

This commit is contained in:
QiuSW
2026-07-25 18:41:16 +08:00
parent 04fa4a0994
commit d013ac8c84
19 changed files with 884 additions and 28 deletions
+3
View File
@@ -85,6 +85,9 @@ dependencies {
// JSON
implementation("org.json:json:20231013")
// Unit tests
testImplementation("junit:junit:4.13.2")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
@@ -48,6 +48,8 @@
<!-- 美团 -->
<package android:name="com.sankuai.meituan" />
<package android:name="com.meituan.android.beam" />
<!-- 拼多多 -->
<package android:name="com.xunmeng.pinduoduo" />
<!-- 饿了么 -->
<package android:name="me.ele" />
<!-- 大众点评 -->
@@ -107,6 +109,19 @@
android:value="automation_overlay" />
</service>
<service
android:name=".accessibility.BuyerAccessibilityService"
android:exported="false"
android:label="@string/buyer_accessibility_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/buyer_accessibility_service" />
</service>
</application>
</manifest>
@@ -1,5 +1,6 @@
package com.roubao.autopilot
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
@@ -30,6 +31,9 @@ import com.roubao.autopilot.agent.MobileAgent
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.data.*
import com.roubao.autopilot.readiness.DeviceReadinessChecker
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.ui.screens.*
import com.roubao.autopilot.ui.theme.*
import androidx.compose.ui.graphics.toArgb
@@ -45,8 +49,8 @@ import android.util.Log
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 Capabilities : Screen("capabilities", "能力", Icons.Outlined.Star, Icons.Filled.Star)
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
}
@@ -56,9 +60,11 @@ class MainActivity : ComponentActivity() {
private lateinit var deviceController: DeviceController
private lateinit var settingsManager: SettingsManager
private lateinit var executionRepository: ExecutionRepository
private lateinit var readinessChecker: DeviceReadinessChecker
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
private var shizukuAvailable = mutableStateOf(false)
private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty())
// 当前执行的协程 Job(用于停止任务)
private var currentExecutionJob: kotlinx.coroutines.Job? = null
@@ -113,6 +119,8 @@ class MainActivity : ComponentActivity() {
deviceController.setCacheDir(cacheDir)
settingsManager = SettingsManager(this)
executionRepository = ExecutionRepository(this)
readinessChecker = DeviceReadinessChecker(this)
refreshReadiness()
// 加载执行记录
lifecycleScope.launch {
@@ -164,10 +172,9 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp() {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var currentScreen by remember { mutableStateOf<Screen>(Screen.Device) }
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
var showShizukuHelpDialog by remember { mutableStateOf(false) }
var hasShownShizukuHelp by remember { mutableStateOf(false) }
val settings by settingsManager.settings.collectAsState()
val colors = BaoziTheme.colors
@@ -179,6 +186,7 @@ class MainActivity : ComponentActivity() {
val executing by remember { isExecuting }
val navigateToRecord by remember { shouldNavigateToRecord }
val recordId by remember { currentRecordId }
val readiness by remember { readinessSnapshot }
// 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) {
@@ -193,14 +201,6 @@ class MainActivity : ComponentActivity() {
}
}
// 首次进入且 Shizuku 未连接时,显示帮助引导(只显示一次)
LaunchedEffect(Unit) {
if (!isShizukuAvailable && settings.hasSeenOnboarding && !hasShownShizukuHelp) {
hasShownShizukuHelp = true
showShizukuHelpDialog = true
}
}
Scaffold(
modifier = Modifier.background(colors.background),
containerColor = colors.background,
@@ -211,7 +211,7 @@ class MainActivity : ComponentActivity() {
contentColor = colors.textPrimary,
tonalElevation = 0.dp
) {
listOf(Screen.Home, Screen.Capabilities, Screen.History, Screen.Settings).forEach { screen ->
listOf(Screen.Device, Screen.Home, Screen.History, Screen.Settings).forEach { screen ->
val selected = currentScreen == screen
NavigationBarItem(
icon = {
@@ -262,6 +262,14 @@ class MainActivity : ComponentActivity() {
label = "screen"
) { screen ->
when (screen) {
Screen.Device -> DeviceReadinessScreen(
snapshot = readiness,
onRefresh = { refreshReadiness() },
onOpenAccessibilitySettings = {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
},
onOpenPinduoduo = { openPinduoduo() }
)
Screen.Home -> {
// 每次进入首页都检测 Shizuku 状态
LaunchedEffect(Unit) {
@@ -291,7 +299,6 @@ class MainActivity : ComponentActivity() {
isExecuting = executing
)
}
Screen.Capabilities -> CapabilitiesScreen()
Screen.History -> HistoryScreen(
records = records,
onRecordClick = { record -> selectedRecord = record },
@@ -346,6 +353,13 @@ class MainActivity : ComponentActivity() {
}
}
override fun onResume() {
super.onResume()
if (::readinessChecker.isInitialized) {
refreshReadiness()
}
}
override fun onDestroy() {
super.onDestroy()
Shizuku.removeBinderReceivedListener(binderReceivedListener)
@@ -354,6 +368,20 @@ class MainActivity : ComponentActivity() {
deviceController.unbindService()
}
private fun refreshReadiness() {
readinessSnapshot.value = readinessChecker.snapshot()
}
private fun openPinduoduo() {
val launchIntent = packageManager.getLaunchIntentForPackage(PINDUODUO_PACKAGE)
if (launchIntent == null) {
Toast.makeText(this, "未安装拼多多", Toast.LENGTH_SHORT).show()
refreshReadiness()
return
}
startActivity(launchIntent)
}
private fun checkShizukuPermission(): Boolean {
return try {
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
@@ -365,7 +393,7 @@ class MainActivity : ComponentActivity() {
}
}
private fun checkAndUpdateShizukuStatus() {
private fun checkAndUpdateShizukuStatus(requestPermission: Boolean = false) {
Log.d(TAG, "checkAndUpdateShizukuStatus called")
try {
val binderAlive = Shizuku.pingBinder()
@@ -379,7 +407,7 @@ class MainActivity : ComponentActivity() {
if (hasPermission) {
Log.d(TAG, "Binding Shizuku service")
deviceController.bindService()
} else {
} else if (requestPermission) {
Log.d(TAG, "Requesting Shizuku permission")
requestShizukuPermission()
}
@@ -396,7 +424,7 @@ class MainActivity : ComponentActivity() {
private fun refreshShizukuStatus() {
Log.d(TAG, "refreshShizukuStatus called by user")
Toast.makeText(this, "正在检查 Shizuku 状态...", Toast.LENGTH_SHORT).show()
checkAndUpdateShizukuStatus()
checkAndUpdateShizukuStatus(requestPermission = true)
if (shizukuAvailable.value && checkShizukuPermission()) {
Toast.makeText(this, "Shizuku 已连接", Toast.LENGTH_SHORT).show()
@@ -0,0 +1,92 @@
package com.roubao.autopilot.accessibility
import android.accessibilityservice.AccessibilityService
import android.os.SystemClock
import android.util.Log
import android.view.accessibility.AccessibilityEvent
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 java.util.ArrayDeque
class BuyerAccessibilityService : AccessibilityService() {
private var lastPinduoduoScanAt = 0L
override fun onServiceConnected() {
super.onServiceConnected()
DeviceObservationStore.setAccessibilityConnected(true)
Log.i(TAG, "Buyer accessibility service connected")
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
event ?: return
val packageName = event.packageName?.toString() ?: return
if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
DeviceObservationStore.recordForeground(packageName)
}
if (packageName != PINDUODUO_PACKAGE || !shouldInspect(event.eventType)) {
return
}
val now = SystemClock.elapsedRealtime()
if (now - lastPinduoduoScanAt < MIN_SCAN_INTERVAL_MS) {
return
}
lastPinduoduoScanAt = now
val root = rootInActiveWindow ?: event.source ?: return
val visibleTexts = collectVisibleTexts(root)
val blocker = LoginBlockerDetector.detect(visibleTexts)
DeviceObservationStore.recordPinduoduoPage(
blocker = blocker,
observedAtMillis = System.currentTimeMillis()
)
Log.d(TAG, "Pinduoduo page classified as ${blocker.name}")
}
override fun onInterrupt() {
Log.w(TAG, "Buyer accessibility service interrupted")
}
override fun onDestroy() {
DeviceObservationStore.setAccessibilityConnected(false)
super.onDestroy()
}
private fun shouldInspect(eventType: Int): Boolean =
eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED ||
eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED
private fun collectVisibleTexts(root: AccessibilityNodeInfo): List<String> {
val texts = ArrayList<String>()
val queue = ArrayDeque<AccessibilityNodeInfo>()
queue.add(root)
var visited = 0
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)
for (index in 0 until node.childCount) {
node.getChild(index)?.let(queue::addLast)
}
}
return texts
}
companion object {
private const val TAG = "BuyerAccessibility"
private const val MAX_NODES = 300
private const val MIN_SCAN_INTERVAL_MS = 300L
}
}
@@ -0,0 +1,33 @@
package com.roubao.autopilot.readiness
data class DeviceObservation(
val accessibilityConnected: Boolean = false,
val foregroundPackage: String? = null,
val loginBlocker: LoginBlocker = LoginBlocker.UNKNOWN,
val pinduoduoObservedAtMillis: Long? = null
)
object DeviceObservationStore {
@Volatile
private var current = DeviceObservation()
fun snapshot(): DeviceObservation = current
@Synchronized
fun setAccessibilityConnected(connected: Boolean) {
current = current.copy(accessibilityConnected = connected)
}
@Synchronized
fun recordForeground(packageName: String) {
current = current.copy(foregroundPackage = packageName)
}
@Synchronized
fun recordPinduoduoPage(blocker: LoginBlocker, observedAtMillis: Long) {
current = current.copy(
loginBlocker = blocker,
pinduoduoObservedAtMillis = observedAtMillis
)
}
}
@@ -0,0 +1,70 @@
package com.roubao.autopilot.readiness
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.view.accessibility.AccessibilityManager
import com.roubao.autopilot.accessibility.BuyerAccessibilityService
class DeviceReadinessChecker(context: Context) {
private val appContext = context.applicationContext
private val packageManager = appContext.packageManager
fun snapshot(): DeviceReadinessSnapshot {
val observation = DeviceObservationStore.snapshot()
return DeviceReadinessSnapshot(
manufacturer = Build.MANUFACTURER,
model = Build.MODEL,
androidVersion = Build.VERSION.RELEASE,
androidApi = Build.VERSION.SDK_INT,
buyerApp = readVersion(appContext.packageName),
pinduoduo = readVersion(PINDUODUO_PACKAGE),
accessibilityEnabled = isBuyerAccessibilityEnabled(),
accessibilityConnected = observation.accessibilityConnected,
foregroundPackage = observation.foregroundPackage,
loginBlocker = observation.loginBlocker,
pinduoduoObservedAtMillis = observation.pinduoduoObservedAtMillis
)
}
private fun isBuyerAccessibilityEnabled(): Boolean {
val manager = appContext.getSystemService(AccessibilityManager::class.java)
val expectedService = BuyerAccessibilityService::class.java.name
return manager
.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
.any { info ->
info.resolveInfo.serviceInfo.packageName == appContext.packageName &&
info.resolveInfo.serviceInfo.name == expectedService
}
}
private fun readVersion(packageName: String): InstalledAppVersion =
try {
val packageInfo = getPackageInfo(packageName)
InstalledAppVersion(
installed = true,
versionName = packageInfo.versionName,
versionCode = packageInfo.longVersionCodeCompat()
)
} catch (_: PackageManager.NameNotFoundException) {
InstalledAppVersion(installed = false)
}
private fun getPackageInfo(packageName: String): PackageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, 0)
}
private fun PackageInfo.longVersionCodeCompat(): Long =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
longVersionCode
} else {
@Suppress("DEPRECATION")
versionCode.toLong()
}
}
@@ -0,0 +1,124 @@
package com.roubao.autopilot.readiness
const val PINDUODUO_PACKAGE = "com.xunmeng.pinduoduo"
data class InstalledAppVersion(
val installed: Boolean,
val versionName: String? = null,
val versionCode: Long? = null
)
enum class LoginBlocker {
NONE,
LOGIN_REQUIRED,
VERIFICATION_REQUIRED,
RISK_CONTROL,
UNKNOWN
}
enum class ReadinessBlocker {
PINDUODUO_NOT_INSTALLED,
ACCESSIBILITY_DISABLED,
ACCESSIBILITY_DISCONNECTED,
LOGIN_REQUIRED,
VERIFICATION_REQUIRED,
RISK_CONTROL
}
data class DeviceReadinessSnapshot(
val manufacturer: String,
val model: String,
val androidVersion: String,
val androidApi: Int,
val buyerApp: InstalledAppVersion,
val pinduoduo: InstalledAppVersion,
val accessibilityEnabled: Boolean,
val accessibilityConnected: Boolean,
val foregroundPackage: String?,
val loginBlocker: LoginBlocker,
val pinduoduoObservedAtMillis: Long?
) {
val blockers: List<ReadinessBlocker>
get() = buildList {
if (!pinduoduo.installed) {
add(ReadinessBlocker.PINDUODUO_NOT_INSTALLED)
}
if (!accessibilityEnabled) {
add(ReadinessBlocker.ACCESSIBILITY_DISABLED)
} else if (!accessibilityConnected) {
add(ReadinessBlocker.ACCESSIBILITY_DISCONNECTED)
}
when (loginBlocker) {
LoginBlocker.LOGIN_REQUIRED -> add(ReadinessBlocker.LOGIN_REQUIRED)
LoginBlocker.VERIFICATION_REQUIRED -> add(ReadinessBlocker.VERIFICATION_REQUIRED)
LoginBlocker.RISK_CONTROL -> add(ReadinessBlocker.RISK_CONTROL)
LoginBlocker.NONE,
LoginBlocker.UNKNOWN -> Unit
}
}
val canStartProbe: Boolean
get() = blockers.isEmpty()
companion object {
fun empty() = DeviceReadinessSnapshot(
manufacturer = "",
model = "",
androidVersion = "",
androidApi = 0,
buyerApp = InstalledAppVersion(installed = false),
pinduoduo = InstalledAppVersion(installed = false),
accessibilityEnabled = false,
accessibilityConnected = false,
foregroundPackage = null,
loginBlocker = LoginBlocker.UNKNOWN,
pinduoduoObservedAtMillis = null
)
}
}
object LoginBlockerDetector {
private val riskMarkers = listOf(
"安全验证",
"账号异常",
"操作频繁",
"存在风险",
"风险验证"
)
private val verificationMarkers = listOf(
"请输入验证码",
"获取验证码",
"短信验证码",
"滑块验证",
"拖动滑块"
)
private val loginMarkers = listOf(
"请先登录",
"登录后继续",
"手机号登录",
"密码登录",
"本机号码一键登录",
"登录/注册"
)
fun detect(visibleTexts: Collection<String>): LoginBlocker {
val normalized = visibleTexts
.asSequence()
.map(::normalize)
.filter(String::isNotEmpty)
.toList()
return when {
normalized.containsAny(riskMarkers) -> LoginBlocker.RISK_CONTROL
normalized.containsAny(verificationMarkers) -> LoginBlocker.VERIFICATION_REQUIRED
normalized.containsAny(loginMarkers) -> LoginBlocker.LOGIN_REQUIRED
else -> LoginBlocker.NONE
}
}
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,252 @@
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.Info
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import com.roubao.autopilot.readiness.LoginBlocker
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import com.roubao.autopilot.readiness.ReadinessBlocker
import com.roubao.autopilot.ui.theme.BaoziTheme
@Composable
fun DeviceReadinessScreen(
snapshot: DeviceReadinessSnapshot,
onRefresh: () -> Unit,
onOpenAccessibilitySettings: () -> Unit,
onOpenPinduoduo: () -> Unit
) {
val colors = BaoziTheme.colors
val ready = snapshot.canStartProbe
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 = if (ready) "自动化探针可以开始" else "请先处理阻塞项",
fontSize = 14.sp,
color = if (ready) colors.success else colors.error
)
Spacer(modifier = Modifier.height(20.dp))
}
item {
ReadinessRow(
title = "测试设备",
value = "${snapshot.manufacturer} ${snapshot.model} · Android ${snapshot.androidVersion} / API ${snapshot.androidApi}",
state = RowState.INFO
)
ReadinessRow(
title = "肉包 App",
value = snapshot.buyerApp.versionLabel(),
state = if (snapshot.buyerApp.installed) RowState.READY else RowState.BLOCKED
)
ReadinessRow(
title = "拼多多",
value = snapshot.pinduoduo.versionLabel(),
state = if (snapshot.pinduoduo.installed) RowState.READY else RowState.BLOCKED
)
ReadinessRow(
title = "肉包无障碍",
value = when {
!snapshot.accessibilityEnabled -> "未启用"
!snapshot.accessibilityConnected -> "已启用,服务未连接"
else -> "已启用并连接"
},
state = when {
!snapshot.accessibilityEnabled -> RowState.BLOCKED
!snapshot.accessibilityConnected -> RowState.WARNING
else -> RowState.READY
}
)
ReadinessRow(
title = "当前前台 App",
value = snapshot.foregroundPackage ?: "尚未观察",
state = if (snapshot.foregroundPackage == PINDUODUO_PACKAGE) {
RowState.READY
} else {
RowState.INFO
}
)
ReadinessRow(
title = "拼多多登录检查",
value = snapshot.loginBlocker.label(),
state = when (snapshot.loginBlocker) {
LoginBlocker.NONE -> RowState.READY
LoginBlocker.UNKNOWN -> RowState.INFO
else -> RowState.BLOCKED
}
)
}
if (snapshot.blockers.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "阻塞原因",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(6.dp))
snapshot.blockers.forEach { blocker ->
Text(
text = "· ${blocker.label()}",
fontSize = 14.sp,
color = colors.error,
modifier = Modifier.padding(vertical = 3.dp)
)
}
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = onRefresh,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = colors.primary)
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("重新检查")
}
OutlinedButton(
onClick = onOpenAccessibilitySettings,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Settings, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("无障碍设置")
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onOpenPinduoduo,
enabled = snapshot.pinduoduo.installed,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.ShoppingCart, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("打开拼多多并检查页面")
}
}
}
}
private enum class RowState {
READY,
WARNING,
BLOCKED,
INFO
}
@Composable
private fun ReadinessRow(title: String, value: String, state: RowState) {
val colors = BaoziTheme.colors
val (icon, tint) = when (state) {
RowState.READY -> Icons.Default.CheckCircle to colors.success
RowState.WARNING -> Icons.Default.Info to colors.warning
RowState.BLOCKED -> Icons.Default.Warning to colors.error
RowState.INFO -> Icons.Default.Info to colors.textSecondary
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp)
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
) {
Text(
text = title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Text(
text = value,
fontSize = 13.sp,
color = colors.textSecondary
)
}
}
Divider(color = colors.surfaceVariant)
}
private fun com.roubao.autopilot.readiness.InstalledAppVersion.versionLabel(): String =
if (!installed) {
"未安装"
} else {
"${versionName ?: "未知版本"} (${versionCode ?: "?"})"
}
private fun LoginBlocker.label(): String = when (this) {
LoginBlocker.NONE -> "未发现阻塞(不代表已登录)"
LoginBlocker.LOGIN_REQUIRED -> "检测到登录页"
LoginBlocker.VERIFICATION_REQUIRED -> "检测到验证码"
LoginBlocker.RISK_CONTROL -> "检测到风控或安全验证"
LoginBlocker.UNKNOWN -> "尚未检查"
}
private fun ReadinessBlocker.label(): String = when (this) {
ReadinessBlocker.PINDUODUO_NOT_INSTALLED -> "未安装拼多多"
ReadinessBlocker.ACCESSIBILITY_DISABLED -> "未启用肉包无障碍服务"
ReadinessBlocker.ACCESSIBILITY_DISCONNECTED -> "肉包无障碍服务尚未连接"
ReadinessBlocker.LOGIN_REQUIRED -> "拼多多要求登录"
ReadinessBlocker.VERIFICATION_REQUIRED -> "拼多多要求验证码"
ReadinessBlocker.RISK_CONTROL -> "拼多多出现风控或安全验证"
}
@@ -144,7 +144,13 @@ class CrashHandler private constructor() : Thread.UncaughtExceptionHandler {
private fun getAppVersion(context: Context): String {
return try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
"${pInfo.versionName} (${pInfo.longVersionCode})"
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pInfo.longVersionCode
} else {
@Suppress("DEPRECATION")
pInfo.versionCode.toLong()
}
"${pInfo.versionName} ($versionCode)"
} catch (e: Exception) {
"Unknown"
}
@@ -2,6 +2,8 @@
<resources>
<!-- App Name -->
<string name="app_name">Baozi</string>
<string name="buyer_accessibility_label">Baozi Purchasing Assistant</string>
<string name="buyer_accessibility_description">Detects Pinduoduo foreground pages and login blockers. It never submits orders or payments.</string>
<!-- Tab Titles -->
<string name="tab_home">Baozi</string>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Baozi" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:navigationBarColor">@color/background_card</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">@color/background_dark</item>
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/secondary</item>
</style>
</resources>
@@ -2,6 +2,8 @@
<resources>
<!-- App Name -->
<string name="app_name">肉包</string>
<string name="buyer_accessibility_label">肉包采购辅助</string>
<string name="buyer_accessibility_description">检测拼多多前台页面和登录阻塞,不自动提交订单或付款。</string>
<!-- Tab Titles -->
<string name="tab_home">肉包</string>
@@ -8,7 +8,6 @@
<!-- 导航栏颜色 -->
<item name="android:navigationBarColor">@color/background_card</item>
<item name="android:windowLightNavigationBar">false</item>
<!-- 窗口背景 -->
<item name="android:windowBackground">@color/background_dark</item>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewScrolled"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds"
android:canRetrieveWindowContent="true"
android:description="@string/buyer_accessibility_description"
android:notificationTimeout="100"
android:settingsActivity="com.roubao.autopilot.MainActivity" />
@@ -0,0 +1,85 @@
package com.roubao.autopilot.readiness
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ReadinessModelsTest {
@Test
fun `risk control takes precedence over verification and login`() {
val result = LoginBlockerDetector.detect(
listOf("手机号登录", "请输入验证码", "账号存在风险,请完成安全验证")
)
assertEquals(LoginBlocker.RISK_CONTROL, result)
}
@Test
fun `verification is distinct from login`() {
val result = LoginBlockerDetector.detect(listOf("登录后继续", "获取验证码"))
assertEquals(LoginBlocker.VERIFICATION_REQUIRED, result)
}
@Test
fun `normal page has no detected blocker`() {
val result = LoginBlockerDetector.detect(listOf("百亿补贴", "搜索商品"))
assertEquals(LoginBlocker.NONE, result)
}
@Test
fun `probe requires installed app and connected accessibility`() {
val blocked = snapshot(
pinduoduoInstalled = false,
accessibilityEnabled = false,
accessibilityConnected = false
)
assertFalse(blocked.canStartProbe)
assertEquals(
listOf(
ReadinessBlocker.PINDUODUO_NOT_INSTALLED,
ReadinessBlocker.ACCESSIBILITY_DISABLED
),
blocked.blockers
)
val ready = snapshot(
pinduoduoInstalled = true,
accessibilityEnabled = true,
accessibilityConnected = true
)
assertTrue(ready.canStartProbe)
}
@Test
fun `detected login blocker prevents probe start`() {
val snapshot = snapshot(
pinduoduoInstalled = true,
accessibilityEnabled = true,
accessibilityConnected = true
).copy(loginBlocker = LoginBlocker.LOGIN_REQUIRED)
assertFalse(snapshot.canStartProbe)
assertEquals(listOf(ReadinessBlocker.LOGIN_REQUIRED), snapshot.blockers)
}
private fun snapshot(
pinduoduoInstalled: Boolean,
accessibilityEnabled: Boolean,
accessibilityConnected: Boolean
) = DeviceReadinessSnapshot(
manufacturer = "test",
model = "device",
androidVersion = "16",
androidApi = 36,
buyerApp = InstalledAppVersion(true, "1.4.2", 7),
pinduoduo = InstalledAppVersion(pinduoduoInstalled, "8.17.0", 81700),
accessibilityEnabled = accessibilityEnabled,
accessibilityConnected = accessibilityConnected,
foregroundPackage = null,
loginBlocker = LoginBlocker.UNKNOWN,
pinduoduoObservedAtMillis = null
)
}
+1 -1
View File
@@ -11,7 +11,7 @@
| Android 构建链 | Gradle 8.2;AGP 8.2.0;Kotlin 1.9.20;JVM target 17 | 已验证 | 已补齐上游缺失的 `gradlew.bat`,不依赖全局 Gradle。 |
| Android SDK | compileSdk/targetSdk 34;minSdk 26;SDK Build Tools 34.0.0 | 已验证 | 支持 Android 8.0+;本机使用 Command-line Tools 22.0。 |
| Android UI | Jetpack Compose + Material 3;Compose Compiler 1.5.5 | 上游已核实 | Compose BOM 为 2023.10.01。 |
| Android 自动化 | 项目目标以 `AccessibilityService` 为主,Shizuku 为兼容/增强路径 | 目标已定,待实现 | 上游 `main` 使用 Shizuku 13.1.5;无障碍实现位于独立开发分支,不能把它误认为主分支现状。 |
| Android 自动化 | 项目目标以 `AccessibilityService` 为主,Shizuku 为兼容/增强路径 | 就绪观察基线已实现,动作待实现 | T-002 已实现只读前台/登录阻塞观察;搜索、点击和安全动作门禁由 T-101 开始实现。上游 `main` 仍保留 Shizuku 13.1.5。 |
| 第一层任务源 | UTF-8 四行蝦皮订单文本 + 同订单号 JPEG | 首份样例已核实,待实现 | 使用 Debug/测试专用 `TaskSource`;原始数据和生成物不得提交。 |
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
+15 -9
View File
@@ -5,22 +5,27 @@
## 当前快照
- 日期:2026-07-25
- 阶段:T-001 Android 可运行基线完成,准备执行 T-002 设备就绪基线
- Git:已初始化,当前分支为 `main`,文档和初始化脚本纳入首个基线提交
- 阶段:T-002 测试设备基线和就绪检查完成,准备执行 T-003
- Git:当前分支为 `main`;T-001 和 T-002 均已纳入 Git 历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:Gradle `test` 成功;上游没有 `test/androidTest` 测试源
- 测试:`lintDebug test assembleDebug` 成功;T-002 新增 5 个纯 Kotlin 测试,
Debug/Release 两个变体共执行 10 次且全部通过
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
`8.17.0 (81700)`
- 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、
验证码或风控文案,但该结果不等于账号已确认登录
- 版本控制内数据:只有 `deepseek总结.txt` 背景摘要和本套项目文档
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
已核实 UTF-8 四行字段格式和图片可解码,真实内容未纳入 Git
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1`
- 当前 blocker:T-002 尚未完成 Shizuku 服务/授权、目标无障碍服务和拼多多登录状态
检查;蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和
- 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认;
蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和
测试凭证未确认
## 当前目录
@@ -30,6 +35,7 @@
| `AGENTS.md` | 已有 | agent 权威入口 |
| `docs/` | 已有 | Harness Coding 文档 |
| `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 |
| `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
| `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 |
@@ -37,9 +43,9 @@
## 任务摘要
- 已完成:T-001 初始化 Git 并接入 Roubao Android 基线。
- 已完成:T-001 Android 基线;T-002 测试设备基线和就绪检查。
- 正在进行:无。
- 下一个可领取任务:T-002 建立测试设备基线和就绪检查。
- 下一个可领取任务:T-003 建立 Android workflow 测试骨架。
## 当前可运行内容
@@ -50,8 +56,8 @@ $env:RUN_START_COMMAND = "1"
.\init.ps1
```
2026-07-25 已在 PKG110、Android 16/API 36 上用第二条命令完成 Debug APK 安装和
冷启动;画面停在预期的 Shizuku 使用指南,进程存活且 logcat 无崩溃。
2026-07-25 已在 PKG110、Android 16/API 36 上完成 Debug APK 更新安装和启动;
首屏为设备就绪检查,肉包采购无障碍服务已绑定,进程存活且 logcat 无崩溃。
## 维护规则
+109
View File
@@ -0,0 +1,109 @@
---
id: T-002
title: 建立测试设备基线和就绪检查
phase: 0
deps:
- T-001
status: DONE
created: 2026-07-25
context_ref: 04fa4a099465a74d68a6aae6c23df0c556d9e7cf
work_branch: main
write_paths:
- android-buyer/app/build.gradle.kts
- android-buyer/app/src/main/AndroidManifest.xml
- 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/readiness/**
- android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/DeviceReadinessScreen.kt
- android-buyer/app/src/main/java/com/roubao/autopilot/utils/CrashHandler.kt
- android-buyer/app/src/main/res/values/strings.xml
- android-buyer/app/src/main/res/values-en/strings.xml
- android-buyer/app/src/main/res/values/themes.xml
- android-buyer/app/src/main/res/values-v27/themes.xml
- android-buyer/app/src/main/res/xml/buyer_accessibility_service.xml
- android-buyer/app/src/test/**
- docs/current-state.md
- docs/03-tech-stack.md
- docs/tasks/T-002.md
- progress.md
---
## 问题 / 背景
T-001 证明 Android 工程能够构建和启动,但 App 仍只判断 Shizuku,无法回答采购流程
开始前最重要的几个问题:拼多多是否安装、肉包无障碍是否启用、当前前台 App 是什么,
以及拼多多页面是否出现登录、验证码或风控阻塞。没有统一快照时,后续搜索探针只能在
未知设备状态下失败。
## 关联需求与交互
- 功能:F-003 采购 App 与设备就绪检查。
- 用户故事:US-003 安全领取下一条任务、US-006 安全失败和可恢复。
- 交互:Android“设备”页。
- 架构:Android 自动化适配器与本地诊断层。
## 方案
1. 建立不依赖 Android 框架的就绪模型和登录阻塞分类器。
2. 增加最小 `AccessibilityService`,只读取拼多多页面文本,不记录原文,也不执行
点击;限制节点遍历数量。
3. Android 状态采集器读取系统/App/拼多多版本、无障碍启用和连接状态、最近前台
包名及最近一次拼多多阻塞分类。
4. 新增“设备”页作为 App 首屏,提供刷新、打开无障碍设置和打开拼多多入口。
5. 使用本地 JVM 测试覆盖阻塞优先级和就绪原因,使用真实设备验证安装、启用、前台
App 变化和无崩溃。
## 验收要点
- [x] 记录测试设备 Android 版本、拼多多版本和肉包 App 版本。
- [x] App 能明确显示拼多多是否安装及版本。
- [x] App 能区分肉包无障碍“未启用”“已启用未连接”“已连接”。
- [x] App 能显示无障碍观察到的当前前台包名。
- [x] 拼多多页面出现登录、验证码或风控文本时返回可区分的阻塞类型。
- [x] 页面文本不写入日志、任务文档或 Git。
- [x] 登录阻塞判定和开始条件有本地 JVM 测试。
- [x] Debug APK 构建、测试和真实设备 smoke 通过。
## 边界
- 不执行拼多多搜索、点击、滑动或候选采集。
- 不尝试绕过登录、验证码或风控。
- 不把“没有发现登录文案”等同于账号一定已登录。
- 不移除现有 Shizuku 路径;本任务只建立采购主路径所需的无障碍基线。
## 执行记录
### 2026-07-25:任务开始
- 基于 T-001 提交 `04fa4a0` 开始。
- 已知测试设备为 OnePlus PKG110、Android 16/API 36;拼多多已安装,无障碍服务
尚未实现。
### 2026-07-25:实现和验证完成
- 新增 `BuyerAccessibilityService`。服务只遍历拼多多可访问节点,单次最多 300 个,
仅保存 `NONE`、`LOGIN_REQUIRED`、`VERIFICATION_REQUIRED`、`RISK_CONTROL`
分类和观察时间;页面原文不写入日志或持久化。
- 新增纯 Kotlin 就绪模型和分类器,以及 Android 状态采集器;开始条件要求拼多多
已安装、肉包无障碍已启用并连接,且没有已识别的登录/验证码/风控阻塞。
- 新增“设备”首屏,展示设备、肉包、拼多多、无障碍、当前前台 App 和登录阻塞,
提供刷新、无障碍设置和打开拼多多入口。查看检查页时前台必然是肉包,因此前台
包名只作为事实展示,不作为设备基线阻塞。
- 停止 App 启动时自动弹出 Shizuku 授权;Shizuku 仍保留为兼容路径,只在用户进入
原有功能并主动刷新时申请。
- 修复导入基线的两个 minSdk lint 错误:API 26/27 安全读取版本号,并把 API 27
导航栏主题属性放入限定资源。
- 真机基线:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
`8.17.0 (81700)`。
- 真机先验证“未启用”阻塞,再在保留 Microsoft 投屏无障碍服务的情况下启用肉包
服务;系统显示服务已绑定且无崩溃。强制停止时观察到“已启用未连接”,重新切换
服务后恢复“已启用并连接”。
- 打开拼多多后系统前台为 `com.xunmeng.pinduoduo/.ui.activity.HomeActivity`,
观察器返回 `NONE`;返回肉包后界面显示
`未发现阻塞(不代表已登录)`,没有把该结果当作登录确认。
- `.\gradlew.bat lintDebug test assembleDebug --no-daemon`:成功。
- 本地 JVM 测试按 Debug/Release 两个变体执行共 10 次,0 failure、0 error、
0 skipped。
- 最新 Debug APK 大小为 10,402,303 字节,SHA-256 为
`464382CE98604FF0E81F592307660C399D018A14106639B3D25E246949CF225B`;
更新安装、Activity 启动、服务重连和 logcat crash 检查通过。
+8
View File
@@ -45,3 +45,11 @@
- 内容:完成 T-001;导入 Roubao `main@c8a6d7f`,移除不可复现的 Firebase 私有
配置依赖,补齐 Windows Gradle Wrapper,并在 Android 16 真机完成构建、安装和启动。
- 影响:Phase 0 的设备、workflow 和蝦皮样本任务可以开始;无障碍分支只选择性移植。
## 2026-07-25 测试设备就绪基线
- 类型:阶段完成
- 内容:完成 T-002;实现肉包采购无障碍只读观察、设备/应用版本采集、前台包名和
登录/验证码/风控分类,并新增设备检查首屏。
- 影响:OnePlus PKG110 + 拼多多 8.17.0 已形成可复现基线;T-101 可以在明确安全
门禁下实现关键词搜索,不再依赖 Shizuku 状态猜测设备是否就绪。