feat(android): add device readiness checks
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user