feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
10 changed files with 315 additions and 0 deletions
Showing only changes of commit 867096d7ef - Show all commits
+1
View File
@@ -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"))
@@ -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)
}
@@ -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, "设备"),
)
}
@@ -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
}
@@ -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
}
@@ -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,
)
@@ -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,
)
@@ -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"))
}
}
@@ -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())
}
+1
View File
@@ -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")