From ca4c34f21c840a914d49b118301414acfcd48471 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:13:11 +0800 Subject: [PATCH] feat: add observable scene execution foundation --- android/app/src/main/AndroidManifest.xml | 1 + android/feature/automation/build.gradle.kts | 1 + .../manager/automation/di/AutomationModule.kt | 7 +++ .../AndroidNotificationGateway.kt | 44 +++++++++++++++++++ .../manager/domain/NotificationGateway.kt | 9 ++++ .../abox/manager/domain/SceneExecution.kt | 14 ++++++ .../abox/manager/domain/SceneExecutor.kt | 23 ++++++++++ .../manager/domain/SceneExecutionFlowTest.kt | 35 +++++++++++++++ 8 files changed, 134 insertions(+) create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5b9189a..5cda260 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + { + if (android.os.Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + } + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), + ) + return try { + NotificationManagerCompat.from(context).notify( + profileId.value.hashCode(), + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setAutoCancel(true) + .build(), + ) + DomainResult.Success(Unit) + } catch (_: SecurityException) { + DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + } + } + + private companion object { const val CHANNEL_ID = "automation_events" } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt new file mode 100644 index 0000000..5bef2b1 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.domain + +interface NotificationGateway { + suspend fun show( + profileId: ProviderProfileId, + title: String, + message: String, + ): DomainResult +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt index f6267ed..2ed7aa9 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -1,5 +1,19 @@ package com.flagship.abox.manager.domain +@JvmInline +value class SceneExecutionId(val value: String) + +enum class SceneExecutionSource { USER, AUTOMATION } + +sealed interface SceneExecutionUpdate { + data class Started(val executionId: SceneExecutionId, val scene: Scene) : SceneExecutionUpdate + data class StepPending(val action: SceneAction) : SceneExecutionUpdate + data class StepRunning(val action: SceneAction) : SceneExecutionUpdate + data class StepFinished(val result: SceneActionExecutionResult) : SceneExecutionUpdate + data class Completed(val result: SceneExecutionResult) : SceneExecutionUpdate + data class Rejected(val error: DomainError) : SceneExecutionUpdate +} + enum class SceneActionOutcome { SUCCESS, FAILURE, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt index 71432ce..73dbe2e 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt @@ -1,5 +1,9 @@ package com.flagship.abox.manager.domain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.util.UUID + /** * Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent * actions; callers receive a [SceneExecutionResult] that summarizes every step. @@ -9,4 +13,23 @@ package com.flagship.abox.manager.domain */ interface SceneExecutor { suspend fun execute(scene: Scene): DomainResult + + fun execute( + scene: Scene, + executionId: SceneExecutionId = SceneExecutionId(UUID.randomUUID().toString()), + source: SceneExecutionSource = SceneExecutionSource.USER, + ): Flow = flow { + emit(SceneExecutionUpdate.Started(executionId, scene)) + when (val result = execute(scene)) { + is DomainResult.Failure -> emit(SceneExecutionUpdate.Rejected(result.error)) + is DomainResult.Success -> { + result.value.results.forEach { actionResult -> + emit(SceneExecutionUpdate.StepPending(actionResult.action)) + emit(SceneExecutionUpdate.StepRunning(actionResult.action)) + emit(SceneExecutionUpdate.StepFinished(actionResult)) + } + emit(SceneExecutionUpdate.Completed(result.value)) + } + } + } } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt new file mode 100644 index 0000000..31cc8c2 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt @@ -0,0 +1,35 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class SceneExecutionFlowTest { + @Test + fun observableExecutionPublishesLifecycleEventsWithoutChangingLegacyResult() = runTest { + val scene = Scene( + id = SceneId("scene"), + profileId = ProviderProfileId("profile"), + name = "测试情景", + ) + val executor = DefaultSceneExecutor( + socketGateway = object : SocketGateway { + override suspend fun getStatus(profileId: ProviderProfileId, deviceName: DeviceName) = + DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF)) + override suspend fun setPower(profileId: ProviderProfileId, deviceName: DeviceName, powerState: SocketPowerState) = + DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + }, + infraredGateway = object : InfraredGateway { + override suspend fun sendKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit) + override suspend fun learnKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit) + override suspend fun downloadCodes(profileId: ProviderProfileId, codes: List) = DomainResult.Success(emptyList()) + }, + ) + + val updates = executor.execute(scene, SceneExecutionId("run-1")).toList() + + assertTrue(updates.first() is SceneExecutionUpdate.Started) + assertTrue(updates.last() is SceneExecutionUpdate.Completed) + } +}