feat: add automation persistence foundation
This commit is contained in:
@@ -70,6 +70,7 @@ dependencies {
|
|||||||
implementation(project(":android:core:devicecatalog"))
|
implementation(project(":android:core:devicecatalog"))
|
||||||
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(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
implementation(project(":runtime:simulation"))
|
implementation(project(":runtime:simulation"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.kapt)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.flagship.abox.manager.automation"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":domain"))
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
kapt(libs.room.compiler)
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
kapt(libs.hilt.compiler)
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.room.testing)
|
||||||
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package com.flagship.abox.manager.automation
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.automation.data.ActivityEventEntity
|
||||||
|
import com.flagship.abox.manager.automation.data.AutomationDatabase
|
||||||
|
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
||||||
|
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEvent
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventId
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||||
|
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||||
|
import com.flagship.abox.manager.domain.ActivitySource
|
||||||
|
import com.flagship.abox.manager.domain.ActivityTarget
|
||||||
|
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AutomationDatabaseTest {
|
||||||
|
private lateinit var database: AutomationDatabase
|
||||||
|
private lateinit var activityRepository: RoomActivityRepository
|
||||||
|
private lateinit var runtimeRepository: RoomAutomationRuntimeRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AutomationDatabase::class.java).build()
|
||||||
|
activityRepository = RoomActivityRepository(database.automationDao())
|
||||||
|
runtimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = database.close()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activityEventsAreIsolatedByProfileAndPageByCursor() = runTest {
|
||||||
|
activityRepository.append(event("profile-a", "a", 10L))
|
||||||
|
activityRepository.append(event("profile-a", "b", 10L))
|
||||||
|
activityRepository.append(event("profile-b", "c", 10L))
|
||||||
|
|
||||||
|
val first = activityRepository.observeLatest(ProviderProfileId("profile-a"), 1).first()
|
||||||
|
val firstEvent = (first as DomainResult.Success).value.single()
|
||||||
|
val next = activityRepository.loadBefore(
|
||||||
|
ProviderProfileId("profile-a"),
|
||||||
|
ActivityEventCursor(firstEvent.occurredAtEpochMillis, firstEvent.id),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("b", firstEvent.id.value)
|
||||||
|
assertEquals(listOf("a"), (next as DomainResult.Success).value.map { it.id.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun occurrenceClaimIsAtomic() = runTest {
|
||||||
|
val first = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
|
||||||
|
val second = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
|
||||||
|
|
||||||
|
assertTrue((first as DomainResult.Success).value)
|
||||||
|
assertEquals(false, (second as DomainResult.Success).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun event(profile: String, id: String, time: Long) = ActivityEvent(
|
||||||
|
id = ActivityEventId(id),
|
||||||
|
profileId = ProviderProfileId(profile),
|
||||||
|
occurredAtEpochMillis = time,
|
||||||
|
kind = ActivityEventKind.SOCKET_COMMAND,
|
||||||
|
source = ActivitySource.USER,
|
||||||
|
outcome = ActivityOutcome.SUCCEEDED,
|
||||||
|
target = ActivityTarget(ActivityTargetType.DEVICE, id, "设备"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AutomationDao {
|
||||||
|
@Query("SELECT * FROM activity_events WHERE profileId = :profileId ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit")
|
||||||
|
fun observeLatest(profileId: String, limit: Int): Flow<List<ActivityEventEntity>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM activity_events WHERE profileId = :profileId AND " +
|
||||||
|
"(occurredAtEpochMillis < :beforeTime OR (occurredAtEpochMillis = :beforeTime AND id < :beforeId)) " +
|
||||||
|
"ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit",
|
||||||
|
)
|
||||||
|
suspend fun loadBefore(profileId: String, beforeTime: Long, beforeId: String, limit: Int): List<ActivityEventEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertEvent(event: ActivityEventEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false)
|
||||||
|
abstract class AutomationDatabase : RoomDatabase() {
|
||||||
|
abstract fun automationDao(): AutomationDao
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "activity_events",
|
||||||
|
primaryKeys = ["profileId", "id"],
|
||||||
|
indices = [Index(value = ["profileId", "occurredAtEpochMillis", "id"])],
|
||||||
|
)
|
||||||
|
data class ActivityEventEntity(
|
||||||
|
val profileId: String,
|
||||||
|
val id: String,
|
||||||
|
val occurredAtEpochMillis: Long,
|
||||||
|
val kind: String,
|
||||||
|
val source: String,
|
||||||
|
val outcome: String,
|
||||||
|
val targetType: String,
|
||||||
|
val targetStableId: String,
|
||||||
|
val targetDisplayName: String,
|
||||||
|
val errorCode: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "automation_occurrences",
|
||||||
|
primaryKeys = ["profileId", "sceneId", "triggerId", "occurrenceKey"],
|
||||||
|
)
|
||||||
|
data class AutomationOccurrenceEntity(
|
||||||
|
val profileId: String,
|
||||||
|
val sceneId: String,
|
||||||
|
val triggerId: String,
|
||||||
|
val occurrenceKey: String,
|
||||||
|
)
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEvent
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventId
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||||
|
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||||
|
import com.flagship.abox.manager.domain.ActivityRepository
|
||||||
|
import com.flagship.abox.manager.domain.ActivitySource
|
||||||
|
import com.flagship.abox.manager.domain.ActivityTarget
|
||||||
|
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class RoomActivityRepository(private val dao: AutomationDao) : ActivityRepository {
|
||||||
|
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> =
|
||||||
|
dao.observeLatest(profileId.value, limit.coerceAtLeast(0)).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
|
||||||
|
|
||||||
|
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int): DomainResult<List<ActivityEvent>> = try {
|
||||||
|
DomainResult.Success(dao.loadBefore(profileId.value, before.occurredAtEpochMillis, before.id.value, limit.coerceAtLeast(0)).map { it.toDomain() })
|
||||||
|
} catch (error: Exception) {
|
||||||
|
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = try {
|
||||||
|
dao.insertEvent(event.toEntity())
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ActivityEventEntity.toDomain() = ActivityEvent(
|
||||||
|
id = ActivityEventId(id),
|
||||||
|
profileId = ProviderProfileId(profileId),
|
||||||
|
occurredAtEpochMillis = occurredAtEpochMillis,
|
||||||
|
kind = ActivityEventKind.valueOf(kind),
|
||||||
|
source = ActivitySource.valueOf(source),
|
||||||
|
outcome = ActivityOutcome.valueOf(outcome),
|
||||||
|
target = ActivityTarget(ActivityTargetType.valueOf(targetType), targetStableId, targetDisplayName),
|
||||||
|
errorCode = errorCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ActivityEvent.toEntity() = ActivityEventEntity(
|
||||||
|
profileId = profileId.value,
|
||||||
|
id = id.value,
|
||||||
|
occurredAtEpochMillis = occurredAtEpochMillis,
|
||||||
|
kind = kind.name,
|
||||||
|
source = source.name,
|
||||||
|
outcome = outcome.name,
|
||||||
|
targetType = target.type.name,
|
||||||
|
targetStableId = target.stableId,
|
||||||
|
targetDisplayName = target.displayName,
|
||||||
|
errorCode = errorCode,
|
||||||
|
)
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.flagship.abox.manager.automation.data
|
||||||
|
|
||||||
|
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.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||||
|
|
||||||
|
data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean)
|
||||||
|
|
||||||
|
interface AutomationRuntimeRepository {
|
||||||
|
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
|
||||||
|
override suspend fun claimOccurrence(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
sceneId: SceneId,
|
||||||
|
triggerId: SceneTriggerId,
|
||||||
|
occurrenceKey: String,
|
||||||
|
): DomainResult<Boolean> = try {
|
||||||
|
val inserted = dao.claimOccurrence(
|
||||||
|
AutomationOccurrenceEntity(profileId.value, sceneId.value, triggerId.value, occurrenceKey),
|
||||||
|
)
|
||||||
|
DomainResult.Success(inserted != -1L)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.flagship.abox.manager.automation.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.flagship.abox.manager.automation.data.AutomationDatabase
|
||||||
|
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
|
||||||
|
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
||||||
|
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
||||||
|
import com.flagship.abox.manager.domain.ActivityRepository
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AutomationModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
|
||||||
|
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao())
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
||||||
|
}
|
||||||
@@ -35,4 +35,5 @@ include(":android:core:preferences")
|
|||||||
include(":android:core:devicecatalog")
|
include(":android:core:devicecatalog")
|
||||||
include(":android:core:session")
|
include(":android:core:session")
|
||||||
include(":android:feature:infrared")
|
include(":android:feature:infrared")
|
||||||
|
include(":android:feature:automation")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
Reference in New Issue
Block a user