feat(ui): complete ABox app experience redesign (#10)

## Summary

- rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience
- redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content
- add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative
- add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes
- persist real activity events for device commands, infrared actions, and scene execution
- stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option
- update launcher icons and bilingual application name

## Verification

- `:android:app:assembleDebug`
- installed the debug APK on `emulator-5554`
- manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions

## Notes

- no automated test suite was run during the visual iteration cycle
- `docs/superpowers/**` and local reference repositories are excluded from this PR

Reviewed-on: #10
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-08-27 23:25:07 +08:00
committed by KremeCN
parent 1570a95f30
commit 86b7e5849b
194 changed files with 11198 additions and 1444 deletions
@@ -0,0 +1,68 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.Flow
@JvmInline
value class ActivityEventId(val value: String)
enum class ActivityEventKind {
SESSION_SIGNED_IN,
SESSION_SIGNED_OUT,
SOCKET_COMMAND,
SOCKET_CONFIRMATION,
SENSOR_REFRESH,
INFRARED_SEND,
INFRARED_LEARN,
SCENE_EXECUTION,
SCENE_ACTION,
}
enum class ActivitySource { USER, AUTOMATION, SYSTEM }
enum class ActivityOutcome {
SUCCEEDED,
FAILED,
CANCELLED,
AWAITING_CONFIRMATION,
}
enum class ActivityTargetType { PROFILE, DEVICE, SCENE, SCENE_ACTION, REMOTE, INFRARED_KEY }
data class ActivityTarget(
val type: ActivityTargetType,
val stableId: String,
val displayName: String,
)
data class ActivityEvent(
val id: ActivityEventId,
val profileId: ProviderProfileId,
val occurredAtEpochMillis: Long,
val kind: ActivityEventKind,
val source: ActivitySource,
val outcome: ActivityOutcome,
val target: ActivityTarget,
val errorCode: String? = null,
)
data class ActivityEventCursor(
val occurredAtEpochMillis: Long,
val id: ActivityEventId,
) : Comparable<ActivityEventCursor> {
override fun compareTo(other: ActivityEventCursor): Int {
val timeComparison = occurredAtEpochMillis.compareTo(other.occurredAtEpochMillis)
return if (timeComparison != 0) timeComparison else id.value.compareTo(other.id.value)
}
}
interface ActivityRepository {
fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>>
suspend fun loadBefore(
profileId: ProviderProfileId,
before: ActivityEventCursor,
limit: Int,
): DomainResult<List<ActivityEvent>>
suspend fun append(event: ActivityEvent): DomainResult<Unit>
}
@@ -0,0 +1,54 @@
package com.flagship.abox.manager.domain
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ActivityEventWriter(
private val repository: ActivityRepository,
private val scope: CoroutineScope,
) {
fun recordDevice(
profileId: ProviderProfileId,
deviceName: DeviceName,
kind: ActivityEventKind,
outcome: ActivityOutcome,
errorCode: String? = null,
source: ActivitySource = ActivitySource.USER,
) {
record(
profileId = profileId,
kind = kind,
source = source,
outcome = outcome,
target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value),
errorCode = errorCode,
)
}
fun record(
profileId: ProviderProfileId,
kind: ActivityEventKind,
source: ActivitySource,
outcome: ActivityOutcome,
target: ActivityTarget,
errorCode: String? = null,
) {
scope.launch {
runCatching {
repository.append(
ActivityEvent(
id = ActivityEventId(UUID.randomUUID().toString()),
profileId = profileId,
occurredAtEpochMillis = System.currentTimeMillis(),
kind = kind,
source = source,
outcome = outcome,
target = target,
errorCode = errorCode,
),
)
}
}
}
}
@@ -0,0 +1,151 @@
package com.flagship.abox.manager.domain
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
interface AutomationClock {
fun nowEpochMillis(): Long
}
data class AutomationSnapshot(
val observedAtEpochMillis: Long,
val readings: Map<DeviceName, DomainResult<TemperatureHumidityReading>> = emptyMap(),
val socketStatuses: Map<DeviceName, DomainResult<SocketStatus>> = emptyMap(),
)
sealed interface TriggerEvaluation {
data class Matched(val occurrenceKey: String) : TriggerEvaluation
data object NotMatched : TriggerEvaluation
data class Unavailable(val error: DomainError) : TriggerEvaluation
}
sealed interface ConditionsEvaluation {
data object Satisfied : ConditionsEvaluation
data class NotSatisfied(val condition: SceneCondition) : ConditionsEvaluation
data class Unavailable(val error: DomainError) : ConditionsEvaluation
}
class SceneTriggerEvaluator {
fun evaluate(scene: Scene, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation {
if (scene.triggers.isEmpty()) return TriggerEvaluation.NotMatched
val matched = scene.triggers.firstNotNullOfOrNull { trigger -> evaluateOne(trigger, snapshot, now) }
return when (matched) {
is TriggerEvaluation.Matched -> matched
is TriggerEvaluation.Unavailable -> matched
is TriggerEvaluation.NotMatched -> matched
null -> TriggerEvaluation.NotMatched
}
}
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
is SceneTrigger.Time -> {
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek
if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) {
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
} else null
}
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
it.temperature
}
is SceneTrigger.Humidity -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdPercent) {
it.humidity
}
is SceneTrigger.DeviceState -> {
when (val result = snapshot.socketStatuses[trigger.deviceName]) {
null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable"))
is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error)
is DomainResult.Success -> if (result.value.powerState == trigger.expectedState) {
TriggerEvaluation.Matched("${snapshot.observedAtEpochMillis}:${trigger.id.value}")
} else null
}
}
}
private fun threshold(
result: DomainResult<TemperatureHumidityReading>?,
operator: ComparisonOperator,
threshold: Double,
value: (TemperatureHumidityReading) -> String?,
): TriggerEvaluation? {
if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
return when (result) {
null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error)
is DomainResult.Success -> {
val actual = value(result.value)?.toDoubleOrNull()
?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null
}
}
}
private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) {
ComparisonOperator.GREATER_THAN -> actual > threshold
ComparisonOperator.LESS_THAN -> actual < threshold
ComparisonOperator.EQUALS -> actual == threshold
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
}
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
}
class SceneConditionEvaluator {
fun evaluate(conditions: List<SceneCondition>, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation {
for (condition in conditions) {
when (val result = evaluateOne(condition, snapshot, now)) {
ConditionsEvaluation.Satisfied -> Unit
else -> return result
}
}
return ConditionsEvaluation.Satisfied
}
private fun evaluateOne(condition: SceneCondition, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation = when (condition) {
is SceneCondition.Temperature -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdCelsius) { it.temperature }
is SceneCondition.Humidity -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdPercent) { it.humidity }
is SceneCondition.SocketPower -> when (val result = snapshot.socketStatuses[condition.deviceName]) {
null -> ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable"))
is DomainResult.Failure -> ConditionsEvaluation.Unavailable(result.error)
is DomainResult.Success -> if (result.value.powerState == condition.expectedState) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
}
is SceneCondition.TimeRange -> {
val start = parseTime(condition.startTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range start"))
val end = parseTime(condition.endTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range end"))
val current = now.toLocalTime()
val inside = if (start <= end) current in start..end else current >= start || current <= end
if (inside) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
}
}
private fun metricCondition(
condition: SceneCondition,
result: DomainResult<TemperatureHumidityReading>?,
operator: ComparisonOperator,
threshold: Double,
value: (TemperatureHumidityReading) -> String?,
): ConditionsEvaluation {
if (!threshold.isFinite()) return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
val actual = when (result) {
null -> return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
is DomainResult.Failure -> return ConditionsEvaluation.Unavailable(result.error)
is DomainResult.Success -> value(result.value)?.toDoubleOrNull()
?: return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
}
return if (compare(actual, operator, threshold)) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
}
private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) {
ComparisonOperator.GREATER_THAN -> actual > threshold
ComparisonOperator.LESS_THAN -> actual < threshold
ComparisonOperator.EQUALS -> actual == threshold
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
}
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
}
@@ -9,6 +9,8 @@ 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.flow
/**
* Production implementation of [SceneExecutor].
@@ -24,6 +26,50 @@ class DefaultSceneExecutor(
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
override fun execute(
scene: Scene,
executionId: SceneExecutionId,
source: SceneExecutionSource,
): Flow<SceneExecutionUpdate> = flow {
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
if (alreadyRunning) {
emit(SceneExecutionUpdate.Rejected(DomainError.OperationFailed(detail = "Scene is already running")))
return@flow
}
val startTime = System.currentTimeMillis()
try {
emit(SceneExecutionUpdate.Started(executionId, scene))
val results = mutableListOf<SceneActionExecutionResult>()
var aborted = false
for (action in scene.actions) {
if (aborted) {
val skipped = SceneActionExecutionResult(action, SceneActionOutcome.SKIPPED, null)
results += skipped
emit(SceneExecutionUpdate.StepFinished(skipped))
continue
}
emit(SceneExecutionUpdate.StepPending(action))
emit(SceneExecutionUpdate.StepRunning(action))
val started = System.currentTimeMillis()
val result = executeAction(scene.profileId, action).copy(
executionDurationMillis = System.currentTimeMillis() - started,
)
results += result
emit(SceneExecutionUpdate.StepFinished(result))
if (result.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
emit(
SceneExecutionUpdate.Completed(
SceneExecutionResult(scene, results, System.currentTimeMillis() - startTime),
),
)
} finally {
runningScenes.remove(scene.id)
}
}
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
if (alreadyRunning) {
@@ -63,6 +63,15 @@ interface SocketGateway {
deviceName: DeviceName,
): DomainResult<SocketStatus>
suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = when (val result = setPower(profileId, deviceName, powerState)) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> result
}
suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
@@ -92,8 +92,7 @@ class InMemorySceneRepository : SceneRepository {
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))
MutableStateFlow(DomainResult.Success(defaultScenes(profileId)))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
@@ -107,12 +106,30 @@ class InMemorySceneRepository : SceneRepository {
DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"),
)
private fun defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-arriving-home"),
profileId = profileId,
name = "回家模式",
description = "开启 Zigbee 插座控制器",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.HOME,
color = SceneColor("#4CAF50"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-arriving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.ON,
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "离家模式",
description = "关闭插座并延时关闭电视",
description = "关闭 Zigbee 插座控制器",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY,
@@ -124,16 +141,32 @@ class InMemorySceneRepository : SceneRepository {
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF,
),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
),
Scene(
id = SceneId("${profileId.value}:scene-temperature-alert"),
profileId = profileId,
name = "高温提醒",
description = "室温高于 28°C 时发送提醒",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.THERMOSTAT,
color = SceneColor("#EF5350"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
label = "温度高于 28°C",
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
),
actions = listOf(
SceneAction.Notification(
id = SceneActionId("${profileId.value}:scene-temperature-alert:notification-1"),
profileId = profileId,
deviceName = DeviceName("红外控制器"),
key = "1",
title = "温度提醒",
message = "当前室温已高于 28°C",
),
),
),
@@ -148,7 +181,7 @@ class InMemorySceneRepository : SceneRepository {
color = SceneColor("#26A69A"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
id = SceneTriggerId("${profileId.value}:trigger-cooling-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
@@ -157,7 +190,7 @@ class InMemorySceneRepository : SceneRepository {
),
conditions = listOf(
SceneCondition.SocketPower(
id = SceneConditionId("${profileId.value}:cond-socket"),
id = SceneConditionId("${profileId.value}:cond-cooling-socket"),
deviceName = DeviceName("Zigbee插座控制器"),
expectedState = SocketPowerState.OFF,
),
@@ -83,8 +83,8 @@ data class SocketStatus(
data class TemperatureHumidityReading(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val temperature: String,
val humidity: String,
val temperature: String?,
val humidity: String?,
)
data class InfraredCode(
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.domain
interface NotificationGateway {
suspend fun show(
profileId: ProviderProfileId,
title: String,
message: String,
): DomainResult<Unit>
}
@@ -62,7 +62,7 @@ sealed interface SceneTrigger {
data class DeviceState(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val expectedState: String, // e.g. "ON" / "OFF"
val expectedState: SocketPowerState,
val label: String = "",
) : SceneTrigger
}
@@ -1,5 +1,19 @@
package com.flagship.abox.manager.domain
@JvmInline
value class SceneExecutionId(val value: String)
enum class SceneExecutionSource { USER, AUTOMATION }
sealed interface SceneExecutionUpdate {
data class Started(val executionId: SceneExecutionId, val scene: Scene) : SceneExecutionUpdate
data class StepPending(val action: SceneAction) : SceneExecutionUpdate
data class StepRunning(val action: SceneAction) : SceneExecutionUpdate
data class StepFinished(val result: SceneActionExecutionResult) : SceneExecutionUpdate
data class Completed(val result: SceneExecutionResult) : SceneExecutionUpdate
data class Rejected(val error: DomainError) : SceneExecutionUpdate
}
enum class SceneActionOutcome {
SUCCESS,
FAILURE,
@@ -1,5 +1,9 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.util.UUID
/**
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
@@ -9,4 +13,23 @@ package com.flagship.abox.manager.domain
*/
interface SceneExecutor {
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
fun execute(
scene: Scene,
executionId: SceneExecutionId = SceneExecutionId(UUID.randomUUID().toString()),
source: SceneExecutionSource = SceneExecutionSource.USER,
): Flow<SceneExecutionUpdate> = flow {
emit(SceneExecutionUpdate.Started(executionId, scene))
when (val result = execute(scene)) {
is DomainResult.Failure -> emit(SceneExecutionUpdate.Rejected(result.error))
is DomainResult.Success -> {
result.value.results.forEach { actionResult ->
emit(SceneExecutionUpdate.StepPending(actionResult.action))
emit(SceneExecutionUpdate.StepRunning(actionResult.action))
emit(SceneExecutionUpdate.StepFinished(actionResult))
}
emit(SceneExecutionUpdate.Completed(result.value))
}
}
}
}
@@ -147,6 +147,35 @@ class SessionCoordinator(
}
}
suspend fun endActiveSession(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val logoutResult = profile.gateway().logout(profile.id)) {
is DomainResult.Failure -> return@storeOperation logoutResult
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
profileStore.setActiveProfile(null)
} else {
DomainResult.Success(Unit)
}
}
suspend fun forgetSavedCredentials(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
is DomainResult.Failure -> return@storeOperation credentialResult
is DomainResult.Success -> Unit
}
val preferences = profileStore.loginPreferences(profile.id)
profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false))
}
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
profile.gateway().validateSession(profile.id)
}
suspend fun logout(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
@@ -23,24 +23,16 @@ class AddVirtualRemoteKey(
}
val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
// Find next available slot
val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
nextSlot.toString()
} else null
val binding = RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
hardwareDeviceName = remote.deviceName,
buttonId = buttonId,
displayName = trimmed,
assignedSlot = assignedSlot,
assignedSlot = null,
rawHexCode = null,
status = InfraredKeyStatus.FAILED, // Initially unlearned
status = InfraredKeyStatus.UNLEARNED,
)
when (val res = repository.updateKeyBinding(binding)) {
@@ -64,11 +64,12 @@ class CreateVirtualRemote(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
hardwareDeviceName = deviceName,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = (index + 1).toString(),
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.UNLEARNED,
)
}
} else {
@@ -77,6 +78,7 @@ class CreateVirtualRemote(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
hardwareDeviceName = deviceName,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = null,
@@ -84,7 +86,7 @@ class CreateVirtualRemote(
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
InfraredKeyStatus.READY
} else {
InfraredKeyStatus.FAILED
InfraredKeyStatus.UNLEARNED
},
)
}
@@ -12,6 +12,7 @@ data class InfraredKeyRef(
)
enum class InfraredKeyStatus {
UNLEARNED,
LEARNING,
READY,
FAILED,
@@ -181,6 +181,7 @@ class RelearnInfraredKey(
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.UNLEARNED,
InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED,
InfraredKeyStatus.LEARNING,
@@ -18,6 +18,11 @@ class LearnVirtualRemoteKey(
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
if (remote.type != RemoteDeviceType.CUSTOM) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Only custom remotes support learning"),
)
}
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> return res
@@ -26,9 +31,13 @@ class LearnVirtualRemoteKey(
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found"))
val targetSlot = binding.assignedSlot ?: run {
val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1
val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
val nextSlot = quota.availableStaticSlots.firstOrNull() ?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"),
)
nextSlot.toString()
}
@@ -80,11 +80,12 @@ data class RemoteKeyBinding(
val id: String,
val remoteId: VirtualRemoteId,
val profileId: ProviderProfileId,
val hardwareDeviceName: DeviceName = DeviceName(""),
val buttonId: String,
val displayName: String,
val assignedSlot: String? = null,
val rawHexCode: String? = null,
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
val status: InfraredKeyStatus = InfraredKeyStatus.UNLEARNED,
val lastErrorCode: String? = null,
val lastErrorDetail: String? = null,
)
@@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
interface VirtualRemoteRepository {
fun observeRemotes(
@@ -25,6 +26,11 @@ interface VirtualRemoteRepository {
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>>
suspend fun slotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SlotQuotaSummary> = observeSlotQuota(profileId, deviceName).first()
suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
@@ -0,0 +1,35 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Test
class SceneExecutionFlowTest {
@Test
fun observableExecutionPublishesLifecycleEventsWithoutChangingLegacyResult() = runTest {
val scene = Scene(
id = SceneId("scene"),
profileId = ProviderProfileId("profile"),
name = "测试情景",
)
val executor = DefaultSceneExecutor(
socketGateway = object : SocketGateway {
override suspend fun getStatus(profileId: ProviderProfileId, deviceName: DeviceName) =
DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
override suspend fun setPower(profileId: ProviderProfileId, deviceName: DeviceName, powerState: SocketPowerState) =
DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
},
infraredGateway = object : InfraredGateway {
override suspend fun sendKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit)
override suspend fun learnKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit)
override suspend fun downloadCodes(profileId: ProviderProfileId, codes: List<InfraredCode>) = DomainResult.Success(emptyList<InfraredCodeDownloadResult>())
},
)
val updates = executor.execute(scene, SceneExecutionId("run-1")).toList()
assertTrue(updates.first() is SceneExecutionUpdate.Started)
assertTrue(updates.last() is SceneExecutionUpdate.Completed)
}
}
@@ -0,0 +1,78 @@
package com.flagship.abox.manager.domain.activity
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ActivityContractTest {
@Test
fun eventDoesNotContainCredentialsOrPayloadFields() {
val event = ActivityEvent(
id = ActivityEventId("event-1"),
profileId = ProviderProfileId("profile-1"),
occurredAtEpochMillis = 10L,
kind = ActivityEventKind.SOCKET_COMMAND,
source = ActivitySource.USER,
outcome = ActivityOutcome.SUCCEEDED,
target = ActivityTarget(ActivityTargetType.DEVICE, "device-1", "客厅插座"),
errorCode = "20504",
)
val fields = ActivityEvent::class.java.declaredFields.map { it.name }
assertFalse(fields.any { it.contains("password", ignoreCase = true) })
assertFalse(fields.any { it.contains("token", ignoreCase = true) })
assertFalse(fields.any { it.contains("payload", ignoreCase = true) })
assertTrue(event.errorCode == "20504")
}
@Test
fun cursorOrdersEqualTimestampsById() {
val first = ActivityEventCursor(10L, ActivityEventId("a"))
val second = ActivityEventCursor(10L, ActivityEventId("b"))
assertTrue(first < second)
}
@Test
fun recordingFailureDoesNotAlterOriginalDomainResult() = runTest {
val repository = object : ActivityRepository {
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> =
MutableStateFlow(DomainResult.Success(emptyList()))
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) =
DomainResult.Success(emptyList<ActivityEvent>())
override suspend fun append(event: ActivityEvent) = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.Unknown())
}
val original: DomainResult<String> = DomainResult.Success("command-complete")
val recorded = repository.append(testEvent())
assertTrue(recorded is DomainResult.Failure)
assertTrue(original is DomainResult.Success)
assertTrue((original as DomainResult.Success).value == "command-complete")
}
private fun testEvent() = ActivityEvent(
id = ActivityEventId("event-1"),
profileId = ProviderProfileId("profile-1"),
occurredAtEpochMillis = 10L,
kind = ActivityEventKind.SESSION_SIGNED_IN,
source = ActivitySource.SYSTEM,
outcome = ActivityOutcome.SUCCEEDED,
target = ActivityTarget(ActivityTargetType.PROFILE, "profile-1", "账户"),
)
}
@@ -0,0 +1,80 @@
package com.flagship.abox.manager.domain.automation
import com.flagship.abox.manager.domain.AutomationSnapshot
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConditionsEvaluation
import com.flagship.abox.manager.domain.DeviceName
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.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionEvaluator
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerEvaluator
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import com.flagship.abox.manager.domain.TriggerEvaluation
import java.time.LocalDateTime
import org.junit.Assert.assertTrue
import org.junit.Test
class SceneEvaluatorTest {
private val profile = ProviderProfileId("profile")
private val sensor = DeviceName("sensor")
private val clockTime = LocalDateTime.of(2026, 8, 26, 7, 30)
private val triggerEvaluator = SceneTriggerEvaluator()
private val conditionEvaluator = SceneConditionEvaluator()
@Test
fun timeMatchesConfiguredWeekday() {
val trigger = SceneTrigger.Time(SceneTriggerId("time"), "07:30", setOf(3))
val result = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime)
assertTrue(result is TriggerEvaluation.Matched)
}
@Test
fun timeRangeCrossesMidnight() {
val condition = SceneCondition.TimeRange(com.flagship.abox.manager.domain.SceneConditionId("range"), "22:00", "07:00")
val result = conditionEvaluator.evaluate(listOf(condition), AutomationSnapshot(1L), LocalDateTime.of(2026, 8, 26, 6, 45))
assertTrue(result is ConditionsEvaluation.Satisfied)
}
@Test
fun temperatureComparisonAndMissingReadingFailClosed() {
val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0)
val missing = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime)
val reading = AutomationSnapshot(
1L,
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", null))),
)
val matched = triggerEvaluator.evaluate(scene(trigger), reading, clockTime)
assertTrue(missing is TriggerEvaluation.Unavailable)
assertTrue(matched is TriggerEvaluation.Matched)
}
@Test
fun allConditionsMustPass() {
val reading = AutomationSnapshot(
1L,
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))),
)
val conditions = listOf(
SceneCondition.Temperature(com.flagship.abox.manager.domain.SceneConditionId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0),
SceneCondition.Humidity(com.flagship.abox.manager.domain.SceneConditionId("humidity"), sensor, ComparisonOperator.LESS_THAN, 50.0),
)
assertTrue(conditionEvaluator.evaluate(conditions, reading, clockTime) is ConditionsEvaluation.Satisfied)
}
@Test
fun invalidNumberReturnsUnavailable() {
val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, Double.NaN)
val reading = AutomationSnapshot(
1L,
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))),
)
assertTrue(triggerEvaluator.evaluate(scene(trigger), reading, clockTime) is TriggerEvaluation.Unavailable)
}
private fun scene(trigger: SceneTrigger) = Scene(SceneId("scene"), profile, "测试", triggers = listOf(trigger))
}
@@ -116,4 +116,46 @@ class VirtualRemoteUseCaseTest {
val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(null, fetched)
}
@Test
fun staticKeyAllocationUsesSlotsAcrossRemotesOnTheSameHardware() = runTest {
val firstRemote = VirtualRemote(
id = VirtualRemoteId("remote-one"),
profileId = profileId,
deviceName = deviceName,
displayName = "First",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
repository.createRemote(
firstRemote,
listOf(
RemoteKeyBinding(
id = "binding-one",
remoteId = firstRemote.id,
profileId = profileId,
buttonId = "POWER",
displayName = "Power",
assignedSlot = "1",
),
),
)
val secondRemote = VirtualRemote(
id = VirtualRemoteId("remote-two"),
profileId = profileId,
deviceName = deviceName,
displayName = "Second",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 2L,
updatedAtEpochMillis = 2L,
)
repository.createRemote(secondRemote, emptyList())
val result = AddVirtualRemoteKey(repository)(profileId, secondRemote.id, "Volume")
assertEquals("2", (result as DomainResult.Success).value.assignedSlot)
}
}