fix(scene): make automation transitions atomic

This commit is contained in:
2026-08-28 03:44:27 +08:00
parent afb65a25c6
commit b6cc083bc7
4 changed files with 39 additions and 19 deletions
@@ -1,6 +1,7 @@
package com.flagship.abox.manager.automation.data package com.flagship.abox.manager.automation.data
import android.content.Context import android.content.Context
import androidx.room.withTransaction
import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
@@ -27,8 +28,9 @@ import kotlinx.coroutines.flow.map
class RoomSceneRepository( class RoomSceneRepository(
context: Context, context: Context,
private val dao: AutomationDao, private val database: AutomationDatabase,
) : SceneRepository { ) : SceneRepository {
private val dao = database.automationDao()
private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE) private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE)
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> = flow { override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> = flow {
@@ -57,15 +59,17 @@ class RoomSceneRepository(
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch")) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
} }
return runCatching { return runCatching {
val existing = dao.getScene(profileId.value, scene.id.value) database.withTransaction {
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found") val existing = dao.getScene(profileId.value, scene.id.value)
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists") if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
dao.upsertScene(scene.toEntity()) if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty() dao.upsertScene(scene.toEntity())
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } } existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
.forEach { changed -> .filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value) .forEach { changed ->
} dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
}
}
scene scene
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) }) }.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
} }
@@ -63,7 +63,7 @@ object AutomationModule {
fun provideSceneRepository( fun provideSceneRepository(
@ApplicationContext context: Context, @ApplicationContext context: Context,
database: AutomationDatabase, database: AutomationDatabase,
): SceneRepository = RoomSceneRepository(context, database.automationDao()) ): SceneRepository = RoomSceneRepository(context, database)
@Provides @Provides
@Singleton @Singleton
@@ -38,18 +38,24 @@ class AutomationBootstrapper(
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) } previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
previousProfileId = active?.id previousProfileId = active?.id
if (active != null && session?.profileId == active.id) { if (active != null && session?.profileId == active.id) {
var scheduleReady = false
while (true) { while (true) {
isForeground.first { it } isForeground.first { it }
when (val validity = sessionCoordinator.validateSession(active.id)) { when (val validity = sessionCoordinator.validateSession(active.id)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) { is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
scheduler.reconcile(active.id) if (!scheduleReady) {
try { scheduleReady = scheduler.reconcile(active.id) is DomainResult.Success
engine.evaluate(active.id) }
} catch (cancelled: CancellationException) { if (scheduleReady) {
throw cancelled try {
} catch (_: Exception) {} engine.evaluate(active.id)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {}
}
} else { } else {
scheduler.cancel(active.id) scheduler.cancel(active.id)
scheduleReady = false
} }
is DomainResult.Failure -> Unit is DomainResult.Failure -> Unit
} }
@@ -118,8 +118,18 @@ class DefaultAutomationEngine(
} }
} }
if (!newlyMatched) continue if (!newlyMatched) continue
if (conditionEvaluator.evaluate(scene.conditions, snapshot, now) !is ConditionsEvaluation.Satisfied) { when (conditionEvaluator.evaluate(scene.conditions, snapshot, now)) {
continue is ConditionsEvaluation.Unavailable -> continue
is ConditionsEvaluation.NotSatisfied -> {
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
is DomainResult.Failure -> return claim
is DomainResult.Success -> Unit
}
}
continue
}
ConditionsEvaluation.Satisfied -> Unit
} }
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1 var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
if (matchedTimeTriggers.isNotEmpty()) { if (matchedTimeTriggers.isNotEmpty()) {