From ff85b3eb45ef068673d00ef414ebe9be3a2fed66 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 10:58:49 +0800 Subject: [PATCH] feat: persist infrared key bindings --- android/feature/infrared/build.gradle.kts | 76 +++++++ .../1.json | 136 +++++++++++++ .../local/RoomInfraredKeyRepositoryTest.kt | 175 ++++++++++++++++ .../infrared/src/main/AndroidManifest.xml | 3 + .../infrared/data/local/InfraredDao.kt | 105 ++++++++++ .../infrared/data/local/InfraredDatabase.kt | 16 ++ .../infrared/data/local/InfraredEntities.kt | 33 +++ .../infrared/data/local/InfraredMappers.kt | 37 ++++ .../data/local/RoomInfraredKeyRepository.kt | 190 ++++++++++++++++++ gradle.properties | 1 + gradle/libs.versions.toml | 3 +- settings.gradle.kts | 1 + 12 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 android/feature/infrared/build.gradle.kts create mode 100644 android/feature/infrared/schemas/com.flagship.abox.manager.infrared.data.local.InfraredDatabase/1.json create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt create mode 100644 android/feature/infrared/src/main/AndroidManifest.xml create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMappers.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt diff --git a/android/feature/infrared/build.gradle.kts b/android/feature/infrared/build.gradle.kts new file mode 100644 index 0000000..d9228d2 --- /dev/null +++ b/android/feature/infrared/build.gradle.kts @@ -0,0 +1,76 @@ +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.infrared" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +kapt { + correctErrorTypes = true + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } +} + +dependencies { + implementation(project(":domain")) + implementation(project(":android:core:designsystem")) + + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + + implementation(libs.datastore.preferences) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(project(":testing:fakes")) + + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) + + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/android/feature/infrared/schemas/com.flagship.abox.manager.infrared.data.local.InfraredDatabase/1.json b/android/feature/infrared/schemas/com.flagship.abox.manager.infrared.data.local.InfraredDatabase/1.json new file mode 100644 index 0000000..c9bb24d --- /dev/null +++ b/android/feature/infrared/schemas/com.flagship.abox.manager.infrared.data.local.InfraredDatabase/1.json @@ -0,0 +1,136 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "be673e12eb4c5275aaa6ef6fc189a861", + "entities": [ + { + "tableName": "infrared_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sdkKey` TEXT NOT NULL, `status` TEXT NOT NULL, `lastErrorCode` TEXT, `lastErrorDetail` TEXT, `createdAtEpochMillis` INTEGER NOT NULL, `updatedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "deviceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sdkKey", + "columnName": "sdkKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastErrorCode", + "columnName": "lastErrorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastErrorDetail", + "columnName": "lastErrorDetail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtEpochMillis", + "columnName": "createdAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtEpochMillis", + "columnName": "updatedAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_infrared_keys_profileId_deviceName", + "unique": false, + "columnNames": [ + "profileId", + "deviceName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName` ON `${TABLE_NAME}` (`profileId`, `deviceName`)" + }, + { + "name": "index_infrared_keys_profileId_deviceName_sdkKey", + "unique": true, + "columnNames": [ + "profileId", + "deviceName", + "sdkKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName_sdkKey` ON `${TABLE_NAME}` (`profileId`, `deviceName`, `sdkKey`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "infrared_device_configuration", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, PRIMARY KEY(`profileId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "deviceName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be673e12eb4c5275aaa6ef6fc189a861')" + ] + } +} \ No newline at end of file diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt new file mode 100644 index 0000000..2212e77 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt @@ -0,0 +1,175 @@ +package com.flagship.abox.manager.infrared.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomInfraredKeyRepositoryTest { + + private lateinit var database: InfraredDatabase + private lateinit var repository: RoomInfraredKeyRepository + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, InfraredDatabase::class.java) + .allowMainThreadQueries() + .build() + repository = RoomInfraredKeyRepository(database.infraredDao()) + } + + @After + fun teardown() { + database.close() + } + + @Test + fun observesOnlyRequestedProfileAndDevice() = runTest { + repository.insert(testKey(profile = "one", device = "ir-1", id = "a", sdkKey = "key_a")) + repository.insert(testKey(profile = "one", device = "ir-2", id = "b", sdkKey = "key_b")) + repository.insert(testKey(profile = "two", device = "ir-1", id = "c", sdkKey = "key_c")) + + val result = repository.observeKeys( + ProviderProfileId("one"), + DeviceName("ir-1"), + ).first() as DomainResult.Success + + assertEquals(listOf(InfraredKeyId("a")), result.value.map { it.id }) + } + + @Test + fun rejectsDuplicateSdkBinding() = runTest { + val first = repository.insert(testKey(id = "a", sdkKey = "app_ir_power")) + assertTrue(first is DomainResult.Success) + + val duplicate = repository.insert(testKey(id = "b", sdkKey = "app_ir_power")) + assertTrue(duplicate is DomainResult.Failure) + } + + @Test + fun transitionsStatusCorrectly() = runTest { + val key = testKey(id = "a", status = InfraredKeyStatus.LEARNING) + repository.insert(key) + + val transitionResult = repository.transitionStatus( + ref = InfraredKeyRef(key.profileId, key.id), + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + updatedAtEpochMillis = 2_000L, + ) + assertTrue(transitionResult is DomainResult.Success) + + val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, fetched.status) + assertEquals(2_000L, fetched.updatedAtEpochMillis) + } + + @Test + fun renamesKeyWithoutChangingSdkKey() = runTest { + val key = testKey(id = "a", displayName = "Old Name", sdkKey = "app_ir_power") + repository.insert(key) + + val renameResult = repository.rename( + ref = InfraredKeyRef(key.profileId, key.id), + displayName = "New Name", + updatedAtEpochMillis = 3_000L, + ) + assertTrue(renameResult is DomainResult.Success) + + val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!! + assertEquals("New Name", fetched.displayName) + assertEquals("app_ir_power", fetched.sdkKey) + assertEquals(3_000L, fetched.updatedAtEpochMillis) + } + + @Test + fun deleteRejectsLearningKeyAndRemovesReadyKey() = runTest { + val learningKey = testKey(id = "learning", status = InfraredKeyStatus.LEARNING) + val readyKey = testKey(id = "ready", sdkKey = "ready_sdk_key", status = InfraredKeyStatus.READY) + repository.insert(learningKey) + repository.insert(readyKey) + + val deleteLearning = repository.deleteIfNotLearning(InfraredKeyRef(learningKey.profileId, learningKey.id)) + assertTrue(deleteLearning is DomainResult.Failure) + + val deleteReady = repository.deleteIfNotLearning(InfraredKeyRef(readyKey.profileId, readyKey.id)) + assertTrue(deleteReady is DomainResult.Success) + + val fetchedReady = (repository.getKey(InfraredKeyRef(readyKey.profileId, readyKey.id)) as DomainResult.Success).value + assertNull(fetchedReady) + } + + @Test + fun failsInterruptedLearningKeys() = runTest { + repository.insert(testKey(id = "k1", sdkKey = "k1", status = InfraredKeyStatus.LEARNING)) + repository.insert(testKey(id = "k2", sdkKey = "k2", status = InfraredKeyStatus.LEARNING)) + repository.insert(testKey(id = "k3", sdkKey = "k3", status = InfraredKeyStatus.READY)) + + val countResult = repository.failInterruptedLearning(ProviderProfileId("profile1"), 5_000L) as DomainResult.Success + assertEquals(2, countResult.value) + + val k1 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.FAILED, k1.status) + assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, k1.lastErrorDetail) + assertNull(k1.lastErrorCode) + assertEquals(5_000L, k1.updatedAtEpochMillis) + + val k3 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k3"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, k3.status) + } + + @Test + fun persistsAndObservesDeviceName() = runTest { + val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success + assertNull(initial.value) + + val setResult = repository.setDeviceName(ProviderProfileId("profile1"), DeviceName("ir_main")) + assertTrue(setResult is DomainResult.Success) + + val observed = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success + assertEquals(DeviceName("ir_main"), observed.value) + } + + private fun testKey( + profile: String = "profile1", + device: String = "ir-1", + id: String = "key-1", + displayName: String = "Power", + sdkKey: String = "app_ir_key1", + status: InfraredKeyStatus = InfraredKeyStatus.LEARNING, + ): SavedInfraredKey { + return SavedInfraredKey( + id = InfraredKeyId(id), + profileId = ProviderProfileId(profile), + deviceName = DeviceName(device), + displayName = displayName, + sdkKey = sdkKey, + status = status, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 1_000L, + updatedAtEpochMillis = 1_000L, + ) + } +} diff --git a/android/feature/infrared/src/main/AndroidManifest.xml b/android/feature/infrared/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/android/feature/infrared/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt new file mode 100644 index 0000000..6a1f240 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt @@ -0,0 +1,105 @@ +package com.flagship.abox.manager.infrared.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface InfraredDao { + @Query( + """ + SELECT * FROM infrared_keys + WHERE profileId = :profileId AND deviceName = :deviceName + ORDER BY createdAtEpochMillis ASC + """, + ) + fun observeKeys(profileId: String, deviceName: String): Flow> + + @Query( + """ + SELECT * FROM infrared_keys + WHERE profileId = :profileId AND id = :id + LIMIT 1 + """, + ) + suspend fun getKey(profileId: String, id: String): InfraredKeyEntity? + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertKey(entity: InfraredKeyEntity) + + @Query( + """ + UPDATE infrared_keys + SET status = :targetStatus, + lastErrorCode = :lastErrorCode, + lastErrorDetail = :lastErrorDetail, + updatedAtEpochMillis = :updatedAtEpochMillis + WHERE profileId = :profileId + AND id = :id + AND status IN (:expectedStatuses) + """, + ) + suspend fun transitionStatus( + profileId: String, + id: String, + expectedStatuses: List, + targetStatus: String, + lastErrorCode: String?, + lastErrorDetail: String?, + updatedAtEpochMillis: Long, + ): Int + + @Query( + """ + UPDATE infrared_keys + SET displayName = :displayName, + updatedAtEpochMillis = :updatedAtEpochMillis + WHERE profileId = :profileId AND id = :id + """, + ) + suspend fun renameKey( + profileId: String, + id: String, + displayName: String, + updatedAtEpochMillis: Long, + ): Int + + @Query( + """ + DELETE FROM infrared_keys + WHERE profileId = :profileId AND id = :id AND status != 'LEARNING' + """, + ) + suspend fun deleteKeyIfNotLearning(profileId: String, id: String): Int + + @Query( + """ + UPDATE infrared_keys + SET status = :targetStatus, + lastErrorCode = NULL, + lastErrorDetail = :errorDetail, + updatedAtEpochMillis = :updatedAtEpochMillis + WHERE profileId = :profileId AND status = 'LEARNING' + """, + ) + suspend fun failInterruptedLearning( + profileId: String, + targetStatus: String, + errorDetail: String, + updatedAtEpochMillis: Long, + ): Int + + @Query( + """ + SELECT * FROM infrared_device_configuration + WHERE profileId = :profileId + LIMIT 1 + """, + ) + fun observeDeviceName(profileId: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt new file mode 100644 index 0000000..c8fb736 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.infrared.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ + InfraredKeyEntity::class, + InfraredDeviceConfigurationEntity::class, + ], + version = 1, + exportSchema = true, +) +abstract class InfraredDatabase : RoomDatabase() { + abstract fun infraredDao(): InfraredDao +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt new file mode 100644 index 0000000..63b1fb9 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt @@ -0,0 +1,33 @@ +package com.flagship.abox.manager.infrared.data.local + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "infrared_keys", + indices = [ + Index(value = ["profileId", "deviceName"]), + Index(value = ["profileId", "deviceName", "sdkKey"], unique = true), + ], +) +data class InfraredKeyEntity( + @PrimaryKey + val id: String, + val profileId: String, + val deviceName: String, + val displayName: String, + val sdkKey: String, + val status: String, + val lastErrorCode: String?, + val lastErrorDetail: String?, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) + +@Entity(tableName = "infrared_device_configuration") +data class InfraredDeviceConfigurationEntity( + @PrimaryKey + val profileId: String, + val deviceName: String, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMappers.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMappers.kt new file mode 100644 index 0000000..7da9a45 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMappers.kt @@ -0,0 +1,37 @@ +package com.flagship.abox.manager.infrared.data.local + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey + +fun InfraredKeyEntity.toDomain(): SavedInfraredKey { + return SavedInfraredKey( + id = InfraredKeyId(id), + profileId = ProviderProfileId(profileId), + deviceName = DeviceName(deviceName), + displayName = displayName, + sdkKey = sdkKey, + status = InfraredKeyStatus.valueOf(status), + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, + createdAtEpochMillis = createdAtEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, + ) +} + +fun SavedInfraredKey.toEntity(): InfraredKeyEntity { + return InfraredKeyEntity( + id = id.value, + profileId = profileId.value, + deviceName = deviceName.value, + displayName = displayName, + sdkKey = sdkKey, + status = status.name, + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, + createdAtEpochMillis = createdAtEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt new file mode 100644 index 0000000..79eb56b --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt @@ -0,0 +1,190 @@ +package com.flagship.abox.manager.infrared.data.local + +import android.database.sqlite.SQLiteConstraintException +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.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +class RoomInfraredKeyRepository( + private val dao: InfraredDao, +) : InfraredKeyRepository, InfraredDeviceConfigurationRepository { + + override fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> { + return dao.observeKeys(profileId.value, deviceName.value) + .map, DomainResult>> { entities -> + DomainResult.Success(entities.map { it.toDomain() }) + } + .catch { throwable -> + emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) + } + } + + override fun observeDeviceName(profileId: ProviderProfileId): Flow> { + return dao.observeDeviceName(profileId.value) + .map> { entity -> + DomainResult.Success(entity?.deviceName?.let { DeviceName(it) }) + } + .catch { throwable -> + emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) + } + } + + override suspend fun getKey(ref: InfraredKeyRef): DomainResult { + return try { + val entity = dao.getKey(ref.profileId.value, ref.keyId.value) + DomainResult.Success(entity?.toDomain()) + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + override suspend fun insert(key: SavedInfraredKey): DomainResult { + return try { + dao.insertKey(key.toEntity()) + DomainResult.Success(Unit) + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + override suspend fun transitionStatus( + ref: InfraredKeyRef, + expectedStatuses: Set, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + lastErrorDetail: String?, + updatedAtEpochMillis: Long, + ): DomainResult { + return try { + val updated = dao.transitionStatus( + profileId = ref.profileId.value, + id = ref.keyId.value, + expectedStatuses = expectedStatuses.map { it.name }, + targetStatus = targetStatus.name, + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + if (updated > 0) { + DomainResult.Success(Unit) + } else { + val existing = dao.getKey(ref.profileId.value, ref.keyId.value) + if (existing == null) { + DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found")) + } else { + DomainResult.Failure( + DomainError.InvalidRequest( + detail = "Cannot transition key from ${existing.status} to ${targetStatus.name}", + ), + ) + } + } + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + override suspend fun rename( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult { + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Display name cannot be blank")) + } + return try { + val updated = dao.renameKey( + profileId = ref.profileId.value, + id = ref.keyId.value, + displayName = trimmed, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + if (updated > 0) { + DomainResult.Success(Unit) + } else { + DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found")) + } + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult { + return try { + val deleted = dao.deleteKeyIfNotLearning(ref.profileId.value, ref.keyId.value) + if (deleted > 0) { + DomainResult.Success(Unit) + } else { + val existing = dao.getKey(ref.profileId.value, ref.keyId.value) + if (existing == null) { + DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found")) + } else { + DomainResult.Failure( + DomainError.InvalidRequest(detail = "Cannot delete key while learning is in progress"), + ) + } + } + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + override suspend fun failInterruptedLearning( + profileId: ProviderProfileId, + updatedAtEpochMillis: Long, + ): DomainResult { + return try { + val count = dao.failInterruptedLearning( + profileId = profileId.value, + targetStatus = InfraredKeyStatus.FAILED.name, + errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + DomainResult.Success(count) + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + override suspend fun setDeviceName( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + val trimmed = deviceName.value.trim() + if (trimmed.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name cannot be blank")) + } + return try { + dao.setDeviceName( + InfraredDeviceConfigurationEntity( + profileId = profileId.value, + deviceName = trimmed, + ), + ) + DomainResult.Success(Unit) + } catch (throwable: Throwable) { + DomainResult.Failure(mapThrowableToDomainError(throwable)) + } + } + + private fun mapThrowableToDomainError(throwable: Throwable): DomainError { + return when (throwable) { + is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation") + else -> DomainError.Unknown(detail = "Database operation failed") + } + } +} diff --git a/gradle.properties b/gradle.properties index badfccb..640f2f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,4 @@ org.gradle.configuration-cache=false android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official +android.overridePathCheck=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ad4e45..c03a4e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,6 @@ espresso = "3.6.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } @@ -35,6 +34,8 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } junit = { module = "junit:junit", version.ref = "junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b9cad8..3e34f95 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,4 +28,5 @@ include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") include(":android:core:session") +include(":android:feature:infrared") include(":android:app")