Files
abox-mgr/docs/superpowers/plans/2026-08-24-infrared-control.md
T
MolerandKremeCN 0c783ca2f4 feat(infrared): add infrared control, learning, and virtual remote module (#9)
### 概述 / Summary
完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。

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

Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 15:00:06 +08:00

50 KiB
Raw Blame History

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<Unit>.
  • 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:
@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<DomainResult<List<SavedInfraredKey>>>
    suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
    suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
    suspend fun update(key: SavedInfraredKey): DomainResult<Unit>
    suspend fun delete(ref: InfraredKeyRef): DomainResult<Unit>
    suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult<List<SavedInfraredKey>>
}

interface InfraredDeviceConfigurationRepository {
    fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
    suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
}

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<SavedInfraredKey>
}
  • Step 1: Write failing SDK-key factory tests
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:

./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
class InfraredKeyFactory(
    private val idGenerator: InfraredKeyIdGenerator,
    private val clock: EpochMillisClock,
) {
    fun create(
        profileId: ProviderProfileId,
        deviceName: DeviceName,
        displayName: String,
    ): DomainResult<SavedInfraredKey> {
        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
./gradlew :domain:test

Expected: all tests pass.

  • Step 6: Commit the contracts
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:
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<InfraredKeyId>
}

class RetryInfraredKeyLearning(
    private val repository: InfraredKeyRepository,
    private val gateway: InfraredGateway,
    private val clock: EpochMillisClock,
) {
    suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit>
}
  • 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
@Test
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
    val events = mutableListOf<String>()
    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
./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:

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
@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:

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
./gradlew :domain:test

Expected: all domain tests pass.

  • Step 8: Commit learning orchestration
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:
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
@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
./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
@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
./gradlew :domain:test
  • Step 7: Commit key management
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

@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
./gradlew :testing:fakes:test --tests "*.FakeDeviceGatewaysTest"
  • Step 3: Add keyed result maps while retaining existing defaults

Use a private key:

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<List<SavedInfraredKey>>, synchronize mutations, and return DomainError.InvalidRequest for duplicate IDs or duplicate (profileId, deviceName, sdkKey) bindings.

  • Step 6: Run all fake tests
./gradlew :testing:fakes:test
  • Step 7: Commit fake improvements
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:

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
@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
./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin

Expected: failure because Room types and repository do not exist.

  • Step 5: Implement Room entities and constraints
@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
./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
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:
enum class InfraredRuntimeMode { FAKE, REAL_ABSDK }

interface InfraredRuntimeModeStore {
    val mode: Flow<InfraredRuntimeMode>
    suspend fun currentMode(): InfraredRuntimeMode
    suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit>
}

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
@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
@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
./gradlew :android:feature:infrared:testDebugUnitTest :android:feature:infrared:compileDebugAndroidTestKotlin
  • Step 7: Commit runtime orchestration
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<InfraredControlUiState> plus intent methods.
data class InfraredControlUiState(
    val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
    val effectiveProfileId: ProviderProfileId? = null,
    val deviceNameInput: String = "",
    val savedDeviceName: DeviceName? = null,
    val keys: List<SavedInfraredKey> = emptyList(),
    val loading: Boolean = true,
    val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
    val message: InfraredUiMessage? = null,
) {
    val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
}

Intent methods:

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:

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
./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
./gradlew :android:feature:infrared:testDebugUnitTest
  • Step 7: Commit ViewModel
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:
@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:

infrared_mode_label
infrared_device_name_input
infrared_save_device
infrared_learn_new_key
infrared_key_<id>
infrared_send_<id>
infrared_retry_<id>

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
./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:

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
./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
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

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:

@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
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application android:usesCleartextTraffic="true" />
</manifest>

This file exists only under src/debug.

  • Step 6: Add a release-disabled gateway

InfraredReleaseModule provides an InfraredGateway whose three methods return:

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
./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
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
./gradlew :android:app:compileDebugAndroidTestKotlin
  • Step 3: Add the route and temporary entry

Use:

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
./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
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:

@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:

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
./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
./gradlew :domain:test :testing:fakes:test :absdk-bridge:test
  • Step 7: Commit bridge verification
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:

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
./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
./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
./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:

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:

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