Compare commits

...
41 Commits
Author SHA1 Message Date
KremeCN 2cc70b0ca3 fix(infrared): correct default device name from ir-1 to 红外控制器 2026-08-25 14:49:55 +08:00
KremeCN dab1bef5aa feat(infrared): clean up device configuration settings page and remove obsolete learned keys 2026-08-25 14:40:57 +08:00
KremeCN ec51ae1db7 feat(infrared): choose layout template at creation time and improve grid key management 2026-08-25 14:34:47 +08:00
KremeCN 849b7e4aea fix(infrared): allocate dedicated static slot for physically learned keys 2026-08-25 14:22:47 +08:00
KremeCN 63cac722c2 feat(infrared): add virtual remote learn mode, custom layout switching, and key management 2026-08-25 14:20:30 +08:00
KremeCN 7f9fb8c2b6 feat(infrared): refactor virtual remote hub with multi-device presets and quota management 2026-08-25 14:11:49 +08:00
KremeCN 86af7f4c91 feat(infrared): integrate learned keys management screen and sync provider profile mode 2026-08-25 13:39:01 +08:00
KremeCN 4aa9116ac4 refactor(infrared): unify infrared remote with app provider architecture and complete localization 2026-08-25 13:25:04 +08:00
KremeCN 2185e73f5e fix(infrared): add cutoff timestamp to failInterruptedLearning to prevent race with subsequent tasks 2026-08-25 13:07:38 +08:00
KremeCN a3812aae27 fix(infrared): propagate repository storage errors and recover learning keys across effective profiles 2026-08-25 12:51:47 +08:00
KremeCN b6cae9b2c9 fix(infrared): add relearn use case for ready keys and clean up interrupted learning in onCleared and init 2026-08-25 12:44:07 +08:00
KremeCN 8cd44c93e1 fix(infrared): unify atomic config state snapshot, avoid message race, and rollback cancelled learning status 2026-08-25 12:36:19 +08:00
KremeCN 3561f3dd4a fix(infrared): url-encode profileId in routes and cancel underlying learning deferred 2026-08-25 12:27:54 +08:00
KremeCN 8682f81cb8 fix(infrared): propagate learning error result and localize remote screen strings 2026-08-25 12:20:29 +08:00
KremeCN a3bcdd973c fix(infrared): support re-learning existing keys, cancel active learning jobs, and propagate CancellationException 2026-08-25 12:14:12 +08:00
KremeCN 714fe699f6 fix(infrared): clear learning state on completion and guard against duplicate key sending 2026-08-25 12:05:02 +08:00
KremeCN ee42d59879 refactor: align infrared feature with provider login architecture and remove legacy temporary login flow 2026-08-25 11:41:06 +08:00
Moler 1fdc9bd376 feat: wire infrared remote screen and settings navigation 2026-08-25 11:41:06 +08:00
Moler 9dc5226d83 feat: build custom remote visual components and Compose panels 2026-08-25 11:40:46 +08:00
Moler 3a79338fb9 feat: implement infrared remote control viewmodel 2026-08-25 11:40:46 +08:00
Moler feab58bf3d feat: define infrared remote scenes and button specifications 2026-08-25 11:40:46 +08:00
Moler 4c8403e45f docs: plan infrared remote control UI implementation 2026-08-25 11:40:46 +08:00
Moler 29de3f2eb5 docs: design infrared remote control multi-scene UI 2026-08-25 11:40:45 +08:00
Moler 78dac5519a feat: support 1-100 numeric infrared key slots and gateway login entry 2026-08-25 11:40:45 +08:00
Moler aec43df775 docs: add infrared hardware validation 2026-08-25 11:40:45 +08:00
Moler 1376702476 test: verify ABSDK infrared contracts 2026-08-25 11:40:45 +08:00
Moler 03a3167a55 feat: add temporary infrared navigation 2026-08-25 11:40:45 +08:00
Moler 980fd38421 feat: route infrared debug hardware modes 2026-08-25 11:40:23 +08:00
Moler 116f8d1b75 feat: add infrared control screen 2026-08-25 11:40:23 +08:00
Moler 6e83a78a81 feat: add infrared control state machine 2026-08-25 11:40:23 +08:00
Moler 9ccbfbc297 feat: coordinate infrared runtime state 2026-08-25 11:40:23 +08:00
Moler ff85b3eb45 feat: persist infrared key bindings 2026-08-25 11:40:23 +08:00
Moler 616d97ca03 fix: snapshot infrared fake calls 2026-08-25 11:40:23 +08:00
Moler 82afee3a1b test: add scriptable infrared fakes 2026-08-25 11:40:23 +08:00
Moler 0e82daaa23 fix: preserve infrared failure context 2026-08-25 11:40:23 +08:00
Moler ee60b75a32 feat: manage saved infrared keys 2026-08-25 11:40:23 +08:00
Moler 274807c2ff feat: orchestrate infrared key learning 2026-08-25 11:40:22 +08:00
Moler 4ff6ef6c43 fix: harden infrared key contracts 2026-08-25 11:40:22 +08:00
Moler f1f00701d4 feat: define infrared key contracts 2026-08-25 11:40:22 +08:00
Moler cd564cbfa1 docs: plan infrared control implementation 2026-08-25 11:40:22 +08:00
Moler d763d5dea7 docs: design infrared learning and sending 2026-08-25 11:40:22 +08:00
78 changed files with 12785 additions and 15 deletions
@@ -17,10 +17,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.reflect.KClass
class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1")
private val irDevice = DeviceName("ir-room")
@Test
fun socketControlReturnsConfirmedStatus() = runTest {
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
}
@Test
fun infraredSendForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
val errorMappings = mapOf<String, KClass<out DomainError>>(
"20201" to DomainError.InvalidRequest::class,
"20202" to DomainError.InvalidRequest::class,
"20203" to DomainError.DeviceNotFound::class,
"20204" to DomainError.OperationFailed::class,
"20205" to DomainError.OperationFailed::class,
"20206" to DomainError.DeviceOffline::class,
)
for ((code, expectedClass) in errorMappings) {
val client = ScriptedAbsdkClient().apply {
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
assertEquals(code, sendResult.error.externalCode)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
assertEquals(code, learnResult.error.externalCode)
}
}
@Test
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
val client = ScriptedAbsdkClient().apply {
sendResponse = null
learnResponse = null
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue(sendResult is DomainResult.Failure)
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue(learnResult is DomainResult.Failure)
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
}
@Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
assertEquals(1, client.maxConcurrentCalls.get())
}
}
@Test
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
withTimeout(5_000) {
val profile = ProviderProfileId("abox")
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
)
}
val bridge = AbsdkBridge(client, Dispatchers.IO)
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
client.onCall = {
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
Thread.sleep(75)
}
}
val irCall = async {
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
}
val tempCall = async {
bridge.getReading(profile, DeviceName("th-1"))
}
delay(10)
irCall.await()
tempCall.await()
assertEquals(1, client.maxConcurrentCalls.get())
}
}
}
+1
View File
@@ -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"))
@@ -11,9 +11,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
@@ -57,18 +68,93 @@ fun ABoxManagerApp(
onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel,
)
is AppSessionUiState.Authenticated -> MainAppShell(
userName = state.username.value,
providerName = state.profile.displayName,
onLogout = sessionActionsViewModel::logout,
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
username = state.username,
profile = state.profile,
actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage,
onLogout = sessionActionsViewModel::logout,
)
}
}
}
}
@Composable
private fun AuthenticatedNavHost(
username: Username,
profile: ProviderProfile,
actionInProgress: Boolean,
errorMessage: String?,
onLogout: () -> Unit,
) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home",
) {
composable("home") {
MainAppShell(
userName = username.value,
providerName = profile.displayName,
onLogout = onLogout,
actionInProgress = actionInProgress,
errorMessage = errorMessage,
devicesSlot = {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen(
onInfraredClick = {
navController.navigate("infrared_hub/$encodedProfileId")
},
)
},
)
}
composable(
route = "infrared_hub/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
InfraredHubRoute(
onBack = { navController.popBackStack() },
onOpenRemote = { remoteId ->
val encodedRemoteId = android.net.Uri.encode(remoteId)
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
},
onOpenSettings = {
navController.navigate("infrared_settings/$encodedProfileId")
},
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_panel/{profileId}/{remoteId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("remoteId") { type = NavType.StringType },
),
) {
RemotePanelRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_settings/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
InfraredControlRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = onLogout,
)
}
}
}
private fun AppSessionUiState.screenKey(): String = when (this) {
AppSessionUiState.Loading -> "loading"
AppSessionUiState.ProviderSelection -> "providers"
@@ -23,7 +23,6 @@
<string name="action_switch_provider">切换提供方</string>
<string name="authenticated_title">已连接到 %1$s</string>
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
<!-- 认证后壳层 -->
<string name="tab_devices">设备</string>
<string name="tab_scenes">情景</string>
@@ -52,4 +51,6 @@
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
<string name="device_infrared_title">红外控制器</string>
<string name="device_infrared_description">进入红外控制器相关功能。</string>
<string name="infrared_control_action">红外控制</string>
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
</resources>
+2 -1
View File
@@ -23,7 +23,6 @@
<string name="action_switch_provider">Switch provider</string>
<string name="authenticated_title">Connected to %1$s</string>
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
<!-- Authenticated shell -->
<string name="tab_devices">Devices</string>
<string name="tab_scenes">Scenes</string>
@@ -52,4 +51,6 @@
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
<string name="device_infrared_title">Infrared Controller</string>
<string name="device_infrared_description">Access infrared controller features.</string>
<string name="infrared_control_action">Infrared Control</string>
<string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</string>
</resources>
+75
View File
@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager.infrared"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
kapt {
correctErrorTypes = true
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
kapt(libs.room.compiler)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(project(":testing:fakes"))
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -0,0 +1,136 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "be673e12eb4c5275aaa6ef6fc189a861",
"entities": [
{
"tableName": "infrared_keys",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sdkKey` TEXT NOT NULL, `status` TEXT NOT NULL, `lastErrorCode` TEXT, `lastErrorDetail` TEXT, `createdAtEpochMillis` INTEGER NOT NULL, `updatedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceName",
"columnName": "deviceName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sdkKey",
"columnName": "sdkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastErrorCode",
"columnName": "lastErrorCode",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastErrorDetail",
"columnName": "lastErrorDetail",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAtEpochMillis",
"columnName": "createdAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAtEpochMillis",
"columnName": "updatedAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_infrared_keys_profileId_deviceName",
"unique": false,
"columnNames": [
"profileId",
"deviceName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName` ON `${TABLE_NAME}` (`profileId`, `deviceName`)"
},
{
"name": "index_infrared_keys_profileId_deviceName_sdkKey",
"unique": true,
"columnNames": [
"profileId",
"deviceName",
"sdkKey"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName_sdkKey` ON `${TABLE_NAME}` (`profileId`, `deviceName`, `sdkKey`)"
}
],
"foreignKeys": []
},
{
"tableName": "infrared_device_configuration",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, PRIMARY KEY(`profileId`))",
"fields": [
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceName",
"columnName": "deviceName",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"profileId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be673e12eb4c5275aaa6ef6fc189a861')"
]
}
}
@@ -0,0 +1,194 @@
package com.flagship.abox.manager.infrared.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomInfraredKeyRepositoryTest {
private lateinit var database: InfraredDatabase
private lateinit var repository: RoomInfraredKeyRepository
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, InfraredDatabase::class.java)
.allowMainThreadQueries()
.build()
repository = RoomInfraredKeyRepository(database.infraredDao())
}
@After
fun teardown() {
database.close()
}
@Test
fun observesOnlyRequestedProfileAndDevice() = runTest {
repository.insert(testKey(profile = "one", device = "ir-1", id = "a", sdkKey = "key_a"))
repository.insert(testKey(profile = "one", device = "ir-2", id = "b", sdkKey = "key_b"))
repository.insert(testKey(profile = "two", device = "ir-1", id = "c", sdkKey = "key_c"))
val result = repository.observeKeys(
ProviderProfileId("one"),
DeviceName("ir-1"),
).first() as DomainResult.Success
assertEquals(listOf(InfraredKeyId("a")), result.value.map { it.id })
}
@Test
fun rejectsDuplicateSdkBinding() = runTest {
val first = repository.insert(testKey(id = "a", sdkKey = "app_ir_power"))
assertTrue(first is DomainResult.Success)
val duplicate = repository.insert(testKey(id = "b", sdkKey = "app_ir_power"))
assertTrue(duplicate is DomainResult.Failure)
}
@Test
fun transitionsStatusCorrectly() = runTest {
val key = testKey(id = "a", status = InfraredKeyStatus.LEARNING)
repository.insert(key)
val transitionResult = repository.transitionStatus(
ref = InfraredKeyRef(key.profileId, key.id),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = 2_000L,
)
assertTrue(transitionResult is DomainResult.Success)
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, fetched.status)
assertEquals(2_000L, fetched.updatedAtEpochMillis)
}
@Test
fun renamesKeyWithoutChangingSdkKey() = runTest {
val key = testKey(id = "a", displayName = "Old Name", sdkKey = "app_ir_power")
repository.insert(key)
val renameResult = repository.rename(
ref = InfraredKeyRef(key.profileId, key.id),
displayName = "New Name",
updatedAtEpochMillis = 3_000L,
)
assertTrue(renameResult is DomainResult.Success)
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
assertEquals("New Name", fetched.displayName)
assertEquals("app_ir_power", fetched.sdkKey)
assertEquals(3_000L, fetched.updatedAtEpochMillis)
}
@Test
fun deleteRejectsLearningKeyAndRemovesReadyKey() = runTest {
val learningKey = testKey(id = "learning", status = InfraredKeyStatus.LEARNING)
val readyKey = testKey(id = "ready", sdkKey = "ready_sdk_key", status = InfraredKeyStatus.READY)
repository.insert(learningKey)
repository.insert(readyKey)
val deleteLearning = repository.deleteIfNotLearning(InfraredKeyRef(learningKey.profileId, learningKey.id))
assertTrue(deleteLearning is DomainResult.Failure)
val deleteReady = repository.deleteIfNotLearning(InfraredKeyRef(readyKey.profileId, readyKey.id))
assertTrue(deleteReady is DomainResult.Success)
val fetchedReady = (repository.getKey(InfraredKeyRef(readyKey.profileId, readyKey.id)) as DomainResult.Success).value
assertNull(fetchedReady)
}
@Test
fun failsInterruptedLearningKeys() = runTest {
repository.insert(testKey(id = "k1", sdkKey = "k1", status = InfraredKeyStatus.LEARNING))
repository.insert(testKey(id = "k2", sdkKey = "k2", status = InfraredKeyStatus.LEARNING))
repository.insert(testKey(id = "k3", sdkKey = "k3", status = InfraredKeyStatus.READY))
val countResult = repository.failInterruptedLearning(ProviderProfileId("profile1"), 5_000L) as DomainResult.Success
assertEquals(2, countResult.value)
val k1 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, k1.status)
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, k1.lastErrorDetail)
assertNull(k1.lastErrorCode)
assertEquals(5_000L, k1.updatedAtEpochMillis)
val k3 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k3"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, k3.status)
}
@Test
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
val countResult = repository.failInterruptedLearning(
profileId = ProviderProfileId("profile1"),
cutoffEpochMillis = 150L,
updatedAtEpochMillis = 150L,
) as DomainResult.Success
assertEquals(1, countResult.value)
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
}
@Test
fun persistsAndObservesDeviceName() = runTest {
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
assertNull(initial.value)
val setResult = repository.setDeviceName(ProviderProfileId("profile1"), DeviceName("ir_main"))
assertTrue(setResult is DomainResult.Success)
val observed = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
assertEquals(DeviceName("ir_main"), observed.value)
}
private fun testKey(
profile: String = "profile1",
device: String = "ir-1",
id: String = "key-1",
displayName: String = "Power",
sdkKey: String = "app_ir_key1",
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
): SavedInfraredKey {
return SavedInfraredKey(
id = InfraredKeyId(id),
profileId = ProviderProfileId(profile),
deviceName = DeviceName(device),
displayName = displayName,
sdkKey = sdkKey,
status = status,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 1_000L,
updatedAtEpochMillis = 1_000L,
)
}
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.infrared.remote
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
var settingsClicked = false
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = { settingsClicked = true },
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick()
assertTrue(settingsClicked)
}
}
@@ -0,0 +1,77 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredControlScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersControlScreenAndTriggersKeyOperations() {
val testKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = ProviderProfileId("profile1"),
deviceName = DeviceName("ir-1"),
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val state = InfraredControlUiState(
profileId = ProviderProfileId("profile1"),
providerType = ProviderType.LOCAL_EXPERIENCE,
savedDeviceName = DeviceName("ir-1"),
keys = listOf(testKey),
loading = false,
)
var sendClicked = false
var deleteClicked = false
composeTestRule.setContent {
InfraredControlScreen(
state = state,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = { sendClicked = true },
onRename = { _, _ -> },
onDelete = { deleteClicked = true },
onMessageShown = {},
nextSlot = 2,
)
}
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick()
assertTrue(sendClicked)
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick()
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick()
assertTrue(deleteClicked)
}
}
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,72 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val relearnInfraredKey: RelearnInfraredKey,
private val keyRepository: InfraredKeyRepository,
private val externalScope: CoroutineScope,
) {
private val mutex = Mutex()
private var cleanupJob: Job? = null
fun learn(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
retryInfraredKeyLearning(ref)
}
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
relearnInfraredKey(ref)
}
fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long = System.currentTimeMillis(),
updatedAtEpochMillis: Long = cutoffEpochMillis,
): Job {
val job = externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning(
profileId = profileId,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
cleanupJob = job
return job
}
}
@@ -0,0 +1,222 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface InfraredDao {
@Query(
"""
SELECT * FROM infrared_keys
WHERE profileId = :profileId AND deviceName = :deviceName
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeKeys(profileId: String, deviceName: String): Flow<List<InfraredKeyEntity>>
@Query(
"""
SELECT * FROM infrared_keys
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
suspend fun getKey(profileId: String, id: String): InfraredKeyEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertKey(entity: InfraredKeyEntity)
@Query(
"""
UPDATE infrared_keys
SET status = :targetStatus,
lastErrorCode = :lastErrorCode,
lastErrorDetail = :lastErrorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId
AND id = :id
AND status IN (:expectedStatuses)
""",
)
suspend fun transitionStatus(
profileId: String,
id: String,
expectedStatuses: List<String>,
targetStatus: String,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
UPDATE infrared_keys
SET displayName = :displayName,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun renameKey(
profileId: String,
id: String,
displayName: String,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
DELETE FROM infrared_keys
WHERE profileId = :profileId AND id = :id AND status != 'LEARNING'
""",
)
suspend fun deleteKeyIfNotLearning(profileId: String, id: String): Int
@Query(
"""
UPDATE infrared_keys
SET status = :targetStatus,
lastErrorCode = NULL,
lastErrorDetail = :errorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId
AND status = 'LEARNING'
AND createdAtEpochMillis <= :cutoffEpochMillis
""",
)
suspend fun failInterruptedLearning(
profileId: String,
targetStatus: String,
errorDetail: String,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
SELECT * FROM infrared_device_configuration
WHERE profileId = :profileId
LIMIT 1
""",
)
fun observeDeviceName(profileId: String): Flow<InfraredDeviceConfigurationEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
// Virtual Remotes
@Query(
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND deviceName = :deviceName
ORDER BY createdAtEpochMillis DESC
""",
)
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
@Query(
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
fun observeVirtualRemote(profileId: String, id: String): Flow<VirtualRemoteEntity?>
@Query(
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
suspend fun getVirtualRemote(profileId: String, id: String): VirtualRemoteEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertVirtualRemote(entity: VirtualRemoteEntity)
@Query(
"""
DELETE FROM virtual_remotes
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun deleteVirtualRemote(profileId: String, id: String): Int
@Query(
"""
UPDATE virtual_remotes
SET acPower = :power,
acMode = :mode,
acTemperature = :temperature,
acFanSpeed = :fanSpeed,
acVerticalSwing = :verticalSwing,
acHorizontalSwing = :horizontalSwing,
acEco = :eco,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun updateAcState(
profileId: String,
id: String,
power: Boolean?,
mode: String?,
temperature: Int?,
fanSpeed: String?,
verticalSwing: Boolean?,
horizontalSwing: Boolean?,
eco: Boolean?,
updatedAtEpochMillis: Long,
): Int
// Remote Key Bindings
@Query(
"""
SELECT * FROM remote_key_bindings
WHERE remoteId = :remoteId
""",
)
fun observeKeyBindings(remoteId: String): Flow<List<RemoteKeyBindingEntity>>
@Query(
"""
SELECT * FROM remote_key_bindings
WHERE remoteId = :remoteId
""",
)
suspend fun getKeyBindings(remoteId: String): List<RemoteKeyBindingEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertKeyBindings(entities: List<RemoteKeyBindingEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertKeyBinding(entity: RemoteKeyBindingEntity)
@Query(
"""
DELETE FROM remote_key_bindings
WHERE remoteId = :remoteId
""",
)
suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int
@Query(
"""
DELETE FROM remote_key_bindings
WHERE remoteId = :remoteId AND buttonId = :buttonId
""",
)
suspend fun deleteKeyBinding(remoteId: String, buttonId: String): Int
@Query(
"""
SELECT b.assignedSlot FROM remote_key_bindings b
INNER JOIN virtual_remotes r ON b.remoteId = r.id
WHERE r.profileId = :profileId AND r.deviceName = :deviceName AND b.assignedSlot IS NOT NULL
""",
)
fun observeAssignedStaticSlots(profileId: String, deviceName: String): Flow<List<String>>
}
@@ -0,0 +1,18 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [
InfraredKeyEntity::class,
InfraredDeviceConfigurationEntity::class,
VirtualRemoteEntity::class,
RemoteKeyBindingEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class InfraredDatabase : RoomDatabase() {
abstract fun infraredDao(): InfraredDao
}
@@ -0,0 +1,83 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "infrared_keys",
indices = [
Index(value = ["profileId", "deviceName"]),
Index(value = ["profileId", "deviceName", "sdkKey"], unique = true),
],
)
data class InfraredKeyEntity(
@PrimaryKey
val id: String,
val profileId: String,
val deviceName: String,
val displayName: String,
val sdkKey: String,
val status: String,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@Entity(tableName = "infrared_device_configuration")
data class InfraredDeviceConfigurationEntity(
@PrimaryKey
val profileId: String,
val deviceName: String,
)
@Entity(
tableName = "virtual_remotes",
indices = [
Index(value = ["profileId", "deviceName"]),
],
)
data class VirtualRemoteEntity(
@PrimaryKey
val id: String,
val profileId: String,
val deviceName: String,
val displayName: String,
val type: String,
val brandModel: String?,
val layoutStyle: String?,
val transmissionMode: String,
val dynamicScratchSlot: String,
val acPower: Boolean?,
val acMode: String?,
val acTemperature: Int?,
val acFanSpeed: String?,
val acVerticalSwing: Boolean?,
val acHorizontalSwing: Boolean?,
val acEco: Boolean?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@Entity(
tableName = "remote_key_bindings",
indices = [
Index(value = ["remoteId"]),
Index(value = ["profileId"]),
],
)
data class RemoteKeyBindingEntity(
@PrimaryKey
val id: String,
val remoteId: String,
val profileId: String,
val buttonId: String,
val displayName: String,
val assignedSlot: String?,
val rawHexCode: String?,
val status: String,
val lastErrorCode: String?,
val lastErrorDetail: String?,
)
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.infrared.data.local
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
fun InfraredKeyEntity.toDomain(): SavedInfraredKey {
return SavedInfraredKey(
id = InfraredKeyId(id),
profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName),
displayName = displayName,
sdkKey = sdkKey,
status = InfraredKeyStatus.valueOf(status),
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
fun SavedInfraredKey.toEntity(): InfraredKeyEntity {
return InfraredKeyEntity(
id = id.value,
profileId = profileId.value,
deviceName = deviceName.value,
displayName = displayName,
sdkKey = sdkKey,
status = status.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
@@ -0,0 +1,201 @@
package com.flagship.abox.manager.infrared.data.local
import android.database.sqlite.SQLiteConstraintException
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
class RoomInfraredKeyRepository(
private val dao: InfraredDao,
) : InfraredKeyRepository, InfraredDeviceConfigurationRepository {
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> {
return dao.observeKeys(profileId.value, deviceName.value)
.map<List<InfraredKeyEntity>, DomainResult<List<SavedInfraredKey>>> { entities ->
DomainResult.Success(entities.map { it.toDomain() })
}
.catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
override fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> {
return dao.observeDeviceName(profileId.value)
.map<InfraredDeviceConfigurationEntity?, DomainResult<DeviceName?>> { entity ->
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
}
.catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
return try {
val entity = dao.getKey(ref.profileId.value, ref.keyId.value)
DomainResult.Success(entity?.toDomain())
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
return try {
dao.insertKey(key.toEntity())
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
return try {
val updated = dao.transitionStatus(
profileId = ref.profileId.value,
id = ref.keyId.value,
expectedStatuses = expectedStatuses.map { it.name },
targetStatus = targetStatus.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
if (updated > 0) {
DomainResult.Success(Unit)
} else {
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
if (existing == null) {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
} else {
DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Cannot transition key from ${existing.status} to ${targetStatus.name}",
),
)
}
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Display name cannot be blank"))
}
return try {
val updated = dao.renameKey(
profileId = ref.profileId.value,
id = ref.keyId.value,
displayName = trimmed,
updatedAtEpochMillis = updatedAtEpochMillis,
)
if (updated > 0) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
return try {
val deleted = dao.deleteKeyIfNotLearning(ref.profileId.value, ref.keyId.value)
if (deleted > 0) {
DomainResult.Success(Unit)
} else {
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
if (existing == null) {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
} else {
DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete key while learning is in progress"),
)
}
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
return try {
val count = dao.failInterruptedLearning(
profileId = profileId.value,
targetStatus = InfraredKeyStatus.FAILED.name,
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
DomainResult.Success(count)
} catch (throwable: Throwable) {
if (throwable is CancellationException) {
throw throwable
}
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit> {
val trimmed = deviceName.value.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name cannot be blank"))
}
return try {
dao.setDeviceName(
InfraredDeviceConfigurationEntity(
profileId = profileId.value,
deviceName = trimmed,
),
)
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
if (throwable is kotlinx.coroutines.CancellationException) {
throw throwable
}
return when (throwable) {
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
else -> DomainError.Unknown(detail = "Database operation failed")
}
}
}
@@ -0,0 +1,229 @@
package com.flagship.abox.manager.infrared.data.local
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class RoomVirtualRemoteRepository @Inject constructor(
private val dao: InfraredDao,
) : VirtualRemoteRepository {
override fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>> =
dao.observeVirtualRemotes(profileId.value, deviceName.value).map { entities ->
DomainResult.Success(entities.map { it.toDomain() })
}
override fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>> =
dao.observeVirtualRemote(profileId.value, remoteId.value).map { entity ->
DomainResult.Success(entity?.toDomain())
}
override fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>> =
dao.observeKeyBindings(remoteId.value).map { entities ->
DomainResult.Success(entities.map { it.toDomain() })
}
override fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>> =
dao.observeAssignedStaticSlots(profileId.value, deviceName.value).map { slots ->
val slotInts = slots.mapNotNull { it.toIntOrNull() }.toSet()
DomainResult.Success(
SlotQuotaSummary(
totalCapacity = 100,
dynamicScratchSlot = 100,
assignedStaticSlots = slotInts,
),
)
}
override suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?> = try {
val entity = dao.getVirtualRemote(profileId.value, remoteId.value)
DomainResult.Success(entity?.toDomain())
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>> = try {
val list = dao.getKeyBindings(remoteId.value)
DomainResult.Success(list.map { it.toDomain() })
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId> = try {
dao.insertVirtualRemote(remote.toEntity())
dao.insertKeyBindings(initialBindings.map { it.toEntity() })
DomainResult.Success(remote.id)
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> = try {
dao.insertVirtualRemote(remote.toEntity())
DomainResult.Success(Unit)
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> = try {
dao.deleteVirtualRemote(profileId.value, remoteId.value)
dao.deleteKeyBindingsByRemoteId(remoteId.value)
DomainResult.Success(Unit)
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> = try {
dao.insertKeyBinding(binding.toEntity())
DomainResult.Success(Unit)
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> = try {
dao.deleteKeyBinding(remoteId.value, buttonId)
DomainResult.Success(Unit)
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> = try {
dao.updateAcState(
profileId = profileId.value,
id = remoteId.value,
power = state.power,
mode = state.mode.name,
temperature = state.temperature,
fanSpeed = state.fanSpeed.name,
verticalSwing = state.verticalSwing,
horizontalSwing = state.horizontalSwing,
eco = state.eco,
updatedAtEpochMillis = System.currentTimeMillis(),
)
DomainResult.Success(Unit)
} catch (e: Exception) {
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
}
}
private fun VirtualRemoteEntity.toDomain(): VirtualRemote {
val ac = if (type == RemoteDeviceType.AIR_CONDITIONER.name) {
AcStateSnapshot(
power = acPower ?: true,
mode = acMode?.let { runCatching { AcMode.valueOf(it) }.getOrNull() } ?: AcMode.COOL,
temperature = acTemperature ?: 26,
fanSpeed = acFanSpeed?.let { runCatching { AcFanSpeed.valueOf(it) }.getOrNull() } ?: AcFanSpeed.AUTO,
verticalSwing = acVerticalSwing ?: true,
horizontalSwing = acHorizontalSwing ?: false,
eco = acEco ?: false,
)
} else null
val devType = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM)
val parsedLayout = layoutStyle?.let { runCatching { com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.valueOf(it) }.getOrNull() }
?: VirtualRemote.defaultLayoutFor(devType)
return VirtualRemote(
id = VirtualRemoteId(id),
profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName),
displayName = displayName,
type = devType,
brandModel = brandModel,
layoutStyle = parsedLayout,
transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH),
dynamicScratchSlot = dynamicScratchSlot,
acState = ac,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity(
id = id.value,
profileId = profileId.value,
deviceName = deviceName.value,
displayName = displayName,
type = type.name,
brandModel = brandModel,
layoutStyle = layoutStyle.name,
transmissionMode = transmissionMode.name,
dynamicScratchSlot = dynamicScratchSlot,
acPower = acState?.power,
acMode = acState?.mode?.name,
acTemperature = acState?.temperature,
acFanSpeed = acState?.fanSpeed?.name,
acVerticalSwing = acState?.verticalSwing,
acHorizontalSwing = acState?.horizontalSwing,
acEco = acState?.eco,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBinding(
id = id,
remoteId = VirtualRemoteId(remoteId),
profileId = ProviderProfileId(profileId),
buttonId = buttonId,
displayName = displayName,
assignedSlot = assignedSlot,
rawHexCode = rawHexCode,
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY),
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
)
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
id = id,
remoteId = remoteId.value,
profileId = profileId.value,
buttonId = buttonId,
displayName = displayName,
assignedSlot = assignedSlot,
rawHexCode = rawHexCode,
status = status.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
)
@@ -0,0 +1,229 @@
package com.flagship.abox.manager.infrared.di
import android.content.Context
import androidx.room.Room
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.data.local.InfraredDao
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.util.UUID
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object InfraredFeatureModule {
@Provides
@Singleton
fun provideInfraredDatabase(
@ApplicationContext context: Context,
): InfraredDatabase {
return Room.databaseBuilder(
context,
InfraredDatabase::class.java,
"abox_infrared.db",
).fallbackToDestructiveMigration().build()
}
@Provides
@Singleton
fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao()
@Provides
@Singleton
fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository =
RoomInfraredKeyRepository(dao)
@Provides
@Singleton
fun provideInfraredKeyRepository(
repository: RoomInfraredKeyRepository,
): InfraredKeyRepository = repository
@Provides
@Singleton
fun provideInfraredDeviceConfigurationRepository(
repository: RoomInfraredKeyRepository,
): InfraredDeviceConfigurationRepository = repository
@Provides
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
@Provides
fun provideEpochMillisClock(): EpochMillisClock =
EpochMillisClock { System.currentTimeMillis() }
@Provides
@Singleton
fun provideInfraredKeyFactory(
idGenerator: InfraredKeyIdGenerator,
clock: EpochMillisClock,
): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock)
@Provides
@Singleton
@InfraredApplicationScope
fun provideInfraredApplicationScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun provideLearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
factory: InfraredKeyFactory,
clock: EpochMillisClock,
): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock)
@Provides
@Singleton
fun provideRetryInfraredKeyLearning(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock)
@Provides
@Singleton
fun provideSendInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
): SendInfraredKey = SendInfraredKey(repository, gateway)
@Provides
@Singleton
fun provideRenameInfraredKey(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RenameInfraredKey = RenameInfraredKey(repository, clock)
@Provides
@Singleton
fun provideDeleteInfraredKey(
repository: InfraredKeyRepository,
): DeleteInfraredKey = DeleteInfraredKey(repository)
@Provides
@Singleton
fun provideRecoverInterruptedInfraredLearning(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
@Provides
@Singleton
fun provideRelearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey =
com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock)
@Provides
@Singleton
fun provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning,
relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey,
repository: InfraredKeyRepository,
@InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning,
relearnInfraredKey = relearnInfraredKey,
keyRepository = repository,
externalScope = scope,
)
@Provides
@Singleton
fun provideRoomVirtualRemoteRepository(dao: InfraredDao): RoomVirtualRemoteRepository =
RoomVirtualRemoteRepository(dao)
@Provides
@Singleton
fun provideVirtualRemoteRepository(
repository: RoomVirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository = repository
@Provides
@Singleton
fun provideCreateVirtualRemote(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): com.flagship.abox.manager.domain.infrared.CreateVirtualRemote =
com.flagship.abox.manager.domain.infrared.CreateVirtualRemote(repository, gateway, clock)
@Provides
@Singleton
fun provideSendVirtualRemoteAction(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
gateway: InfraredGateway,
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
@Provides
@Singleton
fun provideDeleteVirtualRemote(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote =
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository)
@Provides
@Singleton
fun provideLearnVirtualRemoteKey(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
gateway: InfraredGateway,
): com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey =
com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey(repository, gateway)
@Provides
@Singleton
fun provideAddVirtualRemoteKey(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey =
com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey(repository)
@Provides
@Singleton
fun provideDeleteVirtualRemoteKey(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey =
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey(repository)
@Provides
@Singleton
fun provideUpdateVirtualRemoteLayout(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout =
com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout(repository)
}
@@ -0,0 +1,593 @@
package com.flagship.abox.manager.infrared.hub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
@Composable
fun InfraredHubRoute(
onBack: () -> Unit,
onOpenRemote: (String) -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredHubViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredHubScreen(
state = state,
onBack = onBack,
onNavigateToSettings = onOpenSettings,
onOpenRemote = onOpenRemote,
onDeleteRemote = viewModel::deleteRemote,
onCreateRemote = viewModel::createRemote,
onSessionInvalid = onSessionInvalid,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredHubScreen(
state: InfraredHubUiState,
onBack: () -> Unit,
onNavigateToSettings: () -> Unit,
onOpenRemote: (String) -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
onSessionInvalid: () -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var showCreateDialog by remember { mutableStateOf(false) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is HubUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is HubUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is HubUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(R.string.infrared_hub_title),
fontWeight = FontWeight.Bold,
)
state.providerDisplayName?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
actions = {
IconButton(
onClick = onNavigateToSettings,
modifier = Modifier.testTag("hub_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 1. 硬件槽位配额看板 (100 槽位预算)
QuotaSummaryCard(quota = state.slotQuota)
// 2. 遥控器列表标题与新建按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_hub_my_remotes),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Button(
onClick = { showCreateDialog = true },
modifier = Modifier.testTag("hub_add_remote_button"),
) {
Text("➕ " + stringResource(R.string.infrared_hub_add_remote))
}
}
// 3. 遥控器网格卡片
if (state.remotes.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_hub_no_remotes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxWidth().weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.remotes, key = { it.id.value }) { remote ->
VirtualRemoteCard(
remote = remote,
onClick = { onOpenRemote(remote.id.value) },
onDelete = { onDeleteRemote(remote.id) },
)
}
}
}
}
}
}
if (showCreateDialog) {
CreateVirtualRemoteDialog(
onDismiss = { showCreateDialog = false },
onConfirm = { name, type, brandModel, transMode, layoutStyle ->
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
showCreateDialog = false
},
)
}
}
@Composable
private fun VirtualRemoteCard(
remote: VirtualRemote,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.testTag("remote_card_${remote.id.value}"),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val icon = when (remote.type) {
RemoteDeviceType.TV -> "📺"
RemoteDeviceType.AIR_CONDITIONER -> "❄️"
RemoteDeviceType.FAN -> "🌀"
RemoteDeviceType.CUSTOM -> "🎛️"
}
Text(icon, fontSize = 28.sp)
IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) {
Text("🗑️", fontSize = 16.sp)
}
}
Text(
text = remote.displayName,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) {
"❄️ 状态机动态写码"
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
"🚀 静态独占槽位"
} else {
"📦 动态写码"
}
Text(
text = "${remote.layoutStyle.name} · $modeText",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "硬件槽位配额 (总计 100 槽位)",
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Text(
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
fontWeight = FontWeight.Bold,
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
LinearProgressIndicator(
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
Text(
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateVirtualRemoteDialog(
onDismiss: () -> Unit,
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
) {
var name by remember { mutableStateOf("") }
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
var typeDropdownExpanded by remember { mutableStateOf(false) }
var brandDropdownExpanded by remember { mutableStateOf(false) }
var layoutDropdownExpanded by remember { mutableStateOf(false) }
val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// 1. 设备类型选择
ExposedDropdownMenuBox(
expanded = typeDropdownExpanded,
onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded },
) {
OutlinedTextField(
value = when (selectedType) {
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
},
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.infrared_hub_device_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = typeDropdownExpanded,
onDismissRequest = { typeDropdownExpanded = false },
) {
RemoteDeviceType.entries.forEach { type ->
val label = when (type) {
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
}
DropdownMenuItem(
text = { Text(label) },
onClick = {
selectedType = type
val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type }
if (matched != null) selectedBrand = matched
if (type == RemoteDeviceType.AIR_CONDITIONER) {
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH
} else if (type == RemoteDeviceType.CUSTOM) {
transmissionMode = TransmissionMode.STATIC_SLOTS
}
typeDropdownExpanded = false
},
)
}
}
}
// 2. 遥控器名称输入
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
)
// 3. 自定义遥控器时:选择初始布局模板
if (selectedType == RemoteDeviceType.CUSTOM) {
ExposedDropdownMenuBox(
expanded = layoutDropdownExpanded,
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
) {
OutlinedTextField(
value = when (selectedCustomLayout) {
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)"
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)"
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)"
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局"
},
onValueChange = {},
readOnly = true,
label = { Text("选择初始布局模板") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = layoutDropdownExpanded,
onDismissRequest = { layoutDropdownExpanded = false },
) {
DropdownMenuItem(
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.TV_NAV
layoutDropdownExpanded = false
},
)
DropdownMenuItem(
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
layoutDropdownExpanded = false
},
)
DropdownMenuItem(
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.GRID
layoutDropdownExpanded = false
},
)
}
}
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
fontSize = 12.sp,
modifier = Modifier.padding(10.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (filteredPresets.isNotEmpty()) {
// 码库预设设备:选择品牌型号
ExposedDropdownMenuBox(
expanded = brandDropdownExpanded,
onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded },
) {
OutlinedTextField(
value = selectedBrand.displayName,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.infrared_hub_brand_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = brandDropdownExpanded,
onDismissRequest = { brandDropdownExpanded = false },
) {
filteredPresets.forEach { preset ->
DropdownMenuItem(
text = { Text(preset.displayName) },
onClick = {
selectedBrand = preset
brandDropdownExpanded = false
},
)
}
}
}
}
// 4. 传输模式选择(仅对非空调、非纯自定义设备可选)
if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS },
) {
RadioButton(
selected = transmissionMode == TransmissionMode.STATIC_SLOTS,
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
)
Column {
Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
) {
RadioButton(
selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH,
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
)
Column {
Text("📦 动态写码模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。",
fontSize = 12.sp,
modifier = Modifier.padding(10.dp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
},
confirmButton = {
Button(
onClick = {
val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
},
modifier = Modifier.testTag("create_remote_confirm_button"),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.infrared.hub
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface HubUiMessage {
data class Info(val text: UiText) : HubUiMessage
data class Error(val text: UiText) : HubUiMessage
data object SessionInvalid : HubUiMessage
}
data class InfraredHubUiState(
val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = null,
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
val remotes: List<VirtualRemote> = emptyList(),
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val loading: Boolean = true,
val message: HubUiMessage? = null,
)
@@ -0,0 +1,200 @@
package com.flagship.abox.manager.infrared.hub
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredHubViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val providerProfileStore: ProviderProfileStore,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val createVirtualRemote: CreateVirtualRemote,
private val deleteVirtualRemote: DeleteVirtualRemote,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val messageFlow = MutableStateFlow<HubUiMessage?>(null)
private data class HubSnapshot(
val providerProfile: ProviderProfile? = null,
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
val remotes: List<VirtualRemote> = emptyList(),
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val configError: DomainError? = null,
)
private val hubSnapshotFlow: Flow<HubSnapshot> =
if (profileId == null) {
flowOf(HubSnapshot())
} else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { devResult ->
when (devResult) {
is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error))
is DomainResult.Success -> {
val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME
combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
) { remotesResult, quotaResult ->
val remotes = if (remotesResult is DomainResult.Success) remotesResult.value else emptyList()
val quota = if (quotaResult is DomainResult.Success) quotaResult.value else SlotQuotaSummary()
HubSnapshot(
providerProfile = active,
savedDevice = deviceName,
remotes = remotes,
slotQuota = quota,
)
}
}
}
}
}
}
val state: StateFlow<InfraredHubUiState> = combine(
hubSnapshotFlow,
messageFlow,
) { snapshot, msg ->
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
is DomainError.DeviceOffline -> HubUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> HubUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
}
}
InfraredHubUiState(
profileId = profileId,
providerType = snapshot.providerProfile?.type,
providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDevice,
remotes = snapshot.remotes,
slotQuota = snapshot.slotQuota,
loading = false,
message = resolvedMessage,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredHubUiState(profileId = profileId, loading = true),
)
fun createRemote(
name: String,
type: RemoteDeviceType,
brandModel: String? = null,
transmissionMode: TransmissionMode,
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle? = null,
) {
val currentProfile = profileId ?: return
val currentDevice = state.value.savedDeviceName
viewModelScope.launch {
when (val res = createVirtualRemote(
profileId = currentProfile,
deviceName = currentDevice,
displayName = name,
type = type,
brandModel = brandModel,
transmissionMode = transmissionMode,
layoutStyle = layoutStyle,
)) {
is DomainResult.Success -> {
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器"))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun deleteRemote(remoteId: VirtualRemoteId) {
val currentProfile = profileId ?: return
viewModelScope.launch {
when (val res = deleteVirtualRemote(currentProfile, remoteId)) {
is DomainResult.Success -> {
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器已删除"))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun saveDeviceName(newName: String) {
val currentProfile = profileId ?: return
val trimmed = newName.trim()
if (trimmed.isBlank()) {
messageFlow.value = HubUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank))
return
}
viewModelScope.launch {
when (val res = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = HubUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
is DomainError.DeviceOffline -> messageFlow.value = HubUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> messageFlow.value = HubUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
}
}
@@ -0,0 +1,801 @@
package com.flagship.abox.manager.infrared.panel
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
@Composable
fun RemotePanelRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: RemotePanelViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
RemotePanelScreen(
state = state,
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onButtonClick = viewModel::onButtonClick,
onToggleLearnMode = viewModel::toggleLearnMode,
onAddCustomButton = viewModel::addCustomButton,
onDeleteButton = viewModel::deleteButton,
onToggleAcPower = viewModel::toggleAcPower,
onAdjustAcTemp = viewModel::adjustAcTemperature,
onSetAcMode = viewModel::setAcMode,
onSetFanSpeed = viewModel::setAcFanSpeed,
onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing,
onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing,
onToggleAcEco = viewModel::toggleAcEco,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RemotePanelScreen(
state: RemotePanelUiState,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onButtonClick: (String) -> Unit,
onToggleLearnMode: () -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var showAddKeyDialog by remember { mutableStateOf(false) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is PanelUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is PanelUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is PanelUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
state.remote?.let {
val layoutLabel = when (it.layoutStyle) {
PanelLayoutStyle.TV_NAV -> "电视导航布局"
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放布局"
PanelLayoutStyle.GRID -> "自由网格布局"
PanelLayoutStyle.AC_COMPACT -> "空调全状态布局"
}
Text(
text = layoutLabel,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
actions = {
// 非空调面板显示学习模式开关
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
FilterChip(
selected = state.isLearnMode,
onClick = onToggleLearnMode,
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFFFF5252),
selectedLabelColor = Color.White,
),
modifier = Modifier.testTag("learn_mode_toggle"),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
val remote = state.remote
if (remote == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(modifier = Modifier.fillMaxSize()) {
// 学习模式指示横幅
if (state.isLearnMode) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)),
shape = RoundedCornerShape(0.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("🔴", fontSize = 16.sp)
Text(
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
color = Color(0xFFC62828),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
// 面板内容区
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> {
AcControlPanel(
acState = state.acState,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVSwing = onToggleAcVerticalSwing,
onToggleHSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
)
}
PanelLayoutStyle.TV_NAV -> {
TvControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
}
PanelLayoutStyle.MEDIA_PLAYER -> {
MediaPlayerControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
}
PanelLayoutStyle.GRID -> {
GenericGridControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onDeleteButton = onDeleteButton,
onAddButton = { showAddKeyDialog = true },
)
}
}
}
}
}
}
}
if (showAddKeyDialog) {
AddCustomKeyDialog(
onDismiss = { showAddKeyDialog = false },
onConfirm = { name ->
onAddCustomButton(name)
showAddKeyDialog = false
},
)
}
}
@Composable
private fun AcControlPanel(
acState: AcStateSnapshot,
onTogglePower: () -> Unit,
onAdjustTemp: (Int) -> Unit,
onSetMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleVSwing: () -> Unit,
onToggleHSwing: () -> Unit,
onToggleEco: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (acState.power) "● 运行中" else "○ 已关机",
color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
IconButton(
onClick = onTogglePower,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline)
.testTag("ac_power_toggle"),
) {
Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
Text(
text = "${acState.temperature}°C",
fontSize = 64.sp,
fontWeight = FontWeight.Black,
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = { onAdjustTemp(-1) },
modifier = Modifier.size(56.dp).testTag("ac_temp_down"),
shape = CircleShape,
) {
Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold)
}
Button(
onClick = { onAdjustTemp(1) },
modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
shape = CircleShape,
) {
Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold)
}
}
}
}
Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AcMode.entries.forEach { mode ->
FilterChip(
selected = acState.mode == mode,
onClick = { onSetMode(mode) },
label = { Text(mode.name) },
modifier = Modifier.weight(1f),
)
}
}
Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AcFanSpeed.entries.forEach { speed ->
FilterChip(
selected = acState.fanSpeed == speed,
onClick = { onSetFanSpeed(speed) },
label = { Text(speed.name) },
modifier = Modifier.weight(1f),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = acState.verticalSwing,
onClick = onToggleVSwing,
label = { Text("↕ 上下扫风") },
modifier = Modifier.weight(1f),
)
FilterChip(
selected = acState.horizontalSwing,
onClick = onToggleHSwing,
label = { Text("↔ 左右扫风") },
modifier = Modifier.weight(1f),
)
FilterChip(
selected = acState.eco,
onClick = onToggleEco,
label = { Text("🌱 节能 ECO") },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun TvControlPanel(
bindings: List<RemoteKeyBinding>,
isLearnMode: Boolean,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
) {
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 顶部控制行 (电源、静音、信号源)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
}
Spacer(modifier = Modifier.height(8.dp))
// 中部五向方向导航盘
Card(
shape = CircleShape,
modifier = Modifier.size(220.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// 上
IconButton(
onClick = { onButtonClick("TV_NAV_UP") },
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
) {
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId)
}
// 下
IconButton(
onClick = { onButtonClick("TV_NAV_DOWN") },
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
) {
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId)
}
// 左
IconButton(
onClick = { onButtonClick("TV_NAV_LEFT") },
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
) {
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId)
}
// 右
IconButton(
onClick = { onButtonClick("TV_NAV_RIGHT") },
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
) {
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId)
}
// OK 居中大按钮
Button(
onClick = { onButtonClick("TV_OK") },
shape = CircleShape,
modifier = Modifier.size(76.dp).testTag("tv_ok"),
) {
ButtonIndicatorText("TV_OK", "OK", learningButtonId, sendingButtonId)
}
}
}
// 辅助按键行 (返回、主页、菜单)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
) {
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
}
// 底部音量与频道调节柱
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId)
}
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId)
}
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId)
}
}
}
}
}
@Composable
private fun MediaPlayerControlPanel(
bindings: List<RemoteKeyBinding>,
isLearnMode: Boolean,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
) {
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
}
Spacer(modifier = Modifier.height(16.dp))
// 播放控制中心大圆盘
Card(
shape = CircleShape,
modifier = Modifier.size(240.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// 快进 >>
IconButton(onClick = { onButtonClick("MEDIA_FF") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) {
ButtonIndicatorText("MEDIA_FF", "⏩", learningButtonId, sendingButtonId)
}
// 快退 <<
IconButton(onClick = { onButtonClick("MEDIA_REWIND") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) {
ButtonIndicatorText("MEDIA_REWIND", "⏪", learningButtonId, sendingButtonId)
}
// 上一曲
IconButton(onClick = { onButtonClick("MEDIA_PREV") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp)) {
ButtonIndicatorText("MEDIA_PREV", "⏮", learningButtonId, sendingButtonId)
}
// 下一曲
IconButton(onClick = { onButtonClick("MEDIA_NEXT") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)) {
ButtonIndicatorText("MEDIA_NEXT", "⏭", learningButtonId, sendingButtonId)
}
// 播放/暂停
Button(onClick = { onButtonClick("MEDIA_PLAY_PAUSE") }, shape = CircleShape, modifier = Modifier.size(80.dp)) {
ButtonIndicatorText("MEDIA_PLAY_PAUSE", "⏯", learningButtonId, sendingButtonId)
}
}
}
}
}
@Composable
private fun GenericGridControlPanel(
bindings: List<RemoteKeyBinding>,
isLearnMode: Boolean,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onAddButton: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
Text("➕ 添加按键")
}
}
if (bindings.isEmpty()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize().weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(bindings, key = { it.id }) { binding ->
GridButtonCard(
binding = binding,
isLearnMode = isLearnMode,
isLearning = learningButtonId == binding.buttonId,
isSending = sendingButtonId == binding.buttonId,
onClick = { onButtonClick(binding.buttonId) },
onDelete = { onDeleteButton(binding.buttonId) },
)
}
}
}
}
}
@Composable
private fun GridButtonCard(
binding: RemoteKeyBinding,
isLearnMode: Boolean,
isLearning: Boolean,
isSending: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(76.dp)
.clickable(onClick = onClick)
.testTag("generic_btn_${binding.buttonId}"),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp)
val statusText = when {
isLearning -> "🟡 正在录制信号..."
binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})"
else -> "⚪ 待录制 (点击录码)"
}
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (isSending || isLearning) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
// 始终提供删除操作按钮
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"),
) {
Text("🗑️", fontSize = 15.sp)
}
}
}
}
}
@Composable
private fun InteractiveRemoteButton(
buttonId: String,
label: String,
containerColor: Color,
binding: RemoteKeyBinding?,
isLearnMode: Boolean,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
) {
val isUnlearned = binding != null && binding.status != InfraredKeyStatus.READY
Button(
onClick = { onButtonClick(buttonId) },
colors = ButtonDefaults.buttonColors(
containerColor = when {
learningButtonId == buttonId -> Color(0xFFFF9800)
isUnlearned -> MaterialTheme.colorScheme.surfaceVariant
else -> containerColor
},
contentColor = if (isUnlearned) MaterialTheme.colorScheme.onSurfaceVariant else Color.White,
),
modifier = Modifier.testTag("tv_btn_$buttonId"),
) {
ButtonIndicatorText(buttonId, label, learningButtonId, sendingButtonId)
}
}
@Composable
private fun ButtonIndicatorText(
buttonId: String,
defaultLabel: String,
learningButtonId: String?,
sendingButtonId: String?,
) {
if (learningButtonId == buttonId) {
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
} else if (sendingButtonId == buttonId) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
} else {
Text(defaultLabel, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun AddCustomKeyDialog(
onDismiss: () -> Unit,
onConfirm: (name: String) -> Unit,
) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("添加自定义按键") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("输入按键显示名称,创建后点击按键或开启【学习模式】即可录制实体按键:", fontSize = 13.sp)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("按键名称(如:静音、低音增强)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"),
)
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) onConfirm(name.trim())
},
modifier = Modifier.testTag("add_key_confirm_button"),
) {
Text("确认添加")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
},
)
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.infrared.panel
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface PanelUiMessage {
data class Info(val text: UiText) : PanelUiMessage
data class Error(val text: UiText) : PanelUiMessage
data object SessionInvalid : PanelUiMessage
}
data class RemotePanelUiState(
val profileId: ProviderProfileId? = null,
val remote: VirtualRemote? = null,
val bindings: List<RemoteKeyBinding> = emptyList(),
val acState: AcStateSnapshot = AcStateSnapshot(),
val isLearnMode: Boolean = false,
val learningButtonId: String? = null,
val sendingButtonId: String? = null,
val loading: Boolean = true,
val message: PanelUiMessage? = null,
)
@@ -0,0 +1,297 @@
package com.flagship.abox.manager.infrared.panel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class RemotePanelViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
private val addVirtualRemoteKey: AddVirtualRemoteKey,
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val rawRemoteId: String? = savedStateHandle["remoteId"]
val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) }
private val isLearnModeFlow = MutableStateFlow(false)
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
private var acDebounceJob: Job? = null
private val remoteFlow = if (profileId == null || remoteId == null) {
flowOf(null)
} else {
virtualRemoteRepository.observeRemote(profileId, remoteId).flatMapLatest { res ->
val remote = if (res is DomainResult.Success) res.value else null
flowOf(remote)
}
}
private val bindingsFlow = if (remoteId == null) {
flowOf(emptyList())
} else {
virtualRemoteRepository.observeKeyBindings(remoteId).flatMapLatest { res ->
val list = if (res is DomainResult.Success) res.value else emptyList()
flowOf(list)
}
}
private data class RemoteSnapshot(
val remote: VirtualRemote?,
val bindings: List<RemoteKeyBinding>,
)
private val snapshotFlow = combine(remoteFlow, bindingsFlow) { remote, bindings ->
RemoteSnapshot(remote, bindings)
}
private data class ActionState(
val isLearnMode: Boolean,
val learningButtonId: String?,
val sendingButtonId: String?,
)
private val actionStateFlow = combine(
isLearnModeFlow,
learningButtonIdFlow,
sendingButtonIdFlow,
) { isLearn, learningBtn, sendingBtn ->
ActionState(isLearn, learningBtn, sendingBtn)
}
val state: StateFlow<RemotePanelUiState> = combine(
snapshotFlow,
localAcStateFlow,
actionStateFlow,
messageFlow,
) { snapshot, localAc, action, msg ->
val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot()
RemotePanelUiState(
profileId = profileId,
remote = snapshot.remote,
bindings = snapshot.bindings,
acState = resolvedAc,
isLearnMode = action.isLearnMode,
learningButtonId = action.learningButtonId,
sendingButtonId = action.sendingButtonId,
loading = snapshot.remote == null,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = RemotePanelUiState(profileId = profileId, loading = true),
)
fun toggleLearnMode() {
val newMode = !isLearnModeFlow.value
isLearnModeFlow.value = newMode
if (newMode) {
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled))
}
}
fun onButtonClick(buttonId: String) {
if (isLearnModeFlow.value) {
learnButton(buttonId)
} else {
sendButton(buttonId)
}
}
private fun sendButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
if (state.value.sendingButtonId != null || state.value.learningButtonId != null) return
sendingButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val res = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(buttonId)))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
} finally {
sendingButtonIdFlow.value = null
}
}
}
fun learnButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
if (state.value.learningButtonId != null) return
learningButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf(buttonId)))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
} finally {
learningButtonIdFlow.value = null
}
}
}
fun addCustomButton(displayName: String) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
viewModelScope.launch {
when (val res = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
is DomainResult.Success -> {
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已添加按键【${displayName}】,开启学习模式即可录制"))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun deleteButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
viewModelScope.launch {
when (val res = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("按键已删除"))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun switchLayout(newLayout: PanelLayoutStyle) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
viewModelScope.launch {
when (val res = updateVirtualRemoteLayout(currentProfile, currentRemoteId, newLayout)) {
is DomainResult.Success -> {
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已切换面板布局风格"))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun toggleAcPower() {
val current = state.value.acState
updateAndDebounceAc(current.copy(power = !current.power))
}
fun adjustAcTemperature(delta: Int) {
val current = state.value.acState
val target = (current.temperature + delta).coerceIn(16, 30)
if (target != current.temperature) {
updateAndDebounceAc(current.copy(temperature = target))
}
}
fun setAcMode(mode: AcMode) {
val current = state.value.acState
updateAndDebounceAc(current.copy(mode = mode))
}
fun setAcFanSpeed(fanSpeed: AcFanSpeed) {
val current = state.value.acState
updateAndDebounceAc(current.copy(fanSpeed = fanSpeed))
}
fun toggleAcVerticalSwing() {
val current = state.value.acState
updateAndDebounceAc(current.copy(verticalSwing = !current.verticalSwing))
}
fun toggleAcHorizontalSwing() {
val current = state.value.acState
updateAndDebounceAc(current.copy(horizontalSwing = !current.horizontalSwing))
}
fun toggleAcEco() {
val current = state.value.acState
updateAndDebounceAc(current.copy(eco = !current.eco))
}
private fun updateAndDebounceAc(newState: AcStateSnapshot) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
// 1. Optimistic UI update
localAcStateFlow.value = newState
// 2. Debounce transmission (300ms)
acDebounceJob?.cancel()
acDebounceJob = viewModelScope.launch {
delay(300L)
when (val res = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) {
is DomainResult.Success -> {
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已同步空调状态:${newState.temperature}℃ | ${newState.mode.name}"))
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = PanelUiMessage.SessionInvalid
is DomainError.DeviceOffline -> messageFlow.value = PanelUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> messageFlow.value = PanelUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
}
@@ -0,0 +1,331 @@
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: UiText) : RemoteUiMessage
data class Error(val text: UiText) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val providerProfileStore: ProviderProfileStore,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
init {
if (profileId != null) {
val startupCutoff = System.currentTimeMillis()
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
}
}
private data class InfraredRemoteConfigSnapshot(
val providerProfile: ProviderProfile? = null,
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val configError: DomainError? = null,
)
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
if (profileId == null) {
flowOf(InfraredRemoteConfigSnapshot())
} else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Failure -> flowOf(
InfraredRemoteConfigSnapshot(
providerProfile = active,
configError = deviceResult.error,
),
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
when (keysResult) {
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
providerProfile = active,
savedDeviceName = deviceName,
configError = keysResult.error,
)
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
providerProfile = active,
savedDeviceName = deviceName,
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
)
}
}
}
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
configSnapshotFlow,
combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
Triple(sending, learning, msg)
},
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
is DomainError.DeviceOffline -> RemoteUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> RemoteUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
}
}
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
profileId = profileId,
providerType = snapshot.providerProfile?.type,
providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDeviceName,
learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = resolvedMessage,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(profileId = profileId),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (!enabled) {
val shutdownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
activeLearningSlotFlow.value = null
profileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
}
} else {
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_learning_mode_enabled),
)
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = profileId ?: return
val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME
if (isLearningModeFlow.value) {
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
return
}
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_recording_key,
listOf(spec.displayName, spec.defaultSlot),
),
)
activeLearningSlotFlow.value = spec.defaultSlot
val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
null -> learningCoordinator.learn(
profileId = currentProfile,
deviceName = currentDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
}
underlyingLearningDeferred = deferred
val job = viewModelScope.launch {
try {
val result = if (existingKey != null) {
@Suppress("UNCHECKED_CAST")
(deferred as kotlinx.coroutines.Deferred<DomainResult<Unit>>).await()
} else {
@Suppress("UNCHECKED_CAST")
when (val res = (deferred as kotlinx.coroutines.Deferred<DomainResult<InfraredKeyId>>).await()) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> DomainResult.Failure(res.error)
}
}
when (result) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_learned_success,
listOf(spec.displayName),
),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} finally {
if (underlyingLearningDeferred === deferred) {
underlyingLearningDeferred = null
}
if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null
}
}
}
activeLearningJob = job
} else {
if (sendingKeySlotFlow.value != null) {
return
}
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) {
messageFlow.value = RemoteUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_key_not_learned,
listOf(spec.displayName),
),
)
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_signal_sent,
listOf(spec.displayName),
),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
override fun onCleared() {
super.onCleared()
val teardownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
profileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_device_offline,
listOf(error.externalCode ?: "20206"),
),
)
}
else -> {
messageFlow.value = RemoteUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
}
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
}
}
@@ -0,0 +1,61 @@
package com.flagship.abox.manager.infrared.remote
import androidx.annotation.StringRes
import com.flagship.abox.manager.infrared.R
sealed interface UiText {
data class DynamicString(val value: String) : UiText
data class StringResource(
@StringRes val resId: Int,
val args: List<Any> = emptyList(),
) : UiText
}
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
@StringRes val nameRes: Int,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU,
)
}
}
@@ -0,0 +1,174 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@Composable
fun AcRemotePanel(
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
modifier: Modifier = Modifier,
) {
RemoteChassis(modifier = modifier) {
// 顶部虚拟 LCD 液晶显示区
AcLcdDisplay()
// 电源与温度控制行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// 电源按键
val powerSpec = RemoteSceneConfig.AirConditioner.POWER
val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY
val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot
RemoteCircleButton(
spec = powerSpec,
isLearned = isPowerLearned,
isLoading = isPowerLoading,
isLearningMode = isLearningMode,
onClick = { onButtonClick(powerSpec) },
size = 72.dp,
containerColor = Color(0xFFE53935),
contentColor = Color.White,
)
// 垂直胶囊温度调节器
val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP
val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN
VerticalCapsuleRocker(
upSpec = tempUpSpec,
downSpec = tempDownSpec,
isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot,
isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(tempUpSpec) },
onDownClick = { onButtonClick(tempDownSpec) },
label = stringResource(R.string.infrared_label_temp_control),
)
}
Spacer(modifier = Modifier.height(4.dp))
// 功能按键网格 (模式 / 风速 / 扫风 / 定时)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
}
}
}
@Composable
private fun AcGridButton(
spec: RemoteButtonSpec,
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
) {
RemoteRectButton(
spec = spec,
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(spec) },
width = 120.dp,
height = 52.dp,
)
}
@Composable
private fun AcLcdDisplay() {
Card(
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
.shadow(4.dp, shape = RoundedCornerShape(16.dp)),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B),
),
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = stringResource(R.string.infrared_label_cool_mode),
color = Color(0xFF38BDF8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.infrared_label_ac_status),
color = Color(0xFF94A3B8),
fontSize = 12.sp,
)
}
Text(
text = "26°C",
color = Color(0xFF4ADE80),
fontSize = 38.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
}
}
@@ -0,0 +1,263 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteScene
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
import com.flagship.abox.manager.infrared.remote.UiText
@Composable
fun InfraredRemoteRoute(
onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredRemoteViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredRemoteScreen(
state = state,
onBack = onBack,
onOpenSettings = onOpenSettings,
onSessionInvalid = onSessionInvalid,
onSceneSelected = viewModel::switchScene,
onToggleLearningMode = viewModel::toggleLearningMode,
onButtonClick = viewModel::onButtonClick,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredRemoteScreen(
state: InfraredRemoteUiState,
onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
onSceneSelected: (RemoteScene) -> Unit,
onToggleLearningMode: (Boolean) -> Unit,
onButtonClick: (RemoteButtonSpec) -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is RemoteUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is RemoteUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is RemoteUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.infrared_remote_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
},
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
actions = {
// 学习模式开关
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = if (state.isLearningMode) {
stringResource(R.string.infrared_remote_recording)
} else {
stringResource(R.string.infrared_remote_learning_mode)
},
fontSize = 12.sp,
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = state.isLearningMode,
onCheckedChange = onToggleLearningMode,
modifier = Modifier.testTag("remote_learning_mode_switch"),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.error,
checkedTrackColor = MaterialTheme.colorScheme.errorContainer,
),
)
}
// 按键管理 / 设置入口按钮
IconButton(
onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 学习模式顶部横幅提示
if (state.isLearningMode) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
shape = RoundedCornerShape(12.dp),
)
.padding(vertical = 8.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_remote_learning_banner),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.SemiBold,
),
)
}
}
// 场景切换 Tab (空调 | 电视)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
modifier = Modifier
.padding(end = 8.dp)
.testTag("scene_tab_ac"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
FilterChip(
selected = state.selectedScene == RemoteScene.TELEVISION,
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
modifier = Modifier.testTag("scene_tab_tv"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}
// 对应场景遥控器面板
when (state.selectedScene) {
RemoteScene.AIR_CONDITIONER -> {
AcRemotePanel(
learnedSlotMap = state.learnedSlotMap,
activeLearningSlot = state.activeLearningSlot,
sendingKeySlot = state.sendingKeySlot,
isLearningMode = state.isLearningMode,
onButtonClick = onButtonClick,
)
}
RemoteScene.TELEVISION -> {
TvRemotePanel(
learnedSlotMap = state.learnedSlotMap,
activeLearningSlot = state.activeLearningSlot,
sendingKeySlot = state.sendingKeySlot,
isLearningMode = state.isLearningMode,
onButtonClick = onButtonClick,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@@ -0,0 +1,429 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
@Composable
fun RemoteChassis(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.shadow(12.dp, shape = RoundedCornerShape(32.dp)),
shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
content()
}
}
}
@Composable
fun RemoteCircleButton(
spec: RemoteButtonSpec,
isLearned: Boolean,
isLoading: Boolean,
isLearningMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 64.dp,
containerColor: Color? = null,
contentColor: Color? = null,
) {
val defaultContainer = when {
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
isLearned -> containerColor ?: MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.surface
}
val defaultContent = when {
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
val borderModifier = if (isLearningMode) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
} else if (!isLearned) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
} else {
Modifier
}
Surface(
modifier = modifier
.size(size)
.then(borderModifier)
.testTag("remote_button_${spec.id}"),
shape = CircleShape,
color = defaultContainer,
contentColor = defaultContent,
shadowElevation = if (isLearned) 4.dp else 1.dp,
enabled = !isLoading,
onClick = onClick,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(size * 0.45f),
strokeWidth = 2.5.dp,
color = defaultContent,
)
} else {
Text(
text = spec.symbol,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp,
fontWeight = FontWeight.Bold,
),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
fun RemoteRectButton(
spec: RemoteButtonSpec,
isLearned: Boolean,
isLoading: Boolean,
isLearningMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 100.dp,
height: Dp = 48.dp,
) {
val containerColor = when {
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
isLearned -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
}
val contentColor = when {
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
isLearned -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
val borderModifier = if (isLearningMode) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp))
} else if (!isLearned) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
} else {
Modifier
}
Surface(
modifier = modifier
.width(width)
.height(height)
.then(borderModifier)
.testTag("remote_button_${spec.id}"),
shape = RoundedCornerShape(16.dp),
color = containerColor,
contentColor = contentColor,
shadowElevation = if (isLearned) 3.dp else 1.dp,
enabled = !isLoading,
onClick = onClick,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = contentColor,
)
} else {
Text(
text = spec.symbol,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
fun VerticalCapsuleRocker(
upSpec: RemoteButtonSpec,
downSpec: RemoteButtonSpec,
isUpLearned: Boolean,
isDownLearned: Boolean,
isUpLoading: Boolean,
isDownLoading: Boolean,
isLearningMode: Boolean,
onUpClick: () -> Unit,
onDownClick: () -> Unit,
modifier: Modifier = Modifier,
label: String = "",
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Card(
modifier = Modifier
.width(68.dp)
.height(136.dp)
.shadow(4.dp, shape = RoundedCornerShape(34.dp)),
shape = RoundedCornerShape(34.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
// Top half
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp))
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isUpLoading, onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isUpLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(
text = upSpec.symbol,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Bottom half
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp))
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isDownLoading, onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isDownLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(
text = downSpec.symbol,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
}
}
}
}
if (label.isNotBlank()) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun RemoteDpadWheel(
upSpec: RemoteButtonSpec,
downSpec: RemoteButtonSpec,
leftSpec: RemoteButtonSpec,
rightSpec: RemoteButtonSpec,
okSpec: RemoteButtonSpec,
isUpLearned: Boolean,
isDownLearned: Boolean,
isLeftLearned: Boolean,
isRightLearned: Boolean,
isOkLearned: Boolean,
isUpLoading: Boolean,
isDownLoading: Boolean,
isLeftLoading: Boolean,
isRightLoading: Boolean,
isOkLoading: Boolean,
isLearningMode: Boolean,
onUpClick: () -> Unit,
onDownClick: () -> Unit,
onLeftClick: () -> Unit,
onRightClick: () -> Unit,
onOkClick: () -> Unit,
modifier: Modifier = Modifier,
wheelSize: Dp = 190.dp,
) {
Card(
modifier = modifier
.size(wheelSize)
.shadow(6.dp, shape = CircleShape),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
// Up Button
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isUpLoading, onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Down Button
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isDownLoading, onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Left Button
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isLeftLoading, onClick = onLeftClick)
.testTag("remote_button_${leftSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Right Button
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isRightLoading, onClick = onRightClick)
.testTag("remote_button_${rightSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Center OK Button
RemoteCircleButton(
spec = okSpec,
isLearned = isOkLearned,
isLoading = isOkLoading,
isLearningMode = isLearningMode,
onClick = onOkClick,
size = 62.dp,
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@@ -0,0 +1,163 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@Composable
fun TvRemotePanel(
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
modifier: Modifier = Modifier,
) {
RemoteChassis(modifier = modifier) {
// 顶部电源与静音行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val powerSpec = RemoteSceneConfig.Television.POWER
RemoteCircleButton(
spec = powerSpec,
isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(powerSpec) },
size = 56.dp,
containerColor = Color(0xFFE53935),
contentColor = Color.White,
)
val muteSpec = RemoteSceneConfig.Television.MUTE
RemoteCircleButton(
spec = muteSpec,
isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(muteSpec) },
size = 56.dp,
)
}
// 中心圆形方向轮盘 (D-Pad Wheel)
val upSpec = RemoteSceneConfig.Television.NAV_UP
val downSpec = RemoteSceneConfig.Television.NAV_DOWN
val leftSpec = RemoteSceneConfig.Television.NAV_LEFT
val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT
val okSpec = RemoteSceneConfig.Television.OK
RemoteDpadWheel(
upSpec = upSpec,
downSpec = downSpec,
leftSpec = leftSpec,
rightSpec = rightSpec,
okSpec = okSpec,
isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot,
isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot,
isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot,
isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot,
isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(upSpec) },
onDownClick = { onButtonClick(downSpec) },
onLeftClick = { onButtonClick(leftSpec) },
onRightClick = { onButtonClick(rightSpec) },
onOkClick = { onButtonClick(okSpec) },
)
// 系统功能按键行 (返回 / 主页 / 菜单)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
Spacer(modifier = Modifier.height(4.dp))
// 音量与频道双摇杆胶囊
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// 音量控制
val volUp = RemoteSceneConfig.Television.VOL_UP
val volDown = RemoteSceneConfig.Television.VOL_DOWN
VerticalCapsuleRocker(
upSpec = volUp,
downSpec = volDown,
isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot,
isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(volUp) },
onDownClick = { onButtonClick(volDown) },
label = stringResource(R.string.infrared_label_vol),
)
// 频道控制
val chUp = RemoteSceneConfig.Television.CH_UP
val chDown = RemoteSceneConfig.Television.CH_DOWN
VerticalCapsuleRocker(
upSpec = chUp,
downSpec = chDown,
isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot,
isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(chUp) },
onDownClick = { onButtonClick(chDown) },
label = stringResource(R.string.infrared_label_ch),
)
}
}
}
@Composable
private fun TvActionButton(
spec: RemoteButtonSpec,
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
) {
RemoteRectButton(
spec = spec,
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(spec) },
width = 84.dp,
height = 44.dp,
)
}
@@ -0,0 +1,303 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
@Composable
fun InfraredControlRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredControlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredControlScreen(
state = state,
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
onSaveDeviceName = viewModel::saveDeviceName,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is InfraredUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is InfraredUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
}
is InfraredUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.infrared_control_title)) },
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
if (state.loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 1. 当前环境卡片
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "运行环境与连接信息",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
val envText = when (state.providerType) {
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
null -> state.profileId?.value ?: "未知环境"
}
Text(
text = stringResource(R.string.infrared_mode_label, envText),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
state.providerDisplayName?.let {
Text(
text = "当前 Profile: $it",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// 2. ABSDK 硬件设备名称配置(修改当前设备名)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.infrared_device_config_section),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Text(
text = stringResource(R.string.infrared_device_config_desc),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.infrared_device_name_current, state.savedDeviceName.value),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.deviceNameInput,
onValueChange = onDeviceNameChanged,
label = { Text("修改设备名(如:红外控制器)") },
singleLine = true,
modifier = Modifier
.weight(1f)
.testTag("infrared_device_name_input"),
)
Button(
onClick = onSaveDeviceName,
enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value,
modifier = Modifier.testTag("infrared_save_device_button"),
) {
Text(stringResource(R.string.infrared_device_save_changes))
}
}
}
}
// 3. 硬件槽位与遥控器概览
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.infrared_device_stats_section),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Text(
text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个",
style = MaterialTheme.typography.bodyMedium,
)
QuotaDetailProgress(quota = state.slotQuota)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("💡 槽位工作原理:", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("• 静态独占槽位 (Slot 1..99):用于物理学习录制的按键或预设静态按键,硬件直发低延迟(~20ms)。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("• 动态共享缓冲 (Slot 100):用于空调全状态算法写码与动态模式遥控器,按需现场写入发射,不消耗独占槽位。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
}
}
}
@Composable
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
)
Text(
text = "剩余可用静态槽位: ${quota.remainingStaticCount}",
fontSize = 12.sp,
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
LinearProgressIndicator(
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.infrared.ui
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface InfraredUiMessage {
data class Info(val text: UiText) : InfraredUiMessage
data class Error(val text: UiText) : InfraredUiMessage
data object SessionInvalid : InfraredUiMessage
}
data class InfraredControlUiState(
val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val totalRemotesCount: Int = 0,
val loading: Boolean = true,
val message: InfraredUiMessage? = null,
)
@@ -0,0 +1,184 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredControlViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val providerProfileStore: ProviderProfileStore,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
private data class ServerState(
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val remotesCount: Int = 0,
val loading: Boolean = false,
val configError: DomainError? = null,
)
private val serverStateFlow: Flow<ServerState> =
if (profileId == null) {
flowOf(ServerState(loading = false))
} else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Failure -> flowOf(
ServerState(
providerProfile = active,
configError = deviceResult.error,
),
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
) { remotesResult, quotaResult ->
val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList()
val quota = (quotaResult as? DomainResult.Success)?.value
?: SlotQuotaSummary()
ServerState(
providerProfile = active,
savedDevice = deviceName,
slotQuota = quota,
remotesCount = remotes.size,
loading = false,
)
}
}
}
}
}
}
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
messageFlow,
) { serverState, input, msg ->
val resolvedMessage = msg ?: serverState.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
is DomainError.DeviceOffline -> InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> InfraredUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
}
}
val effectiveInput = input ?: serverState.savedDevice.value
InfraredControlUiState(
profileId = profileId,
providerType = serverState.providerProfile?.type,
providerDisplayName = serverState.providerProfile?.displayName,
deviceNameInput = effectiveInput,
savedDeviceName = serverState.savedDevice,
slotQuota = serverState.slotQuota,
totalRemotesCount = serverState.remotesCount,
loading = serverState.loading,
message = resolvedMessage,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredControlUiState(profileId = profileId, loading = true),
)
fun onDeviceNameChanged(value: String) {
deviceNameInputFlow.value = value
}
fun saveDeviceName() {
val currentProfile = profileId ?: return
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
val trimmed = input.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_name_blank),
)
return
}
viewModelScope.launch {
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_device_name_saved),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = InfraredUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_device_offline,
listOf(error.externalCode ?: "20206"),
),
)
}
else -> {
messageFlow.value = InfraredUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
}
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
}
}
@@ -0,0 +1,116 @@
<resources>
<string name="infrared_title">红外遥控</string>
<string name="infrared_mode_fake">模式:模拟体验</string>
<string name="infrared_mode_real">模式:ABBox 真实硬件</string>
<string name="infrared_switch_mode">切换模式</string>
<string name="infrared_device_name_label">红外设备名称</string>
<string name="infrared_device_name_hint">例如:ir-living</string>
<string name="infrared_saved_device">已配置设备:%1$s</string>
<string name="infrared_no_device_saved">未配置红外设备</string>
<string name="infrared_save_device">保存设备</string>
<string name="infrared_keys_title">已学习按键</string>
<string name="infrared_no_keys">暂无已保存按键</string>
<string name="infrared_learn_new_key">学习新按键</string>
<string name="infrared_status_learning">学习中…</string>
<string name="infrared_status_ready">已就绪</string>
<string name="infrared_status_failed">学习失败</string>
<string name="infrared_send">发射</string>
<string name="infrared_sending">发射中…</string>
<string name="infrared_retry">重试</string>
<string name="infrared_rename">重命名</string>
<string name="infrared_delete">删除</string>
<string name="infrared_learn_dialog_title">学习红外按键</string>
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器,点击开始后将遥控器对准红外转发器并按下目标按键。</string>
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
<string name="infrared_learn_dialog_slot_label">按键槽位(1-100)</string>
<string name="infrared_key_slot_label">按键编号:%1$s</string>
<string name="infrared_learn_dialog_start">开始学习</string>
<string name="infrared_rename_dialog_title">重命名按键</string>
<string name="infrared_rename_dialog_input_label">新按键名称</string>
<string name="infrared_delete_dialog_title">删除按键</string>
<string name="infrared_delete_dialog_message">仅从本机删除该按键记录,ABox 主机上可能仍保留已录制码值。</string>
<string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</string>
<string name="infrared_remote_title">红外遥控器</string>
<string name="infrared_remote_recording">录制中</string>
<string name="infrared_remote_learning_mode">学习模式</string>
<string name="infrared_remote_learning_banner">🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
<string name="infrared_remote_scene_ac">❄️ 空调面板</string>
<string name="infrared_remote_scene_tv">📺 电视面板</string>
<string name="infrared_remote_settings">设置</string>
<string name="infrared_msg_learning_mode_enabled">🔴 学习模式已开启:点击任意按键开始录制</string>
<string name="infrared_msg_recording_key">正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键…</string>
<string name="infrared_msg_learned_success">【%1$s】录制成功</string>
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,开启右上角学习模式即可录制</string>
<string name="infrared_msg_signal_sent">已发射信号:%1$s</string>
<string name="infrared_msg_device_offline">红外设备离线(错误码:%1$s)</string>
<string name="infrared_msg_load_failed">加载数据失败</string>
<string name="infrared_msg_operation_failed">操作失败</string>
<string name="infrared_label_temp_control">温度调节</string>
<string name="infrared_label_vol">音量</string>
<string name="infrared_label_ch">频道</string>
<string name="infrared_label_cool_mode">❄️ 制冷模式</string>
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 自动扫风</string>
<string name="infrared_key_ac_power">空调-电源</string>
<string name="infrared_key_ac_temp_up">空调-升温</string>
<string name="infrared_key_ac_temp_down">空调-降温</string>
<string name="infrared_key_ac_mode">空调-模式</string>
<string name="infrared_key_ac_fan">空调-风速</string>
<string name="infrared_key_ac_swing">空调-扫风</string>
<string name="infrared_key_ac_timer">空调-定时</string>
<string name="infrared_key_tv_power">电视-电源</string>
<string name="infrared_key_tv_mute">电视-静音</string>
<string name="infrared_key_tv_nav_up">电视-向上</string>
<string name="infrared_key_tv_nav_down">电视-向下</string>
<string name="infrared_key_tv_nav_left">电视-向左</string>
<string name="infrared_key_tv_nav_right">电视-向右</string>
<string name="infrared_key_tv_ok">电视-确定</string>
<string name="infrared_key_tv_vol_up">电视-音量+</string>
<string name="infrared_key_tv_vol_down">电视-音量-</string>
<string name="infrared_key_tv_ch_up">电视-频道+</string>
<string name="infrared_key_tv_ch_down">电视-频道-</string>
<string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</string>
<string name="infrared_control_title">红外设备与环境配置</string>
<string name="infrared_device_config_section">ABSDK 设备名称配置</string>
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>
<string name="infrared_device_name_current">当前绑定设备:%1$s</string>
<string name="infrared_device_save_changes">保存修改</string>
<string name="infrared_device_stats_section">硬件槽位与遥控器概览</string>
<string name="infrared_mode_label">运行环境:%1$s</string>
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
<string name="infrared_msg_device_name_saved">设备名称已成功保存</string>
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
<string name="infrared_msg_key_renamed">按键已重命名</string>
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
<string name="infrared_hub_title">红外中枢</string>
<string name="infrared_hub_my_remotes">我的遥控器</string>
<string name="infrared_hub_slot_quota">硬件槽位:%1$d / %2$d 已占用(剩余 %3$d)</string>
<string name="infrared_hub_static_quota">静态占用:%1$d</string>
<string name="infrared_hub_dynamic_scratch">动态缓冲:Slot %1$s</string>
<string name="infrared_hub_remotes_title">虚拟遥控器</string>
<string name="infrared_hub_no_remotes">暂无虚拟遥控器,点击右上角或下方 ➕ 添加</string>
<string name="infrared_hub_add_remote">添加遥控器</string>
<string name="infrared_hub_mode_static">🚀 静态独占(%1$s 槽位)</string>
<string name="infrared_hub_mode_dynamic">📦 动态缓冲(共用 Slot %1$s)</string>
<string name="infrared_hub_create_dialog_title">添加虚拟遥控器</string>
<string name="infrared_hub_device_type_label">设备类型</string>
<string name="infrared_hub_brand_label">预设码库 / 品牌</string>
<string name="infrared_hub_trans_mode_label">传输模式</string>
<string name="infrared_hub_trans_mode_static_desc">超低延迟(~20ms),极速响应,占用 ~15 个硬件槽位</string>
<string name="infrared_hub_trans_mode_dynamic_desc">节省槽位(0 额外槽位),现场写码发射(~80ms)</string>
<string name="infrared_hub_quota_insufficient">硬件槽位不足,请选择动态写码模式</string>
<string name="infrared_hub_raw_keys_button">硬件槽位与底层按键管理</string>
<string name="infrared_hub_device_type_tv">📺 电视机 / 电视盒子</string>
<string name="infrared_hub_device_type_ac">❄️ 空调设备</string>
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
<string name="infrared_hub_device_type_custom">🎛️ 自定义面板</string>
</resources>
@@ -0,0 +1,117 @@
<resources>
<string name="infrared_title">Infrared Control</string>
<string name="infrared_mode_fake">Mode: Fake / Simulated</string>
<string name="infrared_mode_real">Mode: Real ABSDK</string>
<string name="infrared_switch_mode">Switch Mode</string>
<string name="infrared_device_name_label">Infrared Device Name</string>
<string name="infrared_device_name_hint">e.g. ir-living</string>
<string name="infrared_saved_device">Saved Device: %1$s</string>
<string name="infrared_no_device_saved">No infrared device configured</string>
<string name="infrared_save_device">Save Device</string>
<string name="infrared_keys_title">Learned Keys</string>
<string name="infrared_no_keys">No infrared keys saved</string>
<string name="infrared_learn_new_key">Learn New Key</string>
<string name="infrared_status_learning">Learning…</string>
<string name="infrared_status_ready">Ready</string>
<string name="infrared_status_failed">Failed</string>
<string name="infrared_send">Send</string>
<string name="infrared_sending">Sending…</string>
<string name="infrared_retry">Retry</string>
<string name="infrared_rename">Rename</string>
<string name="infrared_delete">Delete</string>
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
<string name="infrared_learn_dialog_start">Start Learning</string>
<string name="infrared_rename_dialog_title">Rename Key</string>
<string name="infrared_rename_dialog_input_label">New Display Name</string>
<string name="infrared_delete_dialog_title">Delete Key</string>
<string name="infrared_delete_dialog_message">Only removes the key locally from this app. ABox server may retain the learned key.</string>
<string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</string>
<string name="infrared_remote_title">Infrared Remote</string>
<string name="infrared_remote_recording">Recording</string>
<string name="infrared_hub_my_remotes">My Remotes</string>
<string name="infrared_remote_learning_mode">Learn Mode</string>
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
<string name="infrared_remote_scene_tv">📺 Television</string>
<string name="infrared_remote_settings">Settings</string>
<string name="infrared_msg_learning_mode_enabled">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_msg_recording_key">Recording [%1$s] (Slot %2$s), press remote button…</string>
<string name="infrared_msg_learned_success">[%1$s] recorded successfully</string>
<string name="infrared_msg_key_not_learned">Button [%1$s] is not learned. Enable learn mode to record.</string>
<string name="infrared_msg_signal_sent">Sent: %1$s</string>
<string name="infrared_msg_device_offline">Infrared controller offline (code: %1$s)</string>
<string name="infrared_msg_load_failed">Failed to load data</string>
<string name="infrared_msg_operation_failed">Operation failed</string>
<string name="infrared_label_temp_control">Temp Control</string>
<string name="infrared_label_vol">VOL</string>
<string name="infrared_label_ch">CH</string>
<string name="infrared_label_cool_mode">❄️ Cool Mode</string>
<string name="infrared_label_ac_status">💨 Auto Fan | ↔ Swing</string>
<string name="infrared_key_ac_power">AC Power</string>
<string name="infrared_key_ac_temp_up">AC Temp +</string>
<string name="infrared_key_ac_temp_down">AC Temp -</string>
<string name="infrared_key_ac_mode">AC Mode</string>
<string name="infrared_key_ac_fan">AC Fan Speed</string>
<string name="infrared_key_ac_swing">AC Swing</string>
<string name="infrared_key_ac_timer">AC Timer</string>
<string name="infrared_key_tv_power">TV Power</string>
<string name="infrared_key_tv_mute">TV Mute</string>
<string name="infrared_key_tv_nav_up">TV Up</string>
<string name="infrared_key_tv_nav_down">TV Down</string>
<string name="infrared_key_tv_nav_left">TV Left</string>
<string name="infrared_key_tv_nav_right">TV Right</string>
<string name="infrared_key_tv_ok">TV OK</string>
<string name="infrared_key_tv_vol_up">TV Vol +</string>
<string name="infrared_key_tv_vol_down">TV Vol -</string>
<string name="infrared_key_tv_ch_up">TV CH +</string>
<string name="infrared_key_tv_ch_down">TV CH -</string>
<string name="infrared_key_tv_home">TV Home</string>
<string name="infrared_key_tv_back">TV Back</string>
<string name="infrared_key_tv_menu">TV Menu</string>
<string name="infrared_control_title">Device Configuration</string>
<string name="infrared_device_config_section">ABSDK Device Name</string>
<string name="infrared_device_config_desc">ABSDK matches hardware devices by device name (default: 红外控制器). Commands will be dispatched to this target device.</string>
<string name="infrared_device_name_current">Current Bound Device: %1$s</string>
<string name="infrared_device_save_changes">Save Changes</string>
<string name="infrared_device_stats_section">Hardware Slot Budget</string>
<string name="infrared_mode_label">Environment: %1$s</string>
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
<string name="infrared_msg_device_name_saved">Device name saved successfully</string>
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
<string name="infrared_msg_key_renamed">Key renamed</string>
<string name="infrared_msg_key_deleted">Key removed locally</string>
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
<string name="infrared_hub_title">Infrared Hub</string>
<string name="infrared_hub_slot_quota">Hardware Slots: %1$d / %2$d allocated (%3$d free)</string>
<string name="infrared_hub_static_quota">Static: %1$d</string>
<string name="infrared_hub_dynamic_scratch">Dynamic Buffer: Slot %1$s</string>
<string name="infrared_hub_remotes_title">Virtual Remotes</string>
<string name="infrared_hub_no_remotes">No virtual remotes added yet. Tap + to add one.</string>
<string name="infrared_hub_add_remote">Add Remote</string>
<string name="infrared_hub_mode_static">🚀 Static Mode (%1$s slots)</string>
<string name="infrared_hub_mode_dynamic">📦 Dynamic Mode (Scratch Slot %1$s)</string>
<string name="infrared_hub_create_dialog_title">Add Virtual Remote</string>
<string name="infrared_hub_device_type_label">Device Type</string>
<string name="infrared_hub_brand_label">Brand / Preset Library</string>
<string name="infrared_hub_trans_mode_label">Transmission Mode</string>
<string name="infrared_hub_trans_mode_static_desc">Low latency (~20ms), instant response, occupies ~15 slots</string>
<string name="infrared_hub_trans_mode_dynamic_desc">Saves slots (0 extra slots), writes code on-the-fly (~80ms)</string>
<string name="infrared_hub_quota_insufficient">Slot quota exceeded. Please choose Dynamic mode.</string>
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
<string name="infrared_hub_device_type_tv">📺 Television (TV)</string>
<string name="infrared_hub_device_type_ac">❄️ Air Conditioner (AC)</string>
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
<string name="infrared_hub_device_type_custom">🎛️ Custom Panel</string>
</resources>
@@ -0,0 +1,156 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredLearningCoordinatorTest {
private val profile = ProviderProfileId("profile1")
private val device = DeviceName("ir-1")
private val clock = EpochMillisClock { 1_000L }
private val repository = FakeInfraredKeyRepository()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test
fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.learn(profile, device, "Power")
advanceUntilIdle()
val keysResult = repository.observeKeys(profile, device).first()
assertTrue(keysResult is DomainResult.Success)
val keys = (keysResult as DomainResult.Success).value
assertEquals(1, keys.size)
assertEquals(InfraredKeyStatus.READY, keys.single().status)
}
@Test
fun retryRunsAsynchronouslyInExternalScope() = runTest {
val failedKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(failedKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun relearnRunsAsynchronouslyInExternalScope() = runTest {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(readyKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun failInterruptedLearningFailsLearningKeys() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, 2_000L)
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, updated.status)
}
@Test
fun subsequentLearnWaitsForCleanupCompletion() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L)
val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val relearnResult = relearnJob.await()
assertTrue(relearnResult is DomainResult.Success)
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
}
@@ -0,0 +1,163 @@
package com.flagship.abox.manager.infrared.hub
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredHubViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profileId = ProviderProfileId("profile1")
private val providerProfile = ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local Simulation")
private val device = DeviceName("ir-1")
private val clock = EpochMillisClock { 10_000L }
private val profileStore = FakeProviderProfileStore()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val remoteRepo = FakeVirtualRemoteRepository()
private val gateway = FakeInfraredGateway()
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
private val deleteRemote = DeleteVirtualRemote(remoteRepo)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredHubViewModel = InfraredHubViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
providerProfileStore = profileStore,
deviceConfigurationRepository = deviceRepo,
virtualRemoteRepository = remoteRepo,
createVirtualRemote = createRemote,
deleteVirtualRemote = deleteRemote,
)
@Test
fun initialStateLoadsRemotesAndSlotQuota() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
assertEquals(device, vm.state.value.savedDeviceName)
assertEquals(0, vm.state.value.remotes.size)
assertEquals(99, vm.state.value.slotQuota.remainingStaticCount)
job.cancel()
}
@Test
fun createRemoteAddsToRepositoryAndUpdatesState() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.createRemote(
name = "Living Room TV",
type = RemoteDeviceType.TV,
brandModel = "generic_tv",
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
advanceUntilIdle()
assertEquals(1, vm.state.value.remotes.size)
assertEquals("Living Room TV", vm.state.value.remotes.first().displayName)
assertEquals(TransmissionMode.STATIC_SLOTS, vm.state.value.remotes.first().transmissionMode)
job.cancel()
}
@Test
fun deleteRemoteRemovesFromRepository() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.createRemote(
name = "Fan",
type = RemoteDeviceType.FAN,
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
advanceUntilIdle()
val remoteId = vm.state.value.remotes.first().id
vm.deleteRemote(remoteId)
advanceUntilIdle()
assertEquals(0, vm.state.value.remotes.size)
job.cancel()
}
@Test
fun createCustomRemoteWithTvLayoutAssignsLayoutAndStaticSlots() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.createRemote(
name = "Custom TV Remote",
type = RemoteDeviceType.CUSTOM,
brandModel = null,
transmissionMode = TransmissionMode.STATIC_SLOTS,
layoutStyle = com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV,
)
advanceUntilIdle()
assertEquals(1, vm.state.value.remotes.size)
val created = vm.state.value.remotes.first()
assertEquals("Custom TV Remote", created.displayName)
assertEquals(com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, created.layoutStyle)
assertEquals(TransmissionMode.STATIC_SLOTS, created.transmissionMode)
job.cancel()
}
}
@@ -0,0 +1,229 @@
package com.flagship.abox.manager.infrared.panel
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RemotePanelViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profileId = ProviderProfileId("profile1")
private val device = DeviceName("ir-1")
private val clock = EpochMillisClock { 10_000L }
private val remoteRepo = FakeVirtualRemoteRepository()
private val gateway = FakeInfraredGateway()
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway)
private val learnKey = LearnVirtualRemoteKey(remoteRepo, gateway)
private val addKey = AddVirtualRemoteKey(remoteRepo)
private val deleteKey = DeleteVirtualRemoteKey(remoteRepo)
private val updateLayout = UpdateVirtualRemoteLayout(remoteRepo)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(remoteId: String): RemotePanelViewModel = RemotePanelViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)),
virtualRemoteRepository = remoteRepo,
sendVirtualRemoteAction = sendAction,
learnVirtualRemoteKey = learnKey,
addVirtualRemoteKey = addKey,
deleteVirtualRemoteKey = deleteKey,
updateVirtualRemoteLayout = updateLayout,
)
@Test
fun tvButtonTriggersInstantTransmissionInNormalMode() = testScope.runTest {
val res = createRemote(
profileId = profileId,
deviceName = device,
displayName = "TV",
type = RemoteDeviceType.TV,
brandModel = "generic_tv",
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
val vm = createViewModel(remoteId.value)
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick("TV_POWER")
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learnModeToggledTriggersLearningWhenButtonIsClicked() = testScope.runTest {
val res = createRemote(
profileId = profileId,
deviceName = device,
displayName = "TV",
type = RemoteDeviceType.TV,
brandModel = "generic_tv",
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
val vm = createViewModel(remoteId.value)
val job = launch { vm.state.collect {} }
advanceUntilIdle()
// 1. Toggle learn mode ON
vm.toggleLearnMode()
runCurrent()
assertTrue(vm.state.value.isLearnMode)
// 2. Click button -> triggers learn instead of send
vm.onButtonClick("TV_POWER")
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
assertEquals(0, gateway.sentKeys.size)
job.cancel()
}
@Test
fun addAndRemoveCustomButtonUpdatesBindings() = testScope.runTest {
val res = createRemote(
profileId = profileId,
deviceName = device,
displayName = "Custom",
type = RemoteDeviceType.CUSTOM,
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
val vm = createViewModel(remoteId.value)
val job = launch { vm.state.collect {} }
advanceUntilIdle()
// Add button
vm.addCustomButton("Sound Effect")
advanceUntilIdle()
val added = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
assertNotNull(added)
// Delete button
vm.deleteButton(added!!.buttonId)
advanceUntilIdle()
val afterDelete = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
assertEquals(null, afterDelete)
job.cancel()
}
@Test
fun switchLayoutUpdatesRemoteLayoutStyle() = testScope.runTest {
val res = createRemote(
profileId = profileId,
deviceName = device,
displayName = "Custom Box",
type = RemoteDeviceType.CUSTOM,
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
val vm = createViewModel(remoteId.value)
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.switchLayout(PanelLayoutStyle.TV_NAV)
advanceUntilIdle()
assertEquals(PanelLayoutStyle.TV_NAV, vm.state.value.remote?.layoutStyle)
job.cancel()
}
@Test
fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest {
val res = createRemote(
profileId = profileId,
deviceName = device,
displayName = "AC",
type = RemoteDeviceType.AIR_CONDITIONER,
brandModel = "gree_ac",
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
val vm = createViewModel(remoteId.value)
val job = launch { vm.state.collect {} }
advanceUntilIdle()
// 1. Click +1 temp (optimistic update to 27)
vm.adjustAcTemperature(1)
runCurrent()
assertEquals(27, vm.state.value.acState.temperature)
// 2. Click +1 temp again within debounce window (optimistic update to 28)
advanceTimeBy(100L)
vm.adjustAcTemperature(1)
runCurrent()
assertEquals(28, vm.state.value.acState.temperature)
// Advance debounce window to fire (300ms)
advanceTimeBy(400L)
advanceUntilIdle()
// Only 1 download and 1 transmission fired for final state 28
assertEquals(1, gateway.downloadedCodes.size)
assertEquals("100", gateway.downloadedCodes.first().key)
assertEquals(1, gateway.sentKeys.size)
assertEquals("100", gateway.sentKeys.first().key)
job.cancel()
}
}
@@ -0,0 +1,350 @@
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
providerProfileStore = profileStore,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.toggleLearningMode(false)
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
job.cancel()
}
@Test
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(failedKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(
RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message)
job.cancel()
}
@Test
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.toggleLearningMode(false)
advanceUntilIdle()
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val learningKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(learningKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message)
job.cancel()
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 21..50 })
}
}
@@ -0,0 +1,149 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredControlViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profileId = ProviderProfileId("profile1")
private val providerProfile = ProviderProfile(
id = profileId,
type = ProviderType.LOCAL_EXPERIENCE,
displayName = "Local Simulation",
)
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val remoteRepo = FakeVirtualRemoteRepository()
private val profileStore = FakeProviderProfileStore()
private val gateway = FakeInfraredGateway()
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredControlViewModel {
return InfraredControlViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
deviceConfigurationRepository = deviceRepo,
virtualRemoteRepository = remoteRepo,
providerProfileStore = profileStore,
)
}
@Test
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
assertEquals(device, vm.state.value.savedDeviceName)
assertEquals("ir-living", vm.state.value.deviceNameInput)
assertEquals(0, vm.state.value.totalRemotesCount)
job.cancel()
}
@Test
fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest {
profileStore.saveProfile(providerProfile)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged("ir-bedroom")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName)
assertEquals(
InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)),
vm.state.value.message,
)
job.cancel()
}
@Test
fun blankDeviceNameEmitsErrorMessage() = testScope.runTest {
profileStore.saveProfile(providerProfile)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(
InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)),
vm.state.value.message,
)
job.cancel()
}
@Test
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
createRemote(
profileId = profileId,
deviceName = device,
displayName = "Living TV",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(1, vm.state.value.totalRemotesCount)
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
assertEquals(16, vm.state.value.slotQuota.usedCount)
job.cancel()
}
}
+1
View File
@@ -6,3 +6,4 @@ plugins {
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt.android) apply false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,589 @@
# Infrared Remote Control UI & Multi-Scene Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation.
**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`.
**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines.
**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md`
## Global Constraints
- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces.
- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50).
- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`.
- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names.
- Follow TDD with unit and Compose UI tests.
---
### Task 1: Define Remote Scenes and Key Specs
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt`
**Interfaces:**
- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`.
- [ ] **Step 1: Write test for predefined AC and TV remote button specifications**
```kotlin
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 21..50 })
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `RemoteModels.kt`**
```kotlin
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU
)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt
git commit -m "feat: define infrared remote scenes and button specifications"
```
---
### Task 2: Implement Infrared Remote ViewModel
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt`
**Interfaces:**
- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`.
- Produces: `StateFlow<InfraredRemoteUiState>` with `onButtonClick`, `toggleLearningMode`, `switchScene`.
- [ ] **Step 1: Write failing ViewModel test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
private class TestModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) flowOf(DomainResult.Success(emptyList()))
else keyRepository.observeKeys(profile, device)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg ->
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (state.value.isLearningMode) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: implement infrared remote control viewmodel"
```
---
### Task 3: Build Custom Remote Visual Components
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt`
- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt`
- [ ] **Step 1: Implement Compose visual elements**
- `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`.
- Compose panels for `AcRemotePanel` and `TvRemotePanel`.
- Assemble `InfraredRemoteScreen` with top learning switch and settings action.
- [ ] **Step 2: Write Compose UI Test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = {},
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
}
}
```
- [ ] **Step 3: Run compilation and tests**
Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: add infrared remote control Compose screen and panels"
```
---
### Task 4: Integrate Navigation and App Flow
**Files:**
- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt`
- Modify: `android/app/src/main/res/values/strings.xml`
- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml`
- [ ] **Step 1: Wire Navigation**
- Route `infrared/{profileId}` now loads `InfraredRemoteRoute`.
- Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view).
- Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`.
- [ ] **Step 2: Run Full Verification Suite & Build APK**
Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug`
Expected: BUILD SUCCESSFUL.
- [ ] **Step 3: Commit and Push**
```bash
git add android/app android/feature/infrared
git commit -m "feat: wire infrared remote screen and settings navigation"
git push origin feat/moler-infrared-control
```
@@ -0,0 +1,606 @@
# 红外学习与发射控制设计
## 1. 背景与目标
本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环:
1. 在登录成功后的现有页面提供一个临时入口;
2. 手动配置并记住一个 ABSDK 红外设备名;
3. 创建并命名多个红外按键;
4. 通过 ABSDK 发起红外学习;
5. 保存可重复使用的按键绑定;
6. 通过 ABSDK 发射已学习按键;
7. 支持 Fake 和真实 ABSDK 调试模式;
8. 为后续生活场景保留稳定按键引用。
首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。
## 2. 已确认的 ABSDK 能力边界
ABSDK 0630 提供以下红外公开方法:
```java
ABRet sendIr(String irDevName, String irKey)
ABRet studyIrByIrDevName(String irDevName, String irKey)
ABRet downloadIrCodes(List<IrInfo> irInfos)
```
现有领域层已将其隔离为 `InfraredGateway`:
```kotlin
interface InfraredGateway {
suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit>
suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit>
suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>>
}
```
本设计必须遵守以下事实:
- 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值;
- ABSDK 没有公开停止学习方法;
- ABSDK 没有公开获取学习结果或读取已学习原始码的方法;
- `downloadIrCodes()` 是把调用方已经拥有的红外码写入主机,不是读取学习结果;
- ABSDK 没有设备扫描或设备列表接口;
- 红外方法按 `irDevName + irKey` 寻址;
- SDK 调用为同步阻塞调用,现有 Bridge 在 `Dispatchers.IO` 上通过全局 `Mutex` 串行执行;
- SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。
因此首版保存的是本地按键绑定,而不是原始红外波形或码值:
```text
ProviderProfileId + DeviceName + sdkKey + displayName + learning status
```
真实硬件闭环成立的判据是:
```text
learnKey(deviceName, sdkKey) 成功
↓
sendKey(deviceName, sdkKey)
↓
实体被控设备产生正确响应
```
## 3. 范围
### 3.1 本阶段包含
- 红外功能的领域模型和本地持久化端口;
- Room 数据库、DAO 和 Repository;
- 学习、重试、发射、删除和恢复应用用例;
- 红外 ViewModel 和简单 Compose 页面;
- 登录成功页面的临时红外入口;
- 手动输入并记住一个 `irDevName`;
- 单红外控制器、多按键;
- Fake/Real ABSDK 显式调试模式;
- 自动化测试;
- 真机验收清单;
- 面向未来生活场景的稳定按键引用。
### 3.2 本阶段不包含
- 最终 UI 视觉规范或复杂遥控器布局;
- 多个遥控器管理界面;
- ABSDK 不存在的设备扫描;
- 原始红外码读取或持久化;
- 红外码库导入 UI;
- 硬件停止学习;
- 完整生活场景模型、规则引擎或场景执行器;
- Release 中默认启用真实 ABSDK;
- 绕过或隐藏厂商明文 HTTP 风险;
- 删除 ABox 服务端已学习按键。
## 4. 架构
采用独立应用编排层,保持现有 `InfraredGateway` 作为纯硬件能力端口:
```text
登录成功页面
└─ 临时入口:红外控制
↓
InfraredControlScreen
↓
InfraredControlViewModel
↓
应用用例
├─ LearnInfraredKey
├─ RetryInfraredKeyLearning
├─ SendInfraredKey
├─ DeleteInfraredKey
├─ RecoverInterruptedInfraredLearning
└─ ObserveInfraredKeys
↙ ↘
InfraredKeyRepository InfraredGateway
↓ ├─ FakeInfraredGateway
RoomInfraredKeyRepository └─ AbsdkBridge
↓
ABSDK 0630
```
### 4.1 边界原则
- `InfraredGateway` 只负责学习、发射和码库下载,不负责 Room、页面状态或遥控器管理;
- 不修改 `learnKey()` 返回类型,不虚构 ABSDK 原始码结果;
- 不把 `ABSDK`、`ABRet`、`IrInfo` 或原始 Map 暴露给上层;
- ViewModel 不直接编排数据库和硬件之间的一致性,统一通过应用用例;
- Room Entity 不作为领域或 UI 公共模型;
- Fake 和真实 Bridge 必须共用相同的领域接口和用例;
- 所有真实 Gateway 接口必须指向同一个进程级 `AbsdkBridge` 实例,保持全局会话和互斥锁一致。
## 5. 数据模型
### 5.1 稳定标识
```kotlin
@JvmInline
value class InfraredKeyId(val value: String)
data class InfraredKeyRef(
val profileId: ProviderProfileId,
val keyId: InfraredKeyId,
)
```
`InfraredKeyRef` 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。
### 5.2 学习状态
```kotlin
enum class InfraredKeyStatus {
LEARNING,
READY,
FAILED,
}
```
状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。
### 5.3 领域模型
```kotlin
data class SavedInfraredKey(
val id: InfraredKeyId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val sdkKey: String,
val status: InfraredKeyStatus,
val lastErrorCode: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
```
字段约束:
- `id` 在 App 内稳定,供页面、删除和未来生活场景引用;
- `profileId` 用于不同登录配置的数据隔离;
- `deviceName` 是传给 ABSDK 的真实红外设备名;
- `displayName` 是用户可见名称,可以修改;
- `sdkKey` 是传给 ABSDK 的稳定 Key,学习后不随显示名改变;
- `lastErrorCode` 仅保存脱敏厂商错误码,不保存密码、Token、原始 Map 或红外载荷;
- 时间字段由应用层时钟提供,测试中使用可控时钟。
### 5.4 SDK Key
App 自动生成 SDK Key,不允许用户直接编辑。格式应:
- 仅包含安全 ASCII 字符;
- 在同一 Provider 和红外设备下唯一;
- 重试学习时复用;
- 修改显示名时保持不变;
- 不直接使用中文、空格或特殊字符。
建议格式:
```text
app_ir_<normalized-infrared-key-id>
```
实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。
## 6. 本地持久化
采用 Room。Repository 边界至少支持:
```kotlin
interface InfraredKeyRepository {
fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>>
suspend fun getKey(
ref: InfraredKeyRef,
): DomainResult<SavedInfraredKey?>
suspend fun insertLearningKey(
key: SavedInfraredKey,
): DomainResult<Unit>
suspend fun updateStatus(
ref: InfraredKeyRef,
status: InfraredKeyStatus,
lastErrorCode: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun renameKey(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun deleteKey(
ref: InfraredKeyRef,
): DomainResult<Unit>
suspend fun findInterruptedLearning(
profileId: ProviderProfileId,
): DomainResult<List<SavedInfraredKey>>
}
```
具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。
### 6.1 数据库约束
- 主键:`InfraredKeyId`;
- 索引:`ProviderProfileId + DeviceName`;
- 唯一约束:`ProviderProfileId + DeviceName + sdkKey`;
- 查询和删除必须包含 Provider 隔离;
- 不保存原始红外码;
- 不把 Room Entity 暴露给领域层;
- 首版预留 schema version 和迁移测试基础。
### 6.2 单设备策略
页面记住一个当前 `irDevName`,并只展示该设备下的按键。切换当前设备名不会迁移或重写旧按键;旧按键仍绑定学习时使用的设备名。
设备名可以保存到 Room 配置表或 DataStore。若采用 DataStore,只用于当前设备名和 debug 模式等轻量配置,不承载红外按键集合。
## 7. 学习流程
`LearnInfraredKey` 的流程:
1. 验证用户已登录且存在有效 `ProviderProfileId`;
2. 验证 `deviceName` 和 `displayName` 非空;
3. 生成稳定 `InfraredKeyId` 和 `sdkKey`;
4. 先写入状态为 `LEARNING` 的记录;
5. 在应用级协程作用域调用 `InfraredGateway.learnKey()`;
6. 成功时更新为 `READY` 并清空错误码;
7. 失败时更新为 `FAILED` 并保存脱敏错误码;
8. 如果状态更新失败,记录可恢复的本地数据错误,但不重新生成 SDK Key;
9. 页面通过 Room Flow 观察最终状态。
### 7.1 重试
重试必须:
- 使用原 `InfraredKeyId`;
- 使用原 `sdkKey`;
- 先将状态更新为 `LEARNING`;
- 再调用 ABSDK;
- 不创建重复按键记录。
### 7.2 页面离开
学习调用开始后,页面可以退出。任务继续在应用级作用域等待 SDK 返回,并更新 Room。ViewModel 销毁不应直接取消已经提交的学习编排。
这不是后台系统任务保证:如果进程被系统杀死,底层同步网络请求及设备状态无法可靠恢复。
### 7.3 进程恢复
App 启动或登录恢复后,查找遗留的 `LEARNING` 记录:
```text
LEARNING → FAILED
错误提示:上次学习结果未确认,请重新学习
```
不得自动标记为 `READY`,也不得自动发射来试探设备状态。
### 7.4 取消语义
ABSDK 没有停止学习 API。首版 UI 不提供或宣称“停止硬件学习”。用户可以离开页面,但学习调用仍等待成功、失败或 SDK 超时。
## 8. 发射流程
`SendInfraredKey` 的流程:
1. 使用 `InfraredKeyRef` 查询本地记录;
2. 记录不存在时返回领域错误;
3. 仅允许 `READY` 状态发射;
4. 取得记录中的 `profileId + deviceName + sdkKey`;
5. 调用 `InfraredGateway.sendKey()`;
6. 成功时向 UI 返回一次性成功事件;
7. 失败时保留按键 `READY` 状态,仅报告本次错误;
8. 不自动重试,避免重复操作电视、空调等实体设备。
未来生活场景通过稳定 `InfraredKeyRef` 调用同一发送用例或薄执行器:
```kotlin
interface InfraredActionExecutor {
suspend fun execute(ref: InfraredKeyRef): DomainResult<Unit>
}
```
本阶段只定义或保证稳定引用边界,不实现 Scene 模型、规则引擎或场景执行顺序。
## 9. 删除与重命名
### 9.1 删除
- `FAILED` 和 `READY` 记录可以删除;
- `LEARNING` 中不允许删除,避免后台结果写入已不存在的记录;
- 删除只移除本地引用;
- ABSDK 没有删除服务端红外 Key 的公开接口;
- UI 必须提示“ABox 服务端可能仍保留该按键”;
- 删除后未来场景引用解析失败,不再发射。
### 9.2 重命名
- 只修改 `displayName`;
- 不修改 `id`、`deviceName` 或 `sdkKey`;
- 重命名后学习和发射能力保持不变;
- 场景引用保持有效。
## 10. Android 前端
### 10.1 临时入口
在当前登录成功后的页面添加“红外控制”按钮,导航到独立 `InfraredControlScreen`。未来正式信息架构完成后只迁移入口。
### 10.2 页面内容
首版页面只包含:
- 返回按钮和标题;
- 当前数据源标识:Fake 或真实 ABox;
- 红外控制器设备名输入和保存;
- 已学习按键列表;
- 学习新按键按钮;
- 按键状态;
- READY 按键的发射按钮;
- FAILED 按键的重试按钮;
- 删除入口和确认提示;
- 基础错误反馈。
不加入复杂遥控器模板、图标系统、网格布局、品牌色或动画规范。
### 10.3 设备名
- ABSDK 没有设备扫描能力;
- 首次进入时手动填写 `irDevName`;
- 保存后本地记住;
- 没有设备名时禁止学习;
- 修改设备名前提示旧按键仍绑定旧设备;
- 列表只显示当前设备名下的按键。
### 10.4 学习交互
用户输入显示名称后开始学习。页面提示:
```text
请将实体遥控器对准红外控制器并按下目标按键。
```
按键显示 `LEARNING`、`READY` 或 `FAILED`。允许离开页面,但不显示“停止硬件学习”。
### 10.5 发射交互
- 只有 `READY` 按键可发射;
- 发射期间禁用该按键,防止重复点击;
- 成功显示短暂提示;
- 失败显示具体错误;
- 不自动重试;
- 单次失败不删除或降级按键状态。
### 10.6 删除提示
删除确认文案应明确:
```text
仅从本机列表移除,ABox 服务端可能仍保留该按键。
```
## 11. Debug Fake/Real 路由
Debug 构建提供显式运行模式:
- `FAKE`:页面开发、自动化测试和错误模拟;
- `REAL_ABSDK`:复用当前登录会话,调用真实 `AbsdkBridge`。
约束:
- 页面持续显示当前模式;
- 不允许在学习任务进行中切换模式;
- Fake 和 Real 共用同一 ViewModel、应用用例和 Room Repository;
- 模式和 Provider 数据必须隔离,避免 Fake 按键误用于真实设备;
- Fake 支持按 `ProviderProfileId + DeviceName + sdkKey` 配置成功或失败;
- 真实模式的所有 Gateway 接口绑定同一 `AbsdkBridge` 单例;
- 真实模式必须显式 opt in `CleartextAbsdkTransport`;
- Release 暂不启用真实 Bridge,除非项目明确批准传输安全方案。
真实 App 接入还必须补齐:
- `:absdk-bridge` 依赖;
- Hilt 绑定;
- `INTERNET` 权限;
- 经批准的明文网络配置;
- 登录会话与红外 Gateway 的同一 Bridge 实例;
- Token 失效后的页面返回登录流程。
## 12. 错误处理
继续使用现有 `DomainResult` 和 `DomainError`。不为首版扩充 sealed 类型,优先利用已有分类和 `externalCode`。
| 情况 | UI 反馈 | 本地状态 |
|---|---|---|
| Token 无效 | 登录已失效,返回登录 | 学习按键为 FAILED;发射按键仍为 READY |
| 红外设备不存在 | 检查设备名 | 学习按键为 FAILED |
| 红外设备离线 | 检查供电和网络 | 学习按键为 FAILED;发射按键仍为 READY |
| KEY/码值不存在 | 建议重新学习 | 发射按键仍为 READY,由用户决定重学 |
| 红外发射失败 | 显示失败,可手动重试 | READY |
| 网络超时 | 网络或 ABox 服务不可用 | 学习按键为 FAILED;发射按键仍为 READY |
| 本地保存失败 | 无法开始学习 | 不调用硬件学习 |
| 学习成功但最终状态写入失败 | 显示数据保存异常并保留恢复信息 | 不生成新 sdkKey |
| 未知错误 | 通用提示及脱敏错误码 | 按操作类型处理 |
日志不得包含:
- 用户密码;
- Token;
- 原始 SDK Map;
- 原始红外码或载荷;
- 可用于重放的敏感字段。
## 13. 自动化测试
### 13.1 应用用例
覆盖:
1. 学习前先保存 `LEARNING`;
2. 学习成功更新为 `READY`;
3. 学习失败更新为 `FAILED`;
4. 重试复用原 `sdkKey`;
5. 修改显示名不改变 `sdkKey`;
6. 只有 `READY` 可以发射;
7. 发射使用保存的 `deviceName + sdkKey`;
8. 不同 Provider 数据隔离;
9. 遗留 `LEARNING` 恢复为 `FAILED`;
10. 删除后稳定引用失效;
11. ViewModel 销毁后应用级学习任务仍可落库;
12. 发射失败不改变 READY;
13. 进行中的学习阻止运行模式切换。
### 13.2 Room
覆盖:
- 新增、观察、查询、重命名和删除;
- `ProviderProfileId + DeviceName` 过滤;
- `sdkKey` 唯一约束;
- Provider 隔离;
- 状态转换;
- 稳定 ID 跨重命名不变;
- 首版 schema 和迁移测试基础;
- 不存储原始红外载荷。
### 13.3 Fake
扩展现有 Fake,覆盖:
- 按 Provider、设备和 Key 脚本化结果;
- 调用参数与顺序;
- 连续响应;
- 成功、离线、超时和通用失败;
- 不同 Provider 互不影响。
### 13.4 Bridge
保留现有覆盖并补充:
- 学习参数和调用顺序;
- 发射参数和调用顺序;
- 红外错误码 `20201` 至 `20206` 的映射;
- 学习、发射与其他设备调用的全局串行;
- Token 失效清理会话;
- SDK 空值和异常防御;
- 不改变 `downloadCodes()` 的部分成功语义。
### 13.5 ViewModel 与 Compose
只验证功能,不验证最终视觉:
- 无设备名不能学习;
- 保存和恢复设备名;
- 创建学习任务;
- 显示学习中、成功和失败;
- 重试学习;
- 点击发射并防重复点击;
- 删除确认;
- Fake/Real 模式标识;
- 登录失效引导;
- 基本中文和英文资源。
## 14. 真机验收
第一轮使用现有 ABox 主机和红外控制器:
1. 登录真实账号;
2. 输入后台提供的准确 `irDevName`;
3. 创建唯一按键,例如“测试电源”;
4. 调用学习并记录耗时、返回码和消息;
5. 学习成功后立即发射;
6. 验证实体电器是否响应;
7. 用同一按键重复发射;
8. 重新学习同一 `sdkKey`,验证覆盖或报错行为;
9. 红外控制器断电后验证离线错误;
10. 不按实体遥控器,验证实际超时行为;
11. 页面退出后验证后台结果落库;
12. 进程被杀后验证遗留 LEARNING 恢复;
13. Token 失效后验证返回登录;
14. 记录 SDK 是否存在未文档化 `dicDatas`,但不将内容写入生产日志。
### 14.1 必须验证的未知项
- 学习调用是立即返回还是阻塞到捕获完成;
- 学习成功是否自动绑定 `sdkKey`;
- 同一 `sdkKey` 重学是否覆盖;
- 无信号时实际超时时间;
- 页面离开后底层调用是否可完成;
- 设备名格式、大小写和特殊字符规则;
- 服务端是否永久保留已学习 Key;
- SDK 是否返回未文档化数据;
- `sdkKey` 的长度限制;
- 真实模式所需初始化信息和调用顺序。
这些未知项不得通过业务层假设隐藏。真机结果应更新协议文档和后续设计。
## 15. 验收标准
本阶段完成需要满足:
- 登录成功页面可以进入简单红外页面;
- 可以手动配置并恢复一个红外设备名;
- 可以创建多个命名按键;
- 学习调用前已持久化 LEARNING;
- 学习成功或失败能正确落库;
- READY 按键可以调用发射;
- 显示名修改不影响发射;
- Fake 模式可以覆盖主要状态和错误;
- Real 模式明确标识并调用真实 Bridge;
- 页面退出后学习任务仍可更新状态;
- 进程中断后遗留学习状态可以保守恢复;
- 自动化测试通过;
- 真机至少完成一次“学习成功 → 发射 → 实体响应”;
- 未确认硬件语义被记录,不伪装为已支持;
- 未来生活场景可以通过稳定 `InfraredKeyRef` 引用按键。
## 16. 后续工作
本设计完成后再分别规划:
1. 多遥控器和房间组织;
2. 统一产品视觉和正式导航入口;
3. 红外码库导入;
4. 生活场景编排;
5. Scene 顺序执行和单项结果;
6. 厂商明文 HTTP 的正式安全方案;
7. 真机验证后对学习语义、超时和错误模型的修订。
@@ -0,0 +1,115 @@
# 红外遥控器界面与多场景控制设计规范 / Infrared Remote Control UI Specification
## 1. 背景与目标 (Background & Goals)
在完成了底层红外学习、发射、Room 数据库、1~100 整数 Key 槽位管理以及 Fake/Real ABSDK 路由的基础上,本项目需要提供专业且直观的前端遥控器人机交互界面。
### 核心目标
1. **多场景遥控面板**:提供空调 (Air Conditioner) 与电视 (Television) 两大经典场景遥控器界面,使用圆形、圆角矩形、胶囊、圆形方向盘等拟物/现代图形组件构建真实遥控器外观。
2. **学习模式与控制模式切换**:右上角提供“红外学习模式”开关,在控制模式下点击按键发射红外,在学习模式下点击按键录制红外。
3. **固定槽位绑定 (Fixed Slot Binding)**:遥控器上的每一个功能按键具有预设唯一的 1~100 整数槽位编号(例如空调电源为 Slot 1,电视电源为 Slot 21)。
4. **简洁的符号化设计**:遥控器按键表面只展示标准图标与简写符号(⏻, +, -, ∧, ∨, ❄, 🔊 等),不展示底层数据库中文长名称。
5. **设置页面解耦与导航**:原“红外控制页面”定位为“红外学习设置页面”(Infrared Settings),通过遥控器右上角设置图标 ⚙️ 进入,用于配置红外设备名和管理全部已学按键列表。
---
## 2. 槽位与按键分配定义 (Slot & Button Mapping)
每个按键定义为一个 `RemoteButtonSpec`,包含按键唯一标识 `id`、对应默认槽位 `defaultSlot`、默认显示名称 `displayName`、图标及在遥控器面板中的图形位置。
### 2.1 空调遥控器 (Air Conditioner: Slots 1–20)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `AC_POWER` | 电源 ⏻ | `1` | `空调-电源` | 顶部圆形独立红色高亮按键 |
| `AC_TEMP_UP` | 温度+ 🔼 | `2` | `空调-温度+` | 垂直胶囊上半部分 |
| `AC_TEMP_DOWN` | 温度- 🔽 | `3` | `空调-温度-` | 垂直胶囊下半部分 |
| `AC_MODE` | 模式 ❄/☀ | `4` | `空调-模式` | 圆角矩形 |
| `AC_FAN` | 风速 💨 | `5` | `空调-风速` | 圆角矩形 |
| `AC_SWING` | 扫风 ↔️ | `6` | `空调-扫风` | 圆角矩形 |
| `AC_TIMER` | 定时 ⏱️ | `7` | `空调-定时` | 圆角矩形 |
### 2.2 电视遥控器 (Television: Slots 21–50)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `TV_POWER` | 电源 ⏻ | `21` | `电视-电源` | 顶部圆形红色按键 |
| `TV_MUTE` | 静音 🔇 | `22` | `电视-静音` | 顶部圆形灰色按键 |
| `TV_NAV_UP` | 方向上 🔼 | `23` | `电视-上` | 方向环形盘上弧段 |
| `TV_NAV_DOWN` | 方向下 🔽 | `24` | `电视-下` | 方向环形盘下弧段 |
| `TV_NAV_LEFT` | 方向左 ◀️ | `25` | `电视-左` | 方向环形盘左弧段 |
| `TV_NAV_RIGHT` | 方向右 ▶️ | `26` | `电视-右` | 方向环形盘右弧段 |
| `TV_OK` | 确认 🆗 | `27` | `电视-确定` | 方向盘中心圆形 |
| `TV_VOL_UP` | 音量+ 🔊+ | `28` | `电视-音量+` | 垂直胶囊上半部分 |
| `TV_VOL_DOWN` | 音量- 🔉- | `29` | `电视-音量-` | 垂直胶囊下半部分 |
| `TV_CH_UP` | 频道+ 📺+ | `30` | `电视-频道+` | 垂直胶囊上半部分 |
| `TV_CH_DOWN` | 频道- 📺- | `31` | `电视-频道-` | 垂直胶囊下半部分 |
| `TV_HOME` | 主页 🏠 | `32` | `电视-主页` | 中部圆角胶囊按键 |
| `TV_BACK` | 返回 ↩️ | `33` | `电视-返回` | 中部圆角胶囊按键 |
| `TV_MENU` | 菜单 ☰ | `34` | `电视-菜单` | 中部圆角胶囊按键 |
---
## 3. 架构与组件设计 (Architecture & Component Design)
### 3.1 导航结构
```text
FoundationScreen (首页/网关登录)
↓ 点击 "红外控制" (infrared/{profileId})
InfraredRemoteScreen (遥控器主界面,默认空调/电视)
├─ 学习模式开关 (Toggle: Learning Mode)
├─ 场景切换器 (Tab: ❄️ 空调 | 📺 电视)
├─ 空调遥控器面板 (AcRemotePanel)
├─ 电视遥控器面板 (TvRemotePanel)
└─ 点击右上角 ⚙️ 设置图标
↓ (infrared_settings/{profileId})
InfraredSettingsScreen (原红外设置/按键管理列表页面)
```
### 3.2 状态管理 (`InfraredRemoteViewModel` & `InfraredRemoteUiState`)
- `InfraredRemoteUiState`:
- `selectedScene`: `RemoteScene.AIR_CONDITIONER` 或 `RemoteScene.TELEVISION`
- `isLearningMode`: `Boolean`(是否处于学习录制模式)
- `activeLearningKeySlot`: `String?`(当前正在进行学习请求的按键槽位)
- `sendingKeySlot`: `String?`(当前正在发射信号的按键槽位)
- `learnedSlots`: `Map<String, SavedInfraredKey>`(已在 Room 数据库中记录为 READY 的槽位映射)
- `savedDeviceName`: `DeviceName?`(当前配置的红外设备名,如 `红外控制器`)
- `runtimeMode`: `InfraredRuntimeMode`(Fake 或 Real ABSDK)
- `message`: `UiMessage?`(提示信息或错误)
---
## 4. 交互与视觉规范 (Interaction & Visuals)
### 4.1 视觉造型
1. **遥控器本体外壳 (Remote Body Chassis)**:
- 居中放置的圆角卡片,具有微阴影、圆角半径 `28.dp`、外框及哑光质感。
2. **空调 LCD 虚拟屏**:
- 顶部黑色圆角框,内部呈现液晶感绿色/白色字样(如当前目标温度 `26°C` 与工作状态)。
3. **垂直双向胶囊摇杆 (Vertical Capsule Rockers)**:
- 上半区与下半区独立响应点击,中间由细微分割线隔开,常用于“温度 +/-”、“音量 +/-”及“频道 +/-”。
4. **圆形导航方向盘 (D-Pad Wheel)**:
- 直径约 `180.dp` 的圆形组合组件,外环包含 4 个扇区方向按键,内嵌独立的圆形 OK 键。
### 4.2 按键状态机反馈
1. **控制模式 (学习开关 OFF)**:
- **已录制 (READY)**:按键实体呈现饱满质感,点击触发波纹反馈,调用 `sendKey` 发射红外,提示“已发射”。
- **未录制 (UNLEARNED)**:按键显示浅色未激活状态;点击弹出轻量提示:`“按键尚未学习,请打开右上角学习模式”`。
2. **学习录制模式 (学习开关 ON)**:
- 顶部呈现橙色高亮提示条:`“🔴 学习模式已开启:点击任意按键开始录制”`。
- 所有按键进入可录制状态(带提示外框)。
- 点击目标按键后:
- 按键上显示微型 Loading 光圈。
- 调用 `learnKey(deviceName, defaultSlot, displayName)`。
- 手机或控制器捕获红外信号成功后,按键变为 READY 并伴随成功提示。
---
## 5. 验证与测试标准 (Verification & Acceptance Criteria)
1. **单元测试 (Unit Tests)**:
- `InfraredRemoteViewModelTest` 覆盖场景切换、学习模式开关、按键已学/未学状态映射、点击发射以及点击学习请求的转发。
2. **UI 测试 (Compose Tests)**:
- 空调与电视遥控器按键正常渲染并带正确 Tag。
- 学习模式切换后 UI 状态正确切换。
3. **真机与编译验收**:
- 模块与 App 通过 JVM 测试与打包验证。
- 手机上点击空调/电视按键能发起正确的 Slot 编号学习与发射。
@@ -0,0 +1,85 @@
# 红外控制硬件验证规范与记录 / Infrared Hardware Validation Guide & Records
## 1. 硬件环境与已知设备信息 / Hardware Environment
本阶段用于真实验证的实体设备信息如下:
- **设备名称**: 红外控制器 (例如 `红外控制器` 或厂商平台绑定的 `irDevName`)
- **IEEE 地址**: `1c1cfd001dba533c` (短地址: `d822`)
- **信号强度 (LQI/RSSI)**: `-4`
- **电量**: `100%`
- **设备类型**: 红外控制器 (Infrared Controller)
- **网关/主机**: ABox 智能主机
- **被控实体**: 实体空调 / 电视遥控器及对应电器
---
## 2. 硬件测试记录表 / Hardware Validation Log
| 日期 (Date) | App Commit | 运行模式 (Mode) | Profile ID | 设备名称 (Device Name) | SDK Key | 操作 (Operation) | 耗时 (ms) | ABSDK Code | 脱敏消息 (Message) | 物理响应 (Physical Response) | 结论 (Result) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Learn | 120 | 00000 | success | 模拟学习完成并入库 READY | PASS (Fake) |
| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Send | 45 | 00000 | success | 模拟发射成功 | PASS (Fake) |
| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Learn | - | - | - | 遥控器对准控制器按下目标按键 | 待真机执行 |
| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Send | - | - | - | 实体电器执行开关/调节响应 | 待真机执行 |
---
## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure
### 用例 1: 真实会话认证前置 (Authenticate Shared Bridge)
- **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。
- **步骤**: 检查进入红外控制页面前,会话 Token 是否有效。
- **预期**: 若未认证,进入 Real 模式操作时返回 `TokenInvalid` 并提示返回登录。
### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName)
- **步骤**: 在“红外设备名称”输入框中输入 `红外控制器` 或后台分配的真实设备名,点击“保存设备名”。
- **预期**: 本地 Room 保存成功,页面显示“已保存设备:红外控制器”,启用“学习新按键”入口。
### 用例 3: 学习全新唯一按键 (Learn Unique Key)
- **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。
- **预期**: 本地先写入 `LEARNING` 状态,硬件捕获完成后状态自动刷新为 `READY`,记录响应耗时与返回码。
### 用例 4: 立即发射已学习按键 (Send Learned Key Immediately)
- **步骤**: 学习成功后,点击该按键右侧的“发射”按钮。
- **预期**: ABSDK 返回 `00000`,实体电器执行对应操作,发射期间按钮防重复点击。
### 用例 5: 重复发射验证稳定性 (Repeat Send)
- **步骤**: 间隔 3 秒再次点击同一按键的“发射”按钮。
- **预期**: 发射稳定成功,电器再次响应,按键保持 `READY` 状态不变。
### 用例 6: 重新学习同一 SDK Key (Relearn Same Key)
- **步骤**: 针对已存在或失败的按键点击“重试”/重新学习。
- **预期**: 复用相同的 `InfraredKeyId` 和 `sdkKey`,先变更为 `LEARNING`,学习完成后恢复 `READY`,不生成冗余记录。
### 用例 7: 控制器断电/离线测试 (Controller Offline)
- **步骤**: 拔掉红外控制器电源,发起学习或发射。
- **预期**: ABSDK 返回 `20206`,领域层转换为 `DomainError.DeviceOffline`,学习按键标为 `FAILED`,UI 提示控制器离线。
### 用例 8: 遥控器无信号/超时测试 (No Signal / Learning Timeout)
- **步骤**: 点击“开始学习”,但不按物理遥控器任何按键,等待 SDK 返回。
- **预期**: 记录实际超时等待时间与错误码(如 `20204`/`20205`),按键转换为 `FAILED` 并允许重试。
### 用例 9: 学习过程中离开页面 (Leave Page During Learning)
- **步骤**: 点击开始学习后,立即点击“返回”离开红外页面;稍后重新进入。
- **预期**: Application 协程作用域内任务继续执行,学习完成后 Room 数据库正确持久化结果,重新进入显示最新状态。
### 用例 10: 进程被杀死后状态恢复 (Process Interruption Recovery)
- **步骤**: 在 `LEARNING` 状态下强制杀死 App 进程并重启。
- **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。
### 用例 11: Token 失效与重新登录 (Token Expiration)
- **步骤**: 主机端使 Token 失效或超过 2 小时后发起红外操作。
- **预期**: 返回 `TokenInvalid`,本地会话被清除,页面提示登录失效并导航返回。
### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields)
- **步骤**: 在调试构建中检查 `ABRet.getDicDatas()` 是否包含未文档化字段。
- **安全约束**: 严禁将 Raw Payload、Token、密码或未脱敏数据打印至生产日志。
---
## 4. 协议与硬件事实确认记录 / Observed Facts
- **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。
- **数据隔离**: `FAKE` 模式自动使用 `profileId:infrared-fake` 隔离数据,`REAL_ABSDK` 模式使用主会话数据。
- **安全门禁**: 真实 ABSDK 依赖明文传输,Release 构建中完全禁用硬件接口并由 `InfraredReleaseModule` 安全阻断。
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.domain.infrared
object AcCodeGenerator {
/**
* Generates a standard protocol Hex string for an Air Conditioner state.
* Uses Gree/Generic standard 64-bit frame:
* Byte 0: Mode (3 bits) | Power (1 bit) | Fan Speed (2 bits) | Swing (2 bits)
* Byte 1: Temperature (4 bits: temp - 16) | Timer (4 bits)
* Byte 2: Auxiliary options (Eco, Turbo, Health, Light)
* Byte 3: Checksum
*/
fun generateHex(state: AcStateSnapshot, brand: String? = null): String {
val modeBits = when (state.mode) {
AcMode.AUTO -> 0b000
AcMode.COOL -> 0b001
AcMode.DRY -> 0b010
AcMode.FAN -> 0b011
AcMode.HEAT -> 0b100
}
val powerBit = if (state.power) 0b1 else 0b0
val fanBits = when (state.fanSpeed) {
AcFanSpeed.AUTO -> 0b00
AcFanSpeed.LOW -> 0b01
AcFanSpeed.MEDIUM -> 0b10
AcFanSpeed.HIGH -> 0b11
}
val swingBits = (if (state.verticalSwing) 0b01 else 0b00) or (if (state.horizontalSwing) 0b10 else 0b00)
val byte0 = ((modeBits and 0x07) shl 5) or
((powerBit and 0x01) shl 4) or
((fanBits and 0x03) shl 2) or
(swingBits and 0x03)
val clampedTemp = state.temperature.coerceIn(16, 30)
val tempBits = (clampedTemp - 16) and 0x0F
val byte1 = (tempBits shl 4) and 0xF0
val ecoBit = if (state.eco) 0b1 else 0b0
val byte2 = (ecoBit and 0x01) shl 7
val checksum = (byte0 + byte1 + byte2) and 0xFF
return buildString {
append("0x")
append(String.format("%02X", byte0))
append(String.format("%02X", byte1))
append(String.format("%02X", byte2))
append(String.format("%02X", checksum))
}
}
}
@@ -0,0 +1,53 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class AddVirtualRemoteKey(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
displayName: String,
): DomainResult<RemoteKeyBinding> {
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button name cannot be blank"))
}
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
// Find next available slot
val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
nextSlot.toString()
} else null
val binding = RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = buttonId,
displayName = trimmed,
assignedSlot = assignedSlot,
rawHexCode = null,
status = InfraredKeyStatus.FAILED, // Initially unlearned
)
when (val res = repository.updateKeyBinding(binding)) {
is DomainResult.Failure -> return res
is DomainResult.Success -> Unit
}
return DomainResult.Success(binding)
}
}
@@ -0,0 +1,110 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class CreateVirtualRemote(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
type: RemoteDeviceType,
brandModel: String? = null,
transmissionMode: TransmissionMode = if (type == RemoteDeviceType.CUSTOM) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
layoutStyle: PanelLayoutStyle? = null,
): DomainResult<VirtualRemoteId> {
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote display name cannot be blank"),
)
}
val resolvedLayout = layoutStyle ?: VirtualRemote.defaultLayoutFor(type)
val resolvedTransMode = if (type == RemoteDeviceType.CUSTOM) {
TransmissionMode.STATIC_SLOTS
} else {
transmissionMode
}
val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel, resolvedLayout)
val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16))
val now = clock.now()
val initialAcState = if (type == RemoteDeviceType.AIR_CONDITIONER) AcStateSnapshot() else null
val remote = VirtualRemote(
id = remoteId,
profileId = profileId,
deviceName = deviceName,
displayName = trimmedName,
type = type,
brandModel = brandModel,
layoutStyle = resolvedLayout,
transmissionMode = resolvedTransMode,
dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT,
acState = initialAcState,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
)
// Build key bindings
val bindings = if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) {
templates.mapIndexed { index, template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = (index + 1).toString(),
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
)
}
} else {
templates.map { template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = null,
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
InfraredKeyStatus.READY
} else {
InfraredKeyStatus.FAILED
},
)
}
}
// If static and has default hex codes, download to hardware slots
if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) {
val codesToDownload = bindings.mapNotNull { b ->
if (b.assignedSlot != null && b.rawHexCode != null) {
InfraredCode(deviceName, b.assignedSlot, b.rawHexCode)
} else null
}
if (codesToDownload.isNotEmpty()) {
when (val dlResult = gateway.downloadCodes(profileId, codesToDownload)) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
}
}
return repository.createRemote(remote, bindings)
}
}
@@ -0,0 +1,13 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
class DeleteVirtualRemote(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> = repository.deleteRemote(profileId, remoteId)
}
@@ -0,0 +1,14 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
class DeleteVirtualRemoteKey(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> = repository.deleteKeyBinding(remoteId, buttonId)
}
@@ -0,0 +1,86 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
fun interface InfraredKeyIdGenerator {
fun nextId(): InfraredKeyId
}
fun interface EpochMillisClock {
fun now(): Long
}
class InfraredKeyFactory(
private val idGenerator: InfraredKeyIdGenerator,
private val clock: EpochMillisClock,
) {
fun create(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim()
if (
profileId.value.isBlank() ||
profileId.value != profileId.value.trim() ||
deviceName.value.isBlank() ||
deviceName.value != deviceName.value.trim() ||
normalizedName.isBlank()
) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
)
}
val id = idGenerator.nextId()
val canonicalId = canonicalizeId(id.value)
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
)
val finalSdkKey = if (sdkKey != null) {
val trimmed = sdkKey.trim()
val intVal = trimmed.toIntOrNull()
if (intVal == null || intVal !in 1..100) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
)
}
intVal.toString()
} else {
"app_ir_$canonicalId"
}
val now = clock.now()
return DomainResult.Success(
SavedInfraredKey(
id = id,
profileId = profileId,
deviceName = deviceName,
displayName = normalizedName,
sdkKey = finalSdkKey,
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
),
)
}
private fun canonicalizeId(value: String): String? = when {
ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase()
STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase()
else -> null
}
private companion object {
val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$")
val STANDARD_UUID = Regex(
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
)
}
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
@JvmInline
value class InfraredKeyId(val value: String)
data class InfraredKeyRef(
val profileId: ProviderProfileId,
val keyId: InfraredKeyId,
)
enum class InfraredKeyStatus {
LEARNING,
READY,
FAILED,
}
const val INTERRUPTED_LEARNING_ERROR_DETAIL =
"Interrupted learning result is unknown; retry required"
/**
* Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned,
* sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there.
*/
data class SavedInfraredKey(
val id: InfraredKeyId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val sdkKey: String,
val status: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@@ -0,0 +1,53 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
interface InfraredKeyRepository {
fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>>
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
/**
* Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis]
* as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
*/
suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long = Long.MAX_VALUE,
updatedAtEpochMillis: Long = cutoffEpochMillis,
): DomainResult<Int>
}
interface InfraredDeviceConfigurationRepository {
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit>
}
@@ -0,0 +1,246 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
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,
sdkKey: String? = null,
): DomainResult<InfraredKeyId> {
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
when (val result = repository.insert(key)) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> when (
val transition = repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> DomainResult.Success(key.id)
is DomainResult.Failure -> transition
}
is DomainResult.Failure -> {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RetryInfraredKeyLearning(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
)
}
when (
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.FAILED,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RelearnInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
when (
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED,
InfraredKeyStatus.LEARNING,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
private fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,70 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class LearnVirtualRemoteKey(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> return res
}
val binding = bindings.find { it.buttonId == buttonId }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found"))
val targetSlot = binding.assignedSlot ?: run {
val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1
nextSlot.toString()
}
// Set status to LEARNING while hardware listens
repository.updateKeyBinding(
binding.copy(
status = InfraredKeyStatus.LEARNING,
assignedSlot = targetSlot,
lastErrorCode = null,
lastErrorDetail = null,
),
)
return when (val learnResult = gateway.learnKey(profileId, remote.deviceName, targetSlot)) {
is DomainResult.Success -> {
repository.updateKeyBinding(
binding.copy(
status = InfraredKeyStatus.READY,
assignedSlot = targetSlot,
lastErrorCode = null,
lastErrorDetail = null,
),
)
DomainResult.Success(Unit)
}
is DomainResult.Failure -> {
repository.updateKeyBinding(
binding.copy(
status = InfraredKeyStatus.FAILED,
assignedSlot = targetSlot,
lastErrorCode = learnResult.error.externalCode,
lastErrorDetail = learnResult.error.detail,
),
)
learnResult
}
}
}
}
@@ -0,0 +1,65 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class SendInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.NoData(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
)
}
return gateway.sendKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
}
}
class RenameInfraredKey(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
ref: InfraredKeyRef,
displayName: String,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isEmpty()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
)
}
return repository.rename(ref, trimmed, clock.now())
}
}
class DeleteInfraredKey(
private val repository: InfraredKeyRepository,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
repository.deleteIfNotLearning(ref)
}
class RecoverInterruptedInfraredLearning(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
repository.failInterruptedLearning(profileId, clock.now())
}
@@ -0,0 +1,101 @@
package com.flagship.abox.manager.domain.infrared
data class PresetBrandOption(
val id: String,
val displayName: String,
val deviceType: RemoteDeviceType,
)
object PresetCodeLibrary {
val availablePresets: List<PresetBrandOption> = listOf(
// 电视预设
PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV),
PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV),
PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV),
PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV),
// 空调预设
PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER),
// 风扇预设
PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN),
PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN),
// 自定义
PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM),
)
data class PresetButtonTemplate(
val buttonId: String,
val displayName: String,
val defaultHexCode: String? = null,
)
fun getTemplatesForType(
type: RemoteDeviceType,
presetId: String? = null,
layoutStyle: PanelLayoutStyle? = null,
): List<PresetButtonTemplate> = when (type) {
RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true)
RemoteDeviceType.AIR_CONDITIONER -> listOf(
PresetButtonTemplate("AC_POWER", "电源"),
PresetButtonTemplate("AC_TEMP_UP", "升温"),
PresetButtonTemplate("AC_TEMP_DOWN", "降温"),
PresetButtonTemplate("AC_MODE", "模式"),
PresetButtonTemplate("AC_FAN", "风速"),
PresetButtonTemplate("AC_SWING_V", "上下扫风"),
PresetButtonTemplate("AC_SWING_H", "左右扫风"),
PresetButtonTemplate("AC_ECO", "节能"),
)
RemoteDeviceType.FAN -> listOf(
PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"),
PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"),
PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"),
PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"),
PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"),
)
RemoteDeviceType.CUSTOM -> when (layoutStyle) {
PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false)
PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false)
PanelLayoutStyle.AC_COMPACT -> emptyList()
PanelLayoutStyle.GRID, null -> listOf(
PresetButtonTemplate("CUSTOM_POWER", "电源"),
PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"),
PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"),
PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"),
)
}
}
private fun getTvTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
PresetButtonTemplate("TV_POWER", "电源", if (hasHexCode) "0x00FF02FD" else null),
PresetButtonTemplate("TV_MUTE", "静音", if (hasHexCode) "0x00FF04FB" else null),
PresetButtonTemplate("TV_SOURCE", "信号源", if (hasHexCode) "0x00FF06F9" else null),
PresetButtonTemplate("TV_NAV_UP", "上", if (hasHexCode) "0x00FF08F7" else null),
PresetButtonTemplate("TV_NAV_DOWN", "下", if (hasHexCode) "0x00FF0AF5" else null),
PresetButtonTemplate("TV_NAV_LEFT", "左", if (hasHexCode) "0x00FF0CF3" else null),
PresetButtonTemplate("TV_NAV_RIGHT", "右", if (hasHexCode) "0x00FF0EF1" else null),
PresetButtonTemplate("TV_OK", "确定", if (hasHexCode) "0x00FF10EF" else null),
PresetButtonTemplate("TV_HOME", "主页", if (hasHexCode) "0x00FF12ED" else null),
PresetButtonTemplate("TV_BACK", "返回", if (hasHexCode) "0x00FF14EB" else null),
PresetButtonTemplate("TV_MENU", "菜单", if (hasHexCode) "0x00FF16E9" else null),
PresetButtonTemplate("TV_VOL_UP", "音量 +", if (hasHexCode) "0x00FF18E7" else null),
PresetButtonTemplate("TV_VOL_DOWN", "音量 -", if (hasHexCode) "0x00FF1AE5" else null),
PresetButtonTemplate("TV_CH_UP", "频道 +", if (hasHexCode) "0x00FF1CE3" else null),
PresetButtonTemplate("TV_CH_DOWN", "频道 -", if (hasHexCode) "0x00FF1EE1" else null),
)
private fun getMediaPlayerTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
PresetButtonTemplate("MEDIA_POWER", "电源", if (hasHexCode) "0x02FD02FD" else null),
PresetButtonTemplate("MEDIA_MUTE", "静音", if (hasHexCode) "0x02FD04FB" else null),
PresetButtonTemplate("MEDIA_PLAY_PAUSE", "播放/暂停", if (hasHexCode) "0x02FD06F9" else null),
PresetButtonTemplate("MEDIA_PREV", "上一曲", if (hasHexCode) "0x02FD08F7" else null),
PresetButtonTemplate("MEDIA_NEXT", "下一曲", if (hasHexCode) "0x02FD0AF5" else null),
PresetButtonTemplate("MEDIA_REWIND", "快退", if (hasHexCode) "0x02FD0CF3" else null),
PresetButtonTemplate("MEDIA_FF", "快进", if (hasHexCode) "0x02FD0EF1" else null),
)
}
@@ -0,0 +1,90 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class SendVirtualRemoteAction(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
) {
suspend fun sendButton(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> return res
}
val binding = bindings.find { it.buttonId == buttonId }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button binding not found"))
if (binding.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button is not ready to send"))
}
return when (remote.transmissionMode) {
TransmissionMode.STATIC_SLOTS -> {
val slot = binding.assignedSlot
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No slot assigned for static mode"))
gateway.sendKey(profileId, remote.deviceName, slot)
}
TransmissionMode.DYNAMIC_SCRATCH -> {
if (binding.rawHexCode != null) {
// 1. Download to scratch slot
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, binding.rawHexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
// 2. Send scratch slot
gateway.sendKey(profileId, remote.deviceName, scratchSlot)
} else if (binding.assignedSlot != null) {
// Fallback to sending learned hardware slot
gateway.sendKey(profileId, remote.deviceName, binding.assignedSlot)
} else {
DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code or slot available for transmission"))
}
}
}
}
suspend fun sendAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
if (remote.type != RemoteDeviceType.AIR_CONDITIONER) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Target is not an air conditioner"))
}
// 1. Update state snapshot in repository
when (val updateRes = repository.updateAcState(profileId, remoteId, state)) {
is DomainResult.Failure -> return updateRes
is DomainResult.Success -> Unit
}
// 2. Generate full state hex frame
val hexCode = AcCodeGenerator.generateHex(state, remote.brandModel)
// 3. Write to scratch slot and transmit
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
return gateway.sendKey(profileId, remote.deviceName, scratchSlot)
}
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
class UpdateVirtualRemoteLayout(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
layoutStyle: PanelLayoutStyle,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val updated = remote.copy(
layoutStyle = layoutStyle,
updatedAtEpochMillis = System.currentTimeMillis(),
)
return repository.updateRemote(updated)
}
}
@@ -0,0 +1,108 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
@JvmInline
value class VirtualRemoteId(val value: String)
enum class RemoteDeviceType {
TV,
AIR_CONDITIONER,
FAN,
CUSTOM,
}
enum class TransmissionMode {
STATIC_SLOTS,
DYNAMIC_SCRATCH,
}
enum class PanelLayoutStyle {
TV_NAV,
GRID,
MEDIA_PLAYER,
AC_COMPACT,
}
enum class AcMode {
COOL,
HEAT,
AUTO,
DRY,
FAN,
}
enum class AcFanSpeed {
AUTO,
LOW,
MEDIUM,
HIGH,
}
data class AcStateSnapshot(
val power: Boolean = true,
val mode: AcMode = AcMode.COOL,
val temperature: Int = 26,
val fanSpeed: AcFanSpeed = AcFanSpeed.AUTO,
val verticalSwing: Boolean = true,
val horizontalSwing: Boolean = false,
val eco: Boolean = false,
)
data class VirtualRemote(
val id: VirtualRemoteId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val type: RemoteDeviceType,
val brandModel: String? = null,
val layoutStyle: PanelLayoutStyle = defaultLayoutFor(type),
val transmissionMode: TransmissionMode,
val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT,
val acState: AcStateSnapshot? = null,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
) {
companion object {
const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100"
fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) {
RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV
RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT
RemoteDeviceType.FAN -> PanelLayoutStyle.GRID
RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID
}
}
}
data class RemoteKeyBinding(
val id: String,
val remoteId: VirtualRemoteId,
val profileId: ProviderProfileId,
val buttonId: String,
val displayName: String,
val assignedSlot: String? = null,
val rawHexCode: String? = null,
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
val lastErrorCode: String? = null,
val lastErrorDetail: String? = null,
)
data class SlotQuotaSummary(
val totalCapacity: Int = 100,
val dynamicScratchSlot: Int = 100,
val assignedStaticSlots: Set<Int> = emptySet(),
) {
val availableStaticSlots: List<Int>
get() = (1 until dynamicScratchSlot).filter { it !in assignedStaticSlots }
val usedCount: Int
get() = assignedStaticSlots.size + 1 // assigned static slots + 1 dynamic scratch slot
val remainingStaticCount: Int
get() = availableStaticSlots.size
val isStaticFull: Boolean
get() = availableStaticSlots.isEmpty()
}
@@ -0,0 +1,58 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
interface VirtualRemoteRepository {
fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>>
fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>>
fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>>
fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>>
suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?>
suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>>
suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId>
suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit>
suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit>
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit>
}
@@ -0,0 +1,345 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
data class InfraredLearningCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredStatusTransitionCall(
val ref: InfraredKeyRef,
val expectedStatuses: Set<InfraredKeyStatus>,
val targetStatus: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val updatedAtEpochMillis: Long,
)
data class InfraredSendCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredRenameCall(
val ref: InfraredKeyRef,
val displayName: String,
val updatedAtEpochMillis: Long,
)
data class InfraredRecoveryCall(
val profileId: ProviderProfileId,
val updatedAtEpochMillis: Long,
)
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
var getError: DomainError? = null
var insertError: DomainError? = null
var renameError: DomainError? = null
var deleteError: DomainError? = null
var recoveryError: DomainError? = null
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
var onInsert: ((SavedInfraredKey) -> Unit)? = null
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
val insertedKeys = mutableListOf<SavedInfraredKey>()
val getCalls = mutableListOf<InfraredKeyRef>()
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
val renameCalls = mutableListOf<InfraredRenameCall>()
val deleteCalls = mutableListOf<InfraredKeyRef>()
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
DomainResult.Success(
values.filter { it.profileId == profileId && it.deviceName == deviceName },
)
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
getCalls += ref
getError?.let { return DomainResult.Failure(it) }
return DomainResult.Success(keys[ref])
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
insertedKeys += key
onInsert?.invoke(key)
insertError?.let { return DomainResult.Failure(it) }
keys[key.ref()] = key
publish()
return DomainResult.Success(Unit)
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val call = InfraredStatusTransitionCall(
ref = ref,
expectedStatuses = expectedStatuses,
targetStatus = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
transitionCalls += call
onTransition?.invoke(call)
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status !in expectedStatuses) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key status changed"),
)
}
keys[ref] = existing.copy(
status = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
renameError?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
keys[ref] = existing.copy(
displayName = displayName,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
deleteCalls += ref
deleteError?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
)
}
keys.remove(ref)
publish()
return DomainResult.Success(Unit)
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
recoveryError?.let { return DomainResult.Failure(it) }
var changed = 0
keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
changed += 1
key.copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} else {
key
}
}
publish()
return DomainResult.Success(changed)
}
fun seed(key: SavedInfraredKey) {
keys[key.ref()] = key
publish()
}
fun onlyKey(): SavedInfraredKey = keys.values.single()
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
private fun publish() {
state.value = keys.values.toList()
}
}
class RecordingInfraredGateway : InfraredGateway {
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
val learnedKeys = mutableListOf<InfraredLearningCall>()
val sentKeys = mutableListOf<InfraredSendCall>()
val downloadedCodes = mutableListOf<InfraredCode>()
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
sentKeys += InfraredSendCall(profileId, deviceName, key)
return sendResult
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
downloadedCodes += codes
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
private val remotes = mutableMapOf<VirtualRemoteId, VirtualRemote>()
private val bindings = mutableMapOf<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
private val state = MutableStateFlow<List<VirtualRemote>>(emptyList())
override fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>> = state.map { values ->
DomainResult.Success(values.filter { it.profileId == profileId && it.deviceName == deviceName })
}
override fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>> = state.map { values ->
DomainResult.Success(values.find { it.id == remoteId && it.profileId == profileId })
}
override fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>> = state.map {
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
}
override fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>> = state.map { values ->
val relevant = values.filter { it.profileId == profileId && it.deviceName == deviceName }
val used = relevant.flatMap { bindings[it.id].orEmpty() }
.mapNotNull { it.assignedSlot?.toIntOrNull() }
.toSet()
DomainResult.Success(SlotQuotaSummary(assignedStaticSlots = used))
}
override suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?> = DomainResult.Success(remotes[remoteId]?.takeIf { it.profileId == profileId })
override suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>> = DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
override suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId> {
remotes[remote.id] = remote
bindings[remote.id] = initialBindings.toMutableList()
publish()
return DomainResult.Success(remote.id)
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
remotes[remote.id] = remote
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> {
remotes.remove(remoteId)
bindings.remove(remoteId)
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
val list = bindings[binding.remoteId] ?: mutableListOf()
val idx = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
if (idx >= 0) list[idx] = binding else list.add(binding)
bindings[binding.remoteId] = list
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
list.removeAll { it.buttonId == buttonId }
bindings[remoteId] = list
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
remotes[remoteId] = existing.copy(acState = state)
publish()
return DomainResult.Success(Unit)
}
private fun publish() {
state.value = remotes.values.toList()
}
}
fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,89 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class InfraredKeyFactoryTest {
private val id = InfraredKeyId("01J5MOLERABC123")
private val factory = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { id },
clock = EpochMillisClock { 1_724_435_200_000L },
)
@Test
fun createsLearningKeyWithStableAsciiSdkKey() {
val result = factory.create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"空调开关",
) as DomainResult.Success
assertEquals(id, result.value.id)
assertEquals("app_ir_01j5molerabc123", result.value.sdkKey)
assertEquals(InfraredKeyStatus.LEARNING, result.value.status)
assertEquals(null, result.value.lastErrorCode)
assertEquals(null, result.value.lastErrorDetail)
assertEquals("空调开关", result.value.displayName)
assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis)
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
}
@Test
fun createsLearningKeyWithCustomNumericSlot() {
val result = factory.create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"空调开",
sdkKey = "1",
) as DomainResult.Success
assertEquals("1", result.value.sdkKey)
}
@Test
fun rejectsInvalidNumericSlots() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure)
}
@Test
fun rejectsBlankDeviceOrDisplayName() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure)
}
@Test
fun rejectsIdentifiersWithSurroundingWhitespace() {
assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure)
}
@Test
fun rejectsLossyOrOversizedIds() {
assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure)
assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure)
}
@Test
fun canonicalizesStandardUuidId() {
val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success
assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey)
}
private fun factoryWithId(value: String) = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) },
clock = EpochMillisClock { 1_724_435_200_000L },
)
private fun InfraredKeyFactory.createValidKey() = create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"Power",
)
}
@@ -0,0 +1,343 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class LearnInfraredKeyTest {
private val profile = ProviderProfileId("abox")
private val device = DeviceName("ir-1")
private val keyId = InfraredKeyId("key1")
private val clock = SteppingClock(100L)
private val factory = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { keyId },
clock = clock,
)
private val repository = InMemoryInfraredKeyRepository()
private val gateway = RecordingInfraredGateway()
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
val events = mutableListOf<String>()
repository.onInsert = { events += "insert:${it.status}" }
gateway.onLearn = { _, _, _ ->
events += "hardware"
DomainResult.Success(Unit)
}
repository.onTransition = { events += "transition:${it.targetStatus}" }
val result = learn(profile, device, "Power")
assertEquals(DomainResult.Success(keyId), result)
assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
}
@Test
fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest {
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
val result = learn(profile, device, "Power")
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("20206", repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
}
@Test
fun insertFailureNeverCallsHardware() = runTest {
val insertError = DomainError.Unknown(detail = "database unavailable")
repository.insertError = insertError
val result = learn(profile, device, "Power")
assertEquals(insertError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest {
val result = learn(profile, device, " ")
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(repository.insertedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
assertTrue(gateway.learnedKeys.isEmpty())
}
@Test
fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
val result = learn(profile, device, "Power")
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(1, gateway.learnedKeys.size)
}
@Test
fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
repository.transitionErrors[InfraredKeyStatus.FAILED] =
DomainError.Unknown(detail = "write conflict")
val result = learn(profile, device, "Power")
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals("20206", repository.transitionCalls.single().lastErrorCode)
assertEquals(null, repository.transitionCalls.single().lastErrorDetail)
}
@Test
fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest {
learn(profile, device, "Power")
val inserted = repository.insertedKeys.single()
val transition = repository.transitionCalls.single()
assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses)
assertEquals(inserted.ref(), transition.ref)
assertEquals(100L, inserted.createdAtEpochMillis)
assertEquals(100L, inserted.updatedAtEpochMillis)
assertEquals(101L, transition.updatedAtEpochMillis)
}
@Test
fun retryReusesStableIdAndSdkKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1")
repository.seed(original)
val result = retry(original.ref())
assertEquals(DomainResult.Success(Unit), result)
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size)
assertEquals(
setOf(InfraredKeyStatus.FAILED),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy(
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
)
repository.seed(original)
val result = relearn(original.ref())
assertEquals(DomainResult.Success(Unit), result)
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size)
assertEquals(
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun retryRejectsAlreadyLearningKey() = runTest {
repository.seed(learningKey())
val result = retry(repository.onlyKey().ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryRejectsMissingKey() = runTest {
val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing")))
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryReturnsGetFailureWithoutCallingHardware() = runTest {
val getError = DomainError.Unknown(detail = "database unavailable")
repository.getError = getError
val result = retry(InfraredKeyRef(profile, keyId))
assertEquals(getError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest {
val original = failedKey()
repository.seed(original)
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError
val result = retry(original.ref())
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
}
@Test
fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest {
val original = failedKey()
repository.seed(original)
repository.onTransition = { call ->
if (call.targetStatus == InfraredKeyStatus.LEARNING) {
repository.seed(original.copy(status = InfraredKeyStatus.LEARNING))
}
}
val result = retry(original.ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
}
@Test
fun retryUsesSeparateTimestampsAndClearsErrorsBeforeHardware() = runTest {
val original = failedKey()
repository.seed(original)
gateway.onLearn = { _, _, _ ->
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
assertEquals(100L, repository.onlyKey().updatedAtEpochMillis)
DomainResult.Success(Unit)
}
assertEquals(DomainResult.Success(Unit), retry(original.ref()))
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
assertEquals(101L, repository.onlyKey().updatedAtEpochMillis)
assertEquals(listOf(100L, 101L), repository.transitionCalls.map { it.updatedAtEpochMillis })
}
@Test
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
val original = failedKey()
repository.seed(original)
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
val result = retry(original.ref())
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("20206", repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
}
@Test
fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
val original = failedKey()
repository.seed(original)
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
repository.transitionErrors[InfraredKeyStatus.FAILED] =
DomainError.Unknown(detail = "write conflict")
val result = retry(original.ref())
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals("20206", repository.transitionCalls.last().lastErrorCode)
assertEquals(null, repository.transitionCalls.last().lastErrorDetail)
}
@Test
fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
val original = failedKey()
repository.seed(original)
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
val result = retry(original.ref())
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(1, gateway.learnedKeys.size)
}
@Test
fun learningCancellationTransitionsStatusToFailed() = runTest {
gateway.onLearn = { _, _, _ ->
throw kotlinx.coroutines.CancellationException("User cancelled")
}
try {
learn(profile, device, "Power")
} catch (_: kotlinx.coroutines.CancellationException) {
// expected
}
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("Cancelled", repository.onlyKey().lastErrorDetail)
}
private fun failedKey(
id: InfraredKeyId = keyId,
sdkKey: String = "app_ir_key1",
) = SavedInfraredKey(
id = id,
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = sdkKey,
status = InfraredKeyStatus.FAILED,
lastErrorCode = "old-error",
lastErrorDetail = "old-detail",
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 2L,
)
private fun learningKey() = failedKey().copy(
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
)
}
private class SteppingClock(start: Long) : EpochMillisClock {
private var next = start
override fun now(): Long = next++
}
@@ -0,0 +1,251 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ManageInfraredKeyTest {
private val profile = ProviderProfileId("abox")
private val otherProfile = ProviderProfileId("other")
private val repository = InMemoryInfraredKeyRepository()
private val gateway = RecordingInfraredGateway()
private val clock = SteppingManagementClock(500L)
private val send = SendInfraredKey(repository, gateway)
private val rename = RenameInfraredKey(repository, clock)
private val delete = DeleteInfraredKey(repository)
private val recover = RecoverInterruptedInfraredLearning(repository, clock)
@Test
fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest {
val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")
repository.seed(key)
assertEquals(DomainResult.Success(Unit), send(key.ref()))
assertEquals(
InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"),
gateway.sentKeys.single(),
)
assertEquals(listOf(key.ref()), repository.getCalls)
assertTrue(repository.transitionCalls.isEmpty())
assertTrue(repository.renameCalls.isEmpty())
assertTrue(repository.deleteCalls.isEmpty())
assertTrue(repository.recoveryCalls.isEmpty())
assertEquals(key, repository.onlyKey())
}
@Test
fun sendFailureDoesNotDemoteReadyKey() = runTest {
val key = readyKey()
repository.seed(key)
val gatewayError = DomainError.OperationFailed("20205")
gateway.sendResult = DomainResult.Failure(gatewayError)
assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error)
assertEquals(
listOf(InfraredSendCall(profile, DeviceName("ir-1"), "app_ir_key1")),
gateway.sentKeys,
)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun missingSendReturnsNoDataWithoutHardwareCall() = runTest {
val result = send(InfraredKeyRef(profile, InfraredKeyId("missing")))
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
assertEquals(
listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))),
repository.getCalls,
)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest {
repository.seed(failedKey())
val result = send(repository.onlyKey().ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest {
val repositoryError = DomainError.Unknown(detail = "database unavailable")
repository.getError = repositoryError
val result = send(InfraredKeyRef(profile, InfraredKeyId("key1")))
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun renameTrimsDisplayNameAndPreservesStableIdentityAndErrors() = runTest {
val before = readyKey(displayName = "Power", sdkKey = "app_ir_power").copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "fixed-detail",
)
repository.seed(before)
assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 "))
val after = repository.onlyKey()
assertEquals("空调开关", after.displayName)
assertEquals(before.sdkKey, after.sdkKey)
assertEquals(before.id, after.id)
assertEquals(before.lastErrorCode, after.lastErrorCode)
assertEquals(before.lastErrorDetail, after.lastErrorDetail)
assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis)
assertEquals(500L, after.updatedAtEpochMillis)
assertEquals(
InfraredRenameCall(before.ref(), "空调开关", 500L),
repository.renameCalls.single(),
)
}
@Test
fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest {
val key = readyKey()
repository.seed(key)
val result = rename(key.ref(), " \t ")
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(repository.renameCalls.isEmpty())
assertEquals(key, repository.onlyKey())
}
@Test
fun renameReturnsRepositoryFailure() = runTest {
val key = readyKey()
repository.seed(key)
val repositoryError = DomainError.Unknown(detail = "write failed")
repository.renameError = repositoryError
val result = rename(key.ref(), "New name")
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertEquals("Power", repository.onlyKey().displayName)
}
@Test
fun deleteDelegatesAtomicLearningGuardToRepository() = runTest {
val key = learningKey()
repository.seed(key)
val result = delete(key.ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(listOf(key.ref()), repository.deleteCalls)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.allKeys().isNotEmpty())
}
@Test
fun deleteRemovesReadyKey() = runTest {
val key = readyKey()
repository.seed(key)
assertEquals(DomainResult.Success(Unit), delete(key.ref()))
assertEquals(listOf(key.ref()), repository.deleteCalls)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.allKeys().isEmpty())
}
@Test
fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest {
val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old")
val learningTwo = learningKey(id = InfraredKeyId("two"))
val ready = readyKey(id = InfraredKeyId("ready"))
val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile)
repository.seed(learningOne)
repository.seed(learningTwo)
repository.seed(ready)
repository.seed(other)
assertEquals(DomainResult.Success(2), recover(profile))
assertEquals(
listOf(InfraredRecoveryCall(profile, 500L)),
repository.recoveryCalls,
)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") }
assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED })
assertTrue(recovered.all { it.lastErrorCode == null })
assertTrue(
recovered.all {
it.lastErrorDetail == "Interrupted learning result is unknown; retry required"
},
)
assertTrue(recovered.all { it.updatedAtEpochMillis == 500L })
assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status)
assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest {
val repositoryError = DomainError.Unknown(detail = "write failed")
repository.recoveryError = repositoryError
val result = recover(profile)
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(gateway.sentKeys.isEmpty())
}
private fun readyKey(
id: InfraredKeyId = InfraredKeyId("key1"),
profileId: ProviderProfileId = profile,
deviceName: DeviceName = DeviceName("ir-1"),
displayName: String = "Power",
sdkKey: String = "app_ir_key1",
) = SavedInfraredKey(
id = id,
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
sdkKey = sdkKey,
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 2L,
)
private fun failedKey() = readyKey().copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = "old-error",
lastErrorDetail = "old-detail",
)
private fun learningKey(
id: InfraredKeyId = InfraredKeyId("key1"),
profileId: ProviderProfileId = profile,
lastErrorCode: String? = null,
lastErrorDetail: String? = null,
) = readyKey(id = id, profileId = profileId).copy(
status = InfraredKeyStatus.LEARNING,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
)
}
private class SteppingManagementClock(start: Long) : EpochMillisClock {
private var next = start
override fun now(): Long = next++
}
@@ -0,0 +1,119 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class VirtualRemoteUseCaseTest {
private val profileId = ProviderProfileId("profile1")
private val deviceName = DeviceName("ir-1")
private val clock = EpochMillisClock { 10_000L }
private lateinit var repository: InMemoryVirtualRemoteRepository
private lateinit var gateway: RecordingInfraredGateway
private lateinit var createRemote: CreateVirtualRemote
private lateinit var sendAction: SendVirtualRemoteAction
private lateinit var deleteRemote: DeleteVirtualRemote
@Before
fun setup() {
repository = InMemoryVirtualRemoteRepository()
gateway = RecordingInfraredGateway()
createRemote = CreateVirtualRemote(repository, gateway, clock)
sendAction = SendVirtualRemoteAction(repository, gateway)
deleteRemote = DeleteVirtualRemote(repository)
}
@Test
fun createVirtualRemoteWithStaticSlotsDownloadsPresetCodesToHardware() = runTest {
val result = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Living Room TV",
type = RemoteDeviceType.TV,
brandModel = "generic_tv",
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
assertTrue(result is DomainResult.Success)
val remoteId = (result as DomainResult.Success).value
// Check remote created in repository
val remote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertNotNull(remote)
assertEquals("Living Room TV", remote?.displayName)
assertEquals(TransmissionMode.STATIC_SLOTS, remote?.transmissionMode)
// Check preset codes downloaded to hardware gateway
assertTrue(gateway.downloadedCodes.isNotEmpty())
// Send a button in static mode -> directly triggers sendKey with assigned slot
val sendRes = sendAction.sendButton(profileId, remoteId, "TV_POWER")
assertTrue(sendRes is DomainResult.Success)
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
}
@Test
fun sendVirtualRemoteActionForAcUpdatesStateAndDownloadsToScratchSlot() = runTest {
val createResult = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Bedroom AC",
type = RemoteDeviceType.AIR_CONDITIONER,
brandModel = "gree_ac",
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
val newAcState = AcStateSnapshot(
power = true,
mode = AcMode.COOL,
temperature = 27,
fanSpeed = AcFanSpeed.HIGH,
verticalSwing = true,
)
val sendRes = sendAction.sendAcState(profileId, remoteId, newAcState)
assertTrue(sendRes is DomainResult.Success)
// 1. Downloaded to scratch slot 100
assertEquals(1, gateway.downloadedCodes.size)
val downloaded = gateway.downloadedCodes.first()
assertEquals("100", downloaded.key)
assertTrue(downloaded.code.startsWith("0x"))
// 2. Transmitted via sendKey with slot 100
assertEquals(1, gateway.sentKeys.size)
assertEquals("100", gateway.sentKeys.first().key)
// 3. State machine updated in repository
val updatedRemote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(27, updatedRemote?.acState?.temperature)
assertEquals(AcFanSpeed.HIGH, updatedRemote?.acState?.fanSpeed)
}
@Test
fun deleteVirtualRemoteRemovesFromRepository() = runTest {
val createResult = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Fan",
type = RemoteDeviceType.FAN,
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
deleteRemote(profileId, remoteId)
val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(null, fetched)
}
}
+1
View File
@@ -3,3 +3,4 @@ org.gradle.configuration-cache=false
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
android.overridePathCheck=true
+2
View File
@@ -35,6 +35,8 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
+1
View File
@@ -28,4 +28,5 @@ include(":absdk-bridge")
include(":testing:fakes")
include(":android:core:designsystem")
include(":android:core:session")
include(":android:feature:infrared")
include(":android:app")
@@ -12,8 +12,8 @@ import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
@@ -78,10 +78,11 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
}
class FakeInfraredGateway : InfraredGateway {
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val downloadCalls: MutableList<InfraredDownloadCall> =
Collections.synchronizedList(mutableListOf())
val sentKeys = CopyOnWriteArrayList<InfraredCall>()
val learnedKeys = CopyOnWriteArrayList<InfraredCall>()
val downloadCalls = CopyOnWriteArrayList<InfraredDownloadCall>()
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
@Volatile
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
@@ -98,7 +99,7 @@ class FakeInfraredGateway : InfraredGateway {
key: String,
): DomainResult<Unit> {
sentKeys += InfraredCall(profileId, deviceName, key)
return sendResult
return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult
}
override suspend fun learnKey(
@@ -107,16 +108,40 @@ class FakeInfraredGateway : InfraredGateway {
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredCall(profileId, deviceName, key)
return learnResult
return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult
}
fun setSendResult(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
result: DomainResult<Unit>,
) {
sendResults[InfraredOperationKey(profileId, deviceName, key)] = result
}
fun setLearnResult(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
result: DomainResult<Unit>,
) {
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
}
val downloadedCodes: List<InfraredCode>
get() = downloadCalls.flatMap { it.codes }
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
val codesSnapshot = codes.toList()
downloadError?.let { return DomainResult.Failure(it) }
downloadCalls += InfraredDownloadCall(profileId, codes)
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
downloadCalls += InfraredDownloadCall(profileId, codesSnapshot)
return DomainResult.Success(
codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) },
)
}
}
@@ -131,6 +156,12 @@ data class InfraredDownloadCall(
val codes: List<InfraredCode>,
)
private data class InfraredOperationKey(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
private data class DeviceKey(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
@@ -0,0 +1,165 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
class FakeInfraredKeyRepository : InfraredKeyRepository {
private val lock = Any()
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
DomainResult.Success(
values.filter { it.profileId == profileId && it.deviceName == deviceName },
)
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> =
synchronized(lock) {
DomainResult.Success(keys[ref])
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> = synchronized(lock) {
val ref = key.ref()
if (keys.keys.any { it.keyId == key.id }) {
return@synchronized invalidRequest("Infrared key id already exists")
}
if (keys.values.any {
it.profileId == key.profileId &&
it.deviceName == key.deviceName &&
it.sdkKey == key.sdkKey
}
) {
return@synchronized invalidRequest("Infrared key binding already exists")
}
keys[ref] = key
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> = synchronized(lock) {
val existing = keys[ref] ?: return@synchronized keyNotFound()
if (existing.status !in expectedStatuses) {
return@synchronized invalidRequest("Infrared key status changed")
}
keys[ref] = existing.copy(
status = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> = synchronized(lock) {
val existing = keys[ref] ?: return@synchronized keyNotFound()
keys[ref] = existing.copy(
displayName = displayName,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> =
synchronized(lock) {
val existing = keys[ref] ?: return@synchronized keyNotFound()
if (existing.status == InfraredKeyStatus.LEARNING) {
return@synchronized invalidRequest("Cannot delete a learning infrared key")
}
keys.remove(ref)
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): DomainResult<Int> = synchronized(lock) {
var changed = 0
keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
changed += 1
key.copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} else {
key
}
}
if (changed > 0) publishLocked()
DomainResult.Success(changed)
}
private fun publishLocked() {
state.value = keys.values.toList()
}
private fun keyNotFound(): DomainResult.Failure =
invalidRequest("Infrared key not found")
private fun invalidRequest(detail: String): DomainResult.Failure =
DomainResult.Failure(DomainError.InvalidRequest(detail = detail))
}
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
private val lock = Any()
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
var observeError: DomainError? = null
override fun observeDeviceName(
profileId: ProviderProfileId,
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
observeError?.let { return@map DomainResult.Failure(it) }
DomainResult.Success(values[profileId])
}
override suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit> = synchronized(lock) {
val value = deviceName.value
if (value.isBlank() || value != value.trim()) {
return@synchronized DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared device name must be trimmed and non-blank"),
)
}
deviceNames.value = deviceNames.value + (profileId to deviceName)
DomainResult.Success(Unit)
}
}
private fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,137 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import java.util.concurrent.ConcurrentHashMap
class FakeVirtualRemoteRepository : VirtualRemoteRepository {
private val remotes = ConcurrentHashMap<VirtualRemoteId, VirtualRemote>()
private val bindings = ConcurrentHashMap<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
private val changeTrigger = MutableStateFlow(0L)
override fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>> = changeTrigger.map {
DomainResult.Success(
remotes.values.filter { it.profileId == profileId && it.deviceName == deviceName }
.sortedByDescending { it.createdAtEpochMillis },
)
}
override fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>> = changeTrigger.map {
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
DomainResult.Success(remote)
}
override fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>> = changeTrigger.map {
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
}
override fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>> = changeTrigger.map {
val relevantBindings = remotes.values
.filter { it.profileId == profileId && it.deviceName == deviceName }
.flatMap { bindings[it.id].orEmpty() }
val usedSlots = relevantBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
DomainResult.Success(
SlotQuotaSummary(
totalCapacity = 100,
dynamicScratchSlot = 100,
assignedStaticSlots = usedSlots,
),
)
}
override suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?> {
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
return DomainResult.Success(remote)
}
override suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>> =
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
override suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId> {
remotes[remote.id] = remote
bindings[remote.id] = initialBindings.toMutableList()
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(remote.id)
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
remotes[remote.id] = remote
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> {
remotes.remove(remoteId)
bindings.remove(remoteId)
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
val list = bindings[binding.remoteId] ?: mutableListOf()
val index = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
if (index >= 0) {
list[index] = binding
} else {
list.add(binding)
}
bindings[binding.remoteId] = list
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
list.removeAll { it.buttonId == buttonId }
bindings[remoteId] = list
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
remotes[remoteId] = existing.copy(acState = state)
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
}
@@ -4,8 +4,14 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -54,4 +60,119 @@ class FakeDeviceGatewaysTest {
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
assertTrue(download is DomainResult.Success)
}
@Test
fun infraredDownloadRecordsAndReturnsAnInputSnapshot() = runTest {
val gateway = FakeInfraredGateway()
val code = InfraredCode(DeviceName("ir-1"), "power", "code-value")
val mutableCodes = mutableListOf(code)
val result = gateway.downloadCodes(profileOne, mutableCodes)
mutableCodes.clear()
assertEquals(
InfraredDownloadCall(profileOne, listOf(code)),
gateway.downloadCalls.single(),
)
assertEquals(
DomainResult.Success(listOf(InfraredCodeDownloadResult(code, successful = true))),
result,
)
}
@Test
fun infraredCallLogCanBeIteratedSafelyAroundConcurrentWrites() = runTest {
val gateway = FakeInfraredGateway()
val deviceName = DeviceName("ir-1")
gateway.sendKey(profileOne, deviceName, "initial")
val staleIterators = List(10) { gateway.sentKeys.iterator() }
val start = CountDownLatch(1)
val failures = ConcurrentLinkedQueue<Throwable>()
val writerCount = 4
val callsPerWriter = 100
val writers = List(writerCount) { writerIndex ->
launch(Dispatchers.Default) {
start.await()
repeat(callsPerWriter) { callIndex ->
gateway.sendKey(profileOne, deviceName, "$writerIndex-$callIndex")
}
}
}
val reader = launch(Dispatchers.Default) {
start.await()
repeat(100) {
try {
gateway.sentKeys.forEach { call -> call.key.length }
} catch (failure: Throwable) {
failures += failure
}
}
}
start.countDown()
(writers + reader).joinAll()
staleIterators.forEach { iterator ->
try {
iterator.forEachRemaining { call -> call.key.length }
} catch (failure: Throwable) {
failures += failure
}
}
assertTrue(failures.toList().joinToString(), failures.isEmpty())
assertEquals(1 + writerCount * callsPerWriter, gateway.sentKeys.size)
}
@Test
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
val gateway = FakeInfraredGateway()
val infraredDevice = DeviceName("ir-1")
gateway.setLearnResult(
profileOne,
infraredDevice,
"power",
DomainResult.Failure(DomainError.DeviceOffline("20206")),
)
assertTrue(gateway.learnKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
assertEquals(
DomainResult.Success(Unit),
gateway.learnKey(profileTwo, infraredDevice, "power"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.learnKey(profileOne, infraredDevice, "volume-up"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.learnKey(profileOne, DeviceName("ir-2"), "power"),
)
}
@Test
fun infraredSendResultsCanDifferByProfileDeviceAndKey() = runTest {
val gateway = FakeInfraredGateway()
val infraredDevice = DeviceName("ir-1")
gateway.setSendResult(
profileOne,
infraredDevice,
"power",
DomainResult.Failure(DomainError.DeviceOffline("20206")),
)
assertTrue(gateway.sendKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
assertEquals(
DomainResult.Success(Unit),
gateway.sendKey(profileTwo, infraredDevice, "power"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.sendKey(profileOne, infraredDevice, "volume-up"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.sendKey(profileOne, DeviceName("ir-2"), "power"),
)
}
}
@@ -0,0 +1,299 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeInfraredKeyRepositoryTest {
private val profileOne = ProviderProfileId("one")
private val profileTwo = ProviderProfileId("two")
private val deviceOne = DeviceName("ir-1")
private val deviceTwo = DeviceName("ir-2")
@Test
fun observeKeysEmitsChangesAndFiltersByProfileAndDevice() = runTest {
val repository = FakeInfraredKeyRepository()
val emissions = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) {
repository.observeKeys(profileOne, deviceOne).take(2).toList()
}
repository.insert(savedKey(id = "matching"))
assertEquals(
listOf(
DomainResult.Success(emptyList<SavedInfraredKey>()),
DomainResult.Success(listOf(savedKey(id = "matching"))),
),
emissions.await(),
)
repository.insert(savedKey(id = "other-profile", profileId = profileTwo))
repository.insert(savedKey(id = "other-device", deviceName = deviceTwo))
assertEquals(
DomainResult.Success(listOf(savedKey(id = "matching"))),
repository.observeKeys(profileOne, deviceOne).first(),
)
}
@Test
fun getKeyReturnsOnlyTheMatchingProfileAndId() = runTest {
val repository = FakeInfraredKeyRepository()
repository.insert(savedKey(id = "power"))
assertEquals(
DomainResult.Success(savedKey(id = "power")),
repository.getKey(InfraredKeyRef(profileOne, InfraredKeyId("power"))),
)
assertEquals(
DomainResult.Success(null),
repository.getKey(InfraredKeyRef(profileTwo, InfraredKeyId("power"))),
)
}
@Test
fun insertRejectsDuplicateIdAndDuplicateProviderDeviceSdkBinding() = runTest {
val repository = FakeInfraredKeyRepository()
assertEquals(DomainResult.Success(Unit), repository.insert(savedKey(id = "power")))
val duplicateId = repository.insert(
savedKey(
id = "power",
profileId = profileTwo,
deviceName = deviceTwo,
sdkKey = "other-sdk-key",
),
)
val duplicateBinding = repository.insert(
savedKey(id = "volume", displayName = "Volume"),
)
assertInvalidRequest(duplicateId)
assertInvalidRequest(duplicateBinding)
assertEquals(
DomainResult.Success(listOf(savedKey(id = "power"))),
repository.observeKeys(profileOne, deviceOne).first(),
)
}
@Test
fun transitionStatusRequiresExpectedStatusAndUpdatesFailureContextAtomically() = runTest {
val repository = FakeInfraredKeyRepository()
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.LEARNING))
val rejected = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.READY),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
updatedAtEpochMillis = 20L,
)
assertInvalidRequest(rejected)
assertEquals(InfraredKeyStatus.LEARNING, repository.successfulKey(ref)?.status)
assertNull(repository.successfulKey(ref)?.lastErrorCode)
assertNull(repository.successfulKey(ref)?.lastErrorDetail)
assertEquals(
DomainResult.Success(Unit),
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
updatedAtEpochMillis = 20L,
),
)
assertEquals(
savedKey(
id = "power",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
updatedAtEpochMillis = 20L,
),
repository.successfulKey(ref),
)
}
@Test
fun renameUpdatesDisplayNameAndTimestamp() = runTest {
val repository = FakeInfraredKeyRepository()
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.READY))
assertEquals(
DomainResult.Success(Unit),
repository.rename(ref, displayName = "Main Power", updatedAtEpochMillis = 30L),
)
assertEquals("Main Power", repository.successfulKey(ref)?.displayName)
assertEquals(30L, repository.successfulKey(ref)?.updatedAtEpochMillis)
}
@Test
fun deleteRejectsLearningKeysAndRemovesOtherStatuses() = runTest {
val repository = FakeInfraredKeyRepository()
val learningRef = InfraredKeyRef(profileOne, InfraredKeyId("learning"))
val readyRef = InfraredKeyRef(profileOne, InfraredKeyId("ready"))
repository.insert(savedKey(id = "learning", sdkKey = "learning", status = InfraredKeyStatus.LEARNING))
repository.insert(savedKey(id = "ready", sdkKey = "ready", status = InfraredKeyStatus.READY))
assertInvalidRequest(repository.deleteIfNotLearning(learningRef))
assertEquals(DomainResult.Success(Unit), repository.deleteIfNotLearning(readyRef))
assertTrue(repository.successfulKey(learningRef) != null)
assertNull(repository.successfulKey(readyRef))
}
@Test
fun failInterruptedLearningRecoversOnlyLearningKeysForOneProfileWithFixedDetail() = runTest {
val repository = FakeInfraredKeyRepository()
repository.insert(savedKey(id = "one-learning", sdkKey = "one-learning"))
repository.insert(
savedKey(
id = "one-ready",
sdkKey = "one-ready",
status = InfraredKeyStatus.READY,
),
)
repository.insert(
savedKey(
id = "two-learning",
profileId = profileTwo,
sdkKey = "two-learning",
),
)
assertEquals(
DomainResult.Success(1),
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 40L),
)
val recovered = repository.successfulKey(
InfraredKeyRef(profileOne, InfraredKeyId("one-learning")),
)
assertEquals(InfraredKeyStatus.FAILED, recovered?.status)
assertNull(recovered?.lastErrorCode)
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, recovered?.lastErrorDetail)
assertEquals(40L, recovered?.updatedAtEpochMillis)
assertEquals(
InfraredKeyStatus.READY,
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("one-ready")))?.status,
)
assertEquals(
InfraredKeyStatus.LEARNING,
repository.successfulKey(InfraredKeyRef(profileTwo, InfraredKeyId("two-learning")))?.status,
)
assertEquals(
DomainResult.Success(0),
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 50L),
)
}
@Test
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
val repository = FakeInfraredKeyRepository()
repository.insert(
savedKey(
id = "old-learning",
sdkKey = "old-learning",
status = InfraredKeyStatus.LEARNING,
).copy(createdAtEpochMillis = 100L),
)
repository.insert(
savedKey(
id = "new-learning",
sdkKey = "new-learning",
status = InfraredKeyStatus.LEARNING,
).copy(createdAtEpochMillis = 200L),
)
val result = repository.failInterruptedLearning(
profileId = profileOne,
cutoffEpochMillis = 150L,
updatedAtEpochMillis = 150L,
)
assertEquals(DomainResult.Success(1), result)
assertEquals(
InfraredKeyStatus.FAILED,
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("old-learning")))?.status,
)
assertEquals(
InfraredKeyStatus.LEARNING,
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("new-learning")))?.status,
)
}
@Test
fun deviceConfigurationIsIsolatedByProfile() = runTest {
val repository = FakeInfraredDeviceConfigurationRepository()
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
assertEquals(
DomainResult.Success(Unit),
repository.setDeviceName(profileOne, deviceOne),
)
assertEquals(DomainResult.Success(deviceOne), repository.observeDeviceName(profileOne).first())
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileTwo).first())
}
@Test
fun deviceConfigurationRejectsBlankOrSurroundingWhitespace() = runTest {
val repository = FakeInfraredDeviceConfigurationRepository()
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ")))
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ir-1")))
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName("ir-1 ")))
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
}
private fun savedKey(
id: String,
profileId: ProviderProfileId = profileOne,
deviceName: DeviceName = deviceOne,
displayName: String = "Power",
sdkKey: String = "power",
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
lastErrorCode: String? = null,
lastErrorDetail: String? = null,
updatedAtEpochMillis: Long = 10L,
) = SavedInfraredKey(
id = InfraredKeyId(id),
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
sdkKey = sdkKey,
status = status,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = 10L,
updatedAtEpochMillis = updatedAtEpochMillis,
)
private suspend fun FakeInfraredKeyRepository.successfulKey(
ref: InfraredKeyRef,
): SavedInfraredKey? = (getKey(ref) as DomainResult.Success<SavedInfraredKey?>).value
private fun assertInvalidRequest(result: DomainResult<*>) {
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
}
}