From 9d4350dc8ee1bde430653817457a684bc2dd5630 Mon Sep 17 00:00:00 2001 From: Craun718 <3653544699@qq.com> Date: Sat, 12 Sep 2026 04:09:02 +0800 Subject: [PATCH] feat: notify StartApp failures immediately Emit an app-not-started run log and notification when a StartApp action fails, deduplicated once per execution. --- .../java/com/aliothmoon/maafw/MaaFwApp.kt | 2 + .../aliothmoon/maafw/di/NotificationModule.kt | 9 +++ .../maafw/notification/RunEventNotifier.kt | 8 ++ .../notification/StartAppFailureNotifier.kt | 41 ++++++++++ .../aliothmoon/maafw/runner/RunLogComposer.kt | 69 +++++++++++----- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../StartAppFailureNotifierTest.kt | 78 +++++++++++++++++++ .../maafw/runner/RunLogComposerTest.kt | 26 +++++++ 9 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/aliothmoon/maafw/notification/StartAppFailureNotifier.kt create mode 100644 app/src/test/java/com/aliothmoon/maafw/notification/StartAppFailureNotifierTest.kt diff --git a/app/src/main/java/com/aliothmoon/maafw/MaaFwApp.kt b/app/src/main/java/com/aliothmoon/maafw/MaaFwApp.kt index 5e953e82..a96cede3 100644 --- a/app/src/main/java/com/aliothmoon/maafw/MaaFwApp.kt +++ b/app/src/main/java/com/aliothmoon/maafw/MaaFwApp.kt @@ -16,6 +16,7 @@ import com.aliothmoon.maafw.di.viewModelModule import com.aliothmoon.maafw.log.AppLogWriter import com.aliothmoon.maafw.log.CrashHandler import com.aliothmoon.maafw.log.LogTreeHolder +import com.aliothmoon.maafw.notification.StartAppFailureNotifier import com.aliothmoon.maafw.overlay.OverlayController import com.aliothmoon.maafw.overlay.screensaver.ScreenSaverOverlayManager import com.aliothmoon.maafw.ui.SessionMessagePresenter @@ -80,6 +81,7 @@ class MaaFwApp : Application() { koin.get().setup() koin.get().setup() koin.get().setup() + koin.get().setup() koin.get().setup() } } diff --git a/app/src/main/java/com/aliothmoon/maafw/di/NotificationModule.kt b/app/src/main/java/com/aliothmoon/maafw/di/NotificationModule.kt index 5e3aca0e..8408e7f3 100644 --- a/app/src/main/java/com/aliothmoon/maafw/di/NotificationModule.kt +++ b/app/src/main/java/com/aliothmoon/maafw/di/NotificationModule.kt @@ -5,6 +5,7 @@ import com.aliothmoon.maafw.notification.ExternalNotificationService import com.aliothmoon.maafw.notification.NotificationCenter import com.aliothmoon.maafw.notification.NotificationSettingsManager import com.aliothmoon.maafw.notification.RunEventNotifier +import com.aliothmoon.maafw.notification.StartAppFailureNotifier import com.aliothmoon.maafw.notification.provider.BarkProvider import com.aliothmoon.maafw.notification.provider.CustomWebhookProvider import com.aliothmoon.maafw.notification.provider.DingTalkProvider @@ -25,6 +26,14 @@ val notificationModule = module { single { NotificationSettingsManager(androidContext()) } single { RunEventNotifier(androidContext(), get()) } + single { + StartAppFailureNotifier( + runnerPort = get(), + notifyStartAppFailed = get()::notifyStartAppFailed, + scope = get(named()), + ) + } + single { val http = get() val settings = get() diff --git a/app/src/main/java/com/aliothmoon/maafw/notification/RunEventNotifier.kt b/app/src/main/java/com/aliothmoon/maafw/notification/RunEventNotifier.kt index b0fc96a6..e2b30888 100644 --- a/app/src/main/java/com/aliothmoon/maafw/notification/RunEventNotifier.kt +++ b/app/src/main/java/com/aliothmoon/maafw/notification/RunEventNotifier.kt @@ -37,6 +37,14 @@ class RunEventNotifier( send(title, text, ID_RUN, isError) } + fun notifyStartAppFailed(taskLabel: String?) { + val title = appContext.getString(R.string.run_log_app_not_started) + val text = taskLabel?.takeIf(String::isNotBlank) + ?.let { appContext.getString(R.string.notification_event_app_not_started_task_body, it) } + ?: appContext.getString(R.string.notification_event_app_not_started_body) + send(title, text, ID_RUN, isError = true) + } + fun notifyTest(title: String, text: String) { send(title, text, ID_RUN_RESULT, isError = false) } diff --git a/app/src/main/java/com/aliothmoon/maafw/notification/StartAppFailureNotifier.kt b/app/src/main/java/com/aliothmoon/maafw/notification/StartAppFailureNotifier.kt new file mode 100644 index 00000000..5da64a8a --- /dev/null +++ b/app/src/main/java/com/aliothmoon/maafw/notification/StartAppFailureNotifier.kt @@ -0,0 +1,41 @@ +package com.aliothmoon.maafw.notification + +import com.aliothmoon.maafw.runner.RunLogComposer +import com.aliothmoon.maafw.runner.RunnerEvent +import com.aliothmoon.maafw.runner.RunnerPort +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * StartApp 失败不用等整轮结束才播报 + * + * 复用任务结果通知的同一个 notify id:运行中先看到「应用未启动」, + * 收尾后由最终结果顶掉,避免同一轮在通知栏里叠两条 + */ +class StartAppFailureNotifier( + private val runnerPort: RunnerPort, + private val notifyStartAppFailed: (String?) -> Unit, + private val scope: CoroutineScope, +) { + private var notifiedExecutionId: String? = null + + fun setup() { + scope.launch { + runnerPort.events.collect(::onEvent) + } + } + + private fun onEvent(event: RunnerEvent) { + if (event !is RunnerEvent.Callback) return + val execution = runnerPort.state.value.activeExecution ?: return + if (notifiedExecutionId == execution.executionId) return + if (!RunLogComposer.isStartAppFailure(event)) return + + notifiedExecutionId = execution.executionId + val taskLabel = RunLogComposer.startAppFailureTaskLabel( + event, + execution.currentTaskLabel, + ) + notifyStartAppFailed(taskLabel) + } +} diff --git a/app/src/main/java/com/aliothmoon/maafw/runner/RunLogComposer.kt b/app/src/main/java/com/aliothmoon/maafw/runner/RunLogComposer.kt index 52438382..172714f3 100644 --- a/app/src/main/java/com/aliothmoon/maafw/runner/RunLogComposer.kt +++ b/app/src/main/java/com/aliothmoon/maafw/runner/RunLogComposer.kt @@ -154,35 +154,60 @@ class RunLogComposer { event.details, ) + NODE_ACTION_FAILED -> + if (details.isStartAppAction()) { + Composed( + RunLogKind.Error, + uiTextOf(R.string.run_log_app_not_started), + event.details, + ) + } else { + verbose + } + else -> verbose } } - private fun parseDetails(raw: String): JsonObject? = - runCatching { LOG_JSON.parseToJsonElement(raw) }.getOrNull() as? JsonObject - private data class Composed( val kind: RunLogKind, val text: UiText, val detail: String? = null, ) - private companion object { - const val CONTROLLER_STARTING = "Controller.Action.Starting" - const val CONTROLLER_SUCCEEDED = "Controller.Action.Succeeded" - const val CONTROLLER_FAILED = "Controller.Action.Failed" - const val RESOURCE_STARTING = "Resource.Loading.Starting" - const val RESOURCE_SUCCEEDED = "Resource.Loading.Succeeded" - const val RESOURCE_FAILED = "Resource.Loading.Failed" - const val TASK_STARTING = "Tasker.Task.Starting" - const val TASK_SUCCEEDED = "Tasker.Task.Succeeded" - const val TASK_FAILED = "Tasker.Task.Failed" - - const val AGENT_FLOOD_WINDOW_MS = 2_000L - const val AGENT_FLOOD_THRESHOLD = 15 - - val LOG_JSON = Json { ignoreUnknownKeys = true; isLenient = true } + companion object { + /** 供通知这类日志之外的消费方复用同一套识别规则 */ + fun isStartAppFailure(event: RunnerEvent.Callback): Boolean = + event.message == NODE_ACTION_FAILED && parseDetails(event.details).isStartAppAction() + + /** StartApp 的包名在特权进程里才展开;这里能给出的只有任务/节点这个可指称的名字 */ + fun startAppFailureTaskLabel(event: RunnerEvent.Callback, currentTaskLabel: String?): String? { + val details = parseDetails(event.details) + return currentTaskLabel?.takeIf(String::isNotBlank) + ?: details.string("entry") + ?: details.string("name") + } + + private fun parseDetails(raw: String): JsonObject? = + runCatching { LOG_JSON.parseToJsonElement(raw) }.getOrNull() as? JsonObject + + private const val CONTROLLER_STARTING = "Controller.Action.Starting" + private const val CONTROLLER_SUCCEEDED = "Controller.Action.Succeeded" + private const val CONTROLLER_FAILED = "Controller.Action.Failed" + private const val RESOURCE_STARTING = "Resource.Loading.Starting" + private const val RESOURCE_SUCCEEDED = "Resource.Loading.Succeeded" + private const val RESOURCE_FAILED = "Resource.Loading.Failed" + private const val TASK_STARTING = "Tasker.Task.Starting" + private const val TASK_SUCCEEDED = "Tasker.Task.Succeeded" + private const val TASK_FAILED = "Tasker.Task.Failed" + private const val NODE_ACTION_FAILED = "Node.Action.Failed" + + private const val AGENT_FLOOD_WINDOW_MS = 2_000L + private const val AGENT_FLOOD_THRESHOLD = 15 + + private val LOG_JSON = Json { ignoreUnknownKeys = true; isLenient = true } } + } /** @@ -212,8 +237,16 @@ data class RunLogContext( internal fun JsonObject?.string(key: String): String? = (this?.get(key) as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } +internal fun JsonObject?.obj(key: String): JsonObject? = + this?.get(key) as? JsonObject + /** `action` 的取值官方是 `Connect`,宽容收一个小写写法 */ private fun JsonObject?.isConnectAction(): Boolean = this.string("action")?.equals("Connect", ignoreCase = true) == true +/** MaaFramework 的 StartApp 动作失败即目标应用没有被拉起 */ +private fun JsonObject?.isStartAppAction(): Boolean = + this.string("action")?.equals("StartApp", ignoreCase = true) == true || + this.obj("action_details").string("action")?.equals("StartApp", ignoreCase = true) == true + private const val UNKNOWN_SUBJECT = "?" diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2326a9ee..dd96251a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -465,6 +465,7 @@ Task started: %1$s Task completed: %1$s Task failed: %1$s + App not started Agent output is flooding; display paused. Full output goes to logcat. The target app left the virtual display and could not be moved back (%1$s). Try foreground mode instead. The target app process is not running or was closed unexpectedly (%1$s). @@ -683,6 +684,8 @@ Run ended with failures %1$d of %2$d succeeded; failed: %3$s Run failed + The target app could not be launched + The target app could not be launched; current node: %1$s Background service stopped The background service ended unexpectedly and this run was aborted diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25514360..d1db8be7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -457,6 +457,7 @@ 任务开始: %1$s 任务完成: %1$s 任务失败: %1$s + 应用未启动 Agent 输出过快,已暂停显示;完整输出见 logcat 目标应用已离开虚拟显示器且自动拉回失败(%1$s),可改用前台模式 目标应用进程未启动或被异常关闭(%1$s) @@ -672,6 +673,8 @@ 任务已结束,有失败项 成功 %1$d/%2$d;失败:%3$s 任务执行失败 + 目标应用没有被拉起 + 目标应用没有被拉起;当前节点:%1$s 后台服务中断 运行中的后台服务意外结束,本轮任务已中止 diff --git a/app/src/test/java/com/aliothmoon/maafw/notification/StartAppFailureNotifierTest.kt b/app/src/test/java/com/aliothmoon/maafw/notification/StartAppFailureNotifierTest.kt new file mode 100644 index 00000000..4224e9fb --- /dev/null +++ b/app/src/test/java/com/aliothmoon/maafw/notification/StartAppFailureNotifierTest.kt @@ -0,0 +1,78 @@ +package com.aliothmoon.maafw.notification + +import com.aliothmoon.maafw.domain.RunConfigurationId +import com.aliothmoon.maafw.runner.ActiveExecution +import com.aliothmoon.maafw.runner.RunnerCommandResult +import com.aliothmoon.maafw.runner.RunnerEvent +import com.aliothmoon.maafw.runner.RunnerPort +import com.aliothmoon.maafw.runner.RunnerState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StartAppFailureNotifierTest { + + @Test + fun `a failed StartApp callback notifies once per run`() = runTest(UnconfinedTestDispatcher()) { + val runner = StateRunnerPort( + RunnerState( + activeExecution = ActiveExecution( + executionId = "run-1", + runConfigurationId = RunConfigurationId("config"), + currentTaskName = "startup", + completedTaskCount = 0, + totalTaskCount = 1, + taskResults = emptyList(), + taskLabels = mapOf("startup" to "启动应用"), + ), + ), + ) + val notifications = mutableListOf() + val notifier = StartAppFailureNotifier(runner, { taskLabel -> notifications += taskLabel }, backgroundScope) + notifier.setup() + + runner.emit(RunnerEvent.Callback("Node.Action.Failed", """{"action":"StartApp","name":"startup"}""")) + runner.emit(RunnerEvent.Callback("Node.Action.Failed", """{"action":"StartApp","name":"startup"}""")) + + assertEquals(listOf("启动应用"), notifications) + } + + @Test + fun `other action failures do not notify`() = runTest(UnconfinedTestDispatcher()) { + val runner = StateRunnerPort(RunnerState()) + val notifications = mutableListOf() + val notifier = StartAppFailureNotifier(runner, { taskLabel -> notifications += taskLabel }, backgroundScope) + notifier.setup() + + runner.emit(RunnerEvent.Callback("Node.Action.Failed", """{"action":"Click","name":"NodeA"}""")) + + assertEquals(emptyList(), notifications) + } + + private class StateRunnerPort(initialState: RunnerState) : RunnerPort { + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + override val events: Flow = _events.asSharedFlow() + + fun emit(event: RunnerEvent) { + check(_events.tryEmit(event)) + } + + override suspend fun start(plan: com.aliothmoon.maafw.runner.RunPlan): RunnerCommandResult = + RunnerCommandResult.Accepted + + override suspend fun stop(): RunnerCommandResult = RunnerCommandResult.Accepted + } +} diff --git a/app/src/test/java/com/aliothmoon/maafw/runner/RunLogComposerTest.kt b/app/src/test/java/com/aliothmoon/maafw/runner/RunLogComposerTest.kt index 95db2a35..f25cc3e6 100644 --- a/app/src/test/java/com/aliothmoon/maafw/runner/RunLogComposerTest.kt +++ b/app/src/test/java/com/aliothmoon/maafw/runner/RunLogComposerTest.kt @@ -82,6 +82,32 @@ class RunLogComposerTest { assertEquals("""{"name":"NodeA"}""", entry?.detail) } + /** StartApp 拉不起目标应用时,泛化的节点失败对用户没有行动价值 */ + @Test + fun `a failed StartApp action is reported as app not started`() { + val entry = callback("Node.Action.Failed", """{"action":"StartApp","name":"启动游戏"}""") + assertEquals(RunLogKind.Error, entry?.kind) + assertEquals(UiText.Resource(R.string.run_log_app_not_started), entry?.text) + assertEquals("""{"action":"StartApp","name":"启动游戏"}""", entry?.detail) + } + + @Test + fun `a nested StartApp action detail is reported as app not started`() { + val entry = callback( + "Node.Action.Failed", + """{"action_details":{"action":"StartApp"},"name":"start_up"}""", + ) + assertEquals(RunLogKind.Error, entry?.kind) + assertEquals(UiText.Resource(R.string.run_log_app_not_started), entry?.text) + } + + @Test + fun `other failed node actions stay raw`() { + val entry = callback("Node.Action.Failed", """{"action":"Click","name":"NodeA"}""") + assertEquals(RunLogKind.Verbose, entry?.kind) + assertEquals(UiText.Verbatim("Node.Action.Failed"), entry?.text) + } + @Test fun `unknown messages are kept raw rather than dropped`() { assertEquals(RunLogKind.Verbose, callback("Something.Brand.New")?.kind)