feat: add automation persistence foundation
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user