feat(android): add device readiness checks
This commit is contained in:
@@ -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()
|
||||
|
||||
+92
@@ -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
|
||||
}
|
||||
}
|
||||
+33
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -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) }
|
||||
}
|
||||
+252
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 无崩溃。
|
||||
|
||||
## 维护规则
|
||||
|
||||
|
||||
@@ -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 检查通过。
|
||||
@@ -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 状态猜测设备是否就绪。
|
||||
|
||||
Reference in New Issue
Block a user