feat(android): add device readiness checks
This commit is contained in:
@@ -85,6 +85,9 @@ dependencies {
|
|||||||
// JSON
|
// JSON
|
||||||
implementation("org.json:json:20231013")
|
implementation("org.json:json:20231013")
|
||||||
|
|
||||||
|
// Unit tests
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
<!-- 美团 -->
|
<!-- 美团 -->
|
||||||
<package android:name="com.sankuai.meituan" />
|
<package android:name="com.sankuai.meituan" />
|
||||||
<package android:name="com.meituan.android.beam" />
|
<package android:name="com.meituan.android.beam" />
|
||||||
|
<!-- 拼多多 -->
|
||||||
|
<package android:name="com.xunmeng.pinduoduo" />
|
||||||
<!-- 饿了么 -->
|
<!-- 饿了么 -->
|
||||||
<package android:name="me.ele" />
|
<package android:name="me.ele" />
|
||||||
<!-- 大众点评 -->
|
<!-- 大众点评 -->
|
||||||
@@ -107,6 +109,19 @@
|
|||||||
android:value="automation_overlay" />
|
android:value="automation_overlay" />
|
||||||
</service>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.roubao.autopilot
|
package com.roubao.autopilot
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
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.AppScanner
|
||||||
import com.roubao.autopilot.controller.DeviceController
|
import com.roubao.autopilot.controller.DeviceController
|
||||||
import com.roubao.autopilot.data.*
|
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.screens.*
|
||||||
import com.roubao.autopilot.ui.theme.*
|
import com.roubao.autopilot.ui.theme.*
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
@@ -45,8 +49,8 @@ import android.util.Log
|
|||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
|
|
||||||
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||||
|
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
|
||||||
object Home : Screen("home", "肉包", Icons.Outlined.Home, Icons.Filled.Home)
|
object Home : Screen("home", "肉包", Icons.Outlined.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 History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
|
||||||
object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
|
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 deviceController: DeviceController
|
||||||
private lateinit var settingsManager: SettingsManager
|
private lateinit var settingsManager: SettingsManager
|
||||||
private lateinit var executionRepository: ExecutionRepository
|
private lateinit var executionRepository: ExecutionRepository
|
||||||
|
private lateinit var readinessChecker: DeviceReadinessChecker
|
||||||
|
|
||||||
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
|
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
|
||||||
private var shizukuAvailable = mutableStateOf(false)
|
private var shizukuAvailable = mutableStateOf(false)
|
||||||
|
private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty())
|
||||||
|
|
||||||
// 当前执行的协程 Job(用于停止任务)
|
// 当前执行的协程 Job(用于停止任务)
|
||||||
private var currentExecutionJob: kotlinx.coroutines.Job? = null
|
private var currentExecutionJob: kotlinx.coroutines.Job? = null
|
||||||
@@ -113,6 +119,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
deviceController.setCacheDir(cacheDir)
|
deviceController.setCacheDir(cacheDir)
|
||||||
settingsManager = SettingsManager(this)
|
settingsManager = SettingsManager(this)
|
||||||
executionRepository = ExecutionRepository(this)
|
executionRepository = ExecutionRepository(this)
|
||||||
|
readinessChecker = DeviceReadinessChecker(this)
|
||||||
|
refreshReadiness()
|
||||||
|
|
||||||
// 加载执行记录
|
// 加载执行记录
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -164,10 +172,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainApp() {
|
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 selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
|
||||||
var showShizukuHelpDialog by remember { mutableStateOf(false) }
|
var showShizukuHelpDialog by remember { mutableStateOf(false) }
|
||||||
var hasShownShizukuHelp by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val settings by settingsManager.settings.collectAsState()
|
val settings by settingsManager.settings.collectAsState()
|
||||||
val colors = BaoziTheme.colors
|
val colors = BaoziTheme.colors
|
||||||
@@ -179,6 +186,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val executing by remember { isExecuting }
|
val executing by remember { isExecuting }
|
||||||
val navigateToRecord by remember { shouldNavigateToRecord }
|
val navigateToRecord by remember { shouldNavigateToRecord }
|
||||||
val recordId by remember { currentRecordId }
|
val recordId by remember { currentRecordId }
|
||||||
|
val readiness by remember { readinessSnapshot }
|
||||||
|
|
||||||
// 监听跳转事件
|
// 监听跳转事件
|
||||||
LaunchedEffect(navigateToRecord, recordId) {
|
LaunchedEffect(navigateToRecord, recordId) {
|
||||||
@@ -193,14 +201,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次进入且 Shizuku 未连接时,显示帮助引导(只显示一次)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (!isShizukuAvailable && settings.hasSeenOnboarding && !hasShownShizukuHelp) {
|
|
||||||
hasShownShizukuHelp = true
|
|
||||||
showShizukuHelpDialog = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.background(colors.background),
|
modifier = Modifier.background(colors.background),
|
||||||
containerColor = colors.background,
|
containerColor = colors.background,
|
||||||
@@ -211,7 +211,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
contentColor = colors.textPrimary,
|
contentColor = colors.textPrimary,
|
||||||
tonalElevation = 0.dp
|
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
|
val selected = currentScreen == screen
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = {
|
icon = {
|
||||||
@@ -262,6 +262,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
label = "screen"
|
label = "screen"
|
||||||
) { screen ->
|
) { screen ->
|
||||||
when (screen) {
|
when (screen) {
|
||||||
|
Screen.Device -> DeviceReadinessScreen(
|
||||||
|
snapshot = readiness,
|
||||||
|
onRefresh = { refreshReadiness() },
|
||||||
|
onOpenAccessibilitySettings = {
|
||||||
|
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
|
},
|
||||||
|
onOpenPinduoduo = { openPinduoduo() }
|
||||||
|
)
|
||||||
Screen.Home -> {
|
Screen.Home -> {
|
||||||
// 每次进入首页都检测 Shizuku 状态
|
// 每次进入首页都检测 Shizuku 状态
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -291,7 +299,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
isExecuting = executing
|
isExecuting = executing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Screen.Capabilities -> CapabilitiesScreen()
|
|
||||||
Screen.History -> HistoryScreen(
|
Screen.History -> HistoryScreen(
|
||||||
records = records,
|
records = records,
|
||||||
onRecordClick = { record -> selectedRecord = record },
|
onRecordClick = { record -> selectedRecord = record },
|
||||||
@@ -346,6 +353,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (::readinessChecker.isInitialized) {
|
||||||
|
refreshReadiness()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Shizuku.removeBinderReceivedListener(binderReceivedListener)
|
Shizuku.removeBinderReceivedListener(binderReceivedListener)
|
||||||
@@ -354,6 +368,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
deviceController.unbindService()
|
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 {
|
private fun checkShizukuPermission(): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
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")
|
Log.d(TAG, "checkAndUpdateShizukuStatus called")
|
||||||
try {
|
try {
|
||||||
val binderAlive = Shizuku.pingBinder()
|
val binderAlive = Shizuku.pingBinder()
|
||||||
@@ -379,7 +407,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
Log.d(TAG, "Binding Shizuku service")
|
Log.d(TAG, "Binding Shizuku service")
|
||||||
deviceController.bindService()
|
deviceController.bindService()
|
||||||
} else {
|
} else if (requestPermission) {
|
||||||
Log.d(TAG, "Requesting Shizuku permission")
|
Log.d(TAG, "Requesting Shizuku permission")
|
||||||
requestShizukuPermission()
|
requestShizukuPermission()
|
||||||
}
|
}
|
||||||
@@ -396,7 +424,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private fun refreshShizukuStatus() {
|
private fun refreshShizukuStatus() {
|
||||||
Log.d(TAG, "refreshShizukuStatus called by user")
|
Log.d(TAG, "refreshShizukuStatus called by user")
|
||||||
Toast.makeText(this, "正在检查 Shizuku 状态...", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "正在检查 Shizuku 状态...", Toast.LENGTH_SHORT).show()
|
||||||
checkAndUpdateShizukuStatus()
|
checkAndUpdateShizukuStatus(requestPermission = true)
|
||||||
|
|
||||||
if (shizukuAvailable.value && checkShizukuPermission()) {
|
if (shizukuAvailable.value && checkShizukuPermission()) {
|
||||||
Toast.makeText(this, "Shizuku 已连接", Toast.LENGTH_SHORT).show()
|
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 {
|
private fun getAppVersion(context: Context): String {
|
||||||
return try {
|
return try {
|
||||||
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
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) {
|
} catch (e: Exception) {
|
||||||
"Unknown"
|
"Unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<!-- App Name -->
|
<!-- App Name -->
|
||||||
<string name="app_name">Baozi</string>
|
<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 -->
|
<!-- Tab Titles -->
|
||||||
<string name="tab_home">Baozi</string>
|
<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>
|
<resources>
|
||||||
<!-- App Name -->
|
<!-- App Name -->
|
||||||
<string name="app_name">肉包</string>
|
<string name="app_name">肉包</string>
|
||||||
|
<string name="buyer_accessibility_label">肉包采购辅助</string>
|
||||||
|
<string name="buyer_accessibility_description">检测拼多多前台页面和登录阻塞,不自动提交订单或付款。</string>
|
||||||
|
|
||||||
<!-- Tab Titles -->
|
<!-- Tab Titles -->
|
||||||
<string name="tab_home">肉包</string>
|
<string name="tab_home">肉包</string>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
<!-- 导航栏颜色 -->
|
<!-- 导航栏颜色 -->
|
||||||
<item name="android:navigationBarColor">@color/background_card</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: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 构建链 | 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 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 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`;原始数据和生成物不得提交。 |
|
| 第一层任务源 | UTF-8 四行蝦皮订单文本 + 同订单号 JPEG | 首份样例已核实,待实现 | 使用 Debug/测试专用 `TaskSource`;原始数据和生成物不得提交。 |
|
||||||
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
|
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
|
||||||
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
|
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
|
||||||
|
|||||||
+15
-9
@@ -5,22 +5,27 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-25
|
- 日期:2026-07-25
|
||||||
- 阶段:T-001 Android 可运行基线完成,准备执行 T-002 设备就绪基线
|
- 阶段:T-002 测试设备基线和就绪检查完成,准备执行 T-003
|
||||||
- Git:已初始化,当前分支为 `main`,文档和初始化脚本纳入首个基线提交
|
- Git:当前分支为 `main`;T-001 和 T-002 均已纳入 Git 历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||||
- 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入
|
- 后端:已决定使用 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、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- 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` 背景摘要和本套项目文档
|
- 版本控制内数据:只有 `deepseek总结.txt` 背景摘要和本套项目文档
|
||||||
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
|
- 本地私有样本:仓库根目录存在一组未跟踪、已本地排除的同名蝦皮文本/JPEG;
|
||||||
已核实 UTF-8 四行字段格式和图片可解码,真实内容未纳入 Git
|
已核实 UTF-8 四行字段格式和图片可解码,真实内容未纳入 Git
|
||||||
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
||||||
- 标准验证路径:`.\init.ps1`
|
- 标准验证路径:`.\init.ps1`
|
||||||
- 当前 blocker:T-002 尚未完成 Shizuku 服务/授权、目标无障碍服务和拼多多登录状态
|
- 当前 blocker:拼多多账号是否满足后续搜索流程仍需在 T-101 用页面状态确认;
|
||||||
检查;蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和
|
蝦皮样本正式私有目录、更多图片类型和一单多 SKU 规则未确认;VLM 供应商和
|
||||||
测试凭证未确认
|
测试凭证未确认
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
@@ -30,6 +35,7 @@
|
|||||||
| `AGENTS.md` | 已有 | agent 权威入口 |
|
| `AGENTS.md` | 已有 | agent 权威入口 |
|
||||||
| `docs/` | 已有 | Harness Coding 文档 |
|
| `docs/` | 已有 | Harness Coding 文档 |
|
||||||
| `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 |
|
| `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 |
|
||||||
|
| `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
| `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 |
|
| `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
|
.\init.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
2026-07-25 已在 PKG110、Android 16/API 36 上用第二条命令完成 Debug APK 安装和
|
2026-07-25 已在 PKG110、Android 16/API 36 上完成 Debug APK 更新安装和启动;
|
||||||
冷启动;画面停在预期的 Shizuku 使用指南,进程存活且 logcat 无崩溃。
|
首屏为设备就绪检查,肉包采购无障碍服务已绑定,进程存活且 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 私有
|
- 内容:完成 T-001;导入 Roubao `main@c8a6d7f`,移除不可复现的 Firebase 私有
|
||||||
配置依赖,补齐 Windows Gradle Wrapper,并在 Android 16 真机完成构建、安装和启动。
|
配置依赖,补齐 Windows Gradle Wrapper,并在 Android 16 真机完成构建、安装和启动。
|
||||||
- 影响:Phase 0 的设备、workflow 和蝦皮样本任务可以开始;无障碍分支只选择性移植。
|
- 影响:Phase 0 的设备、workflow 和蝦皮样本任务可以开始;无障碍分支只选择性移植。
|
||||||
|
|
||||||
|
## 2026-07-25 测试设备就绪基线
|
||||||
|
|
||||||
|
- 类型:阶段完成
|
||||||
|
- 内容:完成 T-002;实现肉包采购无障碍只读观察、设备/应用版本采集、前台包名和
|
||||||
|
登录/验证码/风控分类,并新增设备检查首屏。
|
||||||
|
- 影响:OnePlus PKG110 + 拼多多 8.17.0 已形成可复现基线;T-101 可以在明确安全
|
||||||
|
门禁下实现关键词搜索,不再依赖 Shizuku 状态猜测设备是否就绪。
|
||||||
|
|||||||
Reference in New Issue
Block a user