feat: support 1-100 numeric infrared key slots and gateway login entry

This commit is contained in:
2026-08-24 14:13:47 +08:00
parent 94ee2d393d
commit 0620b9f6c8
12 changed files with 329 additions and 36 deletions
@@ -24,7 +24,9 @@ import javax.inject.Singleton
object FoundationModule {
@Provides
@Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
fun provideSessionGateway(
@OptIn(CleartextAbsdkTransport::class) real: AbsdkBridge,
): SessionGateway = real
@Provides
@Singleton
@@ -2,9 +2,19 @@ package com.flagship.abox.manager.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.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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -14,8 +24,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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
@@ -84,34 +98,139 @@ private fun FoundationScreen(
sessionInvalidMessage: String?,
onContinue: () -> Unit,
onOpenInfrared: () -> Unit,
viewModel: FoundationViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
MessageScreen(
title = stringResource(R.string.foundation_title),
description = sessionInvalidMessage ?: stringResource(R.string.foundation_description),
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.foundation_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
ABoxButton(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
Text(
text = sessionInvalidMessage ?: stringResource(R.string.foundation_description),
style = MaterialTheme.typography.bodyMedium,
color = if (sessionInvalidMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// 临时网关登录卡片
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Text(stringResource(R.string.continue_action))
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "ABox 网关登录 (Real 模式前置)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (state.session != null) {
OutlinedButton(
onClick = viewModel::logout,
enabled = !state.isLoading,
) {
Text("退出")
}
}
}
if (state.session == null) {
OutlinedTextField(
value = state.usernameInput,
onValueChange = viewModel::onUsernameChanged,
label = { Text("账号 (默认 a)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.passwordInput,
onValueChange = viewModel::onPasswordChanged,
label = { Text("密码 (默认 a)") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ABoxButton(
onClick = viewModel::login,
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text("连接并登录网关")
}
}
} else {
Text(
text = "✅ 已登录 ABox (账号: ${state.session?.username?.value})",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
Text(
text = "网关会话已建立,现在可以在红外控制中使用真实 ABSDK 模式。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.errorMessage?.let { error ->
Text(
text = "⚠️ $error",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
state.successMessage?.let { success ->
Text(
text = "ℹ️ $success",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
ABoxButton(
onClick = onOpenInfrared,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.infrared_control_action))
}
OutlinedButton(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.continue_action))
}
}
}
@@ -127,12 +246,12 @@ private fun MessageScreen(
) {
Text(
text = title,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
@@ -0,0 +1,103 @@
package com.flagship.abox.manager.ui
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.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class FoundationUiState(
val usernameInput: String = "a",
val passwordInput: String = "a",
val isLoading: Boolean = false,
val session: Session? = null,
val errorMessage: String? = null,
val successMessage: String? = null,
)
@HiltViewModel
class FoundationViewModel @Inject constructor(
private val sessionGateway: SessionGateway,
) : ViewModel() {
private val profileId = ProviderProfileId("abox")
private val _uiState = MutableStateFlow(FoundationUiState())
val uiState: StateFlow<FoundationUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
sessionGateway.observeSession(profileId).collect { session ->
_uiState.value = _uiState.value.copy(session = session)
}
}
}
fun onUsernameChanged(value: String) {
_uiState.value = _uiState.value.copy(usernameInput = value, errorMessage = null)
}
fun onPasswordChanged(value: String) {
_uiState.value = _uiState.value.copy(passwordInput = value, errorMessage = null)
}
fun login() {
val username = _uiState.value.usernameInput.trim()
val password = _uiState.value.passwordInput.trim()
if (username.isBlank() || password.isBlank()) {
_uiState.value = _uiState.value.copy(errorMessage = "用户名和密码不能为空")
return
}
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, successMessage = null)
viewModelScope.launch {
val result = sessionGateway.login(
profileId = profileId,
credentials = LoginCredentials(Username(username), Password(password)),
)
when (result) {
is DomainResult.Success -> {
_uiState.value = _uiState.value.copy(
isLoading = false,
session = result.value,
successMessage = "登录成功!已获取 ABox 会话 Token",
)
}
is DomainResult.Failure -> {
val detail = when (val error = result.error) {
is DomainError.Authentication -> "认证失败 (错误码: ${error.externalCode ?: "20004"}),请检查账号密码"
is DomainError.Network -> "网络连接失败: ${error.detail ?: "请确认已连接网关 WiFi"}"
is DomainError.TokenInvalid -> "Token 无效"
else -> error.detail ?: "登录失败 (${error.externalCode ?: "unknown"})"
}
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = detail,
)
}
}
}
}
fun logout() {
viewModelScope.launch {
sessionGateway.logout(profileId)
_uiState.value = _uiState.value.copy(session = null, successMessage = "已退出登录")
}
}
fun clearMessage() {
_uiState.value = _uiState.value.copy(errorMessage = null, successMessage = null)
}
}
@@ -3,7 +3,6 @@ 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.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
@@ -13,7 +12,6 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -36,11 +34,12 @@ class InfraredControlScreenTest {
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 1,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = {},
onRename = { _, _ -> },
@@ -63,7 +62,7 @@ class InfraredControlScreenTest {
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Power Key",
sdkKey = "app_ir_kready",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
@@ -75,7 +74,7 @@ class InfraredControlScreenTest {
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Volume Up",
sdkKey = "app_ir_kfailed",
sdkKey = "2",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = null,
@@ -96,11 +95,12 @@ class InfraredControlScreenTest {
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 3,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = {},
onLearn = { _, _ -> },
onRetry = { retriedKeyId = it },
onSend = { sentKeyId = it },
onRename = { _, _ -> },
@@ -13,9 +13,14 @@ class InfraredLearningCoordinator(
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val externalScope: CoroutineScope,
) {
fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) {
fun learn(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
) {
externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName)
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
}
@@ -1,6 +1,5 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -58,6 +57,7 @@ fun InfraredControlRoute(
InfraredControlScreen(
state = state,
nextSlot = viewModel.nextAvailableKeySlot(),
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
@@ -76,11 +76,12 @@ fun InfraredControlRoute(
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
nextSlot: Int,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onLearn: (String) -> Unit,
onLearn: (String, String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit,
@@ -265,6 +266,8 @@ internal fun InfraredControlScreen(
if (showLearnDialog) {
var keyName by remember { mutableStateOf("") }
var keySlot by remember { mutableStateOf(nextSlot.toString()) }
AlertDialog(
onDismissRequest = { showLearnDialog = false },
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
@@ -278,15 +281,22 @@ internal fun InfraredControlScreen(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = keySlot,
onValueChange = { keySlot = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
onLearn(keyName)
onLearn(keyName, keySlot)
showLearnDialog = false
},
enabled = keyName.isNotBlank(),
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
) {
Text(stringResource(R.string.infrared_learn_dialog_start))
}
@@ -387,7 +397,7 @@ private fun InfraredKeyRow(
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "SDK: ${key.sdkKey}",
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -192,7 +192,12 @@ class InfraredControlViewModel @Inject constructor(
}
}
fun learnKey(displayName: String) {
fun nextAvailableKeySlot(): Int {
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
return (1..100).firstOrNull { it !in usedSlots } ?: 1
}
fun learnKey(displayName: String, keySlotInput: String? = null) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
@@ -204,7 +209,17 @@ class InfraredControlViewModel @Inject constructor(
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
return
}
learningCoordinator.learn(currentProfile, savedDevice, trimmedName)
val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) {
val slotInt = keySlotInput.trim().toIntOrNull()
if (slotInt == null || slotInt !in 1..100) {
messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100")
return
}
slotInt.toString()
} else {
nextAvailableKeySlot().toString()
}
learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString)
}
fun retryLearning(keyId: InfraredKeyId) {
@@ -22,6 +22,8 @@
<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>
@@ -22,6 +22,8 @@
<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>
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim()
if (
@@ -39,6 +40,20 @@ class InfraredKeyFactory(
?: 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(
@@ -46,7 +61,7 @@ class InfraredKeyFactory(
profileId = profileId,
deviceName = deviceName,
displayName = normalizedName,
sdkKey = "app_ir_$canonicalId",
sdkKey = finalSdkKey,
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
@@ -16,8 +16,9 @@ class LearnInfraredKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<InfraredKeyId> {
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
@@ -32,6 +32,25 @@ class InfraredKeyFactoryTest {
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)