feat(scene): complete automation runtime #11

Merged
KremeCN merged 9 commits from feat/scene-automation-runtime into main 2026-08-28 06:39:31 +08:00
3 changed files with 36 additions and 51 deletions
Showing only changes of commit afb65a25c6 - Show all commits
@@ -15,13 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val canAskAgain = !granted && shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)
getSharedPreferences(NOTIFICATION_PERMISSION_STATE, MODE_PRIVATE)
.edit()
.putBoolean(KEY_PERMISSION_REQUESTED, !canAskAgain)
.apply()
}
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -39,16 +33,6 @@ class MainActivity : ComponentActivity() {
return
}
val permissionState = getSharedPreferences(NOTIFICATION_PERMISSION_STATE, MODE_PRIVATE)
Review

权限误判

系统权限弹窗启动前就将请求状态持久化,而后续判断会把“已请求且无需显示说明”当作不可再请求。若用户直接关闭弹窗,或进程在结果回调前终止,权限仍未决定且 rationale 仍为 false,但之后的启动将永久停止请求,通知只能由用户手动进入系统设置开启。应仅在获得明确结果后记录最终状态,或单独区分请求中、拒绝和永久拒绝。

**权限误判** 系统权限弹窗启动前就将请求状态持久化,而后续判断会把“已请求且无需显示说明”当作不可再请求。若用户直接关闭弹窗,或进程在结果回调前终止,权限仍未决定且 rationale 仍为 false,但之后的启动将永久停止请求,通知只能由用户手动进入系统设置开启。应仅在获得明确结果后记录最终状态,或单独区分请求中、拒绝和永久拒绝。 <!-- pr-agent-dedup: 19f8657c67ec --> <!-- pr-agent-key-issue-location: 7014529e85c4 -->
val canAskAgain = shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)
if (permissionState.getBoolean(KEY_PERMISSION_REQUESTED, false) && !canAskAgain) return
permissionState.edit().putBoolean(KEY_PERMISSION_REQUESTED, true).apply()
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
private companion object {
const val NOTIFICATION_PERMISSION_STATE = "notification_permission_state"
const val KEY_PERMISSION_REQUESTED = "permission_requested"
}
}
@@ -121,23 +121,25 @@ class DefaultAutomationEngine(
if (conditionEvaluator.evaluate(scene.conditions, snapshot, now) !is ConditionsEvaluation.Satisfied) {
Review

触发延迟

时间触发的 occurrence 仅在条件满足后才被认领。如果 08:00 已完成评估但条件不满足,该 occurrence 在宽限期内仍保持未消费;条件于 08:10 变为满足时,同一个 08:00 规则会被延迟执行。非时间触发会先持久化边沿,因此两类触发器的条件语义不一致。应在条件明确不满足时消费或记录该 occurrence,并仅对条件数据不可用的情况保留重试。

**触发延迟** 时间触发的 occurrence 仅在条件满足后才被认领。如果 08:00 已完成评估但条件不满足,该 occurrence 在宽限期内仍保持未消费;条件于 08:10 变为满足时,同一个 08:00 规则会被延迟执行。非时间触发会先持久化边沿,因此两类触发器的条件语义不一致。应在条件明确不满足时消费或记录该 occurrence,并仅对条件数据不可用的情况保留重试。 <!-- pr-agent-dedup: f4cab2f239a0 --> <!-- pr-agent-key-issue-location: e0ecaad0e2a9 -->
continue
}
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
if (matchedTimeTriggers.isNotEmpty()) {
var claimedTimeTrigger = false
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
is DomainResult.Failure -> return claim
is DomainResult.Success -> if (claim.value) claimedTimeTrigger = true
is DomainResult.Success -> if (claim.value) executionCount++
}
}
if (!claimedTimeTrigger && newlyMatchedStateTriggers.isEmpty()) continue
}
if (executionCount == 0) continue
sceneExecutor.execute(
scene = scene,
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
source = SceneExecutionSource.AUTOMATION,
).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
repeat(executionCount) {
sceneExecutor.execute(
scene = scene,
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
source = SceneExecutionSource.AUTOMATION,
).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
}
}
}
return DomainResult.Success(executed)
@@ -24,51 +24,56 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.build()
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
scheduleNextTimeTrigger(profileId, scenes)
Review

任务中断

replace 每次协调都会按标签取消全部定时任务,这也会停止已经处于运行状态的 worker。若用户在定时场景执行期间保存、启用或删除任意场景,当前执行可能在触发记录已被占用后遭到取消,导致动作只执行一部分且无法再次触发。应仅替换尚未运行的调度,或避免取消正在运行的工作。

**任务中断** `replace` 每次协调都会按标签取消全部定时任务,这也会停止已经处于运行状态的 worker。若用户在定时场景执行期间保存、启用或删除任意场景,当前执行可能在触发记录已被占用后遭到取消,导致动作只执行一部分且无法再次触发。应仅替换尚未运行的调度,或避免取消正在运行的工作。 <!-- pr-agent-dedup: ecf27d5946b4 --> <!-- pr-agent-key-issue-location: 8c2cba4abb4d -->
Review

任务残留

replace 只添加当前时间触发器的任务;只要该配置仍有任一自动场景,就不会取消已删除、禁用或改时的旧任务。反复编辑会累积旧 worker,它们到点后仍会校验会话并评估整个配置,产生不必要的后台 I/O 和设备请求。替换计划时应清理或比对旧时间任务。

