Files
cmroubao/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt
T

1101 lines
46 KiB
Kotlin
Raw Normal View History

package com.roubao.autopilot
2026-07-25 18:41:16 +08:00
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.*
2026-07-25 18:41:16 +08:00
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
2026-07-25 19:40:19 +08:00
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
import android.util.Log
2026-07-25 19:40:19 +08:00
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
2026-07-25 19:40:19 +08:00
import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
2026-07-25 19:40:19 +08:00
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) {
2026-07-25 18:41:16 +08:00
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
2026-07-25 19:40:19 +08:00
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
2026-07-25 18:41:16 +08:00
private lateinit var readinessChecker: DeviceReadinessChecker
private lateinit var requirementProbeSource: RequirementProbeSource
private lateinit var candidateEvidenceSource: CandidateEvidenceSource
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
private var shizukuAvailable = mutableStateOf(false)
2026-07-25 18:41:16 +08:00
private val readinessSnapshot = mutableStateOf(DeviceReadinessSnapshot.empty())
2026-07-25 19:40:19 +08:00
private val searchProbeState = mutableStateOf(WorkflowState.IDLE)
private val searchProbeStepId = mutableStateOf<String?>(null)
private val searchProbeReport = mutableStateOf<WorkflowReport?>(null)
private val candidateEvidence =
mutableStateOf<List<PinduoduoCandidateEvidence>>(emptyList())
private val candidateSearchKeyword = mutableStateOf<String?>(null)
private val candidateRequirementSnapshot =
mutableStateOf<RequirementExtraction?>(null)
private val requirementProbeState = mutableStateOf(RequirementProbeState.IDLE)
private val requirementExtraction = mutableStateOf<RequirementExtraction?>(null)
private val requirementFailureCode =
mutableStateOf<RequirementExtractionFailureCode?>(null)
private val candidateEvaluationState =
mutableStateOf(CandidateEvaluationState.IDLE)
private val candidateReviewBatch = mutableStateOf<CandidateReviewBatch?>(null)
private val candidateEvaluationFailureCode =
mutableStateOf<CandidateEvaluationFailureCode?>(null)
2026-07-25 19:40:19 +08:00
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<List<ExecutionRecord>>(emptyList())
// 是否正在执行(点击发送后立即为 true)
private val isExecuting = mutableStateOf(false)
// 当前执行的记录 ID(用于停止后跳转)
private val currentRecordId = mutableStateOf<String?>(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)
2026-07-25 18:41:16 +08:00
readinessChecker = DeviceReadinessChecker(this)
requirementProbeSource = RequirementProbeSource(this)
candidateEvidenceSource = CandidateEvidenceSource(this)
2026-07-25 18:41:16 +08:00
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() {
2026-07-25 19:40:19 +08:00
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(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 }
2026-07-25 18:41:16 +08:00
val readiness by remember { readinessSnapshot }
2026-07-25 19:40:19 +08:00
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
) {
2026-07-25 19:40:19 +08:00
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) {
2026-07-25 18:41:16 +08:00
Screen.Device -> DeviceReadinessScreen(
snapshot = readiness,
onRefresh = { refreshReadiness() },
onOpenAccessibilitySettings = {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
},
onOpenPinduoduo = { openPinduoduo() }
)
2026-07-25 19:40:19 +08:00
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() },
2026-07-25 19:40:19 +08:00
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()
}
}
2026-07-25 18:41:16 +08:00
override fun onResume() {
super.onResume()
if (::readinessChecker.isInitialized) {
refreshReadiness()
2026-07-25 19:40:19 +08:00
lifecycleScope.launch {
delay(READINESS_RECHECK_DELAY_MS)
refreshReadiness()
}
2026-07-25 18:41:16 +08:00
}
}
override fun onDestroy() {
2026-07-25 19:40:19 +08:00
searchProbeRunner?.requestStop()
requirementProbeJob?.cancel()
candidateEvaluationJob?.cancel()
super.onDestroy()
Shizuku.removeBinderReceivedListener(binderReceivedListener)
Shizuku.removeBinderDeadListener(binderDeadListener)
Shizuku.removeRequestPermissionResultListener(permissionResultListener)
deviceController.unbindService()
}
2026-07-25 18:41:16 +08:00
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)
}
2026-07-25 19:40:19 +08:00
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
}
2026-07-25 19:40:19 +08:00
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()
2026-07-25 19:40:19 +08:00
val runner = WorkflowRunner(
PinduoduoProbeAutomation(
searchAutomation = PinduoduoSearchAutomation(
driver = AndroidPinduoduoUiDriver(this),
keyword = searchKeyword,
forceKeywordEntry = boundRequirement != null
),
candidateAutomation = candidateAutomation
)
2026-07-25 19:40:19 +08:00
)
searchProbeRunner = runner
searchProbeReport.value = null
searchProbeState.value = WorkflowState.IDLE
searchProbeStepId.value = null
candidateEvidence.value = emptyList()
candidateSearchKeyword.value = searchKeyword
candidateRequirementSnapshot.value = boundRequirement
clearCandidateEvaluation()
2026-07-25 19:40:19 +08:00
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}")
}
}
}
2026-07-25 19:40:19 +08:00
try {
val report = runner.run(PinduoduoCandidateWorkflow.steps())
2026-07-25 19:40:19 +08:00
searchProbeReport.value = report
searchProbeState.value = report.state
} finally {
stateCollector.cancel()
stepCollector.cancel()
evidenceCollector.cancel()
candidatePhaseCollector.cancel()
2026-07-25 19:40:19 +08:00
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
}
}
2026-07-25 18:41:16 +08:00
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()
2026-07-25 18:41:16 +08:00
} 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()
2026-07-25 18:41:16 +08:00
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()
}
}
2026-07-25 19:40:19 +08:00
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
}
}