Compare commits

...
10 Commits
46 changed files with 3299 additions and 25 deletions
@@ -17,10 +17,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import kotlin.reflect.KClass
class AbsdkDeviceBridgeTest { class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox") private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1") private val socket = DeviceName("socket-1")
private val irDevice = DeviceName("ir-room")
@Test @Test
fun socketControlReturnsConfirmedStatus() = runTest { fun socketControlReturnsConfirmedStatus() = runTest {
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline) 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 @Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest { fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1") val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
assertEquals(1, client.maxConcurrentCalls.get()) 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())
}
}
} }
+12
View File
@@ -45,6 +45,15 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE*"
excludes += "/META-INF/NOTICE*"
}
}
} }
kapt { kapt {
@@ -54,7 +63,9 @@ kapt {
dependencies { dependencies {
implementation(project(":domain")) implementation(project(":domain"))
implementation(project(":android:core:designsystem")) implementation(project(":android:core:designsystem"))
implementation(project(":android:feature:infrared"))
debugImplementation(project(":testing:fakes")) debugImplementation(project(":testing:fakes"))
debugImplementation(project(":absdk-bridge"))
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@@ -68,6 +79,7 @@ dependencies {
kapt(libs.hilt.compiler) kapt(libs.hilt.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)
@@ -0,0 +1,26 @@
package com.flagship.abox.manager.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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.MainActivity
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ABoxManagerAppTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun opensInfraredControlScreenFromFoundationScreen() {
composeTestRule.onNodeWithText("Infrared Control").performClick()
composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed()
}
}
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application android:usesCleartextTraffic="true" />
</manifest>
@@ -0,0 +1,41 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
class DebugRoutingInfraredGateway(
private val modeStore: InfraredRuntimeModeStore,
private val fakeGateway: InfraredGateway,
private val realGateway: InfraredGateway,
) : InfraredGateway {
private suspend fun delegate(): InfraredGateway {
return when (modeStore.currentMode()) {
InfraredRuntimeMode.REAL_ABSDK -> realGateway
InfraredRuntimeMode.FAKE -> fakeGateway
}
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = delegate().sendKey(profileId, deviceName, key)
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = delegate().learnKey(profileId, deviceName, key)
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = delegate().downloadCodes(profileId, codes)
}
@@ -1,10 +1,13 @@
package com.flagship.abox.manager.di package com.flagship.abox.manager.di
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.absdk.bridge.CleartextAbsdkTransport
import com.flagship.abox.manager.domain.DeviceCatalog import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
@@ -21,7 +24,9 @@ import javax.inject.Singleton
object FoundationModule { object FoundationModule {
@Provides @Provides
@Singleton @Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway() fun provideSessionGateway(
@OptIn(CleartextAbsdkTransport::class) real: AbsdkBridge,
): SessionGateway = real
@Provides @Provides
@Singleton @Singleton
@@ -38,5 +43,18 @@ object FoundationModule {
@Provides @Provides
@Singleton @Singleton
fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway() fun provideFakeInfraredGateway(): FakeInfraredGateway = FakeInfraredGateway()
@Provides
@Singleton
@OptIn(CleartextAbsdkTransport::class)
fun provideRealAbsdkBridge(): AbsdkBridge = AbsdkBridge.create()
@Provides
@Singleton
fun provideInfraredGateway(
modeStore: InfraredRuntimeModeStore,
fake: FakeInfraredGateway,
real: AbsdkBridge,
): InfraredGateway = DebugRoutingInfraredGateway(modeStore, fake, real)
} }
@@ -2,38 +2,70 @@ package com.flagship.abox.manager.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
private const val FOUNDATION_ROUTE = "foundation" private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder" private const val PLACEHOLDER_ROUTE = "placeholder"
private const val INFRARED_ROUTE = "infrared/{profileId}"
private const val TEMPORARY_ABOX_PROFILE_ID = "abox"
@Composable @Composable
fun ABoxManagerApp() { fun ABoxManagerApp() {
ABoxTheme { ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) { ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController() val navController = rememberNavController()
var sessionMessage by remember { mutableStateOf<String?>(null) }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = FOUNDATION_ROUTE, startDestination = FOUNDATION_ROUTE,
) { ) {
composable(FOUNDATION_ROUTE) { composable(FOUNDATION_ROUTE) {
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) }) FoundationScreen(
sessionInvalidMessage = sessionMessage,
onContinue = { navController.navigate(PLACEHOLDER_ROUTE) },
onOpenInfrared = {
sessionMessage = null
navController.navigate("infrared/$TEMPORARY_ABOX_PROFILE_ID")
},
)
} }
composable(PLACEHOLDER_ROUTE) { composable(PLACEHOLDER_ROUTE) {
MessageScreen( MessageScreen(
@@ -41,29 +73,161 @@ fun ABoxManagerApp() {
description = stringResource(R.string.placeholder_description), description = stringResource(R.string.placeholder_description),
) )
} }
composable(
route = INFRARED_ROUTE,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
val sessionInvalidText = stringResource(R.string.infrared_session_invalid_message)
InfraredControlRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = {
sessionMessage = sessionInvalidText
navController.popBackStack(FOUNDATION_ROUTE, inclusive = false)
},
)
}
} }
} }
} }
} }
@Composable @Composable
private fun FoundationScreen(onContinue: () -> Unit) { private fun FoundationScreen(
sessionInvalidMessage: String?,
onContinue: () -> Unit,
onOpenInfrared: () -> Unit,
viewModel: FoundationViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(32.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
MessageScreen( Spacer(modifier = Modifier.height(8.dp))
title = stringResource(R.string.foundation_title), Text(
description = stringResource(R.string.foundation_description), text = stringResource(R.string.foundation_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
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),
),
) {
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( 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(),
) {
Text(stringResource(R.string.infrared_control_action))
}
OutlinedButton(
onClick = onContinue, onClick = onContinue,
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(top = 24.dp),
) { ) {
Text(stringResource(R.string.continue_action)) Text(stringResource(R.string.continue_action))
} }
@@ -82,12 +246,12 @@ private fun MessageScreen(
) { ) {
Text( Text(
text = title, text = title,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Text( Text(
text = description, text = description,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center, 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)
}
}
@@ -5,4 +5,6 @@
<string name="continue_action">继续</string> <string name="continue_action">继续</string>
<string name="placeholder_title">功能模块可以开始开发</string> <string name="placeholder_title">功能模块可以开始开发</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string> <string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
<string name="infrared_control_action">红外控制</string>
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
</resources> </resources>
@@ -5,4 +5,6 @@
<string name="continue_action">Continue</string> <string name="continue_action">Continue</string>
<string name="placeholder_title">Feature development can start</string> <string name="placeholder_title">Feature development can start</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string> <string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</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> </resources>
@@ -0,0 +1,47 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object InfraredReleaseModule {
@Provides
@Singleton
fun provideInfraredGateway(): InfraredGateway = object : InfraredGateway {
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = disabledFailure()
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = disabledFailure()
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = disabledFailure()
private fun <T> disabledFailure(): DomainResult<T> = DomainResult.Failure(
DomainError.OperationFailed(
externalCode = "REAL_ABSDK_DISABLED",
detail = "Infrared hardware transport is disabled in release builds",
),
)
}
}
@@ -0,0 +1,70 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
private class TestModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
class DebugRoutingInfraredGatewayTest {
private val modeStore = TestModeStore()
private val fake = FakeInfraredGateway()
private val real = FakeInfraredGateway()
private val router = DebugRoutingInfraredGateway(modeStore, fake, real)
private val profile = ProviderProfileId("profile1")
private val device = DeviceName("ir-1")
@Test
fun delegatesToFakeByDefault() = runTest {
val result = router.sendKey(profile, device, "key1")
assertTrue(result is DomainResult.Success)
assertEquals(1, fake.sentKeys.size)
assertTrue(real.sentKeys.isEmpty())
router.learnKey(profile, device, "key2")
assertEquals(1, fake.learnedKeys.size)
assertTrue(real.learnedKeys.isEmpty())
router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code")))
assertEquals(1, fake.downloadCalls.size)
assertTrue(real.downloadCalls.isEmpty())
}
@Test
fun delegatesToRealWhenModeIsRealAbsdk() = runTest {
modeStore.setMode(InfraredRuntimeMode.REAL_ABSDK)
val result = router.sendKey(profile, device, "key1")
assertTrue(result is DomainResult.Success)
assertTrue(fake.sentKeys.isEmpty())
assertEquals(1, real.sentKeys.size)
router.learnKey(profile, device, "key2")
assertTrue(fake.learnedKeys.isEmpty())
assertEquals(1, real.learnedKeys.size)
router.downloadCodes(profile, listOf(InfraredCode(device, "k", "code")))
assertTrue(fake.downloadCalls.isEmpty())
assertEquals(1, real.downloadCalls.size)
}
}
+76
View File
@@ -0,0 +1,76 @@
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.datastore.preferences)
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,175 @@
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 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,121 @@
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.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.infrared.InfraredKeyId
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.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredControlScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displaysModeAndDeviceInformation() {
val state = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.FAKE,
effectiveProfileId = ProviderProfileId("abox:infrared-fake"),
savedDeviceName = DeviceName("ir-living"),
loading = false,
)
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 1,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = {},
onRename = { _, _ -> },
onDelete = {},
onModeChanged = {},
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed()
}
@Test
fun rendersKeysWithCorrectActionButtons() {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k-ready"),
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Power Key",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k-failed"),
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Volume Up",
sdkKey = "2",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = null,
createdAtEpochMillis = 200L,
updatedAtEpochMillis = 200L,
)
val state = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.REAL_ABSDK,
savedDeviceName = DeviceName("ir-1"),
keys = listOf(readyKey, failedKey),
loading = false,
)
var sentKeyId: InfraredKeyId? = null
var retriedKeyId: InfraredKeyId? = null
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 3,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = { retriedKeyId = it },
onSend = { sentKeyId = it },
onRename = { _, _ -> },
onDelete = {},
onModeChanged = {},
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick()
assertEquals(InfraredKeyId("k-ready"), sentKeyId)
composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick()
assertEquals(InfraredKeyId("k-failed"), retriedKeyId)
}
}
@@ -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,32 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val externalScope: CoroutineScope,
) {
fun learn(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
) {
externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
}
fun retry(ref: InfraredKeyRef) {
externalScope.launch {
retryInfraredKeyLearning(ref)
}
}
}
@@ -0,0 +1,105 @@
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'
""",
)
suspend fun failInterruptedLearning(
profileId: String,
targetStatus: String,
errorDetail: String,
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)
}
@@ -0,0 +1,16 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [
InfraredKeyEntity::class,
InfraredDeviceConfigurationEntity::class,
],
version = 1,
exportSchema = true,
)
abstract class InfraredDatabase : RoomDatabase() {
abstract fun infraredDao(): InfraredDao
}
@@ -0,0 +1,33 @@
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,
)
@@ -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,190 @@
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.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 ->
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 ->
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,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
return try {
val count = dao.failInterruptedLearning(
profileId = profileId.value,
targetStatus = InfraredKeyStatus.FAILED.name,
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
DomainResult.Success(count)
} catch (throwable: 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 {
return when (throwable) {
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
else -> DomainError.Unknown(detail = "Database operation failed")
}
}
}
@@ -0,0 +1,184 @@
package com.flagship.abox.manager.infrared.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
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.runtime.DataStoreInfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
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
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredDataStore
@Module
@InstallIn(SingletonComponent::class)
object InfraredFeatureModule {
@Provides
@Singleton
fun provideInfraredDatabase(
@ApplicationContext context: Context,
): InfraredDatabase {
return Room.databaseBuilder(
context,
InfraredDatabase::class.java,
"abox_infrared.db",
).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
@Singleton
@InfraredDataStore
fun provideInfraredPreferencesDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("infrared_preferences") },
)
}
@Provides
@Singleton
fun provideInfraredRuntimeModeStore(
@InfraredDataStore dataStore: DataStore<Preferences>,
): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore)
@Provides
@Singleton
fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver =
InfraredRuntimeProfileResolver()
@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 provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning,
@InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning,
externalScope = scope,
)
}
@@ -0,0 +1,58 @@
package com.flagship.abox.manager.infrared.runtime
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.IOException
class DataStoreInfraredRuntimeModeStore(
private val dataStore: DataStore<Preferences>,
) : InfraredRuntimeModeStore {
companion object {
private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode")
}
override val mode: Flow<InfraredRuntimeMode> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(androidx.datastore.preferences.core.emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val storedName = preferences[KEY_RUNTIME_MODE]
if (storedName != null) {
try {
InfraredRuntimeMode.valueOf(storedName)
} catch (_: IllegalArgumentException) {
InfraredRuntimeMode.FAKE
}
} else {
InfraredRuntimeMode.FAKE
}
}
override suspend fun currentMode(): InfraredRuntimeMode {
return mode.first()
}
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
return try {
dataStore.edit { preferences ->
preferences[KEY_RUNTIME_MODE] = mode.name
}
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
}
}
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
enum class InfraredRuntimeMode {
FAKE,
REAL_ABSDK,
}
interface InfraredRuntimeModeStore {
val mode: Flow<InfraredRuntimeMode>
suspend fun currentMode(): InfraredRuntimeMode
suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit>
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
class InfraredRuntimeProfileResolver {
fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId {
return when (mode) {
InfraredRuntimeMode.REAL_ABSDK -> realProfileId
InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake")
}
}
}
@@ -0,0 +1,466 @@
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
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 com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
@Composable
fun InfraredControlRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredControlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredControlScreen(
state = state,
nextSlot = viewModel.nextAvailableKeySlot(),
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
onSaveDeviceName = viewModel::saveDeviceName,
onLearn = viewModel::learnKey,
onRetry = viewModel::retryLearning,
onSend = viewModel::sendKey,
onRename = viewModel::renameKey,
onDelete = viewModel::deleteKey,
onModeChanged = viewModel::setRuntimeMode,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
nextSlot: Int,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onLearn: (String, String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit,
onDelete: (InfraredKeyId) -> Unit,
onModeChanged: (InfraredRuntimeMode) -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
var showLearnDialog by remember { mutableStateOf(false) }
var keyToRename by remember { mutableStateOf<SavedInfraredKey?>(null) }
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is InfraredUiMessage.Info -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is InfraredUiMessage.Error -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is InfraredUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.infrared_title)) },
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Mode indicator and toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
stringResource(R.string.infrared_mode_fake)
} else {
stringResource(R.string.infrared_mode_real)
}
Text(
text = modeText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("infrared_mode_label"),
)
OutlinedButton(
onClick = {
val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
InfraredRuntimeMode.REAL_ABSDK
} else {
InfraredRuntimeMode.FAKE
}
onModeChanged(target)
},
enabled = !state.hasActiveLearning,
) {
Text(stringResource(R.string.infrared_switch_mode))
}
}
// Device name configuration card
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val saved = state.savedDeviceName
Text(
text = if (saved != null) {
stringResource(R.string.infrared_saved_device, saved.value)
} else {
stringResource(R.string.infrared_no_device_saved)
},
style = MaterialTheme.typography.titleMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = state.deviceNameInput,
onValueChange = onDeviceNameChanged,
label = { Text(stringResource(R.string.infrared_device_name_label)) },
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
singleLine = true,
modifier = Modifier
.weight(1f)
.testTag("infrared_device_name_input"),
)
ABoxButton(
onClick = onSaveDeviceName,
modifier = Modifier.testTag("infrared_save_device"),
) {
Text(stringResource(R.string.infrared_save_device))
}
}
}
}
// Learned Keys Section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_keys_title),
style = MaterialTheme.typography.titleMedium,
)
ABoxButton(
onClick = { showLearnDialog = true },
enabled = state.savedDeviceName != null,
modifier = Modifier.testTag("infrared_learn_new_key"),
) {
Text(stringResource(R.string.infrared_learn_new_key))
}
}
if (state.keys.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_no_keys),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.keys, key = { it.id.value }) { key ->
InfraredKeyRow(
key = key,
isSending = state.sendingKeyIds.contains(key.id),
onSend = { onSend(key.id) },
onRetry = { onRetry(key.id) },
onRename = { keyToRename = key },
onDelete = { keyToDelete = key },
)
}
}
}
}
}
}
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)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_learn_dialog_prompt))
OutlinedTextField(
value = keyName,
onValueChange = { keyName = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) },
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, keySlot)
showLearnDialog = false
},
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
) {
Text(stringResource(R.string.infrared_learn_dialog_start))
}
},
dismissButton = {
TextButton(onClick = { showLearnDialog = false }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToRename?.let { targetKey ->
var newName by remember { mutableStateOf(targetKey.displayName) }
AlertDialog(
onDismissRequest = { keyToRename = null },
title = { Text(stringResource(R.string.infrared_rename_dialog_title)) },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
Button(
onClick = {
onRename(targetKey.id, newName)
keyToRename = null
},
enabled = newName.isNotBlank(),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToRename = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToDelete?.let { targetKey ->
AlertDialog(
onDismissRequest = { keyToDelete = null },
title = { Text(stringResource(R.string.infrared_delete_dialog_title)) },
text = { Text(stringResource(R.string.infrared_delete_dialog_message)) },
confirmButton = {
Button(
onClick = {
onDelete(targetKey.id)
keyToDelete = null
},
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToDelete = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
}
@Composable
private fun InfraredKeyRow(
key: SavedInfraredKey,
isSending: Boolean,
onSend: () -> Unit,
onRetry: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("infrared_key_${key.id.value}"),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = key.displayName,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val statusLabel = when (key.status) {
InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning)
InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready)
InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed)
}
Text(
text = statusLabel,
style = MaterialTheme.typography.bodyMedium,
color = when (key.status) {
InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary
InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary
InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error
},
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (key.status == InfraredKeyStatus.READY) {
Button(
onClick = onSend,
enabled = !isSending,
modifier = Modifier.testTag("infrared_send_${key.id.value}"),
) {
if (isSending) {
Text(stringResource(R.string.infrared_sending))
} else {
Text(stringResource(R.string.infrared_send))
}
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status == InfraredKeyStatus.FAILED) {
Button(
onClick = onRetry,
modifier = Modifier.testTag("infrared_retry_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_retry))
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status != InfraredKeyStatus.LEARNING) {
OutlinedButton(onClick = onRename) {
Text(stringResource(R.string.infrared_rename))
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(onClick = onDelete) {
Text(stringResource(R.string.infrared_delete))
}
} else {
CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp))
}
}
}
}
}
@@ -0,0 +1,27 @@
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.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
sealed interface InfraredUiMessage {
data class Info(val text: String) : InfraredUiMessage
data class Error(val text: String) : InfraredUiMessage
data object SessionInvalid : InfraredUiMessage
}
data class InfraredControlUiState(
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName? = null,
val keys: List<SavedInfraredKey> = emptyList(),
val loading: Boolean = true,
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
val message: InfraredUiMessage? = null,
) {
val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
}
@@ -0,0 +1,342 @@
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.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
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.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
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.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 keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
private val renameInfraredKey: RenameInfraredKey,
private val deleteInfraredKey: DeleteInfraredKey,
private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning,
private val clock: EpochMillisClock,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val deviceNameInputFlow = MutableStateFlow("")
private val sendingKeyIdsFlow = MutableStateFlow<Set<InfraredKeyId>>(emptySet())
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.mapModeToProfile()
private fun Flow<InfraredRuntimeMode>.mapModeToProfile(): Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> {
return flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode)
flowOf(Pair(effectiveProfile, mode))
}
}
}
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) {
flowOf(DomainResult.Success(null))
} else {
deviceConfigurationRepository.observeDeviceName(profile)
}
}
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
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))
}
}
}
}
private data class ServerState(
val profile: ProviderProfileId?,
val mode: InfraredRuntimeMode,
val deviceResult: DomainResult<DeviceName?>,
val keysResult: DomainResult<List<SavedInfraredKey>>,
)
private val serverStateFlow: Flow<ServerState> = combine(
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
) { profileAndMode, deviceResult, keysResult ->
ServerState(
profile = profileAndMode.first,
mode = profileAndMode.second,
deviceResult = deviceResult,
keysResult = keysResult,
)
}
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
sendingKeyIdsFlow,
messageFlow,
) { serverState, input, sendingIds, msg ->
val savedDevice = (serverState.deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (serverState.keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val loading = serverState.profile == null ||
serverState.deviceResult !is DomainResult.Success ||
serverState.keysResult !is DomainResult.Success
InfraredControlUiState(
runtimeMode = serverState.mode,
effectiveProfileId = serverState.profile,
deviceNameInput = input,
savedDeviceName = savedDevice,
keys = keys,
loading = loading,
sendingKeyIds = sendingIds,
message = if (authenticatedProfileId == null) {
InfraredUiMessage.Error("Invalid or missing profile ID")
} else {
msg
},
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.FAKE,
effectiveProfileId = null,
loading = true,
),
)
init {
if (authenticatedProfileId != null) {
viewModelScope.launch {
val currentMode = runtimeModeStore.currentMode()
val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode)
recoverInterruptedInfraredLearning(effectiveProfile)
}
}
}
fun onDeviceNameChanged(value: String) {
deviceNameInputFlow.value = value
}
fun saveDeviceName() {
val currentProfile = state.value.effectiveProfileId ?: return
val trimmed = deviceNameInputFlow.value.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank")
return
}
viewModelScope.launch {
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Device name saved")
deviceNameInputFlow.value = ""
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
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) {
messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first")
return
}
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
return
}
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) {
val currentProfile = state.value.effectiveProfileId ?: return
learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId))
}
fun sendKey(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
if (state.value.sendingKeyIds.contains(keyId)) return
val key = state.value.keys.find { it.id == keyId }
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = InfraredUiMessage.Error("Key is not ready to send")
return
}
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Signal sent successfully")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId
}
}
}
fun renameKey(keyId: InfraredKeyId, displayName: String) {
val currentProfile = state.value.effectiveProfileId ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Name cannot be blank")
return
}
viewModelScope.launch {
when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Key renamed")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun deleteKey(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
viewModelScope.launch {
when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Key removed locally")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun setRuntimeMode(mode: InfraredRuntimeMode) {
if (state.value.hasActiveLearning) {
messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active")
return
}
viewModelScope.launch {
when (val result = runtimeModeStore.setMode(mode)) {
is DomainResult.Success -> {
if (authenticatedProfileId != null) {
val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode)
recoverInterruptedInfraredLearning(newEffectiveProfile)
}
}
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("Infrared controller is offline (code: ${error.externalCode ?: "20206"})")
}
is DomainError.DeviceNotFound -> {
messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})")
}
is DomainError.InvalidRequest -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request")
}
is DomainError.OperationFailed -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})")
}
is DomainError.Network -> {
messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}")
}
is DomainError.NoData -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found")
}
is DomainError.Unknown -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error")
}
}
}
}
@@ -0,0 +1,35 @@
<resources>
<string name="infrared_title">红外控制</string>
<string name="infrared_mode_fake">模式:模拟 / Fake</string>
<string name="infrared_mode_real">模式:真实 ABSDK</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>
</resources>
@@ -0,0 +1,35 @@
<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>
</resources>
@@ -0,0 +1,76 @@
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.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)
@Test
fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, 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, 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)
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertEquals
import org.junit.Test
class InfraredRuntimeProfileResolverTest {
@Test
fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() {
val resolver = InfraredRuntimeProfileResolver()
assertEquals(
ProviderProfileId("abox"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK),
)
assertEquals(
ProviderProfileId("abox:infrared-fake"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE),
)
}
}
@@ -0,0 +1,294 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
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.DeleteInfraredKey
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.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
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.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.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
class FakeRuntimeModeStore : 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 InfraredControlViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val baseProfile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepository = FakeInfraredKeyRepository()
private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository()
private val runtimeModeStore = FakeRuntimeModeStore()
private val profileResolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock)
private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock)
private val sendUseCase = SendInfraredKey(keyRepository, gateway)
private val renameUseCase = RenameInfraredKey(keyRepository, clock)
private val deleteUseCase = DeleteInfraredKey(keyRepository)
private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel {
val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap())
return InfraredControlViewModel(
savedStateHandle = handle,
keyRepository = keyRepository,
deviceConfigurationRepository = deviceConfigRepository,
runtimeModeStore = runtimeModeStore,
profileResolver = profileResolver,
learningCoordinator = coordinator,
sendInfraredKey = sendUseCase,
renameInfraredKey = renameUseCase,
deleteInfraredKey = deleteUseCase,
recoverInterruptedInfraredLearning = recoverUseCase,
clock = clock,
)
}
@Test
fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(fakeProfile, vm.state.value.effectiveProfileId)
assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode)
vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK)
advanceUntilIdle()
assertEquals(baseProfile, vm.state.value.effectiveProfileId)
assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode)
job.cancel()
}
@Test
fun blankDeviceNameReturnsValidationError() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ")
vm.saveDeviceName()
advanceUntilIdle()
val msg = vm.state.value.message
assertTrue(msg is InfraredUiMessage.Error)
assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text)
job.cancel()
}
@Test
fun saveDeviceNamePersistsAndTrims() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ir-main ")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName)
assertEquals("", vm.state.value.deviceNameInput)
assertTrue(vm.state.value.message is InfraredUiMessage.Info)
job.cancel()
}
@Test
fun learnWithoutSavedDeviceFails() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.learnKey("Power")
advanceUntilIdle()
val msg = vm.state.value.message
assertTrue(msg is InfraredUiMessage.Error)
assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text)
job.cancel()
}
@Test
fun learnWithSavedDeviceSucceeds() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged("ir-1")
vm.saveDeviceName()
advanceUntilIdle()
vm.learnKey("Air Conditioner")
advanceUntilIdle()
val keys = vm.state.value.keys
assertEquals(1, keys.size)
assertEquals("Air Conditioner", keys.first().displayName)
assertEquals(InfraredKeyStatus.READY, keys.first().status)
job.cancel()
}
@Test
fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
assertTrue(vm.state.value.sendingKeyIds.isEmpty())
assertTrue(vm.state.value.message is InfraredUiMessage.Info)
assertEquals(1, gateway.sentKeys.size)
job.cancel()
}
@Test
fun sendFailurePreservesReadyStatus() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206"))
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
val fetched = vm.state.value.keys.single()
assertEquals(InfraredKeyStatus.READY, fetched.status)
assertTrue(vm.state.value.message is InfraredUiMessage.Error)
job.cancel()
}
@Test
fun setModeBlockedWhileLearningIsActive() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
advanceUntilIdle()
assertTrue(vm.state.value.hasActiveLearning)
vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK)
advanceUntilIdle()
assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode)
assertTrue(vm.state.value.message is InfraredUiMessage.Error)
job.cancel()
}
}
+1
View File
@@ -6,3 +6,4 @@ plugins {
alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt.android) apply false alias(libs.plugins.hilt.android) apply false
} }
@@ -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` 安全阻断。
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null,
): DomainResult<SavedInfraredKey> { ): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim() val normalizedName = displayName.trim()
if ( if (
@@ -39,6 +40,20 @@ class InfraredKeyFactory(
?: return DomainResult.Failure( ?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key id is invalid"), 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() val now = clock.now()
return DomainResult.Success( return DomainResult.Success(
SavedInfraredKey( SavedInfraredKey(
@@ -46,7 +61,7 @@ class InfraredKeyFactory(
profileId = profileId, profileId = profileId,
deviceName = deviceName, deviceName = deviceName,
displayName = normalizedName, displayName = normalizedName,
sdkKey = "app_ir_$canonicalId", sdkKey = finalSdkKey,
status = InfraredKeyStatus.LEARNING, status = InfraredKeyStatus.LEARNING,
lastErrorCode = null, lastErrorCode = null,
lastErrorDetail = null, lastErrorDetail = null,
@@ -16,8 +16,9 @@ class LearnInfraredKey(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null,
): DomainResult<InfraredKeyId> { ): 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.Success -> result.value
is DomainResult.Failure -> return result is DomainResult.Failure -> return result
} }
@@ -32,6 +32,25 @@ class InfraredKeyFactoryTest {
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) 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 @Test
fun rejectsBlankDeviceOrDisplayName() { fun rejectsBlankDeviceOrDisplayName() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
+1
View File
@@ -3,3 +3,4 @@ org.gradle.configuration-cache=false
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.code.style=official kotlin.code.style=official
android.overridePathCheck=true
+3
View File
@@ -17,6 +17,7 @@ espresso = "3.6.1"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
@@ -32,6 +33,8 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
hilt-compiler = { module = "com.google.dagger:hilt-compiler", 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-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", 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" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
+1
View File
@@ -26,4 +26,5 @@ include(":domain")
include(":absdk-bridge") include(":absdk-bridge")
include(":testing:fakes") include(":testing:fakes")
include(":android:core:designsystem") include(":android:core:designsystem")
include(":android:feature:infrared")
include(":android:app") 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.SocketStatus
import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TemperatureHumidityReading import com.flagship.abox.manager.domain.TemperatureHumidityReading
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class FakeSocketGateway : SocketGateway { class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>() private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
@@ -78,10 +78,9 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
} }
class FakeInfraredGateway : InfraredGateway { class FakeInfraredGateway : InfraredGateway {
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf()) val sentKeys = CopyOnWriteArrayList<InfraredCall>()
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf()) val learnedKeys = CopyOnWriteArrayList<InfraredCall>()
val downloadCalls: MutableList<InfraredDownloadCall> = val downloadCalls = CopyOnWriteArrayList<InfraredDownloadCall>()
Collections.synchronizedList(mutableListOf())
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>() private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>() private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
@@ -134,9 +133,12 @@ class FakeInfraredGateway : InfraredGateway {
profileId: ProviderProfileId, profileId: ProviderProfileId,
codes: List<InfraredCode>, codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> { ): DomainResult<List<InfraredCodeDownloadResult>> {
val codesSnapshot = codes.toList()
downloadError?.let { return DomainResult.Failure(it) } downloadError?.let { return DomainResult.Failure(it) }
downloadCalls += InfraredDownloadCall(profileId, codes) downloadCalls += InfraredDownloadCall(profileId, codesSnapshot)
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) return DomainResult.Success(
codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) },
)
} }
} }
@@ -4,8 +4,14 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode 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.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState 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 kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -55,6 +61,69 @@ class FakeDeviceGatewaysTest {
assertTrue(download is DomainResult.Success) 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 @Test
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest { fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
val gateway = FakeInfraredGateway() val gateway = FakeInfraredGateway()