**任务残留** `replace` 只添加当前时间触发器的任务;只要该配置仍有任一自动场景,就不会取消已删除、禁用或改时的旧任务。反复编辑会累积旧 worker,它们到点后仍会校验会话并评估整个配置,产生不必要的后台 I/O 和设备请求。替换计划时应清理或比对旧时间任务。 <!-- pr-agent-dedup: 20f0c54dcf7a --> <!-- pr-agent-key-issue-location: eb9820b6b300 -->
Review

任务泄漏

replace 只为当前时间触发器添加任务,却不取消已删除、禁用或修改的触发器对应的旧一次性任务。只要仍有其他自动场景,旧任务就会一直保留到执行;反复编辑时间触发器会累积后台任务,并在触发时产生多余的会话验证、设备读取和场景评估。

**任务泄漏** `replace` 只为当前时间触发器添加任务,却不取消已删除、禁用或修改的触发器对应的旧一次性任务。只要仍有其他自动场景,旧任务就会一直保留到执行;反复编辑时间触发器会累积后台任务,并在触发时产生多余的会话验证、设备读取和场景评估。 <!-- pr-agent-dedup: 1812827f9fe7 --> <!-- pr-agent-key-issue-location: 8f72625368dd -->
workManager.cancelAllWorkByTag(timeWorkTag(profileId))
scenes.forEach { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().forEach { trigger ->
scheduleNextTimeTrigger(profileId, scene, trigger)
}
}
return DomainResult.Success(Unit)
}
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
workManager.cancelUniqueWork(workName(profileId))
workManager.cancelUniqueWork(timeWorkName(profileId))
workManager.cancelAllWorkByTag(timeWorkTag(profileId))
return DomainResult.Success(Unit)
}
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
private fun timeWorkName(profileId: ProviderProfileId) = "automation-time:${profileId.value}"
private fun timeWorkName(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) =
"automation-time:${profileId.value}:${scene.id.value}:${trigger.id.value}"
private fun timeWorkTag(profileId: ProviderProfileId) = "automation-time:${profileId.value}"
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scenes: List<Scene>) {
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) {
val now = LocalDateTime.now()
val next = scenes.flatMap { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().mapNotNull { trigger ->
nextOccurrence(trigger, now)?.let { occurrence -> ScheduledWorkOccurrence(scene, trigger, occurrence) }
}
}.minByOrNull { it.scheduledAt } ?: run {
workManager.cancelUniqueWork(timeWorkName(profileId))
return
}
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val scheduledAtInstant = next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant()
Review

延迟执行

时间触发任务的有效期被放宽到最多 24 小时或下一次发生前。设备关机、处于 Doze 或 WorkManager 严重延迟时,早晨的设备控制场景可能在当晚甚至次日才执行。应使用与前台评估一致的短宽限期,并让过期任务只安排下一次触发而不执行动作。

**延迟执行** 时间触发任务的有效期被放宽到最多 24 小时或下一次发生前。设备关机、处于 Doze 或 WorkManager 严重延迟时,早晨的设备控制场景可能在当晚甚至次日才执行。应使用与前台评估一致的短宽限期,并让过期任务只安排下一次触发而不执行动作。 <!-- pr-agent-dedup: 9c4e97f865b9 --> <!-- pr-agent-key-issue-location: 33287255f21a -->
val scheduledAt = nextOccurrence(trigger, now) ?: return
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val scheduledAtInstant = scheduledAt.atZone(ZoneId.systemDefault()).toInstant()
val maximumExpiry = scheduledAtInstant.plus(MAXIMUM_OCCURRENCE_LATENESS)
Review

过期执行

定时触发的有效期最长被设置为 24 小时,而引擎会在整个有效期内接受该触发。设备休眠或 WorkManager 延迟数小时后,“定时开关设备”等动作仍可能在接近下一次计划时间时执行,与前台评估采用的 20 分钟宽限期也不一致。应将有效期限制为明确且较短的迟到窗口。

**过期执行** 定时触发的有效期最长被设置为 24 小时,而引擎会在整个有效期内接受该触发。设备休眠或 WorkManager 延迟数小时后,“定时开关设备”等动作仍可能在接近下一次计划时间时执行,与前台评估采用的 20 分钟宽限期也不一致。应将有效期限制为明确且较短的迟到窗口。 <!-- pr-agent-dedup: a69636e81e58 --> <!-- pr-agent-key-issue-location: b2e10030dd23 -->
val followingOccurrence = nextOccurrence(next.trigger, next.scheduledAt)
val followingOccurrence = nextOccurrence(trigger, scheduledAt)
?.atZone(ZoneId.systemDefault())
?.toInstant()
?.minusMillis(1)
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.addTag(timeWorkTag(profileId))
.setInputData(
androidx.work.Data.Builder()
.putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, next.scene.id.value)
.putString(KEY_TRIGGER_ID, next.trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, "${next.scheduledAt.toLocalDate()}:${next.trigger.timeOfDay}:${next.trigger.id.value}")
.putString(KEY_SCENE_ID, scene.id.value)
.putString(KEY_TRIGGER_ID, trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
.build(),
)
.build()
workManager.enqueueUniqueWork(timeWorkName(profileId), androidx.work.ExistingWorkPolicy.REPLACE, request)
workManager.enqueueUniqueWork(
timeWorkName(profileId, scene, trigger),
androidx.work.ExistingWorkPolicy.REPLACE,
request,
)
}
private fun nextOccurrence(trigger: SceneTrigger.Time, now: LocalDateTime): LocalDateTime? {
@@ -82,12 +87,6 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
return null
}
private data class ScheduledWorkOccurrence(
val scene: Scene,
val trigger: SceneTrigger.Time,
val scheduledAt: LocalDateTime,
)
companion object {
const val KEY_PROFILE_ID = "profile_id"
const val KEY_SCENE_ID = "scene_id"