Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0620b9f6c8
|
||
|
|
94ee2d393d
|
||
|
|
b27c67fbe4
|
||
|
|
4043d9f0a6
|
||
|
|
26ff049601
|
||
|
|
3952f9f324
|
||
|
|
0004a3bf86
|
||
|
|
0f17b21a59
|
||
|
|
be8d45eeba
|
||
|
|
bdd3779899
|
+65
@@ -17,10 +17,12 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class AbsdkDeviceBridgeTest {
|
||||
private val profile = ProviderProfileId("abox")
|
||||
private val socket = DeviceName("socket-1")
|
||||
private val irDevice = DeviceName("ir-room")
|
||||
|
||||
@Test
|
||||
fun socketControlReturnsConfirmedStatus() = runTest {
|
||||
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
|
||||
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredSendForwardsDeviceAndStableKey() = runTest {
|
||||
val client = ScriptedAbsdkClient()
|
||||
val bridge = authenticatedBridge(client)
|
||||
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
|
||||
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
|
||||
val client = ScriptedAbsdkClient()
|
||||
val bridge = authenticatedBridge(client)
|
||||
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
|
||||
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
|
||||
val errorMappings = mapOf<String, KClass<out DomainError>>(
|
||||
"20201" to DomainError.InvalidRequest::class,
|
||||
"20202" to DomainError.InvalidRequest::class,
|
||||
"20203" to DomainError.DeviceNotFound::class,
|
||||
"20204" to DomainError.OperationFailed::class,
|
||||
"20205" to DomainError.OperationFailed::class,
|
||||
"20206" to DomainError.DeviceOffline::class,
|
||||
)
|
||||
|
||||
for ((code, expectedClass) in errorMappings) {
|
||||
val client = ScriptedAbsdkClient().apply {
|
||||
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
|
||||
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
|
||||
}
|
||||
val bridge = authenticatedBridge(client)
|
||||
|
||||
val sendResult = bridge.sendKey(profile, irDevice, "key1")
|
||||
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
|
||||
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
|
||||
assertEquals(code, sendResult.error.externalCode)
|
||||
|
||||
val learnResult = bridge.learnKey(profile, irDevice, "key1")
|
||||
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
|
||||
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
|
||||
assertEquals(code, learnResult.error.externalCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
|
||||
val client = ScriptedAbsdkClient().apply {
|
||||
sendResponse = null
|
||||
learnResponse = null
|
||||
}
|
||||
val bridge = authenticatedBridge(client)
|
||||
|
||||
val sendResult = bridge.sendKey(profile, irDevice, "key1")
|
||||
assertTrue(sendResult is DomainResult.Failure)
|
||||
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
|
||||
|
||||
val learnResult = bridge.learnKey(profile, irDevice, "key1")
|
||||
assertTrue(learnResult is DomainResult.Failure)
|
||||
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
|
||||
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
|
||||
|
||||
+34
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
|
||||
assertEquals(1, client.maxConcurrentCalls.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
|
||||
withTimeout(5_000) {
|
||||
val profile = ProviderProfileId("abox")
|
||||
val client = ScriptedAbsdkClient().apply {
|
||||
socketStatusResponses = ArrayDeque(
|
||||
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
|
||||
)
|
||||
}
|
||||
val bridge = AbsdkBridge(client, Dispatchers.IO)
|
||||
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
|
||||
|
||||
client.onCall = {
|
||||
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
|
||||
Thread.sleep(75)
|
||||
}
|
||||
}
|
||||
|
||||
val irCall = async {
|
||||
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
|
||||
}
|
||||
val tempCall = async {
|
||||
bridge.getReading(profile, DeviceName("th-1"))
|
||||
}
|
||||
|
||||
delay(10)
|
||||
|
||||
irCall.await()
|
||||
tempCall.await()
|
||||
|
||||
assertEquals(1, client.maxConcurrentCalls.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
excludes += "/META-INF/DEPENDENCIES"
|
||||
excludes += "/META-INF/LICENSE*"
|
||||
excludes += "/META-INF/NOTICE*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
@@ -54,7 +63,9 @@ kapt {
|
||||
dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":android:core:designsystem"))
|
||||
implementation(project(":android:feature:infrared"))
|
||||
debugImplementation(project(":testing:fakes"))
|
||||
debugImplementation(project(":absdk-bridge"))
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -68,6 +79,7 @@ dependencies {
|
||||
kapt(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
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>
|
||||
+41
@@ -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
|
||||
|
||||
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.InfraredGateway
|
||||
import com.flagship.abox.manager.domain.SessionGateway
|
||||
import com.flagship.abox.manager.domain.SocketGateway
|
||||
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.FakeInfraredGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
|
||||
@@ -21,7 +24,9 @@ import javax.inject.Singleton
|
||||
object FoundationModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
|
||||
fun provideSessionGateway(
|
||||
@OptIn(CleartextAbsdkTransport::class) real: AbsdkBridge,
|
||||
): SessionGateway = real
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -38,5 +43,18 @@ object FoundationModule {
|
||||
|
||||
@Provides
|
||||
@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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||
|
||||
private const val FOUNDATION_ROUTE = "foundation"
|
||||
private const val PLACEHOLDER_ROUTE = "placeholder"
|
||||
private const val INFRARED_ROUTE = "infrared/{profileId}"
|
||||
private const val TEMPORARY_ABOX_PROFILE_ID = "abox"
|
||||
|
||||
@Composable
|
||||
fun ABoxManagerApp() {
|
||||
ABoxTheme {
|
||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||
val navController = rememberNavController()
|
||||
var sessionMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = 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) {
|
||||
MessageScreen(
|
||||
@@ -41,29 +73,161 @@ fun ABoxManagerApp() {
|
||||
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
|
||||
private fun FoundationScreen(onContinue: () -> Unit) {
|
||||
private fun FoundationScreen(
|
||||
sessionInvalidMessage: String?,
|
||||
onContinue: () -> Unit,
|
||||
onOpenInfrared: () -> Unit,
|
||||
viewModel: FoundationViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
MessageScreen(
|
||||
title = stringResource(R.string.foundation_title),
|
||||
description = stringResource(R.string.foundation_description),
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.foundation_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
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(
|
||||
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,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.continue_action))
|
||||
}
|
||||
@@ -82,12 +246,12 @@ private fun MessageScreen(
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.flagship.abox.manager.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.LoginCredentials
|
||||
import com.flagship.abox.manager.domain.Password
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.Session
|
||||
import com.flagship.abox.manager.domain.SessionGateway
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class FoundationUiState(
|
||||
val usernameInput: String = "a",
|
||||
val passwordInput: String = "a",
|
||||
val isLoading: Boolean = false,
|
||||
val session: Session? = null,
|
||||
val errorMessage: String? = null,
|
||||
val successMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FoundationViewModel @Inject constructor(
|
||||
private val sessionGateway: SessionGateway,
|
||||
) : ViewModel() {
|
||||
|
||||
private val profileId = ProviderProfileId("abox")
|
||||
private val _uiState = MutableStateFlow(FoundationUiState())
|
||||
val uiState: StateFlow<FoundationUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionGateway.observeSession(profileId).collect { session ->
|
||||
_uiState.value = _uiState.value.copy(session = session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onUsernameChanged(value: String) {
|
||||
_uiState.value = _uiState.value.copy(usernameInput = value, errorMessage = null)
|
||||
}
|
||||
|
||||
fun onPasswordChanged(value: String) {
|
||||
_uiState.value = _uiState.value.copy(passwordInput = value, errorMessage = null)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val username = _uiState.value.usernameInput.trim()
|
||||
val password = _uiState.value.passwordInput.trim()
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "用户名和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, successMessage = null)
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = sessionGateway.login(
|
||||
profileId = profileId,
|
||||
credentials = LoginCredentials(Username(username), Password(password)),
|
||||
)
|
||||
when (result) {
|
||||
is DomainResult.Success -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
session = result.value,
|
||||
successMessage = "登录成功!已获取 ABox 会话 Token",
|
||||
)
|
||||
}
|
||||
is DomainResult.Failure -> {
|
||||
val detail = when (val error = result.error) {
|
||||
is DomainError.Authentication -> "认证失败 (错误码: ${error.externalCode ?: "20004"}),请检查账号密码"
|
||||
is DomainError.Network -> "网络连接失败: ${error.detail ?: "请确认已连接网关 WiFi"}"
|
||||
is DomainError.TokenInvalid -> "Token 无效"
|
||||
else -> error.detail ?: "登录失败 (${error.externalCode ?: "unknown"})"
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
sessionGateway.logout(profileId)
|
||||
_uiState.value = _uiState.value.copy(session = null, successMessage = "已退出登录")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = null, successMessage = null)
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,6 @@
|
||||
<string name="continue_action">继续</string>
|
||||
<string name="placeholder_title">功能模块可以开始开发</string>
|
||||
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
|
||||
<string name="infrared_control_action">红外控制</string>
|
||||
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,4 +5,6 @@
|
||||
<string name="continue_action">Continue</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="infrared_control_action">Infrared Control</string>
|
||||
<string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</string>
|
||||
</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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+136
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+175
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+121
@@ -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>
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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)
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
+190
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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,
|
||||
)
|
||||
}
|
||||
+58
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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>
|
||||
}
|
||||
+12
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+466
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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 }
|
||||
}
|
||||
+342
@@ -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>
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+294
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ plugins {
|
||||
alias(libs.plugins.kotlin.kapt) 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` 安全阻断。
|
||||
+17
-2
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
displayName: String,
|
||||
sdkKey: String? = null,
|
||||
): DomainResult<SavedInfraredKey> {
|
||||
val normalizedName = displayName.trim()
|
||||
if (
|
||||
@@ -39,6 +40,20 @@ class InfraredKeyFactory(
|
||||
?: return DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
||||
)
|
||||
|
||||
val finalSdkKey = if (sdkKey != null) {
|
||||
val trimmed = sdkKey.trim()
|
||||
val intVal = trimmed.toIntOrNull()
|
||||
if (intVal == null || intVal !in 1..100) {
|
||||
return DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
|
||||
)
|
||||
}
|
||||
intVal.toString()
|
||||
} else {
|
||||
"app_ir_$canonicalId"
|
||||
}
|
||||
|
||||
val now = clock.now()
|
||||
return DomainResult.Success(
|
||||
SavedInfraredKey(
|
||||
@@ -46,7 +61,7 @@ class InfraredKeyFactory(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
displayName = normalizedName,
|
||||
sdkKey = "app_ir_$canonicalId",
|
||||
sdkKey = finalSdkKey,
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
@@ -68,4 +83,4 @@ class InfraredKeyFactory(
|
||||
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,8 +16,9 @@ class LearnInfraredKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
displayName: String,
|
||||
sdkKey: String? = null,
|
||||
): DomainResult<InfraredKeyId> {
|
||||
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
|
||||
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
|
||||
is DomainResult.Success -> result.value
|
||||
is DomainResult.Failure -> return result
|
||||
}
|
||||
|
||||
+20
-1
@@ -32,6 +32,25 @@ class InfraredKeyFactoryTest {
|
||||
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsLearningKeyWithCustomNumericSlot() {
|
||||
val result = factory.create(
|
||||
ProviderProfileId("abox"),
|
||||
DeviceName("ir-1"),
|
||||
"空调开",
|
||||
sdkKey = "1",
|
||||
) as DomainResult.Success
|
||||
|
||||
assertEquals("1", result.value.sdkKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsInvalidNumericSlots() {
|
||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure)
|
||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure)
|
||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBlankDeviceOrDisplayName() {
|
||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
||||
@@ -67,4 +86,4 @@ class InfraredKeyFactoryTest {
|
||||
DeviceName("ir-1"),
|
||||
"Power",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ org.gradle.configuration-cache=false
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
android.overridePathCheck=true
|
||||
|
||||
@@ -17,6 +17,7 @@ espresso = "3.6.1"
|
||||
[libraries]
|
||||
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-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||
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" }
|
||||
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" }
|
||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||
|
||||
@@ -26,4 +26,5 @@ include(":domain")
|
||||
include(":absdk-bridge")
|
||||
include(":testing:fakes")
|
||||
include(":android:core:designsystem")
|
||||
include(":android:feature:infrared")
|
||||
include(":android:app")
|
||||
|
||||
+9
-7
@@ -12,8 +12,8 @@ import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.domain.SocketStatus
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class FakeSocketGateway : SocketGateway {
|
||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||
@@ -78,10 +78,9 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
|
||||
}
|
||||
|
||||
class FakeInfraredGateway : InfraredGateway {
|
||||
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||
val downloadCalls: MutableList<InfraredDownloadCall> =
|
||||
Collections.synchronizedList(mutableListOf())
|
||||
val sentKeys = CopyOnWriteArrayList<InfraredCall>()
|
||||
val learnedKeys = CopyOnWriteArrayList<InfraredCall>()
|
||||
val downloadCalls = CopyOnWriteArrayList<InfraredDownloadCall>()
|
||||
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||
|
||||
@@ -134,9 +133,12 @@ class FakeInfraredGateway : InfraredGateway {
|
||||
profileId: ProviderProfileId,
|
||||
codes: List<InfraredCode>,
|
||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
||||
val codesSnapshot = codes.toList()
|
||||
downloadError?.let { return DomainResult.Failure(it) }
|
||||
downloadCalls += InfraredDownloadCall(profileId, codes)
|
||||
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
||||
downloadCalls += InfraredDownloadCall(profileId, codesSnapshot)
|
||||
return DomainResult.Success(
|
||||
codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
@@ -4,8 +4,14 @@ import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.InfraredCode
|
||||
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -55,6 +61,69 @@ class FakeDeviceGatewaysTest {
|
||||
assertTrue(download is DomainResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredDownloadRecordsAndReturnsAnInputSnapshot() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val code = InfraredCode(DeviceName("ir-1"), "power", "code-value")
|
||||
val mutableCodes = mutableListOf(code)
|
||||
|
||||
val result = gateway.downloadCodes(profileOne, mutableCodes)
|
||||
mutableCodes.clear()
|
||||
|
||||
assertEquals(
|
||||
InfraredDownloadCall(profileOne, listOf(code)),
|
||||
gateway.downloadCalls.single(),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(listOf(InfraredCodeDownloadResult(code, successful = true))),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredCallLogCanBeIteratedSafelyAroundConcurrentWrites() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val deviceName = DeviceName("ir-1")
|
||||
gateway.sendKey(profileOne, deviceName, "initial")
|
||||
val staleIterators = List(10) { gateway.sentKeys.iterator() }
|
||||
val start = CountDownLatch(1)
|
||||
val failures = ConcurrentLinkedQueue<Throwable>()
|
||||
val writerCount = 4
|
||||
val callsPerWriter = 100
|
||||
|
||||
val writers = List(writerCount) { writerIndex ->
|
||||
launch(Dispatchers.Default) {
|
||||
start.await()
|
||||
repeat(callsPerWriter) { callIndex ->
|
||||
gateway.sendKey(profileOne, deviceName, "$writerIndex-$callIndex")
|
||||
}
|
||||
}
|
||||
}
|
||||
val reader = launch(Dispatchers.Default) {
|
||||
start.await()
|
||||
repeat(100) {
|
||||
try {
|
||||
gateway.sentKeys.forEach { call -> call.key.length }
|
||||
} catch (failure: Throwable) {
|
||||
failures += failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
(writers + reader).joinAll()
|
||||
staleIterators.forEach { iterator ->
|
||||
try {
|
||||
iterator.forEachRemaining { call -> call.key.length }
|
||||
} catch (failure: Throwable) {
|
||||
failures += failure
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.toList().joinToString(), failures.isEmpty())
|
||||
assertEquals(1 + writerCount * callsPerWriter, gateway.sentKeys.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
|
||||
Reference in New Issue
Block a user