50 KiB
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(), ordownloadCodes(). - Do not claim that ABSDK returns a learned raw infrared code;
learnKey()remainsDomainResult<Unit>. - Do not implement or label any action as “stop hardware learning”; ABSDK 0630 exposes no stop-learning API.
- Store
displayNameseparately from immutablesdkKey; 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
LEARNINGbefore hardware invocation; success becomesREADY, failure becomesFAILED. - Retry learning reuses the same
InfraredKeyIdandsdkKey. - Interrupted
LEARNINGrecords recover conservatively toFAILED; 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
CleartextAbsdkTransportopt-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
mainbranch has no implemented login screen despite the product wording “post-login page.” Use the existingFoundationScreenas the temporary navigation host. Real hardware calls will returnTokenInvaliduntil a login implementation authenticates the sameAbsdkBridgeinstance; do not add credential collection to the infrared feature. - Work only on a dedicated branch based on updated
main; submit through a PR targetingmain. - 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.ktsandsrc/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 realAbsdkBridgebinding.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, andDomainError. - 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
LearnInfraredKeywith 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
SendInfraredKeyminimally
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:
InfraredKeyRepositoryandInfraredDeviceConfigurationRepository. -
Produces: per-call scripted
FakeInfraredGatewayplus 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
FakeInfraredKeyRepositoryandFakeInfraredDeviceConfigurationRepository
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:
InfraredControlUiStateand 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 sharedAbsdkBridge. -
Produces: one injected
InfraredGatewaythat 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 argumentprofileId. -
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
InfraredGatewaycontract. -
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:
- authenticate the shared
AbsdkBridgethrough the login feature; - enter the real
irDevName; - learn a unique key;
- immediately send the same key;
- repeat send;
- relearn the same SDK key;
- test controller offline;
- test no remote signal/timeout;
- leave the page during learning;
- kill/restart the process with LEARNING persisted;
- expire the token;
- record whether undocumented
dicDatasexists 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.