diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index a301ad2..28e5513 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -176,6 +176,7 @@ class AgentForegroundService : Service() { // Separate, serialized I/O ticks keep history reads and result observation off the // heartbeat scheduler and, especially, off the single purchase execution thread. repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS) + repurchaseExecutor.scheduleWithFixedDelay(::refreshDeviceBusy, 1, 2, TimeUnit.SECONDS) diagnosticInstance = this } @@ -197,6 +198,12 @@ class AgentForegroundService : Service() { return START_STICKY } } + if (intent?.action == ACTION_OPEN_PDD_PRODUCT) { + val goodsId = intent.getStringExtra(EXTRA_OPEN_PDD_GOODS_ID) + val purchase = intent.getBooleanExtra(EXTRA_OPEN_PDD_PURCHASE, false) + runCatching { repurchaseExecutor.execute { openPddProduct(goodsId, purchase) } } + return START_STICKY + } if (intent?.action == ACTION_BACKFILL_STOP) { backfillGuard.cancelled.set(true) return START_STICKY @@ -228,6 +235,7 @@ class AgentForegroundService : Service() { repurchase.requestStop(repurchase.state.roundId) repurchaseExecutor.shutdownNow() repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购") + deviceBusyReason = null backfillGuard.cancelled.set(true) runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) } cancelIdleReturn("服务已停止") @@ -497,9 +505,47 @@ class AgentForegroundService : Service() { updateNotification(state.message, force = true) } - private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null && - purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() && - stateStore.read().currentTaskId == null && !backfillState.running + private fun deviceBusyInputs(includeRepurchase: Boolean): DeviceBusyPolicy.Inputs { + val mutexTaskId = taskMutex.currentTaskId() + return DeviceBusyPolicy.Inputs( + repurchaseBusy = includeRepurchase && repurchaseState.busy, + backfillRunning = backfillState.running, + currentPageCollecting = mutexTaskId == CURRENT_PAGE_RESERVATION_ID, + taskActive = working.get() || mutexTaskId != null || purchaseStore.activeTaskId() != null || + stateStore.read().currentTaskId != null, + unuploadedPurchase = purchaseStore.pendingOutbox().isNotEmpty(), + ) + } + + // The repurchase round itself asks this question between its tasks, so it must not count its own busy flag. + private fun repurchaseLocalIdle(): Boolean = DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = false)) == null + + /** Publishes the read-only busy reason for the history detail buttons; click time re-checks. */ + private fun refreshDeviceBusy() { + runCatching { + val reason = if (repurchaseClosed.get()) null else DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) + if (reason != deviceBusyReason) { + deviceBusyReason = reason + sendBroadcast(Intent(ACTION_DEVICE_BUSY_STATE).setPackage(packageName)) + } + } + } + + private fun openPddProduct(goodsId: String?, purchase: Boolean) { + val result = runCatching { + PddManualOpenCoordinator( + busyReason = { synchronized(taskMutex) { DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) } }, + invalidateHandoff = probeHandoff::invalidate, + launch = { url, preferDirect, diagnostic -> + cn.ilapage.goauto.agent.automation.PddLinkLauncher(this).open(url, preferDirect, diagnostic) + }, + ).run(goodsId, purchase) + }.getOrDefault(PddManualOpenResult.Failed) + sendBroadcast(Intent(ACTION_OPEN_PDD_RESULT).apply { + setPackage(packageName) + putExtra(EXTRA_OPEN_PDD_MESSAGE, PddManualOpenCoordinator.message(result)) + }) + } private fun repurchaseEnvironment(session: RepurchaseSession): String? { if (repurchaseClosed.get()) return "服务已经停止" @@ -1811,6 +1857,15 @@ class AgentForegroundService : Service() { const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE" @Volatile var backfillState = OrderBackfillState() private set + const val ACTION_OPEN_PDD_PRODUCT = "cn.ilapage.goauto.agent.OPEN_PDD_PRODUCT" + const val ACTION_OPEN_PDD_RESULT = "cn.ilapage.goauto.agent.OPEN_PDD_RESULT" + const val ACTION_DEVICE_BUSY_STATE = "cn.ilapage.goauto.agent.DEVICE_BUSY_STATE" + const val EXTRA_OPEN_PDD_GOODS_ID = "open_pdd_goods_id" + const val EXTRA_OPEN_PDD_PURCHASE = "open_pdd_purchase" + const val EXTRA_OPEN_PDD_MESSAGE = "open_pdd_message" + /** Null when idle; otherwise the user-facing reason. Updated about every two seconds. */ + @Volatile var deviceBusyReason: String? = null + private set const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT" const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW" const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT" @@ -1867,6 +1922,15 @@ class AgentForegroundService : Service() { } } + fun openPddProduct(context: Context, goodsId: String, purchase: Boolean) { + val intent = Intent(context, AgentForegroundService::class.java).apply { + action = ACTION_OPEN_PDD_PRODUCT + putExtra(EXTRA_OPEN_PDD_GOODS_ID, goodsId) + putExtra(EXTRA_OPEN_PDD_PURCHASE, purchase) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent) + } + fun collectCurrentPage( context: Context, requestId: String, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/PddManualOpen.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PddManualOpen.kt new file mode 100644 index 0000000..4d48476 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/PddManualOpen.kt @@ -0,0 +1,85 @@ +package cn.ilapage.goauto.agent.service + +import cn.ilapage.goauto.agent.automation.PddLaunchEvent +import cn.ilapage.goauto.agent.automation.PddLaunchPath +import cn.ilapage.goauto.agent.automation.PddLaunchStatus + +/** Single source of truth for "the device is busy"; the service and the UI both consume its result. */ +internal object DeviceBusyPolicy { + data class Inputs( + val repurchaseBusy: Boolean = false, + val backfillRunning: Boolean = false, + /** The manual current-page collection holds the task mutex with its own reservation. */ + val currentPageCollecting: Boolean = false, + /** A collection/purchase/image-search task is claimed, running or restored. */ + val taskActive: Boolean = false, + /** Unuploaded purchase results or a purchase waiting for recovery. */ + val unuploadedPurchase: Boolean = false, + ) + + const val REPURCHASE = "重购进行中" + const val BACKFILL = "订单回填中" + const val CURRENT_PAGE = "当前页采集进行中" + const val TASK = "设备正在执行任务" + const val UNUPLOADED = "有未上传的采购结果" + + /** Null means idle. A collection cooldown is intentionally not an input. */ + fun reason(inputs: Inputs): String? = when { + inputs.repurchaseBusy -> REPURCHASE + inputs.backfillRunning -> BACKFILL + inputs.currentPageCollecting -> CURRENT_PAGE + inputs.taskActive -> TASK + inputs.unuploadedPurchase -> UNUPLOADED + else -> null + } +} + +internal sealed class PddManualOpenResult { + object InvalidGoodsId : PddManualOpenResult() + data class Busy(val reason: String) : PddManualOpenResult() + data class Opened(val fallbackNotice: String?) : PddManualOpenResult() + object Failed : PddManualOpenResult() +} + +/** Manual "open in PDD" entry. Only opens the product link; it never clicks anything inside PDD. */ +internal class PddManualOpenCoordinator( + private val busyReason: () -> String?, + private val invalidateHandoff: () -> Unit, + private val launch: (url: String, preferDirect: Boolean, diagnostic: (PddLaunchEvent) -> Unit) -> Boolean, +) { + fun run(goodsId: String?, purchase: Boolean): PddManualOpenResult { + val url = link(goodsId) ?: return PddManualOpenResult.InvalidGoodsId + busyReason()?.let { return PddManualOpenResult.Busy(it) } + // The opened page is no longer the product a previous probe prepared. + invalidateHandoff() + val events = mutableListOf() + val opened = launch(url, purchase) { events += it } + return if (opened) PddManualOpenResult.Opened(fallbackNotice(events)) else PddManualOpenResult.Failed + } + + companion object { + const val OPENED_HINT = "打开期间如有新任务,Agent 会接管屏幕" + const val FAILED = "无法打开拼多多链接" + const val INVALID = "商品编号无效,无法打开" + + fun link(goodsId: String?): String? = + goodsId?.takeIf { it.isNotEmpty() && it.all { c -> c in '0'..'9' } } + ?.let { "https://mobile.yangkeduo.com/goods.html?goods_id=$it" } + + /** Never contains the URL or an exception message; only the simple exception class name. */ + fun fallbackNotice(events: List): String? { + val direct = events.firstOrNull { it.path == PddLaunchPath.DIRECT && it.status != PddLaunchStatus.ACCEPTED } + ?: return null + if (events.none { it.path == PddLaunchPath.BROWSER && it.status == PddLaunchStatus.ACCEPTED }) return null + val name = direct.exceptionClass?.substringAfterLast('.')?.takeIf(String::isNotBlank) + return "拼多多直接打开失败,已改用浏览器" + (name?.let { "($it)" } ?: "") + } + + fun message(result: PddManualOpenResult): String = when (result) { + PddManualOpenResult.InvalidGoodsId -> INVALID + is PddManualOpenResult.Busy -> "${result.reason},暂不能打开拼多多" + is PddManualOpenResult.Opened -> listOfNotNull(result.fallbackNotice, OPENED_HINT).joinToString("\n") + PddManualOpenResult.Failed -> FAILED + } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/service/PddManualOpenTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/service/PddManualOpenTest.kt new file mode 100644 index 0000000..cbe8919 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/service/PddManualOpenTest.kt @@ -0,0 +1,147 @@ +package cn.ilapage.goauto.agent.service + +import cn.ilapage.goauto.agent.automation.PddLaunchEvent +import cn.ilapage.goauto.agent.automation.PddLaunchFallback +import cn.ilapage.goauto.agent.automation.PddLaunchPath +import cn.ilapage.goauto.agent.automation.PddLaunchStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PddManualOpenTest { + private class Harness( + val busy: String? = null, + val direct: () -> Boolean = { true }, + val browser: () -> Boolean = { true }, + ) { + val calls = mutableListOf() + var preferDirect: Boolean? = null + val coordinator = PddManualOpenCoordinator( + busyReason = { calls += "busy"; busy }, + invalidateHandoff = { calls += "invalidate" }, + launch = { _, prefer, diagnostic -> + calls += "launch" + preferDirect = prefer + PddLaunchFallback.open(prefer, direct = { calls += "direct"; direct() }, + browser = { calls += "browser"; browser() }, diagnostic = diagnostic) + }, + ) + } + + @Test fun linkUsesGoodsIdOnly() { + assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=825093359197", PddManualOpenCoordinator.link("825093359197")) + } + + @Test fun linkRejectsBlankOrNonDigitGoodsId() { + listOf(null, "", " ", "12a", "1 2", "12&x=1", "-1", "123").forEach { assertNull(it, PddManualOpenCoordinator.link(it)) } + } + + @Test fun invalidGoodsIdNeitherChecksBusyNorInvalidatesNorOpens() { + val h = Harness() + assertEquals(PddManualOpenResult.InvalidGoodsId, h.coordinator.run("abc", purchase = false)) + assertTrue(h.calls.isEmpty()) + } + + @Test fun collectionButtonOnlyUsesBrowser() { + val h = Harness(direct = { error("unexpected") }) + assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = false)) + assertEquals(false, h.preferDirect) + assertFalse(h.calls.contains("direct")) + assertTrue(h.calls.contains("browser")) + } + + @Test fun purchaseButtonPrefersDirectAndStopsWhenAccepted() { + val h = Harness(browser = { error("unexpected") }) + assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = true)) + assertEquals(true, h.preferDirect) + assertFalse(h.calls.contains("browser")) + } + + @Test fun purchaseDirectExceptionFallsBackToBrowserWithSimpleClassName() { + val h = Harness(direct = { throw SecurityException("secret url and address") }) + val result = h.coordinator.run("1", purchase = true) + assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器(SecurityException)"), result) + val text = PddManualOpenCoordinator.message(result) + assertFalse(text.contains("secret")) + assertFalse(text.contains("http")) + assertTrue(text.endsWith(PddManualOpenCoordinator.OPENED_HINT)) + } + + @Test fun purchaseDirectRejectedFallbackNoticeOmitsParenthesesWithoutClass() { + val h = Harness(direct = { false }) + assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器"), h.coordinator.run("1", purchase = true)) + } + + @Test fun allPathsFailingReportsOpenFailure() { + val h = Harness(direct = { throw IllegalStateException("x") }, browser = { false }) + val result = h.coordinator.run("1", purchase = true) + assertEquals(PddManualOpenResult.Failed, result) + assertEquals("无法打开拼多多链接", PddManualOpenCoordinator.message(result)) + } + + @Test fun fallbackNoticeNeedsAcceptedBrowser() { + val events = listOf( + PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"), + PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED), + ) + assertNull(PddManualOpenCoordinator.fallbackNotice(events)) + } + + @Test fun busyAtClickTimeDoesNotInvalidateOrOpen() { + val h = Harness(busy = DeviceBusyPolicy.TASK) + val result = h.coordinator.run("1", purchase = true) + assertEquals(PddManualOpenResult.Busy("设备正在执行任务"), result) + assertEquals(listOf("busy"), h.calls) + assertEquals("设备正在执行任务,暂不能打开拼多多", PddManualOpenCoordinator.message(result)) + } + + @Test fun handoffIsInvalidatedBeforeAnyLaunch() { + val h = Harness() + h.coordinator.run("1", purchase = true) + assertEquals(listOf("busy", "invalidate", "launch", "direct"), h.calls) + } + + @Test fun handoffIsInvalidatedEvenWhenOpeningFails() { + val h = Harness(direct = { false }, browser = { false }) + h.coordinator.run("1", purchase = true) + assertTrue(h.calls.indexOf("invalidate") in 0 until h.calls.indexOf("launch")) + } + + @Test fun idleInputsAreNotBusy() { + assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs())) + } + + @Test fun runningTaskIsBusy() { + assertEquals("设备正在执行任务", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(taskActive = true))) + } + + @Test fun repurchaseRoundIsBusyIncludingBetweenTasks() { + // Between two tasks every task input is false but the round is still busy. + assertEquals("重购进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = true))) + } + + @Test fun backfillIsBusy() { + assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(backfillRunning = true))) + } + + @Test fun currentPageCollectionIsBusy() { + assertEquals("当前页采集进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(currentPageCollecting = true, taskActive = true))) + } + + @Test fun unuploadedOrRecoverablePurchaseIsBusy() { + assertEquals("有未上传的采购结果", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(unuploadedPurchase = true))) + } + + @Test fun repurchaseLocalIdleViewIgnoresOwnRoundButKeepsOtherWork() { + // The service builds the repurchase-idle inputs with repurchaseBusy=false: the round never blocks itself. + assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false))) + assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false, backfillRunning = true))) + } + + @Test fun collectionCooldownIsNotAnInput() { + // A cooldown only blocks claiming new collection work; it leaves every busy input false. + assertTrue(DeviceBusyPolicy.Inputs::class.java.declaredFields.none { it.name.contains("cooldown", ignoreCase = true) }) + } +}