feat(t206): connect Android procurement tasks

This commit is contained in:
QiuSW
2026-07-27 11:11:16 +08:00
parent 9e698c1b6c
commit 45b66eeea7
35 changed files with 2634 additions and 81 deletions
@@ -109,6 +109,15 @@
android:value="automation_overlay" />
</service>
<service
android:name=".procurement.ProcurementExecutionService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="procurement_task_control" />
</service>
<service
android:name=".accessibility.BuyerAccessibilityService"
android:exported="false"
@@ -4,6 +4,8 @@ import android.app.Application
import android.content.pm.PackageManager
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.procurement.ProcurementExecutionService
import com.roubao.autopilot.procurement.ProcurementRepository
import com.roubao.autopilot.skills.SkillManager
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.utils.CrashHandler
@@ -15,6 +17,8 @@ class App : Application() {
private set
lateinit var appScanner: AppScanner
private set
lateinit var procurementRepository: ProcurementRepository
private set
override fun onCreate() {
super.onCreate()
@@ -38,6 +42,11 @@ class App : Application() {
// 初始化应用扫描器
appScanner = AppScanner(this)
procurementRepository = ProcurementRepository.create(this)
if (procurementRepository.hasRunningExecution) {
ProcurementExecutionService.start(this)
}
// 初始化 Tools 层
val toolManager = ToolManager.init(this, deviceController, appScanner)
@@ -77,6 +77,8 @@ 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.procurement.LoginInput
import com.roubao.autopilot.procurement.ProcurementRepository
import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
@@ -84,6 +86,7 @@ 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 Tasks : Screen("tasks", "任务", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart)
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)
@@ -96,6 +99,7 @@ class MainActivity : ComponentActivity() {
private lateinit var settingsManager: SettingsManager
private lateinit var executionRepository: ExecutionRepository
private lateinit var readinessChecker: DeviceReadinessChecker
private lateinit var procurementRepository: ProcurementRepository
private lateinit var requirementProbeSource: RequirementProbeSource
private lateinit var candidateEvidenceSource: CandidateEvidenceSource
@@ -178,6 +182,7 @@ class MainActivity : ComponentActivity() {
settingsManager = SettingsManager(this)
executionRepository = ExecutionRepository(this)
readinessChecker = DeviceReadinessChecker(this)
procurementRepository = (application as App).procurementRepository
requirementProbeSource = RequirementProbeSource(this)
candidateEvidenceSource = CandidateEvidenceSource(this)
refreshReadiness()
@@ -232,7 +237,7 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp() {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var currentScreen by remember { mutableStateOf<Screen>(Screen.Tasks) }
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
val settings by settingsManager.settings.collectAsState()
@@ -254,6 +259,7 @@ class MainActivity : ComponentActivity() {
val evaluationState by remember { candidateEvaluationState }
val reviewBatch by remember { candidateReviewBatch }
val evaluationFailure by remember { candidateEvaluationFailureCode }
val procurementState by procurementRepository.uiState.collectAsState()
// 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) {
@@ -278,7 +284,13 @@ class MainActivity : ComponentActivity() {
contentColor = colors.textPrimary,
tonalElevation = 0.dp
) {
listOf(Screen.Home, Screen.Device, Screen.History, Screen.Settings).forEach { screen ->
listOf(
Screen.Tasks,
Screen.Home,
Screen.Device,
Screen.History,
Screen.Settings
).forEach { screen ->
val selected = currentScreen == screen
NavigationBarItem(
icon = {
@@ -329,6 +341,35 @@ class MainActivity : ComponentActivity() {
label = "screen"
) { screen ->
when (screen) {
Screen.Tasks -> ProcurementScreen(
state = procurementState,
readiness = readiness,
onLogin = { input: LoginInput ->
lifecycleScope.launch {
procurementRepository.login(input)
}
},
onClaim = {
lifecycleScope.launch {
procurementRepository.claimNext(readiness)
}
},
onStart = {
lifecycleScope.launch {
procurementRepository.start()
}
},
onRelease = {
lifecycleScope.launch {
procurementRepository.release()
}
},
onSync = {
lifecycleScope.launch {
procurementRepository.synchronizeNow()
}
}
)
Screen.Device -> DeviceReadinessScreen(
snapshot = readiness,
onRefresh = { refreshReadiness() },
@@ -0,0 +1,29 @@
package com.roubao.autopilot.procurement
import java.net.URI
object BackendEndpointPolicy {
fun normalize(rawUrl: String, debugBuild: Boolean): Result<String> = runCatching {
val value = rawUrl.trim().trimEnd('/')
val uri = URI(value)
require(uri.host != null && uri.port != 0) { "后台地址必须包含主机" }
require(uri.userInfo == null && uri.query == null && uri.fragment == null) {
"后台地址不能包含账号、查询参数或片段"
}
require(uri.path.isNullOrEmpty() || uri.path == "/") { "后台地址不能包含路径" }
when (uri.scheme?.lowercase()) {
"https" -> Unit
"http" -> require(debugBuild && uri.host.isLoopbackHost()) {
"正式版只允许 HTTPS;调试版 HTTP 仅允许本机回环地址"
}
else -> error("后台地址必须使用 HTTPS")
}
value
}
private fun String.isLoopbackHost(): Boolean =
equals("localhost", ignoreCase = true) ||
this == "127.0.0.1" ||
this == "::1" ||
this == "[::1]"
}
@@ -0,0 +1,9 @@
package com.roubao.autopilot.procurement
import com.roubao.task.TaskSource
class HttpTaskSource(
private val repository: ProcurementRepository
) : TaskSource {
override suspend fun nextTask() = repository.currentProbeTask()
}
@@ -0,0 +1,522 @@
package com.roubao.autopilot.procurement
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.ResponseBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.URI
import java.security.MessageDigest
import java.time.Duration
class ProcurementApiException(
val code: String,
val statusCode: Int,
val retryable: Boolean,
message: String
) : Exception(message)
data class DownloadedReferenceImage(
val bytes: ByteArray,
val sha256: String
)
interface ProcurementRemoteApi {
suspend fun login(
baseUrl: String,
input: LoginInput,
appVersion: String,
androidVersion: String
): ProcurementSession
suspend fun deviceHeartbeat(
session: ProcurementSession,
input: DeviceHeartbeatInput
): DeviceHeartbeatResult
suspend fun claimNext(
session: ProcurementSession,
deviceId: String,
claimToken: String,
idempotencyKey: String
): ClaimResult
suspend fun downloadReferenceImage(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String
): DownloadedReferenceImage
suspend fun start(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String,
idempotencyKey: String
): StartResult
suspend fun heartbeat(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String
): TaskHeartbeatResult
suspend fun release(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String,
idempotencyKey: String
): RemotePurchaseTask
suspend fun acknowledgeCancellation(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
idempotencyKey: String
): RemotePurchaseTask
}
class ProcurementApiClient(
private val client: OkHttpClient = defaultClient()
) : ProcurementRemoteApi {
override suspend fun login(
baseUrl: String,
input: LoginInput,
appVersion: String,
androidVersion: String
): ProcurementSession {
val localNow = System.currentTimeMillis()
val payload = JSONObject()
.put("username", input.username.trim())
.put("password", input.password)
.put("device_id", input.deviceId.trim())
.put("device_token", input.deviceToken)
.put("app_version", appVersion)
.put("android_version", androidVersion)
val json = executeJson(
Request.Builder()
.url("$baseUrl/api/v1/auth/token")
.post(payload.jsonBody())
.build()
)
return ProcurementSession(
backendUrl = baseUrl,
username = input.username.trim(),
deviceId = json.getJSONObject("device").getString("id"),
accessToken = json.getString("access_token"),
expiresAtEpochMillis =
localNow + json.getLong("expires_in") * 1_000L
)
}
override suspend fun deviceHeartbeat(
session: ProcurementSession,
input: DeviceHeartbeatInput
): DeviceHeartbeatResult {
val payload = JSONObject()
.put("device_id", session.deviceId)
.put("app_version", input.appVersion)
.put("android_version", input.androidVersion)
.put("pdd_version", input.pddVersion)
.put(
"readiness",
JSONObject()
.put("accessibility_enabled", input.accessibilityEnabled)
.put("pdd_installed", input.pddInstalled)
.put(
"active_task_id",
input.activeTaskId ?: JSONObject.NULL
)
)
val json = executeJson(
authorizedRequest(session, "/api/v1/devices/heartbeat")
.post(payload.jsonBody())
.build()
)
return DeviceHeartbeatResult(
activeTaskId = json.optionalString("active_task_id"),
clientStateMatches = json.getBoolean("client_state_matches"),
serverTime = json.getString("server_time")
)
}
override suspend fun claimNext(
session: ProcurementSession,
deviceId: String,
claimToken: String,
idempotencyKey: String
): ClaimResult = withContext(Dispatchers.IO) {
val response = client.newCall(
authorizedRequest(session, "/api/v1/tasks/claim-next")
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(JSONObject().put("device_id", deviceId).jsonBody())
.build()
).execute()
response.use {
if (it.code == 204) {
return@withContext ClaimResult(null, null)
}
val json = responseJsonOrThrow(
it.code,
it.body.readBoundedJson(it.code)
)
ClaimResult(
task = parseTask(json.getJSONObject("task")),
serverTime = json.getString("server_time")
)
}
}
override suspend fun downloadReferenceImage(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String
): DownloadedReferenceImage = withContext(Dispatchers.IO) {
val target = referenceImageTarget(session, task)
val request = Request.Builder()
.url(target)
.header("Authorization", "Bearer ${session.accessToken}")
.header(CLAIM_TOKEN_HEADER, claimToken)
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
responseJsonOrThrow(
response.code,
response.body.readBoundedJson(response.code)
)
}
val contentType = response.header("Content-Type")
?.substringBefore(';')
?.trim()
if (contentType != JPEG_MEDIA_TYPE) {
throw ProcurementApiException(
"REFERENCE_IMAGE_INVALID",
response.code,
false,
"参考图不是 JPEG"
)
}
val declaredLength = response.body?.contentLength() ?: -1L
if (declaredLength > MAX_REFERENCE_IMAGE_BYTES) {
throw imageTooLarge()
}
val bytes = response.body?.byteStream()?.use {
val output = ByteArrayOutputStream()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val read = it.read(buffer)
if (read < 0) break
total += read
if (total > MAX_REFERENCE_IMAGE_BYTES) throw imageTooLarge()
output.write(buffer, 0, read)
}
output.toByteArray()
} ?: ByteArray(0)
if (bytes.size < 3 ||
bytes[0] != 0xFF.toByte() ||
bytes[1] != 0xD8.toByte() ||
bytes[2] != 0xFF.toByte()
) {
throw ProcurementApiException(
"REFERENCE_IMAGE_INVALID",
response.code,
false,
"参考图内容无效"
)
}
val sha256 = bytes.sha256()
val expectedHash = response.header("ETag")
?.trim()
?.removePrefix("W/")
?.trim('"')
?.takeIf { SHA256_PATTERN.matches(it) }
if (expectedHash != null && expectedHash != sha256) {
throw ProcurementApiException(
"REFERENCE_IMAGE_HASH_MISMATCH",
response.code,
false,
"参考图校验失败"
)
}
DownloadedReferenceImage(bytes, sha256)
}
}
override suspend fun start(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String,
idempotencyKey: String
): StartResult {
val payload = transitionPayload(session.deviceId, task)
val json = executeJson(
authorizedRequest(session, "/api/v1/tasks/${task.id}/start")
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(payload.jsonBody())
.build()
)
val execution = json.getJSONObject("execution")
return StartResult(
task = parseTask(json.getJSONObject("task")),
executionId = execution.getString("id"),
currentStep = execution.getString("current_step"),
executionExpiresAt = execution.getString("execution_expires_at"),
serverTime = json.getString("server_time")
)
}
override suspend fun heartbeat(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String
): TaskHeartbeatResult {
val payload = JSONObject()
.put("device_id", session.deviceId)
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("step", execution.currentStep)
val json = executeJson(
authorizedRequest(session, "/api/v1/tasks/${task.id}/heartbeat")
.header(CLAIM_TOKEN_HEADER, claimToken)
.post(payload.jsonBody())
.build()
)
val responseExecution = json.getJSONObject("execution")
return TaskHeartbeatResult(
task = parseTask(json.getJSONObject("task")),
executionId = responseExecution.getString("id"),
currentStep = responseExecution.getString("current_step"),
executionExpiresAt =
responseExecution.getString("execution_expires_at"),
cancelRequested = json.getBoolean("cancel_requested"),
serverTime = json.getString("server_time")
)
}
override suspend fun release(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String,
idempotencyKey: String
): RemotePurchaseTask =
executeTransition(
session,
task,
claimToken,
idempotencyKey,
"release"
)
override suspend fun acknowledgeCancellation(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
idempotencyKey: String
): RemotePurchaseTask {
val payload = transitionPayload(session.deviceId, task)
.put("execution_id", execution.id)
val json = executeJson(
authorizedRequest(session, "/api/v1/tasks/${task.id}/cancel-ack")
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(payload.jsonBody())
.build()
)
return parseTask(json.getJSONObject("task"))
}
private suspend fun executeTransition(
session: ProcurementSession,
task: RemotePurchaseTask,
claimToken: String,
idempotencyKey: String,
operation: String
): RemotePurchaseTask {
val json = executeJson(
authorizedRequest(session, "/api/v1/tasks/${task.id}/$operation")
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(transitionPayload(session.deviceId, task).jsonBody())
.build()
)
return parseTask(json.getJSONObject("task"))
}
private fun authorizedRequest(
session: ProcurementSession,
path: String
): Request.Builder =
Request.Builder()
.url(session.backendUrl + path)
.header("Authorization", "Bearer ${session.accessToken}")
private suspend fun executeJson(request: Request): JSONObject =
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
responseJsonOrThrow(
response.code,
response.body.readBoundedJson(response.code)
)
}
}
private fun responseJsonOrThrow(statusCode: Int, rawBody: String?): JSONObject {
val json = runCatching { JSONObject(rawBody ?: "") }.getOrElse {
throw ProcurementApiException(
"INVALID_RESPONSE",
statusCode,
statusCode >= 500,
"后台响应格式无效"
)
}
if (statusCode !in 200..299) {
val error = json.optJSONObject("error")
throw ProcurementApiException(
code = error?.optString("code")?.takeIf(String::isNotBlank)
?: "HTTP_$statusCode",
statusCode = statusCode,
retryable = error?.optBoolean("retryable", statusCode >= 500)
?: (statusCode >= 500),
message = stableErrorMessage(
error?.optString("code"),
statusCode
)
)
}
return json
}
private fun stableErrorMessage(code: String?, statusCode: Int): String =
when (code) {
"AUTH_INVALID_CREDENTIALS" -> "账号、密码或设备凭证错误"
"AUTH_ACCOUNT_OR_DEVICE_DISABLED" -> "账号或设备已停用"
"AUTH_RATE_LIMITED" -> "登录尝试过多,请稍后重试"
"DEVICE_NOT_READY" -> "设备未就绪,无法领取任务"
"DEVICE_HAS_ACTIVE_TASK" -> "设备已有进行中的任务"
"TASK_CLAIM_INVALID" -> "任务归属校验失败"
"TASK_CLAIM_EXPIRED" -> "任务授权已过期"
"TASK_VERSION_CONFLICT" -> "任务已被后台更新,请先同步"
else -> if (statusCode >= 500) "后台暂时不可用" else "后台拒绝了请求"
}
private fun transitionPayload(
deviceId: String,
task: RemotePurchaseTask
): JSONObject =
JSONObject()
.put("device_id", deviceId)
.put("claim_generation", task.claimGeneration)
.put("expected_version", task.version)
private fun referenceImageTarget(
session: ProcurementSession,
task: RemotePurchaseTask
): String {
val backend = URI(session.backendUrl)
val target = backend.resolve(task.referenceImageUrl)
val expectedPath = "/api/v1/tasks/${task.id}/reference-image"
val sameOrigin =
target.scheme.equals(backend.scheme, ignoreCase = true) &&
target.host.equals(backend.host, ignoreCase = true) &&
target.port == backend.port
if (!sameOrigin ||
target.userInfo != null ||
target.fragment != null ||
target.path != expectedPath
) {
throw ProcurementApiException(
"REFERENCE_IMAGE_ORIGIN_INVALID",
0,
false,
"参考图地址不可信"
)
}
return target.toString()
}
private fun parseTask(json: JSONObject): RemotePurchaseTask =
RemotePurchaseTask(
id = json.getString("id"),
status = json.getString("status"),
version = json.getLong("version"),
claimGeneration = json.getLong("claim_generation"),
claimExpiresAt = json.optionalString("claim_expires_at"),
title = json.getString("title"),
description = json.optString("description"),
sku = json.getString("sku"),
referenceImageUrl = json.getString("reference_image_url"),
quantity = json.getInt("quantity"),
maxBudget = json.optionalString("max_budget"),
currency = json.optString("currency", "CNY")
)
private fun JSONObject.jsonBody() =
toString().toRequestBody(JSON_MEDIA_TYPE)
private fun JSONObject.optionalString(name: String): String? =
if (has(name) && !isNull(name)) getString(name) else null
private fun ResponseBody?.readBoundedJson(statusCode: Int): String? {
this ?: return null
if (contentLength() > MAX_JSON_BYTES) {
throw responseTooLarge(statusCode)
}
val source = source()
source.request(MAX_JSON_BYTES + 1L)
if (source.buffer.size > MAX_JSON_BYTES) {
throw responseTooLarge(statusCode)
}
return source.readUtf8()
}
private fun ByteArray.sha256(): String =
MessageDigest.getInstance("SHA-256")
.digest(this)
.joinToString("") { "%02x".format(it) }
private fun imageTooLarge() = ProcurementApiException(
"REFERENCE_IMAGE_TOO_LARGE",
200,
false,
"参考图超过 20 MiB"
)
private fun responseTooLarge(statusCode: Int) = ProcurementApiException(
"RESPONSE_TOO_LARGE",
statusCode,
false,
"后台响应过大"
)
companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private const val JPEG_MEDIA_TYPE = "image/jpeg"
private const val CLAIM_TOKEN_HEADER = "X-Claim-Token"
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
private const val MAX_JSON_BYTES = 1_048_576L
private const val MAX_REFERENCE_IMAGE_BYTES = 20L * 1024L * 1024L
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
private fun defaultClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(10))
.readTimeout(Duration.ofSeconds(30))
.writeTimeout(Duration.ofSeconds(30))
.callTimeout(Duration.ofSeconds(45))
.followRedirects(false)
.followSslRedirects(false)
.retryOnConnectionFailure(false)
.build()
}
}
@@ -0,0 +1,104 @@
package com.roubao.autopilot.procurement
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.roubao.autopilot.App
import com.roubao.autopilot.MainActivity
import com.roubao.autopilot.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class ProcurementExecutionService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var heartbeatJob: Job? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, notification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (heartbeatJob?.isActive != true) {
heartbeatJob = scope.launch {
val repository = (application as App).procurementRepository
while (isActive) {
val decision = repository.synchronizeRunning()
if (decision == ExecutionSyncDecision.STOP) {
stopSelf()
break
}
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
return START_STICKY
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(): Notification {
val openApp = Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val pendingIntent = PendingIntent.getActivity(
this,
0,
openApp,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("肉包采购任务运行中")
.setContentText("正在同步任务进度和后台取消请求")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
private fun createNotificationChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"采购任务",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "保持采购任务运行并同步进度"
}
)
}
companion object {
private const val CHANNEL_ID = "procurement_execution"
private const val NOTIFICATION_ID = 206
private const val HEARTBEAT_INTERVAL_MS = 30_000L
fun start(context: Context) {
ContextCompat.startForegroundService(
context,
Intent(context, ProcurementExecutionService::class.java)
)
}
}
}
@@ -0,0 +1,185 @@
package com.roubao.autopilot.procurement
import com.roubao.task.ProbeReferenceImage
import com.roubao.task.ProbeTask
import java.time.Instant
enum class ProcurementPhase {
SIGNED_OUT,
IDLE,
CLAIMED,
RUNNING,
AUTHORIZATION_EXPIRED
}
data class ConnectionProfile(
val backendUrl: String = "http://127.0.0.1:8080",
val username: String = "",
val deviceId: String = "",
val hasDeviceToken: Boolean = false
)
data class ProcurementSession(
val backendUrl: String,
val username: String,
val deviceId: String,
val accessToken: String,
val expiresAtEpochMillis: Long
) {
fun isValid(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
accessToken.isNotBlank() && expiresAtEpochMillis > nowEpochMillis
}
data class RemotePurchaseTask(
val id: String,
val status: String,
val version: Long,
val claimGeneration: Long,
val claimExpiresAt: String?,
val title: String,
val description: String,
val sku: String,
val referenceImageUrl: String,
val quantity: Int,
val maxBudget: String?,
val currency: String
)
data class ReferenceImageRecord(
val relativePath: String,
val sizeBytes: Long,
val sha256: String
)
data class ClaimContext(
val token: String,
val idempotencyKey: String,
val task: RemotePurchaseTask? = null,
val referenceImage: ReferenceImageRecord? = null,
val previewReadyAtEpochMillis: Long? = null,
val startIdempotencyKey: String? = null,
val releaseIdempotencyKey: String? = null
)
data class RunningExecution(
val id: String,
val currentStep: String,
val expiresAt: String,
val serverClockOffsetMillis: Long,
val safetyStopped: Boolean = false,
val cancelAcknowledgementKey: String? = null
) {
fun isExpired(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
ExecutionAuthorization.isExpired(
expiresAt = expiresAt,
serverClockOffsetMillis = serverClockOffsetMillis,
nowEpochMillis = nowEpochMillis
)
}
data class PersistedProcurementState(
val session: ProcurementSession? = null,
val deviceToken: String? = null,
val claim: ClaimContext? = null,
val execution: RunningExecution? = null
)
data class ProcurementUiState(
val phase: ProcurementPhase = ProcurementPhase.SIGNED_OUT,
val profile: ConnectionProfile = ConnectionProfile(),
val task: RemotePurchaseTask? = null,
val referenceImagePath: String? = null,
val execution: RunningExecution? = null,
val authenticationRequired: Boolean = false,
val busy: Boolean = false,
val backendOnline: Boolean? = null,
val message: String? = null,
val error: String? = null
)
data class LoginInput(
val backendUrl: String,
val username: String,
val password: String,
val deviceId: String,
val deviceToken: String
)
data class DeviceHeartbeatInput(
val appVersion: String,
val androidVersion: String,
val pddVersion: String,
val accessibilityEnabled: Boolean,
val pddInstalled: Boolean,
val activeTaskId: String?
)
data class DeviceHeartbeatResult(
val activeTaskId: String?,
val clientStateMatches: Boolean,
val serverTime: String
)
data class ClaimResult(
val task: RemotePurchaseTask?,
val serverTime: String?
)
data class StartResult(
val task: RemotePurchaseTask,
val executionId: String,
val currentStep: String,
val executionExpiresAt: String,
val serverTime: String
)
data class TaskHeartbeatResult(
val task: RemotePurchaseTask,
val executionId: String,
val currentStep: String,
val executionExpiresAt: String,
val cancelRequested: Boolean,
val serverTime: String
)
object ExecutionAuthorization {
fun isExpired(
expiresAt: String,
serverClockOffsetMillis: Long,
nowEpochMillis: Long
): Boolean {
val serverNow = nowEpochMillis + serverClockOffsetMillis
return runCatching { Instant.parse(expiresAt).toEpochMilli() <= serverNow }
.getOrDefault(true)
}
fun serverClockOffset(serverTime: String, localNowEpochMillis: Long): Long =
Instant.parse(serverTime).toEpochMilli() - localNowEpochMillis
}
object StartAuthorization {
const val MINIMUM_PREVIEW_DURATION_MS = 2_000L
fun isPreviewConfirmed(
previewReadyAtEpochMillis: Long,
nowEpochMillis: Long
): Boolean =
nowEpochMillis - previewReadyAtEpochMillis >=
MINIMUM_PREVIEW_DURATION_MS
}
fun RemotePurchaseTask.toProbeTask(image: ReferenceImageRecord): ProbeTask =
ProbeTask(
probeId = id,
sourceOrderNo = id,
sourceStoreName = "管理后台",
title = title,
sku = sku,
quantity = quantity,
referenceImage = ProbeReferenceImage(
relativePath = image.relativePath,
mediaType = "image/jpeg",
sizeBytes = image.sizeBytes,
sha256 = image.sha256
)
)
@@ -0,0 +1,509 @@
package com.roubao.autopilot.procurement
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import com.roubao.autopilot.BuildConfig
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.io.IOException
import java.security.SecureRandom
import java.util.Base64
enum class ExecutionSyncDecision {
CONTINUE,
STOP
}
class ProcurementRepository(
context: Context,
private val store: ProcurementStateStore,
private val api: ProcurementRemoteApi,
private val storageFailure: String? = null
) {
private val appContext = context.applicationContext
private val mutex = Mutex()
@Volatile
private var persisted = store.load()
private val _uiState = MutableStateFlow(
buildUiState().copy(error = storageFailure)
)
val uiState: StateFlow<ProcurementUiState> = _uiState.asStateFlow()
val hasRunningExecution: Boolean
get() = storageFailure == null &&
persisted.execution?.safetyStopped == false
suspend fun login(input: LoginInput): Boolean = operation {
require(input.password.isNotEmpty()) { "请输入采购员密码" }
val normalizedUrl = BackendEndpointPolicy.normalize(
input.backendUrl,
BuildConfig.DEBUG
).getOrElse { throw IllegalArgumentException(it.message) }
persisted.session?.takeIf {
persisted.claim != null || persisted.execution != null
}?.let { current ->
require(
normalizedUrl == current.backendUrl &&
input.username.trim() == current.username &&
input.deviceId.trim() == current.deviceId
) { "当前任务结束前只能重新认证同一采购员和设备" }
}
val deviceToken = input.deviceToken.ifBlank {
persisted.deviceToken ?: ""
}
require(deviceToken.isNotBlank()) { "请输入预授权设备密钥" }
val session = api.login(
normalizedUrl,
input.copy(deviceToken = deviceToken),
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE
)
persisted = persisted.copy(
session = session,
deviceToken = deviceToken
)
saveAndPublish(
backendOnline = true,
message = "设备登录成功"
)
true
} ?: false
suspend fun claimNext(snapshot: DeviceReadinessSnapshot): Boolean = operation {
val session = requireValidSession()
val existing = persisted.claim
if (existing?.task != null) {
if (existing.referenceImage == null) {
downloadAndPersistReference(session, existing, existing.task)
}
saveAndPublish(
backendOnline = true,
message = "已恢复当前领取任务"
)
return@operation true
}
val heartbeat = api.deviceHeartbeat(
session,
snapshot.toHeartbeatInput(persisted.claim?.task?.id)
)
if (heartbeat.activeTaskId != null &&
existing == null
) {
throw IllegalStateException("后台显示本设备已有任务,请先恢复处理")
}
val claim = existing ?: ClaimContext(
token = newOpaqueSecret(),
idempotencyKey = newOpaqueSecret()
).also {
persisted = persisted.copy(claim = it)
store.save(persisted)
}
val result = api.claimNext(
session,
session.deviceId,
claim.token,
claim.idempotencyKey
)
val task = result.task
if (task == null) {
persisted = persisted.copy(claim = null)
saveAndPublish(
backendOnline = true,
message = "暂无待采购任务"
)
return@operation false
}
val claimed = claim.copy(task = task)
persisted = persisted.copy(claim = claimed)
store.save(persisted)
downloadAndPersistReference(session, claimed, task)
saveAndPublish(
backendOnline = true,
message = "任务已领取,请核对后开始"
)
true
} ?: false
suspend fun start(): Boolean = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim) { "没有可开始的任务" }
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
require(claim.referenceImage != null) { "参考图尚未安全下载" }
val previewReadyAt = requireNotNull(claim.previewReadyAtEpochMillis) {
"任务预览尚未准备完成"
}
require(
StartAuthorization.isPreviewConfirmed(
previewReadyAt,
System.currentTimeMillis()
)
) { "请先核对任务信息,再确认开始" }
val startKey = claim.startIdempotencyKey ?: newOpaqueSecret().also {
persisted = persisted.copy(
claim = claim.copy(startIdempotencyKey = it)
)
store.save(persisted)
}
val requestStartedAt = System.currentTimeMillis()
val result = api.start(session, task, claim.token, startKey)
val execution = RunningExecution(
id = result.executionId,
currentStep = CONTROLLED_WORKFLOW_STEP,
expiresAt = result.executionExpiresAt,
serverClockOffsetMillis = ExecutionAuthorization.serverClockOffset(
result.serverTime,
requestStartedAt
)
)
persisted = persisted.copy(
claim = persisted.claim?.copy(task = result.task),
execution = execution
)
saveAndPublish(
backendOnline = true,
message = "受控采购流程已开始"
)
ProcurementExecutionService.start(appContext)
true
} ?: false
suspend fun release(): Boolean = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim) { "没有已领取任务" }
require(persisted.execution == null) { "运行中的任务不能直接释放" }
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
val releaseKey = claim.releaseIdempotencyKey ?: newOpaqueSecret().also {
persisted = persisted.copy(
claim = claim.copy(releaseIdempotencyKey = it)
)
store.save(persisted)
}
api.release(session, task, claim.token, releaseKey)
clearCurrentTask()
saveAndPublish(
backendOnline = true,
message = "任务已退回待领取队列"
)
true
} ?: false
suspend fun synchronizeRunning(): ExecutionSyncDecision =
synchronize(allowSafetyStoppedSync = false)
private suspend fun synchronize(
allowSafetyStoppedSync: Boolean
): ExecutionSyncDecision = mutex.withLock {
val claim = persisted.claim
val task = claim?.task
var execution = persisted.execution
if (claim == null || task == null || execution == null) {
return@withLock ExecutionSyncDecision.STOP
}
if (execution.isExpired() && !execution.safetyStopped) {
execution = execution.copy(
currentStep = SAFE_STOPPED_STEP,
safetyStopped = true
)
persisted = persisted.copy(execution = execution)
store.save(persisted)
publish(
backendOnline = false,
error = "离线执行授权已到期,自动化已安全停止"
)
}
if (execution.safetyStopped && !allowSafetyStoppedSync) {
return@withLock ExecutionSyncDecision.STOP
}
val session = persisted.session
if (session == null || !session.isValid()) {
publish(
backendOnline = false,
error = "登录令牌已过期;授权截止前保持停止外部动作"
)
return@withLock if (execution.safetyStopped) {
ExecutionSyncDecision.STOP
} else {
ExecutionSyncDecision.CONTINUE
}
}
return@withLock try {
val requestStartedAt = System.currentTimeMillis()
val result = api.heartbeat(session, task, execution, claim.token)
val updatedExecution = execution.copy(
id = result.executionId,
currentStep = result.currentStep,
expiresAt = result.executionExpiresAt,
serverClockOffsetMillis =
ExecutionAuthorization.serverClockOffset(
result.serverTime,
requestStartedAt
),
safetyStopped = execution.safetyStopped
)
persisted = persisted.copy(
claim = claim.copy(task = result.task),
execution = updatedExecution
)
store.save(persisted)
if (result.cancelRequested) {
acknowledgeCancellation(session)
ExecutionSyncDecision.STOP
} else {
publish(
backendOnline = true,
message = if (updatedExecution.safetyStopped) {
"后台状态已同步,任务仍保持安全停止"
} else {
"进度已同步"
}
)
if (updatedExecution.safetyStopped) {
ExecutionSyncDecision.STOP
} else {
ExecutionSyncDecision.CONTINUE
}
}
} catch (_: Exception) {
publish(
backendOnline = false,
message = "后台暂时离线,将在授权截止前重试"
)
if (execution.safetyStopped || execution.isExpired()) {
ExecutionSyncDecision.STOP
} else {
ExecutionSyncDecision.CONTINUE
}
}
}
suspend fun synchronizeNow(): Boolean =
synchronize(allowSafetyStoppedSync = true) ==
ExecutionSyncDecision.CONTINUE
fun currentProbeTask() =
persisted.claim?.let { claim ->
val task = claim.task
val image = claim.referenceImage
if (task != null && image != null) task.toProbeTask(image) else null
}
private suspend fun acknowledgeCancellation(session: ProcurementSession) {
val claim = requireNotNull(persisted.claim)
val task = requireNotNull(claim.task)
val execution = requireNotNull(persisted.execution)
val acknowledgementKey =
execution.cancelAcknowledgementKey ?: newOpaqueSecret().also {
persisted = persisted.copy(
execution = execution.copy(cancelAcknowledgementKey = it)
)
store.save(persisted)
}
api.acknowledgeCancellation(
session,
task,
requireNotNull(persisted.execution),
claim.token,
acknowledgementKey
)
clearCurrentTask()
saveAndPublish(
backendOnline = true,
message = "后台取消已确认,任务已停止"
)
}
private suspend fun downloadAndPersistReference(
session: ProcurementSession,
claim: ClaimContext,
task: RemotePurchaseTask
) {
val image = api.downloadReferenceImage(session, task, claim.token)
val relativePath = "$REFERENCE_DIRECTORY/reference-${task.id}.jpg"
val directory = File(appContext.filesDir, REFERENCE_DIRECTORY)
check(directory.exists() || directory.mkdirs()) { "无法创建参考图目录" }
val destination = File(appContext.filesDir, relativePath)
val temporary = File(directory, ".reference-${task.id}.tmp")
temporary.outputStream().use {
it.write(image.bytes)
it.fd.sync()
}
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(temporary.absolutePath, options)
check(options.outWidth > 0 && options.outHeight > 0) { "参考图无法解码" }
check(
options.outWidth <= MAX_REFERENCE_DIMENSION &&
options.outHeight <= MAX_REFERENCE_DIMENSION &&
options.outWidth.toLong() * options.outHeight <=
MAX_REFERENCE_PIXELS
) { "参考图尺寸超过安全限制" }
check(!destination.exists() || destination.delete()) { "无法替换参考图" }
check(temporary.renameTo(destination)) { "无法保存参考图" }
persisted = persisted.copy(
claim = claim.copy(
task = task,
referenceImage = ReferenceImageRecord(
relativePath = relativePath,
sizeBytes = image.bytes.size.toLong(),
sha256 = image.sha256
),
previewReadyAtEpochMillis = System.currentTimeMillis()
)
)
store.save(persisted)
}
private fun clearCurrentTask() {
persisted.claim?.referenceImage?.let {
File(appContext.filesDir, it.relativePath).delete()
}
persisted = persisted.copy(claim = null, execution = null)
}
private fun requireValidSession(): ProcurementSession {
val session = persisted.session
require(session != null && session.isValid()) { "采购员登录已过期,请重新登录" }
return session
}
private suspend fun <T> operation(block: suspend () -> T): T? = mutex.withLock {
if (storageFailure != null) {
_uiState.value = _uiState.value.copy(error = storageFailure)
return@withLock null
}
_uiState.value = _uiState.value.copy(busy = true, error = null)
try {
block()
} catch (error: Exception) {
publish(
backendOnline = if (error is IOException) false else _uiState.value.backendOnline,
error = userMessage(error)
)
null
} finally {
_uiState.value = _uiState.value.copy(busy = false)
}
}
private fun saveAndPublish(
backendOnline: Boolean?,
message: String? = null
) {
store.save(persisted)
publish(backendOnline = backendOnline, message = message)
}
private fun publish(
backendOnline: Boolean?,
message: String? = null,
error: String? = null
) {
_uiState.value = buildUiState().copy(
busy = _uiState.value.busy,
backendOnline = backendOnline,
message = message,
error = error
)
}
private fun buildUiState(): ProcurementUiState {
val session = persisted.session
val claim = persisted.claim
val execution = persisted.execution
val phase = when {
execution != null &&
(execution.safetyStopped || execution.isExpired()) ->
ProcurementPhase.AUTHORIZATION_EXPIRED
execution != null -> ProcurementPhase.RUNNING
claim?.task != null -> ProcurementPhase.CLAIMED
session?.isValid() == true -> ProcurementPhase.IDLE
else -> ProcurementPhase.SIGNED_OUT
}
val imagePath = claim?.referenceImage?.let {
File(appContext.filesDir, it.relativePath)
.takeIf(File::isFile)
?.absolutePath
}
return ProcurementUiState(
phase = phase,
profile = ConnectionProfile(
backendUrl = session?.backendUrl ?: "http://127.0.0.1:8080",
username = session?.username.orEmpty(),
deviceId = session?.deviceId.orEmpty(),
hasDeviceToken = !persisted.deviceToken.isNullOrBlank()
),
task = claim?.task,
referenceImagePath = imagePath,
execution = execution,
authenticationRequired = session?.isValid() != true
)
}
private fun DeviceReadinessSnapshot.toHeartbeatInput(
activeTaskId: String?
): DeviceHeartbeatInput =
DeviceHeartbeatInput(
appVersion = BuildConfig.VERSION_NAME,
androidVersion = androidVersion,
pddVersion = pinduoduo.versionName
?: pinduoduo.versionCode?.toString()
?: "",
accessibilityEnabled = accessibilityEnabled,
pddInstalled = pinduoduo.installed,
activeTaskId = activeTaskId
)
private fun userMessage(error: Exception): String =
when (error) {
is ProcurementApiException -> error.message ?: "后台请求失败"
is IOException -> "无法连接管理后台"
is IllegalArgumentException,
is IllegalStateException -> error.message ?: "本地任务状态无效"
else -> "任务操作失败"
}
private fun newOpaqueSecret(): String {
val bytes = ByteArray(32)
SECURE_RANDOM.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
companion object {
private const val REFERENCE_DIRECTORY = "procurement"
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
private const val MAX_REFERENCE_DIMENSION = 4_096
private const val MAX_REFERENCE_PIXELS = 20_000_000L
private val SECURE_RANDOM = SecureRandom()
fun create(context: Context): ProcurementRepository =
try {
ProcurementRepository(
context,
ProcurementSecureStore(context),
ProcurementApiClient()
)
} catch (_: Exception) {
ProcurementRepository(
context,
UnavailableProcurementStateStore,
ProcurementApiClient(),
storageFailure = "设备安全存储不可用,后台采购功能已停用"
)
}
}
}
private object UnavailableProcurementStateStore : ProcurementStateStore {
override fun load() = PersistedProcurementState()
override fun save(state: PersistedProcurementState) {
throw IllegalStateException("设备安全存储不可用")
}
}
@@ -0,0 +1,191 @@
package com.roubao.autopilot.procurement
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
interface ProcurementStateStore {
fun load(): PersistedProcurementState
fun save(state: PersistedProcurementState)
}
class ProcurementSecureStore(context: Context) : ProcurementStateStore {
private val preferences = EncryptedSharedPreferences.create(
context.applicationContext,
FILE_NAME,
MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
override fun load(): PersistedProcurementState {
val raw = preferences.getString(STATE_KEY, null) ?: return PersistedProcurementState()
return runCatching { decodeState(JSONObject(raw)) }.getOrElse {
throw IllegalStateException("加密任务状态损坏,已停止恢复", it)
}
}
override fun save(state: PersistedProcurementState) {
check(
preferences.edit()
.putString(STATE_KEY, encodeState(state).toString())
.commit()
) { "无法持久化加密任务状态" }
}
private fun encodeState(state: PersistedProcurementState): JSONObject =
JSONObject().apply {
putNullable("device_token", state.deviceToken)
state.session?.let { session ->
put(
"session",
JSONObject().apply {
put("backend_url", session.backendUrl)
put("username", session.username)
put("device_id", session.deviceId)
put("access_token", session.accessToken)
put("expires_at_ms", session.expiresAtEpochMillis)
}
)
}
state.claim?.let { claim ->
put(
"claim",
JSONObject().apply {
put("token", claim.token)
put("idempotency_key", claim.idempotencyKey)
putNullable("start_idempotency_key", claim.startIdempotencyKey)
putNullable("release_idempotency_key", claim.releaseIdempotencyKey)
claim.previewReadyAtEpochMillis?.let {
put("preview_ready_at_ms", it)
}
claim.task?.let { put("task", encodeTask(it)) }
claim.referenceImage?.let { image ->
put(
"reference_image",
JSONObject().apply {
put("relative_path", image.relativePath)
put("size_bytes", image.sizeBytes)
put("sha256", image.sha256)
}
)
}
}
)
}
state.execution?.let { execution ->
put(
"execution",
JSONObject().apply {
put("id", execution.id)
put("current_step", execution.currentStep)
put("expires_at", execution.expiresAt)
put("server_clock_offset_ms", execution.serverClockOffsetMillis)
put("safety_stopped", execution.safetyStopped)
putNullable(
"cancel_acknowledgement_key",
execution.cancelAcknowledgementKey
)
}
)
}
}
private fun decodeState(json: JSONObject): PersistedProcurementState =
PersistedProcurementState(
session = json.optionalObject("session")?.let {
ProcurementSession(
backendUrl = it.getString("backend_url"),
username = it.getString("username"),
deviceId = it.getString("device_id"),
accessToken = it.getString("access_token"),
expiresAtEpochMillis = it.getLong("expires_at_ms")
)
},
deviceToken = json.optionalString("device_token"),
claim = json.optionalObject("claim")?.let { claim ->
ClaimContext(
token = claim.getString("token"),
idempotencyKey = claim.getString("idempotency_key"),
task = claim.optionalObject("task")?.let(::decodeTask),
referenceImage = claim.optionalObject("reference_image")?.let { image ->
ReferenceImageRecord(
relativePath = image.getString("relative_path"),
sizeBytes = image.getLong("size_bytes"),
sha256 = image.getString("sha256")
)
},
previewReadyAtEpochMillis =
if (claim.has("preview_ready_at_ms")) {
claim.getLong("preview_ready_at_ms")
} else {
null
},
startIdempotencyKey = claim.optionalString("start_idempotency_key"),
releaseIdempotencyKey =
claim.optionalString("release_idempotency_key")
)
},
execution = json.optionalObject("execution")?.let {
RunningExecution(
id = it.getString("id"),
currentStep = it.getString("current_step"),
expiresAt = it.getString("expires_at"),
serverClockOffsetMillis = it.getLong("server_clock_offset_ms"),
safetyStopped = it.optBoolean("safety_stopped", false),
cancelAcknowledgementKey =
it.optionalString("cancel_acknowledgement_key")
)
}
)
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
JSONObject().apply {
put("id", task.id)
put("status", task.status)
put("version", task.version)
put("claim_generation", task.claimGeneration)
putNullable("claim_expires_at", task.claimExpiresAt)
put("title", task.title)
put("description", task.description)
put("sku", task.sku)
put("reference_image_url", task.referenceImageUrl)
put("quantity", task.quantity)
putNullable("max_budget", task.maxBudget)
put("currency", task.currency)
}
private fun decodeTask(json: JSONObject): RemotePurchaseTask =
RemotePurchaseTask(
id = json.getString("id"),
status = json.getString("status"),
version = json.getLong("version"),
claimGeneration = json.getLong("claim_generation"),
claimExpiresAt = json.optionalString("claim_expires_at"),
title = json.getString("title"),
description = json.optString("description"),
sku = json.getString("sku"),
referenceImageUrl = json.getString("reference_image_url"),
quantity = json.getInt("quantity"),
maxBudget = json.optionalString("max_budget"),
currency = json.optString("currency", "CNY")
)
private fun JSONObject.putNullable(name: String, value: String?) {
put(name, value ?: JSONObject.NULL)
}
private fun JSONObject.optionalString(name: String): String? =
if (has(name) && !isNull(name)) getString(name) else null
private fun JSONObject.optionalObject(name: String): JSONObject? =
if (has(name) && !isNull(name)) getJSONObject(name) else null
private companion object {
const val FILE_NAME = "procurement_secure_state"
const val STATE_KEY = "state_v1"
}
}
@@ -0,0 +1,452 @@
package com.roubao.autopilot.ui.screens
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.procurement.LoginInput
import com.roubao.autopilot.procurement.ProcurementPhase
import com.roubao.autopilot.procurement.ProcurementUiState
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import com.roubao.autopilot.ui.theme.BaoziTheme
import java.time.Instant
import kotlinx.coroutines.delay
@Composable
fun ProcurementScreen(
state: ProcurementUiState,
readiness: DeviceReadinessSnapshot,
onLogin: (LoginInput) -> Unit,
onClaim: () -> Unit,
onStart: () -> Unit,
onRelease: () -> Unit,
onSync: () -> Unit
) {
val colors = BaoziTheme.colors
var confirmStart by remember(state.task?.id) { mutableStateOf(false) }
if (confirmStart) {
AlertDialog(
onDismissRequest = { confirmStart = false },
title = { Text("确认开始采购任务?") },
text = { Text("开始后任务将由本设备持有,直到完成、取消或人工结束。") },
dismissButton = {
TextButton(onClick = { confirmStart = false }) {
Text("返回核对")
}
},
confirmButton = {
Button(
onClick = {
confirmStart = false
onStart()
}
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text("确认开始")
}
}
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(colors.background),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "采购任务",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary
)
Text(
text = phaseLabel(state),
fontSize = 14.sp,
color = phaseColor(state)
)
}
if (state.busy) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = colors.primary
)
}
}
}
state.error?.let { error ->
item {
Text(error, color = colors.error, fontSize = 14.sp)
}
}
state.message?.let { message ->
item {
Text(message, color = colors.textSecondary, fontSize = 14.sp)
}
}
when (state.phase) {
ProcurementPhase.SIGNED_OUT -> item {
LoginSection(
state = state,
onLogin = onLogin
)
}
ProcurementPhase.IDLE -> item {
IdleSection(
state = state,
readiness = readiness,
onClaim = onClaim
)
}
ProcurementPhase.CLAIMED -> {
if (state.authenticationRequired) {
item { LoginSection(state = state, onLogin = onLogin) }
}
item { TaskDetails(state) }
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onRelease,
enabled = !state.busy,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.Close, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text("退回")
}
Button(
onClick = { confirmStart = true },
enabled = !state.busy &&
state.referenceImagePath != null,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text("开始")
}
}
}
}
ProcurementPhase.RUNNING,
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
if (state.authenticationRequired) {
item { LoginSection(state = state, onLogin = onLogin) }
}
item { TaskDetails(state) }
item {
ExecutionDetails(state)
}
item {
Button(
onClick = onSync,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text("立即同步")
}
}
}
}
}
}
@Composable
private fun LoginSection(
state: ProcurementUiState,
onLogin: (LoginInput) -> Unit
) {
val colors = BaoziTheme.colors
var backendUrl by remember(state.profile.backendUrl) {
mutableStateOf(state.profile.backendUrl)
}
var username by remember(state.profile.username) {
mutableStateOf(state.profile.username)
}
var password by remember { mutableStateOf("") }
var deviceId by remember(state.profile.deviceId) {
mutableStateOf(state.profile.deviceId)
}
var deviceToken by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"采购员登录",
color = colors.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold
)
OutlinedTextField(
value = backendUrl,
onValueChange = { backendUrl = it },
label = { Text("后台地址") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("采购员账号") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = deviceId,
onValueChange = { deviceId = it },
label = { Text("设备 ID") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = deviceToken,
onValueChange = { deviceToken = it },
label = {
Text(
if (state.profile.hasDeviceToken) {
"设备密钥(已安全保存)"
} else {
"设备密钥"
}
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
onLogin(
LoginInput(
backendUrl = backendUrl,
username = username,
password = password,
deviceId = deviceId,
deviceToken = deviceToken
)
)
password = ""
deviceToken = ""
},
enabled = !state.busy,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Lock, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text("登录")
}
}
}
@Composable
private fun IdleSection(
state: ProcurementUiState,
readiness: DeviceReadinessSnapshot,
onClaim: () -> Unit
) {
val colors = BaoziTheme.colors
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
DetailRow("采购员", state.profile.username)
DetailRow("设备", state.profile.deviceId)
DetailRow(
"设备状态",
if (readiness.canStartProbe) "已就绪" else "存在阻塞项"
)
Divider(color = colors.surfaceVariant)
Button(
onClick = onClaim,
enabled = !state.busy && readiness.canStartProbe,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.ShoppingCart, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text("获取任务")
}
}
}
@Composable
private fun TaskDetails(state: ProcurementUiState) {
val colors = BaoziTheme.colors
val task = state.task ?: return
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
task.title,
color = colors.textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
state.referenceImagePath?.let { path ->
val bitmap = remember(path) {
BitmapFactory.decodeFile(path)?.asImageBitmap()
}
bitmap?.let {
Image(
bitmap = it,
contentDescription = "任务参考图",
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
)
}
}
DetailRow("SKU", task.sku)
DetailRow("数量", task.quantity.toString())
task.maxBudget?.let {
DetailRow("最高预算", "$it ${task.currency}")
}
if (task.description.isNotBlank()) {
DetailRow("描述", task.description)
}
DetailRow("任务 ID", task.id)
}
}
@Composable
private fun ExecutionDetails(state: ProcurementUiState) {
val colors = BaoziTheme.colors
val execution = state.execution ?: return
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(execution.expiresAt) {
while (true) {
now = System.currentTimeMillis()
delay(1_000)
}
}
val remainingSeconds = runCatching {
val deadline = Instant.parse(execution.expiresAt).toEpochMilli() -
execution.serverClockOffsetMillis
((deadline - now).coerceAtLeast(0L) / 1_000L)
}.getOrDefault(0)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Divider(color = colors.surfaceVariant)
DetailRow("当前步骤", execution.currentStep)
DetailRow(
"后台连接",
when (state.backendOnline) {
true -> "在线"
false -> "离线"
null -> "检查中"
}
)
DetailRow(
"剩余授权",
if (state.phase == ProcurementPhase.AUTHORIZATION_EXPIRED) {
"已到期,自动化已停止"
} else {
"%02d:%02d".format(
remainingSeconds / 60,
remainingSeconds % 60
)
}
)
Text(
"订单提交保持禁用",
color = colors.warning,
fontSize = 14.sp
)
}
}
@Composable
private fun DetailRow(label: String, value: String) {
val colors = BaoziTheme.colors
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top
) {
Text(
label,
color = colors.textSecondary,
fontSize = 14.sp,
modifier = Modifier.weight(0.28f)
)
Text(
value,
color = colors.textPrimary,
fontSize = 14.sp,
modifier = Modifier.weight(0.72f)
)
}
}
@Composable
private fun phaseColor(state: ProcurementUiState) =
when (state.phase) {
ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error
ProcurementPhase.RUNNING -> BaoziTheme.colors.success
else -> BaoziTheme.colors.textSecondary
}
private fun phaseLabel(state: ProcurementUiState): String =
when (state.phase) {
ProcurementPhase.SIGNED_OUT -> "未登录管理后台"
ProcurementPhase.IDLE -> "等待领取"
ProcurementPhase.CLAIMED -> "已领取,等待开始"
ProcurementPhase.RUNNING -> "受控流程运行中"
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
}
@@ -0,0 +1,51 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BackendEndpointPolicyTest {
@Test
fun releaseAcceptsOnlyHttps() {
assertEquals(
"https://admin.example.com:8443",
BackendEndpointPolicy.normalize(
"https://admin.example.com:8443/",
debugBuild = false
).getOrThrow()
)
assertFalse(
BackendEndpointPolicy.normalize(
"http://admin.example.com:8080",
debugBuild = false
).isSuccess
)
}
@Test
fun debugHttpIsLimitedToLoopback() {
assertTrue(
BackendEndpointPolicy.normalize(
"http://127.0.0.1:8080",
debugBuild = true
).isSuccess
)
assertFalse(
BackendEndpointPolicy.normalize(
"http://192.168.1.20:8080",
debugBuild = true
).isSuccess
)
}
@Test
fun rejectsEmbeddedCredentialsAndPaths() {
assertFalse(
BackendEndpointPolicy.normalize(
"https://user:pass@example.com/api",
debugBuild = false
).isSuccess
)
}
}
@@ -0,0 +1,59 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ExecutionAuthorizationTest {
@Test
fun usesServerClockOffsetForOfflineDeadline() {
val expiry = "2026-07-27T01:30:00Z"
val localAtServerOneOClock = 1_000L
val offset = ExecutionAuthorization.serverClockOffset(
"2026-07-27T01:00:00Z",
localAtServerOneOClock
)
assertFalse(
ExecutionAuthorization.isExpired(
expiry,
offset,
localAtServerOneOClock + 29 * 60_000L
)
)
assertTrue(
ExecutionAuthorization.isExpired(
expiry,
offset,
localAtServerOneOClock + 30 * 60_000L
)
)
}
@Test
fun malformedExpiryFailsClosed() {
assertTrue(
ExecutionAuthorization.isExpired(
"not-a-time",
serverClockOffsetMillis = 0,
nowEpochMillis = 0
)
)
}
@Test
fun startRequiresAStablePreviewWindow() {
assertFalse(
StartAuthorization.isPreviewConfirmed(
previewReadyAtEpochMillis = 10_000L,
nowEpochMillis = 11_999L
)
)
assertTrue(
StartAuthorization.isPreviewConfirmed(
previewReadyAtEpochMillis = 10_000L,
nowEpochMillis = 12_000L
)
)
}
}
@@ -0,0 +1,221 @@
package com.roubao.autopilot.procurement
import java.security.MessageDigest
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ProcurementApiClientTest {
private lateinit var server: MockWebServer
private lateinit var api: ProcurementApiClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
api = ProcurementApiClient()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun loginClaimStartAndReferenceImageFollowDeviceContract() = runBlocking {
server.enqueue(
jsonResponse(
"""
{
"access_token":"access-token-value",
"expires_in":3600,
"device":{"id":"device-id","enabled":true}
}
""".trimIndent()
)
)
val baseUrl = server.url("/").toString().trimEnd('/')
val session = api.login(
baseUrl,
LoginInput(
backendUrl = baseUrl,
username = "buyer01",
password = "private-password",
deviceId = "device-id",
deviceToken = "private-device-token"
),
appVersion = "1.4.2",
androidVersion = "16"
)
val loginRequest = server.takeRequest()
assertEquals("/api/v1/auth/token", loginRequest.path)
assertTrue(loginRequest.body.readUtf8().contains("\"username\":\"buyer01\""))
server.enqueue(
jsonResponse(
"""
{
"task":${taskJson("CLAIMED", 2)},
"replayed":false,
"server_time":"2026-07-27T01:00:00Z"
}
""".trimIndent()
)
)
val claim = api.claimNext(
session,
"device-id",
"claim-token-value",
"claim-idempotency-key"
)
val claimRequest = server.takeRequest()
assertEquals("/api/v1/tasks/claim-next", claimRequest.path)
assertEquals("claim-token-value", claimRequest.getHeader("X-Claim-Token"))
assertEquals(
"claim-idempotency-key",
claimRequest.getHeader("Idempotency-Key")
)
val task = assertNotNull(claim.task).let { claim.task!! }
assertEquals("测试商品", task.title)
assertEquals(2, task.quantity)
val jpeg = byteArrayOf(
0xFF.toByte(),
0xD8.toByte(),
0xFF.toByte(),
0xD9.toByte()
)
val hash = MessageDigest.getInstance("SHA-256")
.digest(jpeg)
.joinToString("") { "%02x".format(it) }
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "image/jpeg")
.setHeader("ETag", "\"$hash\"")
.setBody(Buffer().write(jpeg))
)
val image = api.downloadReferenceImage(
session,
task,
"claim-token-value"
)
val imageRequest = server.takeRequest()
assertTrue(imageRequest.path!!.startsWith("/api/v1/tasks/task-id/reference-image"))
assertEquals(hash, image.sha256)
server.enqueue(
jsonResponse(
"""
{
"task":${taskJson("RUNNING", 3)},
"execution":{
"id":"execution-id",
"current_step":"PREFLIGHT",
"order_submitted":false,
"execution_expires_at":"2026-07-27T01:30:00Z"
},
"replayed":false,
"server_time":"2026-07-27T01:00:00Z"
}
""".trimIndent()
)
)
val started = api.start(
session,
task,
"claim-token-value",
"start-idempotency-key"
)
assertEquals("execution-id", started.executionId)
assertEquals("2026-07-27T01:30:00Z", started.executionExpiresAt)
}
@Test
fun noTaskIsAStableEmptyResult() = runBlocking {
server.enqueue(MockResponse().setResponseCode(204))
val result = api.claimNext(
session(),
"device-id",
"claim-token-value",
"claim-idempotency-key"
)
assertEquals(null, result.task)
}
@Test
fun referenceImageMustStayOnTheBackendOrigin() = runBlocking {
val failure = runCatching {
api.downloadReferenceImage(
session(),
task(
referenceImageUrl =
"https://credentials.example.invalid/reference.jpg"
),
"claim-token-value"
)
}.exceptionOrNull()
assertTrue(failure is ProcurementApiException)
assertEquals(
"REFERENCE_IMAGE_ORIGIN_INVALID",
(failure as ProcurementApiException).code
)
assertEquals(0, server.requestCount)
}
private fun session() = ProcurementSession(
backendUrl = server.url("/").toString().trimEnd('/'),
username = "buyer01",
deviceId = "device-id",
accessToken = "access-token-value",
expiresAtEpochMillis = Long.MAX_VALUE
)
private fun taskJson(status: String, version: Int): String =
"""
{
"id":"task-id",
"status":"$status",
"version":$version,
"claim_generation":1,
"claim_expires_at":"2026-07-27T01:30:00Z",
"title":"测试商品",
"description":"测试描述",
"sku":"SKU-01",
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
"quantity":2,
"max_budget":"20.00",
"currency":"CNY"
}
""".trimIndent()
private fun task(referenceImageUrl: String) = RemotePurchaseTask(
id = "task-id",
status = "CLAIMED",
version = 2,
claimGeneration = 1,
claimExpiresAt = "2026-07-27T01:30:00Z",
title = "测试商品",
description = "测试描述",
sku = "SKU-01",
referenceImageUrl = referenceImageUrl,
quantity = 2,
maxBudget = "20.00",
currency = "CNY"
)
private fun jsonResponse(body: String) =
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(body)
}