Compare commits
10
Commits
c21ae4e0d9
...
0620b9f6c8
| 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.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class AbsdkDeviceBridgeTest {
|
class AbsdkDeviceBridgeTest {
|
||||||
private val profile = ProviderProfileId("abox")
|
private val profile = ProviderProfileId("abox")
|
||||||
private val socket = DeviceName("socket-1")
|
private val socket = DeviceName("socket-1")
|
||||||
|
private val irDevice = DeviceName("ir-room")
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun socketControlReturnsConfirmedStatus() = runTest {
|
fun socketControlReturnsConfirmedStatus() = runTest {
|
||||||
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
|
|||||||
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
|
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredSendForwardsDeviceAndStableKey() = runTest {
|
||||||
|
val client = ScriptedAbsdkClient()
|
||||||
|
val bridge = authenticatedBridge(client)
|
||||||
|
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
|
||||||
|
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
|
||||||
|
val client = ScriptedAbsdkClient()
|
||||||
|
val bridge = authenticatedBridge(client)
|
||||||
|
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
|
||||||
|
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
|
||||||
|
val errorMappings = mapOf<String, KClass<out DomainError>>(
|
||||||
|
"20201" to DomainError.InvalidRequest::class,
|
||||||
|
"20202" to DomainError.InvalidRequest::class,
|
||||||
|
"20203" to DomainError.DeviceNotFound::class,
|
||||||
|
"20204" to DomainError.OperationFailed::class,
|
||||||
|
"20205" to DomainError.OperationFailed::class,
|
||||||
|
"20206" to DomainError.DeviceOffline::class,
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((code, expectedClass) in errorMappings) {
|
||||||
|
val client = ScriptedAbsdkClient().apply {
|
||||||
|
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
|
||||||
|
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
|
||||||
|
}
|
||||||
|
val bridge = authenticatedBridge(client)
|
||||||
|
|
||||||
|
val sendResult = bridge.sendKey(profile, irDevice, "key1")
|
||||||
|
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
|
||||||
|
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
|
||||||
|
assertEquals(code, sendResult.error.externalCode)
|
||||||
|
|
||||||
|
val learnResult = bridge.learnKey(profile, irDevice, "key1")
|
||||||
|
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
|
||||||
|
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
|
||||||
|
assertEquals(code, learnResult.error.externalCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
|
||||||
|
val client = ScriptedAbsdkClient().apply {
|
||||||
|
sendResponse = null
|
||||||
|
learnResponse = null
|
||||||
|
}
|
||||||
|
val bridge = authenticatedBridge(client)
|
||||||
|
|
||||||
|
val sendResult = bridge.sendKey(profile, irDevice, "key1")
|
||||||
|
assertTrue(sendResult is DomainResult.Failure)
|
||||||
|
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
|
||||||
|
|
||||||
|
val learnResult = bridge.learnKey(profile, irDevice, "key1")
|
||||||
|
assertTrue(learnResult is DomainResult.Failure)
|
||||||
|
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
|
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
|
||||||
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
|
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
|
||||||
|
|||||||
+34
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
|
|||||||
assertEquals(1, client.maxConcurrentCalls.get())
|
assertEquals(1, client.maxConcurrentCalls.get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
|
||||||
|
withTimeout(5_000) {
|
||||||
|
val profile = ProviderProfileId("abox")
|
||||||
|
val client = ScriptedAbsdkClient().apply {
|
||||||
|
socketStatusResponses = ArrayDeque(
|
||||||
|
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val bridge = AbsdkBridge(client, Dispatchers.IO)
|
||||||
|
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
|
||||||
|
|
||||||
|
client.onCall = {
|
||||||
|
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
|
||||||
|
Thread.sleep(75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val irCall = async {
|
||||||
|
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
|
||||||
|
}
|
||||||
|
val tempCall = async {
|
||||||
|
bridge.getReading(profile, DeviceName("th-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(10)
|
||||||
|
|
||||||
|
irCall.await()
|
||||||
|
tempCall.await()
|
||||||
|
|
||||||
|
assertEquals(1, client.maxConcurrentCalls.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
excludes += "/META-INF/DEPENDENCIES"
|
||||||
|
excludes += "/META-INF/LICENSE*"
|
||||||
|
excludes += "/META-INF/NOTICE*"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kapt {
|
kapt {
|
||||||
@@ -54,7 +63,9 @@ kapt {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
|
implementation(project(":android:feature:infrared"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
|
debugImplementation(project(":absdk-bridge"))
|
||||||
|
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -68,6 +79,7 @@ dependencies {
|
|||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(platform(libs.compose.bom))
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.espresso.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.flagship.abox.manager.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.MainActivity
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ABoxManagerAppTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun opensInfraredControlScreenFromFoundationScreen() {
|
||||||
|
composeTestRule.onNodeWithText("Infrared Control").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<application android:usesCleartextTraffic="true" />
|
||||||
|
</manifest>
|
||||||
+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
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
|
||||||
|
import com.flagship.abox.manager.absdk.bridge.CleartextAbsdkTransport
|
||||||
import com.flagship.abox.manager.domain.DeviceCatalog
|
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
import com.flagship.abox.manager.domain.SessionGateway
|
import com.flagship.abox.manager.domain.SessionGateway
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
|
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
|
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
|
||||||
@@ -21,7 +24,9 @@ import javax.inject.Singleton
|
|||||||
object FoundationModule {
|
object FoundationModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
|
fun provideSessionGateway(
|
||||||
|
@OptIn(CleartextAbsdkTransport::class) real: AbsdkBridge,
|
||||||
|
): SessionGateway = real
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -38,5 +43,18 @@ object FoundationModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway()
|
fun provideFakeInfraredGateway(): FakeInfraredGateway = FakeInfraredGateway()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@OptIn(CleartextAbsdkTransport::class)
|
||||||
|
fun provideRealAbsdkBridge(): AbsdkBridge = AbsdkBridge.create()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideInfraredGateway(
|
||||||
|
modeStore: InfraredRuntimeModeStore,
|
||||||
|
fake: FakeInfraredGateway,
|
||||||
|
real: AbsdkBridge,
|
||||||
|
): InfraredGateway = DebugRoutingInfraredGateway(modeStore, fake, real)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,70 @@ package com.flagship.abox.manager.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||||
|
|
||||||
private const val FOUNDATION_ROUTE = "foundation"
|
private const val FOUNDATION_ROUTE = "foundation"
|
||||||
private const val PLACEHOLDER_ROUTE = "placeholder"
|
private const val PLACEHOLDER_ROUTE = "placeholder"
|
||||||
|
private const val INFRARED_ROUTE = "infrared/{profileId}"
|
||||||
|
private const val TEMPORARY_ABOX_PROFILE_ID = "abox"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxManagerApp() {
|
fun ABoxManagerApp() {
|
||||||
ABoxTheme {
|
ABoxTheme {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
var sessionMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = FOUNDATION_ROUTE,
|
startDestination = FOUNDATION_ROUTE,
|
||||||
) {
|
) {
|
||||||
composable(FOUNDATION_ROUTE) {
|
composable(FOUNDATION_ROUTE) {
|
||||||
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
|
FoundationScreen(
|
||||||
|
sessionInvalidMessage = sessionMessage,
|
||||||
|
onContinue = { navController.navigate(PLACEHOLDER_ROUTE) },
|
||||||
|
onOpenInfrared = {
|
||||||
|
sessionMessage = null
|
||||||
|
navController.navigate("infrared/$TEMPORARY_ABOX_PROFILE_ID")
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(PLACEHOLDER_ROUTE) {
|
composable(PLACEHOLDER_ROUTE) {
|
||||||
MessageScreen(
|
MessageScreen(
|
||||||
@@ -41,29 +73,161 @@ fun ABoxManagerApp() {
|
|||||||
description = stringResource(R.string.placeholder_description),
|
description = stringResource(R.string.placeholder_description),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(
|
||||||
|
route = INFRARED_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val sessionInvalidText = stringResource(R.string.infrared_session_invalid_message)
|
||||||
|
InfraredControlRoute(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onSessionInvalid = {
|
||||||
|
sessionMessage = sessionInvalidText
|
||||||
|
navController.popBackStack(FOUNDATION_ROUTE, inclusive = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FoundationScreen(onContinue: () -> Unit) {
|
private fun FoundationScreen(
|
||||||
|
sessionInvalidMessage: String?,
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
onOpenInfrared: () -> Unit,
|
||||||
|
viewModel: FoundationViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
MessageScreen(
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
title = stringResource(R.string.foundation_title),
|
Text(
|
||||||
description = stringResource(R.string.foundation_description),
|
text = stringResource(R.string.foundation_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = sessionInvalidMessage ?: stringResource(R.string.foundation_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (sessionInvalidMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 临时网关登录卡片
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ABox 网关登录 (Real 模式前置)",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
if (state.session != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::logout,
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
) {
|
||||||
|
Text("退出")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.session == null) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.usernameInput,
|
||||||
|
onValueChange = viewModel::onUsernameChanged,
|
||||||
|
label = { Text("账号 (默认 a)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.passwordInput,
|
||||||
|
onValueChange = viewModel::onPasswordChanged,
|
||||||
|
label = { Text("密码 (默认 a)") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
ABoxButton(
|
||||||
|
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(
|
ABoxButton(
|
||||||
|
onClick = onOpenInfrared,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.infrared_control_action))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
onClick = onContinue,
|
onClick = onContinue,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 24.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.continue_action))
|
Text(stringResource(R.string.continue_action))
|
||||||
}
|
}
|
||||||
@@ -82,12 +246,12 @@ private fun MessageScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.flagship.abox.manager.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.LoginCredentials
|
||||||
|
import com.flagship.abox.manager.domain.Password
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Session
|
||||||
|
import com.flagship.abox.manager.domain.SessionGateway
|
||||||
|
import com.flagship.abox.manager.domain.Username
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class FoundationUiState(
|
||||||
|
val usernameInput: String = "a",
|
||||||
|
val passwordInput: String = "a",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val session: Session? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val successMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FoundationViewModel @Inject constructor(
|
||||||
|
private val sessionGateway: SessionGateway,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val profileId = ProviderProfileId("abox")
|
||||||
|
private val _uiState = MutableStateFlow(FoundationUiState())
|
||||||
|
val uiState: StateFlow<FoundationUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionGateway.observeSession(profileId).collect { session ->
|
||||||
|
_uiState.value = _uiState.value.copy(session = session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUsernameChanged(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(usernameInput = value, errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChanged(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(passwordInput = value, errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login() {
|
||||||
|
val username = _uiState.value.usernameInput.trim()
|
||||||
|
val password = _uiState.value.passwordInput.trim()
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = "用户名和密码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, successMessage = null)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = sessionGateway.login(
|
||||||
|
profileId = profileId,
|
||||||
|
credentials = LoginCredentials(Username(username), Password(password)),
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
session = result.value,
|
||||||
|
successMessage = "登录成功!已获取 ABox 会话 Token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
val detail = when (val error = result.error) {
|
||||||
|
is DomainError.Authentication -> "认证失败 (错误码: ${error.externalCode ?: "20004"}),请检查账号密码"
|
||||||
|
is DomainError.Network -> "网络连接失败: ${error.detail ?: "请确认已连接网关 WiFi"}"
|
||||||
|
is DomainError.TokenInvalid -> "Token 无效"
|
||||||
|
else -> error.detail ?: "登录失败 (${error.externalCode ?: "unknown"})"
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionGateway.logout(profileId)
|
||||||
|
_uiState.value = _uiState.value.copy(session = null, successMessage = "已退出登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMessage() {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = null, successMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,6 @@
|
|||||||
<string name="continue_action">继续</string>
|
<string name="continue_action">继续</string>
|
||||||
<string name="placeholder_title">功能模块可以开始开发</string>
|
<string name="placeholder_title">功能模块可以开始开发</string>
|
||||||
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
|
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
|
||||||
|
<string name="infrared_control_action">红外控制</string>
|
||||||
|
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,4 +5,6 @@
|
|||||||
<string name="continue_action">Continue</string>
|
<string name="continue_action">Continue</string>
|
||||||
<string name="placeholder_title">Feature development can start</string>
|
<string name="placeholder_title">Feature development can start</string>
|
||||||
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
|
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
|
||||||
|
<string name="infrared_control_action">Infrared Control</string>
|
||||||
|
<string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredCode
|
||||||
|
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object InfraredReleaseModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideInfraredGateway(): InfraredGateway = object : InfraredGateway {
|
||||||
|
override suspend fun sendKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> = disabledFailure()
|
||||||
|
|
||||||
|
override suspend fun learnKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> = disabledFailure()
|
||||||
|
|
||||||
|
override suspend fun downloadCodes(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
codes: List<InfraredCode>,
|
||||||
|
): DomainResult<List<InfraredCodeDownloadResult>> = disabledFailure()
|
||||||
|
|
||||||
|
private fun <T> disabledFailure(): DomainResult<T> = DomainResult.Failure(
|
||||||
|
DomainError.OperationFailed(
|
||||||
|
externalCode = "REAL_ABSDK_DISABLED",
|
||||||
|
detail = "Infrared hardware transport is disabled in release builds",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.kotlin.kapt) apply false
|
||||||
alias(libs.plugins.hilt.android) apply false
|
alias(libs.plugins.hilt.android) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 红外控制硬件验证规范与记录 / Infrared Hardware Validation Guide & Records
|
||||||
|
|
||||||
|
## 1. 硬件环境与已知设备信息 / Hardware Environment
|
||||||
|
|
||||||
|
本阶段用于真实验证的实体设备信息如下:
|
||||||
|
|
||||||
|
- **设备名称**: 红外控制器 (例如 `红外控制器` 或厂商平台绑定的 `irDevName`)
|
||||||
|
- **IEEE 地址**: `1c1cfd001dba533c` (短地址: `d822`)
|
||||||
|
- **信号强度 (LQI/RSSI)**: `-4`
|
||||||
|
- **电量**: `100%`
|
||||||
|
- **设备类型**: 红外控制器 (Infrared Controller)
|
||||||
|
- **网关/主机**: ABox 智能主机
|
||||||
|
- **被控实体**: 实体空调 / 电视遥控器及对应电器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 硬件测试记录表 / Hardware Validation Log
|
||||||
|
|
||||||
|
| 日期 (Date) | App Commit | 运行模式 (Mode) | Profile ID | 设备名称 (Device Name) | SDK Key | 操作 (Operation) | 耗时 (ms) | ABSDK Code | 脱敏消息 (Message) | 物理响应 (Physical Response) | 结论 (Result) |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Learn | 120 | 00000 | success | 模拟学习完成并入库 READY | PASS (Fake) |
|
||||||
|
| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Send | 45 | 00000 | success | 模拟发射成功 | PASS (Fake) |
|
||||||
|
| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Learn | - | - | - | 遥控器对准控制器按下目标按键 | 待真机执行 |
|
||||||
|
| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Send | - | - | - | 实体电器执行开关/调节响应 | 待真机执行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure
|
||||||
|
|
||||||
|
### 用例 1: 真实会话认证前置 (Authenticate Shared Bridge)
|
||||||
|
- **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。
|
||||||
|
- **步骤**: 检查进入红外控制页面前,会话 Token 是否有效。
|
||||||
|
- **预期**: 若未认证,进入 Real 模式操作时返回 `TokenInvalid` 并提示返回登录。
|
||||||
|
|
||||||
|
### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName)
|
||||||
|
- **步骤**: 在“红外设备名称”输入框中输入 `红外控制器` 或后台分配的真实设备名,点击“保存设备名”。
|
||||||
|
- **预期**: 本地 Room 保存成功,页面显示“已保存设备:红外控制器”,启用“学习新按键”入口。
|
||||||
|
|
||||||
|
### 用例 3: 学习全新唯一按键 (Learn Unique Key)
|
||||||
|
- **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。
|
||||||
|
- **预期**: 本地先写入 `LEARNING` 状态,硬件捕获完成后状态自动刷新为 `READY`,记录响应耗时与返回码。
|
||||||
|
|
||||||
|
### 用例 4: 立即发射已学习按键 (Send Learned Key Immediately)
|
||||||
|
- **步骤**: 学习成功后,点击该按键右侧的“发射”按钮。
|
||||||
|
- **预期**: ABSDK 返回 `00000`,实体电器执行对应操作,发射期间按钮防重复点击。
|
||||||
|
|
||||||
|
### 用例 5: 重复发射验证稳定性 (Repeat Send)
|
||||||
|
- **步骤**: 间隔 3 秒再次点击同一按键的“发射”按钮。
|
||||||
|
- **预期**: 发射稳定成功,电器再次响应,按键保持 `READY` 状态不变。
|
||||||
|
|
||||||
|
### 用例 6: 重新学习同一 SDK Key (Relearn Same Key)
|
||||||
|
- **步骤**: 针对已存在或失败的按键点击“重试”/重新学习。
|
||||||
|
- **预期**: 复用相同的 `InfraredKeyId` 和 `sdkKey`,先变更为 `LEARNING`,学习完成后恢复 `READY`,不生成冗余记录。
|
||||||
|
|
||||||
|
### 用例 7: 控制器断电/离线测试 (Controller Offline)
|
||||||
|
- **步骤**: 拔掉红外控制器电源,发起学习或发射。
|
||||||
|
- **预期**: ABSDK 返回 `20206`,领域层转换为 `DomainError.DeviceOffline`,学习按键标为 `FAILED`,UI 提示控制器离线。
|
||||||
|
|
||||||
|
### 用例 8: 遥控器无信号/超时测试 (No Signal / Learning Timeout)
|
||||||
|
- **步骤**: 点击“开始学习”,但不按物理遥控器任何按键,等待 SDK 返回。
|
||||||
|
- **预期**: 记录实际超时等待时间与错误码(如 `20204`/`20205`),按键转换为 `FAILED` 并允许重试。
|
||||||
|
|
||||||
|
### 用例 9: 学习过程中离开页面 (Leave Page During Learning)
|
||||||
|
- **步骤**: 点击开始学习后,立即点击“返回”离开红外页面;稍后重新进入。
|
||||||
|
- **预期**: Application 协程作用域内任务继续执行,学习完成后 Room 数据库正确持久化结果,重新进入显示最新状态。
|
||||||
|
|
||||||
|
### 用例 10: 进程被杀死后状态恢复 (Process Interruption Recovery)
|
||||||
|
- **步骤**: 在 `LEARNING` 状态下强制杀死 App 进程并重启。
|
||||||
|
- **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。
|
||||||
|
|
||||||
|
### 用例 11: Token 失效与重新登录 (Token Expiration)
|
||||||
|
- **步骤**: 主机端使 Token 失效或超过 2 小时后发起红外操作。
|
||||||
|
- **预期**: 返回 `TokenInvalid`,本地会话被清除,页面提示登录失效并导航返回。
|
||||||
|
|
||||||
|
### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields)
|
||||||
|
- **步骤**: 在调试构建中检查 `ABRet.getDicDatas()` 是否包含未文档化字段。
|
||||||
|
- **安全约束**: 严禁将 Raw Payload、Token、密码或未脱敏数据打印至生产日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 协议与硬件事实确认记录 / Observed Facts
|
||||||
|
|
||||||
|
- **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。
|
||||||
|
- **数据隔离**: `FAKE` 模式自动使用 `profileId:infrared-fake` 隔离数据,`REAL_ABSDK` 模式使用主会话数据。
|
||||||
|
- **安全门禁**: 真实 ABSDK 依赖明文传输,Release 构建中完全禁用硬件接口并由 `InfraredReleaseModule` 安全阻断。
|
||||||
+16
-1
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
|
|||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
sdkKey: String? = null,
|
||||||
): DomainResult<SavedInfraredKey> {
|
): DomainResult<SavedInfraredKey> {
|
||||||
val normalizedName = displayName.trim()
|
val normalizedName = displayName.trim()
|
||||||
if (
|
if (
|
||||||
@@ -39,6 +40,20 @@ class InfraredKeyFactory(
|
|||||||
?: return DomainResult.Failure(
|
?: return DomainResult.Failure(
|
||||||
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val finalSdkKey = if (sdkKey != null) {
|
||||||
|
val trimmed = sdkKey.trim()
|
||||||
|
val intVal = trimmed.toIntOrNull()
|
||||||
|
if (intVal == null || intVal !in 1..100) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
intVal.toString()
|
||||||
|
} else {
|
||||||
|
"app_ir_$canonicalId"
|
||||||
|
}
|
||||||
|
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
return DomainResult.Success(
|
return DomainResult.Success(
|
||||||
SavedInfraredKey(
|
SavedInfraredKey(
|
||||||
@@ -46,7 +61,7 @@ class InfraredKeyFactory(
|
|||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
displayName = normalizedName,
|
displayName = normalizedName,
|
||||||
sdkKey = "app_ir_$canonicalId",
|
sdkKey = finalSdkKey,
|
||||||
status = InfraredKeyStatus.LEARNING,
|
status = InfraredKeyStatus.LEARNING,
|
||||||
lastErrorCode = null,
|
lastErrorCode = null,
|
||||||
lastErrorDetail = null,
|
lastErrorDetail = null,
|
||||||
|
|||||||
+2
-1
@@ -16,8 +16,9 @@ class LearnInfraredKey(
|
|||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
sdkKey: String? = null,
|
||||||
): DomainResult<InfraredKeyId> {
|
): DomainResult<InfraredKeyId> {
|
||||||
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
|
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
|
||||||
is DomainResult.Success -> result.value
|
is DomainResult.Success -> result.value
|
||||||
is DomainResult.Failure -> return result
|
is DomainResult.Failure -> return result
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -32,6 +32,25 @@ class InfraredKeyFactoryTest {
|
|||||||
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
|
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createsLearningKeyWithCustomNumericSlot() {
|
||||||
|
val result = factory.create(
|
||||||
|
ProviderProfileId("abox"),
|
||||||
|
DeviceName("ir-1"),
|
||||||
|
"空调开",
|
||||||
|
sdkKey = "1",
|
||||||
|
) as DomainResult.Success
|
||||||
|
|
||||||
|
assertEquals("1", result.value.sdkKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsInvalidNumericSlots() {
|
||||||
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure)
|
||||||
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure)
|
||||||
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun rejectsBlankDeviceOrDisplayName() {
|
fun rejectsBlankDeviceOrDisplayName() {
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ org.gradle.configuration-cache=false
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
android.overridePathCheck=true
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ espresso = "3.6.1"
|
|||||||
[libraries]
|
[libraries]
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
|
||||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
@@ -32,6 +33,8 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
|
|||||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
|
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||||
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ include(":domain")
|
|||||||
include(":absdk-bridge")
|
include(":absdk-bridge")
|
||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
|
include(":android:feature:infrared")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
+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.SocketStatus
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
||||||
import java.util.Collections
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
class FakeSocketGateway : SocketGateway {
|
class FakeSocketGateway : SocketGateway {
|
||||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||||
@@ -78,10 +78,9 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeInfraredGateway : InfraredGateway {
|
class FakeInfraredGateway : InfraredGateway {
|
||||||
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
val sentKeys = CopyOnWriteArrayList<InfraredCall>()
|
||||||
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
val learnedKeys = CopyOnWriteArrayList<InfraredCall>()
|
||||||
val downloadCalls: MutableList<InfraredDownloadCall> =
|
val downloadCalls = CopyOnWriteArrayList<InfraredDownloadCall>()
|
||||||
Collections.synchronizedList(mutableListOf())
|
|
||||||
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||||
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||||
|
|
||||||
@@ -134,9 +133,12 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
codes: List<InfraredCode>,
|
codes: List<InfraredCode>,
|
||||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
): DomainResult<List<InfraredCodeDownloadResult>> {
|
||||||
|
val codesSnapshot = codes.toList()
|
||||||
downloadError?.let { return DomainResult.Failure(it) }
|
downloadError?.let { return DomainResult.Failure(it) }
|
||||||
downloadCalls += InfraredDownloadCall(profileId, codes)
|
downloadCalls += InfraredDownloadCall(profileId, codesSnapshot)
|
||||||
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
return DomainResult.Success(
|
||||||
|
codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.DomainError
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.InfraredCode
|
import com.flagship.abox.manager.domain.InfraredCode
|
||||||
|
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -55,6 +61,69 @@ class FakeDeviceGatewaysTest {
|
|||||||
assertTrue(download is DomainResult.Success)
|
assertTrue(download is DomainResult.Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredDownloadRecordsAndReturnsAnInputSnapshot() = runTest {
|
||||||
|
val gateway = FakeInfraredGateway()
|
||||||
|
val code = InfraredCode(DeviceName("ir-1"), "power", "code-value")
|
||||||
|
val mutableCodes = mutableListOf(code)
|
||||||
|
|
||||||
|
val result = gateway.downloadCodes(profileOne, mutableCodes)
|
||||||
|
mutableCodes.clear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
InfraredDownloadCall(profileOne, listOf(code)),
|
||||||
|
gateway.downloadCalls.single(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DomainResult.Success(listOf(InfraredCodeDownloadResult(code, successful = true))),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun infraredCallLogCanBeIteratedSafelyAroundConcurrentWrites() = runTest {
|
||||||
|
val gateway = FakeInfraredGateway()
|
||||||
|
val deviceName = DeviceName("ir-1")
|
||||||
|
gateway.sendKey(profileOne, deviceName, "initial")
|
||||||
|
val staleIterators = List(10) { gateway.sentKeys.iterator() }
|
||||||
|
val start = CountDownLatch(1)
|
||||||
|
val failures = ConcurrentLinkedQueue<Throwable>()
|
||||||
|
val writerCount = 4
|
||||||
|
val callsPerWriter = 100
|
||||||
|
|
||||||
|
val writers = List(writerCount) { writerIndex ->
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
start.await()
|
||||||
|
repeat(callsPerWriter) { callIndex ->
|
||||||
|
gateway.sendKey(profileOne, deviceName, "$writerIndex-$callIndex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val reader = launch(Dispatchers.Default) {
|
||||||
|
start.await()
|
||||||
|
repeat(100) {
|
||||||
|
try {
|
||||||
|
gateway.sentKeys.forEach { call -> call.key.length }
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
failures += failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start.countDown()
|
||||||
|
(writers + reader).joinAll()
|
||||||
|
staleIterators.forEach { iterator ->
|
||||||
|
try {
|
||||||
|
iterator.forEachRemaining { call -> call.key.length }
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
failures += failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(failures.toList().joinToString(), failures.isEmpty())
|
||||||
|
assertEquals(1 + writerCount * callsPerWriter, gateway.sentKeys.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
|
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
|
||||||
val gateway = FakeInfraredGateway()
|
val gateway = FakeInfraredGateway()
|
||||||
|
|||||||
Reference in New Issue
Block a user