diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a278a2c..13dab95 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(project(":android:core:devicecatalog")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) + implementation(project(":android:feature:automation")) implementation(project(":absdk-bridge")) implementation(project(":runtime:simulation")) debugImplementation(project(":testing:fakes")) diff --git a/android/feature/automation/build.gradle.kts b/android/feature/automation/build.gradle.kts new file mode 100644 index 0000000..925aff8 --- /dev/null +++ b/android/feature/automation/build.gradle.kts @@ -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) +} diff --git a/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt b/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt new file mode 100644 index 0000000..56d8e81 --- /dev/null +++ b/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt @@ -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, "设备"), + ) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt new file mode 100644 index 0000000..6fd478b --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt @@ -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> + + @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 + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertEvent(event: ActivityEventEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt new file mode 100644 index 0000000..3a8a677 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt @@ -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 +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt new file mode 100644 index 0000000..735ab7d --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt @@ -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, +) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt new file mode 100644 index 0000000..5e30a10 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt @@ -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>> = + 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> = 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 = 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, +) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt new file mode 100644 index 0000000..0506e92 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt @@ -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 +} + +class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository { + override suspend fun claimOccurrence( + profileId: ProviderProfileId, + sceneId: SceneId, + triggerId: SceneTriggerId, + occurrenceKey: String, + ): DomainResult = 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")) + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt new file mode 100644 index 0000000..7932c36 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt @@ -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()) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b31f7a6..a5f925f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,4 +35,5 @@ include(":android:core:preferences") include(":android:core:devicecatalog") include(":android:core:session") include(":android:feature:infrared") +include(":android:feature:automation") include(":android:app")