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:
@@ -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,
|
||||
)
|
||||
|
||||
+87
-87
@@ -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)
|
||||
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
|
||||
+14
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user