feat: persist infrared key bindings
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user