Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/java/com/aliothmoon/maafw/MaaFwApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,7 @@ class MaaFwApp : Application() {
koin.get<OverlayController>().setup()
koin.get<SessionMessagePresenter>().setup()
koin.get<ScreenSaverOverlayManager>().setup()
koin.get<StartAppFailureNotifier>().setup()
koin.get<TelemetryController>().setup()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +26,14 @@ val notificationModule = module {
single { NotificationSettingsManager(androidContext()) }
single { RunEventNotifier(androidContext(), get()) }

single {
StartAppFailureNotifier(
runnerPort = get(),
notifyStartAppFailed = get<RunEventNotifier>()::notifyStartAppFailed,
scope = get(named<AppCoroutineScope>()),
)
}

single {
val http = get<HttpClientHelper>()
val settings = get<NotificationSettingsManager>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
69 changes: 51 additions & 18 deletions app/src/main/java/com/aliothmoon/maafw/runner/RunLogComposer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

}

/**
Expand Down Expand Up @@ -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 = "?"
3 changes: 3 additions & 0 deletions app/src/main/res/values-en/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@
<string name="run_log_task_starting">Task started: %1$s</string>
<string name="run_log_task_succeeded">Task completed: %1$s</string>
<string name="run_log_task_failed">Task failed: %1$s</string>
<string name="run_log_app_not_started">App not started</string>
<string name="run_log_agent_flood">Agent output is flooding; display paused. Full output goes to logcat.</string>
<string name="run_log_app_left_display">The target app left the virtual display and could not be moved back (%1$s). Try foreground mode instead.</string>
<string name="run_log_app_process_gone">The target app process is not running or was closed unexpectedly (%1$s).</string>
Expand Down Expand Up @@ -683,6 +684,8 @@
<string name="notification_event_run_partial">Run ended with failures</string>
<string tools:ignore="PluralsCandidate" name="notification_event_run_partial_body">%1$d of %2$d succeeded; failed: %3$s</string>
<string name="notification_event_run_failed">Run failed</string>
<string name="notification_event_app_not_started_body">The target app could not be launched</string>
<string name="notification_event_app_not_started_task_body">The target app could not be launched; current node: %1$s</string>
<string name="notification_event_service_died">Background service stopped</string>
<string name="notification_event_service_died_body">The background service ended unexpectedly and this run was aborted</string>

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@
<string name="run_log_task_starting">任务开始: %1$s</string>
<string name="run_log_task_succeeded">任务完成: %1$s</string>
<string name="run_log_task_failed">任务失败: %1$s</string>
<string name="run_log_app_not_started">应用未启动</string>
<string name="run_log_agent_flood">Agent 输出过快,已暂停显示;完整输出见 logcat</string>
<string name="run_log_app_left_display">目标应用已离开虚拟显示器且自动拉回失败(%1$s),可改用前台模式</string>
<string name="run_log_app_process_gone">目标应用进程未启动或被异常关闭(%1$s)</string>
Expand Down Expand Up @@ -672,6 +673,8 @@
<string name="notification_event_run_partial">任务已结束,有失败项</string>
<string name="notification_event_run_partial_body">成功 %1$d/%2$d;失败:%3$s</string>
<string name="notification_event_run_failed">任务执行失败</string>
<string name="notification_event_app_not_started_body">目标应用没有被拉起</string>
<string name="notification_event_app_not_started_task_body">目标应用没有被拉起;当前节点:%1$s</string>
<string name="notification_event_service_died">后台服务中断</string>
<string name="notification_event_service_died_body">运行中的后台服务意外结束,本轮任务已中止</string>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String?>()
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<String?>()
val notifier = StartAppFailureNotifier(runner, { taskLabel -> notifications += taskLabel }, backgroundScope)
notifier.setup()

runner.emit(RunnerEvent.Callback("Node.Action.Failed", """{"action":"Click","name":"NodeA"}"""))

assertEquals(emptyList<String?>(), notifications)
}

private class StateRunnerPort(initialState: RunnerState) : RunnerPort {
private val _state = MutableStateFlow(initialState)
override val state: StateFlow<RunnerState> = _state.asStateFlow()

private val _events = MutableSharedFlow<RunnerEvent>(extraBufferCapacity = 16)
override val events: Flow<RunnerEvent> = _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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down