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
5 changed files with 27 additions and 12 deletions
Showing only changes of commit 8b92c84a27 - Show all commits
@@ -15,7 +15,13 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
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()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -34,7 +40,8 @@ class MainActivity : ComponentActivity() {
}
val permissionState = getSharedPreferences(NOTIFICATION_PERMISSION_STATE, MODE_PRIVATE)
Outdated
Review

权限锁死

KEY_PERMISSION_REQUESTED 在启动授权弹窗前就被永久置为 true。如果用户首次拒绝或关闭弹窗,即使系统仍允许再次申请,应用后续也不会再请求权限,通知动作将持续失败。应根据授权结果和 rationale 状态允许重试,或提供明确的系统设置入口。

**权限锁死** `KEY_PERMISSION_REQUESTED` 在启动授权弹窗前就被永久置为 `true`。如果用户首次拒绝或关闭弹窗,即使系统仍允许再次申请,应用后续也不会再请求权限,通知动作将持续失败。应根据授权结果和 rationale 状态允许重试,或提供明确的系统设置入口。 <!-- pr-agent-dedup: 1374868344cf --> <!-- pr-agent-key-issue-location: 53ad2e88bced -->
if (permissionState.getBoolean(KEY_PERMISSION_REQUESTED, false)) return
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)
@@ -14,6 +14,7 @@ import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
@@ -40,7 +41,7 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat
@SuppressLint("MissingPermission")
fun postNotification() {
notificationManager.notify(
Outdated
Review

通知覆盖

notify 的 ID 只由 profileId 生成,因此同一配置下的所有场景和多个通知动作都会更新同一条系统通知。短时间连续触发时,先前通知会被覆盖,用户只能看到最后一条;应为每次事件或动作生成唯一 ID。

**通知覆盖** `notify` 的 ID 只由 `profileId` 生成,因此同一配置下的所有场景和多个通知动作都会更新同一条系统通知。短时间连续触发时,先前通知会被覆盖,用户只能看到最后一条;应为每次事件或动作生成唯一 ID。 <!-- pr-agent-dedup: c844385a2f3e --> <!-- pr-agent-key-issue-location: 737a0caa82af -->
profileId.value.hashCode(),
UUID.randomUUID().hashCode(),
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
@@ -39,6 +39,7 @@ data class ScheduledAutomationOccurrence(
val triggerId: String,
val occurrenceKey: String,
val scheduledAtEpochMillis: Long,
val expiresAtEpochMillis: Long,
)
class DefaultAutomationEngine(
@@ -170,10 +171,4 @@ class DefaultAutomationEngine(
)
}
private val ScheduledAutomationOccurrence.expiresAtEpochMillis: Long
get() = scheduledAtEpochMillis + SCHEDULED_OCCURRENCE_GRACE_MILLIS
private companion object {
const val SCHEDULED_OCCURRENCE_GRACE_MILLIS = 20L * 60L * 1000L
}
}
@@ -38,8 +38,9 @@ class AutomationPollWorker @AssistedInject constructor(
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L)
if (sceneId != null && triggerId != null && scheduledAt >= 0L) {
ScheduledAutomationOccurrence(sceneId, triggerId, key, scheduledAt)
val expiresAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_EXPIRES_AT_EPOCH_MILLIS, -1L)
if (sceneId != null && triggerId != null && scheduledAt >= 0L && expiresAt >= scheduledAt) {
ScheduledAutomationOccurrence(sceneId, triggerId, key, scheduledAt, expiresAt)
} else null
}
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
@@ -48,6 +48,13 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
return
}
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val scheduledAtInstant = next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant()
val maximumExpiry = scheduledAtInstant.plus(MAXIMUM_OCCURRENCE_LATENESS)
val followingOccurrence = nextOccurrence(next.trigger, next.scheduledAt)
?.atZone(ZoneId.systemDefault())
?.toInstant()
?.minusMillis(1)
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
Review

延迟执行

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

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

过期执行

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

**过期执行** 定时触发的有效期最长被设置为 24 小时,而引擎会在整个有效期内接受该触发。设备休眠或 WorkManager 延迟数小时后,“定时开关设备”等动作仍可能在接近下一次计划时间时执行,与前台评估采用的 20 分钟宽限期也不一致。应将有效期限制为明确且较短的迟到窗口。 <!-- pr-agent-dedup: a69636e81e58 --> <!-- pr-agent-key-issue-location: b2e10030dd23 -->
.setInputData(
@@ -56,7 +63,8 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
.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}")
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
.build(),
)
.build()
@@ -86,5 +94,8 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
const val KEY_TRIGGER_ID = "trigger_id"
const val KEY_OCCURRENCE_KEY = "occurrence_key"
const val KEY_SCHEDULED_AT_EPOCH_MILLIS = "scheduled_at_epoch_millis"
const val KEY_EXPIRES_AT_EPOCH_MILLIS = "expires_at_epoch_millis"
val MAXIMUM_OCCURRENCE_LATENESS: Duration = Duration.ofHours(24)
}
}