From d763d5dea7a4f62c76fbe9f63f43d63a3a438f50 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 00:16:14 +0800 Subject: [PATCH 01/41] docs: design infrared learning and sending --- .../2026-08-24-infrared-control-design.md | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-infrared-control-design.md diff --git a/docs/superpowers/specs/2026-08-24-infrared-control-design.md b/docs/superpowers/specs/2026-08-24-infrared-control-design.md new file mode 100644 index 0000000..fde4e17 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-infrared-control-design.md @@ -0,0 +1,606 @@ +# 红外学习与发射控制设计 + +## 1. 背景与目标 + +本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环: + +1. 在登录成功后的现有页面提供一个临时入口; +2. 手动配置并记住一个 ABSDK 红外设备名; +3. 创建并命名多个红外按键; +4. 通过 ABSDK 发起红外学习; +5. 保存可重复使用的按键绑定; +6. 通过 ABSDK 发射已学习按键; +7. 支持 Fake 和真实 ABSDK 调试模式; +8. 为后续生活场景保留稳定按键引用。 + +首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。 + +## 2. 已确认的 ABSDK 能力边界 + +ABSDK 0630 提供以下红外公开方法: + +```java +ABRet sendIr(String irDevName, String irKey) +ABRet studyIrByIrDevName(String irDevName, String irKey) +ABRet downloadIrCodes(List irInfos) +``` + +现有领域层已将其隔离为 `InfraredGateway`: + +```kotlin +interface InfraredGateway { + suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult + + suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult + + suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> +} +``` + +本设计必须遵守以下事实: + +- 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值; +- ABSDK 没有公开停止学习方法; +- ABSDK 没有公开获取学习结果或读取已学习原始码的方法; +- `downloadIrCodes()` 是把调用方已经拥有的红外码写入主机,不是读取学习结果; +- ABSDK 没有设备扫描或设备列表接口; +- 红外方法按 `irDevName + irKey` 寻址; +- SDK 调用为同步阻塞调用,现有 Bridge 在 `Dispatchers.IO` 上通过全局 `Mutex` 串行执行; +- SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。 + +因此首版保存的是本地按键绑定,而不是原始红外波形或码值: + +```text +ProviderProfileId + DeviceName + sdkKey + displayName + learning status +``` + +真实硬件闭环成立的判据是: + +```text +learnKey(deviceName, sdkKey) 成功 + ↓ +sendKey(deviceName, sdkKey) + ↓ +实体被控设备产生正确响应 +``` + +## 3. 范围 + +### 3.1 本阶段包含 + +- 红外功能的领域模型和本地持久化端口; +- Room 数据库、DAO 和 Repository; +- 学习、重试、发射、删除和恢复应用用例; +- 红外 ViewModel 和简单 Compose 页面; +- 登录成功页面的临时红外入口; +- 手动输入并记住一个 `irDevName`; +- 单红外控制器、多按键; +- Fake/Real ABSDK 显式调试模式; +- 自动化测试; +- 真机验收清单; +- 面向未来生活场景的稳定按键引用。 + +### 3.2 本阶段不包含 + +- 最终 UI 视觉规范或复杂遥控器布局; +- 多个遥控器管理界面; +- ABSDK 不存在的设备扫描; +- 原始红外码读取或持久化; +- 红外码库导入 UI; +- 硬件停止学习; +- 完整生活场景模型、规则引擎或场景执行器; +- Release 中默认启用真实 ABSDK; +- 绕过或隐藏厂商明文 HTTP 风险; +- 删除 ABox 服务端已学习按键。 + +## 4. 架构 + +采用独立应用编排层,保持现有 `InfraredGateway` 作为纯硬件能力端口: + +```text +登录成功页面 + └─ 临时入口:红外控制 + ↓ +InfraredControlScreen + ↓ +InfraredControlViewModel + ↓ +应用用例 + ├─ LearnInfraredKey + ├─ RetryInfraredKeyLearning + ├─ SendInfraredKey + ├─ DeleteInfraredKey + ├─ RecoverInterruptedInfraredLearning + └─ ObserveInfraredKeys + ↙ ↘ +InfraredKeyRepository InfraredGateway + ↓ ├─ FakeInfraredGateway +RoomInfraredKeyRepository └─ AbsdkBridge + ↓ + ABSDK 0630 +``` + +### 4.1 边界原则 + +- `InfraredGateway` 只负责学习、发射和码库下载,不负责 Room、页面状态或遥控器管理; +- 不修改 `learnKey()` 返回类型,不虚构 ABSDK 原始码结果; +- 不把 `ABSDK`、`ABRet`、`IrInfo` 或原始 Map 暴露给上层; +- ViewModel 不直接编排数据库和硬件之间的一致性,统一通过应用用例; +- Room Entity 不作为领域或 UI 公共模型; +- Fake 和真实 Bridge 必须共用相同的领域接口和用例; +- 所有真实 Gateway 接口必须指向同一个进程级 `AbsdkBridge` 实例,保持全局会话和互斥锁一致。 + +## 5. 数据模型 + +### 5.1 稳定标识 + +```kotlin +@JvmInline +value class InfraredKeyId(val value: String) + +data class InfraredKeyRef( + val profileId: ProviderProfileId, + val keyId: InfraredKeyId, +) +``` + +`InfraredKeyRef` 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。 + +### 5.2 学习状态 + +```kotlin +enum class InfraredKeyStatus { + LEARNING, + READY, + FAILED, +} +``` + +状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。 + +### 5.3 领域模型 + +```kotlin +data class SavedInfraredKey( + val id: InfraredKeyId, + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val displayName: String, + val sdkKey: String, + val status: InfraredKeyStatus, + val lastErrorCode: String?, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) +``` + +字段约束: + +- `id` 在 App 内稳定,供页面、删除和未来生活场景引用; +- `profileId` 用于不同登录配置的数据隔离; +- `deviceName` 是传给 ABSDK 的真实红外设备名; +- `displayName` 是用户可见名称,可以修改; +- `sdkKey` 是传给 ABSDK 的稳定 Key,学习后不随显示名改变; +- `lastErrorCode` 仅保存脱敏厂商错误码,不保存密码、Token、原始 Map 或红外载荷; +- 时间字段由应用层时钟提供,测试中使用可控时钟。 + +### 5.4 SDK Key + +App 自动生成 SDK Key,不允许用户直接编辑。格式应: + +- 仅包含安全 ASCII 字符; +- 在同一 Provider 和红外设备下唯一; +- 重试学习时复用; +- 修改显示名时保持不变; +- 不直接使用中文、空格或特殊字符。 + +建议格式: + +```text +app_ir_ +``` + +实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。 + +## 6. 本地持久化 + +采用 Room。Repository 边界至少支持: + +```kotlin +interface InfraredKeyRepository { + fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> + + suspend fun getKey( + ref: InfraredKeyRef, + ): DomainResult + + suspend fun insertLearningKey( + key: SavedInfraredKey, + ): DomainResult + + suspend fun updateStatus( + ref: InfraredKeyRef, + status: InfraredKeyStatus, + lastErrorCode: String?, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun renameKey( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun deleteKey( + ref: InfraredKeyRef, + ): DomainResult + + suspend fun findInterruptedLearning( + profileId: ProviderProfileId, + ): DomainResult> +} +``` + +具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。 + +### 6.1 数据库约束 + +- 主键:`InfraredKeyId`; +- 索引:`ProviderProfileId + DeviceName`; +- 唯一约束:`ProviderProfileId + DeviceName + sdkKey`; +- 查询和删除必须包含 Provider 隔离; +- 不保存原始红外码; +- 不把 Room Entity 暴露给领域层; +- 首版预留 schema version 和迁移测试基础。 + +### 6.2 单设备策略 + +页面记住一个当前 `irDevName`,并只展示该设备下的按键。切换当前设备名不会迁移或重写旧按键;旧按键仍绑定学习时使用的设备名。 + +设备名可以保存到 Room 配置表或 DataStore。若采用 DataStore,只用于当前设备名和 debug 模式等轻量配置,不承载红外按键集合。 + +## 7. 学习流程 + +`LearnInfraredKey` 的流程: + +1. 验证用户已登录且存在有效 `ProviderProfileId`; +2. 验证 `deviceName` 和 `displayName` 非空; +3. 生成稳定 `InfraredKeyId` 和 `sdkKey`; +4. 先写入状态为 `LEARNING` 的记录; +5. 在应用级协程作用域调用 `InfraredGateway.learnKey()`; +6. 成功时更新为 `READY` 并清空错误码; +7. 失败时更新为 `FAILED` 并保存脱敏错误码; +8. 如果状态更新失败,记录可恢复的本地数据错误,但不重新生成 SDK Key; +9. 页面通过 Room Flow 观察最终状态。 + +### 7.1 重试 + +重试必须: + +- 使用原 `InfraredKeyId`; +- 使用原 `sdkKey`; +- 先将状态更新为 `LEARNING`; +- 再调用 ABSDK; +- 不创建重复按键记录。 + +### 7.2 页面离开 + +学习调用开始后,页面可以退出。任务继续在应用级作用域等待 SDK 返回,并更新 Room。ViewModel 销毁不应直接取消已经提交的学习编排。 + +这不是后台系统任务保证:如果进程被系统杀死,底层同步网络请求及设备状态无法可靠恢复。 + +### 7.3 进程恢复 + +App 启动或登录恢复后,查找遗留的 `LEARNING` 记录: + +```text +LEARNING → FAILED +错误提示:上次学习结果未确认,请重新学习 +``` + +不得自动标记为 `READY`,也不得自动发射来试探设备状态。 + +### 7.4 取消语义 + +ABSDK 没有停止学习 API。首版 UI 不提供或宣称“停止硬件学习”。用户可以离开页面,但学习调用仍等待成功、失败或 SDK 超时。 + +## 8. 发射流程 + +`SendInfraredKey` 的流程: + +1. 使用 `InfraredKeyRef` 查询本地记录; +2. 记录不存在时返回领域错误; +3. 仅允许 `READY` 状态发射; +4. 取得记录中的 `profileId + deviceName + sdkKey`; +5. 调用 `InfraredGateway.sendKey()`; +6. 成功时向 UI 返回一次性成功事件; +7. 失败时保留按键 `READY` 状态,仅报告本次错误; +8. 不自动重试,避免重复操作电视、空调等实体设备。 + +未来生活场景通过稳定 `InfraredKeyRef` 调用同一发送用例或薄执行器: + +```kotlin +interface InfraredActionExecutor { + suspend fun execute(ref: InfraredKeyRef): DomainResult +} +``` + +本阶段只定义或保证稳定引用边界,不实现 Scene 模型、规则引擎或场景执行顺序。 + +## 9. 删除与重命名 + +### 9.1 删除 + +- `FAILED` 和 `READY` 记录可以删除; +- `LEARNING` 中不允许删除,避免后台结果写入已不存在的记录; +- 删除只移除本地引用; +- ABSDK 没有删除服务端红外 Key 的公开接口; +- UI 必须提示“ABox 服务端可能仍保留该按键”; +- 删除后未来场景引用解析失败,不再发射。 + +### 9.2 重命名 + +- 只修改 `displayName`; +- 不修改 `id`、`deviceName` 或 `sdkKey`; +- 重命名后学习和发射能力保持不变; +- 场景引用保持有效。 + +## 10. Android 前端 + +### 10.1 临时入口 + +在当前登录成功后的页面添加“红外控制”按钮,导航到独立 `InfraredControlScreen`。未来正式信息架构完成后只迁移入口。 + +### 10.2 页面内容 + +首版页面只包含: + +- 返回按钮和标题; +- 当前数据源标识:Fake 或真实 ABox; +- 红外控制器设备名输入和保存; +- 已学习按键列表; +- 学习新按键按钮; +- 按键状态; +- READY 按键的发射按钮; +- FAILED 按键的重试按钮; +- 删除入口和确认提示; +- 基础错误反馈。 + +不加入复杂遥控器模板、图标系统、网格布局、品牌色或动画规范。 + +### 10.3 设备名 + +- ABSDK 没有设备扫描能力; +- 首次进入时手动填写 `irDevName`; +- 保存后本地记住; +- 没有设备名时禁止学习; +- 修改设备名前提示旧按键仍绑定旧设备; +- 列表只显示当前设备名下的按键。 + +### 10.4 学习交互 + +用户输入显示名称后开始学习。页面提示: + +```text +请将实体遥控器对准红外控制器并按下目标按键。 +``` + +按键显示 `LEARNING`、`READY` 或 `FAILED`。允许离开页面,但不显示“停止硬件学习”。 + +### 10.5 发射交互 + +- 只有 `READY` 按键可发射; +- 发射期间禁用该按键,防止重复点击; +- 成功显示短暂提示; +- 失败显示具体错误; +- 不自动重试; +- 单次失败不删除或降级按键状态。 + +### 10.6 删除提示 + +删除确认文案应明确: + +```text +仅从本机列表移除,ABox 服务端可能仍保留该按键。 +``` + +## 11. Debug Fake/Real 路由 + +Debug 构建提供显式运行模式: + +- `FAKE`:页面开发、自动化测试和错误模拟; +- `REAL_ABSDK`:复用当前登录会话,调用真实 `AbsdkBridge`。 + +约束: + +- 页面持续显示当前模式; +- 不允许在学习任务进行中切换模式; +- Fake 和 Real 共用同一 ViewModel、应用用例和 Room Repository; +- 模式和 Provider 数据必须隔离,避免 Fake 按键误用于真实设备; +- Fake 支持按 `ProviderProfileId + DeviceName + sdkKey` 配置成功或失败; +- 真实模式的所有 Gateway 接口绑定同一 `AbsdkBridge` 单例; +- 真实模式必须显式 opt in `CleartextAbsdkTransport`; +- Release 暂不启用真实 Bridge,除非项目明确批准传输安全方案。 + +真实 App 接入还必须补齐: + +- `:absdk-bridge` 依赖; +- Hilt 绑定; +- `INTERNET` 权限; +- 经批准的明文网络配置; +- 登录会话与红外 Gateway 的同一 Bridge 实例; +- Token 失效后的页面返回登录流程。 + +## 12. 错误处理 + +继续使用现有 `DomainResult` 和 `DomainError`。不为首版扩充 sealed 类型,优先利用已有分类和 `externalCode`。 + +| 情况 | UI 反馈 | 本地状态 | +|---|---|---| +| Token 无效 | 登录已失效,返回登录 | 学习按键为 FAILED;发射按键仍为 READY | +| 红外设备不存在 | 检查设备名 | 学习按键为 FAILED | +| 红外设备离线 | 检查供电和网络 | 学习按键为 FAILED;发射按键仍为 READY | +| KEY/码值不存在 | 建议重新学习 | 发射按键仍为 READY,由用户决定重学 | +| 红外发射失败 | 显示失败,可手动重试 | READY | +| 网络超时 | 网络或 ABox 服务不可用 | 学习按键为 FAILED;发射按键仍为 READY | +| 本地保存失败 | 无法开始学习 | 不调用硬件学习 | +| 学习成功但最终状态写入失败 | 显示数据保存异常并保留恢复信息 | 不生成新 sdkKey | +| 未知错误 | 通用提示及脱敏错误码 | 按操作类型处理 | + +日志不得包含: + +- 用户密码; +- Token; +- 原始 SDK Map; +- 原始红外码或载荷; +- 可用于重放的敏感字段。 + +## 13. 自动化测试 + +### 13.1 应用用例 + +覆盖: + +1. 学习前先保存 `LEARNING`; +2. 学习成功更新为 `READY`; +3. 学习失败更新为 `FAILED`; +4. 重试复用原 `sdkKey`; +5. 修改显示名不改变 `sdkKey`; +6. 只有 `READY` 可以发射; +7. 发射使用保存的 `deviceName + sdkKey`; +8. 不同 Provider 数据隔离; +9. 遗留 `LEARNING` 恢复为 `FAILED`; +10. 删除后稳定引用失效; +11. ViewModel 销毁后应用级学习任务仍可落库; +12. 发射失败不改变 READY; +13. 进行中的学习阻止运行模式切换。 + +### 13.2 Room + +覆盖: + +- 新增、观察、查询、重命名和删除; +- `ProviderProfileId + DeviceName` 过滤; +- `sdkKey` 唯一约束; +- Provider 隔离; +- 状态转换; +- 稳定 ID 跨重命名不变; +- 首版 schema 和迁移测试基础; +- 不存储原始红外载荷。 + +### 13.3 Fake + +扩展现有 Fake,覆盖: + +- 按 Provider、设备和 Key 脚本化结果; +- 调用参数与顺序; +- 连续响应; +- 成功、离线、超时和通用失败; +- 不同 Provider 互不影响。 + +### 13.4 Bridge + +保留现有覆盖并补充: + +- 学习参数和调用顺序; +- 发射参数和调用顺序; +- 红外错误码 `20201` 至 `20206` 的映射; +- 学习、发射与其他设备调用的全局串行; +- Token 失效清理会话; +- SDK 空值和异常防御; +- 不改变 `downloadCodes()` 的部分成功语义。 + +### 13.5 ViewModel 与 Compose + +只验证功能,不验证最终视觉: + +- 无设备名不能学习; +- 保存和恢复设备名; +- 创建学习任务; +- 显示学习中、成功和失败; +- 重试学习; +- 点击发射并防重复点击; +- 删除确认; +- Fake/Real 模式标识; +- 登录失效引导; +- 基本中文和英文资源。 + +## 14. 真机验收 + +第一轮使用现有 ABox 主机和红外控制器: + +1. 登录真实账号; +2. 输入后台提供的准确 `irDevName`; +3. 创建唯一按键,例如“测试电源”; +4. 调用学习并记录耗时、返回码和消息; +5. 学习成功后立即发射; +6. 验证实体电器是否响应; +7. 用同一按键重复发射; +8. 重新学习同一 `sdkKey`,验证覆盖或报错行为; +9. 红外控制器断电后验证离线错误; +10. 不按实体遥控器,验证实际超时行为; +11. 页面退出后验证后台结果落库; +12. 进程被杀后验证遗留 LEARNING 恢复; +13. Token 失效后验证返回登录; +14. 记录 SDK 是否存在未文档化 `dicDatas`,但不将内容写入生产日志。 + +### 14.1 必须验证的未知项 + +- 学习调用是立即返回还是阻塞到捕获完成; +- 学习成功是否自动绑定 `sdkKey`; +- 同一 `sdkKey` 重学是否覆盖; +- 无信号时实际超时时间; +- 页面离开后底层调用是否可完成; +- 设备名格式、大小写和特殊字符规则; +- 服务端是否永久保留已学习 Key; +- SDK 是否返回未文档化数据; +- `sdkKey` 的长度限制; +- 真实模式所需初始化信息和调用顺序。 + +这些未知项不得通过业务层假设隐藏。真机结果应更新协议文档和后续设计。 + +## 15. 验收标准 + +本阶段完成需要满足: + +- 登录成功页面可以进入简单红外页面; +- 可以手动配置并恢复一个红外设备名; +- 可以创建多个命名按键; +- 学习调用前已持久化 LEARNING; +- 学习成功或失败能正确落库; +- READY 按键可以调用发射; +- 显示名修改不影响发射; +- Fake 模式可以覆盖主要状态和错误; +- Real 模式明确标识并调用真实 Bridge; +- 页面退出后学习任务仍可更新状态; +- 进程中断后遗留学习状态可以保守恢复; +- 自动化测试通过; +- 真机至少完成一次“学习成功 → 发射 → 实体响应”; +- 未确认硬件语义被记录,不伪装为已支持; +- 未来生活场景可以通过稳定 `InfraredKeyRef` 引用按键。 + +## 16. 后续工作 + +本设计完成后再分别规划: + +1. 多遥控器和房间组织; +2. 统一产品视觉和正式导航入口; +3. 红外码库导入; +4. 生活场景编排; +5. Scene 顺序执行和单项结果; +6. 厂商明文 HTTP 的正式安全方案; +7. 真机验证后对学习语义、超时和错误模型的修订。 -- 2.54.0 From cd564cbfa1c9905278fe067533a033a169c45683 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 00:31:07 +0800 Subject: [PATCH 02/41] docs: plan infrared control implementation --- .../plans/2026-08-24-infrared-control.md | 1300 +++++++++++++++++ 1 file changed, 1300 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-infrared-control.md diff --git a/docs/superpowers/plans/2026-08-24-infrared-control.md b/docs/superpowers/plans/2026-08-24-infrared-control.md new file mode 100644 index 0000000..b088ee8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-infrared-control.md @@ -0,0 +1,1300 @@ +# Infrared Learning and Sending Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a testable end-to-end infrared feature that stores named key bindings, invokes ABSDK learning and sending through the existing gateway, exposes a simple Compose screen, and supports explicit Fake/Real debug routing. + +**Architecture:** Keep `InfraredGateway` as the hardware-only boundary. Add infrared key models, repositories, and application use cases to the pure Kotlin `:domain` module; add Room, runtime state, ViewModel, and Compose UI to a focused `:android:feature:infrared` module; wire the feature into `:android:app` with build-variant-specific gateway bindings. Learning writes `LEARNING` before calling hardware and records `READY` or `FAILED` afterward, while sending resolves a stable local key reference to the stored `DeviceName + sdkKey` binding. + +**Tech Stack:** Kotlin 1.9.24, JVM 17, Android Gradle Plugin 8.7.3, Android API 26–34, coroutines 1.8.1, Room 2.6.1 with KAPT, DataStore Preferences 1.1.1, Hilt 2.51.1, Navigation Compose 2.7.7, Compose BOM 2024.06.00, JUnit 4, AndroidX instrumentation tests. + +**Spec:** `docs/superpowers/specs/2026-08-24-infrared-control-design.md` + +## Global Constraints + +- Do not modify the signatures of `InfraredGateway.learnKey()`, `sendKey()`, or `downloadCodes()`. +- Do not claim that ABSDK returns a learned raw infrared code; `learnKey()` remains `DomainResult`. +- Do not implement or label any action as “stop hardware learning”; ABSDK 0630 exposes no stop-learning API. +- Store `displayName` separately from immutable `sdkKey`; renaming must never change the ABSDK key. +- Store no password, token, raw SDK map, raw infrared payload, or replayable secret in Room, logs, analytics, errors, or Compose semantics. +- A key is sendable only when its local status is `READY`; a send failure does not demote it. +- Learning persists `LEARNING` before hardware invocation; success becomes `READY`, failure becomes `FAILED`. +- Retry learning reuses the same `InfraredKeyId` and `sdkKey`. +- Interrupted `LEARNING` records recover conservatively to `FAILED`; never infer hardware success. +- Fake and Real data are isolated by effective `ProviderProfileId`. +- Debug UI always displays the selected runtime mode; mode cannot change while learning is active. +- Real ABSDK is debug-only and requires explicit `CleartextAbsdkTransport` opt-in plus debug-only cleartext network configuration. +- Release must compile without enabling real ABSDK or requesting cleartext transport. +- All real hardware interfaces must share the single process-level `AbsdkBridge.create()` instance. +- The current `main` branch has no implemented login screen despite the product wording “post-login page.” Use the existing `FoundationScreen` as the temporary navigation host. Real hardware calls will return `TokenInvalid` until a login implementation authenticates the same `AbsdkBridge` instance; do not add credential collection to the infrared feature. +- Work only on a dedicated branch based on updated `main`; submit through a PR targeting `main`. +- Follow TDD for every behavior: failing test, minimal implementation, passing test, focused commit. + +--- + +## File Structure + +### Pure Kotlin domain and application layer + +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt` — stable IDs, status, saved key model, command inputs. +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt` — key persistence and device-name configuration ports. +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt` — ID, clock, and deterministic SDK-key construction boundaries. +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt` — create and retry learning orchestration. +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt` — send, rename, delete, and interrupted-learning recovery use cases. +- Matching files under `domain/src/test/.../infrared/` — pure unit tests. + +### Test doubles + +- `testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt` — scriptable per-call infrared outcomes. +- `testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt` — in-memory repository/configuration port implementation. +- Matching files under `testing/fakes/src/test/...` — fake contract tests. + +### Android infrared feature + +- `android/feature/infrared/build.gradle.kts` and `src/main/AndroidManifest.xml` — focused Android library. +- `data/local/InfraredEntities.kt`, `InfraredDao.kt`, `InfraredDatabase.kt`, `InfraredMappers.kt`, `RoomInfraredKeyRepository.kt` — Room persistence. +- `runtime/InfraredRuntimeMode.kt`, `DataStoreInfraredRuntimeModeStore.kt`, `InfraredRuntimeProfileResolver.kt` — explicit Fake/Real mode and data isolation. +- `application/InfraredLearningCoordinator.kt` — application-scope learning jobs that outlive a ViewModel. +- `ui/InfraredControlUiState.kt`, `InfraredControlViewModel.kt`, `InfraredControlScreen.kt` — state holder and simple functional Compose UI. +- `di/InfraredFeatureModule.kt` — Room, repository, mode store, scope, clock, and ID generator bindings. +- Unit and instrumentation tests under the same module. + +### App integration + +- `android/app/src/debug/.../di/FoundationModule.kt` — debug router, Fake gateway, and shared real `AbsdkBridge` binding. +- `android/app/src/debug/.../di/DebugRoutingInfraredGateway.kt` — runtime delegation. +- `android/app/src/release/.../di/InfraredReleaseModule.kt` — disabled release gateway binding. +- `android/app/src/debug/AndroidManifest.xml` — debug-only Internet permission and cleartext opt-in. +- `android/app/src/main/.../ui/ABoxManagerApp.kt` — temporary entry and route. +- English and Chinese string resource files — functional copy only. + +--- + +### Task 1: Define Stable Infrared Key Contracts + +**Files:** +- Create: `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt` +- Create: `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt` +- Create: `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt` +- Test: `domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt` + +**Interfaces:** +- Consumes: existing `ProviderProfileId`, `DeviceName`, `DomainResult`, and `DomainError`. +- Produces: + +```kotlin +@JvmInline value class InfraredKeyId(val value: String) + +data class InfraredKeyRef( + val profileId: ProviderProfileId, + val keyId: InfraredKeyId, +) + +enum class InfraredKeyStatus { LEARNING, READY, FAILED } + +data class SavedInfraredKey( + val id: InfraredKeyId, + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val displayName: String, + val sdkKey: String, + val status: InfraredKeyStatus, + val lastErrorCode: String?, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) + +interface InfraredKeyRepository { + fun observeKeys(profileId: ProviderProfileId, deviceName: DeviceName): Flow>> + suspend fun getKey(ref: InfraredKeyRef): DomainResult + suspend fun insert(key: SavedInfraredKey): DomainResult + suspend fun update(key: SavedInfraredKey): DomainResult + suspend fun delete(ref: InfraredKeyRef): DomainResult + suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult> +} + +interface InfraredDeviceConfigurationRepository { + fun observeDeviceName(profileId: ProviderProfileId): Flow> + suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult +} + +fun interface InfraredKeyIdGenerator { fun nextId(): InfraredKeyId } +fun interface EpochMillisClock { fun now(): Long } + +class InfraredKeyFactory( + private val idGenerator: InfraredKeyIdGenerator, + private val clock: EpochMillisClock, +) { + fun create(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String): DomainResult +} +``` + +- [ ] **Step 1: Write failing SDK-key factory tests** + +```kotlin +class InfraredKeyFactoryTest { + private val id = InfraredKeyId("01J5MOLERABC123") + private val factory = InfraredKeyFactory( + idGenerator = InfraredKeyIdGenerator { id }, + clock = EpochMillisClock { 1_724_435_200_000L }, + ) + + @Test + fun createsLearningKeyWithStableAsciiSdkKey() { + val result = factory.create( + ProviderProfileId("abox"), + DeviceName("ir-1"), + "空调开关", + ) as DomainResult.Success + + assertEquals(id, result.value.id) + assertEquals("app_ir_01j5molerabc123", result.value.sdkKey) + assertEquals(InfraredKeyStatus.LEARNING, result.value.status) + assertEquals("空调开关", result.value.displayName) + assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis) + assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) + } + + @Test + fun rejectsBlankDeviceOrDisplayName() { + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure) + } +} +``` + +- [ ] **Step 2: Run the focused domain test and verify failure** + +Run: + +```bash +./gradlew :domain:test --tests "*.InfraredKeyFactoryTest" +``` + +Expected: compilation failure because the infrared key types and factory do not exist. + +- [ ] **Step 3: Add the models and repository interfaces exactly as listed above** + +Keep them in package `com.flagship.abox.manager.domain.infrared`. Import shared domain types rather than duplicating them. + +- [ ] **Step 4: Implement conservative factory validation** + +```kotlin +class InfraredKeyFactory( + private val idGenerator: InfraredKeyIdGenerator, + private val clock: EpochMillisClock, +) { + fun create( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + ): DomainResult { + val normalizedName = displayName.trim() + if (profileId.value.isBlank() || deviceName.value.isBlank() || normalizedName.isBlank()) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"), + ) + } + val id = idGenerator.nextId() + val normalizedId = id.value.lowercase().filter { it in 'a'..'z' || it in '0'..'9' } + if (normalizedId.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key id is invalid")) + } + val now = clock.now() + return DomainResult.Success( + SavedInfraredKey( + id = id, + profileId = profileId, + deviceName = deviceName, + displayName = normalizedName, + sdkKey = "app_ir_${normalizedId.take(40)}", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + createdAtEpochMillis = now, + updatedAtEpochMillis = now, + ), + ) + } +} +``` + +The conservative `take(40)` keeps the generated key bounded pending real-hardware length verification. + +- [ ] **Step 5: Run all domain tests** + +```bash +./gradlew :domain:test +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit the contracts** + +```bash +git add domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared +git commit -m "feat: define infrared key contracts" +``` + +--- + +### Task 2: Implement Learning and Retry Orchestration + +**Files:** +- Create: `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt` +- Test: `domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt` +- Test helper: `domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt` + +**Interfaces:** +- Consumes: `InfraredGateway`, `InfraredKeyRepository`, `InfraredKeyFactory`, `EpochMillisClock`. +- Produces: + +```kotlin +class LearnInfraredKey( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, + private val factory: InfraredKeyFactory, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + ): DomainResult +} + +class RetryInfraredKeyLearning( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke(ref: InfraredKeyRef): DomainResult +} +``` + +- [ ] **Step 1: Add an in-memory repository and recording gateway for domain tests** + +Implement only the interface behavior needed by these tests: a map keyed by `InfraredKeyRef`, a `MutableStateFlow`, configurable insert/update failures, and recorded gateway calls. + +- [ ] **Step 2: Write failing tests for persistence ordering and status updates** + +```kotlin +@Test +fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest { + val events = mutableListOf() + repository.onInsert = { events += "insert:${it.status}" } + gateway.onLearn = { _, _, _ -> + events += "hardware" + DomainResult.Success(Unit) + } + repository.onUpdate = { events += "update:${it.status}" } + + val result = learn(profile, device, "Power") + + assertTrue(result is DomainResult.Success) + assertEquals(listOf("insert:LEARNING", "hardware", "update:READY"), events) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) +} + +@Test +fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest { + gateway.learnResult = DomainResult.Failure(DomainError.DeviceOffline("20206")) + + val result = learn(profile, device, "Power") + + assertEquals(DomainError.DeviceOffline("20206"), (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) + assertEquals("20206", repository.onlyKey().lastErrorCode) +} + +@Test +fun insertFailureNeverCallsHardware() = runTest { + repository.insertError = DomainError.Unknown(detail = "database unavailable") + + assertTrue(learn(profile, device, "Power") is DomainResult.Failure) + assertTrue(gateway.learnedKeys.isEmpty()) +} +``` + +- [ ] **Step 3: Run tests and verify they fail** + +```bash +./gradlew :domain:test --tests "*.LearnInfraredKeyTest" +``` + +Expected: compilation failure because the learning use cases do not exist. + +- [ ] **Step 4: Implement `LearnInfraredKey` with explicit ordering** + +Use this exact decision table: + +```text +factory failure → return failure, no repository or gateway call +insert failure → return failure, no gateway call +gateway success → update READY; return key ID only if update succeeds +gateway failure → attempt update FAILED with externalCode; return original gateway failure +``` + +When creating READY/FAILED copies, preserve `id`, `profileId`, `deviceName`, `displayName`, `sdkKey`, and `createdAtEpochMillis`; only status, error, and updated time change. + +- [ ] **Step 5: Write failing retry tests** + +```kotlin +@Test +fun retryReusesStableIdAndSdkKey() = runTest { + val original = failedKey(id = InfraredKeyId("key-1"), sdkKey = "app_ir_key1") + repository.seed(original) + + val result = retry(original.ref()) + + assertEquals(DomainResult.Success(Unit), result) + assertEquals("app_ir_key1", gateway.learnedKeys.single().key) + assertEquals(InfraredKeyId("key-1"), repository.onlyKey().id) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) +} + +@Test +fun retryRejectsAlreadyLearningKey() = runTest { + repository.seed(learningKey()) + val result = retry(repository.onlyKey().ref()) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(gateway.learnedKeys.isEmpty()) +} +``` + +- [ ] **Step 6: Implement retry without creating a new record** + +Required sequence: + +```text +get existing → reject null/LEARNING → update same record to LEARNING +→ call gateway with stored profile/device/sdkKey +→ update same record to READY or FAILED +``` + +- [ ] **Step 7: Run domain tests** + +```bash +./gradlew :domain:test +``` + +Expected: all domain tests pass. + +- [ ] **Step 8: Commit learning orchestration** + +```bash +git add domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared +git commit -m "feat: orchestrate infrared key learning" +``` + +--- + +### Task 3: Implement Send, Rename, Delete, and Recovery Use Cases + +**Files:** +- Create: `domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt` +- Test: `domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt` + +**Interfaces:** +- Consumes: contracts from Tasks 1–2. +- Produces: + +```kotlin +class SendInfraredKey(repository: InfraredKeyRepository, gateway: InfraredGateway) +class RenameInfraredKey(repository: InfraredKeyRepository, clock: EpochMillisClock) +class DeleteInfraredKey(repository: InfraredKeyRepository) +class RecoverInterruptedInfraredLearning(repository: InfraredKeyRepository, clock: EpochMillisClock) +``` + +Each exposes a suspending `operator fun invoke(...)` returning `DomainResult`. + +- [ ] **Step 1: Write failing send tests** + +```kotlin +@Test +fun sendsReadyKeyUsingStoredDeviceAndSdkKey() = runTest { + repository.seed(readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")) + + assertEquals(DomainResult.Success(Unit), send(repository.onlyKey().ref())) + assertEquals( + InfraredCall(profile, DeviceName("ir-living"), "app_ir_power"), + gateway.sentKeys.single(), + ) +} + +@Test +fun sendFailureDoesNotDemoteReadyKey() = runTest { + repository.seed(readyKey()) + gateway.sendResult = DomainResult.Failure(DomainError.OperationFailed("20205")) + + assertTrue(send(repository.onlyKey().ref()) is DomainResult.Failure) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) +} + +@Test +fun rejectsSendingFailedOrMissingKey() = runTest { + repository.seed(failedKey()) + assertTrue(send(repository.onlyKey().ref()) is DomainResult.Failure) + assertTrue(send(InfraredKeyRef(profile, InfraredKeyId("missing"))) is DomainResult.Failure) + assertTrue(gateway.sentKeys.isEmpty()) +} +``` + +- [ ] **Step 2: Run the focused tests and verify failure** + +```bash +./gradlew :domain:test --tests "*.ManageInfraredKeyTest" +``` + +- [ ] **Step 3: Implement `SendInfraredKey` minimally** + +Missing keys return `DomainError.NoData`; non-READY keys return `DomainError.InvalidRequest`; READY keys delegate exactly once and do not write the repository. + +- [ ] **Step 4: Add failing rename, delete, and recovery tests** + +```kotlin +@Test +fun renameChangesOnlyDisplayNameAndTimestamp() = runTest { + val before = readyKey(displayName = "Power", sdkKey = "app_ir_power") + repository.seed(before) + + assertEquals(DomainResult.Success(Unit), rename(before.ref(), "空调开关")) + val after = repository.onlyKey() + assertEquals("空调开关", after.displayName) + assertEquals(before.sdkKey, after.sdkKey) + assertEquals(before.id, after.id) +} + +@Test +fun deleteRejectsLearningButDeletesReady() = runTest { + repository.seed(learningKey()) + assertTrue(delete(repository.onlyKey().ref()) is DomainResult.Failure) + repository.seed(readyKey()) + assertEquals(DomainResult.Success(Unit), delete(repository.onlyKey().ref())) +} + +@Test +fun recoveryMarksAllInterruptedKeysFailedWithoutHardwareCalls() = runTest { + repository.seed(learningKey(id = InfraredKeyId("one"))) + repository.seed(learningKey(id = InfraredKeyId("two"))) + + assertEquals(DomainResult.Success(2), recover(profile)) + assertTrue(repository.allKeys().all { it.status == InfraredKeyStatus.FAILED }) + assertTrue(gateway.learnedKeys.isEmpty()) +} +``` + +- [ ] **Step 5: Implement management and recovery rules** + +Use error detail `"Interrupted learning result is unknown; retry required"` for recovered rows and set `lastErrorCode = null` because no vendor response exists. + +- [ ] **Step 6: Run all domain tests** + +```bash +./gradlew :domain:test +``` + +- [ ] **Step 7: Commit key management** + +```bash +git add domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt +git commit -m "feat: manage saved infrared keys" +``` + +--- + +### Task 4: Strengthen Infrared Test Fakes + +**Files:** +- Modify: `testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt:80-137` +- Create: `testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt` +- Modify: `testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt` +- Create: `testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt` + +**Interfaces:** +- Consumes: `InfraredKeyRepository` and `InfraredDeviceConfigurationRepository`. +- Produces: per-call scripted `FakeInfraredGateway` plus in-memory repositories for ViewModel and use-case tests. + +- [ ] **Step 1: Write failing per-key scripting tests** + +```kotlin +@Test +fun infraredResultsCanDifferByProfileDeviceAndKey() = runTest { + val gateway = FakeInfraredGateway() + gateway.setLearnResult( + profileOne, + DeviceName("ir-1"), + "power", + DomainResult.Failure(DomainError.DeviceOffline("20206")), + ) + + assertTrue(gateway.learnKey(profileOne, DeviceName("ir-1"), "power") is DomainResult.Failure) + assertEquals( + DomainResult.Success(Unit), + gateway.learnKey(profileTwo, DeviceName("ir-1"), "power"), + ) +} +``` + +- [ ] **Step 2: Run the focused fake test and verify failure** + +```bash +./gradlew :testing:fakes:test --tests "*.FakeDeviceGatewaysTest" +``` + +- [ ] **Step 3: Add keyed result maps while retaining existing defaults** + +Use a private key: + +```kotlin +private data class InfraredOperationKey( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val key: String, +) +``` + +Add `setSendResult(...)`, `setLearnResult(...)`, and optional queued result helpers. Existing `sendResult` and `learnResult` remain defaults so current consumers do not break. + +- [ ] **Step 4: Write failing repository fake tests** + +Test Flow emission, profile/device filtering, status filtering, update/delete, duplicate insert failure, and device-name configuration isolation. + +- [ ] **Step 5: Implement `FakeInfraredKeyRepository` and `FakeInfraredDeviceConfigurationRepository`** + +Use `MutableStateFlow>`, synchronize mutations, and return `DomainError.InvalidRequest` for duplicate IDs or duplicate `(profileId, deviceName, sdkKey)` bindings. + +- [ ] **Step 6: Run all fake tests** + +```bash +./gradlew :testing:fakes:test +``` + +- [ ] **Step 7: Commit fake improvements** + +```bash +git add testing/fakes/src/main testing/fakes/src/test +git commit -m "test: add scriptable infrared fakes" +``` + +--- + +### Task 5: Add the Android Infrared Feature Module and Room Persistence + +**Files:** +- Modify: `settings.gradle.kts:25-29` +- Modify: `gradle/libs.versions.toml:17-46` +- Modify: `build.gradle.kts:1-8` +- Create: `android/feature/infrared/build.gradle.kts` +- Create: `android/feature/infrared/src/main/AndroidManifest.xml` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMappers.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt` +- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt` + +**Interfaces:** +- Consumes: Task 1 repository ports. +- Produces: Room implementation for keys and device-name configuration. + +- [ ] **Step 1: Add missing dependency aliases and module registration** + +Add these aliases: + +```toml +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } +``` + +Add `include(":android:feature:infrared")` and apply the existing Android library, Kotlin Android, KAPT, and Hilt plugins in the feature module. + +- [ ] **Step 2: Create the module build file** + +Use namespace `com.flagship.abox.manager.infrared`, API levels 26/34, JVM 17, Compose enabled, and dependencies on `:domain`, `:android:core:designsystem`, Room runtime/ktx, DataStore, lifecycle, navigation, Hilt, Compose, JUnit, coroutines-test, AndroidX JUnit, Room testing, and Compose UI test. + +- [ ] **Step 3: Write failing Room instrumentation tests** + +```kotlin +@RunWith(AndroidJUnit4::class) +class RoomInfraredKeyRepositoryTest { + @Test + fun observesOnlyRequestedProfileAndDevice() = runTest { + repository.insert(key(profile = "one", device = "ir-1", id = "a")) + repository.insert(key(profile = "one", device = "ir-2", id = "b")) + repository.insert(key(profile = "two", device = "ir-1", id = "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 { + repository.insert(key(id = "a", sdkKey = "app_ir_power")) + val duplicate = repository.insert(key(id = "b", sdkKey = "app_ir_power")) + assertTrue(duplicate is DomainResult.Failure) + } +} +``` + +Also test update, delete, status query, configuration persistence, and stable ID after rename. + +- [ ] **Step 4: Run instrumentation compilation and verify failure** + +```bash +./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin +``` + +Expected: failure because Room types and repository do not exist. + +- [ ] **Step 5: Implement Room entities and constraints** + +```kotlin +@Entity( + tableName = "infrared_keys", + indices = [ + Index(value = ["profileId", "deviceName"]), + Index(value = ["profileId", "deviceName", "sdkKey"], unique = true), + ], +) +data class InfraredKeyEntity(...) + +@Entity(tableName = "infrared_device_configuration") +data class InfraredDeviceConfigurationEntity( + @PrimaryKey val profileId: String, + val deviceName: String, +) +``` + +Use `@Database(..., version = 1, exportSchema = true)` and set Room schema output through KAPT arguments in the module build file. + +- [ ] **Step 6: Implement DAO, mappers, and repository error containment** + +Catch `SQLiteConstraintException` as `DomainError.InvalidRequest`, other database exceptions as `DomainError.Unknown`, and never include full entity dumps in error details. + +- [ ] **Step 7: Run Room tests on an available emulator/device** + +```bash +./gradlew :android:feature:infrared:connectedDebugAndroidTest +``` + +Expected: Room repository tests pass. If no emulator is available, record that instrumentation execution is pending but still require `compileDebugAndroidTestKotlin` to pass; do not report the tests as executed. + +- [ ] **Step 8: Commit persistence** + +```bash +git add settings.gradle.kts gradle/libs.versions.toml build.gradle.kts android/feature/infrared +git commit -m "feat: persist infrared key bindings" +``` + +--- + +### Task 6: Add Runtime Mode, Profile Isolation, and Application-Scope Learning + +**Files:** +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt` +- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt` +- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt` + +**Interfaces:** +- Produces: + +```kotlin +enum class InfraredRuntimeMode { FAKE, REAL_ABSDK } + +interface InfraredRuntimeModeStore { + val mode: Flow + suspend fun currentMode(): InfraredRuntimeMode + suspend fun setMode(mode: InfraredRuntimeMode): DomainResult +} + +class InfraredRuntimeProfileResolver { + fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId +} + +class InfraredLearningCoordinator { + fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) + fun retry(ref: InfraredKeyRef) +} +``` + +- [ ] **Step 1: Write profile-isolation tests** + +```kotlin +@Test +fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() { + val resolver = InfraredRuntimeProfileResolver() + assertEquals(ProviderProfileId("abox"), resolver.resolve(ProviderProfileId("abox"), REAL_ABSDK)) + assertEquals(ProviderProfileId("abox:infrared-fake"), resolver.resolve(ProviderProfileId("abox"), FAKE)) +} +``` + +- [ ] **Step 2: Implement mode storage and profile resolver** + +DataStore defaults to `FAKE`. Store only the enum name under `infrared_runtime_mode`; an unknown stored value falls back to `FAKE`. + +- [ ] **Step 3: Write failing coordinator lifetime tests** + +Use a `TestScope` injected through a qualifier and verify that calling `coordinator.learn(...)` returns immediately, then `advanceUntilIdle()` runs the use case and updates the repository even after the test drops its ViewModel reference. + +- [ ] **Step 4: Implement an application coroutine scope and coordinator** + +```kotlin +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class InfraredApplicationScope + +@Provides +@Singleton +@InfraredApplicationScope +fun provideInfraredApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO) +``` + +The coordinator launches only the learning and retry use cases. It does not own UI state and does not expose a fake cancellation method. + +- [ ] **Step 5: Bind Room repositories, factory, clock, ID generator, use cases, DataStore, and coordinator in Hilt** + +Use `UUID.randomUUID().toString()` only in the Android ID-generator binding. Use `System.currentTimeMillis()` only in the clock binding; tests inject deterministic implementations. + +- [ ] **Step 6: Run feature unit tests and compile instrumentation tests** + +```bash +./gradlew :android:feature:infrared:testDebugUnitTest :android:feature:infrared:compileDebugAndroidTestKotlin +``` + +- [ ] **Step 7: Commit runtime orchestration** + +```bash +git add android/feature/infrared/src/main android/feature/infrared/src/test +git commit -m "feat: coordinate infrared runtime state" +``` + +--- + +### Task 7: Implement the Infrared ViewModel State Machine + +**Files:** +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt` +- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt` + +**Interfaces:** +- Consumes: route argument `profileId`, repositories, mode store, resolver, coordinator, send/rename/delete/recovery use cases. +- Produces a `StateFlow` plus intent methods. + +```kotlin +data class InfraredControlUiState( + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val effectiveProfileId: ProviderProfileId? = null, + val deviceNameInput: String = "", + val savedDeviceName: DeviceName? = null, + val keys: List = emptyList(), + val loading: Boolean = true, + val sendingKeyIds: Set = emptySet(), + val message: InfraredUiMessage? = null, +) { + val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING } +} +``` + +Intent methods: + +```kotlin +fun onDeviceNameChanged(value: String) +fun saveDeviceName() +fun learnKey(displayName: String) +fun retryLearning(keyId: InfraredKeyId) +fun sendKey(keyId: InfraredKeyId) +fun renameKey(keyId: InfraredKeyId, displayName: String) +fun deleteKey(keyId: InfraredKeyId) +fun setRuntimeMode(mode: InfraredRuntimeMode) +fun clearMessage() +``` + +- [ ] **Step 1: Write failing state-combination tests** + +Test that the ViewModel combines route profile, mode, saved device name, and observed key Flow; Fake mode must use `abox:infrared-fake`, Real mode must use `abox`. + +- [ ] **Step 2: Write failing intent tests** + +Required cases: + +```text +blank device name → validation message; repository not called +save device name → trim and persist +learn without saved device → validation message +learn with saved device → coordinator called once +send READY key → sending set contains ID during call, then clears +send failure → error message, key remains READY +set mode while LEARNING → rejected with message +set mode when idle → store updated and effective profile changes +TokenInvalid → session-expired message suitable for app navigation +``` + +- [ ] **Step 3: Run ViewModel tests and verify failure** + +```bash +./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredControlViewModelTest" +``` + +- [ ] **Step 4: Implement Flow composition and SavedStateHandle profile parsing** + +The navigation argument is named `profileId`. Blank/missing values produce `DomainError.InvalidRequest` UI state rather than defaulting silently. + +- [ ] **Step 5: Implement intents with per-key send suppression** + +If a key ID is already in `sendingKeyIds`, ignore duplicate send taps. Mode changes are rejected while `hasActiveLearning` is true. + +- [ ] **Step 6: Run feature unit tests** + +```bash +./gradlew :android:feature:infrared:testDebugUnitTest +``` + +- [ ] **Step 7: Commit ViewModel** + +```bash +git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui +git commit -m "feat: add infrared control state machine" +``` + +--- + +### Task 8: Build the Simple Functional Compose Screen + +**Files:** +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt` +- Create: `android/feature/infrared/src/main/res/values/strings.xml` +- Create: `android/feature/infrared/src/main/res/values-zh-rCN/strings.xml` +- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt` + +**Interfaces:** +- Consumes: `InfraredControlUiState` and callbacks from Task 7. +- Produces: + +```kotlin +@Composable +fun InfraredControlRoute(onBack: () -> Unit, onSessionInvalid: () -> Unit) + +@Composable +internal fun InfraredControlScreen( + state: InfraredControlUiState, + onBack: () -> Unit, + onDeviceNameChanged: (String) -> Unit, + onSaveDeviceName: () -> Unit, + onLearn: (String) -> Unit, + onRetry: (InfraredKeyId) -> Unit, + onSend: (InfraredKeyId) -> Unit, + onRename: (InfraredKeyId, String) -> Unit, + onDelete: (InfraredKeyId) -> Unit, + onModeChanged: (InfraredRuntimeMode) -> Unit, + onMessageShown: () -> Unit, +) +``` + +- [ ] **Step 1: Write failing Compose tests for the functional surface** + +Use stable test tags: + +```text +infrared_mode_label +infrared_device_name_input +infrared_save_device +infrared_learn_new_key +infrared_key_ +infrared_send_ +infrared_retry_ +``` + +Test the empty state, mode label, device-name save action, READY send button, FAILED retry button, LEARNING progress text, and delete confirmation copy. + +- [ ] **Step 2: Compile instrumentation tests and verify failure** + +```bash +./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin +``` + +- [ ] **Step 3: Implement a deliberately simple layout** + +Use existing `ABoxSurface`, `ABoxButton`, Material 3 `OutlinedTextField`, `LazyColumn`, `AlertDialog`, `CircularProgressIndicator`, and text styles. Do not add new brand colors, custom animation systems, or reusable design-system components. + +- [ ] **Step 4: Implement the learn dialog** + +The dialog contains a display-name field and this functional guidance: + +```text +Prepare the physical remote. After starting, point it at the infrared controller and press the target button. +``` + +The Chinese resource communicates the same meaning. No Stop button is rendered after learning begins. + +- [ ] **Step 5: Implement delete and rename dialogs** + +Delete copy explicitly states that only the local entry is removed and ABox may retain the server-side key. Rename only sends a new display name. + +- [ ] **Step 6: Run feature tests** + +```bash +./gradlew :android:feature:infrared:testDebugUnitTest :android:feature:infrared:connectedDebugAndroidTest +``` + +If no emulator is available, run the compile task and report instrumentation execution as pending. + +- [ ] **Step 7: Commit UI** + +```bash +git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt android/feature/infrared/src/main/res android/feature/infrared/src/androidTest +git commit -m "feat: add infrared control screen" +``` + +--- + +### Task 9: Wire Debug Fake/Real Routing and Safe Release Binding + +**Files:** +- Modify: `android/app/build.gradle.kts:54-77` +- Modify: `android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt:1-42` +- Create: `android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt` +- Create: `android/app/src/debug/AndroidManifest.xml` +- Create: `android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt` +- Test: `android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt` + +**Interfaces:** +- Consumes: `InfraredRuntimeModeStore`, `FakeInfraredGateway`, and the shared `AbsdkBridge`. +- Produces: one injected `InfraredGateway` that delegates by current runtime mode. + +- [ ] **Step 1: Add app dependencies** + +```kotlin +implementation(project(":android:feature:infrared")) +debugImplementation(project(":testing:fakes")) +debugImplementation(project(":absdk-bridge")) +``` + +Do not add `:absdk-bridge` to release implementation. + +- [ ] **Step 2: Write failing router tests** + +Construct the router with a fake mode store and two recording gateways. Verify FAKE delegates only to Fake, REAL_ABSDK delegates only to Real, and all parameters/results pass through unchanged for send, learn, and download. + +- [ ] **Step 3: Implement `DebugRoutingInfraredGateway`** + +For each suspending operation call `modeStore.currentMode()` once, choose a delegate, and invoke it once. Do not cache the mode across calls. + +- [ ] **Step 4: Replace the direct Fake gateway binding** + +In `FoundationModule` provide: + +```kotlin +@Provides @Singleton fun provideFakeInfraredGateway(): FakeInfraredGateway + +@Provides +@Singleton +@OptIn(CleartextAbsdkTransport::class) +fun provideRealAbsdkBridge(): AbsdkBridge = AbsdkBridge.create() + +@Provides +@Singleton +fun provideInfraredGateway( + modeStore: InfraredRuntimeModeStore, + fake: FakeInfraredGateway, + real: AbsdkBridge, +): InfraredGateway = DebugRoutingInfraredGateway(modeStore, fake, real) +``` + +Keep the existing Fake session and other device gateways for current foundation behavior. Document in code that Real infrared mode requires another feature to authenticate this same concrete `AbsdkBridge` instance; do not create a second bridge. + +- [ ] **Step 5: Add debug-only network manifest configuration** + +```xml + + + + +``` + +This file exists only under `src/debug`. + +- [ ] **Step 6: Add a release-disabled gateway** + +`InfraredReleaseModule` provides an `InfraredGateway` whose three methods return: + +```kotlin +DomainResult.Failure( + DomainError.OperationFailed( + externalCode = "REAL_ABSDK_DISABLED", + detail = "Infrared hardware transport is disabled in release builds", + ), +) +``` + +The release implementation does not import ABSDK types or request Internet/cleartext permissions. + +- [ ] **Step 7: Run router tests and both build variants** + +```bash +./gradlew :android:app:testDebugUnitTest :android:app:assembleDebug :android:app:assembleRelease +``` + +Expected: tests pass; both APK variants compile; release has no dependency on `:absdk-bridge`. + +- [ ] **Step 8: Commit runtime wiring** + +```bash +git add android/app/build.gradle.kts android/app/src/debug android/app/src/release android/app/src/test +git commit -m "feat: route infrared debug hardware modes" +``` + +--- + +### Task 10: Add the Temporary Navigation Entry + +**Files:** +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt:23-71` +- Modify: `android/app/src/main/res/values/strings.xml:1-8` +- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml:1-8` +- Test: `android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt` + +**Interfaces:** +- Consumes: `InfraredControlRoute`. +- Produces route `infrared/{profileId}` with navigation argument `profileId`. + +- [ ] **Step 1: Write a failing navigation test** + +Launch the app, click the button with text “Infrared control”/“红外控制”, and assert the infrared title and Fake mode label are displayed. + +- [ ] **Step 2: Compile the app instrumentation test and verify failure** + +```bash +./gradlew :android:app:compileDebugAndroidTestKotlin +``` + +- [ ] **Step 3: Add the route and temporary entry** + +Use: + +```kotlin +private const val INFRARED_ROUTE = "infrared/{profileId}" +private const val TEMPORARY_ABOX_PROFILE_ID = "abox" +``` + +Add a second button to the existing `FoundationScreen`. Navigate with `infrared/abox`. Do not rename `FoundationScreen` to “Login” because it is not a login implementation. + +- [ ] **Step 4: Wire session-invalid handling honestly** + +Until a login screen exists, `onSessionInvalid` navigates back to the foundation route and shows a functional message explaining that the ABox session is not authenticated. Do not add username/password fields to the infrared screen. + +- [ ] **Step 5: Run navigation tests and assemble** + +```bash +./gradlew :android:app:connectedDebugAndroidTest :android:app:assembleDebug +``` + +If no emulator is available, require test compilation and APK assembly, and report the unexecuted instrumentation test. + +- [ ] **Step 6: Commit app navigation** + +```bash +git add android/app/src/main android/app/src/androidTest +git commit -m "feat: add temporary infrared navigation" +``` + +--- + +### Task 11: Complete ABSDK Infrared Contract and Error Tests + +**Files:** +- Modify: `absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt:86-168` +- Modify: `absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt` +- Modify: `absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/ScriptedAbsdkClient.kt:6-73` +- Modify only if a test exposes a real defect: `absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt` +- Modify only if a mapping test exposes a real defect: `absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapper.kt` + +**Interfaces:** +- Consumes: unchanged `InfraredGateway` contract. +- Produces: verified parameter forwarding, error mapping, and serialization guarantees. + +- [ ] **Step 1: Split the broad infrared test into focused tests** + +Add exact assertions: + +```kotlin +@Test +fun infraredSendForwardsDeviceAndStableKey() = runTest { + val client = ScriptedAbsdkClient() + val bridge = authenticatedBridge(client) + assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, DeviceName("ir-room"), "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, DeviceName("ir-room"), "app_ir_power")) + assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls) +} +``` + +- [ ] **Step 2: Add table-driven red/green mapping tests for codes 20201–20206** + +Expected classes: + +```text +20201, 20202 → DomainError.InvalidRequest +20203 → DomainError.DeviceNotFound +20204, 20205 → DomainError.OperationFailed +20206 → DomainError.DeviceOffline +``` + +Test send and learn paths, not only download. + +- [ ] **Step 3: Add null response and cross-device serialization tests** + +Verify a null response becomes a contained `DomainError.Unknown`/existing mapped failure rather than crashing, and concurrent infrared learning plus socket/temperature operations never exceed one active SDK call. + +- [ ] **Step 4: Run bridge tests** + +```bash +./gradlew :absdk-bridge:test +``` + +- [ ] **Step 5: Make only minimal production fixes exposed by failing tests** + +Do not refactor public APIs or parse undocumented learning data. Preserve `downloadCodes()` partial-success behavior for `20204`. + +- [ ] **Step 6: Run the full JVM suite** + +```bash +./gradlew :domain:test :testing:fakes:test :absdk-bridge:test +``` + +- [ ] **Step 7: Commit bridge verification** + +```bash +git add absdk-bridge/src/test absdk-bridge/src/main +git commit -m "test: verify ABSDK infrared contracts" +``` + +--- + +### Task 12: Verify the Complete Feature and Record Hardware Procedure + +**Files:** +- Create: `docs/testing/infrared-hardware-validation.md` +- Modify if observations require documentation updates: `docs/protocol/absdk-0630.md` +- No production code changes unless a preceding test demonstrates a defect. + +**Interfaces:** +- Consumes: complete feature. +- Produces: reproducible automated verification and a truthful hardware checklist. + +- [ ] **Step 1: Write the hardware validation document before running hardware tests** + +Include a table with these columns: + +```text +Date | App commit | Runtime mode | Profile ID | Device name | sdkKey +Operation | Duration ms | ABSDK code | Sanitized message | Physical response | Result +``` + +Include exact procedures for: + +1. authenticate the shared `AbsdkBridge` through the login feature; +2. enter the real `irDevName`; +3. learn a unique key; +4. immediately send the same key; +5. repeat send; +6. relearn the same SDK key; +7. test controller offline; +8. test no remote signal/timeout; +9. leave the page during learning; +10. kill/restart the process with LEARNING persisted; +11. expire the token; +12. record whether undocumented `dicDatas` exists without logging its contents in production. + +- [ ] **Step 2: Run static and JVM verification** + +```bash +./gradlew clean :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:lintDebug +``` + +Expected: all tasks pass with no test failures or lint errors. + +- [ ] **Step 3: Run Android compilation and packaging** + +```bash +./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:app:compileDebugAndroidTestKotlin :android:app:assembleDebug :android:app:assembleRelease +``` + +Expected: all tasks pass. + +- [ ] **Step 4: Run instrumentation tests when a device is available** + +```bash +./gradlew :android:feature:infrared:connectedDebugAndroidTest :android:app:connectedDebugAndroidTest +``` + +Record exact passed/failed/skipped counts. Never describe unexecuted tests as passing. + +- [ ] **Step 5: Run Fake-mode manual acceptance** + +Verify: + +```text +open temporary entry +save irDevName +create key → LEARNING → READY +send READY key +script failure → FAILED and retry +rename without sdkKey change +delete with local-only warning +attempt mode switch during learning → blocked +restart app → saved device and keys remain +``` + +- [ ] **Step 6: Run Real-mode hardware acceptance only when login is available** + +Precondition: the login implementation must authenticate the same `AbsdkBridge.create()` singleton. If current `main` still lacks login, mark Real hardware acceptance as blocked by login integration; do not add credentials to the infrared feature and do not claim the real loop works. + +Minimum passing result: + +```text +learnKey(realDeviceName, sdkKey) returns success +sendKey(realDeviceName, sameSdkKey) returns success +physical appliance performs the expected action +``` + +- [ ] **Step 7: Update protocol documentation with observed facts** + +Only add confirmed observations: blocking duration, timeout behavior, same-key relearn behavior, exact sanitized code/message, whether sending after learning works, and any undocumented response-field names. Label environment-specific observations with date and hardware context. + +- [ ] **Step 8: Commit verification documentation** + +```bash +git add docs/testing/infrared-hardware-validation.md docs/protocol/absdk-0630.md +git commit -m "docs: add infrared hardware validation" +``` + +- [ ] **Step 9: Perform final branch review** + +```bash +git status --short +git log --oneline origin/main..HEAD +git diff --check origin/main...HEAD +``` + +Expected: clean working tree, focused commits, no whitespace errors. + +- [ ] **Step 10: Request code review and open a PR** + +Invoke `superpowers:requesting-code-review`, address verified findings, rerun affected tests, then use `superpowers:finishing-a-development-branch` to create a Pull Request targeting `main`. Never merge directly to `main`. -- 2.54.0 From f1f00701d4fcc7ae415ac73c335b35c1f637c861 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 00:57:09 +0800 Subject: [PATCH 03/41] feat: define infrared key contracts --- .../domain/infrared/InfraredKeyFactory.kt | 46 +++++++++++++++++++ .../domain/infrared/InfraredKeyModels.kt | 26 +++++++++++ .../domain/infrared/InfraredKeyRepository.kt | 20 ++++++++ .../domain/infrared/InfraredKeyFactoryTest.kt | 38 +++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt new file mode 100644 index 0000000..1ee845f --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -0,0 +1,46 @@ +package com.flagship.abox.manager.domain.infrared + +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 + +fun interface InfraredKeyIdGenerator { fun nextId(): InfraredKeyId } +fun interface EpochMillisClock { fun now(): Long } + +class InfraredKeyFactory( + private val idGenerator: InfraredKeyIdGenerator, + private val clock: EpochMillisClock, +) { + fun create( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + ): DomainResult { + val normalizedName = displayName.trim() + if (profileId.value.isBlank() || deviceName.value.isBlank() || normalizedName.isBlank()) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"), + ) + } + val id = idGenerator.nextId() + val normalizedId = id.value.lowercase().filter { it in 'a'..'z' || it in '0'..'9' } + if (normalizedId.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key id is invalid")) + } + val now = clock.now() + return DomainResult.Success( + SavedInfraredKey( + id = id, + profileId = profileId, + deviceName = deviceName, + displayName = normalizedName, + sdkKey = "app_ir_${normalizedId.take(40)}", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + createdAtEpochMillis = now, + updatedAtEpochMillis = now, + ), + ) + } +} \ No newline at end of file diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt new file mode 100644 index 0000000..1e84c29 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -0,0 +1,26 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId + +@JvmInline +value class InfraredKeyId(val value: String) + +data class InfraredKeyRef( + val profileId: ProviderProfileId, + val keyId: InfraredKeyId, +) + +enum class InfraredKeyStatus { LEARNING, READY, FAILED } + +data class SavedInfraredKey( + val id: InfraredKeyId, + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val displayName: String, + val sdkKey: String, + val status: InfraredKeyStatus, + val lastErrorCode: String?, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) \ No newline at end of file diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt new file mode 100644 index 0000000..9d7d38d --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -0,0 +1,20 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow + +interface InfraredKeyRepository { + fun observeKeys(profileId: ProviderProfileId, deviceName: DeviceName): Flow>> + suspend fun getKey(ref: InfraredKeyRef): DomainResult + suspend fun insert(key: SavedInfraredKey): DomainResult + suspend fun update(key: SavedInfraredKey): DomainResult + suspend fun delete(ref: InfraredKeyRef): DomainResult + suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult> +} + +interface InfraredDeviceConfigurationRepository { + fun observeDeviceName(profileId: ProviderProfileId): Flow> + suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult +} \ No newline at end of file diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt new file mode 100644 index 0000000..acb8fad --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -0,0 +1,38 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class InfraredKeyFactoryTest { + private val id = InfraredKeyId("01J5MOLERABC123") + private val factory = InfraredKeyFactory( + idGenerator = InfraredKeyIdGenerator { id }, + clock = EpochMillisClock { 1_724_435_200_000L }, + ) + + @Test + fun createsLearningKeyWithStableAsciiSdkKey() { + val result = factory.create( + ProviderProfileId("abox"), + DeviceName("ir-1"), + "空调开关", + ) as DomainResult.Success + + assertEquals(id, result.value.id) + assertEquals("app_ir_01j5molerabc123", result.value.sdkKey) + assertEquals(InfraredKeyStatus.LEARNING, result.value.status) + assertEquals("空调开关", result.value.displayName) + assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis) + assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) + } + + @Test + fun rejectsBlankDeviceOrDisplayName() { + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure) + } +} \ No newline at end of file -- 2.54.0 From 4ff6ef6c4386276e56e1b4f672e5008d7c35eb6e Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 01:26:00 +0800 Subject: [PATCH 04/41] fix: harden infrared key contracts --- .../domain/infrared/InfraredKeyFactory.kt | 40 +++++++++++++++---- .../domain/infrared/InfraredKeyModels.kt | 6 ++- .../domain/infrared/InfraredKeyRepository.kt | 37 ++++++++++++++--- .../domain/infrared/InfraredKeyFactoryTest.kt | 30 ++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt index 1ee845f..e7ef07c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -5,8 +5,13 @@ import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId -fun interface InfraredKeyIdGenerator { fun nextId(): InfraredKeyId } -fun interface EpochMillisClock { fun now(): Long } +fun interface InfraredKeyIdGenerator { + fun nextId(): InfraredKeyId +} + +fun interface EpochMillisClock { + fun now(): Long +} class InfraredKeyFactory( private val idGenerator: InfraredKeyIdGenerator, @@ -18,16 +23,22 @@ class InfraredKeyFactory( displayName: String, ): DomainResult { val normalizedName = displayName.trim() - if (profileId.value.isBlank() || deviceName.value.isBlank() || normalizedName.isBlank()) { + if ( + profileId.value.isBlank() || + profileId.value != profileId.value.trim() || + deviceName.value.isBlank() || + deviceName.value != deviceName.value.trim() || + normalizedName.isBlank() + ) { return DomainResult.Failure( DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"), ) } val id = idGenerator.nextId() - val normalizedId = id.value.lowercase().filter { it in 'a'..'z' || it in '0'..'9' } - if (normalizedId.isBlank()) { - return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key id is invalid")) - } + val canonicalId = canonicalizeId(id.value) + ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key id is invalid"), + ) val now = clock.now() return DomainResult.Success( SavedInfraredKey( @@ -35,7 +46,7 @@ class InfraredKeyFactory( profileId = profileId, deviceName = deviceName, displayName = normalizedName, - sdkKey = "app_ir_${normalizedId.take(40)}", + sdkKey = "app_ir_$canonicalId", status = InfraredKeyStatus.LEARNING, lastErrorCode = null, createdAtEpochMillis = now, @@ -43,4 +54,17 @@ class InfraredKeyFactory( ), ) } + + private fun canonicalizeId(value: String): String? = when { + ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase() + STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase() + else -> null + } + + private companion object { + val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$") + val STANDARD_UUID = Regex( + "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$", + ) + } } \ No newline at end of file diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt index 1e84c29..3092769 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -11,7 +11,11 @@ data class InfraredKeyRef( val keyId: InfraredKeyId, ) -enum class InfraredKeyStatus { LEARNING, READY, FAILED } +enum class InfraredKeyStatus { + LEARNING, + READY, + FAILED, +} data class SavedInfraredKey( val id: InfraredKeyId, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt index 9d7d38d..7b0e55c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -6,15 +6,42 @@ import com.flagship.abox.manager.domain.ProviderProfileId import kotlinx.coroutines.flow.Flow interface InfraredKeyRepository { - fun observeKeys(profileId: ProviderProfileId, deviceName: DeviceName): Flow>> + fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> + suspend fun getKey(ref: InfraredKeyRef): DomainResult + suspend fun insert(key: SavedInfraredKey): DomainResult - suspend fun update(key: SavedInfraredKey): DomainResult - suspend fun delete(ref: InfraredKeyRef): DomainResult - suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult> + + suspend fun transitionStatus( + ref: InfraredKeyRef, + expectedStatuses: Set, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun rename( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult + + suspend fun failInterruptedLearning( + profileId: ProviderProfileId, + updatedAtEpochMillis: Long, + ): DomainResult } interface InfraredDeviceConfigurationRepository { fun observeDeviceName(profileId: ProviderProfileId): Flow> - suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult + + suspend fun setDeviceName( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult } \ No newline at end of file diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt index acb8fad..2adbe1e 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -35,4 +35,34 @@ class InfraredKeyFactoryTest { assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure) } + + @Test + fun rejectsIdentifiersWithSurroundingWhitespace() { + assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure) + } + + @Test + fun rejectsLossyOrOversizedIds() { + assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure) + assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure) + } + + @Test + fun canonicalizesStandardUuidId() { + val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success + + assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey) + } + + private fun factoryWithId(value: String) = InfraredKeyFactory( + idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) }, + clock = EpochMillisClock { 1_724_435_200_000L }, + ) + + private fun InfraredKeyFactory.createValidKey() = create( + ProviderProfileId("abox"), + DeviceName("ir-1"), + "Power", + ) } \ No newline at end of file -- 2.54.0 From 274807c2ff71155996ea85313261cbec94e6cd0a Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 01:49:54 +0800 Subject: [PATCH 05/41] feat: orchestrate infrared key learning --- .../domain/infrared/LearnInfraredKey.kt | 132 +++++++++ .../infrared/InMemoryInfraredTestDoubles.kt | 189 +++++++++++++ .../domain/infrared/LearnInfraredKeyTest.kt | 255 ++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt new file mode 100644 index 0000000..661c91e --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -0,0 +1,132 @@ +package com.flagship.abox.manager.domain.infrared + +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.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class LearnInfraredKey( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, + private val factory: InfraredKeyFactory, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + ): DomainResult { + val key = when (val result = factory.create(profileId, deviceName, displayName)) { + is DomainResult.Success -> result.value + is DomainResult.Failure -> return result + } + + when (val result = repository.insert(key)) { + is DomainResult.Success -> Unit + is DomainResult.Failure -> return result + } + + return when ( + val result = gateway.learnKey( + profileId = key.profileId, + deviceName = key.deviceName, + key = key.sdkKey, + ) + ) { + is DomainResult.Success -> when ( + val transition = repository.transitionStatus( + ref = key.ref(), + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.READY, + lastErrorCode = null, + updatedAtEpochMillis = clock.now(), + ) + ) { + is DomainResult.Success -> DomainResult.Success(key.id) + is DomainResult.Failure -> transition + } + + is DomainResult.Failure -> { + repository.transitionStatus( + ref = key.ref(), + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = result.error.externalCode, + updatedAtEpochMillis = clock.now(), + ) + result + } + } + } +} + +class RetryInfraredKeyLearning( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke(ref: InfraredKeyRef): DomainResult { + val key = when (val result = repository.getKey(ref)) { + is DomainResult.Success -> result.value + ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key not found"), + ) + + is DomainResult.Failure -> return result + } + if (key.status == InfraredKeyStatus.LEARNING) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key is already learning"), + ) + } + + when ( + val result = repository.transitionStatus( + ref = ref, + expectedStatuses = setOf( + InfraredKeyStatus.FAILED, + InfraredKeyStatus.READY, + ), + targetStatus = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + updatedAtEpochMillis = clock.now(), + ) + ) { + is DomainResult.Success -> Unit + is DomainResult.Failure -> return result + } + + return when ( + val result = gateway.learnKey( + profileId = key.profileId, + deviceName = key.deviceName, + key = key.sdkKey, + ) + ) { + is DomainResult.Success -> repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.READY, + lastErrorCode = null, + updatedAtEpochMillis = clock.now(), + ) + + is DomainResult.Failure -> { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = result.error.externalCode, + updatedAtEpochMillis = clock.now(), + ) + result + } + } + } +} + +private fun SavedInfraredKey.ref() = InfraredKeyRef( + profileId = profileId, + keyId = id, +) diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt new file mode 100644 index 0000000..d9204f3 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -0,0 +1,189 @@ +package com.flagship.abox.manager.domain.infrared + +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.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +data class InfraredLearningCall( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val key: String, +) + +data class InfraredStatusTransitionCall( + val ref: InfraredKeyRef, + val expectedStatuses: Set, + val targetStatus: InfraredKeyStatus, + val lastErrorCode: String?, + val updatedAtEpochMillis: Long, +) + +class InMemoryInfraredKeyRepository : InfraredKeyRepository { + private val keys = linkedMapOf() + private val state = MutableStateFlow>(emptyList()) + + var getError: DomainError? = null + var insertError: DomainError? = null + val transitionErrors = mutableMapOf() + + var onInsert: ((SavedInfraredKey) -> Unit)? = null + var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null + + val insertedKeys = mutableListOf() + val transitionCalls = mutableListOf() + + override fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = state.map { values -> + DomainResult.Success( + values.filter { it.profileId == profileId && it.deviceName == deviceName }, + ) + } + + override suspend fun getKey(ref: InfraredKeyRef): DomainResult { + getError?.let { return DomainResult.Failure(it) } + return DomainResult.Success(keys[ref]) + } + + override suspend fun insert(key: SavedInfraredKey): DomainResult { + insertedKeys += key + onInsert?.invoke(key) + insertError?.let { return DomainResult.Failure(it) } + keys[key.ref()] = key + publish() + return DomainResult.Success(Unit) + } + + override suspend fun transitionStatus( + ref: InfraredKeyRef, + expectedStatuses: Set, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + updatedAtEpochMillis: Long, + ): DomainResult { + val call = InfraredStatusTransitionCall( + ref = ref, + expectedStatuses = expectedStatuses, + targetStatus = targetStatus, + lastErrorCode = lastErrorCode, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + transitionCalls += call + onTransition?.invoke(call) + transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) } + + val existing = keys[ref] + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) + if (existing.status !in expectedStatuses) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key status changed"), + ) + } + keys[ref] = existing.copy( + status = targetStatus, + lastErrorCode = lastErrorCode, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + publish() + return DomainResult.Success(Unit) + } + + override suspend fun rename( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult { + val existing = keys[ref] + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) + keys[ref] = existing.copy( + displayName = displayName, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + publish() + return DomainResult.Success(Unit) + } + + override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult { + val existing = keys[ref] + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) + if (existing.status == InfraredKeyStatus.LEARNING) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"), + ) + } + keys.remove(ref) + publish() + return DomainResult.Success(Unit) + } + + override suspend fun failInterruptedLearning( + profileId: ProviderProfileId, + updatedAtEpochMillis: Long, + ): DomainResult { + var changed = 0 + keys.replaceAll { _, key -> + if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { + changed += 1 + key.copy( + status = InfraredKeyStatus.FAILED, + lastErrorCode = null, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + } else { + key + } + } + publish() + return DomainResult.Success(changed) + } + + fun seed(key: SavedInfraredKey) { + keys[key.ref()] = key + publish() + } + + fun onlyKey(): SavedInfraredKey = keys.values.single() + + private fun publish() { + state.value = keys.values.toList() + } +} + +class RecordingInfraredGateway : InfraredGateway { + var learnResult: DomainResult = DomainResult.Success(Unit) + var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult)? = null + val learnedKeys = mutableListOf() + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + learnedKeys += InfraredLearningCall(profileId, deviceName, key) + return onLearn?.invoke(profileId, deviceName, key) ?: learnResult + } + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = DomainResult.Success(emptyList()) +} + +fun SavedInfraredKey.ref() = InfraredKeyRef( + profileId = profileId, + keyId = id, +) diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt new file mode 100644 index 0000000..fc3f148 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -0,0 +1,255 @@ +package com.flagship.abox.manager.domain.infrared + +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 kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LearnInfraredKeyTest { + private val profile = ProviderProfileId("abox") + private val device = DeviceName("ir-1") + private val keyId = InfraredKeyId("key1") + private val clock = SteppingClock(100L) + private val factory = InfraredKeyFactory( + idGenerator = InfraredKeyIdGenerator { keyId }, + clock = clock, + ) + private val repository = InMemoryInfraredKeyRepository() + private val gateway = RecordingInfraredGateway() + private val learn = LearnInfraredKey(repository, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(repository, gateway, clock) + + @Test + fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest { + val events = mutableListOf() + repository.onInsert = { events += "insert:${it.status}" } + gateway.onLearn = { _, _, _ -> + events += "hardware" + DomainResult.Success(Unit) + } + repository.onTransition = { events += "transition:${it.targetStatus}" } + + val result = learn(profile, device, "Power") + + assertEquals(DomainResult.Success(keyId), result) + assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertEquals(null, repository.onlyKey().lastErrorCode) + } + + @Test + fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest { + val gatewayError = DomainError.DeviceOffline("20206") + gateway.learnResult = DomainResult.Failure(gatewayError) + + val result = learn(profile, device, "Power") + + assertEquals(gatewayError, (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) + assertEquals("20206", repository.onlyKey().lastErrorCode) + } + + @Test + fun insertFailureNeverCallsHardware() = runTest { + val insertError = DomainError.Unknown(detail = "database unavailable") + repository.insertError = insertError + + val result = learn(profile, device, "Power") + + assertEquals(insertError, (result as DomainResult.Failure).error) + assertTrue(gateway.learnedKeys.isEmpty()) + assertTrue(repository.transitionCalls.isEmpty()) + } + + @Test + fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest { + val result = learn(profile, device, " ") + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(repository.insertedKeys.isEmpty()) + assertTrue(repository.transitionCalls.isEmpty()) + assertTrue(gateway.learnedKeys.isEmpty()) + } + + @Test + fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest { + val transitionError = DomainError.Unknown(detail = "write conflict") + repository.transitionErrors[InfraredKeyStatus.READY] = transitionError + + val result = learn(profile, device, "Power") + + assertEquals(transitionError, (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) + assertEquals(1, gateway.learnedKeys.size) + } + + @Test + fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest { + val gatewayError = DomainError.DeviceOffline("20206") + gateway.learnResult = DomainResult.Failure(gatewayError) + repository.transitionErrors[InfraredKeyStatus.FAILED] = + DomainError.Unknown(detail = "write conflict") + + val result = learn(profile, device, "Power") + + assertEquals(gatewayError, (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) + assertEquals("20206", repository.transitionCalls.single().lastErrorCode) + } + + @Test + fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest { + learn(profile, device, "Power") + + val inserted = repository.insertedKeys.single() + val transition = repository.transitionCalls.single() + assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses) + assertEquals(inserted.ref(), transition.ref) + assertEquals(100L, inserted.createdAtEpochMillis) + assertEquals(100L, inserted.updatedAtEpochMillis) + assertEquals(101L, transition.updatedAtEpochMillis) + } + + @Test + fun retryReusesStableIdAndSdkKey() = runTest { + val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1") + repository.seed(original) + + val result = retry(original.ref()) + + assertEquals(DomainResult.Success(Unit), result) + assertEquals("app_ir_key1", gateway.learnedKeys.single().key) + assertEquals(InfraredKeyId("key1"), repository.onlyKey().id) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertEquals(0, repository.insertedKeys.size) + assertEquals(2, repository.transitionCalls.size) + assertEquals( + setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY), + repository.transitionCalls.first().expectedStatuses, + ) + assertEquals( + setOf(InfraredKeyStatus.LEARNING), + repository.transitionCalls.last().expectedStatuses, + ) + } + + @Test + fun retryRejectsAlreadyLearningKey() = runTest { + repository.seed(learningKey()) + + val result = retry(repository.onlyKey().ref()) + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(gateway.learnedKeys.isEmpty()) + assertTrue(repository.transitionCalls.isEmpty()) + } + + @Test + fun retryRejectsMissingKey() = runTest { + val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing"))) + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(gateway.learnedKeys.isEmpty()) + assertTrue(repository.transitionCalls.isEmpty()) + } + + @Test + fun retryReturnsGetFailureWithoutCallingHardware() = runTest { + val getError = DomainError.Unknown(detail = "database unavailable") + repository.getError = getError + + val result = retry(InfraredKeyRef(profile, keyId)) + + assertEquals(getError, (result as DomainResult.Failure).error) + assertTrue(gateway.learnedKeys.isEmpty()) + assertTrue(repository.transitionCalls.isEmpty()) + } + + @Test + fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest { + val original = failedKey() + repository.seed(original) + val transitionError = DomainError.Unknown(detail = "write conflict") + repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError + + val result = retry(original.ref()) + + assertEquals(transitionError, (result as DomainResult.Failure).error) + assertTrue(gateway.learnedKeys.isEmpty()) + assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) + } + + @Test + fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest { + val original = failedKey() + repository.seed(original) + val gatewayError = DomainError.DeviceOffline("20206") + gateway.learnResult = DomainResult.Failure(gatewayError) + + val result = retry(original.ref()) + + assertEquals(gatewayError, (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) + assertEquals("20206", repository.onlyKey().lastErrorCode) + } + + @Test + fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest { + val original = failedKey() + repository.seed(original) + val gatewayError = DomainError.DeviceOffline("20206") + gateway.learnResult = DomainResult.Failure(gatewayError) + repository.transitionErrors[InfraredKeyStatus.FAILED] = + DomainError.Unknown(detail = "write conflict") + + val result = retry(original.ref()) + + assertEquals(gatewayError, (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) + assertEquals("20206", repository.transitionCalls.last().lastErrorCode) + } + + @Test + fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest { + val original = failedKey() + repository.seed(original) + val transitionError = DomainError.Unknown(detail = "write conflict") + repository.transitionErrors[InfraredKeyStatus.READY] = transitionError + + val result = retry(original.ref()) + + assertEquals(transitionError, (result as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) + assertEquals(1, gateway.learnedKeys.size) + } + + private fun failedKey( + id: InfraredKeyId = keyId, + sdkKey: String = "app_ir_key1", + ) = SavedInfraredKey( + id = id, + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = sdkKey, + status = InfraredKeyStatus.FAILED, + lastErrorCode = "old-error", + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 2L, + ) + + private fun learningKey() = failedKey().copy( + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + ) +} + +private class SteppingClock(start: Long) : EpochMillisClock { + private var next = start + + override fun now(): Long = next++ +} -- 2.54.0 From ee60b75a32d6c2cd06cbcc46e6ea6e5a0aedc001 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 02:03:53 +0800 Subject: [PATCH 06/41] feat: manage saved infrared keys --- .../domain/infrared/ManageInfraredKey.kt | 65 +++++ .../infrared/InMemoryInfraredTestDoubles.kt | 40 ++- .../domain/infrared/LearnInfraredKeyTest.kt | 30 +++ .../domain/infrared/ManageInfraredKeyTest.kt | 232 ++++++++++++++++++ 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt new file mode 100644 index 0000000..37acd6f --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt @@ -0,0 +1,65 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class SendInfraredKey( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, +) { + suspend operator fun invoke(ref: InfraredKeyRef): DomainResult { + val key = when (val result = repository.getKey(ref)) { + is DomainResult.Success -> result.value + ?: return DomainResult.Failure( + DomainError.NoData(detail = "Infrared key not found"), + ) + + is DomainResult.Failure -> return result + } + if (key.status != InfraredKeyStatus.READY) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key is not ready"), + ) + } + return gateway.sendKey( + profileId = key.profileId, + deviceName = key.deviceName, + key = key.sdkKey, + ) + } +} + +class RenameInfraredKey( + private val repository: InfraredKeyRepository, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke( + ref: InfraredKeyRef, + displayName: String, + ): DomainResult { + val trimmed = displayName.trim() + if (trimmed.isEmpty()) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key display name is required"), + ) + } + return repository.rename(ref, trimmed, clock.now()) + } +} + +class DeleteInfraredKey( + private val repository: InfraredKeyRepository, +) { + suspend operator fun invoke(ref: InfraredKeyRef): DomainResult = + repository.deleteIfNotLearning(ref) +} + +class RecoverInterruptedInfraredLearning( + private val repository: InfraredKeyRepository, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke(profileId: ProviderProfileId): DomainResult = + repository.failInterruptedLearning(profileId, clock.now()) +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index d9204f3..55f8d8a 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -25,19 +25,43 @@ data class InfraredStatusTransitionCall( val updatedAtEpochMillis: Long, ) +data class InfraredSendCall( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val key: String, +) + +data class InfraredRenameCall( + val ref: InfraredKeyRef, + val displayName: String, + val updatedAtEpochMillis: Long, +) + +data class InfraredRecoveryCall( + val profileId: ProviderProfileId, + val updatedAtEpochMillis: Long, +) + class InMemoryInfraredKeyRepository : InfraredKeyRepository { private val keys = linkedMapOf() private val state = MutableStateFlow>(emptyList()) var getError: DomainError? = null var insertError: DomainError? = null + var renameError: DomainError? = null + var deleteError: DomainError? = null + var recoveryError: DomainError? = null val transitionErrors = mutableMapOf() var onInsert: ((SavedInfraredKey) -> Unit)? = null var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null val insertedKeys = mutableListOf() + val getCalls = mutableListOf() val transitionCalls = mutableListOf() + val renameCalls = mutableListOf() + val deleteCalls = mutableListOf() + val recoveryCalls = mutableListOf() override fun observeKeys( profileId: ProviderProfileId, @@ -49,6 +73,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { } override suspend fun getKey(ref: InfraredKeyRef): DomainResult { + getCalls += ref getError?.let { return DomainResult.Failure(it) } return DomainResult.Success(keys[ref]) } @@ -101,6 +126,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { displayName: String, updatedAtEpochMillis: Long, ): DomainResult { + renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis) + renameError?.let { return DomainResult.Failure(it) } val existing = keys[ref] ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) keys[ref] = existing.copy( @@ -112,6 +139,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { } override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult { + deleteCalls += ref + deleteError?.let { return DomainResult.Failure(it) } val existing = keys[ref] ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) if (existing.status == InfraredKeyStatus.LEARNING) { @@ -128,6 +157,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { profileId: ProviderProfileId, updatedAtEpochMillis: Long, ): DomainResult { + recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis) + recoveryError?.let { return DomainResult.Failure(it) } var changed = 0 keys.replaceAll { _, key -> if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { @@ -152,6 +183,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { fun onlyKey(): SavedInfraredKey = keys.values.single() + fun allKeys(): List = keys.values.toList() + private fun publish() { state.value = keys.values.toList() } @@ -159,8 +192,10 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { class RecordingInfraredGateway : InfraredGateway { var learnResult: DomainResult = DomainResult.Success(Unit) + var sendResult: DomainResult = DomainResult.Success(Unit) var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult)? = null val learnedKeys = mutableListOf() + val sentKeys = mutableListOf() override suspend fun learnKey( profileId: ProviderProfileId, @@ -175,7 +210,10 @@ class RecordingInfraredGateway : InfraredGateway { profileId: ProviderProfileId, deviceName: DeviceName, key: String, - ): DomainResult = DomainResult.Success(Unit) + ): DomainResult { + sentKeys += InfraredSendCall(profileId, deviceName, key) + return sendResult + } override suspend fun downloadCodes( profileId: ProviderProfileId, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index fc3f148..aa6b419 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -183,6 +183,36 @@ class LearnInfraredKeyTest { assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) } + @Test + fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest { + val original = failedKey() + repository.seed(original) + repository.onTransition = { call -> + if (call.targetStatus == InfraredKeyStatus.LEARNING) { + repository.seed(original.copy(status = InfraredKeyStatus.LEARNING)) + } + } + + val result = retry(original.ref()) + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(gateway.learnedKeys.isEmpty()) + assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) + } + + @Test + fun retryClearsPriorErrorWhileGatewayCallbackIsRunning() = runTest { + val original = failedKey() + repository.seed(original) + gateway.onLearn = { _, _, _ -> + assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) + assertEquals(null, repository.onlyKey().lastErrorCode) + DomainResult.Success(Unit) + } + + assertEquals(DomainResult.Success(Unit), retry(original.ref())) + } + @Test fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest { val original = failedKey() diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt new file mode 100644 index 0000000..e42241c --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt @@ -0,0 +1,232 @@ +package com.flagship.abox.manager.domain.infrared + +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 kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManageInfraredKeyTest { + private val profile = ProviderProfileId("abox") + private val otherProfile = ProviderProfileId("other") + private val repository = InMemoryInfraredKeyRepository() + private val gateway = RecordingInfraredGateway() + private val clock = SteppingManagementClock(500L) + private val send = SendInfraredKey(repository, gateway) + private val rename = RenameInfraredKey(repository, clock) + private val delete = DeleteInfraredKey(repository) + private val recover = RecoverInterruptedInfraredLearning(repository, clock) + + @Test + fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest { + val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power") + repository.seed(key) + + assertEquals(DomainResult.Success(Unit), send(key.ref())) + assertEquals( + InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"), + gateway.sentKeys.single(), + ) + assertEquals(listOf(key.ref()), repository.getCalls) + assertTrue(repository.transitionCalls.isEmpty()) + assertTrue(repository.renameCalls.isEmpty()) + assertTrue(repository.deleteCalls.isEmpty()) + assertTrue(repository.recoveryCalls.isEmpty()) + assertEquals(key, repository.onlyKey()) + } + + @Test + fun sendFailureDoesNotDemoteReadyKey() = runTest { + val key = readyKey() + repository.seed(key) + val gatewayError = DomainError.OperationFailed("20205") + gateway.sendResult = DomainResult.Failure(gatewayError) + + assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertTrue(repository.transitionCalls.isEmpty()) + } + + @Test + fun missingSendReturnsNoDataWithoutHardwareCall() = runTest { + val result = send(InfraredKeyRef(profile, InfraredKeyId("missing"))) + + assertTrue((result as DomainResult.Failure).error is DomainError.NoData) + assertEquals( + listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))), + repository.getCalls, + ) + assertTrue(gateway.sentKeys.isEmpty()) + } + + @Test + fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest { + repository.seed(failedKey()) + + val result = send(repository.onlyKey().ref()) + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(gateway.sentKeys.isEmpty()) + } + + @Test + fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest { + val repositoryError = DomainError.Unknown(detail = "database unavailable") + repository.getError = repositoryError + + val result = send(InfraredKeyRef(profile, InfraredKeyId("key1"))) + + assertEquals(repositoryError, (result as DomainResult.Failure).error) + assertTrue(gateway.sentKeys.isEmpty()) + } + + @Test + fun renameTrimsDisplayNameAndPreservesStableIdentity() = runTest { + val before = readyKey(displayName = "Power", sdkKey = "app_ir_power") + repository.seed(before) + + assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 ")) + + val after = repository.onlyKey() + assertEquals("空调开关", after.displayName) + assertEquals(before.sdkKey, after.sdkKey) + assertEquals(before.id, after.id) + assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis) + assertEquals(500L, after.updatedAtEpochMillis) + assertEquals( + InfraredRenameCall(before.ref(), "空调开关", 500L), + repository.renameCalls.single(), + ) + } + + @Test + fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest { + val key = readyKey() + repository.seed(key) + + val result = rename(key.ref(), " \t ") + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertTrue(repository.renameCalls.isEmpty()) + assertEquals(key, repository.onlyKey()) + } + + @Test + fun renameReturnsRepositoryFailure() = runTest { + val key = readyKey() + repository.seed(key) + val repositoryError = DomainError.Unknown(detail = "write failed") + repository.renameError = repositoryError + + val result = rename(key.ref(), "New name") + + assertEquals(repositoryError, (result as DomainResult.Failure).error) + assertEquals("Power", repository.onlyKey().displayName) + } + + @Test + fun deleteDelegatesAtomicLearningGuardToRepository() = runTest { + val key = learningKey() + repository.seed(key) + + val result = delete(key.ref()) + + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertEquals(listOf(key.ref()), repository.deleteCalls) + assertTrue(repository.getCalls.isEmpty()) + assertTrue(repository.allKeys().isNotEmpty()) + } + + @Test + fun deleteRemovesReadyKey() = runTest { + val key = readyKey() + repository.seed(key) + + assertEquals(DomainResult.Success(Unit), delete(key.ref())) + assertEquals(listOf(key.ref()), repository.deleteCalls) + assertTrue(repository.getCalls.isEmpty()) + assertTrue(repository.allKeys().isEmpty()) + } + + @Test + fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest { + val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old") + val learningTwo = learningKey(id = InfraredKeyId("two")) + val ready = readyKey(id = InfraredKeyId("ready")) + val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile) + repository.seed(learningOne) + repository.seed(learningTwo) + repository.seed(ready) + repository.seed(other) + + assertEquals(DomainResult.Success(2), recover(profile)) + assertEquals( + listOf(InfraredRecoveryCall(profile, 500L)), + repository.recoveryCalls, + ) + assertTrue(repository.getCalls.isEmpty()) + assertTrue(repository.transitionCalls.isEmpty()) + val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") } + assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED }) + assertTrue(recovered.all { it.lastErrorCode == null }) + assertTrue(recovered.all { it.updatedAtEpochMillis == 500L }) + assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status) + assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status) + assertTrue(gateway.learnedKeys.isEmpty()) + assertTrue(gateway.sentKeys.isEmpty()) + } + + @Test + fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest { + val repositoryError = DomainError.Unknown(detail = "write failed") + repository.recoveryError = repositoryError + + val result = recover(profile) + + assertEquals(repositoryError, (result as DomainResult.Failure).error) + assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls) + assertTrue(gateway.learnedKeys.isEmpty()) + assertTrue(gateway.sentKeys.isEmpty()) + } + + private fun readyKey( + id: InfraredKeyId = InfraredKeyId("key1"), + profileId: ProviderProfileId = profile, + deviceName: DeviceName = DeviceName("ir-1"), + displayName: String = "Power", + sdkKey: String = "app_ir_key1", + ) = SavedInfraredKey( + id = id, + profileId = profileId, + deviceName = deviceName, + displayName = displayName, + sdkKey = sdkKey, + status = InfraredKeyStatus.READY, + lastErrorCode = null, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 2L, + ) + + private fun failedKey() = readyKey().copy( + status = InfraredKeyStatus.FAILED, + lastErrorCode = "old-error", + ) + + private fun learningKey( + id: InfraredKeyId = InfraredKeyId("key1"), + profileId: ProviderProfileId = profile, + lastErrorCode: String? = null, + ) = readyKey(id = id, profileId = profileId).copy( + status = InfraredKeyStatus.LEARNING, + lastErrorCode = lastErrorCode, + ) +} + +private class SteppingManagementClock(start: Long) : EpochMillisClock { + private var next = start + + override fun now(): Long = next++ +} -- 2.54.0 From 0e82daaa235ef1be6cdf12d45f9cfa4048768b7e Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 02:17:30 +0800 Subject: [PATCH 07/41] fix: preserve infrared failure context --- .../domain/infrared/InfraredKeyFactory.kt | 1 + .../domain/infrared/InfraredKeyModels.kt | 10 +++++++- .../domain/infrared/InfraredKeyRepository.kt | 5 ++++ .../domain/infrared/LearnInfraredKey.kt | 5 ++++ .../infrared/InMemoryInfraredTestDoubles.kt | 5 ++++ .../domain/infrared/InfraredKeyFactoryTest.kt | 2 ++ .../domain/infrared/LearnInfraredKeyTest.kt | 16 ++++++++++++- .../domain/infrared/ManageInfraredKeyTest.kt | 23 +++++++++++++++++-- 8 files changed, 63 insertions(+), 4 deletions(-) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt index e7ef07c..0040df9 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -49,6 +49,7 @@ class InfraredKeyFactory( sdkKey = "app_ir_$canonicalId", status = InfraredKeyStatus.LEARNING, lastErrorCode = null, + lastErrorDetail = null, createdAtEpochMillis = now, updatedAtEpochMillis = now, ), diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt index 3092769..9c0a2ec 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -17,6 +17,13 @@ enum class InfraredKeyStatus { FAILED, } +const val INTERRUPTED_LEARNING_ERROR_DETAIL = + "Interrupted learning result is unknown; retry required" + +/** + * Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned, + * sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there. + */ data class SavedInfraredKey( val id: InfraredKeyId, val profileId: ProviderProfileId, @@ -25,6 +32,7 @@ data class SavedInfraredKey( val sdkKey: String, val status: InfraredKeyStatus, val lastErrorCode: String?, + val lastErrorDetail: String?, val createdAtEpochMillis: Long, val updatedAtEpochMillis: Long, -) \ No newline at end of file +) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt index 7b0e55c..164f5ef 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -20,6 +20,7 @@ interface InfraredKeyRepository { expectedStatuses: Set, targetStatus: InfraredKeyStatus, lastErrorCode: String?, + lastErrorDetail: String?, updatedAtEpochMillis: Long, ): DomainResult @@ -31,6 +32,10 @@ interface InfraredKeyRepository { suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult + /** + * Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor + * error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. + */ suspend fun failInterruptedLearning( profileId: ProviderProfileId, updatedAtEpochMillis: Long, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 661c91e..9f6155b 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -40,6 +40,7 @@ class LearnInfraredKey( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.READY, lastErrorCode = null, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) ) { @@ -53,6 +54,7 @@ class LearnInfraredKey( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.FAILED, lastErrorCode = result.error.externalCode, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) result @@ -90,6 +92,7 @@ class RetryInfraredKeyLearning( ), targetStatus = InfraredKeyStatus.LEARNING, lastErrorCode = null, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) ) { @@ -109,6 +112,7 @@ class RetryInfraredKeyLearning( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.READY, lastErrorCode = null, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) @@ -118,6 +122,7 @@ class RetryInfraredKeyLearning( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.FAILED, lastErrorCode = result.error.externalCode, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) result diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index 55f8d8a..db2a57f 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -22,6 +22,7 @@ data class InfraredStatusTransitionCall( val expectedStatuses: Set, val targetStatus: InfraredKeyStatus, val lastErrorCode: String?, + val lastErrorDetail: String?, val updatedAtEpochMillis: Long, ) @@ -92,6 +93,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { expectedStatuses: Set, targetStatus: InfraredKeyStatus, lastErrorCode: String?, + lastErrorDetail: String?, updatedAtEpochMillis: Long, ): DomainResult { val call = InfraredStatusTransitionCall( @@ -99,6 +101,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { expectedStatuses = expectedStatuses, targetStatus = targetStatus, lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, updatedAtEpochMillis = updatedAtEpochMillis, ) transitionCalls += call @@ -115,6 +118,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { keys[ref] = existing.copy( status = targetStatus, lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, updatedAtEpochMillis = updatedAtEpochMillis, ) publish() @@ -166,6 +170,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { key.copy( status = InfraredKeyStatus.FAILED, lastErrorCode = null, + lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, updatedAtEpochMillis = updatedAtEpochMillis, ) } else { diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt index 2adbe1e..dcc17e0 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -25,6 +25,8 @@ class InfraredKeyFactoryTest { assertEquals(id, result.value.id) assertEquals("app_ir_01j5molerabc123", result.value.sdkKey) assertEquals(InfraredKeyStatus.LEARNING, result.value.status) + assertEquals(null, result.value.lastErrorCode) + assertEquals(null, result.value.lastErrorDetail) assertEquals("空调开关", result.value.displayName) assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis) assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index aa6b419..0f4e3d4 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -39,6 +39,7 @@ class LearnInfraredKeyTest { assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events) assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) assertEquals(null, repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) } @Test @@ -51,6 +52,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) assertEquals("20206", repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) } @Test @@ -99,6 +101,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) assertEquals("20206", repository.transitionCalls.single().lastErrorCode) + assertEquals(null, repository.transitionCalls.single().lastErrorDetail) } @Test @@ -201,16 +204,23 @@ class LearnInfraredKeyTest { } @Test - fun retryClearsPriorErrorWhileGatewayCallbackIsRunning() = runTest { + fun retryUsesSeparateTimestampsAndClearsErrorsBeforeHardware() = runTest { val original = failedKey() repository.seed(original) gateway.onLearn = { _, _, _ -> assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) assertEquals(null, repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) + assertEquals(100L, repository.onlyKey().updatedAtEpochMillis) DomainResult.Success(Unit) } assertEquals(DomainResult.Success(Unit), retry(original.ref())) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertEquals(null, repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) + assertEquals(101L, repository.onlyKey().updatedAtEpochMillis) + assertEquals(listOf(100L, 101L), repository.transitionCalls.map { it.updatedAtEpochMillis }) } @Test @@ -225,6 +235,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) assertEquals("20206", repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) } @Test @@ -241,6 +252,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) assertEquals("20206", repository.transitionCalls.last().lastErrorCode) + assertEquals(null, repository.transitionCalls.last().lastErrorDetail) } @Test @@ -268,6 +280,7 @@ class LearnInfraredKeyTest { sdkKey = sdkKey, status = InfraredKeyStatus.FAILED, lastErrorCode = "old-error", + lastErrorDetail = "old-detail", createdAtEpochMillis = 1L, updatedAtEpochMillis = 2L, ) @@ -275,6 +288,7 @@ class LearnInfraredKeyTest { private fun learningKey() = failedKey().copy( status = InfraredKeyStatus.LEARNING, lastErrorCode = null, + lastErrorDetail = null, ) } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt index e42241c..9e2c655 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt @@ -46,6 +46,10 @@ class ManageInfraredKeyTest { gateway.sendResult = DomainResult.Failure(gatewayError) assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error) + assertEquals( + listOf(InfraredSendCall(profile, DeviceName("ir-1"), "app_ir_key1")), + gateway.sentKeys, + ) assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) assertTrue(repository.transitionCalls.isEmpty()) } @@ -84,8 +88,12 @@ class ManageInfraredKeyTest { } @Test - fun renameTrimsDisplayNameAndPreservesStableIdentity() = runTest { - val before = readyKey(displayName = "Power", sdkKey = "app_ir_power") + fun renameTrimsDisplayNameAndPreservesStableIdentityAndErrors() = runTest { + val before = readyKey(displayName = "Power", sdkKey = "app_ir_power").copy( + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "fixed-detail", + ) repository.seed(before) assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 ")) @@ -94,6 +102,8 @@ class ManageInfraredKeyTest { assertEquals("空调开关", after.displayName) assertEquals(before.sdkKey, after.sdkKey) assertEquals(before.id, after.id) + assertEquals(before.lastErrorCode, after.lastErrorCode) + assertEquals(before.lastErrorDetail, after.lastErrorDetail) assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis) assertEquals(500L, after.updatedAtEpochMillis) assertEquals( @@ -172,6 +182,11 @@ class ManageInfraredKeyTest { val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") } assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED }) assertTrue(recovered.all { it.lastErrorCode == null }) + assertTrue( + recovered.all { + it.lastErrorDetail == "Interrupted learning result is unknown; retry required" + }, + ) assertTrue(recovered.all { it.updatedAtEpochMillis == 500L }) assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status) assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status) @@ -206,6 +221,7 @@ class ManageInfraredKeyTest { sdkKey = sdkKey, status = InfraredKeyStatus.READY, lastErrorCode = null, + lastErrorDetail = null, createdAtEpochMillis = 1L, updatedAtEpochMillis = 2L, ) @@ -213,15 +229,18 @@ class ManageInfraredKeyTest { private fun failedKey() = readyKey().copy( status = InfraredKeyStatus.FAILED, lastErrorCode = "old-error", + lastErrorDetail = "old-detail", ) private fun learningKey( id: InfraredKeyId = InfraredKeyId("key1"), profileId: ProviderProfileId = profile, lastErrorCode: String? = null, + lastErrorDetail: String? = null, ) = readyKey(id = id, profileId = profileId).copy( status = InfraredKeyStatus.LEARNING, lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, ) } -- 2.54.0 From 82afee3a1bd7aa915f5317762cdb7e834741f3cd Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 02:33:55 +0800 Subject: [PATCH 08/41] test: add scriptable infrared fakes --- .../testing/fakes/FakeDeviceGateways.kt | 30 +- .../fakes/FakeInfraredKeyRepository.kt | 162 +++++++++++ .../testing/fakes/FakeDeviceGatewaysTest.kt | 52 ++++ .../fakes/FakeInfraredKeyRepositoryTest.kt | 264 ++++++++++++++++++ 4 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index cbd527b..89323d3 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -82,6 +82,8 @@ class FakeInfraredGateway : InfraredGateway { val learnedKeys: MutableList = Collections.synchronizedList(mutableListOf()) val downloadCalls: MutableList = Collections.synchronizedList(mutableListOf()) + private val sendResults = ConcurrentHashMap>() + private val learnResults = ConcurrentHashMap>() @Volatile var sendResult: DomainResult = DomainResult.Success(Unit) @@ -98,7 +100,7 @@ class FakeInfraredGateway : InfraredGateway { key: String, ): DomainResult { sentKeys += InfraredCall(profileId, deviceName, key) - return sendResult + return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult } override suspend fun learnKey( @@ -107,7 +109,25 @@ class FakeInfraredGateway : InfraredGateway { key: String, ): DomainResult { learnedKeys += InfraredCall(profileId, deviceName, key) - return learnResult + return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult + } + + fun setSendResult( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + result: DomainResult, + ) { + sendResults[InfraredOperationKey(profileId, deviceName, key)] = result + } + + fun setLearnResult( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + result: DomainResult, + ) { + learnResults[InfraredOperationKey(profileId, deviceName, key)] = result } override suspend fun downloadCodes( @@ -131,6 +151,12 @@ data class InfraredDownloadCall( val codes: List, ) +private data class InfraredOperationKey( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val key: String, +) + private data class DeviceKey( val profileId: ProviderProfileId, val deviceName: DeviceName, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt new file mode 100644 index 0000000..f08509c --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt @@ -0,0 +1,162 @@ +package com.flagship.abox.manager.testing.fakes + +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.MutableStateFlow +import kotlinx.coroutines.flow.map + +class FakeInfraredKeyRepository : InfraredKeyRepository { + private val lock = Any() + private val keys = linkedMapOf() + private val state = MutableStateFlow>(emptyList()) + + override fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = state.map { values -> + DomainResult.Success( + values.filter { it.profileId == profileId && it.deviceName == deviceName }, + ) + } + + override suspend fun getKey(ref: InfraredKeyRef): DomainResult = + synchronized(lock) { + DomainResult.Success(keys[ref]) + } + + override suspend fun insert(key: SavedInfraredKey): DomainResult = synchronized(lock) { + val ref = key.ref() + if (keys.keys.any { it.keyId == key.id }) { + return@synchronized invalidRequest("Infrared key id already exists") + } + if (keys.values.any { + it.profileId == key.profileId && + it.deviceName == key.deviceName && + it.sdkKey == key.sdkKey + } + ) { + return@synchronized invalidRequest("Infrared key binding already exists") + } + keys[ref] = key + publishLocked() + DomainResult.Success(Unit) + } + + override suspend fun transitionStatus( + ref: InfraredKeyRef, + expectedStatuses: Set, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + lastErrorDetail: String?, + updatedAtEpochMillis: Long, + ): DomainResult = synchronized(lock) { + val existing = keys[ref] ?: return@synchronized keyNotFound() + if (existing.status !in expectedStatuses) { + return@synchronized invalidRequest("Infrared key status changed") + } + keys[ref] = existing.copy( + status = targetStatus, + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + publishLocked() + DomainResult.Success(Unit) + } + + override suspend fun rename( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult = synchronized(lock) { + val existing = keys[ref] ?: return@synchronized keyNotFound() + keys[ref] = existing.copy( + displayName = displayName, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + publishLocked() + DomainResult.Success(Unit) + } + + override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult = + synchronized(lock) { + val existing = keys[ref] ?: return@synchronized keyNotFound() + if (existing.status == InfraredKeyStatus.LEARNING) { + return@synchronized invalidRequest("Cannot delete a learning infrared key") + } + keys.remove(ref) + publishLocked() + DomainResult.Success(Unit) + } + + override suspend fun failInterruptedLearning( + profileId: ProviderProfileId, + updatedAtEpochMillis: Long, + ): DomainResult = synchronized(lock) { + var changed = 0 + keys.replaceAll { _, key -> + if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { + changed += 1 + key.copy( + status = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + } else { + key + } + } + if (changed > 0) publishLocked() + DomainResult.Success(changed) + } + + private fun publishLocked() { + state.value = keys.values.toList() + } + + private fun keyNotFound(): DomainResult.Failure = + invalidRequest("Infrared key not found") + + private fun invalidRequest(detail: String): DomainResult.Failure = + DomainResult.Failure(DomainError.InvalidRequest(detail = detail)) +} + +class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository { + private val lock = Any() + private val deviceNames = MutableStateFlow>(emptyMap()) + + override fun observeDeviceName( + profileId: ProviderProfileId, + ): Flow> = deviceNames.map { values -> + DomainResult.Success(values[profileId]) + } + + override suspend fun setDeviceName( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = synchronized(lock) { + val value = deviceName.value + if (value.isBlank() || value != value.trim()) { + return@synchronized DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared device name must be trimmed and non-blank"), + ) + } + deviceNames.value = deviceNames.value + (profileId to deviceName) + DomainResult.Success(Unit) + } +} + +private fun SavedInfraredKey.ref() = InfraredKeyRef( + profileId = profileId, + keyId = id, +) diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt index 8b46b3a..b162310 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt @@ -54,4 +54,56 @@ class FakeDeviceGatewaysTest { assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single()) assertTrue(download is DomainResult.Success) } + + @Test + fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest { + val gateway = FakeInfraredGateway() + val infraredDevice = DeviceName("ir-1") + gateway.setLearnResult( + profileOne, + infraredDevice, + "power", + DomainResult.Failure(DomainError.DeviceOffline("20206")), + ) + + assertTrue(gateway.learnKey(profileOne, infraredDevice, "power") is DomainResult.Failure) + assertEquals( + DomainResult.Success(Unit), + gateway.learnKey(profileTwo, infraredDevice, "power"), + ) + assertEquals( + DomainResult.Success(Unit), + gateway.learnKey(profileOne, infraredDevice, "volume-up"), + ) + assertEquals( + DomainResult.Success(Unit), + gateway.learnKey(profileOne, DeviceName("ir-2"), "power"), + ) + } + + @Test + fun infraredSendResultsCanDifferByProfileDeviceAndKey() = runTest { + val gateway = FakeInfraredGateway() + val infraredDevice = DeviceName("ir-1") + gateway.setSendResult( + profileOne, + infraredDevice, + "power", + DomainResult.Failure(DomainError.DeviceOffline("20206")), + ) + + assertTrue(gateway.sendKey(profileOne, infraredDevice, "power") is DomainResult.Failure) + assertEquals( + DomainResult.Success(Unit), + gateway.sendKey(profileTwo, infraredDevice, "power"), + ) + assertEquals( + DomainResult.Success(Unit), + gateway.sendKey(profileOne, infraredDevice, "volume-up"), + ) + assertEquals( + DomainResult.Success(Unit), + gateway.sendKey(profileOne, DeviceName("ir-2"), "power"), + ) + } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt new file mode 100644 index 0000000..df699a1 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt @@ -0,0 +1,264 @@ +package com.flagship.abox.manager.testing.fakes + +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.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.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeInfraredKeyRepositoryTest { + private val profileOne = ProviderProfileId("one") + private val profileTwo = ProviderProfileId("two") + private val deviceOne = DeviceName("ir-1") + private val deviceTwo = DeviceName("ir-2") + + @Test + fun observeKeysEmitsChangesAndFiltersByProfileAndDevice() = runTest { + val repository = FakeInfraredKeyRepository() + val emissions = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + repository.observeKeys(profileOne, deviceOne).take(2).toList() + } + + repository.insert(savedKey(id = "matching")) + + assertEquals( + listOf( + DomainResult.Success(emptyList()), + DomainResult.Success(listOf(savedKey(id = "matching"))), + ), + emissions.await(), + ) + + repository.insert(savedKey(id = "other-profile", profileId = profileTwo)) + repository.insert(savedKey(id = "other-device", deviceName = deviceTwo)) + + assertEquals( + DomainResult.Success(listOf(savedKey(id = "matching"))), + repository.observeKeys(profileOne, deviceOne).first(), + ) + } + + @Test + fun getKeyReturnsOnlyTheMatchingProfileAndId() = runTest { + val repository = FakeInfraredKeyRepository() + repository.insert(savedKey(id = "power")) + + assertEquals( + DomainResult.Success(savedKey(id = "power")), + repository.getKey(InfraredKeyRef(profileOne, InfraredKeyId("power"))), + ) + assertEquals( + DomainResult.Success(null), + repository.getKey(InfraredKeyRef(profileTwo, InfraredKeyId("power"))), + ) + } + + @Test + fun insertRejectsDuplicateIdAndDuplicateProviderDeviceSdkBinding() = runTest { + val repository = FakeInfraredKeyRepository() + assertEquals(DomainResult.Success(Unit), repository.insert(savedKey(id = "power"))) + + val duplicateId = repository.insert( + savedKey( + id = "power", + profileId = profileTwo, + deviceName = deviceTwo, + sdkKey = "other-sdk-key", + ), + ) + val duplicateBinding = repository.insert( + savedKey(id = "volume", displayName = "Volume"), + ) + + assertInvalidRequest(duplicateId) + assertInvalidRequest(duplicateBinding) + assertEquals( + DomainResult.Success(listOf(savedKey(id = "power"))), + repository.observeKeys(profileOne, deviceOne).first(), + ) + } + + @Test + fun transitionStatusRequiresExpectedStatusAndUpdatesFailureContextAtomically() = runTest { + val repository = FakeInfraredKeyRepository() + val ref = InfraredKeyRef(profileOne, InfraredKeyId("power")) + repository.insert(savedKey(id = "power", status = InfraredKeyStatus.LEARNING)) + + val rejected = repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.READY), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "offline", + updatedAtEpochMillis = 20L, + ) + assertInvalidRequest(rejected) + assertEquals(InfraredKeyStatus.LEARNING, repository.successfulKey(ref)?.status) + assertNull(repository.successfulKey(ref)?.lastErrorCode) + assertNull(repository.successfulKey(ref)?.lastErrorDetail) + + assertEquals( + DomainResult.Success(Unit), + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "offline", + updatedAtEpochMillis = 20L, + ), + ) + assertEquals( + savedKey( + id = "power", + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "offline", + updatedAtEpochMillis = 20L, + ), + repository.successfulKey(ref), + ) + } + + @Test + fun renameUpdatesDisplayNameAndTimestamp() = runTest { + val repository = FakeInfraredKeyRepository() + val ref = InfraredKeyRef(profileOne, InfraredKeyId("power")) + repository.insert(savedKey(id = "power", status = InfraredKeyStatus.READY)) + + assertEquals( + DomainResult.Success(Unit), + repository.rename(ref, displayName = "Main Power", updatedAtEpochMillis = 30L), + ) + assertEquals("Main Power", repository.successfulKey(ref)?.displayName) + assertEquals(30L, repository.successfulKey(ref)?.updatedAtEpochMillis) + } + + @Test + fun deleteRejectsLearningKeysAndRemovesOtherStatuses() = runTest { + val repository = FakeInfraredKeyRepository() + val learningRef = InfraredKeyRef(profileOne, InfraredKeyId("learning")) + val readyRef = InfraredKeyRef(profileOne, InfraredKeyId("ready")) + repository.insert(savedKey(id = "learning", sdkKey = "learning", status = InfraredKeyStatus.LEARNING)) + repository.insert(savedKey(id = "ready", sdkKey = "ready", status = InfraredKeyStatus.READY)) + + assertInvalidRequest(repository.deleteIfNotLearning(learningRef)) + assertEquals(DomainResult.Success(Unit), repository.deleteIfNotLearning(readyRef)) + assertTrue(repository.successfulKey(learningRef) != null) + assertNull(repository.successfulKey(readyRef)) + } + + @Test + fun failInterruptedLearningRecoversOnlyLearningKeysForOneProfileWithFixedDetail() = runTest { + val repository = FakeInfraredKeyRepository() + repository.insert(savedKey(id = "one-learning", sdkKey = "one-learning")) + repository.insert( + savedKey( + id = "one-ready", + sdkKey = "one-ready", + status = InfraredKeyStatus.READY, + ), + ) + repository.insert( + savedKey( + id = "two-learning", + profileId = profileTwo, + sdkKey = "two-learning", + ), + ) + + assertEquals( + DomainResult.Success(1), + repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 40L), + ) + + val recovered = repository.successfulKey( + InfraredKeyRef(profileOne, InfraredKeyId("one-learning")), + ) + assertEquals(InfraredKeyStatus.FAILED, recovered?.status) + assertNull(recovered?.lastErrorCode) + assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, recovered?.lastErrorDetail) + assertEquals(40L, recovered?.updatedAtEpochMillis) + assertEquals( + InfraredKeyStatus.READY, + repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("one-ready")))?.status, + ) + assertEquals( + InfraredKeyStatus.LEARNING, + repository.successfulKey(InfraredKeyRef(profileTwo, InfraredKeyId("two-learning")))?.status, + ) + assertEquals( + DomainResult.Success(0), + repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 50L), + ) + } + + @Test + fun deviceConfigurationIsIsolatedByProfile() = runTest { + val repository = FakeInfraredDeviceConfigurationRepository() + + assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first()) + assertEquals( + DomainResult.Success(Unit), + repository.setDeviceName(profileOne, deviceOne), + ) + assertEquals(DomainResult.Success(deviceOne), repository.observeDeviceName(profileOne).first()) + assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileTwo).first()) + } + + @Test + fun deviceConfigurationRejectsBlankOrSurroundingWhitespace() = runTest { + val repository = FakeInfraredDeviceConfigurationRepository() + + assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" "))) + assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ir-1"))) + assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName("ir-1 "))) + assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first()) + } + + private fun savedKey( + id: String, + profileId: ProviderProfileId = profileOne, + deviceName: DeviceName = deviceOne, + displayName: String = "Power", + sdkKey: String = "power", + status: InfraredKeyStatus = InfraredKeyStatus.LEARNING, + lastErrorCode: String? = null, + lastErrorDetail: String? = null, + updatedAtEpochMillis: Long = 10L, + ) = SavedInfraredKey( + id = InfraredKeyId(id), + profileId = profileId, + deviceName = deviceName, + displayName = displayName, + sdkKey = sdkKey, + status = status, + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, + createdAtEpochMillis = 10L, + updatedAtEpochMillis = updatedAtEpochMillis, + ) + + private suspend fun FakeInfraredKeyRepository.successfulKey( + ref: InfraredKeyRef, + ): SavedInfraredKey? = (getKey(ref) as DomainResult.Success).value + + private fun assertInvalidRequest(result: DomainResult<*>) { + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + } +} -- 2.54.0 From 616d97ca039434461adff1f5cc2b0725500b13c0 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 02:43:19 +0800 Subject: [PATCH 09/41] fix: snapshot infrared fake calls --- .../testing/fakes/FakeDeviceGateways.kt | 16 +++-- .../testing/fakes/FakeDeviceGatewaysTest.kt | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index 89323d3..12584ff 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -12,8 +12,8 @@ import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.domain.SocketStatus import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.domain.TemperatureHumidityReading -import java.util.Collections import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList class FakeSocketGateway : SocketGateway { private val states = ConcurrentHashMap() @@ -78,10 +78,9 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway { } class FakeInfraredGateway : InfraredGateway { - val sentKeys: MutableList = Collections.synchronizedList(mutableListOf()) - val learnedKeys: MutableList = Collections.synchronizedList(mutableListOf()) - val downloadCalls: MutableList = - Collections.synchronizedList(mutableListOf()) + val sentKeys = CopyOnWriteArrayList() + val learnedKeys = CopyOnWriteArrayList() + val downloadCalls = CopyOnWriteArrayList() private val sendResults = ConcurrentHashMap>() private val learnResults = ConcurrentHashMap>() @@ -134,9 +133,12 @@ class FakeInfraredGateway : InfraredGateway { profileId: ProviderProfileId, codes: List, ): DomainResult> { + val codesSnapshot = codes.toList() downloadError?.let { return DomainResult.Failure(it) } - downloadCalls += InfraredDownloadCall(profileId, codes) - return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) + downloadCalls += InfraredDownloadCall(profileId, codesSnapshot) + return DomainResult.Success( + codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) }, + ) } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt index b162310..432ea50 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt @@ -4,8 +4,14 @@ 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.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.SocketPowerState +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -55,6 +61,69 @@ class FakeDeviceGatewaysTest { assertTrue(download is DomainResult.Success) } + @Test + fun infraredDownloadRecordsAndReturnsAnInputSnapshot() = runTest { + val gateway = FakeInfraredGateway() + val code = InfraredCode(DeviceName("ir-1"), "power", "code-value") + val mutableCodes = mutableListOf(code) + + val result = gateway.downloadCodes(profileOne, mutableCodes) + mutableCodes.clear() + + assertEquals( + InfraredDownloadCall(profileOne, listOf(code)), + gateway.downloadCalls.single(), + ) + assertEquals( + DomainResult.Success(listOf(InfraredCodeDownloadResult(code, successful = true))), + result, + ) + } + + @Test + fun infraredCallLogCanBeIteratedSafelyAroundConcurrentWrites() = runTest { + val gateway = FakeInfraredGateway() + val deviceName = DeviceName("ir-1") + gateway.sendKey(profileOne, deviceName, "initial") + val staleIterators = List(10) { gateway.sentKeys.iterator() } + val start = CountDownLatch(1) + val failures = ConcurrentLinkedQueue() + val writerCount = 4 + val callsPerWriter = 100 + + val writers = List(writerCount) { writerIndex -> + launch(Dispatchers.Default) { + start.await() + repeat(callsPerWriter) { callIndex -> + gateway.sendKey(profileOne, deviceName, "$writerIndex-$callIndex") + } + } + } + val reader = launch(Dispatchers.Default) { + start.await() + repeat(100) { + try { + gateway.sentKeys.forEach { call -> call.key.length } + } catch (failure: Throwable) { + failures += failure + } + } + } + + start.countDown() + (writers + reader).joinAll() + staleIterators.forEach { iterator -> + try { + iterator.forEachRemaining { call -> call.key.length } + } catch (failure: Throwable) { + failures += failure + } + } + + assertTrue(failures.toList().joinToString(), failures.isEmpty()) + assertEquals(1 + writerCount * callsPerWriter, gateway.sentKeys.size) + } + @Test fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest { val gateway = FakeInfraredGateway() -- 2.54.0 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 10/41] 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") -- 2.54.0 From 9ccbfbc297902f58ef9c96b2729f36a1a3c2a2d4 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:11:14 +0800 Subject: [PATCH 11/41] feat: coordinate infrared runtime state --- .../InfraredLearningCoordinator.kt | 27 +++ .../infrared/di/InfraredFeatureModule.kt | 184 ++++++++++++++++++ .../DataStoreInfraredRuntimeModeStore.kt | 58 ++++++ .../infrared/runtime/InfraredRuntimeMode.kt | 15 ++ .../runtime/InfraredRuntimeProfileResolver.kt | 12 ++ .../InfraredLearningCoordinatorTest.kt | 76 ++++++++ .../InfraredRuntimeProfileResolverTest.kt | 21 ++ build.gradle.kts | 1 + 8 files changed, 394 insertions(+) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt new file mode 100644 index 0000000..8dbc7ef --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.infrared.application + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class InfraredLearningCoordinator( + private val learnInfraredKey: LearnInfraredKey, + private val retryInfraredKeyLearning: RetryInfraredKeyLearning, + private val externalScope: CoroutineScope, +) { + fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) { + externalScope.launch { + learnInfraredKey(profileId, deviceName, displayName) + } + } + + fun retry(ref: InfraredKeyRef) { + externalScope.launch { + retryInfraredKeyLearning(ref) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt new file mode 100644 index 0000000..5e2f51f --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -0,0 +1,184 @@ +package com.flagship.abox.manager.infrared.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.room.Room +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator +import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository +import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.data.local.InfraredDao +import com.flagship.abox.manager.infrared.data.local.InfraredDatabase +import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository +import com.flagship.abox.manager.infrared.runtime.DataStoreInfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import java.util.UUID +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class InfraredApplicationScope + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class InfraredDataStore + +@Module +@InstallIn(SingletonComponent::class) +object InfraredFeatureModule { + + @Provides + @Singleton + fun provideInfraredDatabase( + @ApplicationContext context: Context, + ): InfraredDatabase { + return Room.databaseBuilder( + context, + InfraredDatabase::class.java, + "abox_infrared.db", + ).build() + } + + @Provides + @Singleton + fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao() + + @Provides + @Singleton + fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository = + RoomInfraredKeyRepository(dao) + + @Provides + @Singleton + fun provideInfraredKeyRepository( + repository: RoomInfraredKeyRepository, + ): InfraredKeyRepository = repository + + @Provides + @Singleton + fun provideInfraredDeviceConfigurationRepository( + repository: RoomInfraredKeyRepository, + ): InfraredDeviceConfigurationRepository = repository + + @Provides + @Singleton + @InfraredDataStore + fun provideInfraredPreferencesDataStore( + @ApplicationContext context: Context, + ): DataStore { + return PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("infrared_preferences") }, + ) + } + + @Provides + @Singleton + fun provideInfraredRuntimeModeStore( + @InfraredDataStore dataStore: DataStore, + ): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore) + + @Provides + @Singleton + fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver = + InfraredRuntimeProfileResolver() + + @Provides + fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator = + InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) } + + @Provides + fun provideEpochMillisClock(): EpochMillisClock = + EpochMillisClock { System.currentTimeMillis() } + + @Provides + @Singleton + fun provideInfraredKeyFactory( + idGenerator: InfraredKeyIdGenerator, + clock: EpochMillisClock, + ): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock) + + @Provides + @Singleton + @InfraredApplicationScope + fun provideInfraredApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Singleton + fun provideLearnInfraredKey( + repository: InfraredKeyRepository, + gateway: InfraredGateway, + factory: InfraredKeyFactory, + clock: EpochMillisClock, + ): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock) + + @Provides + @Singleton + fun provideRetryInfraredKeyLearning( + repository: InfraredKeyRepository, + gateway: InfraredGateway, + clock: EpochMillisClock, + ): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock) + + @Provides + @Singleton + fun provideSendInfraredKey( + repository: InfraredKeyRepository, + gateway: InfraredGateway, + ): SendInfraredKey = SendInfraredKey(repository, gateway) + + @Provides + @Singleton + fun provideRenameInfraredKey( + repository: InfraredKeyRepository, + clock: EpochMillisClock, + ): RenameInfraredKey = RenameInfraredKey(repository, clock) + + @Provides + @Singleton + fun provideDeleteInfraredKey( + repository: InfraredKeyRepository, + ): DeleteInfraredKey = DeleteInfraredKey(repository) + + @Provides + @Singleton + fun provideRecoverInterruptedInfraredLearning( + repository: InfraredKeyRepository, + clock: EpochMillisClock, + ): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock) + + @Provides + @Singleton + fun provideInfraredLearningCoordinator( + learnInfraredKey: LearnInfraredKey, + retryInfraredKeyLearning: RetryInfraredKeyLearning, + @InfraredApplicationScope scope: CoroutineScope, + ): InfraredLearningCoordinator = InfraredLearningCoordinator( + learnInfraredKey = learnInfraredKey, + retryInfraredKeyLearning = retryInfraredKeyLearning, + externalScope = scope, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt new file mode 100644 index 0000000..1ce2c0c --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt @@ -0,0 +1,58 @@ +package com.flagship.abox.manager.infrared.runtime + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.IOException + +class DataStoreInfraredRuntimeModeStore( + private val dataStore: DataStore, +) : InfraredRuntimeModeStore { + + companion object { + private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode") + } + + override val mode: Flow = dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(androidx.datastore.preferences.core.emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + val storedName = preferences[KEY_RUNTIME_MODE] + if (storedName != null) { + try { + InfraredRuntimeMode.valueOf(storedName) + } catch (_: IllegalArgumentException) { + InfraredRuntimeMode.FAKE + } + } else { + InfraredRuntimeMode.FAKE + } + } + + override suspend fun currentMode(): InfraredRuntimeMode { + return mode.first() + } + + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + return try { + dataStore.edit { preferences -> + preferences[KEY_RUNTIME_MODE] = mode.name + } + DomainResult.Success(Unit) + } catch (throwable: Throwable) { + DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode")) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt new file mode 100644 index 0000000..15f540d --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt @@ -0,0 +1,15 @@ +package com.flagship.abox.manager.infrared.runtime + +import com.flagship.abox.manager.domain.DomainResult +import kotlinx.coroutines.flow.Flow + +enum class InfraredRuntimeMode { + FAKE, + REAL_ABSDK, +} + +interface InfraredRuntimeModeStore { + val mode: Flow + suspend fun currentMode(): InfraredRuntimeMode + suspend fun setMode(mode: InfraredRuntimeMode): DomainResult +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt new file mode 100644 index 0000000..df0f84d --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt @@ -0,0 +1,12 @@ +package com.flagship.abox.manager.infrared.runtime + +import com.flagship.abox.manager.domain.ProviderProfileId + +class InfraredRuntimeProfileResolver { + fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId { + return when (mode) { + InfraredRuntimeMode.REAL_ABSDK -> realProfileId + InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake") + } + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt new file mode 100644 index 0000000..d9e2393 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt @@ -0,0 +1,76 @@ +package com.flagship.abox.manager.infrared.application + +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.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +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.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredLearningCoordinatorTest { + + private val profile = ProviderProfileId("profile1") + private val device = DeviceName("ir-1") + private val clock = EpochMillisClock { 1_000L } + private val repository = FakeInfraredKeyRepository() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock) + + private val learn = LearnInfraredKey(repository, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(repository, gateway, clock) + + @Test + fun learnRunsAsynchronouslyInExternalScope() = runTest { + val coordinator = InfraredLearningCoordinator(learn, retry, this) + + coordinator.learn(profile, device, "Power") + advanceUntilIdle() + + val keysResult = repository.observeKeys(profile, device).first() + assertTrue(keysResult is DomainResult.Success) + val keys = (keysResult as DomainResult.Success).value + assertEquals(1, keys.size) + assertEquals(InfraredKeyStatus.READY, keys.single().status) + } + + @Test + fun retryRunsAsynchronouslyInExternalScope() = runTest { + val failedKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_key1", + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "offline", + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(failedKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, this) + + coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1"))) + advanceUntilIdle() + + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt new file mode 100644 index 0000000..6249db9 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt @@ -0,0 +1,21 @@ +package com.flagship.abox.manager.infrared.runtime + +import com.flagship.abox.manager.domain.ProviderProfileId +import org.junit.Assert.assertEquals +import org.junit.Test + +class InfraredRuntimeProfileResolverTest { + + @Test + fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() { + val resolver = InfraredRuntimeProfileResolver() + assertEquals( + ProviderProfileId("abox"), + resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK), + ) + assertEquals( + ProviderProfileId("abox:infrared-fake"), + resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9b1f2d2..46c8d89 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,3 +6,4 @@ plugins { alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.hilt.android) apply false } + -- 2.54.0 From 6e83a78a814eef562e4d0c7d4c9eadfbefda7105 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:17:11 +0800 Subject: [PATCH 12/41] feat: add infrared control state machine --- .../infrared/ui/InfraredControlUiState.kt | 27 ++ .../infrared/ui/InfraredControlViewModel.kt | 327 ++++++++++++++++++ .../ui/InfraredControlViewModelTest.kt | 294 ++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt new file mode 100644 index 0000000..14e8c90 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.infrared.ui + +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 +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode + +sealed interface InfraredUiMessage { + data class Info(val text: String) : InfraredUiMessage + data class Error(val text: String) : InfraredUiMessage + data object SessionInvalid : InfraredUiMessage +} + +data class InfraredControlUiState( + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val effectiveProfileId: ProviderProfileId? = null, + val deviceNameInput: String = "", + val savedDeviceName: DeviceName? = null, + val keys: List = emptyList(), + val loading: Boolean = true, + val sendingKeyIds: Set = emptySet(), + val message: InfraredUiMessage? = null, +) { + val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt new file mode 100644 index 0000000..7b5eeeb --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -0,0 +1,327 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +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.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredControlViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val runtimeModeStore: InfraredRuntimeModeStore, + private val profileResolver: InfraredRuntimeProfileResolver, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, + private val renameInfraredKey: RenameInfraredKey, + private val deleteInfraredKey: DeleteInfraredKey, + private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, + private val clock: EpochMillisClock, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val deviceNameInputFlow = MutableStateFlow("") + private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) + private val messageFlow = MutableStateFlow(null) + + private val effectiveProfileAndMode: Flow> = + runtimeModeStore.mode.mapModeToProfile() + + private fun Flow.mapModeToProfile(): Flow> { + return flatMapLatest { mode -> + if (authenticatedProfileId == null) { + flowOf(Pair(null, mode)) + } else { + val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) + flowOf(Pair(effectiveProfile, mode)) + } + } + } + + private val deviceNameFlow: Flow> = + effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) { + flowOf(DomainResult.Success(null)) + } else { + deviceConfigurationRepository.observeDeviceName(profile) + } + } + + private val keysFlow: Flow>> = + effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) { + flowOf(DomainResult.Success(emptyList())) + } else { + deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Success -> { + val device = deviceResult.value + if (device == null) { + flowOf(DomainResult.Success(emptyList())) + } else { + keyRepository.observeKeys(profile, device) + } + } + is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) + } + } + } + } + + private data class ServerState( + val profile: ProviderProfileId?, + val mode: InfraredRuntimeMode, + val deviceResult: DomainResult, + val keysResult: DomainResult>, + ) + + private val serverStateFlow: Flow = combine( + effectiveProfileAndMode, + deviceNameFlow, + keysFlow, + ) { profileAndMode, deviceResult, keysResult -> + ServerState( + profile = profileAndMode.first, + mode = profileAndMode.second, + deviceResult = deviceResult, + keysResult = keysResult, + ) + } + + val state: StateFlow = combine( + serverStateFlow, + deviceNameInputFlow, + sendingKeyIdsFlow, + messageFlow, + ) { serverState, input, sendingIds, msg -> + val savedDevice = (serverState.deviceResult as? DomainResult.Success)?.value + val keys = (serverState.keysResult as? DomainResult.Success>)?.value ?: emptyList() + val loading = serverState.profile == null || + serverState.deviceResult !is DomainResult.Success || + serverState.keysResult !is DomainResult.Success + + InfraredControlUiState( + runtimeMode = serverState.mode, + effectiveProfileId = serverState.profile, + deviceNameInput = input, + savedDeviceName = savedDevice, + keys = keys, + loading = loading, + sendingKeyIds = sendingIds, + message = if (authenticatedProfileId == null) { + InfraredUiMessage.Error("Invalid or missing profile ID") + } else { + msg + }, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredControlUiState( + runtimeMode = InfraredRuntimeMode.FAKE, + effectiveProfileId = null, + loading = true, + ), + ) + + init { + if (authenticatedProfileId != null) { + viewModelScope.launch { + val currentMode = runtimeModeStore.currentMode() + val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode) + recoverInterruptedInfraredLearning(effectiveProfile) + } + } + } + + fun onDeviceNameChanged(value: String) { + deviceNameInputFlow.value = value + } + + fun saveDeviceName() { + val currentProfile = state.value.effectiveProfileId ?: return + val trimmed = deviceNameInputFlow.value.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank") + return + } + viewModelScope.launch { + when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Device name saved") + deviceNameInputFlow.value = "" + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun learnKey(displayName: String) { + val currentProfile = state.value.effectiveProfileId ?: return + val savedDevice = state.value.savedDeviceName + if (savedDevice == null) { + messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first") + return + } + val trimmedName = displayName.trim() + if (trimmedName.isBlank()) { + messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank") + return + } + learningCoordinator.learn(currentProfile, savedDevice, trimmedName) + } + + fun retryLearning(keyId: InfraredKeyId) { + val currentProfile = state.value.effectiveProfileId ?: return + learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) + } + + fun sendKey(keyId: InfraredKeyId) { + val currentProfile = state.value.effectiveProfileId ?: return + if (state.value.sendingKeyIds.contains(keyId)) return + + val key = state.value.keys.find { it.id == keyId } + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = InfraredUiMessage.Error("Key is not ready to send") + return + } + + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Signal sent successfully") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId + } + } + } + + fun renameKey(keyId: InfraredKeyId, displayName: String) { + val currentProfile = state.value.effectiveProfileId ?: return + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error("Name cannot be blank") + return + } + viewModelScope.launch { + when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Key renamed") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun deleteKey(keyId: InfraredKeyId) { + val currentProfile = state.value.effectiveProfileId ?: return + viewModelScope.launch { + when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Key removed locally") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun setRuntimeMode(mode: InfraredRuntimeMode) { + if (state.value.hasActiveLearning) { + messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active") + return + } + viewModelScope.launch { + when (val result = runtimeModeStore.setMode(mode)) { + is DomainResult.Success -> { + if (authenticatedProfileId != null) { + val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) + recoverInterruptedInfraredLearning(newEffectiveProfile) + } + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = InfraredUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = InfraredUiMessage.Error("Infrared controller is offline (code: ${error.externalCode ?: "20206"})") + } + is DomainError.DeviceNotFound -> { + messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})") + } + is DomainError.InvalidRequest -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request") + } + is DomainError.OperationFailed -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})") + } + is DomainError.Network -> { + messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}") + } + is DomainError.NoData -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found") + } + is DomainError.Unknown -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error") + } + } + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt new file mode 100644 index 0000000..014c39d --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -0,0 +1,294 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +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.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +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.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FakeRuntimeModeStore : InfraredRuntimeModeStore { + private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) + override val mode: Flow = modeFlow + override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + modeFlow.value = mode + return DomainResult.Success(Unit) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredControlViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val baseProfile = ProviderProfileId("abox") + private val fakeProfile = ProviderProfileId("abox:infrared-fake") + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepository = FakeInfraredKeyRepository() + private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository() + private val runtimeModeStore = FakeRuntimeModeStore() + private val profileResolver = InfraredRuntimeProfileResolver() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) + + private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) + private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) + private val sendUseCase = SendInfraredKey(keyRepository, gateway) + private val renameUseCase = RenameInfraredKey(keyRepository, clock) + private val deleteUseCase = DeleteInfraredKey(keyRepository) + private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock) + + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel { + val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap()) + return InfraredControlViewModel( + savedStateHandle = handle, + keyRepository = keyRepository, + deviceConfigurationRepository = deviceConfigRepository, + runtimeModeStore = runtimeModeStore, + profileResolver = profileResolver, + learningCoordinator = coordinator, + sendInfraredKey = sendUseCase, + renameInfraredKey = renameUseCase, + deleteInfraredKey = deleteUseCase, + recoverInterruptedInfraredLearning = recoverUseCase, + clock = clock, + ) + } + + @Test + fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(fakeProfile, vm.state.value.effectiveProfileId) + assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) + + vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) + advanceUntilIdle() + + assertEquals(baseProfile, vm.state.value.effectiveProfileId) + assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode) + + job.cancel() + } + + @Test + fun blankDeviceNameReturnsValidationError() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged(" ") + vm.saveDeviceName() + advanceUntilIdle() + + val msg = vm.state.value.message + assertTrue(msg is InfraredUiMessage.Error) + assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text) + + job.cancel() + } + + @Test + fun saveDeviceNamePersistsAndTrims() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged(" ir-main ") + vm.saveDeviceName() + advanceUntilIdle() + + assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName) + assertEquals("", vm.state.value.deviceNameInput) + assertTrue(vm.state.value.message is InfraredUiMessage.Info) + + job.cancel() + } + + @Test + fun learnWithoutSavedDeviceFails() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.learnKey("Power") + advanceUntilIdle() + + val msg = vm.state.value.message + assertTrue(msg is InfraredUiMessage.Error) + assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text) + + job.cancel() + } + + @Test + fun learnWithSavedDeviceSucceeds() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged("ir-1") + vm.saveDeviceName() + advanceUntilIdle() + + vm.learnKey("Air Conditioner") + advanceUntilIdle() + + val keys = vm.state.value.keys + assertEquals(1, keys.size) + assertEquals("Air Conditioner", keys.first().displayName) + assertEquals(InfraredKeyStatus.READY, keys.first().status) + + job.cancel() + } + + @Test + fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + deviceConfigRepository.setDeviceName(fakeProfile, device) + val key = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_k1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepository.insert(key) + advanceUntilIdle() + + vm.sendKey(InfraredKeyId("k1")) + advanceUntilIdle() + + assertTrue(vm.state.value.sendingKeyIds.isEmpty()) + assertTrue(vm.state.value.message is InfraredUiMessage.Info) + assertEquals(1, gateway.sentKeys.size) + + job.cancel() + } + + @Test + fun sendFailurePreservesReadyStatus() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + deviceConfigRepository.setDeviceName(fakeProfile, device) + val key = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_k1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepository.insert(key) + gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206")) + advanceUntilIdle() + + vm.sendKey(InfraredKeyId("k1")) + advanceUntilIdle() + + val fetched = vm.state.value.keys.single() + assertEquals(InfraredKeyStatus.READY, fetched.status) + assertTrue(vm.state.value.message is InfraredUiMessage.Error) + + job.cancel() + } + + @Test + fun setModeBlockedWhileLearningIsActive() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + deviceConfigRepository.setDeviceName(fakeProfile, device) + val key = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_k1", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepository.insert(key) + advanceUntilIdle() + + assertTrue(vm.state.value.hasActiveLearning) + vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) + advanceUntilIdle() + + assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) + assertTrue(vm.state.value.message is InfraredUiMessage.Error) + + job.cancel() + } +} -- 2.54.0 From 116f8d1b750c17805324fb5c687a7db02b011451 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:21:00 +0800 Subject: [PATCH 13/41] feat: add infrared control screen --- .../infrared/ui/InfraredControlScreenTest.kt | 121 +++++ .../infrared/ui/InfraredControlScreen.kt | 456 ++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 33 ++ .../infrared/src/main/res/values/strings.xml | 33 ++ 4 files changed, 643 insertions(+) create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt create mode 100644 android/feature/infrared/src/main/res/values-zh-rCN/strings.xml create mode 100644 android/feature/infrared/src/main/res/values/strings.xml diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt new file mode 100644 index 0000000..211cefe --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -0,0 +1,121 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredControlScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun displaysModeAndDeviceInformation() { + val state = InfraredControlUiState( + runtimeMode = InfraredRuntimeMode.FAKE, + effectiveProfileId = ProviderProfileId("abox:infrared-fake"), + savedDeviceName = DeviceName("ir-living"), + loading = false, + ) + + composeTestRule.setContent { + InfraredControlScreen( + state = state, + onBack = {}, + onSessionInvalid = {}, + onDeviceNameChanged = {}, + onSaveDeviceName = {}, + onLearn = {}, + onRetry = {}, + onSend = {}, + onRename = { _, _ -> }, + onDelete = {}, + onModeChanged = {}, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed() + } + + @Test + fun rendersKeysWithCorrectActionButtons() { + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k-ready"), + profileId = ProviderProfileId("abox"), + deviceName = DeviceName("ir-1"), + displayName = "Power Key", + sdkKey = "app_ir_kready", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + val failedKey = SavedInfraredKey( + id = InfraredKeyId("k-failed"), + profileId = ProviderProfileId("abox"), + deviceName = DeviceName("ir-1"), + displayName = "Volume Up", + sdkKey = "app_ir_kfailed", + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = null, + createdAtEpochMillis = 200L, + updatedAtEpochMillis = 200L, + ) + + val state = InfraredControlUiState( + runtimeMode = InfraredRuntimeMode.REAL_ABSDK, + savedDeviceName = DeviceName("ir-1"), + keys = listOf(readyKey, failedKey), + loading = false, + ) + + var sentKeyId: InfraredKeyId? = null + var retriedKeyId: InfraredKeyId? = null + + composeTestRule.setContent { + InfraredControlScreen( + state = state, + onBack = {}, + onSessionInvalid = {}, + onDeviceNameChanged = {}, + onSaveDeviceName = {}, + onLearn = {}, + onRetry = { retriedKeyId = it }, + onSend = { sentKeyId = it }, + onRename = { _, _ -> }, + onDelete = {}, + onModeChanged = {}, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick() + assertEquals(InfraredKeyId("k-ready"), sentKeyId) + + composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick() + assertEquals(InfraredKeyId("k-failed"), retriedKeyId) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt new file mode 100644 index 0000000..61b94b1 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -0,0 +1,456 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode + +@Composable +fun InfraredControlRoute( + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredControlViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredControlScreen( + state = state, + onBack = onBack, + onSessionInvalid = onSessionInvalid, + onDeviceNameChanged = viewModel::onDeviceNameChanged, + onSaveDeviceName = viewModel::saveDeviceName, + onLearn = viewModel::learnKey, + onRetry = viewModel::retryLearning, + onSend = viewModel::sendKey, + onRename = viewModel::renameKey, + onDelete = viewModel::deleteKey, + onModeChanged = viewModel::setRuntimeMode, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredControlScreen( + state: InfraredControlUiState, + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + onDeviceNameChanged: (String) -> Unit, + onSaveDeviceName: () -> Unit, + onLearn: (String) -> Unit, + onRetry: (InfraredKeyId) -> Unit, + onSend: (InfraredKeyId) -> Unit, + onRename: (InfraredKeyId, String) -> Unit, + onDelete: (InfraredKeyId) -> Unit, + onModeChanged: (InfraredRuntimeMode) -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + var showLearnDialog by remember { mutableStateOf(false) } + var keyToRename by remember { mutableStateOf(null) } + var keyToDelete by remember { mutableStateOf(null) } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is InfraredUiMessage.Info -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is InfraredUiMessage.Error -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is InfraredUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.infrared_title)) }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Mode indicator and toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { + stringResource(R.string.infrared_mode_fake) + } else { + stringResource(R.string.infrared_mode_real) + } + Text( + text = modeText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("infrared_mode_label"), + ) + OutlinedButton( + onClick = { + val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { + InfraredRuntimeMode.REAL_ABSDK + } else { + InfraredRuntimeMode.FAKE + } + onModeChanged(target) + }, + enabled = !state.hasActiveLearning, + ) { + Text(stringResource(R.string.infrared_switch_mode)) + } + } + + // Device name configuration card + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val saved = state.savedDeviceName + Text( + text = if (saved != null) { + stringResource(R.string.infrared_saved_device, saved.value) + } else { + stringResource(R.string.infrared_no_device_saved) + }, + style = MaterialTheme.typography.titleMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = state.deviceNameInput, + onValueChange = onDeviceNameChanged, + label = { Text(stringResource(R.string.infrared_device_name_label)) }, + placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag("infrared_device_name_input"), + ) + ABoxButton( + onClick = onSaveDeviceName, + modifier = Modifier.testTag("infrared_save_device"), + ) { + Text(stringResource(R.string.infrared_save_device)) + } + } + } + } + + // Learned Keys Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.infrared_keys_title), + style = MaterialTheme.typography.titleMedium, + ) + ABoxButton( + onClick = { showLearnDialog = true }, + enabled = state.savedDeviceName != null, + modifier = Modifier.testTag("infrared_learn_new_key"), + ) { + Text(stringResource(R.string.infrared_learn_new_key)) + } + } + + if (state.keys.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.infrared_no_keys), + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.keys, key = { it.id.value }) { key -> + InfraredKeyRow( + key = key, + isSending = state.sendingKeyIds.contains(key.id), + onSend = { onSend(key.id) }, + onRetry = { onRetry(key.id) }, + onRename = { keyToRename = key }, + onDelete = { keyToDelete = key }, + ) + } + } + } + } + } + } + + if (showLearnDialog) { + var keyName by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { showLearnDialog = false }, + title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.infrared_learn_dialog_prompt)) + OutlinedTextField( + value = keyName, + onValueChange = { keyName = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + onLearn(keyName) + showLearnDialog = false + }, + enabled = keyName.isNotBlank(), + ) { + Text(stringResource(R.string.infrared_learn_dialog_start)) + } + }, + dismissButton = { + TextButton(onClick = { showLearnDialog = false }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } + + keyToRename?.let { targetKey -> + var newName by remember { mutableStateOf(targetKey.displayName) } + AlertDialog( + onDismissRequest = { keyToRename = null }, + title = { Text(stringResource(R.string.infrared_rename_dialog_title)) }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Button( + onClick = { + onRename(targetKey.id, newName) + keyToRename = null + }, + enabled = newName.isNotBlank(), + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { keyToRename = null }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } + + keyToDelete?.let { targetKey -> + AlertDialog( + onDismissRequest = { keyToDelete = null }, + title = { Text(stringResource(R.string.infrared_delete_dialog_title)) }, + text = { Text(stringResource(R.string.infrared_delete_dialog_message)) }, + confirmButton = { + Button( + onClick = { + onDelete(targetKey.id) + keyToDelete = null + }, + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { keyToDelete = null }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } +} + +@Composable +private fun InfraredKeyRow( + key: SavedInfraredKey, + isSending: Boolean, + onSend: () -> Unit, + onRetry: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("infrared_key_${key.id.value}"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = key.displayName, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "SDK: ${key.sdkKey}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + val statusLabel = when (key.status) { + InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning) + InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready) + InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed) + } + Text( + text = statusLabel, + style = MaterialTheme.typography.bodyMedium, + color = when (key.status) { + InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary + InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary + InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error + }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (key.status == InfraredKeyStatus.READY) { + Button( + onClick = onSend, + enabled = !isSending, + modifier = Modifier.testTag("infrared_send_${key.id.value}"), + ) { + if (isSending) { + Text(stringResource(R.string.infrared_sending)) + } else { + Text(stringResource(R.string.infrared_send)) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } + + if (key.status == InfraredKeyStatus.FAILED) { + Button( + onClick = onRetry, + modifier = Modifier.testTag("infrared_retry_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_retry)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + if (key.status != InfraredKeyStatus.LEARNING) { + OutlinedButton(onClick = onRename) { + Text(stringResource(R.string.infrared_rename)) + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton(onClick = onDelete) { + Text(stringResource(R.string.infrared_delete)) + } + } else { + CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) + } + } + } + } +} diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..8592155 --- /dev/null +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,33 @@ + + 红外控制 + 模式:模拟 / Fake + 模式:真实 ABSDK + 切换模式 + 红外设备名称 + 例如:ir-living + 已保存设备:%1$s + 未配置红外设备 + 保存设备名 + 已学习按键 + 暂无保存的红外按键 + 学习新按键 + 学习中… + 就绪 + 失败 + 发射 + 发射中… + 重试 + 重命名 + 删除 + 学习红外按键 + 请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。 + 按键名称(如:电源) + 开始学习 + 重命名按键 + 新按键名称 + 删除按键 + 仅从本机列表移除,ABox 服务端可能仍保留该按键。 + 取消 + 确认 + 返回 + diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml new file mode 100644 index 0000000..42bd2f1 --- /dev/null +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -0,0 +1,33 @@ + + Infrared Control + Mode: Fake / Simulated + Mode: Real ABSDK + Switch Mode + Infrared Device Name + e.g. ir-living + Saved Device: %1$s + No infrared device configured + Save Device + Learned Keys + No infrared keys saved + Learn New Key + Learning… + Ready + Failed + Send + Sending… + Retry + Rename + Delete + Learn Infrared Key + Prepare the physical remote. After starting, point it at the infrared controller and press the target button. + Key Name (e.g. Power) + Start Learning + Rename Key + New Display Name + Delete Key + Only removes the key locally from this app. ABox server may retain the learned key. + Cancel + Confirm + Back + -- 2.54.0 From 980fd384212365fb8ef89c6bf2753795bffcecd1 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:32:03 +0800 Subject: [PATCH 14/41] feat: route infrared debug hardware modes --- android/app/build.gradle.kts | 1 + android/app/src/debug/AndroidManifest.xml | 4 ++ .../manager/di/DebugRoutingInfraredGateway.kt | 41 +++++++++++ .../abox/manager/di/InfraredReleaseModule.kt | 47 +++++++++++++ .../di/DebugRoutingInfraredGatewayTest.kt | 70 +++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt create mode 100644 android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8843f4..08eb8de 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) implementation(project(":android:core:session")) + implementation(project(":android:feature:infrared")) implementation(project(":absdk-bridge")) implementation(project(":runtime:simulation")) debugImplementation(project(":testing:fakes")) diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..c0638bc --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt new file mode 100644 index 0000000..b3b6c01 --- /dev/null +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt @@ -0,0 +1,41 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore + +class DebugRoutingInfraredGateway( + private val modeStore: InfraredRuntimeModeStore, + private val fakeGateway: InfraredGateway, + private val realGateway: InfraredGateway, +) : InfraredGateway { + + private suspend fun delegate(): InfraredGateway { + return when (modeStore.currentMode()) { + InfraredRuntimeMode.REAL_ABSDK -> realGateway + InfraredRuntimeMode.FAKE -> fakeGateway + } + } + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = delegate().sendKey(profileId, deviceName, key) + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = delegate().learnKey(profileId, deviceName, key) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = delegate().downloadCodes(profileId, codes) +} diff --git a/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt b/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt new file mode 100644 index 0000000..705c2b0 --- /dev/null +++ b/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt @@ -0,0 +1,47 @@ +package com.flagship.abox.manager.di + +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.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object InfraredReleaseModule { + + @Provides + @Singleton + fun provideInfraredGateway(): InfraredGateway = object : InfraredGateway { + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = disabledFailure() + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = disabledFailure() + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = disabledFailure() + + private fun disabledFailure(): DomainResult = DomainResult.Failure( + DomainError.OperationFailed( + externalCode = "REAL_ABSDK_DISABLED", + detail = "Infrared hardware transport is disabled in release builds", + ), + ) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt new file mode 100644 index 0000000..339dc83 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt @@ -0,0 +1,70 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private class TestModeStore : InfraredRuntimeModeStore { + private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) + override val mode: Flow = modeFlow + override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + modeFlow.value = mode + return DomainResult.Success(Unit) + } +} + +class DebugRoutingInfraredGatewayTest { + + private val modeStore = TestModeStore() + private val fake = FakeInfraredGateway() + private val real = FakeInfraredGateway() + private val router = DebugRoutingInfraredGateway(modeStore, fake, real) + + private val profile = ProviderProfileId("profile1") + private val device = DeviceName("ir-1") + + @Test + fun delegatesToFakeByDefault() = runTest { + val result = router.sendKey(profile, device, "key1") + assertTrue(result is DomainResult.Success) + assertEquals(1, fake.sentKeys.size) + assertTrue(real.sentKeys.isEmpty()) + + router.learnKey(profile, device, "key2") + assertEquals(1, fake.learnedKeys.size) + assertTrue(real.learnedKeys.isEmpty()) + + router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code"))) + assertEquals(1, fake.downloadCalls.size) + assertTrue(real.downloadCalls.isEmpty()) + } + + @Test + fun delegatesToRealWhenModeIsRealAbsdk() = runTest { + modeStore.setMode(InfraredRuntimeMode.REAL_ABSDK) + + val result = router.sendKey(profile, device, "key1") + assertTrue(result is DomainResult.Success) + assertTrue(fake.sentKeys.isEmpty()) + assertEquals(1, real.sentKeys.size) + + router.learnKey(profile, device, "key2") + assertTrue(fake.learnedKeys.isEmpty()) + assertEquals(1, real.learnedKeys.size) + + router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code"))) + assertTrue(fake.downloadCalls.isEmpty()) + assertEquals(1, real.downloadCalls.size) + } +} -- 2.54.0 From 03a3167a55f8b72d1080c0b806ee930c3e014354 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:37:13 +0800 Subject: [PATCH 15/41] feat: add temporary infrared navigation --- .../abox/manager/ui/ABoxManagerAppTest.kt | 26 +++++++++++++++++++ .../abox/manager/ui/ABoxManagerApp.kt | 2 ++ .../src/main/res/values-zh-rCN/strings.xml | 3 ++- android/app/src/main/res/values/strings.xml | 3 ++- 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt new file mode 100644 index 0000000..bd085c5 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt @@ -0,0 +1,26 @@ +package com.flagship.abox.manager.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.MainActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ABoxManagerAppTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun opensInfraredControlScreenFromFoundationScreen() { + composeTestRule.onNodeWithText("Infrared Control").performClick() + composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed() + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 9a2dc11..d997aeb 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -75,3 +75,5 @@ private fun AppSessionUiState.screenKey(): String = when (this) { is AppSessionUiState.Login -> "login:${profile.id.value}" is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}" } + +} diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index f09dfa4..4fe53de 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -23,7 +23,6 @@ 切换提供方 已连接到 %1$s 登录后入口已经就绪,设备导航将由对应模块负责人接入。 - 设备 情景 @@ -52,4 +51,6 @@ 进入温湿度状态相关功能。 红外控制器 进入红外控制器相关功能。 + 红外控制 + ABox 会话未认证,请先登录。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f9455e4..93a151f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -23,7 +23,6 @@ Switch provider Connected to %1$s The authenticated route is ready. Device navigation will be added by its module owner. - Devices Scenes @@ -52,4 +51,6 @@ View temperature and humidity status. Infrared Controller Access infrared controller features. + Infrared Control + ABox session is not authenticated. Please log in first. -- 2.54.0 From 1376702476157907ceee836045b2d1485cbdc006 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:41:36 +0800 Subject: [PATCH 16/41] test: verify ABSDK infrared contracts --- .../absdk/bridge/AbsdkDeviceBridgeTest.kt | 65 +++++++++++++++++++ .../absdk/bridge/AbsdkSerializationTest.kt | 34 ++++++++++ 2 files changed, 99 insertions(+) diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt index 84002ea..55f63e8 100644 --- a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt @@ -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>( + "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") diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt index fe68163..1663410 100644 --- a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt @@ -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()) + } + } } -- 2.54.0 From aec43df77507efdae5e7ef259a3058cb6c3ecbff Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:48:27 +0800 Subject: [PATCH 17/41] docs: add infrared hardware validation --- docs/testing/infrared-hardware-validation.md | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/testing/infrared-hardware-validation.md diff --git a/docs/testing/infrared-hardware-validation.md b/docs/testing/infrared-hardware-validation.md new file mode 100644 index 0000000..2d541ce --- /dev/null +++ b/docs/testing/infrared-hardware-validation.md @@ -0,0 +1,85 @@ +# 红外控制硬件验证规范与记录 / Infrared Hardware Validation Guide & Records + +## 1. 硬件环境与已知设备信息 / Hardware Environment + +本阶段用于真实验证的实体设备信息如下: + +- **设备名称**: 红外控制器 (例如 `红外控制器` 或厂商平台绑定的 `irDevName`) +- **IEEE 地址**: `1c1cfd001dba533c` (短地址: `d822`) +- **信号强度 (LQI/RSSI)**: `-4` +- **电量**: `100%` +- **设备类型**: 红外控制器 (Infrared Controller) +- **网关/主机**: ABox 智能主机 +- **被控实体**: 实体空调 / 电视遥控器及对应电器 + +--- + +## 2. 硬件测试记录表 / Hardware Validation Log + +| 日期 (Date) | App Commit | 运行模式 (Mode) | Profile ID | 设备名称 (Device Name) | SDK Key | 操作 (Operation) | 耗时 (ms) | ABSDK Code | 脱敏消息 (Message) | 物理响应 (Physical Response) | 结论 (Result) | +|---|---|---|---|---|---|---|---|---|---|---|---| +| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Learn | 120 | 00000 | success | 模拟学习完成并入库 READY | PASS (Fake) | +| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Send | 45 | 00000 | success | 模拟发射成功 | PASS (Fake) | +| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Learn | - | - | - | 遥控器对准控制器按下目标按键 | 待真机执行 | +| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Send | - | - | - | 实体电器执行开关/调节响应 | 待真机执行 | + +--- + +## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure + +### 用例 1: 真实会话认证前置 (Authenticate Shared Bridge) +- **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。 +- **步骤**: 检查进入红外控制页面前,会话 Token 是否有效。 +- **预期**: 若未认证,进入 Real 模式操作时返回 `TokenInvalid` 并提示返回登录。 + +### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName) +- **步骤**: 在“红外设备名称”输入框中输入 `红外控制器` 或后台分配的真实设备名,点击“保存设备名”。 +- **预期**: 本地 Room 保存成功,页面显示“已保存设备:红外控制器”,启用“学习新按键”入口。 + +### 用例 3: 学习全新唯一按键 (Learn Unique Key) +- **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。 +- **预期**: 本地先写入 `LEARNING` 状态,硬件捕获完成后状态自动刷新为 `READY`,记录响应耗时与返回码。 + +### 用例 4: 立即发射已学习按键 (Send Learned Key Immediately) +- **步骤**: 学习成功后,点击该按键右侧的“发射”按钮。 +- **预期**: ABSDK 返回 `00000`,实体电器执行对应操作,发射期间按钮防重复点击。 + +### 用例 5: 重复发射验证稳定性 (Repeat Send) +- **步骤**: 间隔 3 秒再次点击同一按键的“发射”按钮。 +- **预期**: 发射稳定成功,电器再次响应,按键保持 `READY` 状态不变。 + +### 用例 6: 重新学习同一 SDK Key (Relearn Same Key) +- **步骤**: 针对已存在或失败的按键点击“重试”/重新学习。 +- **预期**: 复用相同的 `InfraredKeyId` 和 `sdkKey`,先变更为 `LEARNING`,学习完成后恢复 `READY`,不生成冗余记录。 + +### 用例 7: 控制器断电/离线测试 (Controller Offline) +- **步骤**: 拔掉红外控制器电源,发起学习或发射。 +- **预期**: ABSDK 返回 `20206`,领域层转换为 `DomainError.DeviceOffline`,学习按键标为 `FAILED`,UI 提示控制器离线。 + +### 用例 8: 遥控器无信号/超时测试 (No Signal / Learning Timeout) +- **步骤**: 点击“开始学习”,但不按物理遥控器任何按键,等待 SDK 返回。 +- **预期**: 记录实际超时等待时间与错误码(如 `20204`/`20205`),按键转换为 `FAILED` 并允许重试。 + +### 用例 9: 学习过程中离开页面 (Leave Page During Learning) +- **步骤**: 点击开始学习后,立即点击“返回”离开红外页面;稍后重新进入。 +- **预期**: Application 协程作用域内任务继续执行,学习完成后 Room 数据库正确持久化结果,重新进入显示最新状态。 + +### 用例 10: 进程被杀死后状态恢复 (Process Interruption Recovery) +- **步骤**: 在 `LEARNING` 状态下强制杀死 App 进程并重启。 +- **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。 + +### 用例 11: Token 失效与重新登录 (Token Expiration) +- **步骤**: 主机端使 Token 失效或超过 2 小时后发起红外操作。 +- **预期**: 返回 `TokenInvalid`,本地会话被清除,页面提示登录失效并导航返回。 + +### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields) +- **步骤**: 在调试构建中检查 `ABRet.getDicDatas()` 是否包含未文档化字段。 +- **安全约束**: 严禁将 Raw Payload、Token、密码或未脱敏数据打印至生产日志。 + +--- + +## 4. 协议与硬件事实确认记录 / Observed Facts + +- **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。 +- **数据隔离**: `FAKE` 模式自动使用 `profileId:infrared-fake` 隔离数据,`REAL_ABSDK` 模式使用主会话数据。 +- **安全门禁**: 真实 ABSDK 依赖明文传输,Release 构建中完全禁用硬件接口并由 `InfraredReleaseModule` 安全阻断。 -- 2.54.0 From 78dac5519abcc028d1c3577fa307ab3ee70cf12f Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 14:13:47 +0800 Subject: [PATCH 18/41] feat: support 1-100 numeric infrared key slots and gateway login entry --- .../abox/manager/ui/ABoxManagerApp.kt | 2 -- .../infrared/ui/InfraredControlScreenTest.kt | 12 +++++------ .../InfraredLearningCoordinator.kt | 9 ++++++-- .../infrared/ui/InfraredControlScreen.kt | 20 +++++++++++++----- .../infrared/ui/InfraredControlViewModel.kt | 19 +++++++++++++++-- .../src/main/res/values-zh-rCN/strings.xml | 2 ++ .../infrared/src/main/res/values/strings.xml | 2 ++ .../domain/infrared/InfraredKeyFactory.kt | 19 +++++++++++++++-- .../domain/infrared/LearnInfraredKey.kt | 3 ++- .../domain/infrared/InfraredKeyFactoryTest.kt | 21 ++++++++++++++++++- 10 files changed, 88 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index d997aeb..9a2dc11 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -75,5 +75,3 @@ private fun AppSessionUiState.screenKey(): String = when (this) { is AppSessionUiState.Login -> "login:${profile.id.value}" is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}" } - -} diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt index 211cefe..e670e3f 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -3,7 +3,6 @@ package com.flagship.abox.manager.infrared.ui import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DeviceName @@ -13,7 +12,6 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -36,11 +34,12 @@ class InfraredControlScreenTest { composeTestRule.setContent { InfraredControlScreen( state = state, + nextSlot = 1, onBack = {}, onSessionInvalid = {}, onDeviceNameChanged = {}, onSaveDeviceName = {}, - onLearn = {}, + onLearn = { _, _ -> }, onRetry = {}, onSend = {}, onRename = { _, _ -> }, @@ -63,7 +62,7 @@ class InfraredControlScreenTest { profileId = ProviderProfileId("abox"), deviceName = DeviceName("ir-1"), displayName = "Power Key", - sdkKey = "app_ir_kready", + sdkKey = "1", status = InfraredKeyStatus.READY, lastErrorCode = null, lastErrorDetail = null, @@ -75,7 +74,7 @@ class InfraredControlScreenTest { profileId = ProviderProfileId("abox"), deviceName = DeviceName("ir-1"), displayName = "Volume Up", - sdkKey = "app_ir_kfailed", + sdkKey = "2", status = InfraredKeyStatus.FAILED, lastErrorCode = "20206", lastErrorDetail = null, @@ -96,11 +95,12 @@ class InfraredControlScreenTest { composeTestRule.setContent { InfraredControlScreen( state = state, + nextSlot = 3, onBack = {}, onSessionInvalid = {}, onDeviceNameChanged = {}, onSaveDeviceName = {}, - onLearn = {}, + onLearn = { _, _ -> }, onRetry = { retriedKeyId = it }, onSend = { sentKeyId = it }, onRename = { _, _ -> }, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 8dbc7ef..a191321 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -13,9 +13,14 @@ class InfraredLearningCoordinator( private val retryInfraredKeyLearning: RetryInfraredKeyLearning, private val externalScope: CoroutineScope, ) { - fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) { + fun learn( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + sdkKey: String? = null, + ) { externalScope.launch { - learnInfraredKey(profileId, deviceName, displayName) + learnInfraredKey(profileId, deviceName, displayName, sdkKey) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index 61b94b1..6f575ce 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -1,6 +1,5 @@ package com.flagship.abox.manager.infrared.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -58,6 +57,7 @@ fun InfraredControlRoute( InfraredControlScreen( state = state, + nextSlot = viewModel.nextAvailableKeySlot(), onBack = onBack, onSessionInvalid = onSessionInvalid, onDeviceNameChanged = viewModel::onDeviceNameChanged, @@ -76,11 +76,12 @@ fun InfraredControlRoute( @Composable internal fun InfraredControlScreen( state: InfraredControlUiState, + nextSlot: Int, onBack: () -> Unit, onSessionInvalid: () -> Unit, onDeviceNameChanged: (String) -> Unit, onSaveDeviceName: () -> Unit, - onLearn: (String) -> Unit, + onLearn: (String, String?) -> Unit, onRetry: (InfraredKeyId) -> Unit, onSend: (InfraredKeyId) -> Unit, onRename: (InfraredKeyId, String) -> Unit, @@ -265,6 +266,8 @@ internal fun InfraredControlScreen( if (showLearnDialog) { var keyName by remember { mutableStateOf("") } + var keySlot by remember { mutableStateOf(nextSlot.toString()) } + AlertDialog( onDismissRequest = { showLearnDialog = false }, title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, @@ -278,15 +281,22 @@ internal fun InfraredControlScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = keySlot, + onValueChange = { keySlot = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) } }, confirmButton = { Button( onClick = { - onLearn(keyName) + onLearn(keyName, keySlot) showLearnDialog = false }, - enabled = keyName.isNotBlank(), + enabled = keyName.isNotBlank() && keySlot.isNotBlank(), ) { Text(stringResource(R.string.infrared_learn_dialog_start)) } @@ -387,7 +397,7 @@ private fun InfraredKeyRow( style = MaterialTheme.typography.titleMedium, ) Text( - text = "SDK: ${key.sdkKey}", + text = stringResource(R.string.infrared_key_slot_label, key.sdkKey), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 7b5eeeb..d1ee47f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -192,7 +192,12 @@ class InfraredControlViewModel @Inject constructor( } } - fun learnKey(displayName: String) { + fun nextAvailableKeySlot(): Int { + val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() + return (1..100).firstOrNull { it !in usedSlots } ?: 1 + } + + fun learnKey(displayName: String, keySlotInput: String? = null) { val currentProfile = state.value.effectiveProfileId ?: return val savedDevice = state.value.savedDeviceName if (savedDevice == null) { @@ -204,7 +209,17 @@ class InfraredControlViewModel @Inject constructor( messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank") return } - learningCoordinator.learn(currentProfile, savedDevice, trimmedName) + val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) { + val slotInt = keySlotInput.trim().toIntOrNull() + if (slotInt == null || slotInt !in 1..100) { + messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100") + return + } + slotInt.toString() + } else { + nextAvailableKeySlot().toString() + } + learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) } fun retryLearning(keyId: InfraredKeyId) { diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index 8592155..c166919 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -22,6 +22,8 @@ 学习红外按键 请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。 按键名称(如:电源) + 按键编号(1-100) + 按键编号:%1$s 开始学习 重命名按键 新按键名称 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 42bd2f1..95304d2 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -22,6 +22,8 @@ Learn Infrared Key Prepare the physical remote. After starting, point it at the infrared controller and press the target button. Key Name (e.g. Power) + Key Slot (1-100) + Key Slot: %1$s Start Learning Rename Key New Display Name diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt index 0040df9..c83bd96 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -21,6 +21,7 @@ class InfraredKeyFactory( profileId: ProviderProfileId, deviceName: DeviceName, displayName: String, + sdkKey: String? = null, ): DomainResult { val normalizedName = displayName.trim() if ( @@ -39,6 +40,20 @@ class InfraredKeyFactory( ?: return DomainResult.Failure( DomainError.InvalidRequest(detail = "Infrared key id is invalid"), ) + + val finalSdkKey = if (sdkKey != null) { + val trimmed = sdkKey.trim() + val intVal = trimmed.toIntOrNull() + if (intVal == null || intVal !in 1..100) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"), + ) + } + intVal.toString() + } else { + "app_ir_$canonicalId" + } + val now = clock.now() return DomainResult.Success( SavedInfraredKey( @@ -46,7 +61,7 @@ class InfraredKeyFactory( profileId = profileId, deviceName = deviceName, displayName = normalizedName, - sdkKey = "app_ir_$canonicalId", + sdkKey = finalSdkKey, status = InfraredKeyStatus.LEARNING, lastErrorCode = null, lastErrorDetail = null, @@ -68,4 +83,4 @@ class InfraredKeyFactory( "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$", ) } -} \ No newline at end of file +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 9f6155b..394557d 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -16,8 +16,9 @@ class LearnInfraredKey( profileId: ProviderProfileId, deviceName: DeviceName, displayName: String, + sdkKey: String? = null, ): DomainResult { - val key = when (val result = factory.create(profileId, deviceName, displayName)) { + val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) { is DomainResult.Success -> result.value is DomainResult.Failure -> return result } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt index dcc17e0..95c7b6b 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -32,6 +32,25 @@ class InfraredKeyFactoryTest { assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) } + @Test + fun createsLearningKeyWithCustomNumericSlot() { + val result = factory.create( + ProviderProfileId("abox"), + DeviceName("ir-1"), + "空调开", + sdkKey = "1", + ) as DomainResult.Success + + assertEquals("1", result.value.sdkKey) + } + + @Test + fun rejectsInvalidNumericSlots() { + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure) + } + @Test fun rejectsBlankDeviceOrDisplayName() { assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) @@ -67,4 +86,4 @@ class InfraredKeyFactoryTest { DeviceName("ir-1"), "Power", ) -} \ No newline at end of file +} -- 2.54.0 From 29de3f2eb5b5abe621d1448bacf291b54b1dcc17 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:01:18 +0800 Subject: [PATCH 19/41] docs: design infrared remote control multi-scene UI --- .../2026-08-24-infrared-remote-ui-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md diff --git a/docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md b/docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md new file mode 100644 index 0000000..35608ce --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md @@ -0,0 +1,115 @@ +# 红外遥控器界面与多场景控制设计规范 / Infrared Remote Control UI Specification + +## 1. 背景与目标 (Background & Goals) + +在完成了底层红外学习、发射、Room 数据库、1~100 整数 Key 槽位管理以及 Fake/Real ABSDK 路由的基础上,本项目需要提供专业且直观的前端遥控器人机交互界面。 + +### 核心目标 +1. **多场景遥控面板**:提供空调 (Air Conditioner) 与电视 (Television) 两大经典场景遥控器界面,使用圆形、圆角矩形、胶囊、圆形方向盘等拟物/现代图形组件构建真实遥控器外观。 +2. **学习模式与控制模式切换**:右上角提供“红外学习模式”开关,在控制模式下点击按键发射红外,在学习模式下点击按键录制红外。 +3. **固定槽位绑定 (Fixed Slot Binding)**:遥控器上的每一个功能按键具有预设唯一的 1~100 整数槽位编号(例如空调电源为 Slot 1,电视电源为 Slot 21)。 +4. **简洁的符号化设计**:遥控器按键表面只展示标准图标与简写符号(⏻, +, -, ∧, ∨, ❄, 🔊 等),不展示底层数据库中文长名称。 +5. **设置页面解耦与导航**:原“红外控制页面”定位为“红外学习设置页面”(Infrared Settings),通过遥控器右上角设置图标 ⚙️ 进入,用于配置红外设备名和管理全部已学按键列表。 + +--- + +## 2. 槽位与按键分配定义 (Slot & Button Mapping) + +每个按键定义为一个 `RemoteButtonSpec`,包含按键唯一标识 `id`、对应默认槽位 `defaultSlot`、默认显示名称 `displayName`、图标及在遥控器面板中的图形位置。 + +### 2.1 空调遥控器 (Air Conditioner: Slots 1–20) +| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 | +|---|---|---|---|---| +| `AC_POWER` | 电源 ⏻ | `1` | `空调-电源` | 顶部圆形独立红色高亮按键 | +| `AC_TEMP_UP` | 温度+ 🔼 | `2` | `空调-温度+` | 垂直胶囊上半部分 | +| `AC_TEMP_DOWN` | 温度- 🔽 | `3` | `空调-温度-` | 垂直胶囊下半部分 | +| `AC_MODE` | 模式 ❄/☀ | `4` | `空调-模式` | 圆角矩形 | +| `AC_FAN` | 风速 💨 | `5` | `空调-风速` | 圆角矩形 | +| `AC_SWING` | 扫风 ↔️ | `6` | `空调-扫风` | 圆角矩形 | +| `AC_TIMER` | 定时 ⏱️ | `7` | `空调-定时` | 圆角矩形 | + +### 2.2 电视遥控器 (Television: Slots 21–50) +| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 | +|---|---|---|---|---| +| `TV_POWER` | 电源 ⏻ | `21` | `电视-电源` | 顶部圆形红色按键 | +| `TV_MUTE` | 静音 🔇 | `22` | `电视-静音` | 顶部圆形灰色按键 | +| `TV_NAV_UP` | 方向上 🔼 | `23` | `电视-上` | 方向环形盘上弧段 | +| `TV_NAV_DOWN` | 方向下 🔽 | `24` | `电视-下` | 方向环形盘下弧段 | +| `TV_NAV_LEFT` | 方向左 ◀️ | `25` | `电视-左` | 方向环形盘左弧段 | +| `TV_NAV_RIGHT` | 方向右 ▶️ | `26` | `电视-右` | 方向环形盘右弧段 | +| `TV_OK` | 确认 🆗 | `27` | `电视-确定` | 方向盘中心圆形 | +| `TV_VOL_UP` | 音量+ 🔊+ | `28` | `电视-音量+` | 垂直胶囊上半部分 | +| `TV_VOL_DOWN` | 音量- 🔉- | `29` | `电视-音量-` | 垂直胶囊下半部分 | +| `TV_CH_UP` | 频道+ 📺+ | `30` | `电视-频道+` | 垂直胶囊上半部分 | +| `TV_CH_DOWN` | 频道- 📺- | `31` | `电视-频道-` | 垂直胶囊下半部分 | +| `TV_HOME` | 主页 🏠 | `32` | `电视-主页` | 中部圆角胶囊按键 | +| `TV_BACK` | 返回 ↩️ | `33` | `电视-返回` | 中部圆角胶囊按键 | +| `TV_MENU` | 菜单 ☰ | `34` | `电视-菜单` | 中部圆角胶囊按键 | + +--- + +## 3. 架构与组件设计 (Architecture & Component Design) + +### 3.1 导航结构 +```text +FoundationScreen (首页/网关登录) + ↓ 点击 "红外控制" (infrared/{profileId}) +InfraredRemoteScreen (遥控器主界面,默认空调/电视) + ├─ 学习模式开关 (Toggle: Learning Mode) + ├─ 场景切换器 (Tab: ❄️ 空调 | 📺 电视) + ├─ 空调遥控器面板 (AcRemotePanel) + ├─ 电视遥控器面板 (TvRemotePanel) + └─ 点击右上角 ⚙️ 设置图标 + ↓ (infrared_settings/{profileId}) + InfraredSettingsScreen (原红外设置/按键管理列表页面) +``` + +### 3.2 状态管理 (`InfraredRemoteViewModel` & `InfraredRemoteUiState`) +- `InfraredRemoteUiState`: + - `selectedScene`: `RemoteScene.AIR_CONDITIONER` 或 `RemoteScene.TELEVISION` + - `isLearningMode`: `Boolean`(是否处于学习录制模式) + - `activeLearningKeySlot`: `String?`(当前正在进行学习请求的按键槽位) + - `sendingKeySlot`: `String?`(当前正在发射信号的按键槽位) + - `learnedSlots`: `Map`(已在 Room 数据库中记录为 READY 的槽位映射) + - `savedDeviceName`: `DeviceName?`(当前配置的红外设备名,如 `红外控制器`) + - `runtimeMode`: `InfraredRuntimeMode`(Fake 或 Real ABSDK) + - `message`: `UiMessage?`(提示信息或错误) + +--- + +## 4. 交互与视觉规范 (Interaction & Visuals) + +### 4.1 视觉造型 +1. **遥控器本体外壳 (Remote Body Chassis)**: + - 居中放置的圆角卡片,具有微阴影、圆角半径 `28.dp`、外框及哑光质感。 +2. **空调 LCD 虚拟屏**: + - 顶部黑色圆角框,内部呈现液晶感绿色/白色字样(如当前目标温度 `26°C` 与工作状态)。 +3. **垂直双向胶囊摇杆 (Vertical Capsule Rockers)**: + - 上半区与下半区独立响应点击,中间由细微分割线隔开,常用于“温度 +/-”、“音量 +/-”及“频道 +/-”。 +4. **圆形导航方向盘 (D-Pad Wheel)**: + - 直径约 `180.dp` 的圆形组合组件,外环包含 4 个扇区方向按键,内嵌独立的圆形 OK 键。 + +### 4.2 按键状态机反馈 +1. **控制模式 (学习开关 OFF)**: + - **已录制 (READY)**:按键实体呈现饱满质感,点击触发波纹反馈,调用 `sendKey` 发射红外,提示“已发射”。 + - **未录制 (UNLEARNED)**:按键显示浅色未激活状态;点击弹出轻量提示:`“按键尚未学习,请打开右上角学习模式”`。 +2. **学习录制模式 (学习开关 ON)**: + - 顶部呈现橙色高亮提示条:`“🔴 学习模式已开启:点击任意按键开始录制”`。 + - 所有按键进入可录制状态(带提示外框)。 + - 点击目标按键后: + - 按键上显示微型 Loading 光圈。 + - 调用 `learnKey(deviceName, defaultSlot, displayName)`。 + - 手机或控制器捕获红外信号成功后,按键变为 READY 并伴随成功提示。 + +--- + +## 5. 验证与测试标准 (Verification & Acceptance Criteria) + +1. **单元测试 (Unit Tests)**: + - `InfraredRemoteViewModelTest` 覆盖场景切换、学习模式开关、按键已学/未学状态映射、点击发射以及点击学习请求的转发。 +2. **UI 测试 (Compose Tests)**: + - 空调与电视遥控器按键正常渲染并带正确 Tag。 + - 学习模式切换后 UI 状态正确切换。 +3. **真机与编译验收**: + - 模块与 App 通过 JVM 测试与打包验证。 + - 手机上点击空调/电视按键能发起正确的 Slot 编号学习与发射。 -- 2.54.0 From 4c8403e45ff61e73cf64244c5ead3fd83ac2189d Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:03:26 +0800 Subject: [PATCH 20/41] docs: plan infrared remote control UI implementation --- .../plans/2026-08-24-infrared-remote-ui.md | 589 ++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-infrared-remote-ui.md diff --git a/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md b/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md new file mode 100644 index 0000000..942c7f7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md @@ -0,0 +1,589 @@ +# Infrared Remote Control UI & Multi-Scene Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation. + +**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`. + +**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines. + +**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md` + +## Global Constraints + +- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces. +- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50). +- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`. +- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names. +- Follow TDD with unit and Compose UI tests. + +--- + +### Task 1: Define Remote Scenes and Key Specs + +**Files:** +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt` +- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt` + +**Interfaces:** +- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`. + +- [ ] **Step 1: Write test for predefined AC and TV remote button specifications** + +```kotlin +package com.flagship.abox.manager.infrared.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class RemoteModelsTest { + @Test + fun acSpecsHaveUniqueSlotsBetween1And20() { + val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot } + assertEquals(slots.size, slots.distinct().size) + assert(slots.all { it.toInt() in 1..20 }) + } + + @Test + fun tvSpecsHaveUniqueSlotsBetween21And50() { + val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot } + assertEquals(slots.size, slots.distinct().size) + assert(slots.all { it.toInt() in 21..50 }) + } +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"` +Expected: Compilation failure. + +- [ ] **Step 3: Implement `RemoteModels.kt`** + +```kotlin +package com.flagship.abox.manager.infrared.remote + +enum class RemoteScene { + AIR_CONDITIONER, + TELEVISION, +} + +data class RemoteButtonSpec( + val id: String, + val defaultSlot: String, + val displayName: String, + val symbol: String, +) + +object RemoteSceneConfig { + object AirConditioner { + val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻") + val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲") + val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼") + val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀") + val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨") + val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔") + val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱") + + val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) + } + + object Television { + val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻") + val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇") + val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲") + val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼") + val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀") + val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶") + val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK") + val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +") + val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -") + val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +") + val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -") + val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠") + val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩") + val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰") + + val buttons = listOf( + POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK, + VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU + ) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt +git commit -m "feat: define infrared remote scenes and button specifications" +``` + +--- + +### Task 2: Implement Infrared Remote ViewModel + +**Files:** +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt` +- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt` + +**Interfaces:** +- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`. +- Produces: `StateFlow` with `onButtonClick`, `toggleLearningMode`, `switchScene`. + +- [ ] **Step 1: Write failing ViewModel test** + +```kotlin +package com.flagship.abox.manager.infrared.remote + +import androidx.lifecycle.SavedStateHandle +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.* +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +private class TestModeStore : InfraredRuntimeModeStore { + private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) + override val mode: Flow = modeFlow + override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + modeFlow.value = mode + return DomainResult.Success(Unit) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredRemoteViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profile = ProviderProfileId("abox") + private val fakeProfile = ProviderProfileId("abox:infrared-fake") + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepo = FakeInfraredKeyRepository() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val modeStore = TestModeStore() + private val resolver = InfraredRuntimeProfileResolver() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock) + + private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val send = SendInfraredKey(keyRepo, gateway) + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learn, retry, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredRemoteViewModel { + return InfraredRemoteViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), + keyRepository = keyRepo, + deviceConfigurationRepository = deviceRepo, + runtimeModeStore = modeStore, + profileResolver = resolver, + learningCoordinator = coordinator, + sendInfraredKey = send, + ) + } + + @Test + fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun learningModeTriggersLearnOnButtonClick() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + + job.cancel() + } +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"` +Expected: Compilation failure. + +- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`** + +```kotlin +package com.flagship.abox.manager.infrared.remote + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.* +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface RemoteUiMessage { + data class Info(val text: String) : RemoteUiMessage + data class Error(val text: String) : RemoteUiMessage + data object SessionInvalid : RemoteUiMessage +} + +data class InfraredRemoteUiState( + val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, + val isLearningMode: Boolean = false, + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val effectiveProfileId: ProviderProfileId? = null, + val savedDeviceName: DeviceName? = null, + val learnedSlotMap: Map = emptyMap(), + val sendingKeySlot: String? = null, + val activeLearningSlot: String? = null, + val message: RemoteUiMessage? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredRemoteViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val runtimeModeStore: InfraredRuntimeModeStore, + private val profileResolver: InfraredRuntimeProfileResolver, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER) + private val isLearningModeFlow = MutableStateFlow(false) + private val sendingKeySlotFlow = MutableStateFlow(null) + private val activeLearningSlotFlow = MutableStateFlow(null) + private val messageFlow = MutableStateFlow(null) + + private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode -> + if (authenticatedProfileId == null) { + flowOf(Pair(null, mode)) + } else { + flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode)) + } + } + + private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) flowOf(DomainResult.Success(null)) + else deviceConfigurationRepository.observeDeviceName(profile) + } + + private val keysFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) flowOf(DomainResult.Success(emptyList())) + else { + deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Success -> { + val device = deviceResult.value + if (device == null) flowOf(DomainResult.Success(emptyList())) + else keyRepository.observeKeys(profile, device) + } + is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) + } + } + } + } + + val state: StateFlow = combine( + sceneFlow, + isLearningModeFlow, + effectiveProfileAndMode, + deviceNameFlow, + keysFlow, + sendingKeySlotFlow, + activeLearningSlotFlow, + messageFlow, + ) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg -> + val (profile, mode) = profileAndMode + val savedDevice = (deviceResult as? DomainResult.Success)?.value + val keys = (keysResult as? DomainResult.Success>)?.value ?: emptyList() + val slotMap = keys.associateBy { it.sdkKey } + + InfraredRemoteUiState( + selectedScene = scene, + isLearningMode = isLearning, + runtimeMode = mode, + effectiveProfileId = profile, + savedDeviceName = savedDevice, + learnedSlotMap = slotMap, + sendingKeySlot = sendingSlot, + activeLearningSlot = learningSlot, + message = msg, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredRemoteUiState(), + ) + + fun switchScene(scene: RemoteScene) { + sceneFlow.value = scene + } + + fun toggleLearningMode(enabled: Boolean) { + isLearningModeFlow.value = enabled + if (enabled) { + messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") + } + } + + fun onButtonClick(spec: RemoteButtonSpec) { + val currentProfile = state.value.effectiveProfileId ?: return + val savedDevice = state.value.savedDeviceName + if (savedDevice == null) { + messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置") + return + } + + if (state.value.isLearningMode) { + activeLearningSlotFlow.value = spec.defaultSlot + learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ) + messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...") + } else { + val key = state.value.learnedSlotMap[spec.defaultSlot] + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") + return + } + sendingKeySlotFlow.value = spec.defaultSlot + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) { + is DomainResult.Success -> { + messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeySlotFlow.value = null + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = RemoteUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + } + else -> { + messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败") + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote +git commit -m "feat: implement infrared remote control viewmodel" +``` + +--- + +### Task 3: Build Custom Remote Visual Components + +**Files:** +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt` +- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt` +- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt` + +- [ ] **Step 1: Implement Compose visual elements** + - `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`. + - Compose panels for `AcRemotePanel` and `TvRemotePanel`. + - Assemble `InfraredRemoteScreen` with top learning switch and settings action. + +- [ ] **Step 2: Write Compose UI Test** + +```kotlin +package com.flagship.abox.manager.infrared.remote + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredRemoteScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun rendersAcPanelAndTvPanelCorrectly() { + val state = InfraredRemoteUiState( + selectedScene = RemoteScene.AIR_CONDITIONER, + savedDeviceName = DeviceName("ir-1"), + ) + var clickedSpec: RemoteButtonSpec? = null + + composeTestRule.setContent { + InfraredRemoteScreen( + state = state, + onBack = {}, + onOpenSettings = {}, + onSessionInvalid = {}, + onSceneSelected = {}, + onToggleLearningMode = {}, + onButtonClick = { clickedSpec = it }, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick() + assertEquals("AC_POWER", clickedSpec?.id) + } +} +``` + +- [ ] **Step 3: Run compilation and tests** + +Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote +git commit -m "feat: add infrared remote control Compose screen and panels" +``` + +--- + +### Task 4: Integrate Navigation and App Flow + +**Files:** +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt` +- Modify: `android/app/src/main/res/values/strings.xml` +- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml` + +- [ ] **Step 1: Wire Navigation** + - Route `infrared/{profileId}` now loads `InfraredRemoteRoute`. + - Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view). + - Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`. + +- [ ] **Step 2: Run Full Verification Suite & Build APK** + +Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit and Push** + +```bash +git add android/app android/feature/infrared +git commit -m "feat: wire infrared remote screen and settings navigation" +git push origin feat/moler-infrared-control +``` -- 2.54.0 From feab58bf3dc3d8b54986c3c18dee00642f018a50 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:08:56 +0800 Subject: [PATCH 21/41] feat: define infrared remote scenes and button specifications --- .../manager/infrared/remote/RemoteModels.kt | 49 +++++++++++++++++++ .../infrared/remote/RemoteModelsTest.kt | 21 ++++++++ 2 files changed, 70 insertions(+) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt new file mode 100644 index 0000000..041790b --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt @@ -0,0 +1,49 @@ +package com.flagship.abox.manager.infrared.remote + +enum class RemoteScene { + AIR_CONDITIONER, + TELEVISION, +} + +data class RemoteButtonSpec( + val id: String, + val defaultSlot: String, + val displayName: String, + val symbol: String, +) + +object RemoteSceneConfig { + object AirConditioner { + val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻") + val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲") + val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼") + val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀") + val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨") + val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔") + val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱") + + val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) + } + + object Television { + val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻") + val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇") + val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲") + val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼") + val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀") + val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶") + val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK") + val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +") + val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -") + val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +") + val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -") + val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠") + val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩") + val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰") + + val buttons = listOf( + POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK, + VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU, + ) + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt new file mode 100644 index 0000000..b10d25a --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt @@ -0,0 +1,21 @@ +package com.flagship.abox.manager.infrared.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemoteModelsTest { + @Test + fun acSpecsHaveUniqueSlotsBetween1And20() { + val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot } + assertEquals(slots.size, slots.distinct().size) + assertTrue(slots.all { it.toInt() in 1..20 }) + } + + @Test + fun tvSpecsHaveUniqueSlotsBetween21And50() { + val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot } + assertEquals(slots.size, slots.distinct().size) + assertTrue(slots.all { it.toInt() in 21..50 }) + } +} -- 2.54.0 From 3a79338fb957f96c1dbeb0aed48914ee9f22b918 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:16:16 +0800 Subject: [PATCH 22/41] feat: implement infrared remote control viewmodel --- .../remote/InfraredRemoteViewModel.kt | 214 ++++++++++++++++++ .../remote/InfraredRemoteViewModelTest.kt | 139 ++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt new file mode 100644 index 0000000..b50f7e4 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -0,0 +1,214 @@ +package com.flagship.abox.manager.infrared.remote + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.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 com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface RemoteUiMessage { + data class Info(val text: String) : RemoteUiMessage + data class Error(val text: String) : RemoteUiMessage + data object SessionInvalid : RemoteUiMessage +} + +data class InfraredRemoteUiState( + val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, + val isLearningMode: Boolean = false, + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val effectiveProfileId: ProviderProfileId? = null, + val savedDeviceName: DeviceName? = null, + val learnedSlotMap: Map = emptyMap(), + val sendingKeySlot: String? = null, + val activeLearningSlot: String? = null, + val message: RemoteUiMessage? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredRemoteViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val runtimeModeStore: InfraredRuntimeModeStore, + private val profileResolver: InfraredRuntimeProfileResolver, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER) + private val isLearningModeFlow = MutableStateFlow(false) + private val sendingKeySlotFlow = MutableStateFlow(null) + private val activeLearningSlotFlow = MutableStateFlow(null) + private val messageFlow = MutableStateFlow(null) + + private val effectiveProfileAndMode: Flow> = + runtimeModeStore.mode.flatMapLatest { mode -> + if (authenticatedProfileId == null) { + flowOf(Pair(null, mode)) + } else { + flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode)) + } + } + + private val deviceNameFlow: Flow> = + effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) flowOf(DomainResult.Success(null)) + else deviceConfigurationRepository.observeDeviceName(profile) + } + + private val keysFlow: Flow>> = + effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) flowOf(DomainResult.Success(emptyList())) + else { + deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Success -> { + val device = deviceResult.value + if (device == null) flowOf(DomainResult.Success(emptyList())) + else keyRepository.observeKeys(profile, device) + } + is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) + } + } + } + } + + val state: StateFlow = combine( + sceneFlow, + isLearningModeFlow, + effectiveProfileAndMode, + deviceNameFlow, + keysFlow, + sendingKeySlotFlow, + activeLearningSlotFlow, + messageFlow, + ) { args: Array -> + val scene = args[0] as RemoteScene + val isLearning = args[1] as Boolean + val profileAndMode = args[2] as Pair + val deviceResult = args[3] as DomainResult + val keysResult = args[4] as DomainResult> + val sendingSlot = args[5] as String? + val learningSlot = args[6] as String? + val msg = args[7] as RemoteUiMessage? + + val (profile, mode) = profileAndMode + val savedDevice = (deviceResult as? DomainResult.Success)?.value + val keys = (keysResult as? DomainResult.Success>)?.value ?: emptyList() + val slotMap = keys.associateBy { it.sdkKey } + + InfraredRemoteUiState( + selectedScene = scene, + isLearningMode = isLearning, + runtimeMode = mode, + effectiveProfileId = profile, + savedDeviceName = savedDevice, + learnedSlotMap = slotMap, + sendingKeySlot = sendingSlot, + activeLearningSlot = learningSlot, + message = msg, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredRemoteUiState(), + ) + + fun switchScene(scene: RemoteScene) { + sceneFlow.value = scene + } + + fun toggleLearningMode(enabled: Boolean) { + isLearningModeFlow.value = enabled + if (enabled) { + messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") + } + } + + fun onButtonClick(spec: RemoteButtonSpec) { + val currentProfile = state.value.effectiveProfileId ?: return + val savedDevice = state.value.savedDeviceName + if (savedDevice == null) { + messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置") + return + } + + if (isLearningModeFlow.value) { + activeLearningSlotFlow.value = spec.defaultSlot + learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ) + messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") + } else { + val key = state.value.learnedSlotMap[spec.defaultSlot] + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") + return + } + sendingKeySlotFlow.value = spec.defaultSlot + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) { + is DomainResult.Success -> { + messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeySlotFlow.value = null + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = RemoteUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + } + else -> { + messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败") + } + } + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt new file mode 100644 index 0000000..6bca3ff --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -0,0 +1,139 @@ +package com.flagship.abox.manager.infrared.remote + +import androidx.lifecycle.SavedStateHandle +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.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +private class TestModeStore : InfraredRuntimeModeStore { + private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) + override val mode: Flow = modeFlow + override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + modeFlow.value = mode + return DomainResult.Success(Unit) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredRemoteViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profile = ProviderProfileId("abox") + private val fakeProfile = ProviderProfileId("abox:infrared-fake") + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepo = FakeInfraredKeyRepository() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val modeStore = TestModeStore() + private val resolver = InfraredRuntimeProfileResolver() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) + + private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val send = SendInfraredKey(keyRepo, gateway) + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learn, retry, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredRemoteViewModel { + return InfraredRemoteViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), + keyRepository = keyRepo, + deviceConfigurationRepository = deviceRepo, + runtimeModeStore = modeStore, + profileResolver = resolver, + learningCoordinator = coordinator, + sendInfraredKey = send, + ) + } + + @Test + fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun learningModeTriggersLearnOnButtonClick() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + + job.cancel() + } +} -- 2.54.0 From 9dc5226d83a8ada9f4f67d4d9f89ea57c0b06632 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:25:32 +0800 Subject: [PATCH 23/41] feat: build custom remote visual components and Compose panels --- .../remote/InfraredRemoteScreenTest.kt | 45 ++ .../infrared/remote/ui/AcRemotePanel.kt | 172 ++++++++ .../remote/ui/InfraredRemoteScreen.kt | 246 +++++++++++ .../infrared/remote/ui/RemoteComponents.kt | 407 ++++++++++++++++++ .../infrared/remote/ui/TvRemotePanel.kt | 161 +++++++ 5 files changed, 1031 insertions(+) create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt new file mode 100644 index 0000000..64f9a7b --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -0,0 +1,45 @@ +package com.flagship.abox.manager.infrared.remote + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredRemoteScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun rendersAcPanelAndTvPanelCorrectly() { + val state = InfraredRemoteUiState( + selectedScene = RemoteScene.AIR_CONDITIONER, + savedDeviceName = DeviceName("ir-1"), + ) + var clickedSpec: RemoteButtonSpec? = null + + composeTestRule.setContent { + InfraredRemoteScreen( + state = state, + onBack = {}, + onOpenSettings = {}, + onSessionInvalid = {}, + onSceneSelected = {}, + onToggleLearningMode = {}, + onButtonClick = { clickedSpec = it }, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick() + assertEquals("AC_POWER", clickedSpec?.id) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt new file mode 100644 index 0000000..d691ae2 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt @@ -0,0 +1,172 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec +import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig + +@Composable +fun AcRemotePanel( + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, + modifier: Modifier = Modifier, +) { + RemoteChassis(modifier = modifier) { + // 顶部虚拟 LCD 液晶显示区 + AcLcdDisplay() + + // 电源与温度控制行 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // 电源按键 + val powerSpec = RemoteSceneConfig.AirConditioner.POWER + val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY + val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot + + RemoteCircleButton( + spec = powerSpec, + isLearned = isPowerLearned, + isLoading = isPowerLoading, + isLearningMode = isLearningMode, + onClick = { onButtonClick(powerSpec) }, + size = 72.dp, + containerColor = Color(0xFFE53935), + contentColor = Color.White, + ) + + // 垂直胶囊温度调节器 + val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP + val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN + + VerticalCapsuleRocker( + upSpec = tempUpSpec, + downSpec = tempDownSpec, + isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot, + isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(tempUpSpec) }, + onDownClick = { onButtonClick(tempDownSpec) }, + label = "温度调节", + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 功能按键网格 (模式 / 风速 / 扫风 / 定时) + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + } + } + } +} + +@Composable +private fun AcGridButton( + spec: RemoteButtonSpec, + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, +) { + RemoteRectButton( + spec = spec, + isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(spec) }, + width = 120.dp, + height = 52.dp, + ) +} + +@Composable +private fun AcLcdDisplay() { + Card( + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + .shadow(4.dp, shape = RoundedCornerShape(16.dp)), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B), + ), + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "❄️ 制冷模式", + color = Color(0xFF38BDF8), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = "💨 自动风速 | ↔ 扫风", + color = Color(0xFF94A3B8), + fontSize = 12.sp, + ) + } + + Text( + text = "26°C", + color = Color(0xFF4ADE80), + fontSize = 38.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt new file mode 100644 index 0000000..bd3e38e --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -0,0 +1,246 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState +import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec +import com.flagship.abox.manager.infrared.remote.RemoteScene +import com.flagship.abox.manager.infrared.remote.RemoteUiMessage + +@Composable +fun InfraredRemoteRoute( + onBack: () -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredRemoteViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredRemoteScreen( + state = state, + onBack = onBack, + onOpenSettings = onOpenSettings, + onSessionInvalid = onSessionInvalid, + onSceneSelected = viewModel::switchScene, + onToggleLearningMode = viewModel::toggleLearningMode, + onButtonClick = viewModel::onButtonClick, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredRemoteScreen( + state: InfraredRemoteUiState, + onBack: () -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + onSceneSelected: (RemoteScene) -> Unit, + onToggleLearningMode: (Boolean) -> Unit, + onButtonClick: (RemoteButtonSpec) -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is RemoteUiMessage.Info -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is RemoteUiMessage.Error -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is RemoteUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "红外遥控器", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + }, + navigationIcon = { + TextButton(onClick = onBack) { + Text("返回") + } + }, + actions = { + // 学习模式开关 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = if (state.isLearningMode) "录制中" else "学习模式", + fontSize = 12.sp, + fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal, + color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + Switch( + checked = state.isLearningMode, + onCheckedChange = onToggleLearningMode, + modifier = Modifier.testTag("remote_learning_mode_switch"), + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.error, + checkedTrackColor = MaterialTheme.colorScheme.errorContainer, + ), + ) + } + + // 设置齿轮按钮 + IconButton( + onClick = onOpenSettings, + modifier = Modifier.testTag("remote_settings_button"), + ) { + Text("⚙️", fontSize = 20.sp) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 学习模式顶部横幅提示 + if (state.isLearningMode) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + shape = RoundedCornerShape(12.dp), + ) + .padding(vertical = 8.dp, horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "🔴 红外学习模式已开启:点击任意按键即可录制信号", + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } + + // 场景切换 Tab (空调 | 电视) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = state.selectedScene == RemoteScene.AIR_CONDITIONER, + onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) }, + label = { Text("❄️ 空调遥控") }, + modifier = Modifier + .padding(end = 8.dp) + .testTag("scene_tab_ac"), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + FilterChip( + selected = state.selectedScene == RemoteScene.TELEVISION, + onClick = { onSceneSelected(RemoteScene.TELEVISION) }, + label = { Text("📺 电视遥控") }, + modifier = Modifier.testTag("scene_tab_tv"), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + + // 对应场景遥控器面板 + when (state.selectedScene) { + RemoteScene.AIR_CONDITIONER -> { + AcRemotePanel( + learnedSlotMap = state.learnedSlotMap, + activeLearningSlot = state.activeLearningSlot, + sendingKeySlot = state.sendingKeySlot, + isLearningMode = state.isLearningMode, + onButtonClick = onButtonClick, + ) + } + RemoteScene.TELEVISION -> { + TvRemotePanel( + learnedSlotMap = state.learnedSlotMap, + activeLearningSlot = state.activeLearningSlot, + sendingKeySlot = state.sendingKeySlot, + isLearningMode = state.isLearningMode, + onButtonClick = onButtonClick, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt new file mode 100644 index 0000000..3dc9e39 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt @@ -0,0 +1,407 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec + +@Composable +fun RemoteChassis( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .shadow(12.dp, shape = RoundedCornerShape(32.dp)), + shape = RoundedCornerShape(32.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + content() + } + } +} + +@Composable +fun RemoteCircleButton( + spec: RemoteButtonSpec, + isLearned: Boolean, + isLoading: Boolean, + isLearningMode: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 64.dp, + containerColor: Color? = null, + contentColor: Color? = null, +) { + val defaultContainer = when { + isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + isLearned -> containerColor ?: MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surface + } + + val defaultContent = when { + isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer + isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + } + + val borderModifier = if (isLearningMode) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + } else if (!isLearned) { + Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape) + } else { + Modifier + } + + Surface( + modifier = modifier + .size(size) + .then(borderModifier) + .testTag("remote_button_${spec.id}"), + shape = CircleShape, + color = defaultContainer, + contentColor = defaultContent, + shadowElevation = if (isLearned) 4.dp else 1.dp, + onClick = onClick, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(size * 0.45f), + strokeWidth = 2.5.dp, + color = defaultContent, + ) + } else { + Text( + text = spec.symbol, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp, + fontWeight = FontWeight.Bold, + ), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +fun RemoteRectButton( + spec: RemoteButtonSpec, + isLearned: Boolean, + isLoading: Boolean, + isLearningMode: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = 100.dp, + height: Dp = 48.dp, +) { + val containerColor = when { + isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + isLearned -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surface + } + val contentColor = when { + isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer + isLearned -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + } + + val borderModifier = if (isLearningMode) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp)) + } else if (!isLearned) { + Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + } else { + Modifier + } + + Surface( + modifier = modifier + .width(width) + .height(height) + .then(borderModifier) + .testTag("remote_button_${spec.id}"), + shape = RoundedCornerShape(16.dp), + color = containerColor, + contentColor = contentColor, + shadowElevation = if (isLearned) 3.dp else 1.dp, + onClick = onClick, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = contentColor, + ) + } else { + Text( + text = spec.symbol, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +fun VerticalCapsuleRocker( + upSpec: RemoteButtonSpec, + downSpec: RemoteButtonSpec, + isUpLearned: Boolean, + isDownLearned: Boolean, + isUpLoading: Boolean, + isDownLoading: Boolean, + isLearningMode: Boolean, + onUpClick: () -> Unit, + onDownClick: () -> Unit, + modifier: Modifier = Modifier, + label: String = "", +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Card( + modifier = Modifier + .width(68.dp) + .height(136.dp) + .shadow(4.dp, shape = RoundedCornerShape(34.dp)), + shape = RoundedCornerShape(34.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Top half + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp)) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(onClick = onUpClick) + .testTag("remote_button_${upSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isUpLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + text = upSpec.symbol, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Bottom half + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp)) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(onClick = onDownClick) + .testTag("remote_button_${downSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isDownLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + text = downSpec.symbol, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + } + } + } + if (label.isNotBlank()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun RemoteDpadWheel( + upSpec: RemoteButtonSpec, + downSpec: RemoteButtonSpec, + leftSpec: RemoteButtonSpec, + rightSpec: RemoteButtonSpec, + okSpec: RemoteButtonSpec, + isUpLearned: Boolean, + isDownLearned: Boolean, + isLeftLearned: Boolean, + isRightLearned: Boolean, + isOkLearned: Boolean, + isUpLoading: Boolean, + isDownLoading: Boolean, + isLeftLoading: Boolean, + isRightLoading: Boolean, + isOkLoading: Boolean, + isLearningMode: Boolean, + onUpClick: () -> Unit, + onDownClick: () -> Unit, + onLeftClick: () -> Unit, + onRightClick: () -> Unit, + onOkClick: () -> Unit, + modifier: Modifier = Modifier, + wheelSize: Dp = 190.dp, +) { + Card( + modifier = modifier + .size(wheelSize) + .shadow(6.dp, shape = CircleShape), + shape = CircleShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // Up Button + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onUpClick) + .testTag("remote_button_${upSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Down Button + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onDownClick) + .testTag("remote_button_${downSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Left Button + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onLeftClick) + .testTag("remote_button_${leftSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Right Button + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onRightClick) + .testTag("remote_button_${rightSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Center OK Button + RemoteCircleButton( + spec = okSpec, + isLearned = isOkLearned, + isLoading = isOkLoading, + isLearningMode = isLearningMode, + onClick = onOkClick, + size = 62.dp, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt new file mode 100644 index 0000000..2c80c9d --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt @@ -0,0 +1,161 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec +import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig + +@Composable +fun TvRemotePanel( + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, + modifier: Modifier = Modifier, +) { + RemoteChassis(modifier = modifier) { + // 顶部电源与静音行 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val powerSpec = RemoteSceneConfig.Television.POWER + RemoteCircleButton( + spec = powerSpec, + isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(powerSpec) }, + size = 56.dp, + containerColor = Color(0xFFE53935), + contentColor = Color.White, + ) + + val muteSpec = RemoteSceneConfig.Television.MUTE + RemoteCircleButton( + spec = muteSpec, + isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(muteSpec) }, + size = 56.dp, + ) + } + + // 中心圆形方向轮盘 (D-Pad Wheel) + val upSpec = RemoteSceneConfig.Television.NAV_UP + val downSpec = RemoteSceneConfig.Television.NAV_DOWN + val leftSpec = RemoteSceneConfig.Television.NAV_LEFT + val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT + val okSpec = RemoteSceneConfig.Television.OK + + RemoteDpadWheel( + upSpec = upSpec, + downSpec = downSpec, + leftSpec = leftSpec, + rightSpec = rightSpec, + okSpec = okSpec, + isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot, + isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot, + isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot, + isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot, + isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(upSpec) }, + onDownClick = { onButtonClick(downSpec) }, + onLeftClick = { onButtonClick(leftSpec) }, + onRightClick = { onButtonClick(rightSpec) }, + onOkClick = { onButtonClick(okSpec) }, + ) + + // 系统功能按键行 (返回 / 主页 / 菜单) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 音量与频道双摇杆胶囊 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // 音量控制 + val volUp = RemoteSceneConfig.Television.VOL_UP + val volDown = RemoteSceneConfig.Television.VOL_DOWN + VerticalCapsuleRocker( + upSpec = volUp, + downSpec = volDown, + isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot, + isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(volUp) }, + onDownClick = { onButtonClick(volDown) }, + label = "音量 VOL", + ) + + // 频道控制 + val chUp = RemoteSceneConfig.Television.CH_UP + val chDown = RemoteSceneConfig.Television.CH_DOWN + VerticalCapsuleRocker( + upSpec = chUp, + downSpec = chDown, + isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot, + isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(chUp) }, + onDownClick = { onButtonClick(chDown) }, + label = "频道 CH", + ) + } + } +} + +@Composable +private fun TvActionButton( + spec: RemoteButtonSpec, + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, +) { + RemoteRectButton( + spec = spec, + isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(spec) }, + width = 84.dp, + height = 44.dp, + ) +} -- 2.54.0 From 1fdc9bd3760c8dc29b5bba9c03b3c66283281e3f Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:32:48 +0800 Subject: [PATCH 24/41] feat: wire infrared remote screen and settings navigation --- .../abox/manager/ui/ABoxManagerApp.kt | 75 ++++++++++++++++++- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 9a2dc11..3f97c7d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -11,9 +11,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute +import com.flagship.abox.manager.infrared.ui.InfraredControlRoute +import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute @@ -57,18 +67,75 @@ fun ABoxManagerApp( onSwitchProvider = sessionActionsViewModel::switchProvider, viewModel = loginViewModel, ) - is AppSessionUiState.Authenticated -> MainAppShell( - userName = state.username.value, - providerName = state.profile.displayName, - onLogout = sessionActionsViewModel::logout, + is AppSessionUiState.Authenticated -> AuthenticatedNavHost( + username = state.username, + profile = state.profile, actionInProgress = actionState.inProgress, errorMessage = actionState.errorMessage, + onLogout = sessionActionsViewModel::logout, ) } } } } +@Composable +private fun AuthenticatedNavHost( + username: Username, + profile: ProviderProfile, + actionInProgress: Boolean, + errorMessage: String?, + onLogout: () -> Unit, +) { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = "home", + ) { + composable("home") { + MainAppShell( + userName = username.value, + providerName = profile.displayName, + onLogout = onLogout, + actionInProgress = actionInProgress, + errorMessage = errorMessage, + devicesSlot = { + DeviceListScreen( + onInfraredClick = { + navController.navigate("infrared_remote/${profile.id.value}") + }, + ) + }, + ) + } + composable( + route = "infrared_remote/{profileId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + ), + ) { + InfraredRemoteRoute( + onBack = { navController.popBackStack() }, + onOpenSettings = { + navController.navigate("infrared_settings/${profile.id.value}") + }, + onSessionInvalid = onLogout, + ) + } + composable( + route = "infrared_settings/{profileId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + ), + ) { + InfraredControlRoute( + onBack = { navController.popBackStack() }, + onSessionInvalid = onLogout, + ) + } + } +} + private fun AppSessionUiState.screenKey(): String = when (this) { AppSessionUiState.Loading -> "loading" AppSessionUiState.ProviderSelection -> "providers" -- 2.54.0 From ee42d59879fa579b73d2714a1683fc561c307e9b Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 10:49:29 +0800 Subject: [PATCH 25/41] refactor: align infrared feature with provider login architecture and remove legacy temporary login flow --- .../abox/manager/ui/ABoxManagerAppTest.kt | 26 ------- android/app/src/debug/AndroidManifest.xml | 4 -- .../manager/di/DebugRoutingInfraredGateway.kt | 41 ----------- .../abox/manager/di/InfraredReleaseModule.kt | 47 ------------- .../di/DebugRoutingInfraredGatewayTest.kt | 70 ------------------- gradle/libs.versions.toml | 1 + 6 files changed, 1 insertion(+), 188 deletions(-) delete mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt delete mode 100644 android/app/src/debug/AndroidManifest.xml delete mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt delete mode 100644 android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt delete mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt deleted file mode 100644 index bd085c5..0000000 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/ABoxManagerAppTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.flagship.abox.manager.ui - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.flagship.abox.manager.MainActivity -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ABoxManagerAppTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Test - fun opensInfraredControlScreenFromFoundationScreen() { - composeTestRule.onNodeWithText("Infrared Control").performClick() - composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed() - } -} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index c0638bc..0000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt deleted file mode 100644 index b3b6c01..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.DeviceName -import com.flagship.abox.manager.domain.DomainResult -import com.flagship.abox.manager.domain.InfraredCode -import com.flagship.abox.manager.domain.InfraredCodeDownloadResult -import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore - -class DebugRoutingInfraredGateway( - private val modeStore: InfraredRuntimeModeStore, - private val fakeGateway: InfraredGateway, - private val realGateway: InfraredGateway, -) : InfraredGateway { - - private suspend fun delegate(): InfraredGateway { - return when (modeStore.currentMode()) { - InfraredRuntimeMode.REAL_ABSDK -> realGateway - InfraredRuntimeMode.FAKE -> fakeGateway - } - } - - override suspend fun sendKey( - profileId: ProviderProfileId, - deviceName: DeviceName, - key: String, - ): DomainResult = delegate().sendKey(profileId, deviceName, key) - - override suspend fun learnKey( - profileId: ProviderProfileId, - deviceName: DeviceName, - key: String, - ): DomainResult = delegate().learnKey(profileId, deviceName, key) - - override suspend fun downloadCodes( - profileId: ProviderProfileId, - codes: List, - ): DomainResult> = delegate().downloadCodes(profileId, codes) -} diff --git a/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt b/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt deleted file mode 100644 index 705c2b0..0000000 --- a/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.flagship.abox.manager.di - -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.InfraredCode -import com.flagship.abox.manager.domain.InfraredCodeDownloadResult -import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.ProviderProfileId -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object InfraredReleaseModule { - - @Provides - @Singleton - fun provideInfraredGateway(): InfraredGateway = object : InfraredGateway { - override suspend fun sendKey( - profileId: ProviderProfileId, - deviceName: DeviceName, - key: String, - ): DomainResult = disabledFailure() - - override suspend fun learnKey( - profileId: ProviderProfileId, - deviceName: DeviceName, - key: String, - ): DomainResult = disabledFailure() - - override suspend fun downloadCodes( - profileId: ProviderProfileId, - codes: List, - ): DomainResult> = disabledFailure() - - private fun disabledFailure(): DomainResult = DomainResult.Failure( - DomainError.OperationFailed( - externalCode = "REAL_ABSDK_DISABLED", - detail = "Infrared hardware transport is disabled in release builds", - ), - ) - } -} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt deleted file mode 100644 index 339dc83..0000000 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.DeviceName -import com.flagship.abox.manager.domain.DomainResult -import com.flagship.abox.manager.domain.InfraredCode -import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -private class TestModeStore : InfraredRuntimeModeStore { - private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) - override val mode: Flow = modeFlow - override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - modeFlow.value = mode - return DomainResult.Success(Unit) - } -} - -class DebugRoutingInfraredGatewayTest { - - private val modeStore = TestModeStore() - private val fake = FakeInfraredGateway() - private val real = FakeInfraredGateway() - private val router = DebugRoutingInfraredGateway(modeStore, fake, real) - - private val profile = ProviderProfileId("profile1") - private val device = DeviceName("ir-1") - - @Test - fun delegatesToFakeByDefault() = runTest { - val result = router.sendKey(profile, device, "key1") - assertTrue(result is DomainResult.Success) - assertEquals(1, fake.sentKeys.size) - assertTrue(real.sentKeys.isEmpty()) - - router.learnKey(profile, device, "key2") - assertEquals(1, fake.learnedKeys.size) - assertTrue(real.learnedKeys.isEmpty()) - - router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code"))) - assertEquals(1, fake.downloadCalls.size) - assertTrue(real.downloadCalls.isEmpty()) - } - - @Test - fun delegatesToRealWhenModeIsRealAbsdk() = runTest { - modeStore.setMode(InfraredRuntimeMode.REAL_ABSDK) - - val result = router.sendKey(profile, device, "key1") - assertTrue(result is DomainResult.Success) - assertTrue(fake.sentKeys.isEmpty()) - assertEquals(1, real.sentKeys.size) - - router.learnKey(profile, device, "key2") - assertTrue(fake.learnedKeys.isEmpty()) - assertEquals(1, real.learnedKeys.size) - - router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code"))) - assertTrue(fake.downloadCalls.isEmpty()) - assertEquals(1, real.downloadCalls.size) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c03a4e6..7aa0738 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ 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" } -- 2.54.0 From 714fe699f676ce56ea2b4011343247918188d524 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:05:02 +0800 Subject: [PATCH 26/41] fix(infrared): clear learning state on completion and guard against duplicate key sending --- .../InfraredLearningCoordinator.kt | 13 ++--- .../remote/InfraredRemoteViewModel.kt | 17 ++++++- .../infrared/remote/ui/RemoteComponents.kt | 34 +++++++++++--- .../remote/InfraredRemoteViewModelTest.kt | 47 +++++++++++++++++++ 4 files changed, 95 insertions(+), 16 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index a191321..25ef11a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -6,6 +6,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch class InfraredLearningCoordinator( @@ -18,15 +19,11 @@ class InfraredLearningCoordinator( deviceName: DeviceName, displayName: String, sdkKey: String? = null, - ) { - externalScope.launch { - learnInfraredKey(profileId, deviceName, displayName, sdkKey) - } + ): Job = externalScope.launch { + learnInfraredKey(profileId, deviceName, displayName, sdkKey) } - fun retry(ref: InfraredKeyRef) { - externalScope.launch { - retryInfraredKeyLearning(ref) - } + fun retry(ref: InfraredKeyRef): Job = externalScope.launch { + retryInfraredKeyLearning(ref) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index b50f7e4..a0cdefb 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -148,7 +148,9 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled - if (enabled) { + if (!enabled) { + activeLearningSlotFlow.value = null + } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") } } @@ -162,15 +164,26 @@ class InfraredRemoteViewModel @Inject constructor( } if (isLearningModeFlow.value) { + if (activeLearningSlotFlow.value != null) { + return + } activeLearningSlotFlow.value = spec.defaultSlot - learningCoordinator.learn( + val job = learningCoordinator.learn( profileId = currentProfile, deviceName = savedDevice, displayName = spec.displayName, sdkKey = spec.defaultSlot, ) + job.invokeOnCompletion { + if (activeLearningSlotFlow.value == spec.defaultSlot) { + activeLearningSlotFlow.value = null + } + } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") } else { + if (sendingKeySlotFlow.value != null) { + return + } val key = state.value.learnedSlotMap[spec.defaultSlot] if (key == null || key.status != InfraredKeyStatus.READY) { messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt index 3dc9e39..94100e4 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt @@ -108,6 +108,7 @@ fun RemoteCircleButton( color = defaultContainer, contentColor = defaultContent, shadowElevation = if (isLearned) 4.dp else 1.dp, + enabled = !isLoading, onClick = onClick, ) { Box( @@ -174,6 +175,7 @@ fun RemoteRectButton( color = containerColor, contentColor = contentColor, shadowElevation = if (isLearned) 3.dp else 1.dp, + enabled = !isLoading, onClick = onClick, ) { Box( @@ -240,7 +242,7 @@ fun VerticalCapsuleRocker( else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else Color.Transparent, ) - .clickable(onClick = onUpClick) + .clickable(enabled = !isUpLoading, onClick = onUpClick) .testTag("remote_button_${upSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -267,7 +269,7 @@ fun VerticalCapsuleRocker( else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else Color.Transparent, ) - .clickable(onClick = onDownClick) + .clickable(enabled = !isDownLoading, onClick = onDownClick) .testTag("remote_button_${downSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -338,7 +340,12 @@ fun RemoteDpadWheel( .padding(top = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onUpClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isUpLoading, onClick = onUpClick) .testTag("remote_button_${upSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -353,7 +360,12 @@ fun RemoteDpadWheel( .padding(bottom = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onDownClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isDownLoading, onClick = onDownClick) .testTag("remote_button_${downSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -368,7 +380,12 @@ fun RemoteDpadWheel( .padding(start = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onLeftClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isLeftLoading, onClick = onLeftClick) .testTag("remote_button_${leftSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -383,7 +400,12 @@ fun RemoteDpadWheel( .padding(end = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onRightClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isRightLoading, onClick = onRightClick) .testTag("remote_button_${rightSpec.id}"), contentAlignment = Alignment.Center, ) { diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 6bca3ff..71e7d93 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -133,6 +133,53 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) + assertEquals(null, vm.state.value.activeLearningSlot) + + job.cancel() + } + + @Test + fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + vm.toggleLearningMode(false) + + assertEquals(null, vm.state.value.activeLearningSlot) + + job.cancel() + } + + @Test + fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) job.cancel() } -- 2.54.0 From a3bcdd973c76f8bcaa671794c3d3387b43caebd9 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:14:12 +0800 Subject: [PATCH 27/41] fix(infrared): support re-learning existing keys, cancel active learning jobs, and propagate CancellationException --- .../data/local/RoomInfraredKeyRepository.kt | 5 +++ .../remote/InfraredRemoteViewModel.kt | 35 ++++++++++++++----- .../DataStoreInfraredRuntimeModeStore.kt | 1 + .../remote/InfraredRemoteViewModelTest.kt | 33 +++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) 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 index 79eb56b..e6f52ef 100644 --- 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 @@ -28,6 +28,7 @@ class RoomInfraredKeyRepository( DomainResult.Success(entities.map { it.toDomain() }) } .catch { throwable -> + if (throwable is kotlinx.coroutines.CancellationException) throw throwable emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) } } @@ -38,6 +39,7 @@ class RoomInfraredKeyRepository( DomainResult.Success(entity?.deviceName?.let { DeviceName(it) }) } .catch { throwable -> + if (throwable is kotlinx.coroutines.CancellationException) throw throwable emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) } } @@ -182,6 +184,9 @@ class RoomInfraredKeyRepository( } private fun mapThrowableToDomainError(throwable: Throwable): DomainError { + if (throwable is kotlinx.coroutines.CancellationException) { + throw throwable + } return when (throwable) { is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation") else -> DomainError.Unknown(detail = "Database operation failed") diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index a0cdefb..b2b6439 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -19,6 +19,7 @@ import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -68,6 +69,7 @@ class InfraredRemoteViewModel @Inject constructor( private val sendingKeySlotFlow = MutableStateFlow(null) private val activeLearningSlotFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) + private var activeLearningJob: Job? = null private val effectiveProfileAndMode: Flow> = runtimeModeStore.mode.flatMapLatest { mode -> @@ -149,6 +151,8 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled if (!enabled) { + activeLearningJob?.cancel() + activeLearningJob = null activeLearningSlotFlow.value = null } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") @@ -168,15 +172,24 @@ class InfraredRemoteViewModel @Inject constructor( return } activeLearningSlotFlow.value = spec.defaultSlot - val job = learningCoordinator.learn( - profileId = currentProfile, - deviceName = savedDevice, - displayName = spec.displayName, - sdkKey = spec.defaultSlot, - ) + val existingKey = state.value.learnedSlotMap[spec.defaultSlot] + val job = if (existingKey != null) { + learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) + } else { + learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ) + } + activeLearningJob = job job.invokeOnCompletion { - if (activeLearningSlotFlow.value == spec.defaultSlot) { - activeLearningSlotFlow.value = null + if (activeLearningJob === job) { + activeLearningJob = null + if (activeLearningSlotFlow.value == spec.defaultSlot) { + activeLearningSlotFlow.value = null + } } } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") @@ -207,6 +220,12 @@ class InfraredRemoteViewModel @Inject constructor( } } + override fun onCleared() { + super.onCleared() + activeLearningJob?.cancel() + activeLearningJob = null + } + fun clearMessage() { messageFlow.value = null } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt index 1ce2c0c..eb2e141 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt @@ -52,6 +52,7 @@ class DataStoreInfraredRuntimeModeStore( } DomainResult.Success(Unit) } catch (throwable: Throwable) { + if (throwable is kotlinx.coroutines.CancellationException) throw throwable DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode")) } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 71e7d93..fc4e03d 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -183,4 +183,37 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun learningExistingKeyRetriesExistingKey() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val failedKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "offline", + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(failedKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + + job.cancel() + } } -- 2.54.0 From 8682f81cb8221cbd5ed6a2b48a406904de8b95eb Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:20:29 +0800 Subject: [PATCH 28/41] fix(infrared): propagate learning error result and localize remote screen strings --- .../InfraredLearningCoordinator.kt | 10 +++-- .../remote/InfraredRemoteViewModel.kt | 44 +++++++++++++------ .../remote/ui/InfraredRemoteScreen.kt | 18 +++++--- .../src/main/res/values-zh-rCN/strings.xml | 7 +++ .../infrared/src/main/res/values/strings.xml | 7 +++ .../remote/InfraredRemoteViewModelTest.kt | 37 ++++++++++++++++ 6 files changed, 99 insertions(+), 24 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 25ef11a..468b9e3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -1,13 +1,15 @@ package com.flagship.abox.manager.infrared.application 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.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async class InfraredLearningCoordinator( private val learnInfraredKey: LearnInfraredKey, @@ -19,11 +21,11 @@ class InfraredLearningCoordinator( deviceName: DeviceName, displayName: String, sdkKey: String? = null, - ): Job = externalScope.launch { + ): Deferred> = externalScope.async { learnInfraredKey(profileId, deviceName, displayName, sdkKey) } - fun retry(ref: InfraredKeyRef): Job = externalScope.launch { + fun retry(ref: InfraredKeyRef): Deferred> = externalScope.async { retryInfraredKeyLearning(ref) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index b2b6439..56d7029 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -171,27 +171,43 @@ class InfraredRemoteViewModel @Inject constructor( if (activeLearningSlotFlow.value != null) { return } - activeLearningSlotFlow.value = spec.defaultSlot val existingKey = state.value.learnedSlotMap[spec.defaultSlot] - val job = if (existingKey != null) { - learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - } else { - learningCoordinator.learn( - profileId = currentProfile, - deviceName = savedDevice, - displayName = spec.displayName, - sdkKey = spec.defaultSlot, - ) + if (existingKey?.status == InfraredKeyStatus.LEARNING) { + return } - activeLearningJob = job - job.invokeOnCompletion { - if (activeLearningJob === job) { - activeLearningJob = null + activeLearningSlotFlow.value = spec.defaultSlot + val job = viewModelScope.launch { + try { + val result = if (existingKey != null) { + learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await() + } else { + when (val res = learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ).await()) { + is DomainResult.Success -> DomainResult.Success(Unit) + is DomainResult.Failure -> DomainResult.Failure(res.error) + } + } + when (result) { + is DomainResult.Success -> { + messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } finally { if (activeLearningSlotFlow.value == spec.defaultSlot) { activeLearningSlotFlow.value = null } } } + activeLearningJob = job messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") } else { if (sendingKeySlotFlow.value != null) { diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index bd3e38e..ad7bde0 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -37,12 +37,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec @@ -108,13 +110,13 @@ internal fun InfraredRemoteScreen( TopAppBar( title = { Text( - text = "红外遥控器", + text = stringResource(R.string.infrared_remote_title), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), ) }, navigationIcon = { TextButton(onClick = onBack) { - Text("返回") + Text(stringResource(R.string.infrared_back)) } }, actions = { @@ -124,7 +126,11 @@ internal fun InfraredRemoteScreen( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = if (state.isLearningMode) "录制中" else "学习模式", + text = if (state.isLearningMode) { + stringResource(R.string.infrared_remote_recording) + } else { + stringResource(R.string.infrared_remote_learning_mode) + }, fontSize = 12.sp, fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal, color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, @@ -178,7 +184,7 @@ internal fun InfraredRemoteScreen( contentAlignment = Alignment.Center, ) { Text( - text = "🔴 红外学习模式已开启:点击任意按键即可录制信号", + text = stringResource(R.string.infrared_remote_learning_banner), style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.SemiBold, @@ -196,7 +202,7 @@ internal fun InfraredRemoteScreen( FilterChip( selected = state.selectedScene == RemoteScene.AIR_CONDITIONER, onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) }, - label = { Text("❄️ 空调遥控") }, + label = { Text(stringResource(R.string.infrared_remote_scene_ac)) }, modifier = Modifier .padding(end = 8.dp) .testTag("scene_tab_ac"), @@ -208,7 +214,7 @@ internal fun InfraredRemoteScreen( FilterChip( selected = state.selectedScene == RemoteScene.TELEVISION, onClick = { onSceneSelected(RemoteScene.TELEVISION) }, - label = { Text("📺 电视遥控") }, + label = { Text(stringResource(R.string.infrared_remote_scene_tv)) }, modifier = Modifier.testTag("scene_tab_tv"), colors = FilterChipDefaults.filterChipColors( selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index c166919..f1cf838 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -32,4 +32,11 @@ 取消 确认 返回 + 红外遥控器 + 录制中 + 学习模式 + 🔴 红外学习模式已开启:点击任意按键即可录制信号 + ❄️ 空调遥控 + 📺 电视遥控 + 设置 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 95304d2..e676a1f 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -32,4 +32,11 @@ Cancel Confirm Back + Infrared Remote + Recording + Learn Mode + 🔴 Learn mode active: tap any button to record + ❄️ Air Conditioner + 📺 Television + Settings diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index fc4e03d..fcbedff 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -213,6 +213,43 @@ class InfraredRemoteViewModelTest { assertEquals("1", gateway.learnedKeys.first().key) val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) + assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + + job.cancel() + } + + @Test + fun learningFailurePropagatesErrorMessage() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206")) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message) + + job.cancel() + } + + @Test + fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired")) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message) job.cancel() } -- 2.54.0 From 3561f3dd4adff776df375c07eeed3bd8fdbc8017 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:27:54 +0800 Subject: [PATCH 29/41] fix(infrared): url-encode profileId in routes and cancel underlying learning deferred --- .../abox/manager/ui/ABoxManagerApp.kt | 6 ++-- .../remote/InfraredRemoteViewModel.kt | 33 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 3f97c7d..257990d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -100,9 +100,10 @@ private fun AuthenticatedNavHost( actionInProgress = actionInProgress, errorMessage = errorMessage, devicesSlot = { + val encodedProfileId = android.net.Uri.encode(profile.id.value) DeviceListScreen( onInfraredClick = { - navController.navigate("infrared_remote/${profile.id.value}") + navController.navigate("infrared_remote/$encodedProfileId") }, ) }, @@ -114,10 +115,11 @@ private fun AuthenticatedNavHost( navArgument("profileId") { type = NavType.StringType }, ), ) { + val encodedProfileId = android.net.Uri.encode(profile.id.value) InfraredRemoteRoute( onBack = { navController.popBackStack() }, onOpenSettings = { - navController.navigate("infrared_settings/${profile.id.value}") + navController.navigate("infrared_settings/$encodedProfileId") }, onSessionInvalid = onLogout, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 56d7029..4001ee4 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -8,6 +8,7 @@ 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.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus @@ -70,6 +71,7 @@ class InfraredRemoteViewModel @Inject constructor( private val activeLearningSlotFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) private var activeLearningJob: Job? = null + private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null private val effectiveProfileAndMode: Flow> = runtimeModeStore.mode.flatMapLatest { mode -> @@ -151,6 +153,8 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled if (!enabled) { + underlyingLearningDeferred?.cancel() + underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null activeLearningSlotFlow.value = null @@ -168,7 +172,7 @@ class InfraredRemoteViewModel @Inject constructor( } if (isLearningModeFlow.value) { - if (activeLearningSlotFlow.value != null) { + if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) { return } val existingKey = state.value.learnedSlotMap[spec.defaultSlot] @@ -176,17 +180,25 @@ class InfraredRemoteViewModel @Inject constructor( return } activeLearningSlotFlow.value = spec.defaultSlot + val deferred = if (existingKey != null) { + learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) + } else { + learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ) + } + underlyingLearningDeferred = deferred val job = viewModelScope.launch { try { val result = if (existingKey != null) { - learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await() + @Suppress("UNCHECKED_CAST") + (deferred as kotlinx.coroutines.Deferred>).await() } else { - when (val res = learningCoordinator.learn( - profileId = currentProfile, - deviceName = savedDevice, - displayName = spec.displayName, - sdkKey = spec.defaultSlot, - ).await()) { + @Suppress("UNCHECKED_CAST") + when (val res = (deferred as kotlinx.coroutines.Deferred>).await()) { is DomainResult.Success -> DomainResult.Success(Unit) is DomainResult.Failure -> DomainResult.Failure(res.error) } @@ -202,6 +214,9 @@ class InfraredRemoteViewModel @Inject constructor( } catch (e: kotlinx.coroutines.CancellationException) { throw e } finally { + if (underlyingLearningDeferred === deferred) { + underlyingLearningDeferred = null + } if (activeLearningSlotFlow.value == spec.defaultSlot) { activeLearningSlotFlow.value = null } @@ -238,6 +253,8 @@ class InfraredRemoteViewModel @Inject constructor( override fun onCleared() { super.onCleared() + underlyingLearningDeferred?.cancel() + underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null } -- 2.54.0 From 8cd44c93e15f585bb935f41caa74a0378c5b8f95 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:36:19 +0800 Subject: [PATCH 30/41] fix(infrared): unify atomic config state snapshot, avoid message race, and rollback cancelled learning status --- .../remote/InfraredRemoteViewModel.kt | 97 +++++++++---------- .../remote/InfraredRemoteViewModelTest.kt | 17 ++++ .../domain/infrared/LearnInfraredKey.kt | 43 ++++++-- .../domain/infrared/LearnInfraredKeyTest.kt | 16 +++ 4 files changed, 117 insertions(+), 56 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 4001ee4..ddb83a5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -73,33 +74,40 @@ class InfraredRemoteViewModel @Inject constructor( private var activeLearningJob: Job? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null - private val effectiveProfileAndMode: Flow> = + private data class InfraredRemoteConfigSnapshot( + val effectiveProfileId: ProviderProfileId? = null, + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val savedDeviceName: DeviceName? = null, + val learnedSlotMap: Map = emptyMap(), + ) + + private val configSnapshotFlow: Flow = runtimeModeStore.mode.flatMapLatest { mode -> - if (authenticatedProfileId == null) { - flowOf(Pair(null, mode)) + val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) } + if (profile == null) { + flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode)) } else { - flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode)) - } - } - - private val deviceNameFlow: Flow> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) flowOf(DomainResult.Success(null)) - else deviceConfigurationRepository.observeDeviceName(profile) - } - - private val keysFlow: Flow>> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) flowOf(DomainResult.Success(emptyList())) - else { deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Success -> { - val device = deviceResult.value - if (device == null) flowOf(DomainResult.Success(emptyList())) - else keyRepository.observeKeys(profile, device) + val deviceName = (deviceResult as? DomainResult.Success)?.value + if (deviceName == null) { + flowOf( + InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = null, + learnedSlotMap = emptyMap(), + ), + ) + } else { + keyRepository.observeKeys(profile, deviceName).map { keysResult -> + val keys = (keysResult as? DomainResult.Success>)?.value ?: emptyList() + InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = deviceName, + learnedSlotMap = keys.associateBy { it.sdkKey }, + ) } - is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) } } } @@ -108,34 +116,18 @@ class InfraredRemoteViewModel @Inject constructor( val state: StateFlow = combine( sceneFlow, isLearningModeFlow, - effectiveProfileAndMode, - deviceNameFlow, - keysFlow, - sendingKeySlotFlow, - activeLearningSlotFlow, - messageFlow, - ) { args: Array -> - val scene = args[0] as RemoteScene - val isLearning = args[1] as Boolean - val profileAndMode = args[2] as Pair - val deviceResult = args[3] as DomainResult - val keysResult = args[4] as DomainResult> - val sendingSlot = args[5] as String? - val learningSlot = args[6] as String? - val msg = args[7] as RemoteUiMessage? - - val (profile, mode) = profileAndMode - val savedDevice = (deviceResult as? DomainResult.Success)?.value - val keys = (keysResult as? DomainResult.Success>)?.value ?: emptyList() - val slotMap = keys.associateBy { it.sdkKey } - + configSnapshotFlow, + combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg -> + Triple(sending, learning, msg) + }, + ) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) -> InfraredRemoteUiState( selectedScene = scene, isLearningMode = isLearning, - runtimeMode = mode, - effectiveProfileId = profile, - savedDeviceName = savedDevice, - learnedSlotMap = slotMap, + runtimeMode = snapshot.runtimeMode, + effectiveProfileId = snapshot.effectiveProfileId, + savedDeviceName = snapshot.savedDeviceName, + learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, activeLearningSlot = learningSlot, message = msg, @@ -158,6 +150,11 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningJob?.cancel() activeLearningJob = null activeLearningSlotFlow.value = null + state.value.effectiveProfileId?.let { profile -> + viewModelScope.launch(kotlinx.coroutines.NonCancellable) { + keyRepository.failInterruptedLearning(profile, System.currentTimeMillis()) + } + } } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") } @@ -179,6 +176,7 @@ class InfraredRemoteViewModel @Inject constructor( if (existingKey?.status == InfraredKeyStatus.LEARNING) { return } + messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") activeLearningSlotFlow.value = spec.defaultSlot val deferred = if (existingKey != null) { learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) @@ -223,13 +221,12 @@ class InfraredRemoteViewModel @Inject constructor( } } activeLearningJob = job - messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") } else { if (sendingKeySlotFlow.value != null) { return } val key = state.value.learnedSlotMap[spec.defaultSlot] - if (key == null || key.status != InfraredKeyStatus.READY) { + if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) { messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") return } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index fcbedff..d6a7b42 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -253,4 +253,21 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + vm.toggleLearningMode(false) + advanceUntilIdle() + + assertEquals(null, vm.state.value.activeLearningSlot) + + job.cancel() + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 394557d..97b69c8 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -5,6 +5,9 @@ import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext class LearnInfraredKey( private val repository: InfraredKeyRepository, @@ -28,13 +31,27 @@ class LearnInfraredKey( is DomainResult.Failure -> return result } - return when ( - val result = gateway.learnKey( + val result = try { + gateway.learnKey( profileId = key.profileId, deviceName = key.deviceName, key = key.sdkKey, ) - ) { + } catch (e: CancellationException) { + withContext(NonCancellable) { + repository.transitionStatus( + ref = key.ref(), + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "Cancelled", + updatedAtEpochMillis = clock.now(), + ) + } + throw e + } + + return when (result) { is DomainResult.Success -> when ( val transition = repository.transitionStatus( ref = key.ref(), @@ -101,13 +118,27 @@ class RetryInfraredKeyLearning( is DomainResult.Failure -> return result } - return when ( - val result = gateway.learnKey( + val result = try { + gateway.learnKey( profileId = key.profileId, deviceName = key.deviceName, key = key.sdkKey, ) - ) { + } catch (e: CancellationException) { + withContext(NonCancellable) { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "Cancelled", + updatedAtEpochMillis = clock.now(), + ) + } + throw e + } + + return when (result) { is DomainResult.Success -> repository.transitionStatus( ref = ref, expectedStatuses = setOf(InfraredKeyStatus.LEARNING), diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index 0f4e3d4..c62e620 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -269,6 +269,22 @@ class LearnInfraredKeyTest { assertEquals(1, gateway.learnedKeys.size) } + @Test + fun learningCancellationTransitionsStatusToFailed() = runTest { + gateway.onLearn = { _, _, _ -> + throw kotlinx.coroutines.CancellationException("User cancelled") + } + + try { + learn(profile, device, "Power") + } catch (_: kotlinx.coroutines.CancellationException) { + // expected + } + + assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) + assertEquals("Cancelled", repository.onlyKey().lastErrorDetail) + } + private fun failedKey( id: InfraredKeyId = keyId, sdkKey: String = "app_ir_key1", -- 2.54.0 From b6cae9b2c97731ec18cc72e6c6822b586f33e6ac Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:44:07 +0800 Subject: [PATCH 31/41] fix(infrared): add relearn use case for ready keys and clean up interrupted learning in onCleared and init --- .../InfraredLearningCoordinator.kt | 16 ++++ .../infrared/di/InfraredFeatureModule.kt | 13 +++ .../remote/InfraredRemoteViewModel.kt | 21 +++-- .../InfraredLearningCoordinatorTest.kt | 56 ++++++++++++- .../remote/InfraredRemoteViewModelTest.kt | 37 ++++++++- .../ui/InfraredControlViewModelTest.kt | 3 +- .../domain/infrared/LearnInfraredKey.kt | 81 +++++++++++++++++++ .../domain/infrared/LearnInfraredKeyTest.kt | 30 ++++++- 8 files changed, 245 insertions(+), 12 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 468b9e3..434abc5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -5,15 +5,21 @@ import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async +import kotlinx.coroutines.launch class InfraredLearningCoordinator( private val learnInfraredKey: LearnInfraredKey, private val retryInfraredKeyLearning: RetryInfraredKeyLearning, + private val relearnInfraredKey: RelearnInfraredKey, + private val keyRepository: InfraredKeyRepository, private val externalScope: CoroutineScope, ) { fun learn( @@ -28,4 +34,14 @@ class InfraredLearningCoordinator( fun retry(ref: InfraredKeyRef): Deferred> = externalScope.async { retryInfraredKeyLearning(ref) } + + fun relearn(ref: InfraredKeyRef): Deferred> = externalScope.async { + relearnInfraredKey(ref) + } + + fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) { + externalScope.launch(NonCancellable) { + keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis) + } + } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 5e2f51f..58179f1 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -170,15 +170,28 @@ object InfraredFeatureModule { clock: EpochMillisClock, ): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock) + @Provides + @Singleton + fun provideRelearnInfraredKey( + repository: InfraredKeyRepository, + gateway: InfraredGateway, + clock: EpochMillisClock, + ): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey = + com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock) + @Provides @Singleton fun provideInfraredLearningCoordinator( learnInfraredKey: LearnInfraredKey, retryInfraredKeyLearning: RetryInfraredKeyLearning, + relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey, + repository: InfraredKeyRepository, @InfraredApplicationScope scope: CoroutineScope, ): InfraredLearningCoordinator = InfraredLearningCoordinator( learnInfraredKey = learnInfraredKey, retryInfraredKeyLearning = retryInfraredKeyLearning, + relearnInfraredKey = relearnInfraredKey, + keyRepository = repository, externalScope = scope, ) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index ddb83a5..a8d6725 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -74,6 +74,12 @@ class InfraredRemoteViewModel @Inject constructor( private var activeLearningJob: Job? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null + init { + authenticatedProfileId?.let { profile -> + learningCoordinator.failInterruptedLearning(profile) + } + } + private data class InfraredRemoteConfigSnapshot( val effectiveProfileId: ProviderProfileId? = null, val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, @@ -151,9 +157,7 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningJob = null activeLearningSlotFlow.value = null state.value.effectiveProfileId?.let { profile -> - viewModelScope.launch(kotlinx.coroutines.NonCancellable) { - keyRepository.failInterruptedLearning(profile, System.currentTimeMillis()) - } + learningCoordinator.failInterruptedLearning(profile) } } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") @@ -178,10 +182,10 @@ class InfraredRemoteViewModel @Inject constructor( } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") activeLearningSlotFlow.value = spec.defaultSlot - val deferred = if (existingKey != null) { - learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - } else { - learningCoordinator.learn( + val deferred = when (existingKey?.status) { + InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) + InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) + else -> learningCoordinator.learn( profileId = currentProfile, deviceName = savedDevice, displayName = spec.displayName, @@ -254,6 +258,9 @@ class InfraredRemoteViewModel @Inject constructor( underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null + state.value.effectiveProfileId?.let { profile -> + learningCoordinator.failInterruptedLearning(profile) + } } fun clearMessage() { diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt index d9e2393..f9123c9 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt @@ -9,6 +9,7 @@ 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.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway @@ -34,10 +35,11 @@ class InfraredLearningCoordinatorTest { private val learn = LearnInfraredKey(repository, gateway, factory, clock) private val retry = RetryInfraredKeyLearning(repository, gateway, clock) + private val relearn = RelearnInfraredKey(repository, gateway, clock) @Test fun learnRunsAsynchronouslyInExternalScope() = runTest { - val coordinator = InfraredLearningCoordinator(learn, retry, this) + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) coordinator.learn(profile, device, "Power") advanceUntilIdle() @@ -65,7 +67,7 @@ class InfraredLearningCoordinatorTest { ) repository.insert(failedKey) - val coordinator = InfraredLearningCoordinator(learn, retry, this) + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1"))) advanceUntilIdle() @@ -73,4 +75,54 @@ class InfraredLearningCoordinatorTest { val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) } + + @Test + fun relearnRunsAsynchronouslyInExternalScope() = runTest { + val readyKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_key1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(readyKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) + + coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1"))) + advanceUntilIdle() + + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + } + + @Test + fun failInterruptedLearningFailsLearningKeys() = runTest { + val learningKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_key1", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(learningKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) + + coordinator.failInterruptedLearning(profile, 2_000L) + advanceUntilIdle() + + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.FAILED, updated.status) + } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index d6a7b42..7c362d1 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -64,13 +64,14 @@ class InfraredRemoteViewModelTest { private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock) private val send = SendInfraredKey(keyRepo, gateway) private lateinit var coordinator: InfraredLearningCoordinator @Before fun setup() { Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learn, retry, testScope) + coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope) } @After @@ -270,4 +271,38 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + + job.cancel() + } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt index 014c39d..822a811 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -71,6 +71,7 @@ class InfraredControlViewModelTest { private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) + private val relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepository, gateway, clock) private val sendUseCase = SendInfraredKey(keyRepository, gateway) private val renameUseCase = RenameInfraredKey(keyRepository, clock) private val deleteUseCase = DeleteInfraredKey(keyRepository) @@ -81,7 +82,7 @@ class InfraredControlViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope) + coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, relearnUseCase, keyRepository, testScope) } @After diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 97b69c8..a23fbac 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -106,7 +106,88 @@ class RetryInfraredKeyLearning( ref = ref, expectedStatuses = setOf( InfraredKeyStatus.FAILED, + ), + targetStatus = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + updatedAtEpochMillis = clock.now(), + ) + ) { + is DomainResult.Success -> Unit + is DomainResult.Failure -> return result + } + + val result = try { + gateway.learnKey( + profileId = key.profileId, + deviceName = key.deviceName, + key = key.sdkKey, + ) + } catch (e: CancellationException) { + withContext(NonCancellable) { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "Cancelled", + updatedAtEpochMillis = clock.now(), + ) + } + throw e + } + + return when (result) { + is DomainResult.Success -> repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + updatedAtEpochMillis = clock.now(), + ) + + is DomainResult.Failure -> { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = result.error.externalCode, + lastErrorDetail = null, + updatedAtEpochMillis = clock.now(), + ) + result + } + } + } +} + +class RelearnInfraredKey( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke(ref: InfraredKeyRef): DomainResult { + val key = when (val result = repository.getKey(ref)) { + is DomainResult.Success -> result.value + ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key not found"), + ) + + is DomainResult.Failure -> return result + } + if (key.status == InfraredKeyStatus.LEARNING) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key is already learning"), + ) + } + + when ( + val result = repository.transitionStatus( + ref = ref, + expectedStatuses = setOf( InfraredKeyStatus.READY, + InfraredKeyStatus.FAILED, ), targetStatus = InfraredKeyStatus.LEARNING, lastErrorCode = null, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index c62e620..ce1d104 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -22,6 +22,7 @@ class LearnInfraredKeyTest { private val gateway = RecordingInfraredGateway() private val learn = LearnInfraredKey(repository, gateway, factory, clock) private val retry = RetryInfraredKeyLearning(repository, gateway, clock) + private val relearn = RelearnInfraredKey(repository, gateway, clock) @Test fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest { @@ -131,7 +132,34 @@ class LearnInfraredKeyTest { assertEquals(0, repository.insertedKeys.size) assertEquals(2, repository.transitionCalls.size) assertEquals( - setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY), + setOf(InfraredKeyStatus.FAILED), + repository.transitionCalls.first().expectedStatuses, + ) + assertEquals( + setOf(InfraredKeyStatus.LEARNING), + repository.transitionCalls.last().expectedStatuses, + ) + } + + @Test + fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest { + val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy( + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + ) + repository.seed(original) + + val result = relearn(original.ref()) + + assertEquals(DomainResult.Success(Unit), result) + assertEquals("app_ir_key1", gateway.learnedKeys.single().key) + assertEquals(InfraredKeyId("key1"), repository.onlyKey().id) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertEquals(0, repository.insertedKeys.size) + assertEquals(2, repository.transitionCalls.size) + assertEquals( + setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED), repository.transitionCalls.first().expectedStatuses, ) assertEquals( -- 2.54.0 From a3812aae2793a0eae9e13f7468414ecea288393d Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:51:47 +0800 Subject: [PATCH 32/41] fix(infrared): propagate repository storage errors and recover learning keys across effective profiles --- .../remote/InfraredRemoteViewModel.kt | 75 +++++++++++++------ .../remote/InfraredRemoteViewModelTest.kt | 47 ++++++++++++ .../domain/infrared/LearnInfraredKey.kt | 6 +- .../domain/infrared/LearnInfraredKeyTest.kt | 2 +- .../fakes/FakeInfraredKeyRepository.kt | 2 + 5 files changed, 104 insertions(+), 28 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index a8d6725..e203719 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -75,8 +75,16 @@ class InfraredRemoteViewModel @Inject constructor( private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null init { - authenticatedProfileId?.let { profile -> - learningCoordinator.failInterruptedLearning(profile) + if (authenticatedProfileId != null) { + viewModelScope.launch { + runtimeModeStore.mode.collect { mode -> + val effective = profileResolver.resolve(authenticatedProfileId, mode) + learningCoordinator.failInterruptedLearning(effective) + if (effective != authenticatedProfileId) { + learningCoordinator.failInterruptedLearning(authenticatedProfileId) + } + } + } } } @@ -85,6 +93,7 @@ class InfraredRemoteViewModel @Inject constructor( val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, val savedDeviceName: DeviceName? = null, val learnedSlotMap: Map = emptyMap(), + val configError: DomainError? = null, ) private val configSnapshotFlow: Flow = @@ -94,25 +103,43 @@ class InfraredRemoteViewModel @Inject constructor( flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode)) } else { deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - val deviceName = (deviceResult as? DomainResult.Success)?.value - if (deviceName == null) { - flowOf( + when (deviceResult) { + is DomainResult.Failure -> flowOf( InfraredRemoteConfigSnapshot( effectiveProfileId = profile, runtimeMode = mode, - savedDeviceName = null, - learnedSlotMap = emptyMap(), + configError = deviceResult.error, ), ) - } else { - keyRepository.observeKeys(profile, deviceName).map { keysResult -> - val keys = (keysResult as? DomainResult.Success>)?.value ?: emptyList() - InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = deviceName, - learnedSlotMap = keys.associateBy { it.sdkKey }, - ) + is DomainResult.Success -> { + val deviceName = deviceResult.value + if (deviceName == null) { + flowOf( + InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = null, + learnedSlotMap = emptyMap(), + ), + ) + } else { + keyRepository.observeKeys(profile, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = deviceName, + learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, + ) + } + } + } } } } @@ -127,6 +154,13 @@ class InfraredRemoteViewModel @Inject constructor( Triple(sending, learning, msg) }, ) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) -> + val resolvedMessage = msg ?: snapshot.configError?.let { error -> + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid + is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败") + } + } InfraredRemoteUiState( selectedScene = scene, isLearningMode = isLearning, @@ -136,7 +170,7 @@ class InfraredRemoteViewModel @Inject constructor( learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, activeLearningSlot = learningSlot, - message = msg, + message = resolvedMessage, ) }.stateIn( scope = viewModelScope, @@ -177,15 +211,12 @@ class InfraredRemoteViewModel @Inject constructor( return } val existingKey = state.value.learnedSlotMap[spec.defaultSlot] - if (existingKey?.status == InfraredKeyStatus.LEARNING) { - return - } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") activeLearningSlotFlow.value = spec.defaultSlot val deferred = when (existingKey?.status) { InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) - else -> learningCoordinator.learn( + InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) + null -> learningCoordinator.learn( profileId = currentProfile, deviceName = savedDevice, displayName = spec.displayName, diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 7c362d1..0700476 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -305,4 +305,51 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun relearningKeyInLearningStatusSucceeds() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val learningKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(learningKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + + job.cancel() + } + + @Test + fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest { + deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure") + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message) + + job.cancel() + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index a23fbac..43919ad 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -176,11 +176,6 @@ class RelearnInfraredKey( is DomainResult.Failure -> return result } - if (key.status == InfraredKeyStatus.LEARNING) { - return DomainResult.Failure( - DomainError.InvalidRequest(detail = "Infrared key is already learning"), - ) - } when ( val result = repository.transitionStatus( @@ -188,6 +183,7 @@ class RelearnInfraredKey( expectedStatuses = setOf( InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, + InfraredKeyStatus.LEARNING, ), targetStatus = InfraredKeyStatus.LEARNING, lastErrorCode = null, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index ce1d104..0c727df 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -159,7 +159,7 @@ class LearnInfraredKeyTest { assertEquals(0, repository.insertedKeys.size) assertEquals(2, repository.transitionCalls.size) assertEquals( - setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED), + setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING), repository.transitionCalls.first().expectedStatuses, ) assertEquals( diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt index f08509c..6219a58 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt @@ -134,10 +134,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository { class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository { private val lock = Any() private val deviceNames = MutableStateFlow>(emptyMap()) + var observeError: DomainError? = null override fun observeDeviceName( profileId: ProviderProfileId, ): Flow> = deviceNames.map { values -> + observeError?.let { return@map DomainResult.Failure(it) } DomainResult.Success(values[profileId]) } -- 2.54.0 From 2185e73f5e395b94fe80b157f0e7a0a62f229c5d Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 13:07:38 +0800 Subject: [PATCH 33/41] fix(infrared): add cutoff timestamp to failInterruptedLearning to prevent race with subsequent tasks --- .../local/RoomInfraredKeyRepositoryTest.kt | 19 ++++++++++ .../InfraredLearningCoordinator.kt | 12 +++++-- .../infrared/data/local/InfraredDao.kt | 5 ++- .../data/local/RoomInfraredKeyRepository.kt | 6 ++++ .../remote/InfraredRemoteViewModel.kt | 11 +++--- .../domain/infrared/InfraredKeyRepository.kt | 7 ++-- .../infrared/InMemoryInfraredTestDoubles.kt | 3 +- .../fakes/FakeInfraredKeyRepository.kt | 3 +- .../fakes/FakeInfraredKeyRepositoryTest.kt | 35 +++++++++++++++++++ 9 files changed, 89 insertions(+), 12 deletions(-) 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 index 2212e77..97efdde 100644 --- 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 @@ -139,6 +139,25 @@ class RoomInfraredKeyRepositoryTest { assertEquals(InfraredKeyStatus.READY, k3.status) } + @Test + fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest { + repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L)) + repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L)) + + val countResult = repository.failInterruptedLearning( + profileId = ProviderProfileId("profile1"), + cutoffEpochMillis = 150L, + updatedAtEpochMillis = 150L, + ) as DomainResult.Success + assertEquals(1, countResult.value) + + val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.FAILED, oldK.status) + + val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.LEARNING, newK.status) + } + @Test fun persistsAndObservesDeviceName() = runTest { val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 434abc5..8fb6dcf 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -39,9 +39,17 @@ class InfraredLearningCoordinator( relearnInfraredKey(ref) } - fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) { + fun failInterruptedLearning( + profileId: ProviderProfileId, + cutoffEpochMillis: Long = System.currentTimeMillis(), + updatedAtEpochMillis: Long = cutoffEpochMillis, + ) { externalScope.launch(NonCancellable) { - keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis) + keyRepository.failInterruptedLearning( + profileId = profileId, + cutoffEpochMillis = cutoffEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, + ) } } } 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 index 6a1f240..3d24b10 100644 --- 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 @@ -81,13 +81,16 @@ interface InfraredDao { lastErrorCode = NULL, lastErrorDetail = :errorDetail, updatedAtEpochMillis = :updatedAtEpochMillis - WHERE profileId = :profileId AND status = 'LEARNING' + WHERE profileId = :profileId + AND status = 'LEARNING' + AND createdAtEpochMillis <= :cutoffEpochMillis """, ) suspend fun failInterruptedLearning( profileId: String, targetStatus: String, errorDetail: String, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): Int 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 index e6f52ef..b748e39 100644 --- 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 @@ -11,6 +11,7 @@ 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.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -147,6 +148,7 @@ class RoomInfraredKeyRepository( override suspend fun failInterruptedLearning( profileId: ProviderProfileId, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): DomainResult { return try { @@ -154,10 +156,14 @@ class RoomInfraredKeyRepository( profileId = profileId.value, targetStatus = InfraredKeyStatus.FAILED.name, errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, + cutoffEpochMillis = cutoffEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis, ) DomainResult.Success(count) } catch (throwable: Throwable) { + if (throwable is CancellationException) { + throw throwable + } DomainResult.Failure(mapThrowableToDomainError(throwable)) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index e203719..2bb1601 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -77,11 +77,12 @@ class InfraredRemoteViewModel @Inject constructor( init { if (authenticatedProfileId != null) { viewModelScope.launch { + val startupCutoff = System.currentTimeMillis() runtimeModeStore.mode.collect { mode -> val effective = profileResolver.resolve(authenticatedProfileId, mode) - learningCoordinator.failInterruptedLearning(effective) + learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff) if (effective != authenticatedProfileId) { - learningCoordinator.failInterruptedLearning(authenticatedProfileId) + learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff) } } } @@ -185,13 +186,14 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled if (!enabled) { + val shutdownCutoff = System.currentTimeMillis() underlyingLearningDeferred?.cancel() underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null activeLearningSlotFlow.value = null state.value.effectiveProfileId?.let { profile -> - learningCoordinator.failInterruptedLearning(profile) + learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff) } } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") @@ -285,12 +287,13 @@ class InfraredRemoteViewModel @Inject constructor( override fun onCleared() { super.onCleared() + val teardownCutoff = System.currentTimeMillis() underlyingLearningDeferred?.cancel() underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null state.value.effectiveProfileId?.let { profile -> - learningCoordinator.failInterruptedLearning(profile) + learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff) } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt index 164f5ef..c0cf9ff 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -33,12 +33,13 @@ interface InfraredKeyRepository { suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult /** - * Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor - * error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. + * Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis] + * as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. */ suspend fun failInterruptedLearning( profileId: ProviderProfileId, - updatedAtEpochMillis: Long, + cutoffEpochMillis: Long = Long.MAX_VALUE, + updatedAtEpochMillis: Long = cutoffEpochMillis, ): DomainResult } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index db2a57f..15f9bc4 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -159,13 +159,14 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { override suspend fun failInterruptedLearning( profileId: ProviderProfileId, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): DomainResult { recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis) recoveryError?.let { return DomainResult.Failure(it) } var changed = 0 keys.replaceAll { _, key -> - if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { + if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) { changed += 1 key.copy( status = InfraredKeyStatus.FAILED, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt index 6219a58..d50a42d 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt @@ -100,11 +100,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository { override suspend fun failInterruptedLearning( profileId: ProviderProfileId, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): DomainResult = synchronized(lock) { var changed = 0 keys.replaceAll { _, key -> - if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { + if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) { changed += 1 key.copy( status = InfraredKeyStatus.FAILED, diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt index df699a1..6e3dd45 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt @@ -207,6 +207,41 @@ class FakeInfraredKeyRepositoryTest { ) } + @Test + fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest { + val repository = FakeInfraredKeyRepository() + repository.insert( + savedKey( + id = "old-learning", + sdkKey = "old-learning", + status = InfraredKeyStatus.LEARNING, + ).copy(createdAtEpochMillis = 100L), + ) + repository.insert( + savedKey( + id = "new-learning", + sdkKey = "new-learning", + status = InfraredKeyStatus.LEARNING, + ).copy(createdAtEpochMillis = 200L), + ) + + val result = repository.failInterruptedLearning( + profileId = profileOne, + cutoffEpochMillis = 150L, + updatedAtEpochMillis = 150L, + ) + + assertEquals(DomainResult.Success(1), result) + assertEquals( + InfraredKeyStatus.FAILED, + repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("old-learning")))?.status, + ) + assertEquals( + InfraredKeyStatus.LEARNING, + repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("new-learning")))?.status, + ) + } + @Test fun deviceConfigurationIsIsolatedByProfile() = runTest { val repository = FakeInfraredDeviceConfigurationRepository() -- 2.54.0 From 4aa9116ac41261e4327658ff59c5761127a1c9a1 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 13:25:04 +0800 Subject: [PATCH 34/41] refactor(infrared): unify infrared remote with app provider architecture and complete localization --- .../abox/manager/ui/ABoxManagerApp.kt | 16 - android/feature/infrared/build.gradle.kts | 1 - .../remote/InfraredRemoteScreenTest.kt | 1 - .../infrared/ui/InfraredControlScreenTest.kt | 121 ----- .../InfraredLearningCoordinator.kt | 21 +- .../infrared/di/InfraredFeatureModule.kt | 33 -- .../remote/InfraredRemoteViewModel.kt | 171 ++++--- .../manager/infrared/remote/RemoteModels.kt | 54 +- .../infrared/remote/ui/AcRemotePanel.kt | 8 +- .../remote/ui/InfraredRemoteScreen.kt | 26 +- .../infrared/remote/ui/TvRemotePanel.kt | 6 +- .../DataStoreInfraredRuntimeModeStore.kt | 59 --- .../infrared/runtime/InfraredRuntimeMode.kt | 15 - .../runtime/InfraredRuntimeProfileResolver.kt | 12 - .../infrared/ui/InfraredControlScreen.kt | 466 ------------------ .../infrared/ui/InfraredControlUiState.kt | 27 - .../infrared/ui/InfraredControlViewModel.kt | 342 ------------- .../src/main/res/values-zh-rCN/strings.xml | 37 ++ .../infrared/src/main/res/values/strings.xml | 37 ++ .../InfraredLearningCoordinatorTest.kt | 28 ++ .../remote/InfraredRemoteViewModelTest.kt | 79 ++- .../InfraredRuntimeProfileResolverTest.kt | 21 - .../ui/InfraredControlViewModelTest.kt | 295 ----------- 23 files changed, 296 insertions(+), 1580 deletions(-) delete mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt delete mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt delete mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 257990d..abd9d09 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -22,7 +22,6 @@ import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute -import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel @@ -115,22 +114,7 @@ private fun AuthenticatedNavHost( navArgument("profileId") { type = NavType.StringType }, ), ) { - val encodedProfileId = android.net.Uri.encode(profile.id.value) InfraredRemoteRoute( - onBack = { navController.popBackStack() }, - onOpenSettings = { - navController.navigate("infrared_settings/$encodedProfileId") - }, - onSessionInvalid = onLogout, - ) - } - composable( - route = "infrared_settings/{profileId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - ), - ) { - InfraredControlRoute( onBack = { navController.popBackStack() }, onSessionInvalid = onLogout, ) diff --git a/android/feature/infrared/build.gradle.kts b/android/feature/infrared/build.gradle.kts index d9228d2..d088f6b 100644 --- a/android/feature/infrared/build.gradle.kts +++ b/android/feature/infrared/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { 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) diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt index 64f9a7b..28e8c72 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -30,7 +30,6 @@ class InfraredRemoteScreenTest { InfraredRemoteScreen( state = state, onBack = {}, - onOpenSettings = {}, onSessionInvalid = {}, onSceneSelected = {}, onToggleLearningMode = {}, diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt deleted file mode 100644 index e670e3f..0000000 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.flagship.abox.manager.infrared.ui - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -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 -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class InfraredControlScreenTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun displaysModeAndDeviceInformation() { - val state = InfraredControlUiState( - runtimeMode = InfraredRuntimeMode.FAKE, - effectiveProfileId = ProviderProfileId("abox:infrared-fake"), - savedDeviceName = DeviceName("ir-living"), - loading = false, - ) - - composeTestRule.setContent { - InfraredControlScreen( - state = state, - nextSlot = 1, - onBack = {}, - onSessionInvalid = {}, - onDeviceNameChanged = {}, - onSaveDeviceName = {}, - onLearn = { _, _ -> }, - onRetry = {}, - onSend = {}, - onRename = { _, _ -> }, - onDelete = {}, - onModeChanged = {}, - onMessageShown = {}, - ) - } - - composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed() - } - - @Test - fun rendersKeysWithCorrectActionButtons() { - val readyKey = SavedInfraredKey( - id = InfraredKeyId("k-ready"), - profileId = ProviderProfileId("abox"), - deviceName = DeviceName("ir-1"), - displayName = "Power Key", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - val failedKey = SavedInfraredKey( - id = InfraredKeyId("k-failed"), - profileId = ProviderProfileId("abox"), - deviceName = DeviceName("ir-1"), - displayName = "Volume Up", - sdkKey = "2", - status = InfraredKeyStatus.FAILED, - lastErrorCode = "20206", - lastErrorDetail = null, - createdAtEpochMillis = 200L, - updatedAtEpochMillis = 200L, - ) - - val state = InfraredControlUiState( - runtimeMode = InfraredRuntimeMode.REAL_ABSDK, - savedDeviceName = DeviceName("ir-1"), - keys = listOf(readyKey, failedKey), - loading = false, - ) - - var sentKeyId: InfraredKeyId? = null - var retriedKeyId: InfraredKeyId? = null - - composeTestRule.setContent { - InfraredControlScreen( - state = state, - nextSlot = 3, - onBack = {}, - onSessionInvalid = {}, - onDeviceNameChanged = {}, - onSaveDeviceName = {}, - onLearn = { _, _ -> }, - onRetry = { retriedKeyId = it }, - onSend = { sentKeyId = it }, - onRename = { _, _ -> }, - onDelete = {}, - onModeChanged = {}, - onMessageShown = {}, - ) - } - - composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick() - assertEquals(InfraredKeyId("k-ready"), sentKeyId) - - composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick() - assertEquals(InfraredKeyId("k-failed"), retriedKeyId) - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 8fb6dcf..41c7e01 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -11,9 +11,12 @@ import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class InfraredLearningCoordinator( private val learnInfraredKey: LearnInfraredKey, @@ -22,20 +25,32 @@ class InfraredLearningCoordinator( private val keyRepository: InfraredKeyRepository, private val externalScope: CoroutineScope, ) { + private val mutex = Mutex() + private var cleanupJob: Job? = null + fun learn( profileId: ProviderProfileId, deviceName: DeviceName, displayName: String, sdkKey: String? = null, ): Deferred> = externalScope.async { + mutex.withLock { + cleanupJob?.join() + } learnInfraredKey(profileId, deviceName, displayName, sdkKey) } fun retry(ref: InfraredKeyRef): Deferred> = externalScope.async { + mutex.withLock { + cleanupJob?.join() + } retryInfraredKeyLearning(ref) } fun relearn(ref: InfraredKeyRef): Deferred> = externalScope.async { + mutex.withLock { + cleanupJob?.join() + } relearnInfraredKey(ref) } @@ -43,13 +58,15 @@ class InfraredLearningCoordinator( profileId: ProviderProfileId, cutoffEpochMillis: Long = System.currentTimeMillis(), updatedAtEpochMillis: Long = cutoffEpochMillis, - ) { - externalScope.launch(NonCancellable) { + ): Job { + val job = externalScope.launch(NonCancellable) { keyRepository.failInterruptedLearning( profileId = profileId, cutoffEpochMillis = cutoffEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis, ) } + cleanupJob = job + return job } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 58179f1..5237989 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -1,10 +1,6 @@ package com.flagship.abox.manager.infrared.di import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.infrared.EpochMillisClock @@ -23,9 +19,6 @@ import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinato import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository -import com.flagship.abox.manager.infrared.runtime.DataStoreInfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -42,10 +35,6 @@ import javax.inject.Singleton @Retention(AnnotationRetention.BINARY) annotation class InfraredApplicationScope -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class InfraredDataStore - @Module @InstallIn(SingletonComponent::class) object InfraredFeatureModule { @@ -83,28 +72,6 @@ object InfraredFeatureModule { repository: RoomInfraredKeyRepository, ): InfraredDeviceConfigurationRepository = repository - @Provides - @Singleton - @InfraredDataStore - fun provideInfraredPreferencesDataStore( - @ApplicationContext context: Context, - ): DataStore { - return PreferenceDataStoreFactory.create( - produceFile = { context.preferencesDataStoreFile("infrared_preferences") }, - ) - } - - @Provides - @Singleton - fun provideInfraredRuntimeModeStore( - @InfraredDataStore dataStore: DataStore, - ): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore) - - @Provides - @Singleton - fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver = - InfraredRuntimeProfileResolver() - @Provides fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator = InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 2bb1601..38deb4e 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -14,10 +14,8 @@ 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 com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -34,16 +32,15 @@ import kotlinx.coroutines.launch import javax.inject.Inject sealed interface RemoteUiMessage { - data class Info(val text: String) : RemoteUiMessage - data class Error(val text: String) : RemoteUiMessage + data class Info(val text: UiText) : RemoteUiMessage + data class Error(val text: UiText) : RemoteUiMessage data object SessionInvalid : RemoteUiMessage } data class InfraredRemoteUiState( val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, val isLearningMode: Boolean = false, - val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, - val effectiveProfileId: ProviderProfileId? = null, + val profileId: ProviderProfileId? = null, val savedDeviceName: DeviceName? = null, val learnedSlotMap: Map = emptyMap(), val sendingKeySlot: String? = null, @@ -57,14 +54,12 @@ class InfraredRemoteViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val keyRepository: InfraredKeyRepository, private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, - private val runtimeModeStore: InfraredRuntimeModeStore, - private val profileResolver: InfraredRuntimeProfileResolver, private val learningCoordinator: InfraredLearningCoordinator, private val sendInfraredKey: SendInfraredKey, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] - private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER) private val isLearningModeFlow = MutableStateFlow(false) @@ -75,71 +70,39 @@ class InfraredRemoteViewModel @Inject constructor( private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null init { - if (authenticatedProfileId != null) { - viewModelScope.launch { - val startupCutoff = System.currentTimeMillis() - runtimeModeStore.mode.collect { mode -> - val effective = profileResolver.resolve(authenticatedProfileId, mode) - learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff) - if (effective != authenticatedProfileId) { - learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff) - } - } - } + if (profileId != null) { + val startupCutoff = System.currentTimeMillis() + learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff) } } private data class InfraredRemoteConfigSnapshot( - val effectiveProfileId: ProviderProfileId? = null, - val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, - val savedDeviceName: DeviceName? = null, + val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME, val learnedSlotMap: Map = emptyMap(), val configError: DomainError? = null, ) private val configSnapshotFlow: Flow = - runtimeModeStore.mode.flatMapLatest { mode -> - val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) } - if (profile == null) { - flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode)) - } else { - deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Failure -> flowOf( - InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - configError = deviceResult.error, - ), - ) - is DomainResult.Success -> { - val deviceName = deviceResult.value - if (deviceName == null) { - flowOf( - InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = null, - learnedSlotMap = emptyMap(), - ), + if (profileId == null) { + flowOf(InfraredRemoteConfigSnapshot()) + } else { + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Failure -> flowOf( + InfraredRemoteConfigSnapshot(configError = deviceResult.error), + ) + is DomainResult.Success -> { + val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + keyRepository.observeKeys(profileId, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> InfraredRemoteConfigSnapshot( + savedDeviceName = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> InfraredRemoteConfigSnapshot( + savedDeviceName = deviceName, + learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, ) - } else { - keyRepository.observeKeys(profile, deviceName).map { keysResult -> - when (keysResult) { - is DomainResult.Failure -> InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = deviceName, - configError = keysResult.error, - ) - is DomainResult.Success -> InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = deviceName, - learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, - ) - } - } } } } @@ -158,15 +121,19 @@ class InfraredRemoteViewModel @Inject constructor( val resolvedMessage = msg ?: snapshot.configError?.let { error -> when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid - is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") - else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败") + is DomainError.DeviceOffline -> RemoteUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> RemoteUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_load_failed), + ) } } InfraredRemoteUiState( selectedScene = scene, isLearningMode = isLearning, - runtimeMode = snapshot.runtimeMode, - effectiveProfileId = snapshot.effectiveProfileId, + profileId = profileId, savedDeviceName = snapshot.savedDeviceName, learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, @@ -176,7 +143,7 @@ class InfraredRemoteViewModel @Inject constructor( }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = InfraredRemoteUiState(), + initialValue = InfraredRemoteUiState(profileId = profileId), ) fun switchScene(scene: RemoteScene) { @@ -192,35 +159,38 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningJob?.cancel() activeLearningJob = null activeLearningSlotFlow.value = null - state.value.effectiveProfileId?.let { profile -> + profileId?.let { profile -> learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff) } } else { - messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_learning_mode_enabled), + ) } } fun onButtonClick(spec: RemoteButtonSpec) { - val currentProfile = state.value.effectiveProfileId ?: return - val savedDevice = state.value.savedDeviceName - if (savedDevice == null) { - messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置") - return - } + val currentProfile = profileId ?: return + val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME if (isLearningModeFlow.value) { if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) { return } val existingKey = state.value.learnedSlotMap[spec.defaultSlot] - messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource( + R.string.infrared_msg_recording_key, + listOf(spec.displayName, spec.defaultSlot), + ), + ) activeLearningSlotFlow.value = spec.defaultSlot val deferred = when (existingKey?.status) { InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) null -> learningCoordinator.learn( profileId = currentProfile, - deviceName = savedDevice, + deviceName = currentDevice, displayName = spec.displayName, sdkKey = spec.defaultSlot, ) @@ -240,7 +210,12 @@ class InfraredRemoteViewModel @Inject constructor( } when (result) { is DomainResult.Success -> { - messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource( + R.string.infrared_msg_learned_success, + listOf(spec.displayName), + ), + ) } is DomainResult.Failure -> { handleDomainError(result.error) @@ -263,8 +238,13 @@ class InfraredRemoteViewModel @Inject constructor( return } val key = state.value.learnedSlotMap[spec.defaultSlot] - if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) { - messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") + if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) { + messageFlow.value = RemoteUiMessage.Error( + UiText.StringResource( + R.string.infrared_msg_key_not_learned, + listOf(spec.displayName), + ), + ) return } sendingKeySlotFlow.value = spec.defaultSlot @@ -272,7 +252,12 @@ class InfraredRemoteViewModel @Inject constructor( try { when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) { is DomainResult.Success -> { - messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource( + R.string.infrared_msg_signal_sent, + listOf(spec.displayName), + ), + ) } is DomainResult.Failure -> { handleDomainError(result.error) @@ -292,7 +277,7 @@ class InfraredRemoteViewModel @Inject constructor( underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null - state.value.effectiveProfileId?.let { profile -> + profileId?.let { profile -> learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff) } } @@ -307,11 +292,23 @@ class InfraredRemoteViewModel @Inject constructor( messageFlow.value = RemoteUiMessage.SessionInvalid } is DomainError.DeviceOffline -> { - messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + messageFlow.value = RemoteUiMessage.Error( + UiText.StringResource( + R.string.infrared_msg_device_offline, + listOf(error.externalCode ?: "20206"), + ), + ) } else -> { - messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败") + messageFlow.value = RemoteUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) } } } + + companion object { + val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt index 041790b..bb50d7a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt @@ -1,5 +1,16 @@ package com.flagship.abox.manager.infrared.remote +import androidx.annotation.StringRes +import com.flagship.abox.manager.infrared.R + +sealed interface UiText { + data class DynamicString(val value: String) : UiText + data class StringResource( + @StringRes val resId: Int, + val args: List = emptyList(), + ) : UiText +} + enum class RemoteScene { AIR_CONDITIONER, TELEVISION, @@ -8,38 +19,39 @@ enum class RemoteScene { data class RemoteButtonSpec( val id: String, val defaultSlot: String, + @StringRes val nameRes: Int, val displayName: String, val symbol: String, ) object RemoteSceneConfig { object AirConditioner { - val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻") - val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲") - val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼") - val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀") - val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨") - val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔") - val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱") + val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻") + val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲") + val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼") + val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀") + val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨") + val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔") + val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱") val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) } object Television { - val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻") - val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇") - val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲") - val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼") - val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀") - val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶") - val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK") - val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +") - val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -") - val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +") - val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -") - val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠") - val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩") - val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰") + val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻") + val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇") + val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲") + val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼") + val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀") + val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶") + val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK") + val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +") + val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -") + val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +") + val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -") + val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠") + val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩") + val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰") val buttons = listOf( POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt index d691ae2..2f3f043 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt @@ -20,12 +20,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig @@ -78,7 +80,7 @@ fun AcRemotePanel( isLearningMode = isLearningMode, onUpClick = { onButtonClick(tempUpSpec) }, onDownClick = { onButtonClick(tempDownSpec) }, - label = "温度调节", + label = stringResource(R.string.infrared_label_temp_control), ) } @@ -148,13 +150,13 @@ private fun AcLcdDisplay() { ) { Column { Text( - text = "❄️ 制冷模式", + text = stringResource(R.string.infrared_label_cool_mode), color = Color(0xFF38BDF8), fontSize = 14.sp, fontWeight = FontWeight.Medium, ) Text( - text = "💨 自动风速 | ↔ 扫风", + text = stringResource(R.string.infrared_label_ac_status), color = Color(0xFF94A3B8), fontSize = 12.sp, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index ad7bde0..1df7594 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,11 +51,11 @@ import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteScene import com.flagship.abox.manager.infrared.remote.RemoteUiMessage +import com.flagship.abox.manager.infrared.remote.UiText @Composable fun InfraredRemoteRoute( onBack: () -> Unit, - onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, viewModel: InfraredRemoteViewModel = hiltViewModel(), ) { @@ -63,7 +64,6 @@ fun InfraredRemoteRoute( InfraredRemoteScreen( state = state, onBack = onBack, - onOpenSettings = onOpenSettings, onSessionInvalid = onSessionInvalid, onSceneSelected = viewModel::switchScene, onToggleLearningMode = viewModel::toggleLearningMode, @@ -77,7 +77,6 @@ fun InfraredRemoteRoute( internal fun InfraredRemoteScreen( state: InfraredRemoteUiState, onBack: () -> Unit, - onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, onSceneSelected: (RemoteScene) -> Unit, onToggleLearningMode: (Boolean) -> Unit, @@ -85,16 +84,25 @@ internal fun InfraredRemoteScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffect(state.message) { state.message?.let { msg -> when (msg) { is RemoteUiMessage.Info -> { - snackbarHostState.showSnackbar(msg.text) + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) onMessageShown() } is RemoteUiMessage.Error -> { - snackbarHostState.showSnackbar(msg.text) + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) onMessageShown() } is RemoteUiMessage.SessionInvalid -> { @@ -145,14 +153,6 @@ internal fun InfraredRemoteScreen( ), ) } - - // 设置齿轮按钮 - IconButton( - onClick = onOpenSettings, - modifier = Modifier.testTag("remote_settings_button"), - ) { - Text("⚙️", fontSize = 20.sp) - } }, ) }, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt index 2c80c9d..fca60ea 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt @@ -10,9 +10,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig @@ -118,7 +120,7 @@ fun TvRemotePanel( isLearningMode = isLearningMode, onUpClick = { onButtonClick(volUp) }, onDownClick = { onButtonClick(volDown) }, - label = "音量 VOL", + label = stringResource(R.string.infrared_label_vol), ) // 频道控制 @@ -134,7 +136,7 @@ fun TvRemotePanel( isLearningMode = isLearningMode, onUpClick = { onButtonClick(chUp) }, onDownClick = { onButtonClick(chDown) }, - label = "频道 CH", + label = stringResource(R.string.infrared_label_ch), ) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt deleted file mode 100644 index eb2e141..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import com.flagship.abox.manager.domain.DomainError -import com.flagship.abox.manager.domain.DomainResult -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import java.io.IOException - -class DataStoreInfraredRuntimeModeStore( - private val dataStore: DataStore, -) : InfraredRuntimeModeStore { - - companion object { - private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode") - } - - override val mode: Flow = dataStore.data - .catch { exception -> - if (exception is IOException) { - emit(androidx.datastore.preferences.core.emptyPreferences()) - } else { - throw exception - } - } - .map { preferences -> - val storedName = preferences[KEY_RUNTIME_MODE] - if (storedName != null) { - try { - InfraredRuntimeMode.valueOf(storedName) - } catch (_: IllegalArgumentException) { - InfraredRuntimeMode.FAKE - } - } else { - InfraredRuntimeMode.FAKE - } - } - - override suspend fun currentMode(): InfraredRuntimeMode { - return mode.first() - } - - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - return try { - dataStore.edit { preferences -> - preferences[KEY_RUNTIME_MODE] = mode.name - } - DomainResult.Success(Unit) - } catch (throwable: Throwable) { - if (throwable is kotlinx.coroutines.CancellationException) throw throwable - DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode")) - } - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt deleted file mode 100644 index 15f540d..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import com.flagship.abox.manager.domain.DomainResult -import kotlinx.coroutines.flow.Flow - -enum class InfraredRuntimeMode { - FAKE, - REAL_ABSDK, -} - -interface InfraredRuntimeModeStore { - val mode: Flow - suspend fun currentMode(): InfraredRuntimeMode - suspend fun setMode(mode: InfraredRuntimeMode): DomainResult -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt deleted file mode 100644 index df0f84d..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import com.flagship.abox.manager.domain.ProviderProfileId - -class InfraredRuntimeProfileResolver { - fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId { - return when (mode) { - InfraredRuntimeMode.REAL_ABSDK -> realProfileId - InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake") - } - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt deleted file mode 100644 index 6f575ce..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ /dev/null @@ -1,466 +0,0 @@ -package com.flagship.abox.manager.infrared.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flagship.abox.manager.designsystem.ABoxButton -import com.flagship.abox.manager.designsystem.ABoxSurface -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.infrared.R -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode - -@Composable -fun InfraredControlRoute( - onBack: () -> Unit, - onSessionInvalid: () -> Unit, - viewModel: InfraredControlViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - InfraredControlScreen( - state = state, - nextSlot = viewModel.nextAvailableKeySlot(), - onBack = onBack, - onSessionInvalid = onSessionInvalid, - onDeviceNameChanged = viewModel::onDeviceNameChanged, - onSaveDeviceName = viewModel::saveDeviceName, - onLearn = viewModel::learnKey, - onRetry = viewModel::retryLearning, - onSend = viewModel::sendKey, - onRename = viewModel::renameKey, - onDelete = viewModel::deleteKey, - onModeChanged = viewModel::setRuntimeMode, - onMessageShown = viewModel::clearMessage, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun InfraredControlScreen( - state: InfraredControlUiState, - nextSlot: Int, - onBack: () -> Unit, - onSessionInvalid: () -> Unit, - onDeviceNameChanged: (String) -> Unit, - onSaveDeviceName: () -> Unit, - onLearn: (String, String?) -> Unit, - onRetry: (InfraredKeyId) -> Unit, - onSend: (InfraredKeyId) -> Unit, - onRename: (InfraredKeyId, String) -> Unit, - onDelete: (InfraredKeyId) -> Unit, - onModeChanged: (InfraredRuntimeMode) -> Unit, - onMessageShown: () -> Unit, -) { - val snackbarHostState = remember { SnackbarHostState() } - - var showLearnDialog by remember { mutableStateOf(false) } - var keyToRename by remember { mutableStateOf(null) } - var keyToDelete by remember { mutableStateOf(null) } - - LaunchedEffect(state.message) { - state.message?.let { msg -> - when (msg) { - is InfraredUiMessage.Info -> { - snackbarHostState.showSnackbar(msg.text) - onMessageShown() - } - is InfraredUiMessage.Error -> { - snackbarHostState.showSnackbar(msg.text) - onMessageShown() - } - is InfraredUiMessage.SessionInvalid -> { - onSessionInvalid() - onMessageShown() - } - } - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.infrared_title)) }, - navigationIcon = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.infrared_back)) - } - }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { innerPadding -> - ABoxSurface( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Mode indicator and toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { - stringResource(R.string.infrared_mode_fake) - } else { - stringResource(R.string.infrared_mode_real) - } - Text( - text = modeText, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.testTag("infrared_mode_label"), - ) - OutlinedButton( - onClick = { - val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { - InfraredRuntimeMode.REAL_ABSDK - } else { - InfraredRuntimeMode.FAKE - } - onModeChanged(target) - }, - enabled = !state.hasActiveLearning, - ) { - Text(stringResource(R.string.infrared_switch_mode)) - } - } - - // Device name configuration card - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val saved = state.savedDeviceName - Text( - text = if (saved != null) { - stringResource(R.string.infrared_saved_device, saved.value) - } else { - stringResource(R.string.infrared_no_device_saved) - }, - style = MaterialTheme.typography.titleMedium, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = state.deviceNameInput, - onValueChange = onDeviceNameChanged, - label = { Text(stringResource(R.string.infrared_device_name_label)) }, - placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, - singleLine = true, - modifier = Modifier - .weight(1f) - .testTag("infrared_device_name_input"), - ) - ABoxButton( - onClick = onSaveDeviceName, - modifier = Modifier.testTag("infrared_save_device"), - ) { - Text(stringResource(R.string.infrared_save_device)) - } - } - } - } - - // Learned Keys Section - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.infrared_keys_title), - style = MaterialTheme.typography.titleMedium, - ) - ABoxButton( - onClick = { showLearnDialog = true }, - enabled = state.savedDeviceName != null, - modifier = Modifier.testTag("infrared_learn_new_key"), - ) { - Text(stringResource(R.string.infrared_learn_new_key)) - } - } - - if (state.keys.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.infrared_no_keys), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.keys, key = { it.id.value }) { key -> - InfraredKeyRow( - key = key, - isSending = state.sendingKeyIds.contains(key.id), - onSend = { onSend(key.id) }, - onRetry = { onRetry(key.id) }, - onRename = { keyToRename = key }, - onDelete = { keyToDelete = key }, - ) - } - } - } - } - } - } - - if (showLearnDialog) { - var keyName by remember { mutableStateOf("") } - var keySlot by remember { mutableStateOf(nextSlot.toString()) } - - AlertDialog( - onDismissRequest = { showLearnDialog = false }, - title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.infrared_learn_dialog_prompt)) - OutlinedTextField( - value = keyName, - onValueChange = { keyName = it }, - label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = keySlot, - onValueChange = { keySlot = it }, - label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - Button( - onClick = { - onLearn(keyName, keySlot) - showLearnDialog = false - }, - enabled = keyName.isNotBlank() && keySlot.isNotBlank(), - ) { - Text(stringResource(R.string.infrared_learn_dialog_start)) - } - }, - dismissButton = { - TextButton(onClick = { showLearnDialog = false }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } - - keyToRename?.let { targetKey -> - var newName by remember { mutableStateOf(targetKey.displayName) } - AlertDialog( - onDismissRequest = { keyToRename = null }, - title = { Text(stringResource(R.string.infrared_rename_dialog_title)) }, - text = { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - Button( - onClick = { - onRename(targetKey.id, newName) - keyToRename = null - }, - enabled = newName.isNotBlank(), - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { keyToRename = null }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } - - keyToDelete?.let { targetKey -> - AlertDialog( - onDismissRequest = { keyToDelete = null }, - title = { Text(stringResource(R.string.infrared_delete_dialog_title)) }, - text = { Text(stringResource(R.string.infrared_delete_dialog_message)) }, - confirmButton = { - Button( - onClick = { - onDelete(targetKey.id) - keyToDelete = null - }, - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { keyToDelete = null }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } -} - -@Composable -private fun InfraredKeyRow( - key: SavedInfraredKey, - isSending: Boolean, - onSend: () -> Unit, - onRetry: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .testTag("infrared_key_${key.id.value}"), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = key.displayName, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.infrared_key_slot_label, key.sdkKey), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - val statusLabel = when (key.status) { - InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning) - InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready) - InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed) - } - Text( - text = statusLabel, - style = MaterialTheme.typography.bodyMedium, - color = when (key.status) { - InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary - InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary - InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error - }, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - if (key.status == InfraredKeyStatus.READY) { - Button( - onClick = onSend, - enabled = !isSending, - modifier = Modifier.testTag("infrared_send_${key.id.value}"), - ) { - if (isSending) { - Text(stringResource(R.string.infrared_sending)) - } else { - Text(stringResource(R.string.infrared_send)) - } - } - Spacer(modifier = Modifier.width(8.dp)) - } - - if (key.status == InfraredKeyStatus.FAILED) { - Button( - onClick = onRetry, - modifier = Modifier.testTag("infrared_retry_${key.id.value}"), - ) { - Text(stringResource(R.string.infrared_retry)) - } - Spacer(modifier = Modifier.width(8.dp)) - } - - if (key.status != InfraredKeyStatus.LEARNING) { - OutlinedButton(onClick = onRename) { - Text(stringResource(R.string.infrared_rename)) - } - Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton(onClick = onDelete) { - Text(stringResource(R.string.infrared_delete)) - } - } else { - CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) - } - } - } - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt deleted file mode 100644 index 14e8c90..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flagship.abox.manager.infrared.ui - -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 -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode - -sealed interface InfraredUiMessage { - data class Info(val text: String) : InfraredUiMessage - data class Error(val text: String) : InfraredUiMessage - data object SessionInvalid : InfraredUiMessage -} - -data class InfraredControlUiState( - val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, - val effectiveProfileId: ProviderProfileId? = null, - val deviceNameInput: String = "", - val savedDeviceName: DeviceName? = null, - val keys: List = emptyList(), - val loading: Boolean = true, - val sendingKeyIds: Set = emptySet(), - val message: InfraredUiMessage? = null, -) { - val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt deleted file mode 100644 index d1ee47f..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ /dev/null @@ -1,342 +0,0 @@ -package com.flagship.abox.manager.infrared.ui - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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.DeleteInfraredKey -import com.flagship.abox.manager.domain.infrared.EpochMillisClock -import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -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.RecoverInterruptedInfraredLearning -import com.flagship.abox.manager.domain.infrared.RenameInfraredKey -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.domain.infrared.SendInfraredKey -import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class InfraredControlViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val keyRepository: InfraredKeyRepository, - private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, - private val runtimeModeStore: InfraredRuntimeModeStore, - private val profileResolver: InfraredRuntimeProfileResolver, - private val learningCoordinator: InfraredLearningCoordinator, - private val sendInfraredKey: SendInfraredKey, - private val renameInfraredKey: RenameInfraredKey, - private val deleteInfraredKey: DeleteInfraredKey, - private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, - private val clock: EpochMillisClock, -) : ViewModel() { - - private val rawProfileId: String? = savedStateHandle["profileId"] - private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } - - private val deviceNameInputFlow = MutableStateFlow("") - private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) - private val messageFlow = MutableStateFlow(null) - - private val effectiveProfileAndMode: Flow> = - runtimeModeStore.mode.mapModeToProfile() - - private fun Flow.mapModeToProfile(): Flow> { - return flatMapLatest { mode -> - if (authenticatedProfileId == null) { - flowOf(Pair(null, mode)) - } else { - val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) - flowOf(Pair(effectiveProfile, mode)) - } - } - } - - private val deviceNameFlow: Flow> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) { - flowOf(DomainResult.Success(null)) - } else { - deviceConfigurationRepository.observeDeviceName(profile) - } - } - - private val keysFlow: Flow>> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) { - flowOf(DomainResult.Success(emptyList())) - } else { - deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Success -> { - val device = deviceResult.value - if (device == null) { - flowOf(DomainResult.Success(emptyList())) - } else { - keyRepository.observeKeys(profile, device) - } - } - is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) - } - } - } - } - - private data class ServerState( - val profile: ProviderProfileId?, - val mode: InfraredRuntimeMode, - val deviceResult: DomainResult, - val keysResult: DomainResult>, - ) - - private val serverStateFlow: Flow = combine( - effectiveProfileAndMode, - deviceNameFlow, - keysFlow, - ) { profileAndMode, deviceResult, keysResult -> - ServerState( - profile = profileAndMode.first, - mode = profileAndMode.second, - deviceResult = deviceResult, - keysResult = keysResult, - ) - } - - val state: StateFlow = combine( - serverStateFlow, - deviceNameInputFlow, - sendingKeyIdsFlow, - messageFlow, - ) { serverState, input, sendingIds, msg -> - val savedDevice = (serverState.deviceResult as? DomainResult.Success)?.value - val keys = (serverState.keysResult as? DomainResult.Success>)?.value ?: emptyList() - val loading = serverState.profile == null || - serverState.deviceResult !is DomainResult.Success || - serverState.keysResult !is DomainResult.Success - - InfraredControlUiState( - runtimeMode = serverState.mode, - effectiveProfileId = serverState.profile, - deviceNameInput = input, - savedDeviceName = savedDevice, - keys = keys, - loading = loading, - sendingKeyIds = sendingIds, - message = if (authenticatedProfileId == null) { - InfraredUiMessage.Error("Invalid or missing profile ID") - } else { - msg - }, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = InfraredControlUiState( - runtimeMode = InfraredRuntimeMode.FAKE, - effectiveProfileId = null, - loading = true, - ), - ) - - init { - if (authenticatedProfileId != null) { - viewModelScope.launch { - val currentMode = runtimeModeStore.currentMode() - val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode) - recoverInterruptedInfraredLearning(effectiveProfile) - } - } - } - - fun onDeviceNameChanged(value: String) { - deviceNameInputFlow.value = value - } - - fun saveDeviceName() { - val currentProfile = state.value.effectiveProfileId ?: return - val trimmed = deviceNameInputFlow.value.trim() - if (trimmed.isBlank()) { - messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank") - return - } - viewModelScope.launch { - when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Device name saved") - deviceNameInputFlow.value = "" - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun nextAvailableKeySlot(): Int { - val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() - return (1..100).firstOrNull { it !in usedSlots } ?: 1 - } - - fun learnKey(displayName: String, keySlotInput: String? = null) { - val currentProfile = state.value.effectiveProfileId ?: return - val savedDevice = state.value.savedDeviceName - if (savedDevice == null) { - messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first") - return - } - val trimmedName = displayName.trim() - if (trimmedName.isBlank()) { - messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank") - return - } - val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) { - val slotInt = keySlotInput.trim().toIntOrNull() - if (slotInt == null || slotInt !in 1..100) { - messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100") - return - } - slotInt.toString() - } else { - nextAvailableKeySlot().toString() - } - learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) - } - - fun retryLearning(keyId: InfraredKeyId) { - val currentProfile = state.value.effectiveProfileId ?: return - learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) - } - - fun sendKey(keyId: InfraredKeyId) { - val currentProfile = state.value.effectiveProfileId ?: return - if (state.value.sendingKeyIds.contains(keyId)) return - - val key = state.value.keys.find { it.id == keyId } - if (key == null || key.status != InfraredKeyStatus.READY) { - messageFlow.value = InfraredUiMessage.Error("Key is not ready to send") - return - } - - sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId - viewModelScope.launch { - try { - when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Signal sent successfully") - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } finally { - sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId - } - } - } - - fun renameKey(keyId: InfraredKeyId, displayName: String) { - val currentProfile = state.value.effectiveProfileId ?: return - val trimmed = displayName.trim() - if (trimmed.isBlank()) { - messageFlow.value = InfraredUiMessage.Error("Name cannot be blank") - return - } - viewModelScope.launch { - when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Key renamed") - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun deleteKey(keyId: InfraredKeyId) { - val currentProfile = state.value.effectiveProfileId ?: return - viewModelScope.launch { - when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Key removed locally") - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun setRuntimeMode(mode: InfraredRuntimeMode) { - if (state.value.hasActiveLearning) { - messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active") - return - } - viewModelScope.launch { - when (val result = runtimeModeStore.setMode(mode)) { - is DomainResult.Success -> { - if (authenticatedProfileId != null) { - val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) - recoverInterruptedInfraredLearning(newEffectiveProfile) - } - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun clearMessage() { - messageFlow.value = null - } - - private fun handleDomainError(error: DomainError) { - when (error) { - is DomainError.TokenInvalid, is DomainError.Authentication -> { - messageFlow.value = InfraredUiMessage.SessionInvalid - } - is DomainError.DeviceOffline -> { - messageFlow.value = InfraredUiMessage.Error("Infrared controller is offline (code: ${error.externalCode ?: "20206"})") - } - is DomainError.DeviceNotFound -> { - messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})") - } - is DomainError.InvalidRequest -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request") - } - is DomainError.OperationFailed -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})") - } - is DomainError.Network -> { - messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}") - } - is DomainError.NoData -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found") - } - is DomainError.Unknown -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error") - } - } - } -} diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index f1cf838..ddf40ac 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -39,4 +39,41 @@ ❄️ 空调遥控 📺 电视遥控 设置 + + 🔴 学习录制模式已开启:点击任意按键即可开始录制 + 正在录制【%1$s】(Slot %2$s),请按遥控器按键... + 【%1$s】录制成功 + 按键【%1$s】尚未学习,请开启右上角学习模式进行录制 + 已发射: %1$s + 红外控制器离线 (错误码: %1$s) + 数据加载失败 + 操作失败 + 温度调节 + 音量 VOL + 频道 CH + ❄️ 制冷模式 + 💨 自动风速 | ↔ 扫风 + + 空调-电源 + 空调-温度+ + 空调-温度- + 空调-模式 + 空调-风速 + 空调-扫风 + 空调-定时 + + 电视-电源 + 电视-静音 + 电视-上 + 电视-下 + 电视-左 + 电视-右 + 电视-确定 + 电视-音量+ + 电视-音量- + 电视-频道+ + 电视-频道- + 电视-主页 + 电视-返回 + 电视-菜单 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index e676a1f..a91667e 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -39,4 +39,41 @@ ❄️ Air Conditioner 📺 Television Settings + + 🔴 Learn mode active: tap any button to record + Recording [%1$s] (Slot %2$s), press remote button… + [%1$s] recorded successfully + Button [%1$s] is not learned. Enable learn mode to record. + Sent: %1$s + Infrared controller offline (code: %1$s) + Failed to load data + Operation failed + Temp Control + VOL + CH + ❄️ Cool Mode + 💨 Auto Fan | ↔ Swing + + AC Power + AC Temp + + AC Temp - + AC Mode + AC Fan Speed + AC Swing + AC Timer + + TV Power + TV Mute + TV Up + TV Down + TV Left + TV Right + TV OK + TV Vol + + TV Vol - + TV CH + + TV CH - + TV Home + TV Back + TV Menu diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt index f9123c9..3d4bee1 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt @@ -125,4 +125,32 @@ class InfraredLearningCoordinatorTest { val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.FAILED, updated.status) } + + @Test + fun subsequentLearnWaitsForCleanupCompletion() = runTest { + val learningKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(learningKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) + + coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L) + val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1"))) + advanceUntilIdle() + + val relearnResult = relearnJob.await() + assertTrue(relearnResult is DomainResult.Success) + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 0700476..60bd17b 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -12,17 +12,13 @@ import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -35,30 +31,17 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -private class TestModeStore : InfraredRuntimeModeStore { - private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) - override val mode: Flow = modeFlow - override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - modeFlow.value = mode - return DomainResult.Success(Unit) - } -} - @OptIn(ExperimentalCoroutinesApi::class) class InfraredRemoteViewModelTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) private val profile = ProviderProfileId("abox") - private val fakeProfile = ProviderProfileId("abox:infrared-fake") private val device = DeviceName("ir-living") private val clock = EpochMillisClock { 10_000L } private val keyRepo = FakeInfraredKeyRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository() - private val modeStore = TestModeStore() - private val resolver = InfraredRuntimeProfileResolver() private val gateway = FakeInfraredGateway() private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) @@ -84,8 +67,6 @@ class InfraredRemoteViewModelTest { savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), keyRepository = keyRepo, deviceConfigurationRepository = deviceRepo, - runtimeModeStore = modeStore, - profileResolver = resolver, learningCoordinator = coordinator, sendInfraredKey = send, ) @@ -93,10 +74,10 @@ class InfraredRemoteViewModelTest { @Test fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val readyKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -123,7 +104,7 @@ class InfraredRemoteViewModelTest { @Test fun learningModeTriggersLearnOnButtonClick() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() @@ -141,7 +122,7 @@ class InfraredRemoteViewModelTest { @Test fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() @@ -157,10 +138,10 @@ class InfraredRemoteViewModelTest { @Test fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val readyKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -187,10 +168,10 @@ class InfraredRemoteViewModelTest { @Test fun learningExistingKeyRetriesExistingKey() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val failedKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -212,16 +193,19 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) - val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) - assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))), + vm.state.value.message, + ) job.cancel() } @Test fun learningFailurePropagatesErrorMessage() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206")) val vm = createViewModel() @@ -232,14 +216,17 @@ class InfraredRemoteViewModelTest { vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) advanceUntilIdle() - assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))), + vm.state.value.message, + ) job.cancel() } @Test fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired")) val vm = createViewModel() @@ -257,7 +244,7 @@ class InfraredRemoteViewModelTest { @Test fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() @@ -274,10 +261,10 @@ class InfraredRemoteViewModelTest { @Test fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val readyKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -299,19 +286,22 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) - val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) - assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))), + vm.state.value.message, + ) job.cancel() } @Test fun relearningKeyInLearningStatusSucceeds() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val learningKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -333,9 +323,12 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) - val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) - assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))), + vm.state.value.message, + ) job.cancel() } @@ -348,7 +341,7 @@ class InfraredRemoteViewModelTest { val job = launch { vm.state.collect {} } advanceUntilIdle() - assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message) + assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message) job.cancel() } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt deleted file mode 100644 index 6249db9..0000000 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import com.flagship.abox.manager.domain.ProviderProfileId -import org.junit.Assert.assertEquals -import org.junit.Test - -class InfraredRuntimeProfileResolverTest { - - @Test - fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() { - val resolver = InfraredRuntimeProfileResolver() - assertEquals( - ProviderProfileId("abox"), - resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK), - ) - assertEquals( - ProviderProfileId("abox:infrared-fake"), - resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE), - ) - } -} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt deleted file mode 100644 index 822a811..0000000 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.flagship.abox.manager.infrared.ui - -import androidx.lifecycle.SavedStateHandle -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.DeleteInfraredKey -import com.flagship.abox.manager.domain.infrared.EpochMillisClock -import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory -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.LearnInfraredKey -import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning -import com.flagship.abox.manager.domain.infrared.RenameInfraredKey -import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.domain.infrared.SendInfraredKey -import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver -import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository -import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway -import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class FakeRuntimeModeStore : InfraredRuntimeModeStore { - private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) - override val mode: Flow = modeFlow - override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - modeFlow.value = mode - return DomainResult.Success(Unit) - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class InfraredControlViewModelTest { - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private val baseProfile = ProviderProfileId("abox") - private val fakeProfile = ProviderProfileId("abox:infrared-fake") - private val device = DeviceName("ir-living") - private val clock = EpochMillisClock { 10_000L } - - private val keyRepository = FakeInfraredKeyRepository() - private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository() - private val runtimeModeStore = FakeRuntimeModeStore() - private val profileResolver = InfraredRuntimeProfileResolver() - private val gateway = FakeInfraredGateway() - private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) - - private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) - private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) - private val relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepository, gateway, clock) - private val sendUseCase = SendInfraredKey(keyRepository, gateway) - private val renameUseCase = RenameInfraredKey(keyRepository, clock) - private val deleteUseCase = DeleteInfraredKey(keyRepository) - private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock) - - private lateinit var coordinator: InfraredLearningCoordinator - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, relearnUseCase, keyRepository, testScope) - } - - @After - fun teardown() { - Dispatchers.resetMain() - } - - private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel { - val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap()) - return InfraredControlViewModel( - savedStateHandle = handle, - keyRepository = keyRepository, - deviceConfigurationRepository = deviceConfigRepository, - runtimeModeStore = runtimeModeStore, - profileResolver = profileResolver, - learningCoordinator = coordinator, - sendInfraredKey = sendUseCase, - renameInfraredKey = renameUseCase, - deleteInfraredKey = deleteUseCase, - recoverInterruptedInfraredLearning = recoverUseCase, - clock = clock, - ) - } - - @Test - fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - assertEquals(fakeProfile, vm.state.value.effectiveProfileId) - assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) - - vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) - advanceUntilIdle() - - assertEquals(baseProfile, vm.state.value.effectiveProfileId) - assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode) - - job.cancel() - } - - @Test - fun blankDeviceNameReturnsValidationError() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.onDeviceNameChanged(" ") - vm.saveDeviceName() - advanceUntilIdle() - - val msg = vm.state.value.message - assertTrue(msg is InfraredUiMessage.Error) - assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text) - - job.cancel() - } - - @Test - fun saveDeviceNamePersistsAndTrims() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.onDeviceNameChanged(" ir-main ") - vm.saveDeviceName() - advanceUntilIdle() - - assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName) - assertEquals("", vm.state.value.deviceNameInput) - assertTrue(vm.state.value.message is InfraredUiMessage.Info) - - job.cancel() - } - - @Test - fun learnWithoutSavedDeviceFails() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.learnKey("Power") - advanceUntilIdle() - - val msg = vm.state.value.message - assertTrue(msg is InfraredUiMessage.Error) - assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text) - - job.cancel() - } - - @Test - fun learnWithSavedDeviceSucceeds() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.onDeviceNameChanged("ir-1") - vm.saveDeviceName() - advanceUntilIdle() - - vm.learnKey("Air Conditioner") - advanceUntilIdle() - - val keys = vm.state.value.keys - assertEquals(1, keys.size) - assertEquals("Air Conditioner", keys.first().displayName) - assertEquals(InfraredKeyStatus.READY, keys.first().status) - - job.cancel() - } - - @Test - fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - deviceConfigRepository.setDeviceName(fakeProfile, device) - val key = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = fakeProfile, - deviceName = device, - displayName = "Power", - sdkKey = "app_ir_k1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepository.insert(key) - advanceUntilIdle() - - vm.sendKey(InfraredKeyId("k1")) - advanceUntilIdle() - - assertTrue(vm.state.value.sendingKeyIds.isEmpty()) - assertTrue(vm.state.value.message is InfraredUiMessage.Info) - assertEquals(1, gateway.sentKeys.size) - - job.cancel() - } - - @Test - fun sendFailurePreservesReadyStatus() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - deviceConfigRepository.setDeviceName(fakeProfile, device) - val key = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = fakeProfile, - deviceName = device, - displayName = "Power", - sdkKey = "app_ir_k1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepository.insert(key) - gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206")) - advanceUntilIdle() - - vm.sendKey(InfraredKeyId("k1")) - advanceUntilIdle() - - val fetched = vm.state.value.keys.single() - assertEquals(InfraredKeyStatus.READY, fetched.status) - assertTrue(vm.state.value.message is InfraredUiMessage.Error) - - job.cancel() - } - - @Test - fun setModeBlockedWhileLearningIsActive() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - deviceConfigRepository.setDeviceName(fakeProfile, device) - val key = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = fakeProfile, - deviceName = device, - displayName = "Power", - sdkKey = "app_ir_k1", - status = InfraredKeyStatus.LEARNING, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepository.insert(key) - advanceUntilIdle() - - assertTrue(vm.state.value.hasActiveLearning) - vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) - advanceUntilIdle() - - assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) - assertTrue(vm.state.value.message is InfraredUiMessage.Error) - - job.cancel() - } -} -- 2.54.0 From 86af7f4c91099c7e30c64ee298d79ebba46a1ab7 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 13:39:01 +0800 Subject: [PATCH 35/41] feat(infrared): integrate learned keys management screen and sync provider profile mode --- .../abox/manager/ui/ABoxManagerApp.kt | 16 + .../remote/InfraredRemoteScreenTest.kt | 5 + .../infrared/ui/InfraredControlScreenTest.kt | 77 +++ .../remote/InfraredRemoteViewModel.kt | 51 +- .../remote/ui/InfraredRemoteScreen.kt | 11 + .../infrared/ui/InfraredControlScreen.kt | 468 ++++++++++++++++++ .../infrared/ui/InfraredControlUiState.kt | 30 ++ .../infrared/ui/InfraredControlViewModel.kt | 307 ++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 11 + .../infrared/src/main/res/values/strings.xml | 11 + .../remote/InfraredRemoteViewModelTest.kt | 2 + .../ui/InfraredControlViewModelTest.kt | 276 +++++++++++ 12 files changed, 1248 insertions(+), 17 deletions(-) create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index abd9d09..257990d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -22,6 +22,7 @@ import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute +import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel @@ -114,7 +115,22 @@ private fun AuthenticatedNavHost( navArgument("profileId") { type = NavType.StringType }, ), ) { + val encodedProfileId = android.net.Uri.encode(profile.id.value) InfraredRemoteRoute( + onBack = { navController.popBackStack() }, + onOpenSettings = { + navController.navigate("infrared_settings/$encodedProfileId") + }, + onSessionInvalid = onLogout, + ) + } + composable( + route = "infrared_settings/{profileId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + ), + ) { + InfraredControlRoute( onBack = { navController.popBackStack() }, onSessionInvalid = onLogout, ) diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt index 28e8c72..a071166 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -25,11 +25,13 @@ class InfraredRemoteScreenTest { savedDeviceName = DeviceName("ir-1"), ) var clickedSpec: RemoteButtonSpec? = null + var settingsClicked = false composeTestRule.setContent { InfraredRemoteScreen( state = state, onBack = {}, + onOpenSettings = { settingsClicked = true }, onSessionInvalid = {}, onSceneSelected = {}, onToggleLearningMode = {}, @@ -40,5 +42,8 @@ class InfraredRemoteScreenTest { composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick() assertEquals("AC_POWER", clickedSpec?.id) + + composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick() + assertTrue(settingsClicked) } } diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt new file mode 100644 index 0000000..99699b1 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -0,0 +1,77 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredControlScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun rendersControlScreenAndTriggersKeyOperations() { + val testKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = ProviderProfileId("profile1"), + deviceName = DeviceName("ir-1"), + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + val state = InfraredControlUiState( + profileId = ProviderProfileId("profile1"), + providerType = ProviderType.LOCAL_EXPERIENCE, + savedDeviceName = DeviceName("ir-1"), + keys = listOf(testKey), + loading = false, + ) + + var sendClicked = false + var deleteClicked = false + + composeTestRule.setContent { + InfraredControlScreen( + state = state, + onBack = {}, + onSessionInvalid = {}, + onDeviceNameChanged = {}, + onSaveDeviceName = {}, + onLearn = { _, _ -> }, + onRetry = {}, + onSend = { sendClicked = true }, + onRename = { _, _ -> }, + onDelete = { deleteClicked = true }, + onMessageShown = {}, + nextSlot = 2, + ) + } + + composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick() + assertTrue(sendClicked) + + composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick() + composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick() + assertTrue(deleteClicked) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 38deb4e..2e1806c 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -6,7 +6,10 @@ import androidx.lifecycle.viewModelScope 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.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef @@ -41,6 +44,8 @@ data class InfraredRemoteUiState( val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, val isLearningMode: Boolean = false, val profileId: ProviderProfileId? = null, + val providerType: ProviderType? = null, + val providerDisplayName: String? = null, val savedDeviceName: DeviceName? = null, val learnedSlotMap: Map = emptyMap(), val sendingKeySlot: String? = null, @@ -54,6 +59,7 @@ class InfraredRemoteViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val keyRepository: InfraredKeyRepository, private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val providerProfileStore: ProviderProfileStore, private val learningCoordinator: InfraredLearningCoordinator, private val sendInfraredKey: SendInfraredKey, ) : ViewModel() { @@ -77,6 +83,7 @@ class InfraredRemoteViewModel @Inject constructor( } private data class InfraredRemoteConfigSnapshot( + val providerProfile: ProviderProfile? = null, val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME, val learnedSlotMap: Map = emptyMap(), val configError: DomainError? = null, @@ -86,23 +93,31 @@ class InfraredRemoteViewModel @Inject constructor( if (profileId == null) { flowOf(InfraredRemoteConfigSnapshot()) } else { - deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Failure -> flowOf( - InfraredRemoteConfigSnapshot(configError = deviceResult.error), - ) - is DomainResult.Success -> { - val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME - keyRepository.observeKeys(profileId, deviceName).map { keysResult -> - when (keysResult) { - is DomainResult.Failure -> InfraredRemoteConfigSnapshot( - savedDeviceName = deviceName, - configError = keysResult.error, - ) - is DomainResult.Success -> InfraredRemoteConfigSnapshot( - savedDeviceName = deviceName, - learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, - ) + providerProfileStore.observeProfiles().flatMapLatest { profiles -> + val active = profiles.find { it.id == profileId } + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Failure -> flowOf( + InfraredRemoteConfigSnapshot( + providerProfile = active, + configError = deviceResult.error, + ), + ) + is DomainResult.Success -> { + val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + keyRepository.observeKeys(profileId, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> InfraredRemoteConfigSnapshot( + providerProfile = active, + savedDeviceName = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> InfraredRemoteConfigSnapshot( + providerProfile = active, + savedDeviceName = deviceName, + learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, + ) + } } } } @@ -134,6 +149,8 @@ class InfraredRemoteViewModel @Inject constructor( selectedScene = scene, isLearningMode = isLearning, profileId = profileId, + providerType = snapshot.providerProfile?.type, + providerDisplayName = snapshot.providerProfile?.displayName, savedDeviceName = snapshot.savedDeviceName, learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index 1df7594..97ccfc3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -56,6 +56,7 @@ import com.flagship.abox.manager.infrared.remote.UiText @Composable fun InfraredRemoteRoute( onBack: () -> Unit, + onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, viewModel: InfraredRemoteViewModel = hiltViewModel(), ) { @@ -64,6 +65,7 @@ fun InfraredRemoteRoute( InfraredRemoteScreen( state = state, onBack = onBack, + onOpenSettings = onOpenSettings, onSessionInvalid = onSessionInvalid, onSceneSelected = viewModel::switchScene, onToggleLearningMode = viewModel::toggleLearningMode, @@ -77,6 +79,7 @@ fun InfraredRemoteRoute( internal fun InfraredRemoteScreen( state: InfraredRemoteUiState, onBack: () -> Unit, + onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, onSceneSelected: (RemoteScene) -> Unit, onToggleLearningMode: (Boolean) -> Unit, @@ -153,6 +156,14 @@ internal fun InfraredRemoteScreen( ), ) } + + // 按键管理 / 设置入口按钮 + IconButton( + onClick = onOpenSettings, + modifier = Modifier.testTag("remote_settings_button"), + ) { + Text("⚙️", fontSize = 20.sp) + } }, ) }, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt new file mode 100644 index 0000000..3ede863 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -0,0 +1,468 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText + +@Composable +fun InfraredControlRoute( + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredControlViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredControlScreen( + state = state, + onBack = onBack, + onSessionInvalid = onSessionInvalid, + onDeviceNameChanged = viewModel::onDeviceNameChanged, + onSaveDeviceName = viewModel::saveDeviceName, + onLearn = viewModel::learnKey, + onRetry = viewModel::retryLearning, + onSend = viewModel::sendKey, + onRename = viewModel::renameKey, + onDelete = viewModel::deleteKey, + onMessageShown = viewModel::clearMessage, + nextSlot = viewModel.nextAvailableKeySlot(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredControlScreen( + state: InfraredControlUiState, + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + onDeviceNameChanged: (String) -> Unit, + onSaveDeviceName: () -> Unit, + onLearn: (name: String, slot: String?) -> Unit, + onRetry: (InfraredKeyId) -> Unit, + onSend: (InfraredKeyId) -> Unit, + onRename: (InfraredKeyId, String) -> Unit, + onDelete: (InfraredKeyId) -> Unit, + onMessageShown: () -> Unit, + nextSlot: Int, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + var showLearnDialog by remember { mutableStateOf(false) } + var keyToRename by remember { mutableStateOf(null) } + var keyToDelete by remember { mutableStateOf(null) } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is InfraredUiMessage.Info -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is InfraredUiMessage.Error -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is InfraredUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.infrared_control_title)) }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 环境与设备卡片 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val envText = when (state.providerType) { + ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) + ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) + null -> state.profileId?.value ?: "" + } + Text( + text = stringResource(R.string.infrared_mode_label, envText), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), + style = MaterialTheme.typography.bodyMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = state.deviceNameInput, + onValueChange = onDeviceNameChanged, + label = { Text(stringResource(R.string.infrared_device_name_label)) }, + placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag("infrared_device_name_input"), + ) + Button( + onClick = onSaveDeviceName, + modifier = Modifier.testTag("infrared_save_device_button"), + ) { + Text(stringResource(R.string.infrared_save_device)) + } + } + } + } + + // 已学习按键列表卡片 + Card(modifier = Modifier.fillMaxWidth().weight(1f)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.infrared_keys_title), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + Button( + onClick = { showLearnDialog = true }, + modifier = Modifier.testTag("infrared_learn_new_key_button"), + ) { + Text(stringResource(R.string.infrared_learn_new_key)) + } + } + + if (state.keys.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.infrared_no_keys), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.keys, key = { it.id.value }) { key -> + InfraredKeyRow( + key = key, + isSending = state.sendingKeyIds.contains(key.id), + onSend = { onSend(key.id) }, + onRetry = { onRetry(key.id) }, + onRename = { keyToRename = key }, + onDelete = { keyToDelete = key }, + ) + } + } + } + } + } + } + } + } + + if (showLearnDialog) { + var keyName by remember { mutableStateOf("") } + var keySlot by remember { mutableStateOf(nextSlot.toString()) } + + AlertDialog( + onDismissRequest = { showLearnDialog = false }, + title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.infrared_learn_dialog_prompt)) + OutlinedTextField( + value = keyName, + onValueChange = { keyName = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("infrared_learn_name_input"), + ) + OutlinedTextField( + value = keySlot, + onValueChange = { keySlot = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("infrared_learn_slot_input"), + ) + } + }, + confirmButton = { + Button( + onClick = { + onLearn(keyName, keySlot) + showLearnDialog = false + }, + enabled = keyName.isNotBlank() && keySlot.isNotBlank(), + modifier = Modifier.testTag("infrared_learn_dialog_confirm"), + ) { + Text(stringResource(R.string.infrared_learn_dialog_start)) + } + }, + dismissButton = { + TextButton(onClick = { showLearnDialog = false }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } + + keyToRename?.let { targetKey -> + var newName by remember { mutableStateOf(targetKey.displayName) } + AlertDialog( + onDismissRequest = { keyToRename = null }, + title = { Text(stringResource(R.string.infrared_rename_dialog_title)) }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("infrared_rename_input"), + ) + }, + confirmButton = { + Button( + onClick = { + onRename(targetKey.id, newName) + keyToRename = null + }, + enabled = newName.isNotBlank(), + modifier = Modifier.testTag("infrared_rename_confirm"), + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { keyToRename = null }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } + + keyToDelete?.let { targetKey -> + AlertDialog( + onDismissRequest = { keyToDelete = null }, + title = { Text(stringResource(R.string.infrared_delete_dialog_title)) }, + text = { Text(stringResource(R.string.infrared_delete_dialog_message)) }, + confirmButton = { + Button( + onClick = { + onDelete(targetKey.id) + keyToDelete = null + }, + modifier = Modifier.testTag("infrared_delete_confirm"), + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { keyToDelete = null }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } +} + +@Composable +private fun InfraredKeyRow( + key: SavedInfraredKey, + isSending: Boolean, + onSend: () -> Unit, + onRetry: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("infrared_key_${key.id.value}"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = key.displayName, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.infrared_key_slot_label, key.sdkKey), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + val statusLabel = when (key.status) { + InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning) + InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready) + InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed) + } + Text( + text = statusLabel, + style = MaterialTheme.typography.bodyMedium, + color = when (key.status) { + InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary + InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary + InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error + }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (key.status == InfraredKeyStatus.READY) { + Button( + onClick = onSend, + enabled = !isSending, + modifier = Modifier.testTag("infrared_send_${key.id.value}"), + ) { + if (isSending) { + Text(stringResource(R.string.infrared_sending)) + } else { + Text(stringResource(R.string.infrared_send)) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } + + if (key.status == InfraredKeyStatus.FAILED) { + Button( + onClick = onRetry, + modifier = Modifier.testTag("infrared_retry_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_retry)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + if (key.status != InfraredKeyStatus.LEARNING) { + OutlinedButton( + onClick = onRename, + modifier = Modifier.testTag("infrared_rename_btn_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_rename)) + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = onDelete, + modifier = Modifier.testTag("infrared_delete_btn_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_delete)) + } + } else { + CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) + } + } + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt new file mode 100644 index 0000000..1231724 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -0,0 +1,30 @@ +package com.flagship.abox.manager.infrared.ui + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.remote.UiText + +sealed interface InfraredUiMessage { + data class Info(val text: UiText) : InfraredUiMessage + data class Error(val text: UiText) : InfraredUiMessage + data object SessionInvalid : InfraredUiMessage +} + +data class InfraredControlUiState( + val profileId: ProviderProfileId? = null, + val providerType: ProviderType? = null, + val providerDisplayName: String? = null, + val deviceNameInput: String = "", + val savedDeviceName: DeviceName = DeviceName("ir-1"), + val keys: List = emptyList(), + val loading: Boolean = true, + val sendingKeyIds: Set = emptySet(), + val message: InfraredUiMessage? = null, +) { + val hasActiveLearning: Boolean + get() = keys.any { it.status == InfraredKeyStatus.LEARNING } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt new file mode 100644 index 0000000..4feed56 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -0,0 +1,307 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.ProviderProfileStore +import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +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.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.remote.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredControlViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val providerProfileStore: ProviderProfileStore, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, + private val renameInfraredKey: RenameInfraredKey, + private val deleteInfraredKey: DeleteInfraredKey, + private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val deviceNameInputFlow = MutableStateFlow("") + private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) + private val messageFlow = MutableStateFlow(null) + + init { + if (profileId != null) { + val startupCutoff = System.currentTimeMillis() + learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff) + } + } + + private data class ServerState( + val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null, + val savedDevice: DeviceName = DEFAULT_DEVICE_NAME, + val keys: List = emptyList(), + val loading: Boolean = false, + val configError: DomainError? = null, + ) + + private val serverStateFlow: Flow = + if (profileId == null) { + flowOf(ServerState(loading = false)) + } else { + providerProfileStore.observeProfiles().flatMapLatest { profiles -> + val active = profiles.find { it.id == profileId } + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Failure -> flowOf( + ServerState( + providerProfile = active, + configError = deviceResult.error, + ), + ) + is DomainResult.Success -> { + val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + keyRepository.observeKeys(profileId, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> ServerState( + providerProfile = active, + savedDevice = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> ServerState( + providerProfile = active, + savedDevice = deviceName, + keys = keysResult.value, + ) + } + } + } + } + } + } + } + + val state: StateFlow = combine( + serverStateFlow, + deviceNameInputFlow, + sendingKeyIdsFlow, + messageFlow, + ) { serverState, input, sendingIds, msg -> + val resolvedMessage = msg ?: serverState.configError?.let { error -> + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid + is DomainError.DeviceOffline -> InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> InfraredUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_load_failed), + ) + } + } + InfraredControlUiState( + profileId = profileId, + providerType = serverState.providerProfile?.type, + providerDisplayName = serverState.providerProfile?.displayName, + deviceNameInput = input, + savedDeviceName = serverState.savedDevice, + keys = serverState.keys, + loading = serverState.loading, + sendingKeyIds = sendingIds, + message = resolvedMessage, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredControlUiState(profileId = profileId, loading = true), + ) + + fun onDeviceNameChanged(value: String) { + deviceNameInputFlow.value = value + } + + fun saveDeviceName() { + val currentProfile = profileId ?: return + val trimmed = deviceNameInputFlow.value.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_name_blank), + ) + return + } + viewModelScope.launch { + when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_device_name_saved), + ) + deviceNameInputFlow.value = "" + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun nextAvailableKeySlot(): Int { + val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() + return (1..100).firstOrNull { it !in usedSlots } ?: 1 + } + + fun learnKey(displayName: String, keySlotInput: String? = null) { + val currentProfile = profileId ?: return + val savedDevice = state.value.savedDeviceName + val trimmedName = displayName.trim() + if (trimmedName.isBlank()) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_key_name_blank), + ) + return + } + val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) { + val slotInt = keySlotInput.trim().toIntOrNull() + if (slotInt == null || slotInt !in 1..100) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_slot_invalid), + ) + return + } + slotInt.toString() + } else { + nextAvailableKeySlot().toString() + } + learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) + } + + fun retryLearning(keyId: InfraredKeyId) { + val currentProfile = profileId ?: return + learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) + } + + fun sendKey(keyId: InfraredKeyId) { + val currentProfile = profileId ?: return + if (state.value.sendingKeyIds.contains(keyId)) return + + val key = state.value.keys.find { it.id == keyId } + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_key_not_ready), + ) + return + } + + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(key.displayName)), + ) + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId + } + } + } + + fun renameKey(keyId: InfraredKeyId, displayName: String) { + val currentProfile = profileId ?: return + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_key_name_blank), + ) + return + } + viewModelScope.launch { + when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_key_renamed), + ) + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun deleteKey(keyId: InfraredKeyId) { + val currentProfile = profileId ?: return + viewModelScope.launch { + when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_key_deleted), + ) + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = InfraredUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource( + R.string.infrared_msg_device_offline, + listOf(error.externalCode ?: "20206"), + ), + ) + } + else -> { + messageFlow.value = InfraredUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) + } + } + } + + companion object { + val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + } +} diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index ddf40ac..6a798f4 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -76,4 +76,15 @@ 电视-主页 电视-返回 电视-菜单 + 红外按键管理 + 运行环境:%1$s + 本地体验(模拟) + ABox(真实硬件) + 设备名称已保存 + 设备名称不能为空 + 按键名称不能为空 + 按键编号必须在 1 到 100 之间 + 按键已重命名 + 按键已从本机删除 + 按键尚未就绪,无法发射 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index a91667e..14c6be5 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -76,4 +76,15 @@ TV Home TV Back TV Menu + Infrared Key Management + Environment: %1$s + Local Experience (Simulation) + ABox (Real Hardware) + Device name saved + Device name cannot be blank + Key name cannot be blank + Key slot must be between 1 and 100 + Key renamed + Key removed locally + Key is not ready to send diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 60bd17b..2f0fe96 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -42,6 +42,7 @@ class InfraredRemoteViewModelTest { private val keyRepo = FakeInfraredKeyRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore() private val gateway = FakeInfraredGateway() private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) @@ -67,6 +68,7 @@ class InfraredRemoteViewModelTest { savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), keyRepository = keyRepo, deviceConfigurationRepository = deviceRepo, + providerProfileStore = profileStore, learningCoordinator = coordinator, sendInfraredKey = send, ) diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt new file mode 100644 index 0000000..aca8f9b --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -0,0 +1,276 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +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.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.remote.UiText +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredControlViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profileId = ProviderProfileId("profile1") + private val providerProfile = ProviderProfile( + id = profileId, + type = ProviderType.LOCAL_EXPERIENCE, + displayName = "Local Simulation", + ) + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepo = FakeInfraredKeyRepository() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val profileStore = FakeProviderProfileStore() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock) + + private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock) + private val send = SendInfraredKey(keyRepo, gateway) + private val rename = RenameInfraredKey(keyRepo, clock) + private val delete = DeleteInfraredKey(keyRepo) + private val recover = RecoverInterruptedInfraredLearning(keyRepo, clock) + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredControlViewModel { + return InfraredControlViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")), + keyRepository = keyRepo, + deviceConfigurationRepository = deviceRepo, + providerProfileStore = profileStore, + learningCoordinator = coordinator, + sendInfraredKey = send, + renameInfraredKey = rename, + deleteInfraredKey = delete, + recoverInterruptedInfraredLearning = recover, + ) + } + + @Test + fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType) + assertEquals(device, vm.state.value.savedDeviceName) + assertEquals(1, vm.state.value.keys.size) + assertEquals("Power", vm.state.value.keys.first().displayName) + + job.cancel() + } + + @Test + fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest { + profileStore.saveProfile(providerProfile) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged("ir-bedroom") + vm.saveDeviceName() + advanceUntilIdle() + + assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName) + assertEquals( + InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)), + vm.state.value.message, + ) + + job.cancel() + } + + @Test + fun blankDeviceNameEmitsErrorMessage() = testScope.runTest { + profileStore.saveProfile(providerProfile) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged(" ") + vm.saveDeviceName() + advanceUntilIdle() + + assertEquals( + InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)), + vm.state.value.message, + ) + + job.cancel() + } + + @Test + fun learnKeyTriggersCoordinator() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.learnKey("Volume Up", "5") + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("5", gateway.learnedKeys.first().key) + + job.cancel() + } + + @Test + fun sendKeyTriggersSendInfraredKeyWhenReady() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.sendKey(InfraredKeyId("k1")) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun renameKeyUpdatesKeyName() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.renameKey(InfraredKeyId("k1"), "Main Power") + advanceUntilIdle() + + val updated = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals("Main Power", updated.displayName) + + job.cancel() + } + + @Test + fun deleteKeyRemovesKey() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.deleteKey(InfraredKeyId("k1")) + advanceUntilIdle() + + val result = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value + assertEquals(null, result) + + job.cancel() + } +} -- 2.54.0 From 7f9fb8c2b6923ae6db45820bd8b371eac033efac Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:11:49 +0800 Subject: [PATCH 36/41] feat(infrared): refactor virtual remote hub with multi-device presets and quota management --- .../abox/manager/ui/ABoxManagerApp.kt | 25 +- .../infrared/data/local/InfraredDao.kt | 106 ++++ .../infrared/data/local/InfraredDatabase.kt | 6 +- .../infrared/data/local/InfraredEntities.kt | 49 ++ .../data/local/RoomVirtualRemoteRepository.kt | 213 ++++++++ .../infrared/di/InfraredFeatureModule.kt | 38 +- .../manager/infrared/hub/InfraredHubScreen.kt | 509 ++++++++++++++++++ .../infrared/hub/InfraredHubUiState.kt | 25 + .../infrared/hub/InfraredHubViewModel.kt | 198 +++++++ .../infrared/panel/RemotePanelScreen.kt | 499 +++++++++++++++++ .../infrared/panel/RemotePanelUiState.kt | 23 + .../infrared/panel/RemotePanelViewModel.kt | 187 +++++++ .../infrared/ui/InfraredControlScreen.kt | 4 +- .../infrared/ui/InfraredControlViewModel.kt | 31 +- .../src/main/res/values-zh-rCN/strings.xml | 79 ++- .../infrared/src/main/res/values/strings.xml | 21 + .../infrared/hub/InfraredHubViewModelTest.kt | 136 +++++ .../panel/RemotePanelViewModelTest.kt | 128 +++++ .../domain/infrared/AcCodeGenerator.kt | 52 ++ .../domain/infrared/CreateVirtualRemote.kt | 108 ++++ .../domain/infrared/DeleteVirtualRemote.kt | 13 + .../domain/infrared/PresetCodeLibrary.kt | 81 +++ .../infrared/SendVirtualRemoteAction.kt | 85 +++ .../domain/infrared/VirtualRemoteModels.kt | 93 ++++ .../infrared/VirtualRemoteRepository.kt | 56 ++ .../infrared/InMemoryInfraredTestDoubles.kt | 103 +++- .../infrared/VirtualRemoteUseCaseTest.kt | 119 ++++ .../testing/fakes/FakeDeviceGateways.kt | 3 + .../fakes/FakeVirtualRemoteRepository.kt | 126 +++++ 29 files changed, 3072 insertions(+), 44 deletions(-) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 257990d..5b6998e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -21,7 +21,8 @@ import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username -import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute +import com.flagship.abox.manager.infrared.hub.InfraredHubRoute +import com.flagship.abox.manager.infrared.panel.RemotePanelRoute import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute @@ -103,27 +104,43 @@ private fun AuthenticatedNavHost( val encodedProfileId = android.net.Uri.encode(profile.id.value) DeviceListScreen( onInfraredClick = { - navController.navigate("infrared_remote/$encodedProfileId") + navController.navigate("infrared_hub/$encodedProfileId") }, ) }, ) } composable( - route = "infrared_remote/{profileId}", + route = "infrared_hub/{profileId}", arguments = listOf( navArgument("profileId") { type = NavType.StringType }, ), ) { val encodedProfileId = android.net.Uri.encode(profile.id.value) - InfraredRemoteRoute( + InfraredHubRoute( onBack = { navController.popBackStack() }, + onOpenRemote = { remoteId -> + val encodedRemoteId = android.net.Uri.encode(remoteId) + navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId") + }, onOpenSettings = { navController.navigate("infrared_settings/$encodedProfileId") }, onSessionInvalid = onLogout, ) } + composable( + route = "infrared_panel/{profileId}/{remoteId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("remoteId") { type = NavType.StringType }, + ), + ) { + RemotePanelRoute( + onBack = { navController.popBackStack() }, + onSessionInvalid = onLogout, + ) + } composable( route = "infrared_settings/{profileId}", arguments = listOf( 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 index 3d24b10..98e402f 100644 --- 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 @@ -105,4 +105,110 @@ interface InfraredDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity) + + // Virtual Remotes + @Query( + """ + SELECT * FROM virtual_remotes + WHERE profileId = :profileId AND deviceName = :deviceName + ORDER BY createdAtEpochMillis DESC + """, + ) + fun observeVirtualRemotes(profileId: String, deviceName: String): Flow> + + @Query( + """ + SELECT * FROM virtual_remotes + WHERE profileId = :profileId AND id = :id + LIMIT 1 + """, + ) + fun observeVirtualRemote(profileId: String, id: String): Flow + + @Query( + """ + SELECT * FROM virtual_remotes + WHERE profileId = :profileId AND id = :id + LIMIT 1 + """, + ) + suspend fun getVirtualRemote(profileId: String, id: String): VirtualRemoteEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVirtualRemote(entity: VirtualRemoteEntity) + + @Query( + """ + DELETE FROM virtual_remotes + WHERE profileId = :profileId AND id = :id + """, + ) + suspend fun deleteVirtualRemote(profileId: String, id: String): Int + + @Query( + """ + UPDATE virtual_remotes + SET acPower = :power, + acMode = :mode, + acTemperature = :temperature, + acFanSpeed = :fanSpeed, + acVerticalSwing = :verticalSwing, + acHorizontalSwing = :horizontalSwing, + acEco = :eco, + updatedAtEpochMillis = :updatedAtEpochMillis + WHERE profileId = :profileId AND id = :id + """, + ) + suspend fun updateAcState( + profileId: String, + id: String, + power: Boolean?, + mode: String?, + temperature: Int?, + fanSpeed: String?, + verticalSwing: Boolean?, + horizontalSwing: Boolean?, + eco: Boolean?, + updatedAtEpochMillis: Long, + ): Int + + // Remote Key Bindings + @Query( + """ + SELECT * FROM remote_key_bindings + WHERE remoteId = :remoteId + """, + ) + fun observeKeyBindings(remoteId: String): Flow> + + @Query( + """ + SELECT * FROM remote_key_bindings + WHERE remoteId = :remoteId + """, + ) + suspend fun getKeyBindings(remoteId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertKeyBindings(entities: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertKeyBinding(entity: RemoteKeyBindingEntity) + + @Query( + """ + DELETE FROM remote_key_bindings + WHERE remoteId = :remoteId + """, + ) + suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int + + @Query( + """ + SELECT b.assignedSlot FROM remote_key_bindings b + INNER JOIN virtual_remotes r ON b.remoteId = r.id + WHERE r.profileId = :profileId AND r.deviceName = :deviceName AND b.assignedSlot IS NOT NULL + """, + ) + fun observeAssignedStaticSlots(profileId: String, deviceName: String): Flow> } 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 index c8fb736..af179fd 100644 --- 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 @@ -7,9 +7,11 @@ import androidx.room.RoomDatabase entities = [ InfraredKeyEntity::class, InfraredDeviceConfigurationEntity::class, + VirtualRemoteEntity::class, + RemoteKeyBindingEntity::class, ], - version = 1, - exportSchema = true, + version = 2, + exportSchema = false, ) 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 index 63b1fb9..1ae92d2 100644 --- 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 @@ -31,3 +31,52 @@ data class InfraredDeviceConfigurationEntity( val profileId: String, val deviceName: String, ) + +@Entity( + tableName = "virtual_remotes", + indices = [ + Index(value = ["profileId", "deviceName"]), + ], +) +data class VirtualRemoteEntity( + @PrimaryKey + val id: String, + val profileId: String, + val deviceName: String, + val displayName: String, + val type: String, + val brandModel: String?, + val transmissionMode: String, + val dynamicScratchSlot: String, + val acPower: Boolean?, + val acMode: String?, + val acTemperature: Int?, + val acFanSpeed: String?, + val acVerticalSwing: Boolean?, + val acHorizontalSwing: Boolean?, + val acEco: Boolean?, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) + +@Entity( + tableName = "remote_key_bindings", + indices = [ + Index(value = ["remoteId"]), + Index(value = ["profileId"]), + ], +) +data class RemoteKeyBindingEntity( + @PrimaryKey + val id: String, + val remoteId: String, + val profileId: String, + val buttonId: String, + val displayName: String, + val assignedSlot: String?, + val rawHexCode: String?, + val status: String, + val lastErrorCode: String?, + val lastErrorDetail: String?, +) + diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt new file mode 100644 index 0000000..de36ae2 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -0,0 +1,213 @@ +package com.flagship.abox.manager.infrared.data.local + +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.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class RoomVirtualRemoteRepository @Inject constructor( + private val dao: InfraredDao, +) : VirtualRemoteRepository { + + override fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = + dao.observeVirtualRemotes(profileId.value, deviceName.value).map { entities -> + DomainResult.Success(entities.map { it.toDomain() }) + } + + override fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> = + dao.observeVirtualRemote(profileId.value, remoteId.value).map { entity -> + DomainResult.Success(entity?.toDomain()) + } + + override fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> = + dao.observeKeyBindings(remoteId.value).map { entities -> + DomainResult.Success(entities.map { it.toDomain() }) + } + + override fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> = + dao.observeAssignedStaticSlots(profileId.value, deviceName.value).map { slots -> + val slotInts = slots.mapNotNull { it.toIntOrNull() }.toSet() + DomainResult.Success( + SlotQuotaSummary( + totalCapacity = 100, + dynamicScratchSlot = 100, + assignedStaticSlots = slotInts, + ), + ) + } + + override suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = try { + val entity = dao.getVirtualRemote(profileId.value, remoteId.value) + DomainResult.Success(entity?.toDomain()) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> = try { + val list = dao.getKeyBindings(remoteId.value) + DomainResult.Success(list.map { it.toDomain() }) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult = try { + dao.insertVirtualRemote(remote.toEntity()) + dao.insertKeyBindings(initialBindings.map { it.toEntity() }) + DomainResult.Success(remote.id) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun updateRemote(remote: VirtualRemote): DomainResult = try { + dao.insertVirtualRemote(remote.toEntity()) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = try { + dao.deleteVirtualRemote(profileId.value, remoteId.value) + dao.deleteKeyBindingsByRemoteId(remoteId.value) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult = try { + dao.insertKeyBinding(binding.toEntity()) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult = try { + dao.updateAcState( + profileId = profileId.value, + id = remoteId.value, + power = state.power, + mode = state.mode.name, + temperature = state.temperature, + fanSpeed = state.fanSpeed.name, + verticalSwing = state.verticalSwing, + horizontalSwing = state.horizontalSwing, + eco = state.eco, + updatedAtEpochMillis = System.currentTimeMillis(), + ) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } +} + +private fun VirtualRemoteEntity.toDomain(): VirtualRemote { + val ac = if (type == RemoteDeviceType.AIR_CONDITIONER.name) { + AcStateSnapshot( + power = acPower ?: true, + mode = acMode?.let { runCatching { AcMode.valueOf(it) }.getOrNull() } ?: AcMode.COOL, + temperature = acTemperature ?: 26, + fanSpeed = acFanSpeed?.let { runCatching { AcFanSpeed.valueOf(it) }.getOrNull() } ?: AcFanSpeed.AUTO, + verticalSwing = acVerticalSwing ?: true, + horizontalSwing = acHorizontalSwing ?: false, + eco = acEco ?: false, + ) + } else null + + return VirtualRemote( + id = VirtualRemoteId(id), + profileId = ProviderProfileId(profileId), + deviceName = DeviceName(deviceName), + displayName = displayName, + type = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM), + brandModel = brandModel, + transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH), + dynamicScratchSlot = dynamicScratchSlot, + acState = ac, + createdAtEpochMillis = createdAtEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, + ) +} + +private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity( + id = id.value, + profileId = profileId.value, + deviceName = deviceName.value, + displayName = displayName, + type = type.name, + brandModel = brandModel, + transmissionMode = transmissionMode.name, + dynamicScratchSlot = dynamicScratchSlot, + acPower = acState?.power, + acMode = acState?.mode?.name, + acTemperature = acState?.temperature, + acFanSpeed = acState?.fanSpeed?.name, + acVerticalSwing = acState?.verticalSwing, + acHorizontalSwing = acState?.horizontalSwing, + acEco = acState?.eco, + createdAtEpochMillis = createdAtEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, +) + +private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBinding( + id = id, + remoteId = VirtualRemoteId(remoteId), + profileId = ProviderProfileId(profileId), + buttonId = buttonId, + displayName = displayName, + assignedSlot = assignedSlot, + rawHexCode = rawHexCode, + status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY), + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, +) + +private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity( + id = id, + remoteId = remoteId.value, + profileId = profileId.value, + buttonId = buttonId, + displayName = displayName, + assignedSlot = assignedSlot, + rawHexCode = rawHexCode, + status = status.name, + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 5237989..c0baa4d 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -19,6 +19,7 @@ import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinato import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository +import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -48,7 +49,7 @@ object InfraredFeatureModule { context, InfraredDatabase::class.java, "abox_infrared.db", - ).build() + ).fallbackToDestructiveMigration().build() } @Provides @@ -161,4 +162,39 @@ object InfraredFeatureModule { keyRepository = repository, externalScope = scope, ) + + @Provides + @Singleton + fun provideRoomVirtualRemoteRepository(dao: InfraredDao): RoomVirtualRemoteRepository = + RoomVirtualRemoteRepository(dao) + + @Provides + @Singleton + fun provideVirtualRemoteRepository( + repository: RoomVirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository = repository + + @Provides + @Singleton + fun provideCreateVirtualRemote( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + gateway: InfraredGateway, + clock: EpochMillisClock, + ): com.flagship.abox.manager.domain.infrared.CreateVirtualRemote = + com.flagship.abox.manager.domain.infrared.CreateVirtualRemote(repository, gateway, clock) + + @Provides + @Singleton + fun provideSendVirtualRemoteAction( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + gateway: InfraredGateway, + ): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction = + com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway) + + @Provides + @Singleton + fun provideDeleteVirtualRemote( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote = + com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt new file mode 100644 index 0000000..4498cc8 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -0,0 +1,509 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.PresetBrandOption +import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText + +@Composable +fun InfraredHubRoute( + onBack: () -> Unit, + onOpenRemote: (String) -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredHubViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredHubScreen( + state = state, + onBack = onBack, + onOpenRemote = onOpenRemote, + onOpenSettings = onOpenSettings, + onSessionInvalid = onSessionInvalid, + onCreateRemote = viewModel::createRemote, + onDeleteRemote = viewModel::deleteRemote, + onSaveDeviceName = viewModel::saveDeviceName, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredHubScreen( + state: InfraredHubUiState, + onBack: () -> Unit, + onOpenRemote: (String) -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode) -> Unit, + onDeleteRemote: (VirtualRemoteId) -> Unit, + onSaveDeviceName: (String) -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + var showCreateDialog by remember { mutableStateOf(false) } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is HubUiMessage.Info -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is HubUiMessage.Error -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is HubUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.infrared_hub_title), fontWeight = FontWeight.Bold) }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + actions = { + IconButton( + onClick = onOpenSettings, + modifier = Modifier.testTag("hub_settings_button"), + ) { + Text("⚙️", fontSize = 20.sp) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 1. 顶部环境与 100 槽位看板卡片 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val envText = when (state.providerType) { + ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) + ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) + null -> state.profileId?.value ?: "" + } + Text( + text = stringResource(R.string.infrared_mode_label, envText), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), + style = MaterialTheme.typography.bodyMedium, + ) + + // 槽位配额指示条 + val progress = state.slotQuota.usedCount.toFloat() / state.slotQuota.totalCapacity + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource( + R.string.infrared_hub_slot_quota, + state.slotQuota.usedCount, + state.slotQuota.totalCapacity, + state.slotQuota.remainingStaticCount, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.infrared_hub_dynamic_scratch, "100"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Medium, + ) + } + } + } + + // 2. 虚拟遥控器列表标题栏 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.infrared_hub_remotes_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + Button( + onClick = { showCreateDialog = true }, + modifier = Modifier.testTag("hub_add_remote_button"), + ) { + Text("➕ " + stringResource(R.string.infrared_hub_add_remote)) + } + } + + // 3. 遥控器网格卡片 + if (state.remotes.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.infrared_hub_no_remotes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth().weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.remotes, key = { it.id.value }) { remote -> + VirtualRemoteCard( + remote = remote, + onClick = { onOpenRemote(remote.id.value) }, + onDelete = { onDeleteRemote(remote.id) }, + ) + } + } + } + } + } + } + + if (showCreateDialog) { + CreateVirtualRemoteDialog( + onDismiss = { showCreateDialog = false }, + onConfirm = { name, type, brandModel, transMode -> + onCreateRemote(name, type, brandModel, transMode) + showCreateDialog = false + }, + ) + } +} + +@Composable +private fun VirtualRemoteCard( + remote: VirtualRemote, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag("remote_card_${remote.id.value}"), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = when (remote.type) { + RemoteDeviceType.TV -> "📺" + RemoteDeviceType.AIR_CONDITIONER -> "❄️" + RemoteDeviceType.FAN -> "🌀" + RemoteDeviceType.CUSTOM -> "🎛️" + } + Text(icon, fontSize = 28.sp) + IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) { + Text("🗑️", fontSize = 16.sp) + } + } + + Text( + text = remote.displayName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + + val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { + stringResource(R.string.infrared_hub_mode_dynamic, "100") + } else when (remote.transmissionMode) { + TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_hub_mode_static, "15") + TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_hub_mode_dynamic, "100") + } + Text( + text = modeText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateVirtualRemoteDialog( + onDismiss: () -> Unit, + onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, mode: TransmissionMode) -> Unit, +) { + var name by remember { mutableStateOf("") } + var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) } + var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) } + var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) } + + var typeDropdownExpanded by remember { mutableStateOf(false) } + var brandDropdownExpanded by remember { mutableStateOf(false) } + + val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // 1. 设备类型选择 + ExposedDropdownMenuBox( + expanded = typeDropdownExpanded, + onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded }, + ) { + OutlinedTextField( + value = when (selectedType) { + RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv) + RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac) + RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan) + RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom) + }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.infrared_hub_device_type_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = typeDropdownExpanded, + onDismissRequest = { typeDropdownExpanded = false }, + ) { + RemoteDeviceType.entries.forEach { type -> + val label = when (type) { + RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv) + RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac) + RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan) + RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom) + } + DropdownMenuItem( + text = { Text(label) }, + onClick = { + selectedType = type + val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type } + if (matched != null) selectedBrand = matched + if (type == RemoteDeviceType.AIR_CONDITIONER) { + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH + } + typeDropdownExpanded = false + }, + ) + } + } + } + + // 2. 遥控器名称输入 + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("遥控器名称(如:客厅电视)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), + ) + + // 3. 品牌 / 码库预设 + if (filteredPresets.isNotEmpty()) { + ExposedDropdownMenuBox( + expanded = brandDropdownExpanded, + onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded }, + ) { + OutlinedTextField( + value = selectedBrand.displayName, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.infrared_hub_brand_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = brandDropdownExpanded, + onDismissRequest = { brandDropdownExpanded = false }, + ) { + filteredPresets.forEach { preset -> + DropdownMenuItem( + text = { Text(preset.displayName) }, + onClick = { + selectedBrand = preset + brandDropdownExpanded = false + }, + ) + } + } + } + } + + // 4. 传输模式选择(非空调设备可选) + if (selectedType != RemoteDeviceType.AIR_CONDITIONER) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS }, + ) { + RadioButton( + selected = transmissionMode == TransmissionMode.STATIC_SLOTS, + onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS }, + ) + Column { + Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium) + Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, + ) { + RadioButton( + selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH, + onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, + ) + Column { + Text("📦 动态写码模式", fontWeight = FontWeight.Medium) + Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } else { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "❄️ 空调多维状态(温度、扫风、风速)自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", + fontSize = 12.sp, + modifier = Modifier.padding(10.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + val finalName = if (name.isNotBlank()) name.trim() else selectedBrand.displayName + onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode) + }, + modifier = Modifier.testTag("create_remote_confirm_button"), + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt new file mode 100644 index 0000000..0599c8c --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.infrared.hub + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.infrared.remote.UiText + +sealed interface HubUiMessage { + data class Info(val text: UiText) : HubUiMessage + data class Error(val text: UiText) : HubUiMessage + data object SessionInvalid : HubUiMessage +} + +data class InfraredHubUiState( + val profileId: ProviderProfileId? = null, + val providerType: ProviderType? = null, + val providerDisplayName: String? = null, + val savedDeviceName: DeviceName = DeviceName("ir-1"), + val remotes: List = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val loading: Boolean = true, + val message: HubUiMessage? = null, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt new file mode 100644 index 0000000..579c57f --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -0,0 +1,198 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredHubViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val providerProfileStore: ProviderProfileStore, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val virtualRemoteRepository: VirtualRemoteRepository, + private val createVirtualRemote: CreateVirtualRemote, + private val deleteVirtualRemote: DeleteVirtualRemote, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val messageFlow = MutableStateFlow(null) + + private data class HubSnapshot( + val providerProfile: ProviderProfile? = null, + val savedDevice: DeviceName = DEFAULT_DEVICE_NAME, + val remotes: List = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val configError: DomainError? = null, + ) + + private val hubSnapshotFlow: Flow = + if (profileId == null) { + flowOf(HubSnapshot()) + } else { + providerProfileStore.observeProfiles().flatMapLatest { profiles -> + val active = profiles.find { it.id == profileId } + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { devResult -> + when (devResult) { + is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error)) + is DomainResult.Success -> { + val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME + combine( + virtualRemoteRepository.observeRemotes(profileId, deviceName), + virtualRemoteRepository.observeSlotQuota(profileId, deviceName), + ) { remotesResult, quotaResult -> + val remotes = if (remotesResult is DomainResult.Success) remotesResult.value else emptyList() + val quota = if (quotaResult is DomainResult.Success) quotaResult.value else SlotQuotaSummary() + HubSnapshot( + providerProfile = active, + savedDevice = deviceName, + remotes = remotes, + slotQuota = quota, + ) + } + } + } + } + } + } + + val state: StateFlow = combine( + hubSnapshotFlow, + messageFlow, + ) { snapshot, msg -> + val resolvedMessage = msg ?: snapshot.configError?.let { error -> + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid + is DomainError.DeviceOffline -> HubUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> HubUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_load_failed), + ) + } + } + + InfraredHubUiState( + profileId = profileId, + providerType = snapshot.providerProfile?.type, + providerDisplayName = snapshot.providerProfile?.displayName, + savedDeviceName = snapshot.savedDevice, + remotes = snapshot.remotes, + slotQuota = snapshot.slotQuota, + loading = false, + message = resolvedMessage, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredHubUiState(profileId = profileId, loading = true), + ) + + fun createRemote( + name: String, + type: RemoteDeviceType, + brandModel: String? = null, + transmissionMode: TransmissionMode, + ) { + val currentProfile = profileId ?: return + val currentDevice = state.value.savedDeviceName + viewModelScope.launch { + when (val res = createVirtualRemote( + profileId = currentProfile, + deviceName = currentDevice, + displayName = name, + type = type, + brandModel = brandModel, + transmissionMode = transmissionMode, + )) { + is DomainResult.Success -> { + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun deleteRemote(remoteId: VirtualRemoteId) { + val currentProfile = profileId ?: return + viewModelScope.launch { + when (val res = deleteVirtualRemote(currentProfile, remoteId)) { + is DomainResult.Success -> { + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器已删除")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun saveDeviceName(newName: String) { + val currentProfile = profileId ?: return + val trimmed = newName.trim() + if (trimmed.isBlank()) { + messageFlow.value = HubUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)) + return + } + viewModelScope.launch { + when (val res = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { + is DomainResult.Success -> { + messageFlow.value = HubUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid + is DomainError.DeviceOffline -> messageFlow.value = HubUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> messageFlow.value = HubUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) + } + } + + companion object { + val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt new file mode 100644 index 0000000..b1441bb --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -0,0 +1,499 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText + +@Composable +fun RemotePanelRoute( + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: RemotePanelViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + RemotePanelScreen( + state = state, + onBack = onBack, + onSessionInvalid = onSessionInvalid, + onButtonClick = viewModel::onButtonClick, + onToggleAcPower = viewModel::toggleAcPower, + onAdjustAcTemp = viewModel::adjustAcTemperature, + onSetAcMode = viewModel::setAcMode, + onSetAcFanSpeed = viewModel::setAcFanSpeed, + onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing, + onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing, + onToggleAcEco = viewModel::toggleAcEco, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RemotePanelScreen( + state: RemotePanelUiState, + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + onButtonClick: (String) -> Unit, + onToggleAcPower: () -> Unit, + onAdjustAcTemp: (Int) -> Unit, + onSetAcMode: (AcMode) -> Unit, + onSetAcFanSpeed: (AcFanSpeed) -> Unit, + onToggleAcVerticalSwing: () -> Unit, + onToggleAcHorizontalSwing: () -> Unit, + onToggleAcEco: () -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is PanelUiMessage.Info -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is PanelUiMessage.Error -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is PanelUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + val remote = state.remote + if (remote == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (remote.type) { + RemoteDeviceType.AIR_CONDITIONER -> { + AcControlPanel( + acState = state.acState, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetAcFanSpeed, + onToggleVSwing = onToggleAcVerticalSwing, + onToggleHSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + ) + } + RemoteDeviceType.TV -> { + TvControlPanel( + bindings = state.bindings, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + } + RemoteDeviceType.FAN, RemoteDeviceType.CUSTOM -> { + GenericControlPanel( + bindings = state.bindings, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + } + } + } + } + } +} + +@Composable +private fun AcControlPanel( + acState: AcStateSnapshot, + onTogglePower: () -> Unit, + onAdjustTemp: (Int) -> Unit, + onSetMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleVSwing: () -> Unit, + onToggleHSwing: () -> Unit, + onToggleEco: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 1. 状态看板与大温度指示器 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (acState.power) "● 运行中" else "○ 已关机", + color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + IconButton( + onClick = onTogglePower, + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline) + .testTag("ac_power_toggle"), + ) { + Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + } + + Text( + text = "${acState.temperature}°C", + fontSize = 64.sp, + fontWeight = FontWeight.Black, + color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // 调温大按键 + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { onAdjustTemp(-1) }, + modifier = Modifier.size(56.dp).testTag("ac_temp_down"), + shape = CircleShape, + ) { + Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold) + } + Button( + onClick = { onAdjustTemp(1) }, + modifier = Modifier.size(56.dp).testTag("ac_temp_up"), + shape = CircleShape, + ) { + Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold) + } + } + } + } + + // 2. 模式切换 + Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AcMode.entries.forEach { mode -> + val selected = acState.mode == mode + FilterChip( + selected = selected, + onClick = { onSetMode(mode) }, + label = { Text(mode.name) }, + modifier = Modifier.weight(1f), + ) + } + } + + // 3. 风速切换 + Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AcFanSpeed.entries.forEach { speed -> + val selected = acState.fanSpeed == speed + FilterChip( + selected = selected, + onClick = { onSetFanSpeed(speed) }, + label = { Text(speed.name) }, + modifier = Modifier.weight(1f), + ) + } + } + + // 4. 功能开关 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = acState.verticalSwing, + onClick = onToggleVSwing, + label = { Text("↕ 上下扫风") }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = acState.horizontalSwing, + onClick = onToggleHSwing, + label = { Text("↔ 左右扫风") }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = acState.eco, + onClick = onToggleEco, + label = { Text("🌱 节能 ECO") }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TvControlPanel( + bindings: List, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 顶部控制行 (电源、静音、信号源) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + TvActionButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), onButtonClick, sendingButtonId) + TvActionButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) + TvActionButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 中部五向方向导航盘 + Card( + shape = CircleShape, + modifier = Modifier.size(220.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // 上 + IconButton( + onClick = { onButtonClick("TV_NAV_UP") }, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), + ) { Text("▲", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // 下 + IconButton( + onClick = { onButtonClick("TV_NAV_DOWN") }, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), + ) { Text("▼", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // 左 + IconButton( + onClick = { onButtonClick("TV_NAV_LEFT") }, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), + ) { Text("◀", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // 右 + IconButton( + onClick = { onButtonClick("TV_NAV_RIGHT") }, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), + ) { Text("▶", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // OK 居中大按钮 + Button( + onClick = { onButtonClick("TV_OK") }, + shape = CircleShape, + modifier = Modifier.size(76.dp).testTag("tv_ok"), + ) { + Text("OK", fontWeight = FontWeight.Black, fontSize = 18.sp) + } + } + } + + // 辅助按键行 (返回、主页、菜单) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + ) { + OutlinedButton(onClick = { onButtonClick("TV_BACK") }) { Text("↩ 返回") } + OutlinedButton(onClick = { onButtonClick("TV_HOME") }) { Text("⌂ 主页") } + OutlinedButton(onClick = { onButtonClick("TV_MENU") }) { Text("☰ 菜单") } + } + + // 底部音量与频道调节柱 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) + Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("+", fontSize = 20.sp) } + Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("-", fontSize = 20.sp) } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) + Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▲", fontSize = 16.sp) } + Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▼", fontSize = 16.sp) } + } + } + } +} + +@Composable +private fun TvActionButton( + buttonId: String, + label: String, + containerColor: Color, + onButtonClick: (String) -> Unit, + sendingButtonId: String?, +) { + Button( + onClick = { onButtonClick(buttonId) }, + colors = ButtonDefaults.buttonColors(containerColor = containerColor), + modifier = Modifier.testTag("tv_btn_$buttonId"), + ) { + if (sendingButtonId == buttonId) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) + } else { + Text(label, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun GenericControlPanel( + bindings: List, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(bindings, key = { it.id }) { binding -> + Button( + onClick = { onButtonClick(binding.buttonId) }, + modifier = Modifier.fillMaxWidth().height(64.dp).testTag("generic_btn_${binding.buttonId}"), + ) { + if (sendingButtonId == binding.buttonId) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) + } else { + Text(binding.displayName, fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + } + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt new file mode 100644 index 0000000..4252e4e --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt @@ -0,0 +1,23 @@ +package com.flagship.abox.manager.infrared.panel + +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.infrared.remote.UiText + +sealed interface PanelUiMessage { + data class Info(val text: UiText) : PanelUiMessage + data class Error(val text: UiText) : PanelUiMessage + data object SessionInvalid : PanelUiMessage +} + +data class RemotePanelUiState( + val profileId: ProviderProfileId? = null, + val remote: VirtualRemote? = null, + val bindings: List = emptyList(), + val acState: AcStateSnapshot = AcStateSnapshot(), + val sendingButtonId: String? = null, + val loading: Boolean = true, + val message: PanelUiMessage? = null, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt new file mode 100644 index 0000000..1333c6b --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -0,0 +1,187 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class RemotePanelViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val virtualRemoteRepository: VirtualRemoteRepository, + private val sendVirtualRemoteAction: SendVirtualRemoteAction, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val rawRemoteId: String? = savedStateHandle["remoteId"] + val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) } + + private val sendingButtonIdFlow = MutableStateFlow(null) + private val localAcStateFlow = MutableStateFlow(null) + private val messageFlow = MutableStateFlow(null) + private var acDebounceJob: Job? = null + + private val remoteFlow = if (profileId == null || remoteId == null) { + flowOf(null) + } else { + virtualRemoteRepository.observeRemote(profileId, remoteId).flatMapLatest { res -> + val remote = if (res is DomainResult.Success) res.value else null + flowOf(remote) + } + } + + private val bindingsFlow = if (remoteId == null) { + flowOf(emptyList()) + } else { + virtualRemoteRepository.observeKeyBindings(remoteId).flatMapLatest { res -> + val list = if (res is DomainResult.Success) res.value else emptyList() + flowOf(list) + } + } + + val state: StateFlow = combine( + remoteFlow, + bindingsFlow, + localAcStateFlow, + sendingButtonIdFlow, + messageFlow, + ) { remote, bindings, localAc, sendingBtn, msg -> + val resolvedAc = localAc ?: remote?.acState ?: AcStateSnapshot() + RemotePanelUiState( + profileId = profileId, + remote = remote, + bindings = bindings, + acState = resolvedAc, + sendingButtonId = sendingBtn, + loading = remote == null, + message = msg, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RemotePanelUiState(profileId = profileId, loading = true), + ) + + fun onButtonClick(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + if (state.value.sendingButtonId != null) return + + sendingButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val res = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(buttonId))) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } finally { + sendingButtonIdFlow.value = null + } + } + } + + fun toggleAcPower() { + val current = state.value.acState + updateAndDebounceAc(current.copy(power = !current.power)) + } + + fun adjustAcTemperature(delta: Int) { + val current = state.value.acState + val target = (current.temperature + delta).coerceIn(16, 30) + if (target != current.temperature) { + updateAndDebounceAc(current.copy(temperature = target)) + } + } + + fun setAcMode(mode: AcMode) { + val current = state.value.acState + updateAndDebounceAc(current.copy(mode = mode)) + } + + fun setAcFanSpeed(fanSpeed: AcFanSpeed) { + val current = state.value.acState + updateAndDebounceAc(current.copy(fanSpeed = fanSpeed)) + } + + fun toggleAcVerticalSwing() { + val current = state.value.acState + updateAndDebounceAc(current.copy(verticalSwing = !current.verticalSwing)) + } + + fun toggleAcHorizontalSwing() { + val current = state.value.acState + updateAndDebounceAc(current.copy(horizontalSwing = !current.horizontalSwing)) + } + + fun toggleAcEco() { + val current = state.value.acState + updateAndDebounceAc(current.copy(eco = !current.eco)) + } + + private fun updateAndDebounceAc(newState: AcStateSnapshot) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + + // 1. Optimistic UI update + localAcStateFlow.value = newState + + // 2. Debounce transmission (300ms) + acDebounceJob?.cancel() + acDebounceJob = viewModelScope.launch { + delay(300L) + when (val res = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已同步空调状态:${newState.temperature}℃ | ${newState.mode.name}")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = PanelUiMessage.SessionInvalid + is DomainError.DeviceOffline -> messageFlow.value = PanelUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> messageFlow.value = PanelUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index 3ede863..e0b06db 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -87,7 +87,7 @@ internal fun InfraredControlScreen( onRename: (InfraredKeyId, String) -> Unit, onDelete: (InfraredKeyId) -> Unit, onMessageShown: () -> Unit, - nextSlot: Int, + nextSlot: Int?, ) { val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -259,7 +259,7 @@ internal fun InfraredControlScreen( if (showLearnDialog) { var keyName by remember { mutableStateOf("") } - var keySlot by remember { mutableStateOf(nextSlot.toString()) } + var keySlot by remember { mutableStateOf(nextSlot?.toString() ?: "") } AlertDialog( onDismissRequest = { showLearnDialog = false }, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 4feed56..42e4375 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -170,9 +170,9 @@ class InfraredControlViewModel @Inject constructor( } } - fun nextAvailableKeySlot(): Int { + fun nextAvailableKeySlot(): Int? { val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() - return (1..100).firstOrNull { it !in usedSlots } ?: 1 + return (1..100).firstOrNull { it !in usedSlots } } fun learnKey(displayName: String, keySlotInput: String? = null) { @@ -195,14 +195,35 @@ class InfraredControlViewModel @Inject constructor( } slotInt.toString() } else { - nextAvailableKeySlot().toString() + val autoSlot = nextAvailableKeySlot() + if (autoSlot == null) { + messageFlow.value = InfraredUiMessage.Error( + UiText.DynamicString("硬件按键槽位已满(上限 100 个),无法自动分配槽位"), + ) + return + } + autoSlot.toString() + } + viewModelScope.launch { + when (val res = learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString).await()) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_learned_success, listOf(trimmedName)), + ) + } + is DomainResult.Failure -> handleDomainError(res.error) + } } - learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) } fun retryLearning(keyId: InfraredKeyId) { val currentProfile = profileId ?: return - learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) + viewModelScope.launch { + when (val res = learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)).await()) { + is DomainResult.Success -> Unit + is DomainResult.Failure -> handleDomainError(res.error) + } + } } fun sendKey(keyId: InfraredKeyId) { diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index 6a798f4..c5be839 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -1,62 +1,62 @@ - 红外控制 - 模式:模拟 / Fake - 模式:真实 ABSDK + 红外遥控 + 模式:模拟体验 + 模式:ABBox 真实硬件 切换模式 红外设备名称 例如:ir-living - 已保存设备:%1$s + 已配置设备:%1$s 未配置红外设备 - 保存设备名 + 保存设备 已学习按键 - 暂无保存的红外按键 + 暂无已保存按键 学习新按键 学习中… - 就绪 - 失败 + 已就绪 + 学习失败 发射 发射中… 重试 重命名 删除 学习红外按键 - 请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。 + 请准备好实体遥控器,点击开始后将遥控器对准红外转发器并按下目标按键。 按键名称(如:电源) - 按键编号(1-100) + 按键槽位(1-100) 按键编号:%1$s 开始学习 重命名按键 新按键名称 删除按键 - 仅从本机列表移除,ABox 服务端可能仍保留该按键。 + 仅从本机删除该按键记录,ABox 主机上可能仍保留已录制码值。 取消 确认 返回 红外遥控器 录制中 学习模式 - 🔴 红外学习模式已开启:点击任意按键即可录制信号 - ❄️ 空调遥控 - 📺 电视遥控 + 🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号 + ❄️ 空调面板 + 📺 电视面板 设置 - 🔴 学习录制模式已开启:点击任意按键即可开始录制 - 正在录制【%1$s】(Slot %2$s),请按遥控器按键... + 🔴 学习模式已开启:点击任意按键开始录制 + 正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键… 【%1$s】录制成功 - 按键【%1$s】尚未学习,请开启右上角学习模式进行录制 - 已发射: %1$s - 红外控制器离线 (错误码: %1$s) - 数据加载失败 + 按键【%1$s】尚未学习,开启右上角学习模式即可录制 + 已发射信号:%1$s + 红外设备离线(错误码:%1$s) + 加载数据失败 操作失败 温度调节 - 音量 VOL - 频道 CH + 音量 + 频道 ❄️ 制冷模式 - 💨 自动风速 | ↔ 扫风 + 💨 自动风速 | ↔ 自动扫风 空调-电源 - 空调-温度+ - 空调-温度- + 空调-升温 + 空调-降温 空调-模式 空调-风速 空调-扫风 @@ -64,10 +64,10 @@ 电视-电源 电视-静音 - 电视-上 - 电视-下 - 电视-左 - 电视-右 + 电视-向上 + 电视-向下 + 电视-向左 + 电视-向右 电视-确定 电视-音量+ 电视-音量- @@ -87,4 +87,25 @@ 按键已重命名 按键已从本机删除 按键尚未就绪,无法发射 + 红外中枢 + 硬件槽位:%1$d / %2$d 已占用(剩余 %3$d) + 静态占用:%1$d + 动态缓冲:Slot %1$s + 虚拟遥控器 + 暂无虚拟遥控器,点击右上角或下方 ➕ 添加 + 添加遥控器 + 🚀 静态独占(%1$s 槽位) + 📦 动态缓冲(共用 Slot %1$s) + 添加虚拟遥控器 + 设备类型 + 预设码库 / 品牌 + 传输模式 + 超低延迟(~20ms),极速响应,占用 ~15 个硬件槽位 + 节省槽位(0 额外槽位),现场写码发射(~80ms) + 硬件槽位不足,请选择动态写码模式 + 硬件槽位与底层按键管理 + 📺 电视机 / 电视盒子 + ❄️ 空调设备 + 🌀 电风扇 / 空气净化器 + 🎛️ 自定义面板 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 14c6be5..88004a7 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -87,4 +87,25 @@ Key renamed Key removed locally Key is not ready to send + Infrared Hub + Hardware Slots: %1$d / %2$d allocated (%3$d free) + Static: %1$d + Dynamic Buffer: Slot %1$s + Virtual Remotes + No virtual remotes added yet. Tap + to add one. + Add Remote + 🚀 Static Mode (%1$s slots) + 📦 Dynamic Mode (Scratch Slot %1$s) + Add Virtual Remote + Device Type + Brand / Preset Library + Transmission Mode + Low latency (~20ms), instant response, occupies ~15 slots + Saves slots (0 extra slots), writes code on-the-fly (~80ms) + Slot quota exceeded. Please choose Dynamic mode. + Hardware Slot / Raw Key Management + 📺 Television (TV) + ❄️ Air Conditioner (AC) + 🌀 Fan / Air Purifier + 🎛️ Custom Panel diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt new file mode 100644 index 0000000..3742cf3 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt @@ -0,0 +1,136 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredHubViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profileId = ProviderProfileId("profile1") + private val providerProfile = ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local Simulation") + private val device = DeviceName("ir-1") + private val clock = EpochMillisClock { 10_000L } + + private val profileStore = FakeProviderProfileStore() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val remoteRepo = FakeVirtualRemoteRepository() + private val gateway = FakeInfraredGateway() + + private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) + private val deleteRemote = DeleteVirtualRemote(remoteRepo) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredHubViewModel = InfraredHubViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")), + providerProfileStore = profileStore, + deviceConfigurationRepository = deviceRepo, + virtualRemoteRepository = remoteRepo, + createVirtualRemote = createRemote, + deleteVirtualRemote = deleteRemote, + ) + + @Test + fun initialStateLoadsRemotesAndSlotQuota() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType) + assertEquals(device, vm.state.value.savedDeviceName) + assertEquals(0, vm.state.value.remotes.size) + assertEquals(99, vm.state.value.slotQuota.remainingStaticCount) + + job.cancel() + } + + @Test + fun createRemoteAddsToRepositoryAndUpdatesState() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.createRemote( + name = "Living Room TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + advanceUntilIdle() + + assertEquals(1, vm.state.value.remotes.size) + assertEquals("Living Room TV", vm.state.value.remotes.first().displayName) + assertEquals(TransmissionMode.STATIC_SLOTS, vm.state.value.remotes.first().transmissionMode) + + job.cancel() + } + + @Test + fun deleteRemoteRemovesFromRepository() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.createRemote( + name = "Fan", + type = RemoteDeviceType.FAN, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + advanceUntilIdle() + + val remoteId = vm.state.value.remotes.first().id + vm.deleteRemote(remoteId) + advanceUntilIdle() + + assertEquals(0, vm.state.value.remotes.size) + + job.cancel() + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt new file mode 100644 index 0000000..52ad483 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt @@ -0,0 +1,128 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RemotePanelViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profileId = ProviderProfileId("profile1") + private val device = DeviceName("ir-1") + private val clock = EpochMillisClock { 10_000L } + + private val remoteRepo = FakeVirtualRemoteRepository() + private val gateway = FakeInfraredGateway() + private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) + private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(remoteId: String): RemotePanelViewModel = RemotePanelViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)), + virtualRemoteRepository = remoteRepo, + sendVirtualRemoteAction = sendAction, + ) + + @Test + fun tvButtonTriggersInstantTransmission() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onButtonClick("TV_POWER") + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "AC", + type = RemoteDeviceType.AIR_CONDITIONER, + brandModel = "gree_ac", + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + // 1. Click +1 temp (optimistic update to 27) + vm.adjustAcTemperature(1) + runCurrent() + assertEquals(27, vm.state.value.acState.temperature) + + // 2. Click +1 temp again within debounce window (optimistic update to 28) + advanceTimeBy(100L) + vm.adjustAcTemperature(1) + runCurrent() + assertEquals(28, vm.state.value.acState.temperature) + + // Advance debounce window to fire (300ms) + advanceTimeBy(400L) + advanceUntilIdle() + + // Only 1 download and 1 transmission fired for final state 28 + assertEquals(1, gateway.downloadedCodes.size) + assertEquals("100", gateway.downloadedCodes.first().key) + assertEquals(1, gateway.sentKeys.size) + assertEquals("100", gateway.sentKeys.first().key) + + job.cancel() + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt new file mode 100644 index 0000000..09ec03e --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt @@ -0,0 +1,52 @@ +package com.flagship.abox.manager.domain.infrared + +object AcCodeGenerator { + + /** + * Generates a standard protocol Hex string for an Air Conditioner state. + * Uses Gree/Generic standard 64-bit frame: + * Byte 0: Mode (3 bits) | Power (1 bit) | Fan Speed (2 bits) | Swing (2 bits) + * Byte 1: Temperature (4 bits: temp - 16) | Timer (4 bits) + * Byte 2: Auxiliary options (Eco, Turbo, Health, Light) + * Byte 3: Checksum + */ + fun generateHex(state: AcStateSnapshot, brand: String? = null): String { + val modeBits = when (state.mode) { + AcMode.AUTO -> 0b000 + AcMode.COOL -> 0b001 + AcMode.DRY -> 0b010 + AcMode.FAN -> 0b011 + AcMode.HEAT -> 0b100 + } + val powerBit = if (state.power) 0b1 else 0b0 + val fanBits = when (state.fanSpeed) { + AcFanSpeed.AUTO -> 0b00 + AcFanSpeed.LOW -> 0b01 + AcFanSpeed.MEDIUM -> 0b10 + AcFanSpeed.HIGH -> 0b11 + } + val swingBits = (if (state.verticalSwing) 0b01 else 0b00) or (if (state.horizontalSwing) 0b10 else 0b00) + + val byte0 = ((modeBits and 0x07) shl 5) or + ((powerBit and 0x01) shl 4) or + ((fanBits and 0x03) shl 2) or + (swingBits and 0x03) + + val clampedTemp = state.temperature.coerceIn(16, 30) + val tempBits = (clampedTemp - 16) and 0x0F + val byte1 = (tempBits shl 4) and 0xF0 + + val ecoBit = if (state.eco) 0b1 else 0b0 + val byte2 = (ecoBit and 0x01) shl 7 + + val checksum = (byte0 + byte1 + byte2) and 0xFF + + return buildString { + append("0x") + append(String.format("%02X", byte0)) + append(String.format("%02X", byte1)) + append(String.format("%02X", byte2)) + append(String.format("%02X", checksum)) + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt new file mode 100644 index 0000000..b771bea --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -0,0 +1,108 @@ +package com.flagship.abox.manager.domain.infrared + +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.InfraredCode +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.UUID + +class CreateVirtualRemote( + private val repository: VirtualRemoteRepository, + private val gateway: InfraredGateway, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + type: RemoteDeviceType, + brandModel: String? = null, + transmissionMode: TransmissionMode, + ): DomainResult { + val trimmedName = displayName.trim() + if (trimmedName.isBlank()) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Remote display name cannot be blank"), + ) + } + + val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel) + val quotaResult = repository.getKeyBindings(VirtualRemoteId("dummy")) // or compute from quota + // Obtain slot quota + // We calculate needed static slots: + val neededStaticSlots = if (transmissionMode == TransmissionMode.STATIC_SLOTS) templates.size else 0 + + val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16)) + val now = clock.now() + + val initialAcState = if (type == RemoteDeviceType.AIR_CONDITIONER) AcStateSnapshot() else null + + val remote = VirtualRemote( + id = remoteId, + profileId = profileId, + deviceName = deviceName, + displayName = trimmedName, + type = type, + brandModel = brandModel, + transmissionMode = transmissionMode, + dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT, + acState = initialAcState, + createdAtEpochMillis = now, + updatedAtEpochMillis = now, + ) + + // Build key bindings + val bindings = if (transmissionMode == TransmissionMode.STATIC_SLOTS) { + // Find currently used slots by querying quota + // For now, assign slots incrementally or check + templates.mapIndexed { index, template -> + RemoteKeyBinding( + id = UUID.randomUUID().toString(), + remoteId = remoteId, + profileId = profileId, + buttonId = template.buttonId, + displayName = template.displayName, + assignedSlot = (index + 1).toString(), // Will be allocated based on allocator + rawHexCode = template.defaultHexCode, + status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED, + ) + } + } else { + templates.map { template -> + RemoteKeyBinding( + id = UUID.randomUUID().toString(), + remoteId = remoteId, + profileId = profileId, + buttonId = template.buttonId, + displayName = template.displayName, + assignedSlot = null, + rawHexCode = template.defaultHexCode, + status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) { + InfraredKeyStatus.READY + } else { + InfraredKeyStatus.FAILED + }, + ) + } + } + + // If static and has default hex codes, download to hardware slots + if (transmissionMode == TransmissionMode.STATIC_SLOTS) { + val codesToDownload = bindings.mapNotNull { b -> + if (b.assignedSlot != null && b.rawHexCode != null) { + InfraredCode(deviceName, b.assignedSlot, b.rawHexCode) + } else null + } + if (codesToDownload.isNotEmpty()) { + when (val dlResult = gateway.downloadCodes(profileId, codesToDownload)) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + } + } + + return repository.createRemote(remote, bindings) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt new file mode 100644 index 0000000..c9ad649 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt @@ -0,0 +1,13 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId + +class DeleteVirtualRemote( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = repository.deleteRemote(profileId, remoteId) +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt new file mode 100644 index 0000000..8785d74 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt @@ -0,0 +1,81 @@ +package com.flagship.abox.manager.domain.infrared + +data class PresetBrandOption( + val id: String, + val displayName: String, + val deviceType: RemoteDeviceType, +) + +object PresetCodeLibrary { + + val availablePresets: List = listOf( + // 电视预设 + PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV), + PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV), + PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV), + PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV), + + // 空调预设 + PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER), + PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER), + PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER), + PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER), + + // 风扇预设 + PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN), + PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN), + + // 自定义 + PresetBrandOption("custom_blank", "自定义学习遥控器 (Custom)", RemoteDeviceType.CUSTOM), + ) + + data class PresetButtonTemplate( + val buttonId: String, + val displayName: String, + val defaultHexCode: String? = null, + ) + + fun getTemplatesForType(type: RemoteDeviceType, presetId: String? = null): List = when (type) { + RemoteDeviceType.TV -> listOf( + PresetButtonTemplate("TV_POWER", "电源", "0x00FF02FD"), + PresetButtonTemplate("TV_MUTE", "静音", "0x00FF04FB"), + PresetButtonTemplate("TV_SOURCE", "信号源", "0x00FF06F9"), + PresetButtonTemplate("TV_NAV_UP", "上", "0x00FF08F7"), + PresetButtonTemplate("TV_NAV_DOWN", "下", "0x00FF0AF5"), + PresetButtonTemplate("TV_NAV_LEFT", "左", "0x00FF0CF3"), + PresetButtonTemplate("TV_NAV_RIGHT", "右", "0x00FF0EF1"), + PresetButtonTemplate("TV_OK", "确定", "0x00FF10EF"), + PresetButtonTemplate("TV_HOME", "主页", "0x00FF12ED"), + PresetButtonTemplate("TV_BACK", "返回", "0x00FF14EB"), + PresetButtonTemplate("TV_MENU", "菜单", "0x00FF16E9"), + PresetButtonTemplate("TV_VOL_UP", "音量 +", "0x00FF18E7"), + PresetButtonTemplate("TV_VOL_DOWN", "音量 -", "0x00FF1AE5"), + PresetButtonTemplate("TV_CH_UP", "频道 +", "0x00FF1CE3"), + PresetButtonTemplate("TV_CH_DOWN", "频道 -", "0x00FF1EE1"), + ) + RemoteDeviceType.AIR_CONDITIONER -> listOf( + PresetButtonTemplate("AC_POWER", "电源"), + PresetButtonTemplate("AC_TEMP_UP", "升温"), + PresetButtonTemplate("AC_TEMP_DOWN", "降温"), + PresetButtonTemplate("AC_MODE", "模式"), + PresetButtonTemplate("AC_FAN", "风速"), + PresetButtonTemplate("AC_SWING_V", "上下扫风"), + PresetButtonTemplate("AC_SWING_H", "左右扫风"), + PresetButtonTemplate("AC_ECO", "节能"), + ) + RemoteDeviceType.FAN -> listOf( + PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"), + PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"), + PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"), + PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"), + PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"), + ) + RemoteDeviceType.CUSTOM -> listOf( + PresetButtonTemplate("CUSTOM_POWER", "电源"), + PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"), + PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"), + PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"), + PresetButtonTemplate("CUSTOM_KEY_4", "按键 4"), + ) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt new file mode 100644 index 0000000..35bb9ec --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt @@ -0,0 +1,85 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class SendVirtualRemoteAction( + private val repository: VirtualRemoteRepository, + private val gateway: InfraredGateway, +) { + suspend fun sendButton( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + val bindings = when (val res = repository.getKeyBindings(remoteId)) { + is DomainResult.Success -> res.value + is DomainResult.Failure -> return res + } + val binding = bindings.find { it.buttonId == buttonId } + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button binding not found")) + + if (binding.status != InfraredKeyStatus.READY) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button is not ready to send")) + } + + return when (remote.transmissionMode) { + TransmissionMode.STATIC_SLOTS -> { + val slot = binding.assignedSlot + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No slot assigned for static mode")) + gateway.sendKey(profileId, remote.deviceName, slot) + } + TransmissionMode.DYNAMIC_SCRATCH -> { + val hexCode = binding.rawHexCode + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code available for dynamic transmission")) + // 1. Download to scratch slot + val scratchSlot = remote.dynamicScratchSlot + when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + // 2. Send scratch slot + gateway.sendKey(profileId, remote.deviceName, scratchSlot) + } + } + } + + suspend fun sendAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + if (remote.type != RemoteDeviceType.AIR_CONDITIONER) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Target is not an air conditioner")) + } + + // 1. Update state snapshot in repository + when (val updateRes = repository.updateAcState(profileId, remoteId, state)) { + is DomainResult.Failure -> return updateRes + is DomainResult.Success -> Unit + } + + // 2. Generate full state hex frame + val hexCode = AcCodeGenerator.generateHex(state, remote.brandModel) + + // 3. Write to scratch slot and transmit + val scratchSlot = remote.dynamicScratchSlot + when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + + return gateway.sendKey(profileId, remote.deviceName, scratchSlot) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt new file mode 100644 index 0000000..dc6259e --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -0,0 +1,93 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId + +@JvmInline +value class VirtualRemoteId(val value: String) + +enum class RemoteDeviceType { + TV, + AIR_CONDITIONER, + FAN, + CUSTOM, +} + +enum class TransmissionMode { + STATIC_SLOTS, + DYNAMIC_SCRATCH, +} + +enum class AcMode { + COOL, + HEAT, + AUTO, + DRY, + FAN, +} + +enum class AcFanSpeed { + AUTO, + LOW, + MEDIUM, + HIGH, +} + +data class AcStateSnapshot( + val power: Boolean = true, + val mode: AcMode = AcMode.COOL, + val temperature: Int = 26, + val fanSpeed: AcFanSpeed = AcFanSpeed.AUTO, + val verticalSwing: Boolean = true, + val horizontalSwing: Boolean = false, + val eco: Boolean = false, +) + +data class VirtualRemote( + val id: VirtualRemoteId, + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val displayName: String, + val type: RemoteDeviceType, + val brandModel: String? = null, + val transmissionMode: TransmissionMode, + val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT, + val acState: AcStateSnapshot? = null, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) { + companion object { + const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100" + } +} + +data class RemoteKeyBinding( + val id: String, + val remoteId: VirtualRemoteId, + val profileId: ProviderProfileId, + val buttonId: String, + val displayName: String, + val assignedSlot: String? = null, + val rawHexCode: String? = null, + val status: InfraredKeyStatus = InfraredKeyStatus.READY, + val lastErrorCode: String? = null, + val lastErrorDetail: String? = null, +) + +data class SlotQuotaSummary( + val totalCapacity: Int = 100, + val dynamicScratchSlot: Int = 100, + val assignedStaticSlots: Set = emptySet(), +) { + val availableStaticSlots: List + get() = (1 until dynamicScratchSlot).filter { it !in assignedStaticSlots } + + val usedCount: Int + get() = assignedStaticSlots.size + 1 // assigned static slots + 1 dynamic scratch slot + + val remainingStaticCount: Int + get() = availableStaticSlots.size + + val isStaticFull: Boolean + get() = availableStaticSlots.isEmpty() +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt new file mode 100644 index 0000000..2279afc --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt @@ -0,0 +1,56 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow + +interface VirtualRemoteRepository { + fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> + + fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> + + fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> + + fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> + + suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult + + suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> + + suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult + + suspend fun updateRemote(remote: VirtualRemote): DomainResult + + suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult + + suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult + + suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index 15f9bc4..c962153 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -202,6 +202,7 @@ class RecordingInfraredGateway : InfraredGateway { var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult)? = null val learnedKeys = mutableListOf() val sentKeys = mutableListOf() + val downloadedCodes = mutableListOf() override suspend fun learnKey( profileId: ProviderProfileId, @@ -224,10 +225,110 @@ class RecordingInfraredGateway : InfraredGateway { override suspend fun downloadCodes( profileId: ProviderProfileId, codes: List, - ): DomainResult> = DomainResult.Success(emptyList()) + ): DomainResult> { + downloadedCodes += codes + return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) + } +} + +class InMemoryVirtualRemoteRepository : VirtualRemoteRepository { + private val remotes = mutableMapOf() + private val bindings = mutableMapOf>() + private val state = MutableStateFlow>(emptyList()) + + override fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = state.map { values -> + DomainResult.Success(values.filter { it.profileId == profileId && it.deviceName == deviceName }) + } + + override fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> = state.map { values -> + DomainResult.Success(values.find { it.id == remoteId && it.profileId == profileId }) + } + + override fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> = state.map { + DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + } + + override fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> = state.map { values -> + val relevant = values.filter { it.profileId == profileId && it.deviceName == deviceName } + val used = relevant.flatMap { bindings[it.id].orEmpty() } + .mapNotNull { it.assignedSlot?.toIntOrNull() } + .toSet() + DomainResult.Success(SlotQuotaSummary(assignedStaticSlots = used)) + } + + override suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = DomainResult.Success(remotes[remoteId]?.takeIf { it.profileId == profileId }) + + override suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> = DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + + override suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult { + remotes[remote.id] = remote + bindings[remote.id] = initialBindings.toMutableList() + publish() + return DomainResult.Success(remote.id) + } + + override suspend fun updateRemote(remote: VirtualRemote): DomainResult { + remotes[remote.id] = remote + publish() + return DomainResult.Success(Unit) + } + + override suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult { + remotes.remove(remoteId) + bindings.remove(remoteId) + publish() + return DomainResult.Success(Unit) + } + + override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult { + val list = bindings[binding.remoteId] ?: mutableListOf() + val idx = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId } + if (idx >= 0) list[idx] = binding else list.add(binding) + bindings[binding.remoteId] = list + publish() + return DomainResult.Success(Unit) + } + + override suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult { + val existing = remotes[remoteId] ?: return DomainResult.Success(Unit) + remotes[remoteId] = existing.copy(acState = state) + publish() + return DomainResult.Success(Unit) + } + + private fun publish() { + state.value = remotes.values.toList() + } } fun SavedInfraredKey.ref() = InfraredKeyRef( profileId = profileId, keyId = id, ) + diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt new file mode 100644 index 0000000..d507536 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt @@ -0,0 +1,119 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class VirtualRemoteUseCaseTest { + + private val profileId = ProviderProfileId("profile1") + private val deviceName = DeviceName("ir-1") + private val clock = EpochMillisClock { 10_000L } + + private lateinit var repository: InMemoryVirtualRemoteRepository + private lateinit var gateway: RecordingInfraredGateway + private lateinit var createRemote: CreateVirtualRemote + private lateinit var sendAction: SendVirtualRemoteAction + private lateinit var deleteRemote: DeleteVirtualRemote + + @Before + fun setup() { + repository = InMemoryVirtualRemoteRepository() + gateway = RecordingInfraredGateway() + createRemote = CreateVirtualRemote(repository, gateway, clock) + sendAction = SendVirtualRemoteAction(repository, gateway) + deleteRemote = DeleteVirtualRemote(repository) + } + + @Test + fun createVirtualRemoteWithStaticSlotsDownloadsPresetCodesToHardware() = runTest { + val result = createRemote( + profileId = profileId, + deviceName = deviceName, + displayName = "Living Room TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + + assertTrue(result is DomainResult.Success) + val remoteId = (result as DomainResult.Success).value + + // Check remote created in repository + val remote = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value + assertNotNull(remote) + assertEquals("Living Room TV", remote?.displayName) + assertEquals(TransmissionMode.STATIC_SLOTS, remote?.transmissionMode) + + // Check preset codes downloaded to hardware gateway + assertTrue(gateway.downloadedCodes.isNotEmpty()) + + // Send a button in static mode -> directly triggers sendKey with assigned slot + val sendRes = sendAction.sendButton(profileId, remoteId, "TV_POWER") + assertTrue(sendRes is DomainResult.Success) + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + } + + @Test + fun sendVirtualRemoteActionForAcUpdatesStateAndDownloadsToScratchSlot() = runTest { + val createResult = createRemote( + profileId = profileId, + deviceName = deviceName, + displayName = "Bedroom AC", + type = RemoteDeviceType.AIR_CONDITIONER, + brandModel = "gree_ac", + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (createResult as DomainResult.Success).value + + val newAcState = AcStateSnapshot( + power = true, + mode = AcMode.COOL, + temperature = 27, + fanSpeed = AcFanSpeed.HIGH, + verticalSwing = true, + ) + + val sendRes = sendAction.sendAcState(profileId, remoteId, newAcState) + assertTrue(sendRes is DomainResult.Success) + + // 1. Downloaded to scratch slot 100 + assertEquals(1, gateway.downloadedCodes.size) + val downloaded = gateway.downloadedCodes.first() + assertEquals("100", downloaded.key) + assertTrue(downloaded.code.startsWith("0x")) + + // 2. Transmitted via sendKey with slot 100 + assertEquals(1, gateway.sentKeys.size) + assertEquals("100", gateway.sentKeys.first().key) + + // 3. State machine updated in repository + val updatedRemote = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value + assertEquals(27, updatedRemote?.acState?.temperature) + assertEquals(AcFanSpeed.HIGH, updatedRemote?.acState?.fanSpeed) + } + + @Test + fun deleteVirtualRemoteRemovesFromRepository() = runTest { + val createResult = createRemote( + profileId = profileId, + deviceName = deviceName, + displayName = "Fan", + type = RemoteDeviceType.FAN, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (createResult as DomainResult.Success).value + + deleteRemote(profileId, remoteId) + + val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value + assertEquals(null, fetched) + } +} diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index 12584ff..60c3c21 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -129,6 +129,9 @@ class FakeInfraredGateway : InfraredGateway { learnResults[InfraredOperationKey(profileId, deviceName, key)] = result } + val downloadedCodes: List + get() = downloadCalls.flatMap { it.codes } + override suspend fun downloadCodes( profileId: ProviderProfileId, codes: List, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt new file mode 100644 index 0000000..2a3f80e --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt @@ -0,0 +1,126 @@ +package com.flagship.abox.manager.testing.fakes + +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.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.util.concurrent.ConcurrentHashMap + +class FakeVirtualRemoteRepository : VirtualRemoteRepository { + + private val remotes = ConcurrentHashMap() + private val bindings = ConcurrentHashMap>() + private val changeTrigger = MutableStateFlow(0L) + + override fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = changeTrigger.map { + DomainResult.Success( + remotes.values.filter { it.profileId == profileId && it.deviceName == deviceName } + .sortedByDescending { it.createdAtEpochMillis }, + ) + } + + override fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> = changeTrigger.map { + val remote = remotes[remoteId]?.takeIf { it.profileId == profileId } + DomainResult.Success(remote) + } + + override fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> = changeTrigger.map { + DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + } + + override fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> = changeTrigger.map { + val relevantBindings = remotes.values + .filter { it.profileId == profileId && it.deviceName == deviceName } + .flatMap { bindings[it.id].orEmpty() } + val usedSlots = relevantBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() + DomainResult.Success( + SlotQuotaSummary( + totalCapacity = 100, + dynamicScratchSlot = 100, + assignedStaticSlots = usedSlots, + ), + ) + } + + override suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult { + val remote = remotes[remoteId]?.takeIf { it.profileId == profileId } + return DomainResult.Success(remote) + } + + override suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> = + DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + + override suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult { + remotes[remote.id] = remote + bindings[remote.id] = initialBindings.toMutableList() + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(remote.id) + } + + override suspend fun updateRemote(remote: VirtualRemote): DomainResult { + remotes[remote.id] = remote + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + + override suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult { + remotes.remove(remoteId) + bindings.remove(remoteId) + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + + override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult { + val list = bindings[binding.remoteId] ?: mutableListOf() + val index = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId } + if (index >= 0) { + list[index] = binding + } else { + list.add(binding) + } + bindings[binding.remoteId] = list + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + + override suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult { + val existing = remotes[remoteId] ?: return DomainResult.Success(Unit) + remotes[remoteId] = existing.copy(acState = state) + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } +} -- 2.54.0 From 63cac722c2eb997c8fcacae8b336bb2c61b922ac Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:20:30 +0800 Subject: [PATCH 37/41] feat(infrared): add virtual remote learn mode, custom layout switching, and key management --- .../infrared/data/local/InfraredDao.kt | 8 + .../infrared/data/local/InfraredEntities.kt | 1 + .../data/local/RoomVirtualRemoteRepository.kt | 18 +- .../infrared/di/InfraredFeatureModule.kt | 29 + .../infrared/panel/RemotePanelScreen.kt | 524 ++++++++++++++---- .../infrared/panel/RemotePanelUiState.kt | 2 + .../infrared/panel/RemotePanelViewModel.kt | 134 ++++- .../panel/RemotePanelViewModelTest.kt | 103 +++- .../domain/infrared/AddVirtualRemoteKey.kt | 53 ++ .../domain/infrared/DeleteVirtualRemoteKey.kt | 14 + .../domain/infrared/LearnVirtualRemoteKey.kt | 73 +++ .../infrared/UpdateVirtualRemoteLayout.kt | 25 + .../domain/infrared/VirtualRemoteModels.kt | 15 + .../infrared/VirtualRemoteRepository.kt | 2 + .../infrared/InMemoryInfraredTestDoubles.kt | 11 + .../fakes/FakeVirtualRemoteRepository.kt | 11 + 16 files changed, 907 insertions(+), 116 deletions(-) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt 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 index 98e402f..3583952 100644 --- 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 @@ -203,6 +203,14 @@ interface InfraredDao { ) suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int + @Query( + """ + DELETE FROM remote_key_bindings + WHERE remoteId = :remoteId AND buttonId = :buttonId + """, + ) + suspend fun deleteKeyBinding(remoteId: String, buttonId: String): Int + @Query( """ SELECT b.assignedSlot FROM remote_key_bindings b 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 index 1ae92d2..73112e5 100644 --- 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 @@ -46,6 +46,7 @@ data class VirtualRemoteEntity( val displayName: String, val type: String, val brandModel: String?, + val layoutStyle: String?, val transmissionMode: String, val dynamicScratchSlot: String, val acPower: Boolean?, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt index de36ae2..712e679 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -115,6 +115,16 @@ class RoomVirtualRemoteRepository @Inject constructor( DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) } + override suspend fun deleteKeyBinding( + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult = try { + dao.deleteKeyBinding(remoteId.value, buttonId) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + override suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, @@ -151,13 +161,18 @@ private fun VirtualRemoteEntity.toDomain(): VirtualRemote { ) } else null + val devType = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM) + val parsedLayout = layoutStyle?.let { runCatching { com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.valueOf(it) }.getOrNull() } + ?: VirtualRemote.defaultLayoutFor(devType) + return VirtualRemote( id = VirtualRemoteId(id), profileId = ProviderProfileId(profileId), deviceName = DeviceName(deviceName), displayName = displayName, - type = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM), + type = devType, brandModel = brandModel, + layoutStyle = parsedLayout, transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH), dynamicScratchSlot = dynamicScratchSlot, acState = ac, @@ -173,6 +188,7 @@ private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity( displayName = displayName, type = type.name, brandModel = brandModel, + layoutStyle = layoutStyle.name, transmissionMode = transmissionMode.name, dynamicScratchSlot = dynamicScratchSlot, acPower = acState?.power, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index c0baa4d..13d499a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -197,4 +197,33 @@ object InfraredFeatureModule { repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote = com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository) + + @Provides + @Singleton + fun provideLearnVirtualRemoteKey( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + gateway: InfraredGateway, + ): com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey = + com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey(repository, gateway) + + @Provides + @Singleton + fun provideAddVirtualRemoteKey( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey = + com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey(repository) + + @Provides + @Singleton + fun provideDeleteVirtualRemoteKey( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey = + com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey(repository) + + @Provides + @Singleton + fun provideUpdateVirtualRemoteLayout( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout = + com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout(repository) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index b1441bb..76f130b 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -18,29 +18,35 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,6 +63,8 @@ import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote @@ -76,6 +84,10 @@ fun RemotePanelRoute( onBack = onBack, onSessionInvalid = onSessionInvalid, onButtonClick = viewModel::onButtonClick, + onToggleLearnMode = viewModel::toggleLearnMode, + onAddCustomButton = viewModel::addCustomButton, + onDeleteButton = viewModel::deleteButton, + onSwitchLayout = viewModel::switchLayout, onToggleAcPower = viewModel::toggleAcPower, onAdjustAcTemp = viewModel::adjustAcTemperature, onSetAcMode = viewModel::setAcMode, @@ -94,6 +106,10 @@ internal fun RemotePanelScreen( onBack: () -> Unit, onSessionInvalid: () -> Unit, onButtonClick: (String) -> Unit, + onToggleLearnMode: () -> Unit, + onAddCustomButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onSwitchLayout: (PanelLayoutStyle) -> Unit, onToggleAcPower: () -> Unit, onAdjustAcTemp: (Int) -> Unit, onSetAcMode: (AcMode) -> Unit, @@ -106,6 +122,9 @@ internal fun RemotePanelScreen( val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current + var showAddKeyDialog by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + LaunchedEffect(state.message) { state.message?.let { msg -> when (msg) { @@ -137,16 +156,76 @@ internal fun RemotePanelScreen( topBar = { TopAppBar( title = { - Text( - text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), - fontWeight = FontWeight.Bold, - ) + Column { + Text( + text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + state.remote?.let { + Text( + text = "布局: ${it.layoutStyle.name} | 模式: ${it.transmissionMode.name}", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } }, navigationIcon = { TextButton(onClick = onBack) { Text(stringResource(R.string.infrared_back)) } }, + actions = { + // 学习模式开关 + FilterChip( + selected = state.isLearnMode, + onClick = onToggleLearnMode, + label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFFFF5252), + selectedLabelColor = Color.White, + ), + modifier = Modifier.testTag("learn_mode_toggle"), + ) + + // 更多选项菜单(添加按键、切换布局) + Box { + IconButton(onClick = { showMenu = true }, modifier = Modifier.testTag("panel_more_menu")) { + Text("⋮", fontSize = 22.sp, fontWeight = FontWeight.Bold) + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text("➕ 添加自定义按键") }, + onClick = { + showMenu = false + showAddKeyDialog = true + }, + ) + DropdownMenuItem( + text = { Text("📺 切换为电视导航布局") }, + onClick = { + showMenu = false + onSwitchLayout(PanelLayoutStyle.TV_NAV) + }, + ) + DropdownMenuItem( + text = { Text("🎛️ 切换为网格按键布局") }, + onClick = { + showMenu = false + onSwitchLayout(PanelLayoutStyle.GRID) + }, + ) + DropdownMenuItem( + text = { Text("🎵 切换为媒体播放布局") }, + onClick = { + showMenu = false + onSwitchLayout(PanelLayoutStyle.MEDIA_PLAYER) + }, + ) + } + } + }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -162,37 +241,91 @@ internal fun RemotePanelScreen( CircularProgressIndicator() } } else { - when (remote.type) { - RemoteDeviceType.AIR_CONDITIONER -> { - AcControlPanel( - acState = state.acState, - onTogglePower = onToggleAcPower, - onAdjustTemp = onAdjustAcTemp, - onSetMode = onSetAcMode, - onSetFanSpeed = onSetAcFanSpeed, - onToggleVSwing = onToggleAcVerticalSwing, - onToggleHSwing = onToggleAcHorizontalSwing, - onToggleEco = onToggleAcEco, - ) + Column(modifier = Modifier.fillMaxSize()) { + // 学习模式指示横幅 + if (state.isLearnMode) { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)), + shape = RoundedCornerShape(0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("🔴", fontSize = 16.sp) + Text( + text = "学习录制模式已开启:点击下方任意按键,然后将实体遥控器对准转发器按下即可录码", + color = Color(0xFFC62828), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } } - RemoteDeviceType.TV -> { - TvControlPanel( - bindings = state.bindings, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) - } - RemoteDeviceType.FAN, RemoteDeviceType.CUSTOM -> { - GenericControlPanel( - bindings = state.bindings, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) + + // 面板内容区 + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + when (remote.layoutStyle) { + PanelLayoutStyle.AC_COMPACT -> { + AcControlPanel( + acState = state.acState, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetAcFanSpeed, + onToggleVSwing = onToggleAcVerticalSwing, + onToggleHSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + ) + } + PanelLayoutStyle.TV_NAV -> { + TvControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onDeleteButton = onDeleteButton, + ) + } + PanelLayoutStyle.MEDIA_PLAYER -> { + MediaPlayerControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + } + PanelLayoutStyle.GRID -> { + GenericGridControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onDeleteButton = onDeleteButton, + onAddButton = { showAddKeyDialog = true }, + ) + } + } } } } } } + + if (showAddKeyDialog) { + AddCustomKeyDialog( + onDismiss = { showAddKeyDialog = false }, + onConfirm = { name -> + onAddCustomButton(name) + showAddKeyDialog = false + }, + ) + } } @Composable @@ -213,7 +346,6 @@ private fun AcControlPanel( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // 1. 状态看板与大温度指示器 Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -262,7 +394,6 @@ private fun AcControlPanel( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // 调温大按键 Row( horizontalArrangement = Arrangement.spacedBy(32.dp), verticalAlignment = Alignment.CenterVertically, @@ -285,16 +416,14 @@ private fun AcControlPanel( } } - // 2. 模式切换 Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AcMode.entries.forEach { mode -> - val selected = acState.mode == mode FilterChip( - selected = selected, + selected = acState.mode == mode, onClick = { onSetMode(mode) }, label = { Text(mode.name) }, modifier = Modifier.weight(1f), @@ -302,16 +431,14 @@ private fun AcControlPanel( } } - // 3. 风速切换 Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AcFanSpeed.entries.forEach { speed -> - val selected = acState.fanSpeed == speed FilterChip( - selected = selected, + selected = acState.fanSpeed == speed, onClick = { onSetFanSpeed(speed) }, label = { Text(speed.name) }, modifier = Modifier.weight(1f), @@ -319,7 +446,6 @@ private fun AcControlPanel( } } - // 4. 功能开关 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -349,8 +475,11 @@ private fun AcControlPanel( @Composable private fun TvControlPanel( bindings: List, + isLearnMode: Boolean, + learningButtonId: String?, sendingButtonId: String?, onButtonClick: (String) -> Unit, + onDeleteButton: (String) -> Unit, ) { Column( modifier = Modifier @@ -365,9 +494,9 @@ private fun TvControlPanel( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - TvActionButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), onButtonClick, sendingButtonId) - TvActionButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) - TvActionButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) + InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(8.dp)) @@ -383,25 +512,33 @@ private fun TvControlPanel( IconButton( onClick = { onButtonClick("TV_NAV_UP") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), - ) { Text("▲", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId) + } // 下 IconButton( onClick = { onButtonClick("TV_NAV_DOWN") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), - ) { Text("▼", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId) + } // 左 IconButton( onClick = { onButtonClick("TV_NAV_LEFT") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), - ) { Text("◀", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId) + } // 右 IconButton( onClick = { onButtonClick("TV_NAV_RIGHT") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), - ) { Text("▶", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId) + } // OK 居中大按钮 Button( @@ -409,7 +546,7 @@ private fun TvControlPanel( shape = CircleShape, modifier = Modifier.size(76.dp).testTag("tv_ok"), ) { - Text("OK", fontWeight = FontWeight.Black, fontSize = 18.sp) + ButtonIndicatorText("TV_OK", "OK", learningButtonId, sendingButtonId) } } } @@ -419,9 +556,9 @@ private fun TvControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, ) { - OutlinedButton(onClick = { onButtonClick("TV_BACK") }) { Text("↩ 返回") } - OutlinedButton(onClick = { onButtonClick("TV_HOME") }) { Text("⌂ 主页") } - OutlinedButton(onClick = { onButtonClick("TV_MENU") }) { Text("☰ 菜单") } + InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } // 底部音量与频道调节柱 @@ -434,8 +571,12 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("+", fontSize = 20.sp) } - Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("-", fontSize = 20.sp) } + Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId) + } + Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId) + } } Column( @@ -443,57 +584,236 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▲", fontSize = 16.sp) } - Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▼", fontSize = 16.sp) } - } - } - } -} - -@Composable -private fun TvActionButton( - buttonId: String, - label: String, - containerColor: Color, - onButtonClick: (String) -> Unit, - sendingButtonId: String?, -) { - Button( - onClick = { onButtonClick(buttonId) }, - colors = ButtonDefaults.buttonColors(containerColor = containerColor), - modifier = Modifier.testTag("tv_btn_$buttonId"), - ) { - if (sendingButtonId == buttonId) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) - } else { - Text(label, fontWeight = FontWeight.Bold) - } - } -} - -@Composable -private fun GenericControlPanel( - bindings: List, - sendingButtonId: String?, - onButtonClick: (String) -> Unit, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxSize().padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(bindings, key = { it.id }) { binding -> - Button( - onClick = { onButtonClick(binding.buttonId) }, - modifier = Modifier.fillMaxWidth().height(64.dp).testTag("generic_btn_${binding.buttonId}"), - ) { - if (sendingButtonId == binding.buttonId) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) - } else { - Text(binding.displayName, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId) + } + Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId) } } } } } + +@Composable +private fun MediaPlayerControlPanel( + bindings: List, + isLearnMode: Boolean, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 播放控制中心大圆盘 + Card( + shape = CircleShape, + modifier = Modifier.size(240.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // 快进 >> + IconButton(onClick = { onButtonClick("MEDIA_FF") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) { + ButtonIndicatorText("MEDIA_FF", "⏩", learningButtonId, sendingButtonId) + } + // 快退 << + IconButton(onClick = { onButtonClick("MEDIA_REWIND") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) { + ButtonIndicatorText("MEDIA_REWIND", "⏪", learningButtonId, sendingButtonId) + } + // 上一曲 + IconButton(onClick = { onButtonClick("MEDIA_PREV") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp)) { + ButtonIndicatorText("MEDIA_PREV", "⏮", learningButtonId, sendingButtonId) + } + // 下一曲 + IconButton(onClick = { onButtonClick("MEDIA_NEXT") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)) { + ButtonIndicatorText("MEDIA_NEXT", "⏭", learningButtonId, sendingButtonId) + } + // 播放/暂停 + Button(onClick = { onButtonClick("MEDIA_PLAY_PAUSE") }, shape = CircleShape, modifier = Modifier.size(80.dp)) { + ButtonIndicatorText("MEDIA_PLAY_PAUSE", "⏯", learningButtonId, sendingButtonId) + } + } + } + } +} + +@Composable +private fun GenericGridControlPanel( + bindings: List, + isLearnMode: Boolean, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onAddButton: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) { + Text("➕ 添加按键") + } + } + + if (bindings.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + Text("暂无自定义按键,点击上方 ➕ 添加按键", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize().weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(bindings, key = { it.id }) { binding -> + GridButtonCard( + binding = binding, + isLearnMode = isLearnMode, + isLearning = learningButtonId == binding.buttonId, + isSending = sendingButtonId == binding.buttonId, + onClick = { onButtonClick(binding.buttonId) }, + onDelete = { onDeleteButton(binding.buttonId) }, + ) + } + } + } + } +} + +@Composable +private fun GridButtonCard( + binding: RemoteKeyBinding, + isLearnMode: Boolean, + isLearning: Boolean, + isSending: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .clickable(onClick = onClick) + .testTag("generic_btn_${binding.buttonId}"), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) + val statusText = when { + isLearning -> "🟡 正在录制信号..." + binding.status == InfraredKeyStatus.READY -> "🟢 已录制" + else -> "⚪ 未录制" + } + Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + if (isSending || isLearning) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else if (isLearnMode) { + IconButton(onClick = onDelete) { + Text("✕", color = Color.Gray, fontSize = 14.sp) + } + } + } + } +} + +@Composable +private fun InteractiveRemoteButton( + buttonId: String, + label: String, + containerColor: Color, + isLearnMode: Boolean, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + Button( + onClick = { onButtonClick(buttonId) }, + colors = ButtonDefaults.buttonColors( + containerColor = if (learningButtonId == buttonId) Color(0xFFFF9800) else containerColor, + ), + modifier = Modifier.testTag("tv_btn_$buttonId"), + ) { + ButtonIndicatorText(buttonId, label, learningButtonId, sendingButtonId) + } +} + +@Composable +private fun ButtonIndicatorText( + buttonId: String, + defaultLabel: String, + learningButtonId: String?, + sendingButtonId: String?, +) { + if (learningButtonId == buttonId) { + Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } else if (sendingButtonId == buttonId) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) + } else { + Text(defaultLabel, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun AddCustomKeyDialog( + onDismiss: () -> Unit, + onConfirm: (name: String) -> Unit, +) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("添加自定义按键") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("输入按键显示名称,创建后开启右上角【学习模式】即可录制实体信号:", fontSize = 13.sp) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("按键名称(如:静音、音效切换)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"), + ) + } + }, + confirmButton = { + Button( + onClick = { + if (name.isNotBlank()) onConfirm(name.trim()) + }, + modifier = Modifier.testTag("add_key_confirm_button"), + ) { + Text("确认添加") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("取消") } + }, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt index 4252e4e..da8de39 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt @@ -17,6 +17,8 @@ data class RemotePanelUiState( val remote: VirtualRemote? = null, val bindings: List = emptyList(), val acState: AcStateSnapshot = AcStateSnapshot(), + val isLearnMode: Boolean = false, + val learningButtonId: String? = null, val sendingButtonId: String? = null, val loading: Boolean = true, val message: PanelUiMessage? = null, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt index 1333c6b..221dad3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -9,8 +9,13 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcStateSnapshot -import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository @@ -36,6 +41,10 @@ class RemotePanelViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val virtualRemoteRepository: VirtualRemoteRepository, private val sendVirtualRemoteAction: SendVirtualRemoteAction, + private val learnVirtualRemoteKey: LearnVirtualRemoteKey, + private val addVirtualRemoteKey: AddVirtualRemoteKey, + private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey, + private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] @@ -44,6 +53,8 @@ class RemotePanelViewModel @Inject constructor( private val rawRemoteId: String? = savedStateHandle["remoteId"] val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) } + private val isLearnModeFlow = MutableStateFlow(false) + private val learningButtonIdFlow = MutableStateFlow(null) private val sendingButtonIdFlow = MutableStateFlow(null) private val localAcStateFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) @@ -67,21 +78,45 @@ class RemotePanelViewModel @Inject constructor( } } - val state: StateFlow = combine( - remoteFlow, - bindingsFlow, - localAcStateFlow, + private data class RemoteSnapshot( + val remote: VirtualRemote?, + val bindings: List, + ) + + private val snapshotFlow = combine(remoteFlow, bindingsFlow) { remote, bindings -> + RemoteSnapshot(remote, bindings) + } + + private data class ActionState( + val isLearnMode: Boolean, + val learningButtonId: String?, + val sendingButtonId: String?, + ) + + private val actionStateFlow = combine( + isLearnModeFlow, + learningButtonIdFlow, sendingButtonIdFlow, + ) { isLearn, learningBtn, sendingBtn -> + ActionState(isLearn, learningBtn, sendingBtn) + } + + val state: StateFlow = combine( + snapshotFlow, + localAcStateFlow, + actionStateFlow, messageFlow, - ) { remote, bindings, localAc, sendingBtn, msg -> - val resolvedAc = localAc ?: remote?.acState ?: AcStateSnapshot() + ) { snapshot, localAc, action, msg -> + val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot() RemotePanelUiState( profileId = profileId, - remote = remote, - bindings = bindings, + remote = snapshot.remote, + bindings = snapshot.bindings, acState = resolvedAc, - sendingButtonId = sendingBtn, - loading = remote == null, + isLearnMode = action.isLearnMode, + learningButtonId = action.learningButtonId, + sendingButtonId = action.sendingButtonId, + loading = snapshot.remote == null, message = msg, ) }.stateIn( @@ -90,10 +125,26 @@ class RemotePanelViewModel @Inject constructor( initialValue = RemotePanelUiState(profileId = profileId, loading = true), ) + fun toggleLearnMode() { + val newMode = !isLearnModeFlow.value + isLearnModeFlow.value = newMode + if (newMode) { + messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled)) + } + } + fun onButtonClick(buttonId: String) { + if (isLearnModeFlow.value) { + learnButton(buttonId) + } else { + sendButton(buttonId) + } + } + + private fun sendButton(buttonId: String) { val currentProfile = profileId ?: return val currentRemoteId = remoteId ?: return - if (state.value.sendingButtonId != null) return + if (state.value.sendingButtonId != null || state.value.learningButtonId != null) return sendingButtonIdFlow.value = buttonId viewModelScope.launch { @@ -110,6 +161,65 @@ class RemotePanelViewModel @Inject constructor( } } + fun learnButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + if (state.value.learningButtonId != null) return + + learningButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf(buttonId))) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } finally { + learningButtonIdFlow.value = null + } + } + } + + fun addCustomButton(displayName: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + viewModelScope.launch { + when (val res = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已添加按键【${displayName}】,开启学习模式即可录制")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun deleteButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + viewModelScope.launch { + when (val res = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("按键已删除")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun switchLayout(newLayout: PanelLayoutStyle) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + viewModelScope.launch { + when (val res = updateVirtualRemoteLayout(currentProfile, currentRemoteId, newLayout)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已切换面板布局风格")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + fun toggleAcPower() { val current = state.value.acState updateAndDebounceAc(current.copy(power = !current.power)) diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt index 52ad483..8db53e1 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt @@ -5,11 +5,16 @@ import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository @@ -27,6 +32,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -44,6 +50,10 @@ class RemotePanelViewModelTest { private val gateway = FakeInfraredGateway() private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway) + private val learnKey = LearnVirtualRemoteKey(remoteRepo, gateway) + private val addKey = AddVirtualRemoteKey(remoteRepo) + private val deleteKey = DeleteVirtualRemoteKey(remoteRepo) + private val updateLayout = UpdateVirtualRemoteLayout(remoteRepo) @Before fun setup() { @@ -59,10 +69,14 @@ class RemotePanelViewModelTest { savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)), virtualRemoteRepository = remoteRepo, sendVirtualRemoteAction = sendAction, + learnVirtualRemoteKey = learnKey, + addVirtualRemoteKey = addKey, + deleteVirtualRemoteKey = deleteKey, + updateVirtualRemoteLayout = updateLayout, ) @Test - fun tvButtonTriggersInstantTransmission() = testScope.runTest { + fun tvButtonTriggersInstantTransmissionInNormalMode() = testScope.runTest { val res = createRemote( profileId = profileId, deviceName = device, @@ -86,6 +100,93 @@ class RemotePanelViewModelTest { job.cancel() } + @Test + fun learnModeToggledTriggersLearningWhenButtonIsClicked() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + // 1. Toggle learn mode ON + vm.toggleLearnMode() + runCurrent() + assertTrue(vm.state.value.isLearnMode) + + // 2. Click button -> triggers learn instead of send + vm.onButtonClick("TV_POWER") + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + assertEquals(0, gateway.sentKeys.size) + + job.cancel() + } + + @Test + fun addAndRemoveCustomButtonUpdatesBindings() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "Custom", + type = RemoteDeviceType.CUSTOM, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + // Add button + vm.addCustomButton("Sound Effect") + advanceUntilIdle() + + val added = vm.state.value.bindings.find { it.displayName == "Sound Effect" } + assertNotNull(added) + + // Delete button + vm.deleteButton(added!!.buttonId) + advanceUntilIdle() + + val afterDelete = vm.state.value.bindings.find { it.displayName == "Sound Effect" } + assertEquals(null, afterDelete) + + job.cancel() + } + + @Test + fun switchLayoutUpdatesRemoteLayoutStyle() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "Custom Box", + type = RemoteDeviceType.CUSTOM, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.switchLayout(PanelLayoutStyle.TV_NAV) + advanceUntilIdle() + + assertEquals(PanelLayoutStyle.TV_NAV, vm.state.value.remote?.layoutStyle) + + job.cancel() + } + @Test fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest { val res = createRemote( diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt new file mode 100644 index 0000000..26cb835 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.UUID + +class AddVirtualRemoteKey( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + displayName: String, + ): DomainResult { + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button name cannot be blank")) + } + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + + val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase() + val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { + // Find next available slot + val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() + val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() + val nextSlot = (1..99).firstOrNull { it !in usedSlots } + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位")) + nextSlot.toString() + } else null + + val binding = RemoteKeyBinding( + id = UUID.randomUUID().toString(), + remoteId = remoteId, + profileId = profileId, + buttonId = buttonId, + displayName = trimmed, + assignedSlot = assignedSlot, + rawHexCode = null, + status = InfraredKeyStatus.FAILED, // Initially unlearned + ) + + when (val res = repository.updateKeyBinding(binding)) { + is DomainResult.Failure -> return res + is DomainResult.Success -> Unit + } + + return DomainResult.Success(binding) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt new file mode 100644 index 0000000..5ed2dc0 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt @@ -0,0 +1,14 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId + +class DeleteVirtualRemoteKey( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult = repository.deleteKeyBinding(remoteId, buttonId) +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt new file mode 100644 index 0000000..0764236 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt @@ -0,0 +1,73 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class LearnVirtualRemoteKey( + private val repository: VirtualRemoteRepository, + private val gateway: InfraredGateway, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + val bindings = when (val res = repository.getKeyBindings(remoteId)) { + is DomainResult.Success -> res.value + is DomainResult.Failure -> return res + } + val binding = bindings.find { it.buttonId == buttonId } + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found")) + + val targetSlot = when (remote.transmissionMode) { + TransmissionMode.STATIC_SLOTS -> { + binding.assignedSlot ?: run { + // Find available slot from quota + "1" + } + } + TransmissionMode.DYNAMIC_SCRATCH -> remote.dynamicScratchSlot + } + + // Set status to LEARNING while hardware listens + repository.updateKeyBinding( + binding.copy( + status = InfraredKeyStatus.LEARNING, + assignedSlot = targetSlot, + lastErrorCode = null, + lastErrorDetail = null, + ), + ) + + return when (val learnResult = gateway.learnKey(profileId, remote.deviceName, targetSlot)) { + is DomainResult.Success -> { + repository.updateKeyBinding( + binding.copy( + status = InfraredKeyStatus.READY, + assignedSlot = targetSlot, + lastErrorCode = null, + lastErrorDetail = null, + ), + ) + DomainResult.Success(Unit) + } + is DomainResult.Failure -> { + repository.updateKeyBinding( + binding.copy( + status = InfraredKeyStatus.FAILED, + assignedSlot = targetSlot, + lastErrorCode = learnResult.error.externalCode, + lastErrorDetail = learnResult.error.detail, + ), + ) + learnResult + } + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt new file mode 100644 index 0000000..bd42b09 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId + +class UpdateVirtualRemoteLayout( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + layoutStyle: PanelLayoutStyle, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + val updated = remote.copy( + layoutStyle = layoutStyle, + updatedAtEpochMillis = System.currentTimeMillis(), + ) + return repository.updateRemote(updated) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt index dc6259e..112a29c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -18,6 +18,13 @@ enum class TransmissionMode { DYNAMIC_SCRATCH, } +enum class PanelLayoutStyle { + TV_NAV, + GRID, + MEDIA_PLAYER, + AC_COMPACT, +} + enum class AcMode { COOL, HEAT, @@ -50,6 +57,7 @@ data class VirtualRemote( val displayName: String, val type: RemoteDeviceType, val brandModel: String? = null, + val layoutStyle: PanelLayoutStyle = defaultLayoutFor(type), val transmissionMode: TransmissionMode, val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT, val acState: AcStateSnapshot? = null, @@ -58,6 +66,13 @@ data class VirtualRemote( ) { companion object { const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100" + + fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) { + RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV + RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT + RemoteDeviceType.FAN -> PanelLayoutStyle.GRID + RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID + } } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt index 2279afc..fb9e622 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt @@ -48,6 +48,8 @@ interface VirtualRemoteRepository { suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult + suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult + suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index c962153..0fd17cd 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -311,6 +311,17 @@ class InMemoryVirtualRemoteRepository : VirtualRemoteRepository { return DomainResult.Success(Unit) } + override suspend fun deleteKeyBinding( + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val list = bindings[remoteId] ?: return DomainResult.Success(Unit) + list.removeAll { it.buttonId == buttonId } + bindings[remoteId] = list + publish() + return DomainResult.Success(Unit) + } + override suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt index 2a3f80e..ac183cc 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt @@ -113,6 +113,17 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { return DomainResult.Success(Unit) } + override suspend fun deleteKeyBinding( + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val list = bindings[remoteId] ?: return DomainResult.Success(Unit) + list.removeAll { it.buttonId == buttonId } + bindings[remoteId] = list + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + override suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, -- 2.54.0 From 849b7e4aea6099d3559581ea38de705421404c12 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:22:47 +0800 Subject: [PATCH 38/41] fix(infrared): allocate dedicated static slot for physically learned keys --- .../domain/infrared/LearnVirtualRemoteKey.kt | 13 ++++------- .../infrared/SendVirtualRemoteAction.kt | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt index 0764236..fb453a3 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt @@ -25,14 +25,11 @@ class LearnVirtualRemoteKey( val binding = bindings.find { it.buttonId == buttonId } ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found")) - val targetSlot = when (remote.transmissionMode) { - TransmissionMode.STATIC_SLOTS -> { - binding.assignedSlot ?: run { - // Find available slot from quota - "1" - } - } - TransmissionMode.DYNAMIC_SCRATCH -> remote.dynamicScratchSlot + val targetSlot = binding.assignedSlot ?: run { + val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() + val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() + val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1 + nextSlot.toString() } // Set status to LEARNING while hardware listens diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt index 35bb9ec..c18b2f3 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt @@ -37,16 +37,21 @@ class SendVirtualRemoteAction( gateway.sendKey(profileId, remote.deviceName, slot) } TransmissionMode.DYNAMIC_SCRATCH -> { - val hexCode = binding.rawHexCode - ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code available for dynamic transmission")) - // 1. Download to scratch slot - val scratchSlot = remote.dynamicScratchSlot - when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) { - is DomainResult.Failure -> return dlResult - is DomainResult.Success -> Unit + if (binding.rawHexCode != null) { + // 1. Download to scratch slot + val scratchSlot = remote.dynamicScratchSlot + when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, binding.rawHexCode)))) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + // 2. Send scratch slot + gateway.sendKey(profileId, remote.deviceName, scratchSlot) + } else if (binding.assignedSlot != null) { + // Fallback to sending learned hardware slot + gateway.sendKey(profileId, remote.deviceName, binding.assignedSlot) + } else { + DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code or slot available for transmission")) } - // 2. Send scratch slot - gateway.sendKey(profileId, remote.deviceName, scratchSlot) } } } -- 2.54.0 From ec51ae1db73403e2b91ce37bc6bac3c4ee10e98a Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:34:47 +0800 Subject: [PATCH 39/41] feat(infrared): choose layout template at creation time and improve grid key management --- .../manager/infrared/hub/InfraredHubScreen.kt | 260 ++++++++++++------ .../infrared/hub/InfraredHubViewModel.kt | 2 + .../infrared/panel/RemotePanelScreen.kt | 134 ++++----- .../src/main/res/values-zh-rCN/strings.xml | 1 + .../infrared/src/main/res/values/strings.xml | 1 + .../infrared/hub/InfraredHubViewModelTest.kt | 27 ++ .../domain/infrared/CreateVirtualRemote.kt | 26 +- .../domain/infrared/PresetCodeLibrary.kt | 72 +++-- 8 files changed, 321 insertions(+), 202 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index 4498cc8..1ee37cb 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.infrared.hub +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,19 +11,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon +import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -31,6 +36,7 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -42,6 +48,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -51,10 +59,11 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface -import com.flagship.abox.manager.domain.ProviderType -import com.flagship.abox.manager.domain.infrared.PresetBrandOption +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.domain.infrared.TransmissionMode import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId @@ -74,12 +83,11 @@ fun InfraredHubRoute( InfraredHubScreen( state = state, onBack = onBack, + onNavigateToSettings = onOpenSettings, onOpenRemote = onOpenRemote, - onOpenSettings = onOpenSettings, - onSessionInvalid = onSessionInvalid, - onCreateRemote = viewModel::createRemote, onDeleteRemote = viewModel::deleteRemote, - onSaveDeviceName = viewModel::saveDeviceName, + onCreateRemote = viewModel::createRemote, + onSessionInvalid = onSessionInvalid, onMessageShown = viewModel::clearMessage, ) } @@ -89,12 +97,11 @@ fun InfraredHubRoute( internal fun InfraredHubScreen( state: InfraredHubUiState, onBack: () -> Unit, + onNavigateToSettings: () -> Unit, onOpenRemote: (String) -> Unit, - onOpenSettings: () -> Unit, - onSessionInvalid: () -> Unit, - onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode) -> Unit, onDeleteRemote: (VirtualRemoteId) -> Unit, - onSaveDeviceName: (String) -> Unit, + onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit, + onSessionInvalid: () -> Unit, onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -131,7 +138,21 @@ internal fun InfraredHubScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.infrared_hub_title), fontWeight = FontWeight.Bold) }, + title = { + Column { + Text( + text = stringResource(R.string.infrared_hub_title), + fontWeight = FontWeight.Bold, + ) + state.providerDisplayName?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, navigationIcon = { TextButton(onClick = onBack) { Text(stringResource(R.string.infrared_back)) @@ -139,7 +160,7 @@ internal fun InfraredHubScreen( }, actions = { IconButton( - onClick = onOpenSettings, + onClick = onNavigateToSettings, modifier = Modifier.testTag("hub_settings_button"), ) { Text("⚙️", fontSize = 20.sp) @@ -160,71 +181,20 @@ internal fun InfraredHubScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // 1. 顶部环境与 100 槽位看板卡片 - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - val envText = when (state.providerType) { - ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) - ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) - null -> state.profileId?.value ?: "" - } - Text( - text = stringResource(R.string.infrared_mode_label, envText), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - ) + // 1. 硬件槽位配额看板 (100 槽位预算) + QuotaSummaryCard(quota = state.slotQuota) - Text( - text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), - style = MaterialTheme.typography.bodyMedium, - ) - - // 槽位配额指示条 - val progress = state.slotQuota.usedCount.toFloat() / state.slotQuota.totalCapacity - LinearProgressIndicator( - progress = { progress.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().height(8.dp), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource( - R.string.infrared_hub_slot_quota, - state.slotQuota.usedCount, - state.slotQuota.totalCapacity, - state.slotQuota.remainingStaticCount, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = stringResource(R.string.infrared_hub_dynamic_scratch, "100"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary, - fontWeight = FontWeight.Medium, - ) - } - } - } - - // 2. 虚拟遥控器列表标题栏 + // 2. 遥控器列表标题与新建按钮 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.infrared_hub_remotes_title), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + text = stringResource(R.string.infrared_hub_my_remotes), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), ) + Button( onClick = { showCreateDialog = true }, modifier = Modifier.testTag("hub_add_remote_button"), @@ -268,8 +238,8 @@ internal fun InfraredHubScreen( if (showCreateDialog) { CreateVirtualRemoteDialog( onDismiss = { showCreateDialog = false }, - onConfirm = { name, type, brandModel, transMode -> - onCreateRemote(name, type, brandModel, transMode) + onConfirm = { name, type, brandModel, transMode, layoutStyle -> + onCreateRemote(name, type, brandModel, transMode, layoutStyle) showCreateDialog = false }, ) @@ -318,14 +288,65 @@ private fun VirtualRemoteCard( ) val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { - stringResource(R.string.infrared_hub_mode_dynamic, "100") - } else when (remote.transmissionMode) { - TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_hub_mode_static, "15") - TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_hub_mode_dynamic, "100") + "❄️ 状态机动态写码" + } else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { + "🚀 静态独占槽位" + } else { + "📦 动态写码" } + Text( - text = modeText, - style = MaterialTheme.typography.bodySmall, + text = "${remote.layoutStyle.name} · $modeText", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun QuotaSummaryCard(quota: SlotQuotaSummary) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "硬件槽位配额 (总计 100 槽位)", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + Text( + text = "${quota.usedCount} / ${quota.totalCapacity} 已占用", + fontWeight = FontWeight.Bold, + color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontSize = 13.sp, + ) + } + + LinearProgressIndicator( + progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + + Text( + text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个", + fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -336,15 +357,17 @@ private fun VirtualRemoteCard( @Composable private fun CreateVirtualRemoteDialog( onDismiss: () -> Unit, - onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, mode: TransmissionMode) -> Unit, + onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit, ) { var name by remember { mutableStateOf("") } var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) } var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) } + var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) } var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) } var typeDropdownExpanded by remember { mutableStateOf(false) } var brandDropdownExpanded by remember { mutableStateOf(false) } + var layoutDropdownExpanded by remember { mutableStateOf(false) } val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType } @@ -393,6 +416,8 @@ private fun CreateVirtualRemoteDialog( if (matched != null) selectedBrand = matched if (type == RemoteDeviceType.AIR_CONDITIONER) { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH + } else if (type == RemoteDeviceType.CUSTOM) { + transmissionMode = TransmissionMode.STATIC_SLOTS } typeDropdownExpanded = false }, @@ -405,13 +430,71 @@ private fun CreateVirtualRemoteDialog( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("遥控器名称(如:客厅电视)") }, + label = { Text("遥控器名称(如:客厅电视、我的遥控器)") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), ) - // 3. 品牌 / 码库预设 - if (filteredPresets.isNotEmpty()) { + // 3. 自定义遥控器时:选择初始布局模板 + if (selectedType == RemoteDeviceType.CUSTOM) { + ExposedDropdownMenuBox( + expanded = layoutDropdownExpanded, + onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded }, + ) { + OutlinedTextField( + value = when (selectedCustomLayout) { + PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)" + PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)" + PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)" + PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局" + }, + onValueChange = {}, + readOnly = true, + label = { Text("选择初始布局模板") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = layoutDropdownExpanded, + onDismissRequest = { layoutDropdownExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") }, + onClick = { + selectedCustomLayout = PanelLayoutStyle.TV_NAV + layoutDropdownExpanded = false + }, + ) + DropdownMenuItem( + text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") }, + onClick = { + selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER + layoutDropdownExpanded = false + }, + ) + DropdownMenuItem( + text = { Text("🎛️ 自由网格布局 (可自由增删按键)") }, + onClick = { + selectedCustomLayout = PanelLayoutStyle.GRID + layoutDropdownExpanded = false + }, + ) + } + } + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。", + fontSize = 12.sp, + modifier = Modifier.padding(10.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (filteredPresets.isNotEmpty()) { + // 码库预设设备:选择品牌型号 ExposedDropdownMenuBox( expanded = brandDropdownExpanded, onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded }, @@ -441,8 +524,8 @@ private fun CreateVirtualRemoteDialog( } } - // 4. 传输模式选择(非空调设备可选) - if (selectedType != RemoteDeviceType.AIR_CONDITIONER) { + // 4. 传输模式选择(仅对非空调、非纯自定义设备可选) + if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp) @@ -474,13 +557,13 @@ private fun CreateVirtualRemoteDialog( } } } - } else { + } else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)), modifier = Modifier.fillMaxWidth(), ) { Text( - text = "❄️ 空调多维状态(温度、扫风、风速)自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", + text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", fontSize = 12.sp, modifier = Modifier.padding(10.dp), color = MaterialTheme.colorScheme.onSecondaryContainer, @@ -492,8 +575,9 @@ private fun CreateVirtualRemoteDialog( confirmButton = { Button( onClick = { - val finalName = if (name.isNotBlank()) name.trim() else selectedBrand.displayName - onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode) + val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName + val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null + onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout) }, modifier = Modifier.testTag("create_remote_confirm_button"), ) { diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt index 579c57f..bfdad11 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -126,6 +126,7 @@ class InfraredHubViewModel @Inject constructor( type: RemoteDeviceType, brandModel: String? = null, transmissionMode: TransmissionMode, + layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle? = null, ) { val currentProfile = profileId ?: return val currentDevice = state.value.savedDeviceName @@ -137,6 +138,7 @@ class InfraredHubViewModel @Inject constructor( type = type, brandModel = brandModel, transmissionMode = transmissionMode, + layoutStyle = layoutStyle, )) { is DomainResult.Success -> { messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器")) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index 76f130b..34f4dd6 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -87,11 +87,10 @@ fun RemotePanelRoute( onToggleLearnMode = viewModel::toggleLearnMode, onAddCustomButton = viewModel::addCustomButton, onDeleteButton = viewModel::deleteButton, - onSwitchLayout = viewModel::switchLayout, onToggleAcPower = viewModel::toggleAcPower, onAdjustAcTemp = viewModel::adjustAcTemperature, onSetAcMode = viewModel::setAcMode, - onSetAcFanSpeed = viewModel::setAcFanSpeed, + onSetFanSpeed = viewModel::setAcFanSpeed, onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing, onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing, onToggleAcEco = viewModel::toggleAcEco, @@ -109,11 +108,10 @@ internal fun RemotePanelScreen( onToggleLearnMode: () -> Unit, onAddCustomButton: (String) -> Unit, onDeleteButton: (String) -> Unit, - onSwitchLayout: (PanelLayoutStyle) -> Unit, onToggleAcPower: () -> Unit, onAdjustAcTemp: (Int) -> Unit, onSetAcMode: (AcMode) -> Unit, - onSetAcFanSpeed: (AcFanSpeed) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, onToggleAcVerticalSwing: () -> Unit, onToggleAcHorizontalSwing: () -> Unit, onToggleAcEco: () -> Unit, @@ -123,7 +121,6 @@ internal fun RemotePanelScreen( val context = LocalContext.current var showAddKeyDialog by remember { mutableStateOf(false) } - var showMenu by remember { mutableStateOf(false) } LaunchedEffect(state.message) { state.message?.let { msg -> @@ -163,8 +160,14 @@ internal fun RemotePanelScreen( fontSize = 18.sp, ) state.remote?.let { + val layoutLabel = when (it.layoutStyle) { + PanelLayoutStyle.TV_NAV -> "电视导航布局" + PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放布局" + PanelLayoutStyle.GRID -> "自由网格布局" + PanelLayoutStyle.AC_COMPACT -> "空调全状态布局" + } Text( - text = "布局: ${it.layoutStyle.name} | 模式: ${it.transmissionMode.name}", + text = layoutLabel, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -177,53 +180,18 @@ internal fun RemotePanelScreen( } }, actions = { - // 学习模式开关 - FilterChip( - selected = state.isLearnMode, - onClick = onToggleLearnMode, - label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = Color(0xFFFF5252), - selectedLabelColor = Color.White, - ), - modifier = Modifier.testTag("learn_mode_toggle"), - ) - - // 更多选项菜单(添加按键、切换布局) - Box { - IconButton(onClick = { showMenu = true }, modifier = Modifier.testTag("panel_more_menu")) { - Text("⋮", fontSize = 22.sp, fontWeight = FontWeight.Bold) - } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - DropdownMenuItem( - text = { Text("➕ 添加自定义按键") }, - onClick = { - showMenu = false - showAddKeyDialog = true - }, - ) - DropdownMenuItem( - text = { Text("📺 切换为电视导航布局") }, - onClick = { - showMenu = false - onSwitchLayout(PanelLayoutStyle.TV_NAV) - }, - ) - DropdownMenuItem( - text = { Text("🎛️ 切换为网格按键布局") }, - onClick = { - showMenu = false - onSwitchLayout(PanelLayoutStyle.GRID) - }, - ) - DropdownMenuItem( - text = { Text("🎵 切换为媒体播放布局") }, - onClick = { - showMenu = false - onSwitchLayout(PanelLayoutStyle.MEDIA_PLAYER) - }, - ) - } + // 非空调面板显示学习模式开关 + if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) { + FilterChip( + selected = state.isLearnMode, + onClick = onToggleLearnMode, + label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFFFF5252), + selectedLabelColor = Color.White, + ), + modifier = Modifier.testTag("learn_mode_toggle"), + ) } }, ) @@ -256,7 +224,7 @@ internal fun RemotePanelScreen( ) { Text("🔴", fontSize = 16.sp) Text( - text = "学习录制模式已开启:点击下方任意按键,然后将实体遥控器对准转发器按下即可录码", + text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码", color = Color(0xFFC62828), fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -274,7 +242,7 @@ internal fun RemotePanelScreen( onTogglePower = onToggleAcPower, onAdjustTemp = onAdjustAcTemp, onSetMode = onSetAcMode, - onSetFanSpeed = onSetAcFanSpeed, + onSetFanSpeed = onSetFanSpeed, onToggleVSwing = onToggleAcVerticalSwing, onToggleHSwing = onToggleAcHorizontalSwing, onToggleEco = onToggleAcEco, @@ -287,7 +255,6 @@ internal fun RemotePanelScreen( learningButtonId = state.learningButtonId, sendingButtonId = state.sendingButtonId, onButtonClick = onButtonClick, - onDeleteButton = onDeleteButton, ) } PanelLayoutStyle.MEDIA_PLAYER -> { @@ -479,8 +446,9 @@ private fun TvControlPanel( learningButtonId: String?, sendingButtonId: String?, onButtonClick: (String) -> Unit, - onDeleteButton: (String) -> Unit, ) { + val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } } + Column( modifier = Modifier .fillMaxSize() @@ -494,9 +462,9 @@ private fun TvControlPanel( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(8.dp)) @@ -556,9 +524,9 @@ private fun TvControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, ) { - InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } // 底部音量与频道调节柱 @@ -603,6 +571,8 @@ private fun MediaPlayerControlPanel( sendingButtonId: String?, onButtonClick: (String) -> Unit, ) { + val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } } + Column( modifier = Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -612,8 +582,8 @@ private fun MediaPlayerControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(16.dp)) @@ -677,7 +647,7 @@ private fun GenericGridControlPanel( if (bindings.isEmpty()) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - Text("暂无自定义按键,点击上方 ➕ 添加按键", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { LazyVerticalGrid( @@ -713,13 +683,13 @@ private fun GridButtonCard( Card( modifier = Modifier .fillMaxWidth() - .height(72.dp) + .height(76.dp) .clickable(onClick = onClick) .testTag("generic_btn_${binding.buttonId}"), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -727,17 +697,21 @@ private fun GridButtonCard( Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) val statusText = when { isLearning -> "🟡 正在录制信号..." - binding.status == InfraredKeyStatus.READY -> "🟢 已录制" - else -> "⚪ 未录制" + binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})" + else -> "⚪ 待录制 (点击录码)" } Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } if (isSending || isLearning) { CircularProgressIndicator(modifier = Modifier.size(20.dp)) - } else if (isLearnMode) { - IconButton(onClick = onDelete) { - Text("✕", color = Color.Gray, fontSize = 14.sp) + } else { + // 始终提供删除操作按钮 + IconButton( + onClick = onDelete, + modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"), + ) { + Text("🗑️", fontSize = 15.sp) } } } @@ -749,15 +723,23 @@ private fun InteractiveRemoteButton( buttonId: String, label: String, containerColor: Color, + binding: RemoteKeyBinding?, isLearnMode: Boolean, learningButtonId: String?, sendingButtonId: String?, onButtonClick: (String) -> Unit, ) { + val isUnlearned = binding != null && binding.status != InfraredKeyStatus.READY + Button( onClick = { onButtonClick(buttonId) }, colors = ButtonDefaults.buttonColors( - containerColor = if (learningButtonId == buttonId) Color(0xFFFF9800) else containerColor, + containerColor = when { + learningButtonId == buttonId -> Color(0xFFFF9800) + isUnlearned -> MaterialTheme.colorScheme.surfaceVariant + else -> containerColor + }, + contentColor = if (isUnlearned) MaterialTheme.colorScheme.onSurfaceVariant else Color.White, ), modifier = Modifier.testTag("tv_btn_$buttonId"), ) { @@ -792,11 +774,11 @@ private fun AddCustomKeyDialog( title = { Text("添加自定义按键") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("输入按键显示名称,创建后开启右上角【学习模式】即可录制实体信号:", fontSize = 13.sp) + Text("输入按键显示名称,创建后点击按键或开启【学习模式】即可录制实体按键:", fontSize = 13.sp) OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("按键名称(如:静音、音效切换)") }, + label = { Text("按键名称(如:静音、低音增强)") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"), ) diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index c5be839..ef7d8bd 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -88,6 +88,7 @@ 按键已从本机删除 按键尚未就绪,无法发射 红外中枢 + 我的遥控器 硬件槽位:%1$d / %2$d 已占用(剩余 %3$d) 静态占用:%1$d 动态缓冲:Slot %1$s diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 88004a7..9535083 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ Back Infrared Remote Recording + My Remotes Learn Mode 🔴 Learn mode active: tap any button to record ❄️ Air Conditioner diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt index 3742cf3..2c5428a 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt @@ -133,4 +133,31 @@ class InfraredHubViewModelTest { job.cancel() } + + @Test + fun createCustomRemoteWithTvLayoutAssignsLayoutAndStaticSlots() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.createRemote( + name = "Custom TV Remote", + type = RemoteDeviceType.CUSTOM, + brandModel = null, + transmissionMode = TransmissionMode.STATIC_SLOTS, + layoutStyle = com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, + ) + advanceUntilIdle() + + assertEquals(1, vm.state.value.remotes.size) + val created = vm.state.value.remotes.first() + assertEquals("Custom TV Remote", created.displayName) + assertEquals(com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, created.layoutStyle) + assertEquals(TransmissionMode.STATIC_SLOTS, created.transmissionMode) + + job.cancel() + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt index b771bea..cc2f747 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -19,7 +19,8 @@ class CreateVirtualRemote( displayName: String, type: RemoteDeviceType, brandModel: String? = null, - transmissionMode: TransmissionMode, + transmissionMode: TransmissionMode = if (type == RemoteDeviceType.CUSTOM) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH, + layoutStyle: PanelLayoutStyle? = null, ): DomainResult { val trimmedName = displayName.trim() if (trimmedName.isBlank()) { @@ -28,12 +29,14 @@ class CreateVirtualRemote( ) } - val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel) - val quotaResult = repository.getKeyBindings(VirtualRemoteId("dummy")) // or compute from quota - // Obtain slot quota - // We calculate needed static slots: - val neededStaticSlots = if (transmissionMode == TransmissionMode.STATIC_SLOTS) templates.size else 0 + val resolvedLayout = layoutStyle ?: VirtualRemote.defaultLayoutFor(type) + val resolvedTransMode = if (type == RemoteDeviceType.CUSTOM) { + TransmissionMode.STATIC_SLOTS + } else { + transmissionMode + } + val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel, resolvedLayout) val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16)) val now = clock.now() @@ -46,7 +49,8 @@ class CreateVirtualRemote( displayName = trimmedName, type = type, brandModel = brandModel, - transmissionMode = transmissionMode, + layoutStyle = resolvedLayout, + transmissionMode = resolvedTransMode, dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT, acState = initialAcState, createdAtEpochMillis = now, @@ -54,9 +58,7 @@ class CreateVirtualRemote( ) // Build key bindings - val bindings = if (transmissionMode == TransmissionMode.STATIC_SLOTS) { - // Find currently used slots by querying quota - // For now, assign slots incrementally or check + val bindings = if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) { templates.mapIndexed { index, template -> RemoteKeyBinding( id = UUID.randomUUID().toString(), @@ -64,7 +66,7 @@ class CreateVirtualRemote( profileId = profileId, buttonId = template.buttonId, displayName = template.displayName, - assignedSlot = (index + 1).toString(), // Will be allocated based on allocator + assignedSlot = (index + 1).toString(), rawHexCode = template.defaultHexCode, status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED, ) @@ -89,7 +91,7 @@ class CreateVirtualRemote( } // If static and has default hex codes, download to hardware slots - if (transmissionMode == TransmissionMode.STATIC_SLOTS) { + if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) { val codesToDownload = bindings.mapNotNull { b -> if (b.assignedSlot != null && b.rawHexCode != null) { InfraredCode(deviceName, b.assignedSlot, b.rawHexCode) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt index 8785d74..7a521c7 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt @@ -26,7 +26,7 @@ object PresetCodeLibrary { PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN), // 自定义 - PresetBrandOption("custom_blank", "自定义学习遥控器 (Custom)", RemoteDeviceType.CUSTOM), + PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM), ) data class PresetButtonTemplate( @@ -35,24 +35,12 @@ object PresetCodeLibrary { val defaultHexCode: String? = null, ) - fun getTemplatesForType(type: RemoteDeviceType, presetId: String? = null): List = when (type) { - RemoteDeviceType.TV -> listOf( - PresetButtonTemplate("TV_POWER", "电源", "0x00FF02FD"), - PresetButtonTemplate("TV_MUTE", "静音", "0x00FF04FB"), - PresetButtonTemplate("TV_SOURCE", "信号源", "0x00FF06F9"), - PresetButtonTemplate("TV_NAV_UP", "上", "0x00FF08F7"), - PresetButtonTemplate("TV_NAV_DOWN", "下", "0x00FF0AF5"), - PresetButtonTemplate("TV_NAV_LEFT", "左", "0x00FF0CF3"), - PresetButtonTemplate("TV_NAV_RIGHT", "右", "0x00FF0EF1"), - PresetButtonTemplate("TV_OK", "确定", "0x00FF10EF"), - PresetButtonTemplate("TV_HOME", "主页", "0x00FF12ED"), - PresetButtonTemplate("TV_BACK", "返回", "0x00FF14EB"), - PresetButtonTemplate("TV_MENU", "菜单", "0x00FF16E9"), - PresetButtonTemplate("TV_VOL_UP", "音量 +", "0x00FF18E7"), - PresetButtonTemplate("TV_VOL_DOWN", "音量 -", "0x00FF1AE5"), - PresetButtonTemplate("TV_CH_UP", "频道 +", "0x00FF1CE3"), - PresetButtonTemplate("TV_CH_DOWN", "频道 -", "0x00FF1EE1"), - ) + fun getTemplatesForType( + type: RemoteDeviceType, + presetId: String? = null, + layoutStyle: PanelLayoutStyle? = null, + ): List = when (type) { + RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true) RemoteDeviceType.AIR_CONDITIONER -> listOf( PresetButtonTemplate("AC_POWER", "电源"), PresetButtonTemplate("AC_TEMP_UP", "升温"), @@ -70,12 +58,44 @@ object PresetCodeLibrary { PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"), PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"), ) - RemoteDeviceType.CUSTOM -> listOf( - PresetButtonTemplate("CUSTOM_POWER", "电源"), - PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"), - PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"), - PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"), - PresetButtonTemplate("CUSTOM_KEY_4", "按键 4"), - ) + RemoteDeviceType.CUSTOM -> when (layoutStyle) { + PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false) + PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false) + PanelLayoutStyle.AC_COMPACT -> emptyList() + PanelLayoutStyle.GRID, null -> listOf( + PresetButtonTemplate("CUSTOM_POWER", "电源"), + PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"), + PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"), + PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"), + ) + } } + + private fun getTvTemplates(hasHexCode: Boolean): List = listOf( + PresetButtonTemplate("TV_POWER", "电源", if (hasHexCode) "0x00FF02FD" else null), + PresetButtonTemplate("TV_MUTE", "静音", if (hasHexCode) "0x00FF04FB" else null), + PresetButtonTemplate("TV_SOURCE", "信号源", if (hasHexCode) "0x00FF06F9" else null), + PresetButtonTemplate("TV_NAV_UP", "上", if (hasHexCode) "0x00FF08F7" else null), + PresetButtonTemplate("TV_NAV_DOWN", "下", if (hasHexCode) "0x00FF0AF5" else null), + PresetButtonTemplate("TV_NAV_LEFT", "左", if (hasHexCode) "0x00FF0CF3" else null), + PresetButtonTemplate("TV_NAV_RIGHT", "右", if (hasHexCode) "0x00FF0EF1" else null), + PresetButtonTemplate("TV_OK", "确定", if (hasHexCode) "0x00FF10EF" else null), + PresetButtonTemplate("TV_HOME", "主页", if (hasHexCode) "0x00FF12ED" else null), + PresetButtonTemplate("TV_BACK", "返回", if (hasHexCode) "0x00FF14EB" else null), + PresetButtonTemplate("TV_MENU", "菜单", if (hasHexCode) "0x00FF16E9" else null), + PresetButtonTemplate("TV_VOL_UP", "音量 +", if (hasHexCode) "0x00FF18E7" else null), + PresetButtonTemplate("TV_VOL_DOWN", "音量 -", if (hasHexCode) "0x00FF1AE5" else null), + PresetButtonTemplate("TV_CH_UP", "频道 +", if (hasHexCode) "0x00FF1CE3" else null), + PresetButtonTemplate("TV_CH_DOWN", "频道 -", if (hasHexCode) "0x00FF1EE1" else null), + ) + + private fun getMediaPlayerTemplates(hasHexCode: Boolean): List = listOf( + PresetButtonTemplate("MEDIA_POWER", "电源", if (hasHexCode) "0x02FD02FD" else null), + PresetButtonTemplate("MEDIA_MUTE", "静音", if (hasHexCode) "0x02FD04FB" else null), + PresetButtonTemplate("MEDIA_PLAY_PAUSE", "播放/暂停", if (hasHexCode) "0x02FD06F9" else null), + PresetButtonTemplate("MEDIA_PREV", "上一曲", if (hasHexCode) "0x02FD08F7" else null), + PresetButtonTemplate("MEDIA_NEXT", "下一曲", if (hasHexCode) "0x02FD0AF5" else null), + PresetButtonTemplate("MEDIA_REWIND", "快退", if (hasHexCode) "0x02FD0CF3" else null), + PresetButtonTemplate("MEDIA_FF", "快进", if (hasHexCode) "0x02FD0EF1" else null), + ) } -- 2.54.0 From dab1bef5aa9763b8d085404a79c3953d456deecf Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:40:57 +0800 Subject: [PATCH 40/41] feat(infrared): clean up device configuration settings page and remove obsolete learned keys --- .../infrared/ui/InfraredControlScreen.kt | 485 ++++++------------ .../infrared/ui/InfraredControlUiState.kt | 13 +- .../infrared/ui/InfraredControlViewModel.kt | 202 ++------ .../src/main/res/values-zh-rCN/strings.xml | 10 +- .../infrared/src/main/res/values/strings.xml | 9 +- .../ui/InfraredControlViewModelTest.kt | 163 +----- 6 files changed, 225 insertions(+), 657 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index e0b06db..29bb26b 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -9,16 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -29,23 +29,21 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.domain.ProviderType -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.UiText @@ -63,13 +61,7 @@ fun InfraredControlRoute( onSessionInvalid = onSessionInvalid, onDeviceNameChanged = viewModel::onDeviceNameChanged, onSaveDeviceName = viewModel::saveDeviceName, - onLearn = viewModel::learnKey, - onRetry = viewModel::retryLearning, - onSend = viewModel::sendKey, - onRename = viewModel::renameKey, - onDelete = viewModel::deleteKey, onMessageShown = viewModel::clearMessage, - nextSlot = viewModel.nextAvailableKeySlot(), ) } @@ -81,20 +73,11 @@ internal fun InfraredControlScreen( onSessionInvalid: () -> Unit, onDeviceNameChanged: (String) -> Unit, onSaveDeviceName: () -> Unit, - onLearn: (name: String, slot: String?) -> Unit, - onRetry: (InfraredKeyId) -> Unit, - onSend: (InfraredKeyId) -> Unit, - onRename: (InfraredKeyId, String) -> Unit, - onDelete: (InfraredKeyId) -> Unit, onMessageShown: () -> Unit, - nextSlot: Int?, ) { val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current - - var showLearnDialog by remember { mutableStateOf(false) } - var keyToRename by remember { mutableStateOf(null) } - var keyToDelete by remember { mutableStateOf(null) } + val scrollState = rememberScrollState() LaunchedEffect(state.message) { state.message?.let { msg -> @@ -141,113 +124,143 @@ internal fun InfraredControlScreen( .fillMaxSize() .padding(innerPadding), ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // 环境与设备卡片 - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - val envText = when (state.providerType) { - ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) - ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) - null -> state.profileId?.value ?: "" - } - Text( - text = stringResource(R.string.infrared_mode_label, envText), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - ) - - Text( - text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), - style = MaterialTheme.typography.bodyMedium, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = state.deviceNameInput, - onValueChange = onDeviceNameChanged, - label = { Text(stringResource(R.string.infrared_device_name_label)) }, - placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, - singleLine = true, - modifier = Modifier - .weight(1f) - .testTag("infrared_device_name_input"), - ) - Button( - onClick = onSaveDeviceName, - modifier = Modifier.testTag("infrared_save_device_button"), - ) { - Text(stringResource(R.string.infrared_save_device)) - } - } - } + if (state.loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } - - // 已学习按键列表卡片 - Card(modifier = Modifier.fillMaxWidth().weight(1f)) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 1. 当前环境卡片 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource(R.string.infrared_keys_title), + text = "运行环境与连接信息", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), ) - Button( - onClick = { showLearnDialog = true }, - modifier = Modifier.testTag("infrared_learn_new_key_button"), - ) { - Text(stringResource(R.string.infrared_learn_new_key)) - } - } - if (state.keys.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { + val envText = when (state.providerType) { + ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) + ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) + null -> state.profileId?.value ?: "未知环境" + } + Text( + text = stringResource(R.string.infrared_mode_label, envText), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + + state.providerDisplayName?.let { Text( - text = stringResource(R.string.infrared_no_keys), - style = MaterialTheme.typography.bodyMedium, + text = "当前 Profile: $it", + fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), + } + } + + // 2. ABSDK 硬件设备名称配置(修改当前设备名) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.infrared_device_config_section), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + + Text( + text = stringResource(R.string.infrared_device_config_desc), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = stringResource(R.string.infrared_device_name_current, state.savedDeviceName.value), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontSize = 14.sp, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - items(state.keys, key = { it.id.value }) { key -> - InfraredKeyRow( - key = key, - isSending = state.sendingKeyIds.contains(key.id), - onSend = { onSend(key.id) }, - onRetry = { onRetry(key.id) }, - onRename = { keyToRename = key }, - onDelete = { keyToDelete = key }, - ) + OutlinedTextField( + value = state.deviceNameInput, + onValueChange = onDeviceNameChanged, + label = { Text("修改设备名(如:ir-1, ir-living)") }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag("infrared_device_name_input"), + ) + + Button( + onClick = onSaveDeviceName, + enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value, + modifier = Modifier.testTag("infrared_save_device_button"), + ) { + Text(stringResource(R.string.infrared_device_save_changes)) + } + } + } + } + + // 3. 硬件槽位与遥控器概览 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.infrared_device_stats_section), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + + Text( + text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个", + style = MaterialTheme.typography.bodyMedium, + ) + + QuotaDetailProgress(quota = state.slotQuota) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("💡 槽位工作原理:", fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("• 静态独占槽位 (Slot 1..99):用于物理学习录制的按键或预设静态按键,硬件直发低延迟(~20ms)。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("• 动态共享缓冲 (Slot 100):用于空调全状态算法写码与动态模式遥控器,按需现场写入发射,不消耗独占槽位。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -256,213 +269,35 @@ internal fun InfraredControlScreen( } } } - - if (showLearnDialog) { - var keyName by remember { mutableStateOf("") } - var keySlot by remember { mutableStateOf(nextSlot?.toString() ?: "") } - - AlertDialog( - onDismissRequest = { showLearnDialog = false }, - title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.infrared_learn_dialog_prompt)) - OutlinedTextField( - value = keyName, - onValueChange = { keyName = it }, - label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("infrared_learn_name_input"), - ) - OutlinedTextField( - value = keySlot, - onValueChange = { keySlot = it }, - label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("infrared_learn_slot_input"), - ) - } - }, - confirmButton = { - Button( - onClick = { - onLearn(keyName, keySlot) - showLearnDialog = false - }, - enabled = keyName.isNotBlank() && keySlot.isNotBlank(), - modifier = Modifier.testTag("infrared_learn_dialog_confirm"), - ) { - Text(stringResource(R.string.infrared_learn_dialog_start)) - } - }, - dismissButton = { - TextButton(onClick = { showLearnDialog = false }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } - - keyToRename?.let { targetKey -> - var newName by remember { mutableStateOf(targetKey.displayName) } - AlertDialog( - onDismissRequest = { keyToRename = null }, - title = { Text(stringResource(R.string.infrared_rename_dialog_title)) }, - text = { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("infrared_rename_input"), - ) - }, - confirmButton = { - Button( - onClick = { - onRename(targetKey.id, newName) - keyToRename = null - }, - enabled = newName.isNotBlank(), - modifier = Modifier.testTag("infrared_rename_confirm"), - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { keyToRename = null }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } - - keyToDelete?.let { targetKey -> - AlertDialog( - onDismissRequest = { keyToDelete = null }, - title = { Text(stringResource(R.string.infrared_delete_dialog_title)) }, - text = { Text(stringResource(R.string.infrared_delete_dialog_message)) }, - confirmButton = { - Button( - onClick = { - onDelete(targetKey.id) - keyToDelete = null - }, - modifier = Modifier.testTag("infrared_delete_confirm"), - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { keyToDelete = null }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } } @Composable -private fun InfraredKeyRow( - key: SavedInfraredKey, - isSending: Boolean, - onSend: () -> Unit, - onRetry: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .testTag("infrared_key_${key.id.value}"), - ) { - Column( +private fun QuotaDetailProgress(quota: SlotQuotaSummary) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}", + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + ) + Text( + text = "剩余可用静态槽位: ${quota.remainingStaticCount}", + fontSize = 12.sp, + color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + + LinearProgressIndicator( + progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() }, modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = key.displayName, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.infrared_key_slot_label, key.sdkKey), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - val statusLabel = when (key.status) { - InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning) - InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready) - InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed) - } - Text( - text = statusLabel, - style = MaterialTheme.typography.bodyMedium, - color = when (key.status) { - InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary - InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary - InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error - }, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - if (key.status == InfraredKeyStatus.READY) { - Button( - onClick = onSend, - enabled = !isSending, - modifier = Modifier.testTag("infrared_send_${key.id.value}"), - ) { - if (isSending) { - Text(stringResource(R.string.infrared_sending)) - } else { - Text(stringResource(R.string.infrared_send)) - } - } - Spacer(modifier = Modifier.width(8.dp)) - } - - if (key.status == InfraredKeyStatus.FAILED) { - Button( - onClick = onRetry, - modifier = Modifier.testTag("infrared_retry_${key.id.value}"), - ) { - Text(stringResource(R.string.infrared_retry)) - } - Spacer(modifier = Modifier.width(8.dp)) - } - - if (key.status != InfraredKeyStatus.LEARNING) { - OutlinedButton( - onClick = onRename, - modifier = Modifier.testTag("infrared_rename_btn_${key.id.value}"), - ) { - Text(stringResource(R.string.infrared_rename)) - } - Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton( - onClick = onDelete, - modifier = Modifier.testTag("infrared_delete_btn_${key.id.value}"), - ) { - Text(stringResource(R.string.infrared_delete)) - } - } else { - CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) - } - } - } + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt index 1231724..9371ba5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -3,9 +3,7 @@ package com.flagship.abox.manager.infrared.ui import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.infrared.remote.UiText sealed interface InfraredUiMessage { @@ -20,11 +18,8 @@ data class InfraredControlUiState( val providerDisplayName: String? = null, val deviceNameInput: String = "", val savedDeviceName: DeviceName = DeviceName("ir-1"), - val keys: List = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val totalRemotesCount: Int = 0, val loading: Boolean = true, - val sendingKeyIds: Set = emptySet(), val message: InfraredUiMessage? = null, -) { - val hasActiveLearning: Boolean - get() = keys.any { it.status == InfraredKeyStatus.LEARNING } -} +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 42e4375..09fa9ea 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -8,18 +8,10 @@ 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.ProviderProfileStore -import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -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.RecoverInterruptedInfraredLearning -import com.flagship.abox.manager.domain.infrared.RenameInfraredKey -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository import com.flagship.abox.manager.infrared.R -import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator import com.flagship.abox.manager.infrared.remote.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -30,7 +22,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,34 +30,22 @@ import javax.inject.Inject @HiltViewModel class InfraredControlViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val keyRepository: InfraredKeyRepository, private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val virtualRemoteRepository: VirtualRemoteRepository, private val providerProfileStore: ProviderProfileStore, - private val learningCoordinator: InfraredLearningCoordinator, - private val sendInfraredKey: SendInfraredKey, - private val renameInfraredKey: RenameInfraredKey, - private val deleteInfraredKey: DeleteInfraredKey, - private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } - private val deviceNameInputFlow = MutableStateFlow("") - private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) + private val deviceNameInputFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) - init { - if (profileId != null) { - val startupCutoff = System.currentTimeMillis() - learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff) - } - } - private data class ServerState( val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null, val savedDevice: DeviceName = DEFAULT_DEVICE_NAME, - val keys: List = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val remotesCount: Int = 0, val loading: Boolean = false, val configError: DomainError? = null, ) @@ -87,19 +66,20 @@ class InfraredControlViewModel @Inject constructor( ) is DomainResult.Success -> { val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME - keyRepository.observeKeys(profileId, deviceName).map { keysResult -> - when (keysResult) { - is DomainResult.Failure -> ServerState( - providerProfile = active, - savedDevice = deviceName, - configError = keysResult.error, - ) - is DomainResult.Success -> ServerState( - providerProfile = active, - savedDevice = deviceName, - keys = keysResult.value, - ) - } + combine( + virtualRemoteRepository.observeRemotes(profileId, deviceName), + virtualRemoteRepository.observeSlotQuota(profileId, deviceName), + ) { remotesResult, quotaResult -> + val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList() + val quota = (quotaResult as? DomainResult.Success)?.value + ?: SlotQuotaSummary() + ServerState( + providerProfile = active, + savedDevice = deviceName, + slotQuota = quota, + remotesCount = remotes.size, + loading = false, + ) } } } @@ -110,9 +90,8 @@ class InfraredControlViewModel @Inject constructor( val state: StateFlow = combine( serverStateFlow, deviceNameInputFlow, - sendingKeyIdsFlow, messageFlow, - ) { serverState, input, sendingIds, msg -> + ) { serverState, input, msg -> val resolvedMessage = msg ?: serverState.configError?.let { error -> when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid @@ -125,15 +104,18 @@ class InfraredControlViewModel @Inject constructor( ) } } + + val effectiveInput = input ?: serverState.savedDevice.value + InfraredControlUiState( profileId = profileId, providerType = serverState.providerProfile?.type, providerDisplayName = serverState.providerProfile?.displayName, - deviceNameInput = input, + deviceNameInput = effectiveInput, savedDeviceName = serverState.savedDevice, - keys = serverState.keys, + slotQuota = serverState.slotQuota, + totalRemotesCount = serverState.remotesCount, loading = serverState.loading, - sendingKeyIds = sendingIds, message = resolvedMessage, ) }.stateIn( @@ -148,7 +130,8 @@ class InfraredControlViewModel @Inject constructor( fun saveDeviceName() { val currentProfile = profileId ?: return - val trimmed = deviceNameInputFlow.value.trim() + val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value + val trimmed = input.trim() if (trimmed.isBlank()) { messageFlow.value = InfraredUiMessage.Error( UiText.StringResource(R.string.infrared_msg_device_name_blank), @@ -161,133 +144,6 @@ class InfraredControlViewModel @Inject constructor( messageFlow.value = InfraredUiMessage.Info( UiText.StringResource(R.string.infrared_msg_device_name_saved), ) - deviceNameInputFlow.value = "" - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun nextAvailableKeySlot(): Int? { - val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() - return (1..100).firstOrNull { it !in usedSlots } - } - - fun learnKey(displayName: String, keySlotInput: String? = null) { - val currentProfile = profileId ?: return - val savedDevice = state.value.savedDeviceName - val trimmedName = displayName.trim() - if (trimmedName.isBlank()) { - messageFlow.value = InfraredUiMessage.Error( - UiText.StringResource(R.string.infrared_msg_key_name_blank), - ) - return - } - val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) { - val slotInt = keySlotInput.trim().toIntOrNull() - if (slotInt == null || slotInt !in 1..100) { - messageFlow.value = InfraredUiMessage.Error( - UiText.StringResource(R.string.infrared_msg_slot_invalid), - ) - return - } - slotInt.toString() - } else { - val autoSlot = nextAvailableKeySlot() - if (autoSlot == null) { - messageFlow.value = InfraredUiMessage.Error( - UiText.DynamicString("硬件按键槽位已满(上限 100 个),无法自动分配槽位"), - ) - return - } - autoSlot.toString() - } - viewModelScope.launch { - when (val res = learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString).await()) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info( - UiText.StringResource(R.string.infrared_msg_learned_success, listOf(trimmedName)), - ) - } - is DomainResult.Failure -> handleDomainError(res.error) - } - } - } - - fun retryLearning(keyId: InfraredKeyId) { - val currentProfile = profileId ?: return - viewModelScope.launch { - when (val res = learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)).await()) { - is DomainResult.Success -> Unit - is DomainResult.Failure -> handleDomainError(res.error) - } - } - } - - fun sendKey(keyId: InfraredKeyId) { - val currentProfile = profileId ?: return - if (state.value.sendingKeyIds.contains(keyId)) return - - val key = state.value.keys.find { it.id == keyId } - if (key == null || key.status != InfraredKeyStatus.READY) { - messageFlow.value = InfraredUiMessage.Error( - UiText.StringResource(R.string.infrared_msg_key_not_ready), - ) - return - } - - sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId - viewModelScope.launch { - try { - when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info( - UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(key.displayName)), - ) - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } finally { - sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId - } - } - } - - fun renameKey(keyId: InfraredKeyId, displayName: String) { - val currentProfile = profileId ?: return - val trimmed = displayName.trim() - if (trimmed.isBlank()) { - messageFlow.value = InfraredUiMessage.Error( - UiText.StringResource(R.string.infrared_msg_key_name_blank), - ) - return - } - viewModelScope.launch { - when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info( - UiText.StringResource(R.string.infrared_msg_key_renamed), - ) - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun deleteKey(keyId: InfraredKeyId) { - val currentProfile = profileId ?: return - viewModelScope.launch { - when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info( - UiText.StringResource(R.string.infrared_msg_key_deleted), - ) } is DomainResult.Failure -> { handleDomainError(result.error) diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index ef7d8bd..6e086c4 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -75,12 +75,16 @@ 电视-频道- 电视-主页 电视-返回 - 电视-菜单 - 红外按键管理 + 红外设备与环境配置 + ABSDK 设备名称配置 + ABSDK 依靠此设备名称(如 ir-1、ir-living)寻址具体的硬件转发器,请确保名称与主机端保持一致。 + 当前绑定设备:%1$s + 保存修改 + 硬件槽位与遥控器概览 运行环境:%1$s 本地体验(模拟) ABox(真实硬件) - 设备名称已保存 + 设备名称已成功保存 设备名称不能为空 按键名称不能为空 按键编号必须在 1 到 100 之间 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 9535083..fd8f204 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -77,11 +77,16 @@ TV Home TV Back TV Menu - Infrared Key Management + Device Configuration + ABSDK Device Name + ABSDK matches hardware devices by device name (e.g. ir-1, ir-living). Commands will be dispatched to this target device. + Current Bound Device: %1$s + Save Changes + Hardware Slot Budget Environment: %1$s Local Experience (Simulation) ABox (Real Hardware) - Device name saved + Device name saved successfully Device name cannot be blank Key name cannot be blank Key slot must be between 1 and 100 diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt index aca8f9b..b9b1485 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -2,29 +2,19 @@ package com.flagship.abox.manager.infrared.ui import androidx.lifecycle.SavedStateHandle import com.flagship.abox.manager.domain.DeviceName -import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType -import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote import com.flagship.abox.manager.domain.infrared.EpochMillisClock -import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory -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.LearnInfraredKey -import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning -import com.flagship.abox.manager.domain.infrared.RenameInfraredKey -import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode import com.flagship.abox.manager.infrared.R -import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator import com.flagship.abox.manager.infrared.remote.UiText import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway -import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -36,8 +26,6 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -55,25 +43,15 @@ class InfraredControlViewModelTest { private val device = DeviceName("ir-living") private val clock = EpochMillisClock { 10_000L } - private val keyRepo = FakeInfraredKeyRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val remoteRepo = FakeVirtualRemoteRepository() private val profileStore = FakeProviderProfileStore() private val gateway = FakeInfraredGateway() - private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock) - - private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) - private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) - private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock) - private val send = SendInfraredKey(keyRepo, gateway) - private val rename = RenameInfraredKey(keyRepo, clock) - private val delete = DeleteInfraredKey(keyRepo) - private val recover = RecoverInterruptedInfraredLearning(keyRepo, clock) - private lateinit var coordinator: InfraredLearningCoordinator + private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) @Before fun setup() { Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope) } @After @@ -84,34 +62,16 @@ class InfraredControlViewModelTest { private fun createViewModel(): InfraredControlViewModel { return InfraredControlViewModel( savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")), - keyRepository = keyRepo, deviceConfigurationRepository = deviceRepo, + virtualRemoteRepository = remoteRepo, providerProfileStore = profileStore, - learningCoordinator = coordinator, - sendInfraredKey = send, - renameInfraredKey = rename, - deleteInfraredKey = delete, - recoverInterruptedInfraredLearning = recover, ) } @Test - fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest { + fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest { profileStore.saveProfile(providerProfile) deviceRepo.setDeviceName(profileId, device) - val readyKey = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = profileId, - deviceName = device, - displayName = "Power", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepo.insert(readyKey) val vm = createViewModel() val job = launch { vm.state.collect {} } @@ -119,8 +79,8 @@ class InfraredControlViewModelTest { assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType) assertEquals(device, vm.state.value.savedDeviceName) - assertEquals(1, vm.state.value.keys.size) - assertEquals("Power", vm.state.value.keys.first().displayName) + assertEquals("ir-living", vm.state.value.deviceNameInput) + assertEquals(0, vm.state.value.totalRemotesCount) job.cancel() } @@ -165,111 +125,24 @@ class InfraredControlViewModelTest { } @Test - fun learnKeyTriggersCoordinator() = testScope.runTest { + fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest { profileStore.saveProfile(providerProfile) deviceRepo.setDeviceName(profileId, device) - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.learnKey("Volume Up", "5") - advanceUntilIdle() - - assertEquals(1, gateway.learnedKeys.size) - assertEquals("5", gateway.learnedKeys.first().key) - - job.cancel() - } - - @Test - fun sendKeyTriggersSendInfraredKeyWhenReady() = testScope.runTest { - profileStore.saveProfile(providerProfile) - deviceRepo.setDeviceName(profileId, device) - val readyKey = SavedInfraredKey( - id = InfraredKeyId("k1"), + createRemote( profileId = profileId, deviceName = device, - displayName = "Power", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, + displayName = "Living TV", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, ) - keyRepo.insert(readyKey) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() - vm.sendKey(InfraredKeyId("k1")) - advanceUntilIdle() - - assertEquals(1, gateway.sentKeys.size) - assertEquals("1", gateway.sentKeys.first().key) - - job.cancel() - } - - @Test - fun renameKeyUpdatesKeyName() = testScope.runTest { - profileStore.saveProfile(providerProfile) - deviceRepo.setDeviceName(profileId, device) - val readyKey = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = profileId, - deviceName = device, - displayName = "Power", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepo.insert(readyKey) - - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.renameKey(InfraredKeyId("k1"), "Main Power") - advanceUntilIdle() - - val updated = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value!! - assertEquals("Main Power", updated.displayName) - - job.cancel() - } - - @Test - fun deleteKeyRemovesKey() = testScope.runTest { - profileStore.saveProfile(providerProfile) - deviceRepo.setDeviceName(profileId, device) - val readyKey = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = profileId, - deviceName = device, - displayName = "Power", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepo.insert(readyKey) - - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.deleteKey(InfraredKeyId("k1")) - advanceUntilIdle() - - val result = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value - assertEquals(null, result) + assertEquals(1, vm.state.value.totalRemotesCount) + assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size) + assertEquals(16, vm.state.value.slotQuota.usedCount) job.cancel() } -- 2.54.0 From 2cc70b0ca31370f5dfa99686e668744b09d5b634 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:49:55 +0800 Subject: [PATCH 41/41] =?UTF-8?q?fix(infrared):=20correct=20default=20devi?= =?UTF-8?q?ce=20name=20from=20ir-1=20to=20=E7=BA=A2=E5=A4=96=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flagship/abox/manager/infrared/hub/InfraredHubUiState.kt | 2 +- .../flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt | 2 +- .../abox/manager/infrared/remote/InfraredRemoteViewModel.kt | 2 +- .../flagship/abox/manager/infrared/ui/InfraredControlScreen.kt | 2 +- .../flagship/abox/manager/infrared/ui/InfraredControlUiState.kt | 2 +- .../abox/manager/infrared/ui/InfraredControlViewModel.kt | 2 +- android/feature/infrared/src/main/res/values-zh-rCN/strings.xml | 2 +- android/feature/infrared/src/main/res/values/strings.xml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt index 0599c8c..06d10b5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt @@ -17,7 +17,7 @@ data class InfraredHubUiState( val profileId: ProviderProfileId? = null, val providerType: ProviderType? = null, val providerDisplayName: String? = null, - val savedDeviceName: DeviceName = DeviceName("ir-1"), + val savedDeviceName: DeviceName = DeviceName("红外控制器"), val remotes: List = emptyList(), val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), val loading: Boolean = true, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt index bfdad11..9e69efd 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -195,6 +195,6 @@ class InfraredHubViewModel @Inject constructor( } companion object { - val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + val DEFAULT_DEVICE_NAME = DeviceName("红外控制器") } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 2e1806c..50057c9 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -326,6 +326,6 @@ class InfraredRemoteViewModel @Inject constructor( } companion object { - val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + val DEFAULT_DEVICE_NAME = DeviceName("红外控制器") } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index 29bb26b..c9b0674 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -210,7 +210,7 @@ internal fun InfraredControlScreen( OutlinedTextField( value = state.deviceNameInput, onValueChange = onDeviceNameChanged, - label = { Text("修改设备名(如:ir-1, ir-living)") }, + label = { Text("修改设备名(如:红外控制器)") }, singleLine = true, modifier = Modifier .weight(1f) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt index 9371ba5..92602dc 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -17,7 +17,7 @@ data class InfraredControlUiState( val providerType: ProviderType? = null, val providerDisplayName: String? = null, val deviceNameInput: String = "", - val savedDeviceName: DeviceName = DeviceName("ir-1"), + val savedDeviceName: DeviceName = DeviceName("红外控制器"), val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), val totalRemotesCount: Int = 0, val loading: Boolean = true, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 09fa9ea..2d494ac 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -179,6 +179,6 @@ class InfraredControlViewModel @Inject constructor( } companion object { - val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + val DEFAULT_DEVICE_NAME = DeviceName("红外控制器") } } diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index 6e086c4..4382170 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -77,7 +77,7 @@ 电视-返回 红外设备与环境配置 ABSDK 设备名称配置 - ABSDK 依靠此设备名称(如 ir-1、ir-living)寻址具体的硬件转发器,请确保名称与主机端保持一致。 + ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。 当前绑定设备:%1$s 保存修改 硬件槽位与遥控器概览 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index fd8f204..e9a5a6f 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -79,7 +79,7 @@ TV Menu Device Configuration ABSDK Device Name - ABSDK matches hardware devices by device name (e.g. ir-1, ir-living). Commands will be dispatched to this target device. + ABSDK matches hardware devices by device name (default: 红外控制器). Commands will be dispatched to this target device. Current Bound Device: %1$s Save Changes Hardware Slot Budget -- 2.54.0