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:
@@ -71,6 +71,7 @@ dependencies {
|
|||||||
implementation(project(":android:core:session"))
|
implementation(project(":android:core:session"))
|
||||||
implementation(project(":android:feature:infrared"))
|
implementation(project(":android:feature:infrared"))
|
||||||
implementation(project(":android:feature:automation"))
|
implementation(project(":android:feature:automation"))
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
implementation(project(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
implementation(project(":runtime:simulation"))
|
implementation(project(":runtime:simulation"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
|
|||||||
@@ -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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
@@ -11,6 +12,16 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.ABoxManager">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
package com.flagship.abox.manager
|
package com.flagship.abox.manager
|
||||||
|
|
||||||
import android.app.Application
|
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 dagger.hilt.android.HiltAndroidApp
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@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
|
package com.flagship.abox.manager
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.flagship.abox.manager.ui.ABoxManagerApp
|
import com.flagship.abox.manager.ui.ABoxManagerApp
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val notificationPermissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ABoxManagerApp()
|
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
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DefaultSceneExecutor
|
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.InfraredGateway
|
||||||
import com.flagship.abox.manager.domain.SceneExecutor
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
import com.flagship.abox.manager.domain.SceneRepository
|
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.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
@@ -21,22 +21,20 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object SceneIntegrationModule {
|
object SceneIntegrationModule {
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSceneExecutor(
|
fun provideSceneExecutor(
|
||||||
socketGateway: SocketGateway,
|
socketGateway: SocketGateway,
|
||||||
infraredGateway: InfraredGateway,
|
infraredGateway: InfraredGateway,
|
||||||
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||||
|
notificationGateway: NotificationGateway,
|
||||||
activityWriter: ActivityEventWriter,
|
activityWriter: ActivityEventWriter,
|
||||||
): SceneExecutor = RecordingSceneExecutor(
|
): SceneExecutor = RecordingSceneExecutor(
|
||||||
delegate = DefaultSceneExecutor(
|
delegate = DefaultSceneExecutor(
|
||||||
socketGateway = socketGateway,
|
socketGateway = socketGateway,
|
||||||
infraredGateway = infraredGateway,
|
infraredGateway = infraredGateway,
|
||||||
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
||||||
|
notificationGateway = notificationGateway,
|
||||||
),
|
),
|
||||||
writer = activityWriter,
|
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) {
|
if (state.type == AutomationType.AUTOMATIC) {
|
||||||
item(key = "triggers_card") {
|
item(key = "triggers_card") {
|
||||||
OutlinedCard(
|
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") {
|
item(key = "save_section") {
|
||||||
state.errorMessage?.let {
|
state.errorMessage?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
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.VirtualRemote
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -69,6 +70,7 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
private val deviceCatalog: DeviceCatalog,
|
private val deviceCatalog: DeviceCatalog,
|
||||||
|
private val automationScheduler: AutomationScheduler? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SceneEditorState())
|
private val _state = MutableStateFlow(SceneEditorState())
|
||||||
@@ -395,7 +397,10 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
when (result) {
|
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(
|
is DomainResult.Failure -> current.copy(
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
errorMessage = result.error.detail ?: "Save failed",
|
errorMessage = result.error.detail ?: "Save failed",
|
||||||
|
|||||||
@@ -111,9 +111,8 @@ fun SceneListScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.userMessage.collectLatest { msg ->
|
viewModel.userMessage.collectLatest { msg ->
|
||||||
if (msg == "executed") {
|
val messageRes = if (msg == "executed") R.string.scene_toast_executed else R.string.scene_toast_failed
|
||||||
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, context.getString(messageRes), Toast.LENGTH_SHORT).show()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +401,6 @@ private fun SceneCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (scene.type == AutomationType.MANUAL) {
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onQuickExecute,
|
onClick = onQuickExecute,
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
@@ -414,7 +412,6 @@ private fun SceneCard(
|
|||||||
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
|
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
|
||||||
IconButton(onClick = onEdit, modifier = Modifier.size(32.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)
|
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.SceneExecutor
|
||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
import com.flagship.abox.manager.domain.SceneRepository
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -38,6 +39,7 @@ class SceneListViewModel @Inject constructor(
|
|||||||
private val sceneRepository: SceneRepository,
|
private val sceneRepository: SceneRepository,
|
||||||
private val sceneExecutor: SceneExecutor,
|
private val sceneExecutor: SceneExecutor,
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val automationScheduler: AutomationScheduler? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
|
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
|
||||||
@@ -84,21 +86,29 @@ class SceneListViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val profile = profileStore.activeProfile() ?: return@launch
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
val updated = scene.copy(enabled = !scene.enabled)
|
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) {
|
fun executeQuick(scene: Scene) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sceneExecutor.execute(scene)
|
when (val result = sceneExecutor.execute(scene)) {
|
||||||
_userMessage.emit("executed")
|
is DomainResult.Success -> {
|
||||||
|
_userMessage.emit(if (result.value.failedCount == 0) "executed" else "failed")
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> _userMessage.emit("failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteScene(sceneId: SceneId) {
|
fun deleteScene(sceneId: SceneId) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val profile = profileStore.activeProfile() ?: return@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
|
_pendingDelete.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
|
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
|
||||||
<string name="scene_action_run_now">立即执行</string>
|
<string name="scene_action_run_now">立即执行</string>
|
||||||
<string name="scene_toast_executed">指令已触发执行</string>
|
<string name="scene_toast_executed">指令已触发执行</string>
|
||||||
|
<string name="scene_toast_failed">指令执行失败</string>
|
||||||
|
|
||||||
<!-- Scene: delete dialog -->
|
<!-- Scene: delete dialog -->
|
||||||
<string name="scene_delete_dialog_title">删除指令</string>
|
<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_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_action_run_now">Run Now</string>
|
||||||
<string name="scene_toast_executed">Command triggered</string>
|
<string name="scene_toast_executed">Command triggered</string>
|
||||||
|
<string name="scene_toast_failed">Command execution failed</string>
|
||||||
|
|
||||||
<!-- Scene: delete dialog -->
|
<!-- Scene: delete dialog -->
|
||||||
<string name="scene_delete_dialog_title">Delete Command</string>
|
<string name="scene_delete_dialog_title">Delete Command</string>
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ dependencies {
|
|||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
|
kapt(libs.androidx.hilt.compiler)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
|
|||||||
+35
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -23,4 +24,38 @@ interface AutomationDao {
|
|||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
|
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
|
||||||
|
|
||||||
|
@Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id")
|
||||||
|
fun observeScenes(profileId: String): Flow<List<SceneEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id")
|
||||||
|
suspend fun getScenes(profileId: String): List<SceneEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM scenes WHERE profileId = :profileId AND id = :sceneId LIMIT 1")
|
||||||
|
suspend fun getScene(profileId: String, sceneId: String): SceneEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertScene(scene: SceneEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM scenes WHERE profileId = :profileId AND id = :sceneId")
|
||||||
|
suspend fun deleteScene(profileId: String, sceneId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId AND triggerId = :triggerId LIMIT 1")
|
||||||
|
suspend fun getTriggerState(profileId: String, sceneId: String, triggerId: String): AutomationTriggerStateEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertTriggerState(state: AutomationTriggerStateEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun transitionTriggerState(state: AutomationTriggerStateEntity): Boolean {
|
||||||
|
val previous = getTriggerState(state.profileId, state.sceneId, state.triggerId)?.matched ?: false
|
||||||
|
upsertTriggerState(state)
|
||||||
|
return !previous && state.matched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId")
|
||||||
|
suspend fun deleteTriggerStates(profileId: String, sceneId: String): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId AND triggerId = :triggerId")
|
||||||
|
suspend fun deleteTriggerState(profileId: String, sceneId: String, triggerId: String): Int
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -3,7 +3,16 @@ package com.flagship.abox.manager.automation.data
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false)
|
@Database(
|
||||||
|
entities = [
|
||||||
|
ActivityEventEntity::class,
|
||||||
|
AutomationOccurrenceEntity::class,
|
||||||
|
SceneEntity::class,
|
||||||
|
AutomationTriggerStateEntity::class,
|
||||||
|
],
|
||||||
|
version = 2,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
abstract class AutomationDatabase : RoomDatabase() {
|
abstract class AutomationDatabase : RoomDatabase() {
|
||||||
abstract fun automationDao(): AutomationDao
|
abstract fun automationDao(): AutomationDao
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -31,3 +31,29 @@ data class AutomationOccurrenceEntity(
|
|||||||
val triggerId: String,
|
val triggerId: String,
|
||||||
val occurrenceKey: String,
|
val occurrenceKey: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "scenes",
|
||||||
|
primaryKeys = ["profileId", "id"],
|
||||||
|
indices = [Index(value = ["profileId", "type", "enabled"])],
|
||||||
|
)
|
||||||
|
data class SceneEntity(
|
||||||
|
val profileId: String,
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val document: String,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "automation_trigger_state",
|
||||||
|
primaryKeys = ["profileId", "sceneId", "triggerId"],
|
||||||
|
)
|
||||||
|
data class AutomationTriggerStateEntity(
|
||||||
|
val profileId: String,
|
||||||
|
val sceneId: String,
|
||||||
|
val triggerId: String,
|
||||||
|
val matched: Boolean,
|
||||||
|
val updatedAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scenes (
|
||||||
|
profileId TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL,
|
||||||
|
document TEXT NOT NULL,
|
||||||
|
updatedAtEpochMillis INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(profileId, id)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_scenes_profileId_type_enabled ON scenes(profileId, type, enabled)")
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS automation_trigger_state (
|
||||||
|
profileId TEXT NOT NULL,
|
||||||
|
sceneId TEXT NOT NULL,
|
||||||
|
triggerId TEXT NOT NULL,
|
||||||
|
matched INTEGER NOT NULL,
|
||||||
|
updatedAtEpochMillis INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(profileId, sceneId, triggerId)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-2
@@ -6,13 +6,23 @@ import com.flagship.abox.manager.domain.ProviderProfileId
|
|||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
import com.flagship.abox.manager.domain.SceneTriggerId
|
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||||
|
|
||||||
data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean)
|
|
||||||
|
|
||||||
interface AutomationRuntimeRepository {
|
interface AutomationRuntimeRepository {
|
||||||
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
|
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
|
||||||
|
suspend fun isMatched(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId): DomainResult<Boolean>
|
||||||
|
suspend fun transitionMatch(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, isMatched: Boolean, nowEpochMillis: Long): DomainResult<Boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
|
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
|
||||||
|
override suspend fun isMatched(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
sceneId: SceneId,
|
||||||
|
triggerId: SceneTriggerId,
|
||||||
|
): DomainResult<Boolean> = try {
|
||||||
|
DomainResult.Success(dao.getTriggerState(profileId.value, sceneId.value, triggerId.value)?.matched ?: false)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun claimOccurrence(
|
override suspend fun claimOccurrence(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
sceneId: SceneId,
|
sceneId: SceneId,
|
||||||
@@ -26,4 +36,25 @@ class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : Automati
|
|||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
|
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun transitionMatch(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
sceneId: SceneId,
|
||||||
|
triggerId: SceneTriggerId,
|
||||||
|
isMatched: Boolean,
|
||||||
|
nowEpochMillis: Long,
|
||||||
|
): DomainResult<Boolean> = try {
|
||||||
|
val transitioned = dao.transitionTriggerState(
|
||||||
|
AutomationTriggerStateEntity(
|
||||||
|
profileId = profileId.value,
|
||||||
|
sceneId = sceneId.value,
|
||||||
|
triggerId = triggerId.value,
|
||||||
|
matched = isMatched,
|
||||||
|
updatedAtEpochMillis = nowEpochMillis,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DomainResult.Success(transitioned)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneCondition
|
||||||
|
import com.flagship.abox.manager.domain.SceneConditionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
|
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class RoomSceneRepository(
|
||||||
|
context: Context,
|
||||||
|
private val database: AutomationDatabase,
|
||||||
|
) : SceneRepository {
|
||||||
|
private val dao = database.automationDao()
|
||||||
|
private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> = flow {
|
||||||
|
ensureSeeded(profileId)
|
||||||
|
emitAll(dao.observeScenes(profileId.value)
|
||||||
|
.map<List<SceneEntity>, DomainResult<List<Scene>>> { entities -> DomainResult.Success(entities.map(SceneDocumentCodec::decode)) }
|
||||||
|
.catch { emit(DomainResult.Failure(DomainError.Unknown(detail = "Scene storage unavailable"))) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult<Scene?> = runCatching {
|
||||||
|
dao.getScene(profileId.value, sceneId.value)?.let(SceneDocumentCodec::decode)
|
||||||
|
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.Unknown(detail = "Scene storage unavailable")) })
|
||||||
|
|
||||||
|
override suspend fun createScene(profileId: ProviderProfileId, scene: Scene): DomainResult<Scene> = save(profileId, scene, requireExisting = false)
|
||||||
|
|
||||||
|
override suspend fun updateScene(profileId: ProviderProfileId, scene: Scene): DomainResult<Scene> = save(profileId, scene, requireExisting = true)
|
||||||
|
|
||||||
|
override suspend fun deleteScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult<Unit> = runCatching {
|
||||||
|
val deleted = dao.deleteScene(profileId.value, sceneId.value)
|
||||||
|
dao.deleteTriggerStates(profileId.value, sceneId.value)
|
||||||
|
if (deleted == 0) throw IllegalArgumentException("Scene not found")
|
||||||
|
}.fold({ DomainResult.Success(Unit) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
|
||||||
|
|
||||||
|
private suspend fun save(profileId: ProviderProfileId, scene: Scene, requireExisting: Boolean): DomainResult<Scene> {
|
||||||
|
if (scene.profileId != profileId || scene.actions.any { it.profileId != profileId }) {
|
||||||
|
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
database.withTransaction {
|
||||||
|
val existing = dao.getScene(profileId.value, scene.id.value)
|
||||||
|
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
|
||||||
|
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
|
||||||
|
dao.upsertScene(scene.toEntity())
|
||||||
|
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
|
||||||
|
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
|
||||||
|
.forEach { changed ->
|
||||||
|
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene
|
||||||
|
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SceneTrigger.hasSameDefinitionAs(other: SceneTrigger): Boolean = when {
|
||||||
|
this is SceneTrigger.Time && other is SceneTrigger.Time ->
|
||||||
|
id == other.id && timeOfDay == other.timeOfDay && daysOfWeek == other.daysOfWeek
|
||||||
|
this is SceneTrigger.Temperature && other is SceneTrigger.Temperature ->
|
||||||
|
id == other.id && deviceName == other.deviceName && operator == other.operator && thresholdCelsius == other.thresholdCelsius
|
||||||
|
this is SceneTrigger.Humidity && other is SceneTrigger.Humidity ->
|
||||||
|
id == other.id && deviceName == other.deviceName && operator == other.operator && thresholdPercent == other.thresholdPercent
|
||||||
|
this is SceneTrigger.DeviceState && other is SceneTrigger.DeviceState ->
|
||||||
|
id == other.id && deviceName == other.deviceName && expectedState == other.expectedState
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureSeeded(profileId: ProviderProfileId) {
|
||||||
|
val marker = "seed_v1_${profileId.value}"
|
||||||
|
if (seedPreferences.getBoolean(marker, false)) return
|
||||||
|
defaultScenes(profileId).forEach { scene ->
|
||||||
|
dao.upsertScene(scene.toEntity())
|
||||||
|
}
|
||||||
|
seedPreferences.edit().putBoolean(marker, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Scene.toEntity() = SceneEntity(profileId.value, id.value, type.name, enabled, SceneDocumentCodec.encode(this), System.currentTimeMillis())
|
||||||
|
|
||||||
|
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
|
||||||
|
Scene(
|
||||||
|
SceneId("${profileId.value}:scene-arriving-home"), profileId, "回家模式", "开启 Zigbee 插座控制器",
|
||||||
|
AutomationType.MANUAL, true, SceneIcon.HOME, SceneColor("#4CAF50"),
|
||||||
|
actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:arrive:socket"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.ON)),
|
||||||
|
),
|
||||||
|
Scene(
|
||||||
|
SceneId("${profileId.value}:scene-leaving-home"), profileId, "离家模式", "关闭 Zigbee 插座控制器",
|
||||||
|
AutomationType.MANUAL, true, SceneIcon.AWAY, SceneColor("#FF8A65"),
|
||||||
|
actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:leave:socket"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.OFF)),
|
||||||
|
),
|
||||||
|
Scene(
|
||||||
|
SceneId("${profileId.value}:scene-temperature-alert"), profileId, "高温提醒", "室温高于 28°C 时发送提醒",
|
||||||
|
AutomationType.AUTOMATIC, true, SceneIcon.THERMOSTAT, SceneColor("#EF5350"),
|
||||||
|
triggers = listOf(SceneTrigger.Temperature(SceneTriggerId("${profileId.value}:temp-alert"), DeviceName("温湿度传感器"), ComparisonOperator.GREATER_THAN, 28.0, "温度高于 28°C")),
|
||||||
|
actions = listOf(SceneAction.Notification(SceneActionId("${profileId.value}:temp-alert:notification"), profileId, "温度提醒", "当前室温已高于 28°C")),
|
||||||
|
),
|
||||||
|
Scene(
|
||||||
|
SceneId("${profileId.value}:scene-cooling-auto"), profileId, "高温自动降温", "当室温高于 28°C 时开启插座",
|
||||||
|
AutomationType.AUTOMATIC, true, SceneIcon.THERMOSTAT, SceneColor("#26A69A"),
|
||||||
|
triggers = listOf(SceneTrigger.Temperature(SceneTriggerId("${profileId.value}:cooling-temp"), DeviceName("温湿度传感器"), ComparisonOperator.GREATER_THAN, 28.0, "温度高于 28°C")),
|
||||||
|
conditions = listOf(SceneCondition.SocketPower(SceneConditionId("${profileId.value}:cooling-socket"), DeviceName("Zigbee插座控制器"), SocketPowerState.OFF)),
|
||||||
|
actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:cooling:on"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.ON)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||||
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneCondition
|
||||||
|
import com.flagship.abox.manager.domain.SceneConditionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
|
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
internal object SceneDocumentCodec {
|
||||||
|
fun encode(scene: Scene): String = JSONObject()
|
||||||
|
.put("name", scene.name)
|
||||||
|
.put("description", scene.description)
|
||||||
|
.put("type", scene.type.name)
|
||||||
|
.put("enabled", scene.enabled)
|
||||||
|
.put("icon", scene.icon.name)
|
||||||
|
.put("color", scene.color.hex)
|
||||||
|
.put("strategy", scene.strategy.name)
|
||||||
|
.put("concurrency", scene.concurrencyMode.name)
|
||||||
|
.put("logging", scene.enableLogging)
|
||||||
|
.put("triggers", JSONArray().apply { scene.triggers.forEach { put(encodeTrigger(it)) } })
|
||||||
|
.put("conditions", JSONArray().apply { scene.conditions.forEach { put(encodeCondition(it)) } })
|
||||||
|
.put("actions", JSONArray().apply { scene.actions.forEach { put(encodeAction(it)) } })
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
fun decode(entity: SceneEntity): Scene {
|
||||||
|
val document = JSONObject(entity.document)
|
||||||
|
val profileId = ProviderProfileId(entity.profileId)
|
||||||
|
return Scene(
|
||||||
|
id = SceneId(entity.id),
|
||||||
|
profileId = profileId,
|
||||||
|
name = document.getString("name"),
|
||||||
|
description = document.optString("description"),
|
||||||
|
type = enumValue(document.getString("type"), AutomationType.MANUAL),
|
||||||
|
enabled = document.optBoolean("enabled", true),
|
||||||
|
icon = enumValue(document.optString("icon"), SceneIcon.HOME),
|
||||||
|
color = SceneColor(document.optString("color", "#3F51B5")),
|
||||||
|
triggers = document.optJSONArray("triggers").objects().map(::decodeTrigger),
|
||||||
|
conditions = document.optJSONArray("conditions").objects().map(::decodeCondition),
|
||||||
|
actions = document.optJSONArray("actions").objects().map { decodeAction(it, profileId) },
|
||||||
|
strategy = enumValue(document.optString("strategy"), ExecutionStrategy.CONTINUE_ON_ERROR),
|
||||||
|
concurrencyMode = enumValue(document.optString("concurrency"), ConcurrencyMode.SEQUENTIAL),
|
||||||
|
enableLogging = document.optBoolean("logging", true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeTrigger(trigger: SceneTrigger): JSONObject = JSONObject().put("id", trigger.id.value).apply {
|
||||||
|
when (trigger) {
|
||||||
|
is SceneTrigger.Time -> put("kind", "time").put("time", trigger.timeOfDay)
|
||||||
|
.put("days", JSONArray(trigger.daysOfWeek.sorted())).put("label", trigger.label)
|
||||||
|
is SceneTrigger.Temperature -> put("kind", "temperature").put("device", trigger.deviceName.value)
|
||||||
|
.put("operator", trigger.operator.name).put("threshold", trigger.thresholdCelsius).put("label", trigger.label)
|
||||||
|
is SceneTrigger.Humidity -> put("kind", "humidity").put("device", trigger.deviceName.value)
|
||||||
|
.put("operator", trigger.operator.name).put("threshold", trigger.thresholdPercent).put("label", trigger.label)
|
||||||
|
is SceneTrigger.DeviceState -> put("kind", "device_state").put("device", trigger.deviceName.value)
|
||||||
|
.put("state", trigger.expectedState.name).put("label", trigger.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeTrigger(value: JSONObject): SceneTrigger {
|
||||||
|
val id = SceneTriggerId(value.getString("id"))
|
||||||
|
return when (value.getString("kind")) {
|
||||||
|
"time" -> SceneTrigger.Time(id, value.getString("time"), value.optJSONArray("days").ints().toSet(), value.optString("label"))
|
||||||
|
"temperature" -> SceneTrigger.Temperature(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"), value.optString("label"))
|
||||||
|
"humidity" -> SceneTrigger.Humidity(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"), value.optString("label"))
|
||||||
|
"device_state" -> SceneTrigger.DeviceState(id, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF), value.optString("label"))
|
||||||
|
else -> error("Unsupported trigger kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeCondition(condition: SceneCondition): JSONObject = JSONObject().put("id", condition.id.value).apply {
|
||||||
|
when (condition) {
|
||||||
|
is SceneCondition.Temperature -> put("kind", "temperature").put("device", condition.deviceName.value)
|
||||||
|
.put("operator", condition.operator.name).put("threshold", condition.thresholdCelsius)
|
||||||
|
is SceneCondition.Humidity -> put("kind", "humidity").put("device", condition.deviceName.value)
|
||||||
|
.put("operator", condition.operator.name).put("threshold", condition.thresholdPercent)
|
||||||
|
is SceneCondition.SocketPower -> put("kind", "socket").put("device", condition.deviceName.value)
|
||||||
|
.put("state", condition.expectedState.name)
|
||||||
|
is SceneCondition.TimeRange -> put("kind", "time_range").put("start", condition.startTime).put("end", condition.endTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeCondition(value: JSONObject): SceneCondition {
|
||||||
|
val id = SceneConditionId(value.getString("id"))
|
||||||
|
return when (value.getString("kind")) {
|
||||||
|
"temperature" -> SceneCondition.Temperature(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"))
|
||||||
|
"humidity" -> SceneCondition.Humidity(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"))
|
||||||
|
"socket" -> SceneCondition.SocketPower(id, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF))
|
||||||
|
"time_range" -> SceneCondition.TimeRange(id, value.getString("start"), value.getString("end"))
|
||||||
|
else -> error("Unsupported condition kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeAction(action: SceneAction): JSONObject = JSONObject().put("id", action.id.value).apply {
|
||||||
|
when (action) {
|
||||||
|
is SceneAction.SocketPower -> put("kind", "socket").put("device", action.deviceName.value).put("state", action.powerState.name)
|
||||||
|
is SceneAction.VirtualRemoteButton -> put("kind", "remote").put("remoteId", action.remoteId)
|
||||||
|
.put("remoteName", action.remoteDisplayName).put("buttonId", action.buttonId).put("buttonName", action.buttonDisplayName)
|
||||||
|
is SceneAction.InfraredKey -> put("kind", "infrared").put("device", action.deviceName.value).put("key", action.key)
|
||||||
|
is SceneAction.Delay -> put("kind", "delay").put("seconds", action.durationSeconds)
|
||||||
|
is SceneAction.Notification -> put("kind", "notification").put("title", action.title).put("message", action.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAction(value: JSONObject, profileId: ProviderProfileId): SceneAction {
|
||||||
|
val id = SceneActionId(value.getString("id"))
|
||||||
|
return when (value.getString("kind")) {
|
||||||
|
"socket" -> SceneAction.SocketPower(id, profileId, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF))
|
||||||
|
"remote" -> SceneAction.VirtualRemoteButton(id, profileId, value.getString("remoteId"), value.optString("remoteName"), value.getString("buttonId"), value.optString("buttonName"))
|
||||||
|
"infrared" -> SceneAction.InfraredKey(id, profileId, DeviceName(value.getString("device")), value.getString("key"))
|
||||||
|
"delay" -> SceneAction.Delay(id, profileId, value.getInt("seconds"))
|
||||||
|
"notification" -> SceneAction.Notification(id, profileId, value.getString("title"), value.optString("message"))
|
||||||
|
else -> error("Unsupported action kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T : Enum<T>> enumValue(value: String, fallback: T): T =
|
||||||
|
enumValues<T>().firstOrNull { it.name == value } ?: fallback
|
||||||
|
|
||||||
|
private fun JSONArray?.objects(): List<JSONObject> = if (this == null) emptyList() else List(length()) { getJSONObject(it) }
|
||||||
|
private fun JSONArray?.ints(): List<Int> = if (this == null) emptyList() else List(length()) { getInt(it) }
|
||||||
|
}
|
||||||
+69
-1
@@ -6,9 +6,24 @@ import com.flagship.abox.manager.automation.data.AutomationDatabase
|
|||||||
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
|
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
|
||||||
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
||||||
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
||||||
|
import com.flagship.abox.manager.automation.data.RoomSceneRepository
|
||||||
|
import com.flagship.abox.manager.automation.data.MIGRATION_1_2
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationBootstrapper
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationEngine
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationScheduleStore
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
|
||||||
|
import com.flagship.abox.manager.automation.runtime.DefaultAutomationEngine
|
||||||
|
import com.flagship.abox.manager.automation.runtime.ProfileAutomationScheduler
|
||||||
|
import com.flagship.abox.manager.automation.runtime.WorkManagerAutomationScheduleStore
|
||||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
import com.flagship.abox.manager.domain.ActivityRepository
|
import com.flagship.abox.manager.domain.ActivityRepository
|
||||||
import com.flagship.abox.manager.domain.NotificationGateway
|
import com.flagship.abox.manager.domain.NotificationGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway
|
import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -26,7 +41,9 @@ object AutomationModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
|
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
|
||||||
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build()
|
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db")
|
||||||
|
.addMigrations(MIGRATION_1_2)
|
||||||
|
.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -41,6 +58,57 @@ object AutomationModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSceneRepository(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
database: AutomationDatabase,
|
||||||
|
): SceneRepository = RoomSceneRepository(context, database)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAutomationEngine(
|
||||||
|
sceneRepository: SceneRepository,
|
||||||
|
runtimeRepository: AutomationRuntimeRepository,
|
||||||
|
temperatureHumidityGateway: TemperatureHumidityGateway,
|
||||||
|
socketGateway: SocketGateway,
|
||||||
|
sceneExecutor: SceneExecutor,
|
||||||
|
): AutomationEngine = DefaultAutomationEngine(
|
||||||
|
sceneRepository,
|
||||||
|
runtimeRepository,
|
||||||
|
temperatureHumidityGateway,
|
||||||
|
socketGateway,
|
||||||
|
sceneExecutor,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAutomationScheduleStore(@ApplicationContext context: Context): AutomationScheduleStore =
|
||||||
|
WorkManagerAutomationScheduleStore(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAutomationScheduler(
|
||||||
|
profileStore: ProviderProfileStore,
|
||||||
|
sessionCoordinator: SessionCoordinator,
|
||||||
|
sceneRepository: SceneRepository,
|
||||||
|
scheduleStore: AutomationScheduleStore,
|
||||||
|
): AutomationScheduler = ProfileAutomationScheduler(profileStore, sessionCoordinator, sceneRepository, scheduleStore)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAutomationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAutomationBootstrapper(
|
||||||
|
profileStore: ProviderProfileStore,
|
||||||
|
sessionCoordinator: SessionCoordinator,
|
||||||
|
scheduler: AutomationScheduler,
|
||||||
|
engine: AutomationEngine,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
): AutomationBootstrapper = AutomationBootstrapper(profileStore, sessionCoordinator, scheduler, engine, scope)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
|
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
|
||||||
|
|||||||
+24
-6
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -13,27 +14,39 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.NotificationGateway
|
import com.flagship.abox.manager.domain.NotificationGateway
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
|
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
|
||||||
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
|
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 33 &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
|
return notificationUnavailable()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
if (!notificationManager.areNotificationsEnabled()) return notificationUnavailable()
|
||||||
|
|
||||||
val manager = context.getSystemService(NotificationManager::class.java)
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
|
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
|
||||||
)
|
)
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE) {
|
||||||
|
return notificationUnavailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun postNotification() {
|
fun postNotification() {
|
||||||
NotificationManagerCompat.from(context).notify(
|
notificationManager.notify(
|
||||||
profileId.value.hashCode(),
|
UUID.randomUUID().hashCode(),
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(message)
|
.setContentText(message)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
@@ -41,9 +54,14 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat
|
|||||||
postNotification()
|
postNotification()
|
||||||
DomainResult.Success(Unit)
|
DomainResult.Success(Unit)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
|
notificationUnavailable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object { const val CHANNEL_ID = "automation_events" }
|
private fun notificationUnavailable(): DomainResult.Failure =
|
||||||
|
DomainResult.Failure(DomainError.OperationFailed(detail = "Notifications are not allowed"))
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CHANNEL_ID = "automation_events"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-7
@@ -1,25 +1,78 @@
|
|||||||
package com.flagship.abox.manager.automation.runtime
|
package com.flagship.abox.manager.automation.runtime
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SessionValidity
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class AutomationBootstrapper(
|
class AutomationBootstrapper(
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val sessionCoordinator: SessionCoordinator,
|
||||||
private val scheduler: AutomationScheduler,
|
private val scheduler: AutomationScheduler,
|
||||||
|
private val engine: AutomationEngine,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) : DefaultLifecycleObserver {
|
||||||
private var previousProfileId: ProviderProfileId? = null
|
private var previousProfileId: ProviderProfileId? = null
|
||||||
|
private val isForeground = MutableStateFlow(false)
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
profileStore.observeActiveProfile().collectLatest { active ->
|
combine(
|
||||||
|
profileStore.observeActiveProfile(),
|
||||||
|
sessionCoordinator.observeActiveSession(),
|
||||||
|
) { active, session -> active to session }
|
||||||
|
.collectLatest { (active, session) ->
|
||||||
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
||||||
previousProfileId = active?.id
|
previousProfileId = active?.id
|
||||||
scheduler.reconcileActiveProfile()
|
if (active != null && session?.profileId == active.id) {
|
||||||
|
var scheduleReady = false
|
||||||
|
while (true) {
|
||||||
|
isForeground.first { it }
|
||||||
|
when (val validity = sessionCoordinator.validateSession(active.id)) {
|
||||||
|
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
|
||||||
|
if (!scheduleReady) {
|
||||||
|
scheduleReady = scheduler.reconcile(active.id) is DomainResult.Success
|
||||||
|
}
|
||||||
|
if (scheduleReady) {
|
||||||
|
try {
|
||||||
|
engine.evaluate(active.id)
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
throw cancelled
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scheduler.cancel(active.id)
|
||||||
|
scheduleReady = false
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> Unit
|
||||||
|
}
|
||||||
|
if (isForeground.value) delay(60_000L)
|
||||||
|
}
|
||||||
|
} else if (active != null) {
|
||||||
|
scheduler.cancel(active.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
isForeground.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
isForeground.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package com.flagship.abox.manager.automation.runtime
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
|
||||||
|
import com.flagship.abox.manager.domain.AutomationSnapshot
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.ConditionsEvaluation
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneCondition
|
||||||
|
import com.flagship.abox.manager.domain.SceneConditionEvaluator
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionSource
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
|
import com.flagship.abox.manager.domain.SceneTriggerEvaluator
|
||||||
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
|
import com.flagship.abox.manager.domain.TriggerEvaluation
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
interface AutomationEngine {
|
||||||
|
suspend fun evaluate(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
scheduledOccurrence: ScheduledAutomationOccurrence? = null,
|
||||||
|
): DomainResult<Int>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ScheduledAutomationOccurrence(
|
||||||
|
val sceneId: String,
|
||||||
|
val triggerId: String,
|
||||||
|
val occurrenceKey: String,
|
||||||
|
val triggerDefinition: String,
|
||||||
|
val scheduledAtEpochMillis: Long,
|
||||||
|
val expiresAtEpochMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DefaultAutomationEngine(
|
||||||
|
private val sceneRepository: SceneRepository,
|
||||||
|
private val runtimeRepository: AutomationRuntimeRepository,
|
||||||
|
private val temperatureHumidityGateway: TemperatureHumidityGateway,
|
||||||
|
private val socketGateway: SocketGateway,
|
||||||
|
private val sceneExecutor: SceneExecutor,
|
||||||
|
private val triggerEvaluator: SceneTriggerEvaluator = SceneTriggerEvaluator(),
|
||||||
|
private val conditionEvaluator: SceneConditionEvaluator = SceneConditionEvaluator(),
|
||||||
|
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||||
|
) : AutomationEngine {
|
||||||
|
private val evaluationMutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun evaluate(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
scheduledOccurrence: ScheduledAutomationOccurrence?,
|
||||||
|
): DomainResult<Int> {
|
||||||
|
val scenesToExecute = when (val prepared = evaluationMutex.withLock { prepareExecutions(profileId, scheduledOccurrence) }) {
|
||||||
|
is DomainResult.Failure -> return prepared
|
||||||
|
is DomainResult.Success -> prepared.value
|
||||||
|
}
|
||||||
|
var executed = 0
|
||||||
|
for (scene in scenesToExecute) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun prepareExecutions(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
scheduledOccurrence: ScheduledAutomationOccurrence?,
|
||||||
|
): DomainResult<List<Scene>> {
|
||||||
|
val scenes = when (val result = sceneRepository.observeScenes(profileId).first()) {
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }
|
||||||
|
}
|
||||||
|
if (scenes.isEmpty()) return DomainResult.Success(emptyList())
|
||||||
|
|
||||||
|
val snapshot = collectSnapshot(profileId, scenes)
|
||||||
|
val now = LocalDateTime.ofInstant(Instant.ofEpochMilli(snapshot.observedAtEpochMillis), ZoneId.systemDefault())
|
||||||
|
val executions = mutableListOf<Scene>()
|
||||||
|
for (scene in scenes) {
|
||||||
|
var newlyMatched = false
|
||||||
|
val newlyMatchedStateTriggers = mutableListOf<SceneTrigger>()
|
||||||
|
val matchedTimeTriggers = mutableListOf<Pair<SceneTrigger.Time, String>>()
|
||||||
|
for (trigger in scene.triggers) {
|
||||||
|
val wasMatched = if (trigger is SceneTrigger.Time) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
when (val state = runtimeRepository.isMatched(profileId, scene.id, trigger.id)) {
|
||||||
|
is DomainResult.Failure -> return state
|
||||||
|
is DomainResult.Success -> state.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val evaluation = if (
|
||||||
|
trigger is SceneTrigger.Time &&
|
||||||
|
scheduledOccurrence?.sceneId == scene.id.value &&
|
||||||
|
scheduledOccurrence.triggerId == trigger.id.value &&
|
||||||
|
scheduledOccurrence.triggerDefinition == trigger.automationDefinition() &&
|
||||||
|
snapshot.observedAtEpochMillis in scheduledOccurrence.scheduledAtEpochMillis..scheduledOccurrence.expiresAtEpochMillis
|
||||||
|
) {
|
||||||
|
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
|
||||||
|
} else {
|
||||||
|
triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched)
|
||||||
|
}
|
||||||
|
val isMatched = evaluation is TriggerEvaluation.Matched
|
||||||
|
if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) {
|
||||||
|
newlyMatched = true
|
||||||
|
matchedTimeTriggers += trigger to evaluation.occurrenceKey
|
||||||
|
} else if (trigger !is SceneTrigger.Time && evaluation !is TriggerEvaluation.Unavailable) {
|
||||||
|
when (val transition = runtimeRepository.transitionMatch(
|
||||||
|
profileId,
|
||||||
|
scene.id,
|
||||||
|
trigger.id,
|
||||||
|
isMatched,
|
||||||
|
snapshot.observedAtEpochMillis,
|
||||||
|
)) {
|
||||||
|
is DomainResult.Failure -> return transition
|
||||||
|
is DomainResult.Success -> if (transition.value) {
|
||||||
|
newlyMatched = true
|
||||||
|
newlyMatchedStateTriggers += trigger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!newlyMatched) continue
|
||||||
|
when (conditionEvaluator.evaluate(scene.conditions, snapshot, now)) {
|
||||||
|
is ConditionsEvaluation.Unavailable -> continue
|
||||||
|
is ConditionsEvaluation.NotSatisfied -> {
|
||||||
|
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
|
||||||
|
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
|
||||||
|
is DomainResult.Failure -> return claim
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ConditionsEvaluation.Satisfied -> Unit
|
||||||
|
}
|
||||||
|
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
|
||||||
|
if (matchedTimeTriggers.isNotEmpty()) {
|
||||||
|
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) executionCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (executionCount == 0) continue
|
||||||
|
|
||||||
|
repeat(executionCount) {
|
||||||
|
executions += scene
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DomainResult.Success(executions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectSnapshot(profileId: ProviderProfileId, scenes: List<Scene>): AutomationSnapshot {
|
||||||
|
val readingDevices = linkedSetOf<DeviceName>()
|
||||||
|
val socketDevices = linkedSetOf<DeviceName>()
|
||||||
|
scenes.forEach { scene ->
|
||||||
|
scene.triggers.forEach { trigger ->
|
||||||
|
when (trigger) {
|
||||||
|
is SceneTrigger.Temperature -> readingDevices += trigger.deviceName
|
||||||
|
is SceneTrigger.Humidity -> readingDevices += trigger.deviceName
|
||||||
|
is SceneTrigger.DeviceState -> socketDevices += trigger.deviceName
|
||||||
|
is SceneTrigger.Time -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.conditions.forEach { condition ->
|
||||||
|
when (condition) {
|
||||||
|
is SceneCondition.Temperature -> readingDevices += condition.deviceName
|
||||||
|
is SceneCondition.Humidity -> readingDevices += condition.deviceName
|
||||||
|
is SceneCondition.SocketPower -> socketDevices += condition.deviceName
|
||||||
|
is SceneCondition.TimeRange -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AutomationSnapshot(
|
||||||
|
observedAtEpochMillis = nowMillis(),
|
||||||
|
readings = readingDevices.associateWith { temperatureHumidityGateway.getReading(profileId, it) },
|
||||||
|
socketStatuses = socketDevices.associateWith { socketGateway.getStatus(profileId, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SceneTrigger.Time.automationDefinition(): String =
|
||||||
|
listOf(timeOfDay, daysOfWeek.sorted().joinToString(",")).joinToString("|")
|
||||||
+50
-3
@@ -1,10 +1,57 @@
|
|||||||
package com.flagship.abox.manager.automation.runtime
|
package com.flagship.abox.manager.automation.runtime
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SessionValidity
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
|
||||||
class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
@HiltWorker
|
||||||
override suspend fun doWork(): Result =
|
class AutomationPollWorker @AssistedInject constructor(
|
||||||
if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success()
|
@Assisted appContext: Context,
|
||||||
|
@Assisted workerParams: WorkerParameters,
|
||||||
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val sessionCoordinator: SessionCoordinator,
|
||||||
|
private val automationEngine: AutomationEngine,
|
||||||
|
private val automationScheduler: AutomationScheduler,
|
||||||
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val rawProfileId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID)
|
||||||
|
?: return Result.failure()
|
||||||
|
val profileId = ProviderProfileId(rawProfileId)
|
||||||
|
if (profileStore.activeProfile()?.id != profileId) return Result.success()
|
||||||
|
|
||||||
|
sessionCoordinator.bootstrap()
|
||||||
|
return when (val validity = sessionCoordinator.validateSession(profileId)) {
|
||||||
|
is DomainResult.Failure -> Result.retry()
|
||||||
|
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
|
||||||
|
automationScheduler.cancel(profileId)
|
||||||
|
Result.success()
|
||||||
|
} else {
|
||||||
|
val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
|
||||||
|
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
|
||||||
|
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
|
||||||
|
val triggerDefinition = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_DEFINITION)
|
||||||
|
val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L)
|
||||||
|
val expiresAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_EXPIRES_AT_EPOCH_MILLIS, -1L)
|
||||||
|
if (sceneId != null && triggerId != null && triggerDefinition != null && scheduledAt >= 0L && expiresAt >= scheduledAt) {
|
||||||
|
ScheduledAutomationOccurrence(sceneId, triggerId, key, triggerDefinition, scheduledAt, expiresAt)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
automationScheduler.reconcile(profileId)
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ import com.flagship.abox.manager.domain.ProviderProfileId
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.Scene
|
import com.flagship.abox.manager.domain.Scene
|
||||||
import com.flagship.abox.manager.domain.SceneRepository
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
import com.flagship.abox.manager.domain.SessionGateway
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
import com.flagship.abox.manager.domain.SessionValidity
|
import com.flagship.abox.manager.domain.SessionValidity
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ interface AutomationScheduleStore {
|
|||||||
|
|
||||||
class ProfileAutomationScheduler(
|
class ProfileAutomationScheduler(
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
private val sessionGateway: SessionGateway,
|
private val sessionCoordinator: SessionCoordinator,
|
||||||
private val sceneRepository: SceneRepository,
|
private val sceneRepository: SceneRepository,
|
||||||
private val scheduleStore: AutomationScheduleStore,
|
private val scheduleStore: AutomationScheduleStore,
|
||||||
) : AutomationScheduler {
|
) : AutomationScheduler {
|
||||||
@@ -36,7 +36,7 @@ class ProfileAutomationScheduler(
|
|||||||
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
|
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||||
val active = profileStore.activeProfile()
|
val active = profileStore.activeProfile()
|
||||||
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
|
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
|
||||||
return when (val validity = sessionGateway.validateSession(profileId)) {
|
return when (val validity = sessionCoordinator.validateSession(profileId)) {
|
||||||
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
|
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
|
||||||
when (val result = sceneRepository.observeScenes(profileId).first()) {
|
when (val result = sceneRepository.observeScenes(profileId).first()) {
|
||||||
is DomainResult.Failure -> result
|
is DomainResult.Failure -> result
|
||||||
|
|||||||
+71
-1
@@ -3,11 +3,17 @@ package com.flagship.abox.manager.automation.runtime
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.Scene
|
import com.flagship.abox.manager.domain.Scene
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
|
|
||||||
class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore {
|
class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore {
|
||||||
private val workManager = WorkManager.getInstance(context)
|
private val workManager = WorkManager.getInstance(context)
|
||||||
@@ -18,15 +24,79 @@ 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)
|
||||||
|
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.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, scene: Scene, trigger: SceneTrigger.Time, occurrenceKey: String) =
|
||||||
|
"automation-time:${profileId.value}:${scene.id.value}:${trigger.id.value}:$occurrenceKey"
|
||||||
|
private fun timeWorkTag(profileId: ProviderProfileId) = "automation-time:${profileId.value}"
|
||||||
|
|
||||||
companion object { const val KEY_PROFILE_ID = "profile_id" }
|
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
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)
|
||||||
|
val followingOccurrence = nextOccurrence(trigger, scheduledAt)
|
||||||
|
?.atZone(ZoneId.systemDefault())
|
||||||
|
?.toInstant()
|
||||||
|
?.minusMillis(1)
|
||||||
|
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
|
||||||
|
val occurrenceKey = "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}"
|
||||||
|
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, scene.id.value)
|
||||||
|
.putString(KEY_TRIGGER_ID, trigger.id.value)
|
||||||
|
.putString(KEY_OCCURRENCE_KEY, occurrenceKey)
|
||||||
|
.putString(KEY_TRIGGER_DEFINITION, trigger.automationDefinition())
|
||||||
|
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
|
||||||
|
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniqueWork(
|
||||||
|
timeWorkName(profileId, scene, trigger, occurrenceKey),
|
||||||
|
androidx.work.ExistingWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextOccurrence(trigger: SceneTrigger.Time, now: LocalDateTime): LocalDateTime? {
|
||||||
|
val time = runCatching { LocalTime.parse(trigger.timeOfDay) }.getOrNull() ?: return null
|
||||||
|
for (offset in 0..7) {
|
||||||
|
val date = now.toLocalDate().plusDays(offset.toLong())
|
||||||
|
val candidate = LocalDateTime.of(date, time)
|
||||||
|
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
|
||||||
|
if (dayMatches && candidate.isAfter(now)) return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_PROFILE_ID = "profile_id"
|
||||||
|
const val KEY_SCENE_ID = "scene_id"
|
||||||
|
const val KEY_TRIGGER_ID = "trigger_id"
|
||||||
|
const val KEY_OCCURRENCE_KEY = "occurrence_key"
|
||||||
|
const val KEY_TRIGGER_DEFINITION = "trigger_definition"
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.time.LocalDate
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
interface AutomationClock {
|
interface AutomationClock {
|
||||||
fun nowEpochMillis(): Long
|
fun nowEpochMillis(): Long
|
||||||
@@ -40,13 +41,40 @@ class SceneTriggerEvaluator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun evaluate(
|
||||||
|
trigger: SceneTrigger,
|
||||||
|
snapshot: AutomationSnapshot,
|
||||||
|
now: LocalDateTime,
|
||||||
|
wasMatched: Boolean = false,
|
||||||
|
): TriggerEvaluation = when (trigger) {
|
||||||
|
is SceneTrigger.Temperature -> threshold(
|
||||||
|
snapshot.readings[trigger.deviceName],
|
||||||
|
trigger.operator,
|
||||||
|
trigger.thresholdCelsius,
|
||||||
|
hysteresis = if (wasMatched) TEMPERATURE_HYSTERESIS else 0.0,
|
||||||
|
) { it.temperature } ?: TriggerEvaluation.NotMatched
|
||||||
|
is SceneTrigger.Humidity -> threshold(
|
||||||
|
snapshot.readings[trigger.deviceName],
|
||||||
|
trigger.operator,
|
||||||
|
trigger.thresholdPercent,
|
||||||
|
hysteresis = if (wasMatched) HUMIDITY_HYSTERESIS else 0.0,
|
||||||
|
) { it.humidity } ?: TriggerEvaluation.NotMatched
|
||||||
|
else -> evaluateOne(trigger, snapshot, now) ?: TriggerEvaluation.NotMatched
|
||||||
|
}
|
||||||
|
|
||||||
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
|
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
|
||||||
is SceneTrigger.Time -> {
|
is SceneTrigger.Time -> {
|
||||||
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
|
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
|
||||||
val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek
|
val scheduled = listOf(now.toLocalDate(), now.toLocalDate().minusDays(1))
|
||||||
if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) {
|
.map { date -> LocalDateTime.of(date, time) }
|
||||||
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
.firstOrNull { candidate ->
|
||||||
} else null
|
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
|
||||||
|
val latenessSeconds = Duration.between(candidate, now).seconds
|
||||||
|
dayMatches && latenessSeconds in 0..TIME_TRIGGER_GRACE_SECONDS
|
||||||
|
}
|
||||||
|
scheduled?.let { occurrence ->
|
||||||
|
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
|
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
|
||||||
it.temperature
|
it.temperature
|
||||||
@@ -69,6 +97,7 @@ class SceneTriggerEvaluator {
|
|||||||
result: DomainResult<TemperatureHumidityReading>?,
|
result: DomainResult<TemperatureHumidityReading>?,
|
||||||
operator: ComparisonOperator,
|
operator: ComparisonOperator,
|
||||||
threshold: Double,
|
threshold: Double,
|
||||||
|
hysteresis: Double = 0.0,
|
||||||
value: (TemperatureHumidityReading) -> String?,
|
value: (TemperatureHumidityReading) -> String?,
|
||||||
): TriggerEvaluation? {
|
): TriggerEvaluation? {
|
||||||
if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
|
if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
|
||||||
@@ -78,7 +107,7 @@ class SceneTriggerEvaluator {
|
|||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
val actual = value(result.value)?.toDoubleOrNull()
|
val actual = value(result.value)?.toDoubleOrNull()
|
||||||
?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
|
?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
|
||||||
if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null
|
if (compareWithHysteresis(actual, operator, threshold, hysteresis)) TriggerEvaluation.Matched("threshold") else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +120,26 @@ class SceneTriggerEvaluator {
|
|||||||
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
|
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun compareWithHysteresis(
|
||||||
|
actual: Double,
|
||||||
|
operator: ComparisonOperator,
|
||||||
|
threshold: Double,
|
||||||
|
hysteresis: Double,
|
||||||
|
): Boolean = when (operator) {
|
||||||
|
ComparisonOperator.GREATER_THAN -> actual > threshold - hysteresis
|
||||||
|
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold - hysteresis
|
||||||
|
ComparisonOperator.LESS_THAN -> actual < threshold + hysteresis
|
||||||
|
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + hysteresis
|
||||||
|
ComparisonOperator.EQUALS -> kotlin.math.abs(actual - threshold) <= maxOf(0.1, hysteresis)
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
|
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TEMPERATURE_HYSTERESIS = 0.5
|
||||||
|
const val HUMIDITY_HYSTERESIS = 2.0
|
||||||
|
const val TIME_TRIGGER_GRACE_SECONDS = 20L * 60L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SceneConditionEvaluator {
|
class SceneConditionEvaluator {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class DefaultSceneExecutor(
|
|||||||
private val socketGateway: SocketGateway,
|
private val socketGateway: SocketGateway,
|
||||||
private val infraredGateway: InfraredGateway,
|
private val infraredGateway: InfraredGateway,
|
||||||
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
|
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
|
||||||
|
private val notificationGateway: NotificationGateway? = null,
|
||||||
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
|
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
|
||||||
) : SceneExecutor {
|
) : SceneExecutor {
|
||||||
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
|
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
|
||||||
@@ -222,11 +223,15 @@ class DefaultSceneExecutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SceneAction.Notification -> {
|
is SceneAction.Notification -> {
|
||||||
|
if (notificationGateway == null) {
|
||||||
SceneActionExecutionResult(
|
SceneActionExecutionResult(
|
||||||
action = action,
|
action = action,
|
||||||
outcome = SceneActionOutcome.SUCCESS,
|
outcome = SceneActionOutcome.FAILURE,
|
||||||
error = null,
|
error = DomainError.OperationFailed(detail = "Notification gateway is unavailable"),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
toExecutionResult(action, notificationGateway.show(profileId, action.title, action.message))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
@@ -173,7 +174,23 @@ class SessionCoordinator(
|
|||||||
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
|
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
|
||||||
val profile = profileStore.activeProfile()
|
val profile = profileStore.activeProfile()
|
||||||
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||||
profile.gateway().validateSession(profile.id)
|
validateSession(profile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun validateSession(profileId: ProviderProfileId): DomainResult<SessionValidity> = storeOperation {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile?.id != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||||
|
val session = profile.gateway().observeSession(profileId).first()
|
||||||
|
if (session?.profileId != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||||
|
when (val validity = profile.gateway().validateSession(profileId)) {
|
||||||
|
is DomainResult.Failure -> validity
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
val stillActive = profileStore.activeProfile()?.id == profileId
|
||||||
|
val currentSession = profile.gateway().observeSession(profileId).first()
|
||||||
|
if (stillActive && currentSession?.profileId == profileId) validity
|
||||||
|
else DomainResult.Success(SessionValidity.INVALID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout(): DomainResult<Unit> = storeOperation {
|
suspend fun logout(): DomainResult<Unit> = storeOperation {
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-
|
|||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" }
|
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" }
|
||||||
|
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" }
|
||||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
|
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
||||||
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
||||||
androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" }
|
androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" }
|
||||||
androidx-window = { module = "androidx.window:window", version.ref = "window" }
|
androidx-window = { module = "androidx.window:window", version.ref = "window" }
|
||||||
|
|||||||
Reference in New Issue
Block a user