feat(scene): complete automation runtime (#11)

## Summary

- persist scenes and trigger state in Room with a database migration and one-time default seeding
- execute enabled automatic scenes from real device readings with edge-triggered thresholds, conditions, hysteresis, and time occurrence deduplication
- schedule foreground polling and WorkManager background evaluation, including reconciliation after scene changes
- allow automatic commands to be run manually for testing while bypassing triggers and conditions
- send real system notifications with runtime permission handling and accurate failure reporting
- move Other Options & Appearance to the bottom of the command editor

## Verification

- ./gradlew.bat :android:app:assembleDebug --no-daemon
- installed and exercised on mulator-5554
- verified manual automatic-command execution and Activity records
- verified notification permission flow, posted notification, and successful Activity records

Automated tests were not run per the requested iteration workflow.

Reviewed-on: #11
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #11.
This commit is contained in:
2026-08-28 06:39:31 +08:00
committed by KremeCN
parent 86b7e5849b
commit 4d4dd2893e
30 changed files with 1141 additions and 157 deletions
+12 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
@@ -11,6 +12,16 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ABoxManager">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true">
@@ -1,7 +1,22 @@
package com.flagship.abox.manager
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.flagship.abox.manager.automation.runtime.AutomationBootstrapper
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class ABoxManagerApplication : Application()
class ABoxManagerApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var automationBootstrapper: AutomationBootstrapper
override fun getWorkManagerConfiguration(): Configuration =
Configuration.Builder().setWorkerFactory(workerFactory).build()
override fun onCreate() {
super.onCreate()
automationBootstrapper.start()
}
}
@@ -1,19 +1,38 @@
package com.flagship.abox.manager
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.content.ContextCompat
import com.flagship.abox.manager.ui.ABoxManagerApp
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ABoxManagerApp()
}
requestNotificationPermissionIfNeeded()
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
) {
return
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -1,10 +1,10 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DefaultSceneExecutor
import com.flagship.abox.manager.domain.InMemorySceneRepository
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.ActivityEventWriter
@@ -21,22 +21,20 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SceneIntegrationModule {
@Provides
@Singleton
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
@Provides
@Singleton
fun provideSceneExecutor(
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
sendVirtualRemoteAction: SendVirtualRemoteAction,
notificationGateway: NotificationGateway,
activityWriter: ActivityEventWriter,
): SceneExecutor = RecordingSceneExecutor(
delegate = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
notificationGateway = notificationGateway,
),
writer = activityWriter,
)
@@ -218,93 +218,6 @@ private fun SceneEditorContent(
}
}
item(key = "icon_color_card") {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(R.string.scene_section_other),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
listOf(
SceneIcon.HOME to "Home",
SceneIcon.NIGHT to "Night",
SceneIcon.AWAY to "Away",
SceneIcon.MOVIE to "Movie",
SceneIcon.CUSTOM to "Custom",
).forEach { (icon, name) ->
val isSelected = state.icon == icon
Surface(
shape = RoundedCornerShape(12.dp),
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.size(52.dp)
.clickable { viewModel.updateIcon(icon) },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = when (icon) {
SceneIcon.HOME -> Icons.Outlined.CheckCircle
SceneIcon.NIGHT -> Icons.Outlined.Schedule
SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled
SceneIcon.MOVIE -> Icons.Outlined.PlayCircle
else -> Icons.Outlined.Settings
},
contentDescription = name,
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(26.dp),
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
listOf(
"#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50",
).forEach { hex ->
val isSelected = state.colorHex.equals(hex, ignoreCase = true)
val parsedColor = try {
Color(android.graphics.Color.parseColor(hex))
} catch (e: Exception) {
MaterialTheme.colorScheme.primary
}
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(parsedColor)
.clickable { viewModel.updateColor(hex) },
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
}
}
if (state.type == AutomationType.AUTOMATIC) {
item(key = "triggers_card") {
OutlinedCard(
@@ -552,6 +465,93 @@ private fun SceneEditorContent(
}
}
item(key = "icon_color_card") {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(R.string.scene_section_other),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
listOf(
SceneIcon.HOME to "Home",
SceneIcon.NIGHT to "Night",
SceneIcon.AWAY to "Away",
SceneIcon.MOVIE to "Movie",
SceneIcon.CUSTOM to "Custom",
).forEach { (icon, name) ->
val isSelected = state.icon == icon
Surface(
shape = RoundedCornerShape(12.dp),
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.size(52.dp)
.clickable { viewModel.updateIcon(icon) },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = when (icon) {
SceneIcon.HOME -> Icons.Outlined.CheckCircle
SceneIcon.NIGHT -> Icons.Outlined.Schedule
SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled
SceneIcon.MOVIE -> Icons.Outlined.PlayCircle
else -> Icons.Outlined.Settings
},
contentDescription = name,
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(26.dp),
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
listOf(
"#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50",
).forEach { hex ->
val isSelected = state.colorHex.equals(hex, ignoreCase = true)
val parsedColor = try {
Color(android.graphics.Color.parseColor(hex))
} catch (e: Exception) {
MaterialTheme.colorScheme.primary
}
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(parsedColor)
.clickable { viewModel.updateColor(hex) },
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
}
}
item(key = "save_section") {
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
@@ -28,6 +28,7 @@ import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject
@@ -69,6 +70,7 @@ class SceneEditorViewModel @Inject constructor(
private val virtualRemoteRepository: VirtualRemoteRepository,
private val profileStore: ProviderProfileStore,
private val deviceCatalog: DeviceCatalog,
private val automationScheduler: AutomationScheduler? = null,
) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState())
@@ -395,7 +397,10 @@ class SceneEditorViewModel @Inject constructor(
}
_state.update { current ->
when (result) {
is DomainResult.Success -> current.copy(isSaving = false, isSaved = true)
is DomainResult.Success -> {
automationScheduler?.reconcile(profile.id)
current.copy(isSaving = false, isSaved = true)
}
is DomainResult.Failure -> current.copy(
isSaving = false,
errorMessage = result.error.detail ?: "Save failed",
@@ -111,9 +111,8 @@ fun SceneListScreen(
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.userMessage.collectLatest { msg ->
if (msg == "executed") {
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
}
val messageRes = if (msg == "executed") R.string.scene_toast_executed else R.string.scene_toast_failed
Toast.makeText(context, context.getString(messageRes), Toast.LENGTH_SHORT).show()
}
}
@@ -402,19 +401,17 @@ private fun SceneCard(
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (scene.type == AutomationType.MANUAL) {
FilledTonalButton(
onClick = onQuickExecute,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp),
shape = RoundedCornerShape(8.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
}
FilledTonalButton(
onClick = onQuickExecute,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp),
shape = RoundedCornerShape(8.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -9,6 +9,7 @@ import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -38,6 +39,7 @@ class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor,
private val profileStore: ProviderProfileStore,
private val automationScheduler: AutomationScheduler? = null,
) : ViewModel() {
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
@@ -84,21 +86,29 @@ class SceneListViewModel @Inject constructor(
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val updated = scene.copy(enabled = !scene.enabled)
sceneRepository.updateScene(profile.id, updated)
if (sceneRepository.updateScene(profile.id, updated) is DomainResult.Success) {
automationScheduler?.reconcile(profile.id)
}
}
}
fun executeQuick(scene: Scene) {
viewModelScope.launch {
sceneExecutor.execute(scene)
_userMessage.emit("executed")
when (val result = sceneExecutor.execute(scene)) {
is DomainResult.Success -> {
_userMessage.emit(if (result.value.failedCount == 0) "executed" else "failed")
}
is DomainResult.Failure -> _userMessage.emit("failed")
}
}
}
fun deleteScene(sceneId: SceneId) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
sceneRepository.deleteScene(profile.id, sceneId)
if (sceneRepository.deleteScene(profile.id, sceneId) is DomainResult.Success) {
automationScheduler?.reconcile(profile.id)
}
_pendingDelete.value = null
}
}
@@ -57,6 +57,7 @@
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
<string name="scene_action_run_now">立即执行</string>
<string name="scene_toast_executed">指令已触发执行</string>
<string name="scene_toast_failed">指令执行失败</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除指令</string>
@@ -57,6 +57,7 @@
<string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</string>
<string name="scene_action_run_now">Run Now</string>
<string name="scene_toast_executed">Command triggered</string>
<string name="scene_toast_failed">Command execution failed</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">Delete Command</string>