feat: support 1-100 numeric infrared key slots and gateway login entry
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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 = { _, _ -> },
|
||||
|
||||
+7
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -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,
|
||||
)
|
||||
|
||||
+17
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user