feat(android): extract structured purchase requirements

This commit is contained in:
QiuSW
2026-07-25 21:05:51 +08:00
parent c25d63c730
commit 32d5310000
22 changed files with 2014 additions and 32 deletions
@@ -41,6 +41,16 @@ 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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -74,6 +84,7 @@ class MainActivity : ComponentActivity() {
private lateinit var settingsManager: SettingsManager
private lateinit var executionRepository: ExecutionRepository
private lateinit var readinessChecker: DeviceReadinessChecker
private lateinit var requirementProbeSource: RequirementProbeSource
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
private var shizukuAvailable = mutableStateOf(false)
@@ -83,8 +94,13 @@ class MainActivity : ComponentActivity() {
private val searchProbeReport = mutableStateOf<WorkflowReport?>(null)
private val candidateEvidence =
mutableStateOf<List<PinduoduoCandidateEvidence>>(emptyList())
private val requirementProbeState = mutableStateOf(RequirementProbeState.IDLE)
private val requirementExtraction = mutableStateOf<RequirementExtraction?>(null)
private val requirementFailureCode =
mutableStateOf<RequirementExtractionFailureCode?>(null)
private var searchProbeRunner: WorkflowRunner? = null
private var searchProbeJob: Job? = null
private var requirementProbeJob: Job? = null
// 当前执行的协程 Job(用于停止任务)
private var currentExecutionJob: kotlinx.coroutines.Job? = null
@@ -140,6 +156,7 @@ class MainActivity : ComponentActivity() {
settingsManager = SettingsManager(this)
executionRepository = ExecutionRepository(this)
readinessChecker = DeviceReadinessChecker(this)
requirementProbeSource = RequirementProbeSource(this)
refreshReadiness()
// 加载执行记录
@@ -206,6 +223,9 @@ class MainActivity : ComponentActivity() {
val probeStepId by remember { searchProbeStepId }
val probeReport by remember { searchProbeReport }
val evidence by remember { candidateEvidence }
val extractionState by remember { requirementProbeState }
val extraction by remember { requirementExtraction }
val extractionFailure by remember { requirementFailureCode }
// 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) {
@@ -295,6 +315,11 @@ class MainActivity : ComponentActivity() {
currentStepId = probeStepId,
report = probeReport,
candidateEvidenceCount = evidence.size,
requirementState = extractionState,
requirement = extraction,
requirementFailureCode = extractionFailure,
onStartRequirement = { startRequirementProbe() },
onStopRequirement = { stopRequirementProbe() },
onStart = { startSearchProbe() },
onStop = { stopSearchProbe() }
)
@@ -361,6 +386,7 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
searchProbeRunner?.requestStop()
requirementProbeJob?.cancel()
super.onDestroy()
Shizuku.removeBinderReceivedListener(binderReceivedListener)
Shizuku.removeBinderDeadListener(binderDeadListener)
@@ -448,6 +474,110 @@ class MainActivity : ComponentActivity() {
searchProbeRunner?.requestStop()
}
private fun startRequirementProbe() {
if (requirementProbeJob?.isActive == true) {
return
}
if (searchProbeJob?.isActive == true) {
Toast.makeText(this, "请先停止候选探针", Toast.LENGTH_SHORT).show()
return
}
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 checkShizukuPermission(): Boolean {
return try {
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED