feat(scene): add scene editor, executor and fakes (phase 1) (#5)

## Summary

Adds scene integration phase 1: domain model, fakes, UI, navigation, and tests.

## Changes

- **domain**: Add `Scene`, `SceneAction` (SocketPower/InfraredKey), `SceneRepository`, `SceneExecutor`, and execution result models.
- **testing/fakes**: Add `FakeSceneRepository` (isolated by `ProviderProfileId`, seeded defaults for the local profile) and `FakeSceneExecutor` (sequential execution, continue-on-failure).
- **android/app**: Add scene UI module - list screen, editor screen, execution progress screen - with Hilt-bound ViewModels.
- **navigation**: Wire scene routes into `ABoxManagerApp` NavHost.
- **strings**: Add `scene_*` entries for en and zh-rCN.
- **tests**: Cover `FakeSceneRepository` CRUD/isolation and `FakeSceneExecutor` ordering/continue-on-failure.

## Test plan

- [x] `./gradlew :testing:fakes:test` passes.
- [x] `FakeSceneRepository` isolates scenes by provider profile.
- [x] `FakeSceneExecutor` runs actions in order and continues on failure.

## Notes

Phase 1 ships fakes only; real repository/executor backed by the abox gateway will land in a follow-up.

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Co-authored-by: mxhsnd <2049295946@qq.com>
Reviewed-on: #5
Co-authored-by: tzh <3091898014@qq.com>
This commit was merged in pull request #5.
This commit is contained in:
tzh
2026-08-25 20:20:18 +08:00
committed by KremeCN
co-authored by KremeCN Moler
parent 30d6d39597
commit 1570a95f30
28 changed files with 5471 additions and 1 deletions
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DefaultSceneExecutor
import com.flagship.abox.manager.domain.InMemorySceneRepository
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Production dependency injection module for Scene management and execution.
*/
@Module
@InstallIn(SingletonComponent::class)
object SceneIntegrationModule {
@Provides
@Singleton
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
@Provides
@Singleton
fun provideSceneExecutor(
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
sendVirtualRemoteAction: SendVirtualRemoteAction,
): SceneExecutor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
)
}
@@ -32,6 +32,10 @@ import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
import com.flagship.abox.manager.ui.navigation.navigateToSocket
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.scene.SceneListScreen
import com.flagship.abox.manager.ui.scene.SceneRoutes
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
@@ -124,11 +128,26 @@ private fun AuthenticatedNavHost(
},
)
},
scenesSlot = {
SceneListScreen(
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
},
)
}
deviceDetailGraph(
onBackClick = navController::popBackStack,
)
sceneEditorDestination(
onBack = { navController.popBackStack() },
onSaved = { navController.popBackStack() },
)
sceneExecutionDestination(
onBack = { navController.popBackStack() },
)
composable(
route = "infrared_hub/{profileId}",
arguments = listOf(
@@ -0,0 +1,385 @@
package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionId
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SceneEditorState(
val sceneId: SceneId? = null,
val type: AutomationType = AutomationType.MANUAL,
val name: String = "",
val description: String = "",
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val colorHex: String = "#3F51B5",
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
val availableRemotes: List<VirtualRemote> = emptyList(),
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isSaved: Boolean = false,
val errorMessage: String? = null,
) {
val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
val isEditing: Boolean get() = sceneId != null
}
@HiltViewModel
class SceneEditorViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState())
val state: StateFlow<SceneEditorState> = _state.asStateFlow()
private var initializedSceneId: SceneId? = null
private var isInitialized: Boolean = false
init {
observeAvailableRemotes()
}
private fun observeAvailableRemotes() {
viewModelScope.launch {
profileStore.observeActiveProfile().collectLatest { profile ->
if (profile != null) {
virtualRemoteRepository.observeRemotes(profile.id, DeviceName("红外控制器")).collectLatest { res ->
if (res is DomainResult.Success) {
_state.update { it.copy(availableRemotes = res.value) }
}
}
}
}
}
}
fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) {
if (isInitialized && initializedSceneId == sceneId) return
isInitialized = true
initializedSceneId = sceneId
if (sceneId == null) {
_state.update {
SceneEditorState(
type = initialType ?: AutomationType.MANUAL,
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
availableRemotes = it.availableRemotes,
isLoading = false,
)
}
return
}
_state.update {
it.copy(
sceneId = sceneId,
isLoading = true,
errorMessage = null,
)
}
viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(isLoading = false, errorMessage = "No active profile") }
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
if (scene != null) {
_state.update {
it.copy(
sceneId = scene.id,
type = scene.type,
name = scene.name,
description = scene.description,
enabled = scene.enabled,
icon = scene.icon,
colorHex = scene.color.hex,
triggers = scene.triggers,
conditions = scene.conditions,
actions = scene.actions,
strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging,
isLoading = false,
)
}
} else {
_state.update { it.copy(isLoading = false, errorMessage = "Scene not found") }
}
}
}
fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) }
fun updateDescription(desc: String) = _state.update { it.copy(description = desc) }
fun updateType(type: AutomationType) = _state.update { it.copy(type = type) }
fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) }
fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) }
fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) }
fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) }
fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) }
fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) }
// --- Triggers ---
fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set<Int> = emptySet()) {
val trigger = SceneTrigger.Time(
id = SceneTriggerId(UUID.randomUUID().toString()),
timeOfDay = timeOfDay,
daysOfWeek = daysOfWeek,
label = "定时 $timeOfDay",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun addTemperatureTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val trigger = SceneTrigger.Temperature(
id = SceneTriggerId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdCelsius = threshold,
label = "$deviceName 温度 ${operator.symbol} $threshold°C",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun addHumidityTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val trigger = SceneTrigger.Humidity(
id = SceneTriggerId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdPercent = threshold,
label = "$deviceName 湿度 ${operator.symbol} $threshold%",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
val trigger = SceneTrigger.DeviceState(
id = SceneTriggerId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
expectedState = expectedState,
label = "$deviceName 状态变为 $expectedState",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun removeTrigger(triggerId: SceneTriggerId) =
_state.update { it.copy(triggers = it.triggers.filterNot { t -> t.id == triggerId }) }
// --- Conditions ---
fun addTemperatureCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val cond = SceneCondition.Temperature(
id = SceneConditionId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdCelsius = threshold,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun addHumidityCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val cond = SceneCondition.Humidity(
id = SceneConditionId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdPercent = threshold,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun addSocketPowerCondition(deviceName: String, powerState: SocketPowerState) {
val cond = SceneCondition.SocketPower(
id = SceneConditionId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
expectedState = powerState,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun addTimeRangeCondition(startTime: String, endTime: String) {
val cond = SceneCondition.TimeRange(
id = SceneConditionId(UUID.randomUUID().toString()),
startTime = startTime,
endTime = endTime,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun removeCondition(conditionId: SceneConditionId) =
_state.update { it.copy(conditions = it.conditions.filterNot { c -> c.id == conditionId }) }
// --- Actions ---
fun addSocketAction(deviceName: String, powerState: SocketPowerState) {
if (deviceName.isBlank()) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.SocketPower(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
deviceName = DeviceName(deviceName),
powerState = powerState,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addVirtualRemoteAction(
remoteId: String,
remoteDisplayName: String,
buttonId: String,
buttonDisplayName: String,
) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.VirtualRemoteButton(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
remoteId = remoteId,
remoteDisplayName = remoteDisplayName,
buttonId = buttonId,
buttonDisplayName = buttonDisplayName,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addInfraredAction(deviceName: String, key: String) {
if (deviceName.isBlank() || key.isBlank()) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.InfraredKey(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
deviceName = DeviceName(deviceName),
key = key,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addDelayAction(seconds: Int) {
if (seconds <= 0) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.Delay(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
durationSeconds = seconds,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addNotificationAction(title: String, message: String) {
if (title.isBlank()) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.Notification(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
title = title,
message = message,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun removeAction(actionId: SceneActionId) =
_state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) }
fun moveAction(from: Int, to: Int) {
val current = _state.value.actions
if (from !in current.indices || to !in current.indices || from == to) return
val updated = current.toMutableList().apply { add(to, removeAt(from)) }
_state.update { it.copy(actions = updated) }
}
suspend fun getKeyBindings(remoteId: VirtualRemoteId): List<RemoteKeyBinding> {
return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> emptyList()
}
}
fun save() {
val state = _state.value
if (!state.isValid || state.isSaving) return
_state.update { it.copy(isSaving = true, errorMessage = null) }
viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(isSaving = false, errorMessage = "No active profile") }
return@launch
}
val scene = Scene(
id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
profileId = profile.id,
name = state.name.trim(),
description = state.description.trim(),
type = state.type,
enabled = state.enabled,
icon = state.icon,
color = SceneColor(state.colorHex),
triggers = state.triggers,
conditions = state.conditions,
actions = state.actions,
strategy = state.strategy,
concurrencyMode = state.concurrencyMode,
enableLogging = state.enableLogging,
)
val result = if (state.isEditing) {
sceneRepository.updateScene(profile.id, scene)
} else {
sceneRepository.createScene(profile.id, scene)
}
_state.update { current ->
when (result) {
is DomainResult.Success -> current.copy(isSaving = false, isSaved = true)
is DomainResult.Failure -> current.copy(
isSaving = false,
errorMessage = result.error.detail ?: "Save failed",
)
}
}
}
}
}
@@ -0,0 +1,345 @@
package com.flagship.abox.manager.ui.scene
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.HourglassDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneExecutionScreen(
sceneId: SceneId,
onBack: () -> Unit,
viewModel: SceneExecutionViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(sceneId) {
viewModel.initialize(sceneId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back))
}
},
)
},
) { innerPadding ->
when (val state = uiState) {
SceneExecutionUiState.Loading -> CenteredLoading(innerPadding)
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message, onRetry = { viewModel.initialize(sceneId) })
is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute)
is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name)
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute)
}
}
}
@Composable
private fun CenteredLoading(paddingValues: PaddingValues) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.action_retry))
}
}
}
@Composable
private fun ReadyContent(
paddingValues: PaddingValues,
sceneName: String,
onExecute: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(stringResource(R.string.scene_exec_ready_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
Button(
onClick = onExecute,
modifier = Modifier.fillMaxWidth(0.6f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.scene_execution_run), fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(56.dp))
Spacer(Modifier.height(20.dp))
Text(
stringResource(R.string.scene_execution_running, sceneName),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
)
}
}
@Composable
private fun CompletedContent(
paddingValues: PaddingValues,
result: SceneExecutionResult,
onBack: () -> Unit,
onRerun: () -> Unit,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(key = "summary") {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.outlinedCardColors(
containerColor = if (result.allSucceeded) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
},
),
border = BorderStroke(
1.dp,
if (result.allSucceeded) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.error.copy(alpha = 0.4f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.scene_execution_done),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(
R.string.scene_execution_summary,
result.succeededCount,
result.failedCount,
result.totalActions,
),
style = MaterialTheme.typography.bodyMedium,
)
if (result.totalDurationMillis > 0) {
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.scene_execution_duration, result.totalDurationMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry ->
ActionResultRow(index + 1, entry)
}
item(key = "buttons") {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onRerun,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.scene_exec_rerun))
}
Button(
onClick = onBack,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Text(stringResource(R.string.action_back), fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@Composable
private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = when (entry.outcome) {
SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
when (entry.outcome) {
SceneActionOutcome.SUCCESS -> Icon(
Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
SceneActionOutcome.FAILURE -> Icon(
Icons.Filled.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
SceneActionOutcome.SKIPPED -> Icon(
Icons.Outlined.HourglassDisabled,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.scene_step_format, stepNumber, entry.action.describe()),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
entry.error?.detail?.let { detail ->
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
if (entry.executionDurationMillis > 0) {
Text(
text = "${entry.executionDurationMillis}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun SceneAction.describe(): String = when (this) {
is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name)
is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName)
is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key)
is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds)
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
}
@@ -0,0 +1,83 @@
package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface SceneExecutionUiState {
data object Loading : SceneExecutionUiState
data class Ready(val scene: Scene) : SceneExecutionUiState
data class Running(val scene: Scene) : SceneExecutionUiState
data class Completed(val result: SceneExecutionResult) : SceneExecutionUiState
data class Failed(val message: String) : SceneExecutionUiState
}
@HiltViewModel
class SceneExecutionViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
val uiState: StateFlow<SceneExecutionUiState> = _uiState.asStateFlow()
private var initializedSceneId: SceneId? = null
fun initialize(sceneId: SceneId) {
if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) {
return
}
initializedSceneId = sceneId
_uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_uiState.value = SceneExecutionUiState.Failed("No active profile")
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
_uiState.value = if (scene != null) {
SceneExecutionUiState.Ready(scene)
} else {
SceneExecutionUiState.Failed("Scene not found")
}
}
}
fun execute() {
val current = _uiState.value
val scene = when (current) {
is SceneExecutionUiState.Ready -> current.scene
is SceneExecutionUiState.Completed -> current.result.scene
else -> return
}
_uiState.value = SceneExecutionUiState.Running(scene)
viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null || profile.id != scene.profileId) {
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
return@launch
}
when (val result = sceneExecutor.execute(scene)) {
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
result.error.detail ?: "Execution failed",
)
}
}
}
}
@@ -0,0 +1,417 @@
package com.flagship.abox.manager.ui.scene
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
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.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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneListScreen(
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(),
) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle()
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
var showFabMenu by remember { mutableStateOf(false) }
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.userMessage.collectLatest { msg ->
if (msg == "executed") {
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
}
}
}
Scaffold(
containerColor = Color.Transparent,
floatingActionButton = {
Box {
ExtendedFloatingActionButton(
onClick = { showFabMenu = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) },
)
DropdownMenu(
expanded = showFabMenu,
onDismissRequest = { showFabMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_manual)) },
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
onClick = {
showFabMenu = false
onAddManual()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_automatic)) },
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
onClick = {
showFabMenu = false
onAddAutomatic()
},
)
}
}
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 12.dp,
bottom = innerPadding.calculateBottomPadding() + 80.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (headerContent != null) {
item(key = "header") {
headerContent()
}
}
item(key = "tabs") {
SecondaryTabRow(
selectedTabIndex = selectedTab.ordinal,
containerColor = Color.Transparent,
divider = {},
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
) {
SceneFilterTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { viewModel.selectTab(tab) },
text = {
Text(
text = when (tab) {
SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all)
SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual)
SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
},
fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
if (scenes.isEmpty()) {
item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
}
} else {
items(scenes, key = { it.id.value }) { scene ->
SceneCard(
scene = scene,
onClick = {
if (scene.type == AutomationType.MANUAL) {
onExecuteScene(scene.id)
} else {
onEditScene(scene.id)
}
},
onQuickExecute = { viewModel.executeQuick(scene) },
onToggleEnabled = { viewModel.toggleEnabled(scene) },
onEdit = { onEditScene(scene.id) },
onDelete = { viewModel.markPendingDelete(scene.id) },
)
}
}
}
}
pendingDelete?.let { sceneId ->
AlertDialog(
onDismissRequest = viewModel::cancelDelete,
title = { Text(stringResource(R.string.scene_delete_dialog_title)) },
text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
confirmButton = {
TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = viewModel::cancelDelete) {
Text(stringResource(R.string.scene_delete_cancel))
}
},
)
}
}
@Composable
private fun SceneCard(
scene: Scene,
onClick: () -> Unit,
onQuickExecute: () -> Unit,
onToggleEnabled: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
val accentColor = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
color = accentColor.copy(alpha = 0.12f),
shape = RoundedCornerShape(12.dp),
),
contentAlignment = Alignment.Center,
) {
Text(
text = scene.icon.displayEmoji(),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = scene.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TypeBadge(type = scene.type)
}
if (scene.description.isNotBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = scene.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (scene.type == AutomationType.AUTOMATIC) {
Switch(
checked = scene.enabled,
onCheckedChange = { onToggleEnabled() },
)
}
}
Spacer(Modifier.height(12.dp))
// Trigger & Action summary and controls
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Summary text without nested heavy container shells
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (scene.type == AutomationType.AUTOMATIC) {
Text(
text = if (scene.triggers.isNotEmpty()) {
stringResource(R.string.scene_summary_triggers_and_actions, scene.triggers.size, scene.actions.size)
} else {
stringResource(R.string.scene_summary_actions_only, scene.actions.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = stringResource(R.string.scene_summary_actions_only, scene.actions.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (scene.type == AutomationType.MANUAL) {
FilledTonalButton(
onClick = onQuickExecute,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp),
shape = RoundedCornerShape(8.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
}
Spacer(Modifier.width(4.dp))
}
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@Composable
private fun TypeBadge(type: AutomationType) {
val label = when (type) {
AutomationType.MANUAL -> stringResource(R.string.scene_tab_manual)
AutomationType.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
}
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun EmptySceneList(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "⚡",
style = MaterialTheme.typography.displayMedium,
)
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_message),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
fun SceneIcon.displayEmoji(): String = when (this) {
SceneIcon.HOME -> "🏠"
SceneIcon.OFFICE -> "💼"
SceneIcon.NIGHT -> "🌙"
SceneIcon.MOVIE -> "🎬"
SceneIcon.AWAY -> "🚶"
SceneIcon.READING -> "📖"
SceneIcon.DINING -> "🍽️"
SceneIcon.GAMING -> "🎮"
SceneIcon.THERMOSTAT -> "🌡️"
SceneIcon.LIGHT -> "💡"
SceneIcon.CLEAN -> "🧹"
SceneIcon.CUSTOM -> "✨"
}
internal fun SceneColor.toColorOrNull(): Color? {
val cleanHex = hex.removePrefix("#")
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
}
@@ -0,0 +1,115 @@
package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
enum class SceneFilterTab {
ALL,
MANUAL,
AUTOMATIC,
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
val selectedTab: StateFlow<SceneFilterTab> = _selectedTab.asStateFlow()
private val allScenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
.flatMapLatest { profile ->
if (profile == null) {
flowOf(DomainResult.Success(emptyList()))
} else {
sceneRepository.observeScenes(profile.id)
}
}
.map { result -> (result as? DomainResult.Success)?.value.orEmpty() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val scenes: StateFlow<List<Scene>> = combine(allScenes, _selectedTab) { list, tab ->
when (tab) {
SceneFilterTab.ALL -> list
SceneFilterTab.MANUAL -> list.filter { it.type == AutomationType.MANUAL }
SceneFilterTab.AUTOMATIC -> list.filter { it.type == AutomationType.AUTOMATIC }
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
private val _pendingDelete = MutableStateFlow<SceneId?>(null)
val pendingDelete: StateFlow<SceneId?> = _pendingDelete.asStateFlow()
private val _userMessage = MutableSharedFlow<String>()
val userMessage: SharedFlow<String> = _userMessage.asSharedFlow()
fun selectTab(tab: SceneFilterTab) {
_selectedTab.value = tab
}
fun toggleEnabled(scene: Scene) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val updated = scene.copy(enabled = !scene.enabled)
sceneRepository.updateScene(profile.id, updated)
}
}
fun executeQuick(scene: Scene) {
viewModelScope.launch {
sceneExecutor.execute(scene)
_userMessage.emit("executed")
}
}
fun deleteScene(sceneId: SceneId) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
sceneRepository.deleteScene(profile.id, sceneId)
_pendingDelete.value = null
}
}
fun cancelDelete() {
_pendingDelete.value = null
}
fun markPendingDelete(sceneId: SceneId) {
_pendingDelete.value = sceneId
}
}
@@ -0,0 +1,119 @@
package com.flagship.abox.manager.ui.scene
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.SceneId
/**
* Scene navigation routes.
*/
object SceneRoutes {
const val LIST = "scene/list"
const val EDITOR = "scene/editor"
const val EDITOR_MANUAL = "scene/editor/manual"
const val EDITOR_AUTOMATIC = "scene/editor/automatic"
const val EDITOR_WITH_ID = "scene/editor/{sceneId}"
const val EXECUTION = "scene/execution/{sceneId}"
fun editorFor(sceneId: SceneId): String = "scene/editor/${sceneId.value}"
fun executionFor(sceneId: SceneId): String = "scene/execution/${sceneId.value}"
}
/**
* Embedded navigation host for the scenes tab in [MainAppShell].
*/
@Composable
fun SceneNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
) {
NavHost(
navController = navController,
startDestination = SceneRoutes.LIST,
modifier = modifier,
) {
sceneListDestination(
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
sceneEditorDestination(
onBack = { navController.popBackStack() },
onSaved = {
navController.popBackStack(SceneRoutes.LIST, inclusive = false)
},
)
sceneExecutionDestination(
onBack = { navController.popBackStack() },
)
}
}
fun NavGraphBuilder.sceneListDestination(
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
) {
composable(SceneRoutes.LIST) {
SceneListScreen(
onAddManual = onAddManual,
onAddAutomatic = onAddAutomatic,
onEditScene = onEditScene,
onExecuteScene = onExecuteScene,
headerContent = headerContent,
)
}
}
fun NavGraphBuilder.sceneEditorDestination(
onBack: () -> Unit,
onSaved: () -> Unit,
) {
composable(SceneRoutes.EDITOR) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
}
composable(SceneRoutes.EDITOR_MANUAL) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
}
composable(SceneRoutes.EDITOR_AUTOMATIC) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.AUTOMATIC, onBack = onBack, onSaved = onSaved)
}
composable(
route = SceneRoutes.EDITOR_WITH_ID,
arguments = listOf(navArgument("sceneId") { type = NavType.StringType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("sceneId")
SceneEditorScreen(
sceneId = id?.let { SceneId(it) },
initialType = null,
onBack = onBack,
onSaved = onSaved,
)
}
}
fun NavGraphBuilder.sceneExecutionDestination(
onBack: () -> Unit,
) {
composable(
route = SceneRoutes.EXECUTION,
arguments = listOf(navArgument("sceneId") { type = NavType.StringType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("sceneId")
SceneExecutionScreen(
sceneId = SceneId(requireNotNull(id) { "sceneId argument is required" }),
onBack = onBack,
)
}
}
@@ -42,6 +42,167 @@
<string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string>
<!-- Scene & Automation strings -->
<string name="scene_list_title">自动化与情景</string>
<string name="scene_tab_all">全部</string>
<string name="scene_tab_manual">一键指令</string>
<string name="scene_tab_automatic">自动指令</string>
<string name="scene_create_manual">建立一键指令</string>
<string name="scene_create_automatic">建立自动指令</string>
<string name="scene_create_action">新建指令</string>
<string name="scene_action_count">%1$d 个动作</string>
<string name="scene_edit">编辑指令</string>
<string name="scene_delete">删除指令</string>
<string name="scene_empty_title">还没有自动化指令</string>
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
<string name="scene_action_run_now">立即执行</string>
<string name="scene_toast_executed">指令已触发执行</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除指令</string>
<string name="scene_delete_dialog_message">确定要删除该指令吗?此操作无法撤销。</string>
<string name="scene_delete_confirm">删除</string>
<string name="scene_delete_cancel">取消</string>
<!-- Scene: editor -->
<string name="scene_editor_manual_title">建立一键指令</string>
<string name="scene_editor_auto_title">建立自动指令</string>
<string name="scene_editor_edit_title">编辑指令</string>
<string name="scene_back">返回</string>
<string name="scene_name_label">标题</string>
<string name="scene_name_hint">请输入指令标题(如:回家一键开灯、高温自动降温)</string>
<string name="scene_description_label">描述</string>
<string name="scene_description_hint">可填写该指令的详细说明或备注</string>
<!-- Sections -->
<string name="scene_section_triggers">触发器</string>
<string name="scene_section_conditions">条件(可选)</string>
<string name="scene_section_actions">动作</string>
<string name="scene_section_advanced">进阶选项</string>
<string name="scene_section_other">其他选项与外观</string>
<!-- Add Buttons -->
<string name="scene_add_trigger">+ 新增触发器</string>
<string name="scene_add_condition">+ 新增条件</string>
<string name="scene_add_action_btn">+ 新增动作</string>
<string name="scene_add_action">添加</string>
<string name="scene_save_action">保存指令</string>
<!-- Strategies & Toggles -->
<string name="scene_repeat_strategy">重复执行策略</string>
<string name="scene_strategy_continue">继续执行新任务</string>
<string name="scene_concurrency_mode">多指令同步策略</string>
<string name="scene_concurrency_sync">同步 (串行执行)</string>
<string name="scene_concurrency_async">异步 (并发执行)</string>
<string name="scene_error_policy">遇到错误时</string>
<string name="scene_policy_continue">继续执行后续动作</string>
<string name="scene_policy_abort">立即中止剩余动作</string>
<string name="scene_appearance_icon">图示</string>
<string name="scene_appearance_color">图示背景色</string>
<string name="scene_enable_rule">启用该自动指令</string>
<string name="scene_enable_logging">开启指令执行记录</string>
<!-- Trigger Types & Dialog -->
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
<string name="scene_trigger_type_time">⏰ 定时时间触发</string>
<string name="scene_trigger_type_temp">🌡️ 环境温度触发</string>
<string name="scene_trigger_type_humidity">💧 环境湿度触发</string>
<string name="scene_trigger_type_device">🔌 插座状态变更触发</string>
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
<string name="scene_operator_label">比较条件</string>
<string name="scene_operator_gt">高于 (&gt;)</string>
<string name="scene_operator_lt">低于 (&lt;)</string>
<string name="scene_operator_eq">等于 (=)</string>
<!-- Condition Types & Dialog -->
<string name="scene_dialog_add_condition_title">选择限制条件</string>
<string name="scene_condition_type_temp">🌡️ 环境温度范围</string>
<string name="scene_condition_type_humidity">💧 环境湿度范围</string>
<string name="scene_condition_type_socket">🔌 插座当前电源状态</string>
<string name="scene_condition_type_time_range">🕒 生效时间区间</string>
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
<!-- Action Types & Dialog -->
<string name="scene_dialog_add_action_title">选择执行动作</string>
<string name="scene_action_type_socket">🔌 智能插座控制</string>
<string name="scene_action_type_virtual_remote">📺 红外遥控按键</string>
<string name="scene_action_type_delay">⏱️ 延时等待 (缓冲)</string>
<string name="scene_action_type_notification">🔔 发送系统提示</string>
<string name="scene_select_socket">选择目标插座</string>
<string name="scene_socket_power_state">设定电源状态</string>
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
<string name="scene_select_remote_button">选择遥控器功能按键</string>
<string name="scene_delay_duration_label">等待时长 (秒)</string>
<string name="scene_notification_title_label">提示标题</string>
<string name="scene_notification_msg_label">提示消息内容</string>
<string name="scene_unit_seconds">%1$d 秒</string>
<!-- Action Card Displays -->
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
<string name="scene_action_delay">⏱️ 等待 %1$d 秒</string>
<string name="scene_action_notification">🔔 提示: %1$s</string>
<!-- Scene: execution -->
<string name="scene_execution_title">执行指令</string>
<string name="scene_execution_run">执行</string>
<string name="scene_execution_running">正在执行:%1$s…</string>
<string name="scene_execution_summary">%3$d 个动作中成功 %1$d 个,失败 %2$d 个</string>
<string name="scene_execution_done">执行完成</string>
<string name="scene_execution_duration">总耗时:%1$s ms</string>
<string name="scene_step_pending">排队中</string>
<string name="scene_step_running">正在执行…</string>
<string name="scene_step_delaying">正在延时等待 (%1$d s)…</string>
<string name="scene_step_success">已完成</string>
<string name="scene_step_failed">执行失败</string>
<string name="scene_step_skipped">已跳过</string>
<!-- 校验与弹窗选项/占位文案 -->
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
<string name="scene_chip_time">⏰ 定时</string>
<string name="scene_chip_temp">🌡️ 温度</string>
<string name="scene_chip_humidity">💧 湿度</string>
<string name="scene_chip_socket">🔌 插座</string>
<string name="scene_chip_remote">📺 遥控</string>
<string name="scene_chip_delay">⏱️ 延时</string>
<string name="scene_chip_notify">🔔 提示</string>
<string name="scene_chip_above">&gt; 高于</string>
<string name="scene_chip_below">&lt; 低于</string>
<string name="scene_chip_power_on">开启</string>
<string name="scene_chip_power_off">关闭</string>
<string name="scene_threshold_temp">设定阈值:%1$d °C</string>
<string name="scene_threshold_hum">设定阈值:%1$d %%</string>
<string name="scene_target_socket_state">要求插座当前状态为:</string>
<string name="scene_no_virtual_remotes">未找到虚拟遥控器,将默认使用红外控制器</string>
<string name="scene_remote_button_slot_label">按键名称/槽位</string>
<string name="scene_remote_button_name_label">按键名称</string>
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
<string name="scene_summary_triggers_and_actions">⚡ %1$d 个触发器 • %2$d 个动作</string>
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
<string name="scene_exec_rerun">重新执行</string>
<string name="scene_step_format">步骤 %1$d:%2$s</string>
<string name="scene_trigger_desc_temp">温度 %1$s %2$s°C</string>
<string name="scene_trigger_desc_humidity">湿度 %1$s %2$s%%</string>
<string name="scene_trigger_desc_device">%1$s 状态变为 %2$s</string>
<string name="scene_condition_desc_temp">温度需 %1$s %2$s°C</string>
<string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s 处于 %2$s</string>
<string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
<string name="scene_device_name_label">设备名称(网关配对标识)</string>
<string name="scene_loading_scene">正在加载情景配置…</string>
<string name="scene_load_failed">无法加载情景配置。</string>
<string name="scene_default_button_power">电源</string>
<string name="scene_default_notify_title">情景完成</string>
<string name="scene_default_notify_msg">自动化任务执行完成</string>
<!-- 设备列表 -->
<string name="device_list_title">设备列表</string>
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
+161
View File
@@ -42,6 +42,167 @@
<string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string>
<!-- Scene & Automation strings -->
<string name="scene_list_title">Automation &amp; Scenes</string>
<string name="scene_tab_all">All</string>
<string name="scene_tab_manual">Manual</string>
<string name="scene_tab_automatic">Automatic</string>
<string name="scene_create_manual">New Manual Command</string>
<string name="scene_create_automatic">New Automatic Rule</string>
<string name="scene_create_action">New Command</string>
<string name="scene_action_count">%1$d actions</string>
<string name="scene_edit">Edit Command</string>
<string name="scene_delete">Delete Command</string>
<string name="scene_empty_title">No automation commands yet</string>
<string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</string>
<string name="scene_action_run_now">Run Now</string>
<string name="scene_toast_executed">Command triggered</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">Delete Command</string>
<string name="scene_delete_dialog_message">Are you sure you want to delete this command? This cannot be undone.</string>
<string name="scene_delete_confirm">Delete</string>
<string name="scene_delete_cancel">Cancel</string>
<!-- Scene: editor -->
<string name="scene_editor_manual_title">Create Manual Command</string>
<string name="scene_editor_auto_title">Create Automatic Rule</string>
<string name="scene_editor_edit_title">Edit Command</string>
<string name="scene_back">Back</string>
<string name="scene_name_label">Title</string>
<string name="scene_name_hint">Enter title (e.g. Movie Night, High Temp Auto Cooling)</string>
<string name="scene_description_label">Description</string>
<string name="scene_description_hint">Optional notes or details about this rule</string>
<!-- Sections -->
<string name="scene_section_triggers">Triggers</string>
<string name="scene_section_conditions">Conditions (Optional)</string>
<string name="scene_section_actions">Actions</string>
<string name="scene_section_advanced">Advanced Options</string>
<string name="scene_section_other">Other Options &amp; Appearance</string>
<!-- Add Buttons -->
<string name="scene_add_trigger">+ Add Trigger</string>
<string name="scene_add_condition">+ Add Condition</string>
<string name="scene_add_action_btn">+ Add Action</string>
<string name="scene_add_action">Add</string>
<string name="scene_save_action">Save Command</string>
<!-- Strategies & Toggles -->
<string name="scene_repeat_strategy">Repeat Execution Strategy</string>
<string name="scene_strategy_continue">Continue new task</string>
<string name="scene_concurrency_mode">Concurrency Mode</string>
<string name="scene_concurrency_sync">Synchronous (Sequential)</string>
<string name="scene_concurrency_async">Asynchronous (Parallel)</string>
<string name="scene_error_policy">On Error Policy</string>
<string name="scene_policy_continue">Continue subsequent actions</string>
<string name="scene_policy_abort">Abort remaining actions</string>
<string name="scene_appearance_icon">Icon</string>
<string name="scene_appearance_color">Icon Background Color</string>
<string name="scene_enable_rule">Enable this automatic rule</string>
<string name="scene_enable_logging">Enable command execution logs</string>
<!-- Trigger Types & Dialog -->
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
<string name="scene_trigger_type_time">⏰ Time / Schedule Trigger</string>
<string name="scene_trigger_type_temp">🌡️ Temperature Threshold</string>
<string name="scene_trigger_type_humidity">💧 Humidity Threshold</string>
<string name="scene_trigger_type_device">🔌 Socket State Changed</string>
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
<string name="scene_operator_label">Condition</string>
<string name="scene_operator_gt">Greater than (&gt;)</string>
<string name="scene_operator_lt">Less than (&lt;)</string>
<string name="scene_operator_eq">Equals (=)</string>
<!-- Condition Types & Dialog -->
<string name="scene_dialog_add_condition_title">Select Condition</string>
<string name="scene_condition_type_temp">🌡️ Temperature Range</string>
<string name="scene_condition_type_humidity">💧 Humidity Range</string>
<string name="scene_condition_type_socket">🔌 Current Socket Power</string>
<string name="scene_condition_type_time_range">🕒 Active Time Range</string>
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
<!-- Action Types & Dialog -->
<string name="scene_dialog_add_action_title">Select Action</string>
<string name="scene_action_type_socket">🔌 Smart Socket Control</string>
<string name="scene_action_type_virtual_remote">📺 Infrared Remote Button</string>
<string name="scene_action_type_delay">⏱️ Delay / Buffer Timer</string>
<string name="scene_action_type_notification">🔔 System Notification</string>
<string name="scene_select_socket">Select Target Socket</string>
<string name="scene_socket_power_state">Target Power State</string>
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
<string name="scene_select_remote_button">Select Remote Key</string>
<string name="scene_delay_duration_label">Delay Duration (seconds)</string>
<string name="scene_notification_title_label">Notification Title</string>
<string name="scene_notification_msg_label">Notification Message</string>
<string name="scene_unit_seconds">%1$d sec</string>
<!-- Action Card Displays -->
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
<string name="scene_action_delay">⏱️ Delay %1$d seconds</string>
<string name="scene_action_notification">🔔 Notify: %1$s</string>
<!-- Scene: execution -->
<string name="scene_execution_title">Execute Command</string>
<string name="scene_execution_run">Run</string>
<string name="scene_execution_running">Running: %1$s…</string>
<string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string>
<string name="scene_execution_done">Execution Completed</string>
<string name="scene_execution_duration">Total Elapsed: %1$s ms</string>
<string name="scene_step_pending">Pending</string>
<string name="scene_step_running">Running…</string>
<string name="scene_step_delaying">Waiting (%1$d s)…</string>
<string name="scene_step_success">Completed</string>
<string name="scene_step_failed">Failed</string>
<string name="scene_step_skipped">Skipped</string>
<!-- Validation & dialog chips / placeholders -->
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
<string name="scene_chip_time">⏰ Schedule</string>
<string name="scene_chip_temp">🌡️ Temperature</string>
<string name="scene_chip_humidity">💧 Humidity</string>
<string name="scene_chip_socket">🔌 Socket</string>
<string name="scene_chip_remote">📺 Remote</string>
<string name="scene_chip_delay">⏱️ Delay</string>
<string name="scene_chip_notify">🔔 Notify</string>
<string name="scene_chip_above">&gt; Above</string>
<string name="scene_chip_below">&lt; Below</string>
<string name="scene_chip_power_on">Turn ON</string>
<string name="scene_chip_power_off">Turn OFF</string>
<string name="scene_threshold_temp">Target threshold: %1$d °C</string>
<string name="scene_threshold_hum">Target threshold: %1$d %%</string>
<string name="scene_target_socket_state">Require socket state to be:</string>
<string name="scene_no_virtual_remotes">No virtual remotes found; using infrared controller by default.</string>
<string name="scene_remote_button_slot_label">Button Name / Slot</string>
<string name="scene_remote_button_name_label">Button Name</string>
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</string>
<string name="scene_empty_conditions_hint">No conditions configured. Tap + below to add.</string>
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
<string name="scene_summary_triggers_and_actions">⚡ %1$d triggers • %2$d actions</string>
<string name="scene_summary_actions_only">%1$d actions</string>
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
<string name="scene_exec_rerun">Re-run</string>
<string name="scene_step_format">Step %1$d: %2$s</string>
<string name="scene_trigger_desc_temp">Temp %1$s %2$s°C</string>
<string name="scene_trigger_desc_humidity">Humidity %1$s %2$s%%</string>
<string name="scene_trigger_desc_device">%1$s state changed to %2$s</string>
<string name="scene_condition_desc_temp">Temp must be %1$s %2$s°C</string>
<string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s is %2$s</string>
<string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string>
<string name="scene_device_name_label">Device Name (Gateway Identifier)</string>
<string name="scene_loading_scene">Loading scene...</string>
<string name="scene_load_failed">Failed to load scene.</string>
<string name="scene_default_button_power">Power</string>
<string name="scene_default_notify_title">Scene Completed</string>
<string name="scene_default_notify_msg">Automation task executed successfully</string>
<!-- Device list -->
<string name="device_list_title">Device List</string>
<string name="device_list_description">Select an ABox terminal device to control or check status.</string>
@@ -0,0 +1,204 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneEditorViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
private val profileId = ProviderProfileId("local-experience")
private val existingScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Existing Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private suspend fun setupProfile() {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(existingScene))
}
@Test
fun initializeWithNullPreparesNewScene() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
val state = viewModel.state.value
assertFalse(state.isEditing)
assertEquals("", state.name)
assertTrue(state.actions.isEmpty())
assertFalse(state.isValid)
}
@Test
fun initializeWithExistingSceneLoadsState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id)
// Immediately binds sceneId and enters loading state
assertTrue(viewModel.state.value.isEditing)
assertEquals(existingScene.id, viewModel.state.value.sceneId)
assertTrue(viewModel.state.value.isLoading)
assertFalse(viewModel.state.value.isValid)
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state.isEditing)
assertFalse(state.isLoading)
assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size)
assertTrue(state.isValid)
}
@Test
fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addDelayAction(3)
viewModel.addNotificationAction("Done", "Completed")
advanceUntilIdle()
assertEquals(3, viewModel.state.value.actions.size)
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification)
// Move second to first
viewModel.moveAction(1, 0)
assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action
val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId)
assertEquals(2, viewModel.state.value.actions.size)
}
@Test
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Rule")
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF)
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL)
viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR)
advanceUntilIdle()
val state = viewModel.state.value
assertEquals(1, state.triggers.size)
assertTrue(state.triggers[0] is SceneTrigger.Temperature)
assertEquals(1, state.conditions.size)
assertTrue(state.conditions[0] is SceneCondition.SocketPower)
assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode)
assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy)
assertTrue(state.isValid)
}
@Test
fun saveNewScenePersistsToRepository() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("Brand New Scene")
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
advanceUntilIdle()
assertTrue(viewModel.state.value.isValid)
viewModel.save()
advanceUntilIdle()
assertTrue(viewModel.state.value.isSaved)
assertFalse(viewModel.state.value.isSaving)
}
@Test
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Without Trigger")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
advanceUntilIdle()
// Missing triggers -> invalid
assertFalse(viewModel.state.value.isValid)
// Adding trigger makes it valid
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
advanceUntilIdle()
assertTrue(viewModel.state.value.isValid)
}
@Test
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id)
advanceUntilIdle()
viewModel.updateName("Modified Unsaved Name")
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
// Re-invoking initialize with same sceneId should NOT overwrite draft
viewModel.initialize(existingScene.id)
advanceUntilIdle()
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
}
}
@@ -0,0 +1,125 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneExecutionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val fakeSocket = FakeSocketGateway()
private val fakeInfrared = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(fakeSocket, fakeInfrared)
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Execution Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private suspend fun setupProfile() {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
}
@Test
fun initializeLoadsSceneStateReady() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Ready)
val ready = viewModel.uiState.value as SceneExecutionUiState.Ready
assertEquals(testScene.id, ready.scene.id)
}
@Test
fun executeRunsSceneAndTransitionsToCompleted() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
val completed = viewModel.uiState.value as SceneExecutionUiState.Completed
assertEquals(testScene.id, completed.result.scene.id)
assertEquals(1, completed.result.results.size)
}
@Test
fun initializeIsIdempotentAndPreservesCompletedState() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
// Re-initializing with same sceneId should preserve Completed state
viewModel.initialize(testScene.id)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
}
@Test
fun executeFailsWhenActiveProfileMismatches() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
// Switch to a different profile
profileStore.setActiveProfile(ProviderProfileId("different-profile"))
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Failed)
val failed = viewModel.uiState.value as SceneExecutionUiState.Failed
assertEquals("Active profile mismatch", failed.message)
}
}
@@ -0,0 +1,170 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val socketGateway = FakeSocketGateway()
private val infraredGateway = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway)
private val profileId = ProviderProfileId("local-experience")
private val manualScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Manual Scene",
type = AutomationType.MANUAL,
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private val autoScene = Scene(
id = SceneId("local-experience:scene-2"),
profileId = profileId,
name = "Auto Rule",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.THERMOSTAT,
color = SceneColor("#26A69A"),
actions = listOf(
SceneAction.Delay(
id = SceneActionId("local-experience:action-2"),
profileId = profileId,
durationSeconds = 5,
),
),
)
@Test
fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
assertEquals(2, viewModel.scenes.value.size)
assertEquals("Manual Scene", viewModel.scenes.value[0].name)
assertEquals("Auto Rule", viewModel.scenes.value[1].name)
}
@Test
fun filterTabFiltersScenes() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.selectTab(SceneFilterTab.MANUAL)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type)
viewModel.selectTab(SceneFilterTab.AUTOMATIC)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type)
}
@Test
fun toggleEnabledUpdatesSceneInRepository() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.toggleEnabled(autoScene)
advanceUntilIdle()
val updated = viewModel.scenes.value.first()
assertFalse(updated.enabled)
}
@Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(manualScene.id)
advanceUntilIdle()
assertNull(viewModel.pendingDelete.value)
assertTrue(viewModel.scenes.value.isEmpty())
}
@Test
fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value)
}
}
+1
View File
@@ -11,3 +11,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,211 @@
package com.flagship.abox.manager.domain
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
/**
* Production implementation of [SceneExecutor].
*
* Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction],
* and built-in timers.
*/
class DefaultSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
if (alreadyRunning) {
return DomainResult.Failure(
DomainError.OperationFailed(
detail = "Scene ${scene.id.value} is already running",
),
)
}
val startTime = System.currentTimeMillis()
return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
coroutineScope {
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
deferreds = scene.actions.mapIndexed { index, action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
}
deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
for (action in scene.actions) {
if (aborted) {
sequentialResults.add(
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SKIPPED,
error = null,
),
)
continue
}
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration)
sequentialResults.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
sequentialResults
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
SceneExecutionResult(
scene = scene,
results = results,
totalDurationMillis = totalDuration,
),
)
} catch (e: CancellationException) {
throw e
} finally {
runningScenes.remove(scene.id)
}
}
private suspend fun executeAction(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
return try {
when (action) {
is SceneAction.SocketPower -> {
val res = socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
toExecutionResult(action, res)
}
is SceneAction.InfraredKey -> {
val res = infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
toExecutionResult(action, res)
}
is SceneAction.VirtualRemoteButton -> {
if (sendVirtualRemoteAction != null) {
val res = sendVirtualRemoteAction.sendButton(
profileId = profileId,
remoteId = VirtualRemoteId(action.remoteId),
buttonId = action.buttonId,
)
toExecutionResult(action, res)
} else {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(
detail = "Virtual remote action executor is not available",
),
)
}
}
is SceneAction.Delay -> {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Notification -> {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult {
return when (res) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = res.error,
)
}
}
}
@@ -0,0 +1,179 @@
package com.flagship.abox.manager.domain
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Provider-isolated in-memory [SceneRepository] implementation that supports
* seed defaults and real-time observation.
*/
class InMemorySceneRepository : SceneRepository {
private val flows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<DomainResult<List<Scene>>>>()
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
private fun mutexFor(profileId: ProviderProfileId): Mutex =
mutexes.getOrPut(profileId) { Mutex() }
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> =
flowFor(profileId)
override suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?> {
val scenes = currentScenes(profileId) ?: return notFound(sceneId)
return DomainResult.Success(scenes.firstOrNull { it.id == sceneId })
}
override suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied profileId"
}
val scenes = currentScenes(profileId).orEmpty()
if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}",
),
)
}
val updated = scenes + scene
publish(profileId, updated)
DomainResult.Success(scene)
}
override suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied profileId"
}
val scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated)
DomainResult.Success(scene)
}
override suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit> = mutexFor(profileId).withLock {
val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated)
DomainResult.Success(Unit)
}
fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) {
require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied profileId"
}
require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) {
"All scene actions must belong to the supplied profileId"
}
publish(profileId, scenes)
}
private fun flowFor(profileId: ProviderProfileId): MutableStateFlow<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList()
MutableStateFlow(DomainResult.Success(initial))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
(flowFor(profileId).value as? DomainResult.Success)?.value
private fun publish(profileId: ProviderProfileId, scenes: List<Scene>) {
flowFor(profileId).value = DomainResult.Success(scenes)
}
private fun notFound(sceneId: SceneId): DomainResult<Nothing> = DomainResult.Failure(
DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"),
)
private fun defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "离家模式",
description = "关闭插座并延时关闭电视",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF,
),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("红外控制器"),
key = "1",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-cooling-auto"),
profileId = profileId,
name = "高温自动降温",
description = "当室温高于 28°C 时开启插座",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.HOME,
color = SceneColor("#26A69A"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
label = "温度高于 28°C",
),
),
conditions = listOf(
SceneCondition.SocketPower(
id = SceneConditionId("${profileId.value}:cond-socket"),
deviceName = DeviceName("Zigbee插座控制器"),
expectedState = SocketPowerState.OFF,
),
),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-cooling:socket-1"),
profileId = profileId,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.ON,
),
),
),
)
companion object {
val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience")
}
}
@@ -0,0 +1,204 @@
package com.flagship.abox.manager.domain
@JvmInline
value class SceneId(val value: String)
@JvmInline
value class SceneActionId(val value: String)
@JvmInline
value class SceneTriggerId(val value: String)
@JvmInline
value class SceneConditionId(val value: String)
enum class AutomationType {
MANUAL, // 一键指令 (One-Tap / Instant Command)
AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation)
}
enum class SceneActionType {
SOCKET_POWER,
VIRTUAL_REMOTE_BUTTON,
INFRARED_KEY,
DELAY,
NOTIFICATION,
}
enum class ComparisonOperator(val symbol: String) {
GREATER_THAN(">"),
LESS_THAN("<"),
EQUALS("="),
GREATER_OR_EQUAL(">="),
LESS_OR_EQUAL("<="),
}
sealed interface SceneTrigger {
val id: SceneTriggerId
data class Time(
override val id: SceneTriggerId,
val timeOfDay: String, // e.g. "08:30"
val daysOfWeek: Set<Int> = emptySet(), // 1..7 (Monday..Sunday), empty means everyday
val label: String = "",
) : SceneTrigger
data class Temperature(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdCelsius: Double,
val label: String = "",
) : SceneTrigger
data class Humidity(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdPercent: Double,
val label: String = "",
) : SceneTrigger
data class DeviceState(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val expectedState: String, // e.g. "ON" / "OFF"
val label: String = "",
) : SceneTrigger
}
sealed interface SceneCondition {
val id: SceneConditionId
data class Temperature(
override val id: SceneConditionId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdCelsius: Double,
) : SceneCondition
data class Humidity(
override val id: SceneConditionId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdPercent: Double,
) : SceneCondition
data class SocketPower(
override val id: SceneConditionId,
val deviceName: DeviceName,
val expectedState: SocketPowerState,
) : SceneCondition
data class TimeRange(
override val id: SceneConditionId,
val startTime: String, // "22:00"
val endTime: String, // "07:00"
) : SceneCondition
}
/**
* A single executable step inside a [Scene].
*/
sealed interface SceneAction {
val id: SceneActionId
val profileId: ProviderProfileId
val type: SceneActionType
data class SocketPower(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val deviceName: DeviceName,
val powerState: SocketPowerState,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER
}
data class VirtualRemoteButton(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val remoteId: String,
val remoteDisplayName: String,
val buttonId: String,
val buttonDisplayName: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON
}
data class InfraredKey(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY
}
data class Delay(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val durationSeconds: Int,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.DELAY
}
data class Notification(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val title: String,
val message: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.NOTIFICATION
}
}
enum class ExecutionStrategy {
CONTINUE_ON_ERROR, // 遇错继续
ABORT_ON_ERROR, // 遇错中止
}
enum class ConcurrencyMode {
SEQUENTIAL, // 同步 / 串行
PARALLEL, // 异步 / 并行
}
/**
* Visual identity for a scene.
*/
enum class SceneIcon {
HOME,
OFFICE,
NIGHT,
MOVIE,
AWAY,
READING,
DINING,
GAMING,
THERMOSTAT,
LIGHT,
CLEAN,
CUSTOM,
}
/**
* Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`).
*/
@JvmInline
value class SceneColor(val hex: String)
data class Scene(
val id: SceneId,
val profileId: ProviderProfileId,
val name: String,
val description: String = "",
val type: AutomationType = AutomationType.MANUAL,
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val color: SceneColor = SceneColor("#3F51B5"),
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
)
@@ -0,0 +1,36 @@
package com.flagship.abox.manager.domain
enum class SceneActionOutcome {
SUCCESS,
FAILURE,
SKIPPED,
}
/**
* Result of executing a single [SceneAction]. [error] is non-null when [outcome] is [FAILURE].
*/
data class SceneActionExecutionResult(
val action: SceneAction,
val outcome: SceneActionOutcome,
val error: DomainError?,
val executionDurationMillis: Long = 0L,
)
/**
* Aggregated result of executing a [Scene].
*/
data class SceneExecutionResult(
val scene: Scene,
val results: List<SceneActionExecutionResult>,
val totalDurationMillis: Long = 0L,
) {
val totalActions: Int get() = results.size
val succeededCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
val failedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
val skippedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SKIPPED }
val allSucceeded: Boolean
get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.domain
/**
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
*
* Implementations must be idempotent from the caller's perspective: triggering a scene that is
* already running returns [DomainError.OperationFailed] rather than starting a second pass.
*/
interface SceneExecutor {
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.Flow
/**
* Owns the scene catalog for every provider profile. Implementations must isolate data by
* [ProviderProfileId]: scenes, actions and identifiers must not leak across profiles.
*
* This port is intentionally persistence-agnostic. The first PR provides an in-memory Fake
* implementation; Room persistence will land in a follow-up `feat/scene-persistence` branch.
*/
interface SceneRepository {
fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>>
suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?>
suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene>
suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene>
suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit>
}
@@ -0,0 +1,352 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultSceneExecutorTest {
private val profileId = ProviderProfileId("local-experience")
private class TestSocketGateway(
var response: DomainResult<SocketStatus> = DomainResult.Success(
SocketStatus(
profileId = ProviderProfileId("local-experience"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
) : SocketGateway {
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, SocketPowerState>>()
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = response
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
calls.add(Triple(profileId, deviceName, powerState))
return response
}
}
private class TestInfraredGateway(
var response: DomainResult<Unit> = DomainResult.Success(Unit),
) : InfraredGateway {
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, String>>()
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls.add(Triple(profileId, deviceName, key))
return response
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = DomainResult.Success(Unit)
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
}
@Test
fun executesActionsSequentiallyAndAggregatesResults() = runTest {
val socketGateway = TestSocketGateway()
val infraredGateway = TestInfraredGateway()
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(scene, executionResult.scene)
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(Triple(profileId, DeviceName("socket-1"), SocketPowerState.ON), socketGateway.calls[0])
assertEquals(1, infraredGateway.calls.size)
assertEquals(Triple(profileId, DeviceName("ir-1"), "power"), infraredGateway.calls[0])
}
@Test
fun continuesExecutionWhenAnActionFails() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertTrue(executionResult.results[0].error is DomainError.DeviceOffline)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
@Test
fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(action1, action2),
strategy = ExecutionStrategy.ABORT_ON_ERROR,
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
assertEquals(0, infraredGateway.calls.size)
}
@Test
fun executesDelayActionSuccessfully() = runTest {
var delayCalled = 0L
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
delayDurationProvider = { delayCalled = it },
)
val action = SceneAction.Delay(
id = SceneActionId("d1"),
profileId = profileId,
durationSeconds = 5,
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Delay Scene",
actions = listOf(action),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(1, executionResult.results.size)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(5000L, delayCalled)
}
@Test
fun executesActionsConcurrentlyWhenParallelMode() = runTest {
val socketGateway = TestSocketGateway()
val infraredGateway = TestInfraredGateway()
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Parallel Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
@Test
fun failsVirtualRemoteButtonActionWhenSenderNotConfigured() = runTest {
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
sendVirtualRemoteAction = null,
)
val action = SceneAction.VirtualRemoteButton(
id = SceneActionId("vr1"),
profileId = profileId,
remoteId = "tv-remote",
remoteDisplayName = "TV",
buttonId = "power",
buttonDisplayName = "Power",
)
val scene = Scene(
id = SceneId("s-vr"),
profileId = profileId,
name = "VR Scene",
actions = listOf(action),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(1, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertTrue(executionResult.results[0].error is DomainError.OperationFailed)
}
@Test(expected = kotlinx.coroutines.CancellationException::class)
fun rethrowsCancellationExceptionWhenDelayCancelled() = runTest {
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
delayDurationProvider = { throw kotlinx.coroutines.CancellationException("Job cancelled") },
)
val action = SceneAction.Delay(
id = SceneActionId("d1"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("s-cancelled"),
profileId = profileId,
name = "Cancelled Scene",
actions = listOf(action),
)
executor.execute(scene)
}
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.OperationFailed(detail = "Socket failed")),
)
val executor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = TestInfraredGateway(),
delayDurationProvider = {
kotlinx.coroutines.delay(1000)
},
)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.Delay(
id = SceneActionId("a2"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("s-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
}
}
@@ -0,0 +1,128 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InMemorySceneRepositoryTest {
private val localProfile = ProviderProfileId("local-experience")
private val aboxProfile = ProviderProfileId("abox:admin")
@Test
fun isolatesScenesBetweenProfiles() = runTest {
val repository = InMemorySceneRepository()
val localScenes = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
assertTrue(localScenes.isNotEmpty())
val aboxScenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertTrue(aboxScenes.isEmpty())
val newScene = Scene(
id = SceneId("abox:admin:scene-custom"),
profileId = aboxProfile,
name = "ABox Scene",
icon = SceneIcon.NIGHT,
color = SceneColor("#4CAF50"),
actions = emptyList(),
)
val createResult = repository.createScene(aboxProfile, newScene)
assertTrue(createResult is DomainResult.Success)
val updatedAbox = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertEquals(1, updatedAbox.size)
assertEquals("ABox Scene", updatedAbox.first().name)
val updatedLocal = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
assertEquals(localScenes.size, updatedLocal.size)
}
@Test
fun crudOperationsFunctionCorrectly() = runTest {
val repository = InMemorySceneRepository()
val sceneId = SceneId("abox:admin:scene-1")
val initialScene = Scene(
id = sceneId,
profileId = aboxProfile,
name = "Initial",
icon = SceneIcon.HOME,
color = SceneColor("#FFFFFF"),
actions = emptyList(),
)
// Create
val createResult = repository.createScene(aboxProfile, initialScene)
assertTrue(createResult is DomainResult.Success)
// Get
val getResult = repository.getScene(aboxProfile, sceneId)
assertTrue(getResult is DomainResult.Success)
assertEquals("Initial", (getResult as DomainResult.Success).value?.name)
// Update
val updatedScene = initialScene.copy(name = "Updated")
val updateResult = repository.updateScene(aboxProfile, updatedScene)
assertTrue(updateResult is DomainResult.Success)
val getAfterUpdate = repository.getScene(aboxProfile, sceneId)
assertEquals("Updated", (getAfterUpdate as DomainResult.Success).value?.name)
// Delete
val deleteResult = repository.deleteScene(aboxProfile, sceneId)
assertTrue(deleteResult is DomainResult.Success)
val getAfterDelete = repository.getScene(aboxProfile, sceneId)
assertNull((getAfterDelete as DomainResult.Success).value)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsSceneWithMismatchedActionProfileId() = runTest {
val repository = InMemorySceneRepository()
val crossAction = SceneAction.SocketPower(
id = SceneActionId("a-cross"),
profileId = ProviderProfileId("other-profile"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val scene = Scene(
id = SceneId("abox:admin:scene-invalid"),
profileId = aboxProfile,
name = "Invalid Scene",
actions = listOf(crossAction),
)
repository.createScene(aboxProfile, scene)
}
@Test
fun concurrentCreationsAreThreadSafe() = runTest {
val repository = InMemorySceneRepository()
val count = 20
coroutineScope {
(1..count).map { idx ->
async {
val scene = Scene(
id = SceneId("abox:admin:scene-$idx"),
profileId = aboxProfile,
name = "Scene $idx",
actions = emptyList(),
)
repository.createScene(aboxProfile, scene)
}
}.awaitAll()
}
val scenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertEquals(count, scenes.size)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
org.gradle.configuration-cache=false
android.useAndroidX=true
android.nonTransitiveRClass=true
+4
View File
@@ -12,6 +12,10 @@ pluginManagement {
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@@ -0,0 +1,391 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by
* [ProviderProfileId]; mutating scenes for one profile never touches another profile's flow.
*
* The repository is intentionally deterministic: [defaultScenes] seeds the same catalog every
* run so UI snapshots and tests stay stable. Mutations emit a new list through the profile's
* StateFlow.
*/
class FakeSceneRepository : SceneRepository {
private val flows = ConcurrentHashMap<
ProviderProfileId,
MutableStateFlow<DomainResult<List<Scene>>>,
>()
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
private fun mutexFor(profileId: ProviderProfileId): Mutex =
mutexes.getOrPut(profileId) { Mutex() }
override fun observeScenes(
profileId: ProviderProfileId,
): Flow<DomainResult<List<Scene>>> = flowFor(profileId)
override suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?> {
val scenes = currentScenes(profileId) ?: return notFound(sceneId)
return DomainResult.Success(scenes.firstOrNull { it.id == sceneId })
}
override suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied provider profile"
}
val scenes = currentScenes(profileId).orEmpty()
if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}",
),
)
}
val updated = scenes + scene
publish(profileId, updated)
DomainResult.Success(scene)
}
override suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied provider profile"
}
val scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated)
DomainResult.Success(scene)
}
override suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit> = mutexFor(profileId).withLock {
val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated)
DomainResult.Success(Unit)
}
/**
* Replaces the catalog for [profileId] in tests. Returns the same repository so callers can
* chain `repo.setScenes(profile, listOf(...))`.
*/
fun setScenes(profileId: ProviderProfileId, scenes: List<Scene>): FakeSceneRepository {
require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied provider profile"
}
require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) {
"All scene actions must belong to the supplied provider profile"
}
publish(profileId, scenes)
return this
}
fun setError(profileId: ProviderProfileId, error: DomainError) {
flowFor(profileId).value = DomainResult.Failure(error)
}
private fun flowFor(
profileId: ProviderProfileId,
): MutableStateFlow<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == localProfile) defaultScenes(profileId) else emptyList()
MutableStateFlow(DomainResult.Success(initial))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
(flowFor(profileId).value as? DomainResult.Success)?.value
private fun publish(profileId: ProviderProfileId, scenes: List<Scene>) {
flowFor(profileId).value = DomainResult.Success(scenes)
}
private fun notFound(sceneId: SceneId): DomainResult<Nothing> = DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Scene ${sceneId.value} not found",
),
)
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "Leaving home",
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-movie-night"),
profileId = profileId,
name = "Movie night",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
),
),
),
)
companion object {
/**
* The canonical profile id used by the in-app local experience provider. UI code that
* hard-codes a profile in phase 1 should use this constant so the Fake shows data.
*/
val localProfile: ProviderProfileId = ProviderProfileId("local-experience")
}
}
/**
* Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes,
* respecting delays and recording virtual remote / notification actions.
*/
class FakeSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val running = ConcurrentHashMap<SceneId, Boolean>()
val executedRemoteButtons = mutableListOf<SceneAction.VirtualRemoteButton>()
val sentNotifications = mutableListOf<SceneAction.Notification>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = running.putIfAbsent(scene.id, true) != null
if (alreadyRunning) {
return DomainResult.Failure(
DomainError.OperationFailed(
detail = "Scene ${scene.id.value} is already running",
),
)
}
val startTime = System.currentTimeMillis()
return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
coroutineScope {
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
deferreds = scene.actions.mapIndexed { index, action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
}
deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
for (action in scene.actions) {
if (aborted) {
sequentialResults.add(
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SKIPPED,
error = null,
),
)
continue
}
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration)
sequentialResults.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
sequentialResults
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
SceneExecutionResult(
scene = scene,
results = results,
totalDurationMillis = totalDuration,
),
)
} catch (e: CancellationException) {
throw e
} finally {
running.remove(scene.id)
}
}
private suspend fun executeAction(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
return try {
when (action) {
is SceneAction.SocketPower -> {
val result = socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
toExecutionResult(action, result)
}
is SceneAction.InfraredKey -> {
val result = infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
toExecutionResult(action, result)
}
is SceneAction.VirtualRemoteButton -> {
executedRemoteButtons.add(action)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Delay -> {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Notification -> {
sentNotifications.add(action)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
private fun toExecutionResult(action: SceneAction, result: DomainResult<*>): SceneActionExecutionResult {
return when (result) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = result.error,
)
}
}
}
@@ -0,0 +1,247 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeSceneExecutorTest {
private val profileId = ProviderProfileId("abox")
@Test
fun executesActionsInOrder() = runTest {
val socketGateway = FakeSocketGateway()
val infraredGateway = FakeInfraredGateway()
val executor = FakeSceneExecutor(socketGateway, infraredGateway)
val scene = scene(
actions = listOf(
socketAction("socket-1", SocketPowerState.ON),
infraredAction("ir-1", "power"),
socketAction("socket-2", SocketPowerState.OFF),
),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(3, executionResult.results.size)
executionResult.results.forEach {
assertEquals(SceneActionOutcome.SUCCESS, it.outcome)
}
assertEquals(1, infraredGateway.sentKeys.size)
}
@Test
fun continuesAfterActionFailure() = runTest {
val socketGateway = FakeSocketGateway()
socketGateway.setError(
profileId,
DeviceName("socket-1"),
DomainError.DeviceOffline(detail = "offline"),
)
val infraredGateway = FakeInfraredGateway()
val executor = FakeSceneExecutor(socketGateway, infraredGateway)
val scene = scene(
actions = listOf(
socketAction("socket-1", SocketPowerState.ON),
infraredAction("ir-1", "power"),
),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, executionResult.succeededCount)
assertEquals(1, executionResult.failedCount)
assertFalse(executionResult.allSucceeded)
}
@Test
fun runningSceneRejectsConcurrentExecution() = runTest {
val gate = CompletableDeferred<Unit>()
val socketGateway = BlockingSocketGateway(gate)
val executor = FakeSceneExecutor(socketGateway, FakeInfraredGateway())
val scene = scene(
actions = listOf(socketAction("socket-1", SocketPowerState.ON)),
)
val first = async { executor.execute(scene) }
// Let the first coroutine enter the executor and block on the socket gateway.
testScheduler.advanceUntilIdle()
val second = async { executor.execute(scene) }
val secondResult = second.await()
assertTrue(secondResult is DomainResult.Failure)
assertTrue((secondResult as DomainResult.Failure).error is DomainError.OperationFailed)
gate.complete(Unit)
val firstResult = first.await()
assertTrue(firstResult is DomainResult.Success)
}
@Test
fun emptySceneSucceedsWithNoResults() = runTest {
val executor = FakeSceneExecutor(FakeSocketGateway(), FakeInfraredGateway())
val scene = scene(actions = emptyList())
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(0, executionResult.results.size)
assertEquals(0, executionResult.succeededCount)
assertEquals(0, executionResult.failedCount)
assertFalse(executionResult.allSucceeded)
}
@Test
fun executesDelayVirtualRemoteAndNotification() = runTest {
val delays = mutableListOf<Long>()
val executor = FakeSceneExecutor(
socketGateway = FakeSocketGateway(),
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { delays.add(it) },
)
val remoteAction = SceneAction.VirtualRemoteButton(
id = SceneActionId("abox:btn-1"),
profileId = profileId,
remoteId = "remote-1",
remoteDisplayName = "TV Remote",
buttonId = "power",
buttonDisplayName = "Power",
)
val delayAction = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 5,
)
val notificationAction = SceneAction.Notification(
id = SceneActionId("abox:notif-1"),
profileId = profileId,
title = "Test Done",
message = "Success",
)
val scene = scene(listOf(remoteAction, delayAction, notificationAction))
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val execResult = (result as DomainResult.Success).value
assertTrue(execResult.allSucceeded)
assertEquals(listOf(5000L), delays)
assertEquals(listOf(remoteAction), executor.executedRemoteButtons)
assertEquals(listOf(notificationAction), executor.sentNotifications)
}
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = FakeSocketGateway()
socketGateway.setError(
profileId,
DeviceName("socket-1"),
DomainError.DeviceOffline(detail = "offline"),
)
val executor = FakeSceneExecutor(
socketGateway = socketGateway,
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { kotlinx.coroutines.delay(1000) },
)
val action1 = socketAction("socket-1", SocketPowerState.ON)
val action2 = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("${profileId.value}:scene-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
}
private fun scene(actions: List<SceneAction>): Scene = Scene(
id = SceneId("${profileId.value}:scene-test"),
profileId = profileId,
name = "Test scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = actions,
)
private fun socketAction(
deviceName: String,
powerState: SocketPowerState,
): SceneAction.SocketPower = SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:action-${deviceName}-${powerState.name}"),
profileId = profileId,
deviceName = DeviceName(deviceName),
powerState = powerState,
)
private fun infraredAction(
deviceName: String,
key: String,
): SceneAction.InfraredKey = SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:action-${deviceName}-$key"),
profileId = profileId,
deviceName = DeviceName(deviceName),
key = key,
)
private class BlockingSocketGateway(
private val gate: CompletableDeferred<Unit>,
) : SocketGateway {
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.OFF),
)
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
gate.await()
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
}
}
@@ -0,0 +1,191 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeSceneRepositoryTest {
private val aboxProfile = ProviderProfileId("abox")
private val localProfile = FakeSceneRepository.localProfile
@Test
fun defaultScenesAreIsolatedByProviderProfile() = runTest {
val repo = FakeSceneRepository()
val aboxScenes = scenesFor(repo, aboxProfile)
val localScenes = scenesFor(repo, localProfile)
assertEquals(0, aboxScenes.size)
assertEquals(2, localScenes.size)
assertTrue(localScenes.all { it.profileId == localProfile })
}
@Test
fun createScenePublishesSceneToFlow() = runTest {
val repo = FakeSceneRepository()
val scene = sampleScene(localProfile, "scene-1")
val result = repo.createScene(localProfile, scene)
assertTrue(result is DomainResult.Success)
assertEquals(scene, (result as DomainResult.Success).value)
val list = scenesFor(repo, localProfile)
assertEquals(3, list.size)
assertEquals(scene, list.last())
}
@Test
fun createSceneWithDuplicateIdFails() = runTest {
val repo = FakeSceneRepository()
val scene = sampleScene(localProfile, "dup")
repo.createScene(localProfile, scene)
val second = repo.createScene(localProfile, scene)
assertTrue(second is DomainResult.Failure)
assertTrue((second as DomainResult.Failure).error is DomainError.InvalidRequest)
}
@Test
fun updateSceneReplacesExistingEntry() = runTest {
val repo = FakeSceneRepository()
val original = sampleScene(localProfile, "scene-2", "Original")
repo.createScene(localProfile, original)
val updated = original.copy(name = "Renamed")
val result = repo.updateScene(localProfile, updated)
assertTrue(result is DomainResult.Success)
val list = scenesFor(repo, localProfile)
assertEquals(3, list.size)
val stored = list.first { it.id == original.id }
assertEquals("Renamed", stored.name)
}
@Test
fun deleteSceneRemovesEntry() = runTest {
val repo = FakeSceneRepository()
val scene = sampleScene(localProfile, "scene-3")
repo.createScene(localProfile, scene)
val result = repo.deleteScene(localProfile, scene.id)
assertTrue(result is DomainResult.Success)
val list = scenesFor(repo, localProfile)
assertEquals(2, list.size)
assertNull(list.firstOrNull { it.id == scene.id })
}
@Test
fun deleteUnknownSceneFails() = runTest {
val repo = FakeSceneRepository()
val result = repo.deleteScene(localProfile, SceneId("missing"))
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
}
@Test
fun getSceneReturnsNullForUnknownId() = runTest {
val repo = FakeSceneRepository()
val result = repo.getScene(localProfile, SceneId("missing"))
assertTrue(result is DomainResult.Success)
assertNull((result as DomainResult.Success).value)
}
@Test
fun setErrorStopsPublishingSuccess() = runTest {
val repo = FakeSceneRepository()
repo.setError(localProfile, DomainError.Unknown(detail = "boom"))
val value = repo.observeScenes(localProfile).first()
assertTrue(value is DomainResult.Failure)
assertNotNull((value as DomainResult.Failure).error)
}
@Test(expected = IllegalArgumentException::class)
fun createSceneRejectsMismatchedActionProfileId() = runTest {
val repo = FakeSceneRepository()
val crossAction = SceneAction.SocketPower(
id = SceneActionId("a-cross"),
profileId = ProviderProfileId("other"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val scene = Scene(
id = SceneId("${localProfile.value}:scene-cross"),
profileId = localProfile,
name = "Cross Action",
actions = listOf(crossAction),
)
repo.createScene(localProfile, scene)
}
@Test
fun concurrentCreationsAreThreadSafe() = runTest {
val repo = FakeSceneRepository()
val count = 20
coroutineScope {
(1..count).map { idx ->
async {
val scene = Scene(
id = SceneId("${aboxProfile.value}:scene-$idx"),
profileId = aboxProfile,
name = "Scene $idx",
actions = emptyList(),
)
repo.createScene(aboxProfile, scene)
}
}.awaitAll()
}
val scenes = scenesFor(repo, aboxProfile)
assertEquals(count, scenes.size)
}
private suspend fun scenesFor(
repo: FakeSceneRepository,
profileId: ProviderProfileId,
): List<Scene> = (repo.observeScenes(profileId).first() as DomainResult.Success).value
private fun sampleScene(
profileId: ProviderProfileId,
idSuffix: String,
name: String = "Test scene",
): Scene = Scene(
id = SceneId("${profileId.value}:scene-$idSuffix"),
profileId = profileId,
name = name,
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-$idSuffix:1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
}