2026-07-25 18:20:37 +08:00
|
|
|
package com.roubao.autopilot
|
|
|
|
|
|
2026-07-25 18:41:16 +08:00
|
|
|
import android.content.Intent
|
2026-07-25 18:20:37 +08:00
|
|
|
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
|
2026-07-25 18:20:37 +08:00
|
|
|
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 kotlinx.coroutines.Dispatchers
|
2026-07-25 19:40:19 +08:00
|
|
|
import kotlinx.coroutines.Job
|
|
|
|
|
import kotlinx.coroutines.delay
|
2026-07-25 18:20:37 +08:00
|
|
|
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.PinduoduoSearchAutomation
|
|
|
|
|
import com.roubao.autopilot.pinduoduo.PinduoduoSearchWorkflow
|
|
|
|
|
import com.roubao.autopilot.workflow.WorkflowReport
|
|
|
|
|
import com.roubao.autopilot.workflow.WorkflowRunner
|
|
|
|
|
import com.roubao.autopilot.workflow.WorkflowState
|
2026-07-25 18:20:37 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-07-25 18:20:37 +08:00
|
|
|
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
|
2026-07-25 18:20:37 +08:00
|
|
|
|
|
|
|
|
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 var searchProbeRunner: WorkflowRunner? = null
|
|
|
|
|
private var searchProbeJob: Job? = null
|
2026-07-25 18:20:37 +08:00
|
|
|
|
|
|
|
|
// 当前执行的协程 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)
|
|
|
|
|
refreshReadiness()
|
2026-07-25 18:20:37 +08:00
|
|
|
|
|
|
|
|
// 加载执行记录
|
|
|
|
|
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) }
|
2026-07-25 18:20:37 +08:00
|
|
|
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 }
|
2026-07-25 18:20:37 +08:00
|
|
|
|
|
|
|
|
// 监听跳转事件
|
|
|
|
|
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 ->
|
2026-07-25 18:20:37 +08:00
|
|
|
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,
|
|
|
|
|
onStart = { startSearchProbe() },
|
|
|
|
|
onStop = { stopSearchProbe() }
|
|
|
|
|
)
|
2026-07-25 18:20:37 +08:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:20:37 +08:00
|
|
|
override fun onDestroy() {
|
2026-07-25 19:40:19 +08:00
|
|
|
searchProbeRunner?.requestStop()
|
2026-07-25 18:20:37 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val runner = WorkflowRunner(
|
|
|
|
|
PinduoduoSearchAutomation(AndroidPinduoduoUiDriver(this))
|
|
|
|
|
)
|
|
|
|
|
searchProbeRunner = runner
|
|
|
|
|
searchProbeReport.value = null
|
|
|
|
|
searchProbeState.value = WorkflowState.IDLE
|
|
|
|
|
searchProbeStepId.value = null
|
|
|
|
|
searchProbeJob = lifecycleScope.launch {
|
|
|
|
|
val stateCollector = launch {
|
|
|
|
|
runner.state.collect { state -> searchProbeState.value = state }
|
|
|
|
|
}
|
|
|
|
|
val stepCollector = launch {
|
|
|
|
|
runner.currentStepId.collect { stepId -> searchProbeStepId.value = stepId }
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
val report = runner.run(PinduoduoSearchWorkflow.steps())
|
|
|
|
|
searchProbeReport.value = report
|
|
|
|
|
searchProbeState.value = report.state
|
|
|
|
|
} finally {
|
|
|
|
|
stateCollector.cancel()
|
|
|
|
|
stepCollector.cancel()
|
|
|
|
|
searchProbeRunner = null
|
|
|
|
|
refreshReadiness()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun stopSearchProbe() {
|
|
|
|
|
searchProbeRunner?.requestStop()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:20:37 +08:00
|
|
|
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) {
|
2026-07-25 18:20:37 +08:00
|
|
|
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) {
|
2026-07-25 18:20:37 +08:00
|
|
|
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)
|
2026-07-25 18:20:37 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:20:37 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|