feat(infrared): add infrared control, learning, and virtual remote module (#9)

### 概述 / Summary
完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。

### 核心变更 / Key Changes
1. **领域模型与用例 (`:domain`)**:
   - 实现按键学习流程:`LearnInfraredKey`、`ManageInfraredKey`、`InfraredKeyFactory`;
   - 增加虚拟遥控器模型与用例:`CreateVirtualRemote`、`LearnVirtualRemoteKey`、`SendVirtualRemoteAction`、`AcCodeGenerator` 与预置码库 `PresetCodeLibrary`。
2. **数据持久化与仓储 (`:android:feature:infrared`)**:
   - 引入 Room 数据库 `InfraredDatabase` 及 DAO,提供 `RoomInfraredKeyRepository` 与 `RoomVirtualRemoteRepository`;
   - 支持多配置隔离(`ProviderProfileId`)、按键状态管理(`LEARNING` / `READY` / `FAILED`)。
3. **UI 与 ViewModel (`:android:feature:infrared`)**:
   - `InfraredControlScreen`:红外学习与按键调试列表;
   - `RemotePanelScreen` / `AcRemotePanel` / `TvRemotePanel`:空调/电视专属可视化遥控面板;
   - `InfraredHubScreen`:红外中枢与多遥控器管理入口;
   - 在认证后导航栈中将 `DeviceListScreen` 的红外入口无缝对接到 `InfraredHubRoute`。
4. **测试与文档**:
   - 补齐用例、Fakes、Repository、ViewModel 以及 Compose UI 自动化测试;
   - 补充红外控制设计规范与真机硬件验收清单。

Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
Co-authored-by: KremeCN <3265494849@qq.com>
This commit is contained in:
2026-08-25 15:00:06 +08:00
committed by KremeCN
co-authored by KremeCN
parent 249721be33
commit 0c783ca2f4
78 changed files with 12785 additions and 15 deletions
@@ -17,10 +17,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.reflect.KClass
class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1")
private val irDevice = DeviceName("ir-room")
@Test
fun socketControlReturnsConfirmedStatus() = runTest {
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
}
@Test
fun infraredSendForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
val errorMappings = mapOf<String, KClass<out DomainError>>(
"20201" to DomainError.InvalidRequest::class,
"20202" to DomainError.InvalidRequest::class,
"20203" to DomainError.DeviceNotFound::class,
"20204" to DomainError.OperationFailed::class,
"20205" to DomainError.OperationFailed::class,
"20206" to DomainError.DeviceOffline::class,
)
for ((code, expectedClass) in errorMappings) {
val client = ScriptedAbsdkClient().apply {
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
assertEquals(code, sendResult.error.externalCode)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
assertEquals(code, learnResult.error.externalCode)
}
}
@Test
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
val client = ScriptedAbsdkClient().apply {
sendResponse = null
learnResponse = null
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue(sendResult is DomainResult.Failure)
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue(learnResult is DomainResult.Failure)
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
}
@Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
assertEquals(1, client.maxConcurrentCalls.get())
}
}
@Test
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
withTimeout(5_000) {
val profile = ProviderProfileId("abox")
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
)
}
val bridge = AbsdkBridge(client, Dispatchers.IO)
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
client.onCall = {
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
Thread.sleep(75)
}
}
val irCall = async {
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
}
val tempCall = async {
bridge.getReading(profile, DeviceName("th-1"))
}
delay(10)
irCall.await()
tempCall.await()
assertEquals(1, client.maxConcurrentCalls.get())
}
}
}