feat: persist infrared key bindings
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
+136
@@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -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<Context>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
</manifest>
|
||||||
+105
@@ -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<List<InfraredKeyEntity>>
|
||||||
|
|
||||||
|
@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<String>,
|
||||||
|
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<InfraredDeviceConfigurationEntity?>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
|
||||||
|
}
|
||||||
+16
@@ -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
|
||||||
|
}
|
||||||
+33
@@ -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,
|
||||||
|
)
|
||||||
+37
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+190
@@ -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<DomainResult<List<SavedInfraredKey>>> {
|
||||||
|
return dao.observeKeys(profileId.value, deviceName.value)
|
||||||
|
.map<List<InfraredKeyEntity>, DomainResult<List<SavedInfraredKey>>> { entities ->
|
||||||
|
DomainResult.Success(entities.map { it.toDomain() })
|
||||||
|
}
|
||||||
|
.catch { throwable ->
|
||||||
|
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> {
|
||||||
|
return dao.observeDeviceName(profileId.value)
|
||||||
|
.map<InfraredDeviceConfigurationEntity?, DomainResult<DeviceName?>> { entity ->
|
||||||
|
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
|
||||||
|
}
|
||||||
|
.catch { throwable ->
|
||||||
|
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
|
||||||
|
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<Unit> {
|
||||||
|
return try {
|
||||||
|
dao.insertKey(key.toEntity())
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transitionStatus(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
targetStatus: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
lastErrorDetail: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
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<Unit> {
|
||||||
|
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<Unit> {
|
||||||
|
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<Int> {
|
||||||
|
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<Unit> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ org.gradle.configuration-cache=false
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
android.overridePathCheck=true
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ espresso = "3.6.1"
|
|||||||
[libraries]
|
[libraries]
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
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-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-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
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" }
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
|
||||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
@@ -32,6 +33,8 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
|
|||||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", 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-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
room-ktx = { module = "androidx.room:room-ktx", 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" }
|
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ include(":domain")
|
|||||||
include(":absdk-bridge")
|
include(":absdk-bridge")
|
||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
|
include(":android:feature:infrared")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
Reference in New Issue
Block a user