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] 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`.