From d013ac8c84b69ab1bcf5b2cfd5385c93049d5439 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Sat, 25 Jul 2026 18:41:16 +0800
Subject: [PATCH] feat(android): add device readiness checks
---
android-buyer/app/build.gradle.kts | 3 +
.../app/src/main/AndroidManifest.xml | 15 ++
.../java/com/roubao/autopilot/MainActivity.kt | 60 +++--
.../BuyerAccessibilityService.kt | 92 +++++++
.../readiness/DeviceObservationStore.kt | 33 +++
.../readiness/DeviceReadinessChecker.kt | 70 +++++
.../autopilot/readiness/ReadinessModels.kt | 124 +++++++++
.../ui/screens/DeviceReadinessScreen.kt | 252 ++++++++++++++++++
.../roubao/autopilot/utils/CrashHandler.kt | 8 +-
.../app/src/main/res/values-en/strings.xml | 2 +
.../app/src/main/res/values-v27/themes.xml | 13 +
.../app/src/main/res/values/strings.xml | 2 +
.../app/src/main/res/values/themes.xml | 1 -
.../res/xml/buyer_accessibility_service.xml | 9 +
.../readiness/ReadinessModelsTest.kt | 85 ++++++
docs/03-tech-stack.md | 2 +-
docs/current-state.md | 24 +-
docs/tasks/T-002.md | 109 ++++++++
progress.md | 8 +
19 files changed, 884 insertions(+), 28 deletions(-)
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceObservationStore.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceReadinessChecker.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/readiness/ReadinessModels.kt
create mode 100644 android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/DeviceReadinessScreen.kt
create mode 100644 android-buyer/app/src/main/res/values-v27/themes.xml
create mode 100644 android-buyer/app/src/main/res/xml/buyer_accessibility_service.xml
create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/readiness/ReadinessModelsTest.kt
create mode 100644 docs/tasks/T-002.md
diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts
index 5bb7554..3bc4548 100644
--- a/android-buyer/app/build.gradle.kts
+++ b/android-buyer/app/build.gradle.kts
@@ -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")
diff --git a/android-buyer/app/src/main/AndroidManifest.xml b/android-buyer/app/src/main/AndroidManifest.xml
index 0031f86..54c1d56 100644
--- a/android-buyer/app/src/main/AndroidManifest.xml
+++ b/android-buyer/app/src/main/AndroidManifest.xml
@@ -48,6 +48,8 @@
+
+
@@ -107,6 +109,19 @@
android:value="automation_overlay" />
+
+
+
+
+
+
+
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
index c0466f7..68cbb18 100644
--- a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
@@ -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(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.Home) }
+ var currentScreen by remember { mutableStateOf(Screen.Device) }
var selectedRecord by remember { mutableStateOf(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()
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt
new file mode 100644
index 0000000..f4291b1
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt
@@ -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 {
+ val texts = ArrayList()
+ val queue = ArrayDeque()
+ 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
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceObservationStore.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceObservationStore.kt
new file mode 100644
index 0000000..10a4d35
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceObservationStore.kt
@@ -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
+ )
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceReadinessChecker.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceReadinessChecker.kt
new file mode 100644
index 0000000..64f365a
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/DeviceReadinessChecker.kt
@@ -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()
+ }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/ReadinessModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/ReadinessModels.kt
new file mode 100644
index 0000000..c2668d2
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/readiness/ReadinessModels.kt
@@ -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
+ 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): 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.containsAny(markers: Collection): Boolean =
+ any { text -> markers.any(text::contains) }
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/DeviceReadinessScreen.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/DeviceReadinessScreen.kt
new file mode 100644
index 0000000..3ef1fdd
--- /dev/null
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/DeviceReadinessScreen.kt
@@ -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 -> "拼多多出现风控或安全验证"
+}
diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/utils/CrashHandler.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/utils/CrashHandler.kt
index ccda35c..42f4a19 100644
--- a/android-buyer/app/src/main/java/com/roubao/autopilot/utils/CrashHandler.kt
+++ b/android-buyer/app/src/main/java/com/roubao/autopilot/utils/CrashHandler.kt
@@ -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"
}
diff --git a/android-buyer/app/src/main/res/values-en/strings.xml b/android-buyer/app/src/main/res/values-en/strings.xml
index cf2819e..f25d406 100644
--- a/android-buyer/app/src/main/res/values-en/strings.xml
+++ b/android-buyer/app/src/main/res/values-en/strings.xml
@@ -2,6 +2,8 @@
Baozi
+ Baozi Purchasing Assistant
+ Detects Pinduoduo foreground pages and login blockers. It never submits orders or payments.
Baozi
diff --git a/android-buyer/app/src/main/res/values-v27/themes.xml b/android-buyer/app/src/main/res/values-v27/themes.xml
new file mode 100644
index 0000000..1132457
--- /dev/null
+++ b/android-buyer/app/src/main/res/values-v27/themes.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/android-buyer/app/src/main/res/values/strings.xml b/android-buyer/app/src/main/res/values/strings.xml
index 4d04d64..b8213c3 100644
--- a/android-buyer/app/src/main/res/values/strings.xml
+++ b/android-buyer/app/src/main/res/values/strings.xml
@@ -2,6 +2,8 @@
肉包
+ 肉包采购辅助
+ 检测拼多多前台页面和登录阻塞,不自动提交订单或付款。
肉包
diff --git a/android-buyer/app/src/main/res/values/themes.xml b/android-buyer/app/src/main/res/values/themes.xml
index 27136d0..2d5273b 100644
--- a/android-buyer/app/src/main/res/values/themes.xml
+++ b/android-buyer/app/src/main/res/values/themes.xml
@@ -8,7 +8,6 @@
- @color/background_card
- - false
- @color/background_dark
diff --git a/android-buyer/app/src/main/res/xml/buyer_accessibility_service.xml b/android-buyer/app/src/main/res/xml/buyer_accessibility_service.xml
new file mode 100644
index 0000000..e6b9178
--- /dev/null
+++ b/android-buyer/app/src/main/res/xml/buyer_accessibility_service.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/readiness/ReadinessModelsTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/readiness/ReadinessModelsTest.kt
new file mode 100644
index 0000000..940b449
--- /dev/null
+++ b/android-buyer/app/src/test/java/com/roubao/autopilot/readiness/ReadinessModelsTest.kt
@@ -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
+ )
+}
diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md
index 7785a24..ce9c9f8 100644
--- a/docs/03-tech-stack.md
+++ b/docs/03-tech-stack.md
@@ -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` 防止静默升级。 |
diff --git a/docs/current-state.md b/docs/current-state.md
index 7ae80d8..8d51a69 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -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 无崩溃。
## 维护规则
diff --git a/docs/tasks/T-002.md b/docs/tasks/T-002.md
new file mode 100644
index 0000000..ce05141
--- /dev/null
+++ b/docs/tasks/T-002.md
@@ -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 检查通过。
diff --git a/progress.md b/progress.md
index e72d4d2..9bd12f7 100644
--- a/progress.md
+++ b/progress.md
@@ -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 状态猜测设备是否就绪。