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