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 @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher = private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
val canAskAgain = !granted && shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)
getSharedPreferences(NOTIFICATION_PERMISSION_STATE, MODE_PRIVATE)
.edit()
.putBoolean(KEY_PERMISSION_REQUESTED, !canAskAgain)
.apply()
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -39,16 +33,6 @@ class MainActivity : ComponentActivity() {
return 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) 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) { 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 continue
} }
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
if (matchedTimeTriggers.isNotEmpty()) { if (matchedTimeTriggers.isNotEmpty()) {
Outdated
Review

触发合并

当同一场景的多个时间触发器在一次评估中同时到期时,所有 occurrence 都会被成功占用,但场景只执行一次。例如后台任务在 10:15 才处理 10:00 和 10:10 的触发器时,两个去重键都会被记录,第二次计划执行将永久丢失。应按成功占用的 occurrence 分别执行,或仅占用本次实际执行对应的 occurrence。

**触发合并** 当同一场景的多个时间触发器在一次评估中同时到期时,所有 occurrence 都会被成功占用,但场景只执行一次。例如后台任务在 10:15 才处理 10:00 和 10:10 的触发器时,两个去重键都会被记录,第二次计划执行将永久丢失。应按成功占用的 occurrence 分别执行,或仅占用本次实际执行对应的 occurrence。 <!-- pr-agent-dedup: cc702268df73 --> <!-- pr-agent-key-issue-location: c943741e1e43 -->
var claimedTimeTrigger = false
for ((trigger, occurrenceKey) in matchedTimeTriggers) { for ((trigger, occurrenceKey) in matchedTimeTriggers) {
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) { when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
is DomainResult.Failure -> return claim 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( repeat(executionCount) {
scene = scene, sceneExecutor.execute(
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"), scene = scene,
source = SceneExecutionSource.AUTOMATION, executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
).collect { update -> source = SceneExecutionSource.AUTOMATION,
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++ ).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
}
} }
} }
return DomainResult.Success(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()) .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.build() .build()
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request) workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
scheduleNextTimeTrigger(profileId, scenes) workManager.cancelAllWorkByTag(timeWorkTag(profileId))
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 -->
scenes.forEach { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().forEach { trigger ->
scheduleNextTimeTrigger(profileId, scene, trigger)
}
}
return DomainResult.Success(Unit) return DomainResult.Success(Unit)
} }
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> { override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
workManager.cancelUniqueWork(workName(profileId)) workManager.cancelUniqueWork(workName(profileId))
workManager.cancelUniqueWork(timeWorkName(profileId)) workManager.cancelAllWorkByTag(timeWorkTag(profileId))
return DomainResult.Success(Unit) return DomainResult.Success(Unit)
} }
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}" 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 now = LocalDateTime.now()
val next = scenes.flatMap { scene -> val scheduledAt = nextOccurrence(trigger, now) ?: return
Outdated
Review

计划遗漏

整个配置档只保存最早的一个时间 occurrence。若 Doze 或电量限制使任务延迟超过二十分钟,期间其他已到期的时间触发器既没有独立的计划任务,也已超出普通评估的宽限期;重新协调时又只会查找未来时间,因此这些执行会被永久跳过。应为每个 occurrence 建立唯一任务,或持久化并依次处理全部待执行 occurrence。

**计划遗漏** 整个配置档只保存最早的一个时间 occurrence。若 Doze 或电量限制使任务延迟超过二十分钟,期间其他已到期的时间触发器既没有独立的计划任务,也已超出普通评估的宽限期;重新协调时又只会查找未来时间,因此这些执行会被永久跳过。应为每个 occurrence 建立唯一任务,或持久化并依次处理全部待执行 occurrence。 <!-- pr-agent-dedup: 44f30beb57cd --> <!-- pr-agent-key-issue-location: bdcee4cf7c5b -->
scene.triggers.filterIsInstance<SceneTrigger.Time>().mapNotNull { trigger -> val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
nextOccurrence(trigger, now)?.let { occurrence -> ScheduledWorkOccurrence(scene, trigger, occurrence) } val scheduledAtInstant = scheduledAt.atZone(ZoneId.systemDefault()).toInstant()
}
}.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 maximumExpiry = scheduledAtInstant.plus(MAXIMUM_OCCURRENCE_LATENESS) 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()) ?.atZone(ZoneId.systemDefault())
?.toInstant() ?.toInstant()
?.minusMillis(1) ?.minusMillis(1)
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>() val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS) .setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.addTag(timeWorkTag(profileId))
.setInputData( .setInputData(
androidx.work.Data.Builder() androidx.work.Data.Builder()
.putString(KEY_PROFILE_ID, profileId.value) .putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, next.scene.id.value) .putString(KEY_SCENE_ID, scene.id.value)
.putString(KEY_TRIGGER_ID, next.trigger.id.value) .putString(KEY_TRIGGER_ID, trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, "${next.scheduledAt.toLocalDate()}:${next.trigger.timeOfDay}:${next.trigger.id.value}") .putString(KEY_OCCURRENCE_KEY, "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli()) .putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli()) .putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
.build(), .build(),
) )
.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? { private fun nextOccurrence(trigger: SceneTrigger.Time, now: LocalDateTime): LocalDateTime? {
@@ -82,12 +87,6 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
return null return null
} }
private data class ScheduledWorkOccurrence(
val scene: Scene,
val trigger: SceneTrigger.Time,
val scheduledAt: LocalDateTime,
)
companion object { companion object {
const val KEY_PROFILE_ID = "profile_id" const val KEY_PROFILE_ID = "profile_id"
const val KEY_SCENE_ID = "scene_id" const val KEY_SCENE_ID = "scene_id"