package com.roubao.autopilot import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.lifecycleScope import android.net.Uri import android.provider.Settings 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 import androidx.core.view.WindowCompat import com.roubao.autopilot.vlm.GUIOwlClient import com.roubao.autopilot.vlm.MAIUIClient import com.roubao.autopilot.vlm.VLMClient import com.roubao.autopilot.task.RequirementProbeSource import com.roubao.autopilot.vlm.AndroidRequirementVlmGateway import com.roubao.autopilot.vlm.RequirementExtraction import com.roubao.autopilot.vlm.RequirementExtractionFailureCode import com.roubao.autopilot.vlm.RequirementExtractionInput import com.roubao.autopilot.vlm.RequirementExtractionResult import com.roubao.autopilot.vlm.RequirementExtractor import com.roubao.autopilot.vlm.RequirementProbeState import com.roubao.autopilot.vlm.RequirementProviderEndpointPolicy import com.roubao.autopilot.vlm.AndroidCandidateEvaluationVlmGateway import com.roubao.autopilot.vlm.CandidateBatchConclusion import com.roubao.autopilot.vlm.CandidateEvaluationFailureCode import com.roubao.autopilot.vlm.CandidateEvaluationImage import com.roubao.autopilot.vlm.CandidateEvaluationInput import com.roubao.autopilot.vlm.CandidateEvaluationResult import com.roubao.autopilot.vlm.CandidateEvaluationState import com.roubao.autopilot.vlm.CandidateEvaluator import com.roubao.autopilot.vlm.CandidateReviewBatch import com.roubao.autopilot.vlm.CandidateHumanReviewPolicy import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import rikka.shizuku.Shizuku import android.util.Log import com.roubao.autopilot.pinduoduo.AndroidPinduoduoUiDriver import com.roubao.autopilot.pinduoduo.AndroidPinduoduoCandidateDriver import com.roubao.autopilot.pinduoduo.PinduoduoCandidateAutomation import com.roubao.autopilot.pinduoduo.CandidateBrowsePhase import com.roubao.autopilot.pinduoduo.PinduoduoCandidateEvidence import com.roubao.autopilot.pinduoduo.PinduoduoCandidateWorkflow import com.roubao.autopilot.pinduoduo.PinduoduoProbeAutomation import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD import com.roubao.autopilot.workflow.WorkflowReport import com.roubao.autopilot.workflow.WorkflowRunner import com.roubao.autopilot.workflow.WorkflowState 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.Search, Icons.Filled.Search) object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List) object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings) } class MainActivity : ComponentActivity() { private lateinit var deviceController: DeviceController private lateinit var settingsManager: SettingsManager private lateinit var executionRepository: ExecutionRepository private lateinit var readinessChecker: DeviceReadinessChecker private lateinit var requirementProbeSource: RequirementProbeSource private lateinit var candidateEvidenceSource: CandidateEvidenceSource private val mobileAgent = mutableStateOf(null) private var shizukuAvailable = mutableStateOf(false) private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty()) private val searchProbeState = mutableStateOf(WorkflowState.IDLE) private val searchProbeStepId = mutableStateOf(null) private val searchProbeReport = mutableStateOf(null) private val candidateEvidence = mutableStateOf>(emptyList()) private val candidateSearchKeyword = mutableStateOf(null) private val candidateRequirementSnapshot = mutableStateOf(null) private val requirementProbeState = mutableStateOf(RequirementProbeState.IDLE) private val requirementExtraction = mutableStateOf(null) private val requirementFailureCode = mutableStateOf(null) private val candidateEvaluationState = mutableStateOf(CandidateEvaluationState.IDLE) private val candidateReviewBatch = mutableStateOf(null) private val candidateEvaluationFailureCode = mutableStateOf(null) private var searchProbeRunner: WorkflowRunner? = null private var searchProbeJob: Job? = null private var requirementProbeJob: Job? = null private var candidateEvaluationJob: Job? = null // 当前执行的协程 Job(用于停止任务) private var currentExecutionJob: kotlinx.coroutines.Job? = null // 执行记录列表 private val executionRecords = mutableStateOf>(emptyList()) // 是否正在执行(点击发送后立即为 true) private val isExecuting = mutableStateOf(false) // 当前执行的记录 ID(用于停止后跳转) private val currentRecordId = mutableStateOf(null) // 是否需要跳转到记录详情(悬浮窗停止后触发) private val shouldNavigateToRecord = mutableStateOf(false) private val binderReceivedListener = Shizuku.OnBinderReceivedListener { Log.d(TAG, "Shizuku binder received") shizukuAvailable.value = true if (checkShizukuPermission()) { Log.d(TAG, "Shizuku permission granted, binding service") deviceController.bindService() } else { Log.d(TAG, "Shizuku permission not granted") } } private val binderDeadListener = Shizuku.OnBinderDeadListener { Log.d(TAG, "Shizuku binder dead") shizukuAvailable.value = false } private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> Log.d(TAG, "Shizuku permission result: $grantResult") if (grantResult == PackageManager.PERMISSION_GRANTED) { deviceController.bindService() Toast.makeText(this, "Shizuku 权限已获取", Toast.LENGTH_SHORT).show() } } override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) // 设置边到边显示,深色状态栏和导航栏 enableEdgeToEdge( statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) ) deviceController = DeviceController(this) deviceController.setCacheDir(cacheDir) settingsManager = SettingsManager(this) executionRepository = ExecutionRepository(this) readinessChecker = DeviceReadinessChecker(this) requirementProbeSource = RequirementProbeSource(this) candidateEvidenceSource = CandidateEvidenceSource(this) refreshReadiness() // 加载执行记录 lifecycleScope.launch { executionRecords.value = executionRepository.getAllRecords() } // 添加 Shizuku 监听器 Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) Shizuku.addRequestPermissionResultListener(permissionResultListener) // 检查 Shizuku 状态 checkAndUpdateShizukuStatus() // 预加载已安装应用 lifecycleScope.launch(Dispatchers.IO) { AppScanner(this@MainActivity).getApps() } setContent { val settings by settingsManager.settings.collectAsState() BaoziTheme(themeMode = settings.themeMode) { val colors = BaoziTheme.colors // 动态更新系统栏颜色 SideEffect { val window = this@MainActivity.window window.statusBarColor = colors.background.toArgb() window.navigationBarColor = colors.backgroundCard.toArgb() WindowCompat.getInsetsController(window, window.decorView).apply { isAppearanceLightStatusBars = !colors.isDark isAppearanceLightNavigationBars = !colors.isDark } } // 首次启动显示引导画面 if (!settings.hasSeenOnboarding) { OnboardingScreen( onComplete = { settingsManager.setOnboardingSeen() } ) } else { MainApp() } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainApp() { var currentScreen by remember { mutableStateOf(Screen.Home) } var selectedRecord by remember { mutableStateOf(null) } val settings by settingsManager.settings.collectAsState() val colors = BaoziTheme.colors val records by remember { executionRecords } val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission() val navigateToRecord by remember { shouldNavigateToRecord } val recordId by remember { currentRecordId } val readiness by remember { readinessSnapshot } val probeState by remember { searchProbeState } val probeStepId by remember { searchProbeStepId } val probeReport by remember { searchProbeReport } val evidence by remember { candidateEvidence } val evidenceSearchKeyword by remember { candidateSearchKeyword } val evidenceRequirement by remember { candidateRequirementSnapshot } val extractionState by remember { requirementProbeState } val extraction by remember { requirementExtraction } val extractionFailure by remember { requirementFailureCode } val evaluationState by remember { candidateEvaluationState } val reviewBatch by remember { candidateReviewBatch } val evaluationFailure by remember { candidateEvaluationFailureCode } // 监听跳转事件 LaunchedEffect(navigateToRecord, recordId) { if (navigateToRecord && recordId != null) { // 找到对应的记录并跳转 val record = records.find { it.id == recordId } if (record != null) { selectedRecord = record currentScreen = Screen.History } shouldNavigateToRecord.value = false } } Scaffold( modifier = Modifier.background(colors.background), containerColor = colors.background, bottomBar = { if (selectedRecord == null) { NavigationBar( containerColor = colors.background, contentColor = colors.textPrimary, tonalElevation = 0.dp ) { listOf(Screen.Home, Screen.Device, Screen.History, Screen.Settings).forEach { screen -> val selected = currentScreen == screen NavigationBarItem( icon = { Icon( imageVector = if (selected) screen.selectedIcon else screen.icon, contentDescription = screen.title ) }, label = { Text(screen.title) }, selected = selected, onClick = { currentScreen = screen }, colors = NavigationBarItemDefaults.colors( selectedIconColor = if (colors.isDark) colors.textPrimary else Color.White, selectedTextColor = colors.primary, unselectedIconColor = colors.textSecondary, unselectedTextColor = colors.textSecondary, indicatorColor = colors.primary ) ) } } } } ) { padding -> Box( modifier = Modifier .fillMaxSize() .padding(padding) ) { // 处理系统返回手势 BackHandler(enabled = selectedRecord != null) { selectedRecord = null } // 详情页优先显示 if (selectedRecord != null) { HistoryDetailScreen( record = selectedRecord!!, onBack = { selectedRecord = null } ) } else { // 主页面切换 AnimatedContent( targetState = currentScreen, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "screen" ) { screen -> when (screen) { Screen.Device -> DeviceReadinessScreen( snapshot = readiness, onRefresh = { refreshReadiness() }, onOpenAccessibilitySettings = { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }, onOpenPinduoduo = { openPinduoduo() } ) Screen.Home -> SearchProbeScreen( readiness = readiness, state = probeState, currentStepId = probeStepId, report = probeReport, candidateEvidenceCount = evidence.size, searchKeyword = evidenceSearchKeyword ?: extraction?.searchQuery ?: SEARCH_PROBE_KEYWORD, usesRequirementSearch = evidenceRequirement != null || extractionState == RequirementProbeState.READY, requirementState = extractionState, requirement = extraction, requirementFailureCode = extractionFailure, candidateEvaluationState = evaluationState, candidateReviewBatch = reviewBatch, candidateEvaluationFailureCode = evaluationFailure, canStartCandidateEvaluation = extractionState == RequirementProbeState.READY && extraction != null && evidenceRequirement == extraction && evidenceSearchKeyword == extraction?.searchQuery && probeReport?.state == WorkflowState.SUCCEEDED && evidence.isNotEmpty(), onStartRequirement = { startRequirementProbe() }, onStopRequirement = { stopRequirementProbe() }, onStartCandidateEvaluation = { startCandidateEvaluation() }, onStopCandidateEvaluation = { stopCandidateEvaluation() }, onAcceptCandidate = { acceptRecommendedCandidate() }, onRejectCandidates = { rejectCandidateReview() }, onStart = { startSearchProbe() }, onStop = { stopSearchProbe() } ) Screen.History -> HistoryScreen( records = records, onRecordClick = { record -> selectedRecord = record }, onDeleteRecord = { id -> deleteRecord(id) } ) Screen.Settings -> SettingsScreen( settings = settings, onUpdateApiKey = { settingsManager.updateApiKey(it) }, onUpdateBaseUrl = { settingsManager.updateBaseUrl(it) }, onUpdateModel = { settingsManager.updateModel(it) }, onUpdateCachedModels = { settingsManager.updateCachedModels(it) }, onUpdateThemeMode = { settingsManager.updateThemeMode(it) }, onUpdateMaxSteps = { settingsManager.updateMaxSteps(it) }, onUpdateRootModeEnabled = { settingsManager.updateRootModeEnabled(it) }, onUpdateSuCommandEnabled = { settingsManager.updateSuCommandEnabled(it) }, onSelectProvider = { settingsManager.selectProvider(it) }, shizukuAvailable = isShizukuAvailable, shizukuPrivilegeLevel = if (isShizukuAvailable) { when (deviceController.getShizukuPrivilegeLevel()) { DeviceController.ShizukuPrivilegeLevel.ROOT -> "ROOT" DeviceController.ShizukuPrivilegeLevel.ADB -> "ADB" else -> "NONE" } } else "NONE", onFetchModels = { onSuccess, onError -> lifecycleScope.launch { val result = VLMClient.fetchModels(settings.baseUrl, settings.apiKey) result.onSuccess { models -> onSuccess(models) }.onFailure { error -> onError(error.message ?: "未知错误") } } } ) } } } } } } private fun deleteRecord(id: String) { lifecycleScope.launch { executionRepository.deleteRecord(id) executionRecords.value = executionRepository.getAllRecords() } } override fun onResume() { super.onResume() if (::readinessChecker.isInitialized) { refreshReadiness() lifecycleScope.launch { delay(READINESS_RECHECK_DELAY_MS) refreshReadiness() } } } override fun onDestroy() { searchProbeRunner?.requestStop() requirementProbeJob?.cancel() candidateEvaluationJob?.cancel() super.onDestroy() Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderDeadListener(binderDeadListener) Shizuku.removeRequestPermissionResultListener(permissionResultListener) 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 startSearchProbe() { val readiness = readinessChecker.snapshot() readinessSnapshot.value = readiness if (!readiness.canStartProbe) { Toast.makeText(this, "设备检查存在阻塞项", Toast.LENGTH_SHORT).show() return } if (searchProbeJob?.isActive == true) { return } if (candidateEvaluationJob?.isActive == true) { Toast.makeText(this, "请先停止候选评估", Toast.LENGTH_SHORT).show() return } val boundRequirement = requirementExtraction.value?.takeIf { requirementProbeState.value == RequirementProbeState.READY && !it.manualReviewRequired } val searchKeyword = boundRequirement?.searchQuery ?: SEARCH_PROBE_KEYWORD val candidateAutomation = PinduoduoCandidateAutomation( AndroidPinduoduoCandidateDriver(this) ) candidateAutomation.reset() val runner = WorkflowRunner( PinduoduoProbeAutomation( searchAutomation = PinduoduoSearchAutomation( driver = AndroidPinduoduoUiDriver(this), keyword = searchKeyword, forceKeywordEntry = boundRequirement != null ), candidateAutomation = candidateAutomation ) ) searchProbeRunner = runner searchProbeReport.value = null searchProbeState.value = WorkflowState.IDLE searchProbeStepId.value = null candidateEvidence.value = emptyList() candidateSearchKeyword.value = searchKeyword candidateRequirementSnapshot.value = boundRequirement clearCandidateEvaluation() searchProbeJob = lifecycleScope.launch { val stateCollector = launch { runner.state.collect { state -> searchProbeState.value = state } } val stepCollector = launch { runner.currentStepId.collect { stepId -> searchProbeStepId.value = stepId } } val evidenceCollector = launch { candidateAutomation.evidence.collect { evidence -> candidateEvidence.value = evidence } } val candidatePhaseCollector = launch { candidateAutomation.phase.collect { phase -> if (phase != CandidateBrowsePhase.IDLE) { Log.d(TAG, "Candidate probe phase: ${phase.name}") } } } try { val report = runner.run(PinduoduoCandidateWorkflow.steps()) searchProbeReport.value = report searchProbeState.value = report.state } finally { stateCollector.cancel() stepCollector.cancel() evidenceCollector.cancel() candidatePhaseCollector.cancel() searchProbeRunner = null refreshReadiness() } } } private fun stopSearchProbe() { searchProbeRunner?.requestStop() } private fun startRequirementProbe() { if (requirementProbeJob?.isActive == true) { return } if (searchProbeJob?.isActive == true) { Toast.makeText(this, "请先停止候选探针", Toast.LENGTH_SHORT).show() return } if (candidateEvaluationJob?.isActive == true) { Toast.makeText(this, "请先停止候选评估", Toast.LENGTH_SHORT).show() return } candidateEvidence.value = emptyList() candidateSearchKeyword.value = null candidateRequirementSnapshot.value = null searchProbeReport.value = null clearCandidateEvaluation() requirementExtraction.value = null requirementFailureCode.value = null val settings = settingsManager.settings.value val provider = settings.currentProvider if (!provider.supportsRequirementExtraction) { setRequirementFailure(RequirementExtractionFailureCode.PROVIDER_UNSUPPORTED) return } if (settings.baseUrl.isBlank() || settings.model.isBlank()) { setRequirementFailure(RequirementExtractionFailureCode.PROVIDER_NOT_CONFIGURED) return } if ( !RequirementProviderEndpointPolicy.isAllowed( baseUrl = settings.baseUrl, apiKey = settings.apiKey ) ) { setRequirementFailure(RequirementExtractionFailureCode.UNSAFE_PROVIDER_ENDPOINT) return } if ( settings.apiKey.isNotBlank() && !settingsManager.isSecureCredentialStorageAvailable ) { setRequirementFailure( RequirementExtractionFailureCode.SECURE_CREDENTIAL_STORAGE_UNAVAILABLE ) return } if (provider.id != ApiProvider.CUSTOM.id && settings.apiKey.isBlank()) { setRequirementFailure(RequirementExtractionFailureCode.PROVIDER_NOT_CONFIGURED) return } requirementProbeState.value = RequirementProbeState.RUNNING requirementExtraction.value = null requirementFailureCode.value = null requirementProbeJob = lifecycleScope.launch { try { val fixture = requirementProbeSource.loadFirst().getOrElse { setRequirementFailure(RequirementExtractionFailureCode.SOURCE_UNAVAILABLE) return@launch } ?: run { setRequirementFailure(RequirementExtractionFailureCode.SOURCE_UNAVAILABLE) return@launch } val client = VLMClient( apiKey = settings.apiKey, baseUrl = settings.baseUrl, model = settings.model ) val result = RequirementExtractor( gateway = AndroidRequirementVlmGateway(client), providerId = provider.id, model = settings.model ).extract( RequirementExtractionInput.from( task = fixture.task, imageBytes = fixture.referenceImageBytes ) ) when (result) { is RequirementExtractionResult.Completed -> { requirementExtraction.value = result.extraction requirementProbeState.value = if (result.extraction.manualReviewRequired) { RequirementProbeState.MANUAL_REVIEW } else { RequirementProbeState.READY } } is RequirementExtractionResult.Failed -> { setRequirementFailure(result.code) } } } catch (error: CancellationException) { requirementProbeState.value = RequirementProbeState.STOPPED throw error } finally { requirementProbeJob = null } } } private fun stopRequirementProbe() { requirementProbeJob?.cancel() } private fun setRequirementFailure(code: RequirementExtractionFailureCode) { requirementExtraction.value = null requirementFailureCode.value = code requirementProbeState.value = RequirementProbeState.FAILED } private fun startCandidateEvaluation() { if (candidateEvaluationJob?.isActive == true) { return } if (searchProbeJob?.isActive == true || requirementProbeJob?.isActive == true) { Toast.makeText(this, "请先停止其他探针", Toast.LENGTH_SHORT).show() return } val requirement = requirementExtraction.value if ( requirement == null || requirementProbeState.value != RequirementProbeState.READY || requirement.manualReviewRequired ) { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.REQUIREMENT_NOT_READY ) return } val evidence = candidateEvidence.value if ( searchProbeReport.value?.state != WorkflowState.SUCCEEDED || evidence.isEmpty() ) { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.EVIDENCE_UNAVAILABLE ) return } if ( candidateRequirementSnapshot.value != requirement || candidateSearchKeyword.value != requirement.searchQuery ) { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.EVIDENCE_REQUIREMENT_MISMATCH ) return } val settings = settingsManager.settings.value val provider = settings.currentProvider when { !provider.supportsRequirementExtraction -> { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.PROVIDER_UNSUPPORTED ) return } settings.baseUrl.isBlank() || settings.model.isBlank() -> { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.PROVIDER_NOT_CONFIGURED ) return } !RequirementProviderEndpointPolicy.isAllowed( baseUrl = settings.baseUrl, apiKey = settings.apiKey ) -> { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.UNSAFE_PROVIDER_ENDPOINT ) return } settings.apiKey.isNotBlank() && !settingsManager.isSecureCredentialStorageAvailable -> { setCandidateEvaluationFailure( CandidateEvaluationFailureCode .SECURE_CREDENTIAL_STORAGE_UNAVAILABLE ) return } provider.id != ApiProvider.CUSTOM.id && settings.apiKey.isBlank() -> { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.PROVIDER_NOT_CONFIGURED ) return } } candidateEvaluationState.value = CandidateEvaluationState.RUNNING candidateReviewBatch.value = null candidateEvaluationFailureCode.value = null candidateEvaluationJob = lifecycleScope.launch { try { val validated = candidateEvidenceSource.load(evidence).getOrElse { setCandidateEvaluationFailure( CandidateEvaluationFailureCode.EVIDENCE_INVALID ) return@launch } val client = VLMClient( apiKey = settings.apiKey, baseUrl = settings.baseUrl, model = settings.model ) val result = CandidateEvaluator( gateway = AndroidCandidateEvaluationVlmGateway(client), providerId = provider.id, model = settings.model ).evaluate( CandidateEvaluationInput( requirement = requirement, candidates = validated.map { candidate -> CandidateEvaluationImage( ordinal = candidate.ordinal, mediaType = "image/png", bytes = candidate.pngBytes, sha256 = candidate.sha256 ) } ) ) when (result) { is CandidateEvaluationResult.Completed -> { candidateReviewBatch.value = result.batch candidateEvaluationState.value = when ( result.batch.conclusion ) { CandidateBatchConclusion.SUGGESTED -> CandidateEvaluationState.AWAITING_CONFIRMATION CandidateBatchConclusion.NO_MATCH -> CandidateEvaluationState.NO_MATCH CandidateBatchConclusion.MANUAL_REQUIRED -> CandidateEvaluationState.MANUAL_REVIEW } } is CandidateEvaluationResult.Failed -> { setCandidateEvaluationFailure(result.code) } } } catch (error: CancellationException) { candidateEvaluationState.value = CandidateEvaluationState.STOPPED throw error } finally { candidateEvaluationJob = null } } } private fun stopCandidateEvaluation() { candidateEvaluationJob?.cancel() } private fun acceptRecommendedCandidate() { candidateEvaluationState.value = CandidateHumanReviewPolicy.accept( currentState = candidateEvaluationState.value, batch = candidateReviewBatch.value ) } private fun rejectCandidateReview() { candidateEvaluationState.value = CandidateHumanReviewPolicy.reject( candidateEvaluationState.value ) } private fun setCandidateEvaluationFailure( code: CandidateEvaluationFailureCode ) { candidateReviewBatch.value = null candidateEvaluationFailureCode.value = code candidateEvaluationState.value = CandidateEvaluationState.FAILED } private fun clearCandidateEvaluation() { candidateEvaluationJob?.cancel() candidateReviewBatch.value = null candidateEvaluationFailureCode.value = null candidateEvaluationState.value = CandidateEvaluationState.IDLE } private fun checkShizukuPermission(): Boolean { return try { val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED Log.d(TAG, "checkShizukuPermission: $granted") granted } catch (e: Exception) { Log.e(TAG, "checkShizukuPermission error", e) false } } private fun checkAndUpdateShizukuStatus(requestPermission: Boolean = false) { Log.d(TAG, "checkAndUpdateShizukuStatus called") try { val binderAlive = Shizuku.pingBinder() Log.d(TAG, "Shizuku pingBinder: $binderAlive") if (binderAlive) { shizukuAvailable.value = true val hasPermission = checkShizukuPermission() Log.d(TAG, "Shizuku hasPermission: $hasPermission") if (hasPermission) { Log.d(TAG, "Binding Shizuku service") deviceController.bindService() } else if (requestPermission) { Log.d(TAG, "Requesting Shizuku permission") requestShizukuPermission() } } else { Log.d(TAG, "Shizuku binder not alive") shizukuAvailable.value = false } } catch (e: Exception) { Log.e(TAG, "checkAndUpdateShizukuStatus error", e) shizukuAvailable.value = false } } private fun refreshShizukuStatus() { Log.d(TAG, "refreshShizukuStatus called by user") Toast.makeText(this, "正在检查 Shizuku 状态...", Toast.LENGTH_SHORT).show() checkAndUpdateShizukuStatus(requestPermission = true) if (shizukuAvailable.value && checkShizukuPermission()) { Toast.makeText(this, "Shizuku 已连接", Toast.LENGTH_SHORT).show() } else if (shizukuAvailable.value) { Toast.makeText(this, "请在弹窗中授权 Shizuku", Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show() } } private fun requestShizukuPermission() { try { if (!Shizuku.pingBinder()) { Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show() return } if (Shizuku.isPreV11()) { Toast.makeText(this, "Shizuku 版本过低", Toast.LENGTH_SHORT).show() return } if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { Toast.makeText(this, "Shizuku 权限已获取", Toast.LENGTH_SHORT).show() shizukuAvailable.value = true deviceController.bindService() return } Shizuku.requestPermission(0) } catch (e: Exception) { Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show() } } private companion object { const val READINESS_RECHECK_DELAY_MS = 750L } private fun runAgent( instruction: String, apiKey: String, baseUrl: String, model: String, maxSteps: Int, isGUIAgent: Boolean = false, providerId: String = "" ) { if (instruction.isBlank()) { Toast.makeText(this, "请输入指令", Toast.LENGTH_SHORT).show() return } // MAI-UI 本地部署不需要 API Key val requiresApiKey = providerId != "mai_ui" if (requiresApiKey && apiKey.isBlank()) { Toast.makeText(this, "请输入 API Key", Toast.LENGTH_SHORT).show() return } // 检查悬浮窗权限 if (!Settings.canDrawOverlays(this)) { Toast.makeText(this, "请授予悬浮窗权限", Toast.LENGTH_LONG).show() val intent = android.content.Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName") ) startActivity(intent) return } // 立即设置执行状态为 true,显示停止按钮 isExecuting.value = true // 根据服务商类型创建相应的客户端 if (isGUIAgent) { // GUI-Owl 模式 val guiOwlClient = GUIOwlClient( apiKey = apiKey, model = model.ifBlank { "pre-gui_owl_7b" } ) mobileAgent.value = MobileAgent( vlmClient = null, controller = deviceController, context = this, guiOwlClient = guiOwlClient ) } else if (providerId == "mai_ui") { // MAI-UI 模式 val maiuiClient = MAIUIClient( baseUrl = baseUrl.ifBlank { "http://localhost:8000/v1" }, model = model.ifBlank { "MAI-UI-2B" } ) mobileAgent.value = MobileAgent( vlmClient = null, controller = deviceController, context = this, maiuiClient = maiuiClient ) } else { // OpenAI 兼容模式 (阿里云 Qwen-VL, OpenAI, OpenRouter 等) val vlmClient = VLMClient( apiKey = apiKey, baseUrl = baseUrl.ifBlank { "https://dashscope.aliyuncs.com/compatible-mode/v1" }, model = model.ifBlank { "qwen3-vl-plus" } ) mobileAgent.value = MobileAgent(vlmClient, deviceController, this) } // 设置停止回调,用于取消协程 mobileAgent.value?.onStopRequested = { currentExecutionJob?.cancel() currentExecutionJob = null } // 创建执行记录 val record = ExecutionRecord( title = generateTitle(instruction), instruction = instruction, startTime = System.currentTimeMillis(), status = ExecutionStatus.RUNNING ) // 保存当前记录 ID,用于停止后跳转 currentRecordId.value = record.id // 取消之前的任务(如果有) currentExecutionJob?.cancel() currentExecutionJob = lifecycleScope.launch { // 保存初始记录 executionRepository.saveRecord(record) executionRecords.value = executionRepository.getAllRecords() try { val result = mobileAgent.value!!.runInstruction(instruction, maxSteps) // 更新记录状态 val agentState = mobileAgent.value?.state?.value val steps = agentState?.executionSteps ?: emptyList() val currentLogs = mobileAgent.value?.logs?.value ?: emptyList() val updatedRecord = record.copy( endTime = System.currentTimeMillis(), status = if (result.success) ExecutionStatus.COMPLETED else ExecutionStatus.FAILED, steps = steps, logs = currentLogs, resultMessage = result.message ) executionRepository.saveRecord(updatedRecord) executionRecords.value = executionRepository.getAllRecords() Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show() // 重置执行状态 isExecuting.value = false // 延迟3秒后清空日志,恢复默认状态 kotlinx.coroutines.delay(3000) mobileAgent.value?.clearLogs() } catch (e: kotlinx.coroutines.CancellationException) { // 用户取消任务 - 使用 NonCancellable 确保清理操作完成 kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { val agentState = mobileAgent.value?.state?.value val steps = agentState?.executionSteps ?: emptyList() val currentLogs = mobileAgent.value?.logs?.value ?: emptyList() println("[MainActivity] 取消任务 - steps: ${steps.size}, logs: ${currentLogs.size}") val updatedRecord = record.copy( endTime = System.currentTimeMillis(), status = ExecutionStatus.STOPPED, steps = steps, logs = currentLogs, resultMessage = "已取消" ) executionRepository.saveRecord(updatedRecord) executionRecords.value = executionRepository.getAllRecords() // 重置执行状态 isExecuting.value = false Toast.makeText(this@MainActivity, "任务已停止", Toast.LENGTH_SHORT).show() mobileAgent.value?.clearLogs() // 触发跳转到记录详情页 shouldNavigateToRecord.value = true } } catch (e: Exception) { // 更新失败记录 val currentLogs = mobileAgent.value?.logs?.value ?: emptyList() val updatedRecord = record.copy( endTime = System.currentTimeMillis(), status = ExecutionStatus.FAILED, logs = currentLogs, resultMessage = "错误: ${e.message}" ) executionRepository.saveRecord(updatedRecord) executionRecords.value = executionRepository.getAllRecords() // 重置执行状态 isExecuting.value = false Toast.makeText(this@MainActivity, "错误: ${e.message}", Toast.LENGTH_LONG).show() // 延迟3秒后清空日志,恢复默认状态 kotlinx.coroutines.delay(3000) mobileAgent.value?.clearLogs() } } } private fun generateTitle(instruction: String): String { // 生成简短标题 val keywords = listOf( "打开" to "打开应用", "点" to "点餐", "发" to "发送消息", "看" to "浏览内容", "搜" to "搜索", "设置" to "调整设置", "播放" to "播放媒体" ) for ((key, title) in keywords) { if (instruction.contains(key)) { return title } } return if (instruction.length > 10) instruction.take(10) + "..." else instruction } }