feat: add socket control and temperature humidity modules #7

Merged
KremeCN merged 2 commits from feat/socket-and-temperature-control into main 2026-08-25 15:41:34 +08:00
13 changed files with 225 additions and 6 deletions
Showing only changes of commit dc45e6affc - Show all commits
@@ -19,6 +19,7 @@ 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.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
@@ -27,6 +28,9 @@ 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.navigation.deviceDetailGraph
import com.flagship.abox.manager.ui.navigation.navigateToSocket
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
@@ -103,6 +107,18 @@ private fun AuthenticatedNavHost(
devicesSlot = {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen(
onSocketClick = {
navController.navigateToSocket(
profileId = profile.id,
deviceName = DeviceName("Zigbee插座控制器"),
)
},
onTemperatureHumidityClick = {
navController.navigateToTemperatureHumidity(
profileId = profile.id,
deviceName = DeviceName("温湿度传感器"),
)
},
onInfraredClick = {
navController.navigate("infrared_hub/$encodedProfileId")
},
@@ -110,6 +126,9 @@ private fun AuthenticatedNavHost(
},
)
}
deviceDetailGraph(
onBackClick = navController::popBackStack,
)
composable(
route = "infrared_hub/{profileId}",
arguments = listOf(
@@ -9,16 +9,21 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.ui.socket.SocketRoute
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityRoute
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object DeviceDetailRoutes {
const val SOCKET_PATTERN = "device/socket/{profileId}/{deviceName}"
const val TH_PATTERN = "device/th/{profileId}/{deviceName}"
fun socketRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
Outdated
Review

路由编码

profileId 和 deviceName 被原样拼接到路由路径中。设备名称或配置 ID 包含 /、?、# 等字符时,会被解析成额外路径段、查询参数或片段,导致目标页面无法匹配或收到截断参数。请对每个路径参数进行 URI 编码,或改用类型安全导航参数。

**路由编码** `profileId` 和 `deviceName` 被原样拼接到路由路径中。设备名称或配置 ID 包含 `/`、`?`、`#` 等字符时,会被解析成额外路径段、查询参数或片段,导致目标页面无法匹配或收到截断参数。请对每个路径参数进行 URI 编码,或改用类型安全导航参数。 <!-- pr-agent-dedup: b313370a059d --> <!-- pr-agent-key-issue-location: 48955917f6ca -->
Outdated
Review

路由编码

profileId.value 和 deviceName.value 被直接拼接到路由路径。设备名包含 /、?、# 或 % 时,这些字符会被解释为路由语法,导致目标无法匹配或 SavedStateHandle 收到错误参数;应对每个路径段进行 URI 编码,或改用类型安全路由。

**路由编码** `profileId.value` 和 `deviceName.value` 被直接拼接到路由路径。设备名包含 `/`、`?`、`#` 或 `%` 时,这些字符会被解释为路由语法,导致目标无法匹配或 `SavedStateHandle` 收到错误参数;应对每个路径段进行 URI 编码,或改用类型安全路由。 <!-- pr-agent-dedup: 415e3644f613 --> <!-- pr-agent-key-issue-location: 5bcef409df98 -->
"device/socket/${profileId.value}/${deviceName.value}"
"device/socket/${encode(profileId.value)}/${encode(deviceName.value)}"
Outdated
Review

参数损坏

路由使用 URLEncoder 编码,而 Navigation 已会解码路径参数,两个 ViewModel 随后又通过 URLDecoder 解码一次。设备名或配置 ID 包含字面量 +、%2F 等内容时会被二次转换,例如 A+B 会变成 A B,导致查询错误设备或找不到设备。应改用适合 URI 路径的编码方式,并移除 ViewModel 中的二次解码。

**参数损坏** 路由使用 `URLEncoder` 编码,而 Navigation 已会解码路径参数,两个 ViewModel 随后又通过 `URLDecoder` 解码一次。设备名或配置 ID 包含字面量 `+`、`%2F` 等内容时会被二次转换,例如 `A+B` 会变成 `A B`,导致查询错误设备或找不到设备。应改用适合 URI 路径的编码方式,并移除 ViewModel 中的二次解码。 <!-- pr-agent-dedup: c724c8573d3b --> <!-- pr-agent-key-issue-location: a2b312c1af6a -->
Outdated
Review

参数损坏

Navigation 在写入 SavedStateHandle 前已经解码路径参数,而两个 ViewModel 又使用 URLDecoder 再次解码。设备名或配置 ID 包含字面量 +、%2F 等内容时会被转换成空格或斜杠,导致网关查询错误的设备。应使用适合路径的编码方式,并确保参数只解码一次。

**参数损坏** Navigation 在写入 `SavedStateHandle` 前已经解码路径参数,而两个 ViewModel 又使用 `URLDecoder` 再次解码。设备名或配置 ID 包含字面量 `+`、`%2F` 等内容时会被转换成空格或斜杠,导致网关查询错误的设备。应使用适合路径的编码方式,并确保参数只解码一次。 <!-- pr-agent-dedup: 95fc3b6d0547 --> <!-- pr-agent-key-issue-location: 1d90e03d542c -->
fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
"device/th/${profileId.value}/${deviceName.value}"
"device/th/${encode(profileId.value)}/${encode(deviceName.value)}"
private fun encode(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name())
}
fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) {
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,12 +24,16 @@ 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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
@@ -43,6 +48,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.SocketPowerState
import java.text.SimpleDateFormat
import java.util.Date
@@ -73,6 +79,7 @@ fun SocketScreen(
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
var showEditNameDialog by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxSize(),
@@ -90,6 +97,12 @@ fun SocketScreen(
}
},
actions = {
IconButton(
onClick = { showEditNameDialog = true },
enabled = !state.isLoading && !state.isOperating,
) {
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
IconButton(
onClick = { onEvent(SocketUiEvent.Refresh) },
enabled = !state.isLoading && !state.isOperating,
@@ -234,6 +247,42 @@ fun SocketScreen(
}
}
}
if (showEditNameDialog) {
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
AlertDialog(
onDismissRequest = { showEditNameDialog = false },
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
text = {
OutlinedTextField(
value = inputName,
onValueChange = { inputName = it },
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = inputName.trim()
if (trimmed.isNotBlank()) {
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(trimmed)))
}
showEditNameDialog = false
},
enabled = inputName.isNotBlank(),
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = { showEditNameDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@Composable
@@ -24,6 +24,7 @@ data class SocketUiState(
sealed interface SocketUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
data object Refresh : SocketUiEvent
data object TogglePower : SocketUiEvent
data class SetPower(val targetState: SocketPowerState) : SocketUiEvent
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject
@HiltViewModel
@@ -28,8 +30,8 @@ class SocketViewModel @Inject constructor(
val state: StateFlow<SocketUiState> = _state.asStateFlow()
init {
val profileIdString: String? = savedStateHandle["profileId"]
val deviceNameString: String? = savedStateHandle["deviceName"]
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent(
@@ -41,6 +43,12 @@ class SocketViewModel @Inject constructor(
}
}
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
value
}
fun onEvent(event: SocketUiEvent) {
when (event) {
is SocketUiEvent.Initialize -> {
@@ -53,6 +61,21 @@ class SocketViewModel @Inject constructor(
loadStatus()
}
is SocketUiEvent.ChangeDeviceName -> {
val trimmed = event.deviceName.value.trim()
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
_state.update {
it.copy(
deviceName = DeviceName(trimmed),
powerState = null,
errorMessage = null,
lastConfirmedTimeMillis = null,
)
}
loadStatus()
}
}
SocketUiEvent.Refresh -> {
loadStatus()
}
@@ -13,17 +13,22 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.res.stringResource
@@ -35,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.DeviceName
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -64,6 +70,7 @@ fun TemperatureHumidityScreen(
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
var showEditNameDialog by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxSize(),
@@ -81,6 +88,12 @@ fun TemperatureHumidityScreen(
}
},
actions = {
IconButton(
onClick = { showEditNameDialog = true },
enabled = !state.isLoading && !state.isRefreshing,
) {
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
IconButton(
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
enabled = !state.isLoading && !state.isRefreshing,
@@ -225,6 +238,42 @@ fun TemperatureHumidityScreen(
}
}
}
if (showEditNameDialog) {
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
AlertDialog(
onDismissRequest = { showEditNameDialog = false },
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
text = {
OutlinedTextField(
value = inputName,
onValueChange = { inputName = it },
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = inputName.trim()
if (trimmed.isNotBlank()) {
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(trimmed)))
}
showEditNameDialog = false
},
enabled = inputName.isNotBlank(),
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = { showEditNameDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@Composable
@@ -28,6 +28,7 @@ data class TemperatureHumidityUiState(
sealed interface TemperatureHumidityUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : TemperatureHumidityUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : TemperatureHumidityUiEvent
data object Refresh : TemperatureHumidityUiEvent
data object ClearError : TemperatureHumidityUiEvent
}
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject
@HiltViewModel
@@ -27,8 +29,8 @@ class TemperatureHumidityViewModel @Inject constructor(
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
init {
val profileIdString: String? = savedStateHandle["profileId"]
val deviceNameString: String? = savedStateHandle["deviceName"]
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent(
@@ -40,6 +42,12 @@ class TemperatureHumidityViewModel @Inject constructor(
}
}
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
value
}
fun onEvent(event: TemperatureHumidityUiEvent) {
when (event) {
is TemperatureHumidityUiEvent.Initialize -> {
@@ -52,6 +60,23 @@ class TemperatureHumidityViewModel @Inject constructor(
loadReading(isRefresh = false)
}
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
val trimmed = event.deviceName.value.trim()
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
_state.update {
it.copy(
deviceName = DeviceName(trimmed),
temperature = null,
humidity = null,
hasNoData = false,
errorMessage = null,
lastUpdatedTimeMillis = null,
)
}
loadReading(isRefresh = false)
}
}
TemperatureHumidityUiEvent.Refresh -> {
loadReading(isRefresh = true)
}
@@ -82,6 +82,11 @@
<string name="action_refresh">刷新</string>
<string name="action_retry">重试</string>
<string name="action_back">返回</string>
<string name="action_edit_device_name">修改设备名称</string>
<string name="dialog_edit_device_name_title">修改设备名称</string>
<string name="dialog_edit_device_name_hint">请输入网关中的设备名称</string>
<string name="action_confirm">确定</string>
<string name="action_cancel">取消</string>
<string name="status_loading">加载中…</string>
<string name="status_device_offline_banner">设备已离线,无法进行通信。</string>
<string name="error_authentication">认证失败,请重新登录。</string>
@@ -82,6 +82,11 @@
<string name="action_refresh">Refresh</string>
<string name="action_retry">Retry</string>
<string name="action_back">Back</string>
<string name="action_edit_device_name">Edit Device Name</string>
<string name="dialog_edit_device_name_title">Edit Device Name</string>
<string name="dialog_edit_device_name_hint">Enter gateway device name</string>
<string name="action_confirm">Confirm</string>
<string name="action_cancel">Cancel</string>
<string name="status_loading">Loading…</string>
<string name="status_device_offline_banner">Device is offline. Commands cannot be sent.</string>
<string name="error_authentication">Authentication failed. Please log in again.</string>
@@ -170,6 +170,20 @@ class SocketViewModelTest {
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
}
@Test
fun changeDeviceName_updatesNameAndReloads() = runTest {
val newDevice = DeviceName("socket-2")
fakeSocketGateway.setPower(profileId, newDevice, SocketPowerState.ON)
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.ChangeDeviceName(newDevice))
assertEquals(newDevice, viewModel.state.value.deviceName)
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
}
@Test
fun clearError_resetsErrorMessage() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest())
@@ -158,6 +158,28 @@ class TemperatureHumidityViewModelTest {
assertFalse(state.isRefreshing)
}
@Test
fun changeDeviceName_updatesNameAndReloads() = runTest {
val newDevice = DeviceName("th-2")
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = newDevice,
temperature = "26.8",
humidity = "55",
),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertEquals("23.5", viewModel.state.value.temperature)
viewModel.onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(newDevice))
assertEquals(newDevice, viewModel.state.value.deviceName)
assertEquals("26.8", viewModel.state.value.temperature)
assertEquals("55", viewModel.state.value.humidity)
}
@Test
fun clearError_resetsErrorMessage() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.Network())
@@ -75,6 +75,7 @@
<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_key_tv_menu">电视-菜单</string>
<string name="infrared_control_title">红外设备与环境配置</string>
<string name="infrared_device_config_section">ABSDK 设备名称配置</string>
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>