diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index f8843f4..08eb8de 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -64,6 +64,7 @@ dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
implementation(project(":android:core:session"))
+ implementation(project(":android:feature:infrared"))
implementation(project(":absdk-bridge"))
implementation(project(":runtime:simulation"))
debugImplementation(project(":testing:fakes"))
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..c0638bc
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt
new file mode 100644
index 0000000..b3b6c01
--- /dev/null
+++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGateway.kt
@@ -0,0 +1,41 @@
+package com.flagship.abox.manager.di
+
+import com.flagship.abox.manager.domain.DeviceName
+import com.flagship.abox.manager.domain.DomainResult
+import com.flagship.abox.manager.domain.InfraredCode
+import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
+import com.flagship.abox.manager.domain.InfraredGateway
+import com.flagship.abox.manager.domain.ProviderProfileId
+import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
+import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
+
+class DebugRoutingInfraredGateway(
+ private val modeStore: InfraredRuntimeModeStore,
+ private val fakeGateway: InfraredGateway,
+ private val realGateway: InfraredGateway,
+) : InfraredGateway {
+
+ private suspend fun delegate(): InfraredGateway {
+ return when (modeStore.currentMode()) {
+ InfraredRuntimeMode.REAL_ABSDK -> realGateway
+ InfraredRuntimeMode.FAKE -> fakeGateway
+ }
+ }
+
+ override suspend fun sendKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult = delegate().sendKey(profileId, deviceName, key)
+
+ override suspend fun learnKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult = delegate().learnKey(profileId, deviceName, key)
+
+ override suspend fun downloadCodes(
+ profileId: ProviderProfileId,
+ codes: List,
+ ): DomainResult> = delegate().downloadCodes(profileId, codes)
+}
diff --git a/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt b/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt
new file mode 100644
index 0000000..705c2b0
--- /dev/null
+++ b/android/app/src/release/kotlin/com/flagship/abox/manager/di/InfraredReleaseModule.kt
@@ -0,0 +1,47 @@
+package com.flagship.abox.manager.di
+
+import com.flagship.abox.manager.domain.DeviceName
+import com.flagship.abox.manager.domain.DomainError
+import com.flagship.abox.manager.domain.DomainResult
+import com.flagship.abox.manager.domain.InfraredCode
+import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
+import com.flagship.abox.manager.domain.InfraredGateway
+import com.flagship.abox.manager.domain.ProviderProfileId
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object InfraredReleaseModule {
+
+ @Provides
+ @Singleton
+ fun provideInfraredGateway(): InfraredGateway = object : InfraredGateway {
+ override suspend fun sendKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult = disabledFailure()
+
+ override suspend fun learnKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult = disabledFailure()
+
+ override suspend fun downloadCodes(
+ profileId: ProviderProfileId,
+ codes: List,
+ ): DomainResult> = disabledFailure()
+
+ private fun disabledFailure(): DomainResult = DomainResult.Failure(
+ DomainError.OperationFailed(
+ externalCode = "REAL_ABSDK_DISABLED",
+ detail = "Infrared hardware transport is disabled in release builds",
+ ),
+ )
+ }
+}
diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt
new file mode 100644
index 0000000..339dc83
--- /dev/null
+++ b/android/app/src/test/kotlin/com/flagship/abox/manager/di/DebugRoutingInfraredGatewayTest.kt
@@ -0,0 +1,70 @@
+package com.flagship.abox.manager.di
+
+import com.flagship.abox.manager.domain.DeviceName
+import com.flagship.abox.manager.domain.DomainResult
+import com.flagship.abox.manager.domain.InfraredCode
+import com.flagship.abox.manager.domain.ProviderProfileId
+import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
+import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
+import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+private class TestModeStore : InfraredRuntimeModeStore {
+ private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
+ override val mode: Flow = modeFlow
+ override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
+ override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult {
+ modeFlow.value = mode
+ return DomainResult.Success(Unit)
+ }
+}
+
+class DebugRoutingInfraredGatewayTest {
+
+ private val modeStore = TestModeStore()
+ private val fake = FakeInfraredGateway()
+ private val real = FakeInfraredGateway()
+ private val router = DebugRoutingInfraredGateway(modeStore, fake, real)
+
+ private val profile = ProviderProfileId("profile1")
+ private val device = DeviceName("ir-1")
+
+ @Test
+ fun delegatesToFakeByDefault() = runTest {
+ val result = router.sendKey(profile, device, "key1")
+ assertTrue(result is DomainResult.Success)
+ assertEquals(1, fake.sentKeys.size)
+ assertTrue(real.sentKeys.isEmpty())
+
+ router.learnKey(profile, device, "key2")
+ assertEquals(1, fake.learnedKeys.size)
+ assertTrue(real.learnedKeys.isEmpty())
+
+ router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code")))
+ assertEquals(1, fake.downloadCalls.size)
+ assertTrue(real.downloadCalls.isEmpty())
+ }
+
+ @Test
+ fun delegatesToRealWhenModeIsRealAbsdk() = runTest {
+ modeStore.setMode(InfraredRuntimeMode.REAL_ABSDK)
+
+ val result = router.sendKey(profile, device, "key1")
+ assertTrue(result is DomainResult.Success)
+ assertTrue(fake.sentKeys.isEmpty())
+ assertEquals(1, real.sentKeys.size)
+
+ router.learnKey(profile, device, "key2")
+ assertTrue(fake.learnedKeys.isEmpty())
+ assertEquals(1, real.learnedKeys.size)
+
+ router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code")))
+ assertTrue(fake.downloadCalls.isEmpty())
+ assertEquals(1, real.downloadCalls.size)
+ }
+}