Compare commits

...
28 Commits
Author SHA1 Message Date
KremeCN ee42d59879 refactor: align infrared feature with provider login architecture and remove legacy temporary login flow 2026-08-25 11:41:06 +08:00
Moler 1fdc9bd376 feat: wire infrared remote screen and settings navigation 2026-08-25 11:41:06 +08:00
Moler 9dc5226d83 feat: build custom remote visual components and Compose panels 2026-08-25 11:40:46 +08:00
Moler 3a79338fb9 feat: implement infrared remote control viewmodel 2026-08-25 11:40:46 +08:00
Moler feab58bf3d feat: define infrared remote scenes and button specifications 2026-08-25 11:40:46 +08:00
Moler 4c8403e45f docs: plan infrared remote control UI implementation 2026-08-25 11:40:46 +08:00
Moler 29de3f2eb5 docs: design infrared remote control multi-scene UI 2026-08-25 11:40:45 +08:00
Moler 78dac5519a feat: support 1-100 numeric infrared key slots and gateway login entry 2026-08-25 11:40:45 +08:00
Moler aec43df775 docs: add infrared hardware validation 2026-08-25 11:40:45 +08:00
Moler 1376702476 test: verify ABSDK infrared contracts 2026-08-25 11:40:45 +08:00
Moler 03a3167a55 feat: add temporary infrared navigation 2026-08-25 11:40:45 +08:00
Moler 980fd38421 feat: route infrared debug hardware modes 2026-08-25 11:40:23 +08:00
Moler 116f8d1b75 feat: add infrared control screen 2026-08-25 11:40:23 +08:00
Moler 6e83a78a81 feat: add infrared control state machine 2026-08-25 11:40:23 +08:00
Moler 9ccbfbc297 feat: coordinate infrared runtime state 2026-08-25 11:40:23 +08:00
Moler ff85b3eb45 feat: persist infrared key bindings 2026-08-25 11:40:23 +08:00
Moler 616d97ca03 fix: snapshot infrared fake calls 2026-08-25 11:40:23 +08:00
Moler 82afee3a1b test: add scriptable infrared fakes 2026-08-25 11:40:23 +08:00
Moler 0e82daaa23 fix: preserve infrared failure context 2026-08-25 11:40:23 +08:00
Moler ee60b75a32 feat: manage saved infrared keys 2026-08-25 11:40:23 +08:00
Moler 274807c2ff feat: orchestrate infrared key learning 2026-08-25 11:40:22 +08:00
Moler 4ff6ef6c43 fix: harden infrared key contracts 2026-08-25 11:40:22 +08:00
Moler f1f00701d4 feat: define infrared key contracts 2026-08-25 11:40:22 +08:00
Moler cd564cbfa1 docs: plan infrared control implementation 2026-08-25 11:40:22 +08:00
Moler d763d5dea7 docs: design infrared learning and sending 2026-08-25 11:40:22 +08:00
alZerNestandKremeCN 249721be33 新增设备列表 Compose 页面 (#8)
# WIP: Add device list Compose screen

## Summary

This MR adds only the reusable Kotlin Jetpack Compose device list screen for my assigned scope.

The screen displays the three ABox device categories:

- Smart socket
- Temperature/humidity sensor
- Infrared controller

This MR intentionally does not modify the app entry point, login flow, app shell routing, or any device detail/control screen. Those parts are handled by other feature branches.

## Changes

- Add `DeviceListScreen`.
- Expose click callbacks for each device category:
  - `onSocketClick`
  - `onTemperatureHumidityClick`
  - `onInfraredClick`
- Update the device-list change log document.
- Remove the earlier out-of-scope temporary device control screens from this branch.

## Scope

Included:

- Device list Compose UI component
- Device category entry UI
- Callback API for future route integration

Not included:

- App entry point changes
- Login flow changes
- Device control navigation graph
- Smart socket control screen
- Temperature/humidity status screen
- Infrared controller screen
- Direct ABSDK calls
- Java Activity classes
- XML layout files

## Integration Notes

The screen is designed to be hosted by the app shell or routing owner later:

```kotlin
DeviceListScreen(
    onSocketClick = { /* route to socket module */ },
    onTemperatureHumidityClick = { /* route to temperature/humidity module */ },
    onInfraredClick = { /* route to infrared controller module */ },
)
```

The current branch keeps these callbacks external so other feature owners can connect their modules without this MR taking ownership of routing.

## Verification

- Built and ran `android.app` locally from Android Studio.
- `:android:app:assembleDebug` completed successfully after trimming this branch to the device-list component scope.

## Notes

`settings.gradle.kts` is intentionally not part of this MR. Local Gradle/JDK/toolchain setup is handled on the developer machine and should not modify the team-provided project configuration.

---

# WIP: 新增设备列表 Compose 页面

## 概要

本 MR 只新增我当前分工负责的 Kotlin Jetpack Compose 设备列表页面组件。

设备列表展示三类 ABox 设备入口:

- 智能插座
- 温湿度传感器
- 红外控制器

本 MR 故意不修改 App 入口、登录流程、应用壳路由,也不实现任何设备详情/控制页面。这些部分由其他功能分支负责。

## 修改内容

- 新增 `DeviceListScreen`。
- 为三类设备入口暴露点击回调:
  - `onSocketClick`
  - `onTemperatureHumidityClick`
  - `onInfraredClick`
- 更新设备列表变更说明文档。
- 从本分支中移除早期临时加入、但不属于当前分工范围的设备控制页面。

## 本 MR 包含

- 设备列表 Compose UI 组件
- 设备类别入口 UI
- 后续路由接入所需的 callback API

## 本 MR 不包含

- App 入口修改
- 登录流程修改
- 设备控制 navigation graph
- 智能插座控制页面
- 温湿度状态页面
- 红外控制器页面
- Compose UI 中直接调用 ABSDK
- Java Activity
- XML layout

## 对接说明

该页面后续可以由 App shell 或路由负责人挂载:

```kotlin
DeviceListScreen(
    onSocketClick = { /* 后续跳转到智能插座模块 */ },
    onTemperatureHumidityClick = { /* 后续跳转到温湿度模块 */ },
    onInfraredClick = { /* 后续跳转到红外控制器模块 */ },
)
```

当前分支只提供页面和回调 API,不负责具体跳转目标,以便其他模块负责人对接自己的功能页面。

## 验证

- 已在本地通过 Android Studio 成功 build/run `android.app`。
- 修剪为设备列表组件范围后,`:android:app:assembleDebug` 构建成功。

## 备注

`settings.gradle.kts` 不属于本 MR 范围。本地 Gradle/JDK/toolchain 配置在开发者本机处理,不修改团队提供的项目配置文件。

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Reviewed-on: #8
Co-authored-by: alZerNest <2223976172@qq.com>
2026-08-25 11:39:12 +08:00
KremeCN ed98c2be98 feat(app): add authenticated app shell with top bar and bottom navigation (#6)
### 概述 / Summary
实现认证后的主应用壳层(`MainAppShell`),包含胶囊顶栏(`ABoxTopPillBar`)与 Material 3 底部导航栏(`ABoxNavigationBar`),并为团队其他成员预留标准 Composable 插槽。

### 核心变更 / Changes
1. **设计系统 (`:android:core:designsystem`)**:
   - 新增 `ABoxTopPillBar`:展示提供方/空间信息、快捷 `+` 操作与用户头像徽标。
   - 新增 `ABoxUserAvatar`:支持用户名首字符与主题强调轮廓。
   - 新增 `ABoxNavigationBar` & `ABoxNavigationBarItem`:M3 胶囊指示器底部导航栏封装。
2. **应用壳层 (`:android:app`)**:
   - 新增 `MainTab`(`DEVICES`, `SCENES`, `ACTIVITY`),严格对齐需求与分工术语。
   - 新增 `MainAppShell`:承载顶栏、底栏、Tab 平滑切换,并开放 `devicesSlot`、`scenesSlot`、`activitySlot`。
   - 新增 `AccountProviderBottomSheet`:点击头像展示当前会话/提供方信息、支持切换提供方与退出登录。
3. **多语言与测试**:
   - 补充 `values` 与 `values-zh-rCN` 双语资源。
   - 新增 `MainTabTest` 单元测试并通过全量 `check` 与 `test`。

### 分工保护 / Boundaries
- 仅提供顶栏、底栏与主页面容器骨架;
- 设备列表、情景编排与设备控制详情的具体 UI 保持为插槽,供 alZerNest、tzh 和 Aurum 后续无缝填充。

Reviewed-on: #6
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 10:49:59 +08:00
KremeCN 06ccfdf952 feat: add provider login flow (#4)
## Summary

- replace the former three-provider model with two pre-login choices:
  - **ABox**: authenticate through the vendor ABSDK on the current local network
  - **Local experience**: establish a production simulation session without credentials, network access, or ABSDK calls
- remove the compatible-backend product/server scope and safely ignore legacy persisted compatible-provider values
- bind one process-wide `AbsdkBridge` for ABox session, socket, temperature/humidity, and infrared operations
- add production `:runtime:simulation` implementations and route device operations by the active provider
- retain provider-scoped username preferences, Keystore-encrypted automatic-login credentials, token recovery, logout, and session isolation
- add regression coverage for authentication gating, profile/session alignment, provider switching cleanup, routing, and simulation behavior
- align requirement, plan, architecture, and protocol documentation with LAN ABSDK access

## Verification

```text
./gradlew check testDebugUnitTest test
BUILD SUCCESSFUL (220 actionable tasks)

./gradlew :android:app:assembleDebug :android:app:assembleRelease
BUILD SUCCESSFUL (217 actionable tasks)

./gradlew :android:app:compileDebugAndroidTestKotlin
BUILD SUCCESSFUL

git diff --check
passed
```

Emulator smoke validation:

- provider selection displays ABox and Local experience only
- Local experience enters the authenticated route without credentials
- Local experience also enters while emulator Wi-Fi/data are disabled
- Local Sign out and Switch provider return to provider selection
- ABox opens the credential form; empty submission stays on login with the required-fields error

## Known constraints and accepted risks

- The vendor JAR uses cleartext HTTP and hardcodes `a-box.com.cn`; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. `@CleartextAbsdkTransport`, `usesCleartextTraffic`, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity.
- Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed.
- The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated `feat/moler-infrared-control` branch.
- `FakeDeviceCatalog` remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation.

Reviewed-on: #4
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 01:59:00 +08:00
122 changed files with 14698 additions and 208 deletions
@@ -17,10 +17,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import kotlin.reflect.KClass
class AbsdkDeviceBridgeTest { class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox") private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1") private val socket = DeviceName("socket-1")
private val irDevice = DeviceName("ir-room")
@Test @Test
fun socketControlReturnsConfirmedStatus() = runTest { fun socketControlReturnsConfirmedStatus() = runTest {
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline) assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
} }
@Test
fun infraredSendForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
val errorMappings = mapOf<String, KClass<out DomainError>>(
"20201" to DomainError.InvalidRequest::class,
"20202" to DomainError.InvalidRequest::class,
"20203" to DomainError.DeviceNotFound::class,
"20204" to DomainError.OperationFailed::class,
"20205" to DomainError.OperationFailed::class,
"20206" to DomainError.DeviceOffline::class,
)
for ((code, expectedClass) in errorMappings) {
val client = ScriptedAbsdkClient().apply {
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
assertEquals(code, sendResult.error.externalCode)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
assertEquals(code, learnResult.error.externalCode)
}
}
@Test
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
val client = ScriptedAbsdkClient().apply {
sendResponse = null
learnResponse = null
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue(sendResult is DomainResult.Failure)
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue(learnResult is DomainResult.Failure)
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
}
@Test @Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest { fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1") val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
assertEquals(1, client.maxConcurrentCalls.get()) assertEquals(1, client.maxConcurrentCalls.get())
} }
} }
@Test
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
withTimeout(5_000) {
val profile = ProviderProfileId("abox")
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
)
}
val bridge = AbsdkBridge(client, Dispatchers.IO)
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
client.onCall = {
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
Thread.sleep(75)
}
}
val irCall = async {
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
}
val tempCall = async {
bridge.getReading(profile, DeviceName("th-1"))
}
delay(10)
irCall.await()
tempCall.await()
assertEquals(1, client.maxConcurrentCalls.get())
}
}
} }
+20
View File
@@ -45,6 +45,15 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE*"
excludes += "/META-INF/NOTICE*"
}
}
} }
kapt { kapt {
@@ -54,22 +63,33 @@ kapt {
dependencies { dependencies {
implementation(project(":domain")) implementation(project(":domain"))
implementation(project(":android:core:designsystem")) implementation(project(":android:core:designsystem"))
implementation(project(":android:core:session"))
implementation(project(":android:feature:infrared"))
implementation(project(":absdk-bridge"))
implementation(project(":runtime:simulation"))
debugImplementation(project(":testing:fakes")) debugImplementation(project(":testing:fakes"))
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.datastore.preferences)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.hilt.android) implementation(libs.hilt.android)
kapt(libs.hilt.compiler) kapt(libs.hilt.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(project(":testing:fakes"))
androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
@@ -0,0 +1,54 @@
package com.flagship.abox.manager.ui.devicecontrollist
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DeviceListScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun deviceListRendersAllDeviceCategoryButtonsAndHandlesClicks() {
var socketClicks = 0
var tempClicks = 0
var infraredClicks = 0
val context = composeRule.activity
val socketTitle = context.getString(R.string.device_socket_title)
val tempTitle = context.getString(R.string.device_temperature_humidity_title)
val infraredTitle = context.getString(R.string.device_infrared_title)
val listTitle = context.getString(R.string.device_list_title)
composeRule.setContent {
ABoxTheme {
DeviceListScreen(
onSocketClick = { socketClicks++ },
onTemperatureHumidityClick = { tempClicks++ },
onInfraredClick = { infraredClicks++ },
)
}
}
composeRule.onNodeWithText(listTitle).assertIsDisplayed()
composeRule.onNodeWithText(socketTitle).assertIsDisplayed().performClick()
assertEquals(1, socketClicks)
composeRule.onNodeWithText(tempTitle).assertIsDisplayed().performClick()
assertEquals(1, tempClicks)
composeRule.onNodeWithText(infraredTitle).assertIsDisplayed().performClick()
assertEquals(1, infraredClicks)
}
}
@@ -0,0 +1,101 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val profile = ProviderProfile(
ProviderProfileId("abox"),
ProviderType.ABOX,
"ABox",
)
@Test
fun topBackControlSwitchesProviderBeforeLogin() {
var switches = 0
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = { switches++ },
)
}
}
composeRule.onNodeWithContentDescription("back-to-providers")
.assertIsEnabled()
.performClick()
composeRule.onAllNodesWithText("Back to providers").assertCountEquals(0)
assertEquals(1, switches)
}
@Test
fun systemBackIsConsumedWhileSubmitting() {
var switches = 0
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(isSubmitting = true),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = { switches++ },
)
}
}
composeRule.activity.onBackPressedDispatcher.onBackPressed()
composeRule.waitForIdle()
assertEquals(0, switches)
}
@Test
fun backControlsAreDisabledWhileSubmitting() {
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(isSubmitting = true),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = {},
)
}
}
composeRule.onNodeWithContentDescription("back-to-providers").assertIsNotEnabled()
composeRule.onAllNodesWithText("Back to providers").assertCountEquals(0)
}
}
@@ -0,0 +1,46 @@
package com.flagship.abox.manager.ui.provider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProviderSelectionScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() {
val abox = ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox")
val local = ProviderProfile(
ProviderProfileId("local"),
ProviderType.LOCAL_EXPERIENCE,
"Local",
)
var selected: ProviderProfile? = null
composeRule.setContent {
ABoxTheme {
ProviderSelectionScreen(
state = ProviderSelectionUiState(listOf(abox, local)),
onSelect = { selected = it },
)
}
}
composeRule.onNodeWithText("ABox").assertIsEnabled()
composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()
assertEquals(local, selected)
composeRule.onNodeWithText("Compatible backend").assertDoesNotExist()
}
}
@@ -1,15 +1,7 @@
package com.flagship.abox.manager.di package com.flagship.abox.manager.di
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.SessionGateway
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
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.FakeSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -19,24 +11,12 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object FoundationModule { object FoundationModule {
@Provides // TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production
@Singleton // ABox catalog is owned by the device-catalog module. Until that exists, debug builds
fun provideSessionGateway(): SessionGateway = FakeSessionGateway() // keep the fake ABox catalog here. Do NOT route ABox catalog requests to
// SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding
// until a real release consumer appears.
@Provides @Provides
@Singleton @Singleton
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog() fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
@Provides
@Singleton
fun provideSocketGateway(): SocketGateway = FakeSocketGateway()
@Provides
@Singleton
fun provideTemperatureHumidityGateway(): TemperatureHumidityGateway =
FakeTemperatureHumidityGateway()
@Provides
@Singleton
fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway()
} }
+2
View File
@@ -1,9 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".ABoxManagerApplication" android:name=".ABoxManagerApplication"
android:allowBackup="false" android:allowBackup="false"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ABoxManager"> android:theme="@style/Theme.ABoxManager">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -0,0 +1,62 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
import com.flagship.abox.manager.simulation.SimulationSocketGateway
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Binds each public device gateway to a router that dispatches on the active provider:
* ABox goes to the shared real [AbsdkBridge] and local experience goes to the shared
* production simulation gateway.
*/
@Module
@InstallIn(SingletonComponent::class)
object DeviceGatewayModule {
@Provides
@Singleton
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
@Provides
@Singleton
fun provideSimulationTemperatureHumidityGateway(): SimulationTemperatureHumidityGateway =
SimulationTemperatureHumidityGateway()
@Provides
@Singleton
fun provideSimulationInfraredGateway(): SimulationInfraredGateway = SimulationInfraredGateway()
@Provides
@Singleton
fun provideSocketGateway(
profileStore: ProviderProfileStore,
bridge: AbsdkBridge,
simulation: SimulationSocketGateway,
): SocketGateway = RoutingSocketGateway(profileStore, bridge, simulation)
@Provides
@Singleton
fun provideTemperatureHumidityGateway(
profileStore: ProviderProfileStore,
bridge: AbsdkBridge,
simulation: SimulationTemperatureHumidityGateway,
): TemperatureHumidityGateway =
RoutingTemperatureHumidityGateway(profileStore, bridge, simulation)
@Provides
@Singleton
fun provideInfraredGateway(
profileStore: ProviderProfileStore,
bridge: AbsdkBridge,
simulation: SimulationInfraredGateway,
): InfraredGateway = RoutingInfraredGateway(profileStore, bridge, simulation)
}
@@ -0,0 +1,140 @@
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 com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import kotlinx.coroutines.CancellationException
/**
* Resolves the provider type a device operation must be routed to.
*
* A device call is only valid for the currently active provider profile: a missing
* active profile or a mismatched [profileId] is rejected with [DomainError.InvalidRequest]
* before any gateway is invoked.
*/
internal class ActiveProviderGatewayResolver(
private val profileStore: ProviderProfileStore,
) {
suspend fun resolve(profileId: ProviderProfileId): DomainResult<ProviderType> = try {
val active = profileStore.activeProfile()
when {
active == null ->
DomainResult.Failure(DomainError.InvalidRequest(detail = "No active provider"))
active.id != profileId ->
DomainResult.Failure(
DomainError.InvalidRequest(detail = "Provider profile is not active"),
)
else -> DomainResult.Success(active.type)
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
/** Routes socket operations to the real ABSDK bridge or the simulation gateway by active provider. */
internal class RoutingSocketGateway(
profileStore: ProviderProfileStore,
private val real: SocketGateway,
private val simulation: SocketGateway,
) : SocketGateway {
private val resolver = ActiveProviderGatewayResolver(profileStore)
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).setPower(profileId, deviceName, powerState)
}
private fun delegate(type: ProviderType): SocketGateway = when (type) {
ProviderType.ABOX -> real
ProviderType.LOCAL_EXPERIENCE -> simulation
}
}
/** Routes temperature/humidity operations by active provider, mirroring [RoutingSocketGateway]. */
internal class RoutingTemperatureHumidityGateway(
profileStore: ProviderProfileStore,
private val real: TemperatureHumidityGateway,
private val simulation: TemperatureHumidityGateway,
) : TemperatureHumidityGateway {
private val resolver = ActiveProviderGatewayResolver(profileStore)
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).getReading(profileId, deviceName)
}
private fun delegate(type: ProviderType): TemperatureHumidityGateway = when (type) {
ProviderType.ABOX -> real
ProviderType.LOCAL_EXPERIENCE -> simulation
}
}
/** Routes infrared operations by active provider, mirroring [RoutingSocketGateway]. */
internal class RoutingInfraredGateway(
profileStore: ProviderProfileStore,
private val real: InfraredGateway,
private val simulation: InfraredGateway,
) : InfraredGateway {
private val resolver = ActiveProviderGatewayResolver(profileStore)
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).sendKey(profileId, deviceName, key)
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).learnKey(profileId, deviceName, key)
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).downloadCodes(profileId, codes)
}
private fun delegate(type: ProviderType): InfraredGateway = when (type) {
ProviderType.ABOX -> real
ProviderType.LOCAL_EXPERIENCE -> simulation
}
}
@@ -0,0 +1,80 @@
package com.flagship.abox.manager.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.absdk.bridge.CleartextAbsdkTransport
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.session.credential.AndroidKeystoreCredentialCipher
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import com.flagship.abox.manager.session.profile.sessionDataStore
import com.flagship.abox.manager.simulation.SimulationSessionGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionModule {
@Provides
@Singleton
fun provideSessionDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.sessionDataStore
@Provides
@Singleton
fun provideProviderProfileStore(
dataStore: DataStore<Preferences>,
): ProviderProfileStore = ProviderProfileStoreImpl(dataStore)
@Provides
@Singleton
fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher()
@Provides
@Singleton
fun provideCredentialStore(
dataStore: DataStore<Preferences>,
cipher: CredentialCipher,
): CredentialStore = KeystoreCredentialStore(dataStore, cipher)
@Provides
@Singleton
@OptIn(CleartextAbsdkTransport::class)
fun provideAbsdkBridge(): AbsdkBridge = AbsdkBridge.create()
@Provides
@Singleton
fun provideRemoteSessionGateway(
bridge: AbsdkBridge,
): SessionGateway = bridge
@Provides
@Singleton
fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway()
@Provides
@Singleton
fun provideSessionCoordinator(
profileStore: ProviderProfileStore,
credentialStore: CredentialStore,
remoteGateway: SessionGateway,
localGateway: LocalSessionGateway,
): SessionCoordinator = SessionCoordinator(
profileStore,
credentialStore,
remoteGateway,
localGateway,
)
}
@@ -1,94 +1,144 @@
package com.flagship.abox.manager.ui package com.flagship.abox.manager.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.unit.dp 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 com.flagship.abox.manager.R import androidx.navigation.navArgument
import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
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.domain.ProviderProfile
private const val FOUNDATION_ROUTE = "foundation" import com.flagship.abox.manager.domain.Username
private const val PLACEHOLDER_ROUTE = "placeholder" import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
import com.flagship.abox.manager.ui.shell.MainAppShell
@Composable @Composable
fun ABoxManagerApp() { fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
loginViewModel: LoginViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
if (sessionState !is AppSessionUiState.Login) {
loginViewModel.clearSensitiveState()
}
}
ABoxTheme { ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) { ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController() when (val state = sessionState) {
NavHost( AppSessionUiState.Loading -> Box(
navController = navController, modifier = Modifier
startDestination = FOUNDATION_ROUTE, .fillMaxSize()
) { .windowInsetsPadding(WindowInsets.safeDrawing),
composable(FOUNDATION_ROUTE) { contentAlignment = Alignment.Center,
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) }) ) {
} ABoxLoadingIndicator()
composable(PLACEHOLDER_ROUTE) {
MessageScreen(
title = stringResource(R.string.placeholder_title),
description = stringResource(R.string.placeholder_description),
)
} }
AppSessionUiState.ProviderSelection -> ProviderSelectionRoute()
is AppSessionUiState.Login -> LoginRoute(
profile = state.profile,
bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage,
actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel,
)
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
username = state.username,
profile = state.profile,
actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage,
onLogout = sessionActionsViewModel::logout,
)
} }
} }
} }
} }
@Composable @Composable
private fun FoundationScreen(onContinue: () -> Unit) { private fun AuthenticatedNavHost(
Column( username: Username,
modifier = Modifier profile: ProviderProfile,
.fillMaxSize() actionInProgress: Boolean,
.padding(32.dp), errorMessage: String?,
horizontalAlignment = Alignment.CenterHorizontally, onLogout: () -> Unit,
verticalArrangement = Arrangement.Center, ) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home",
) { ) {
MessageScreen( composable("home") {
title = stringResource(R.string.foundation_title), MainAppShell(
description = stringResource(R.string.foundation_description), userName = username.value,
) providerName = profile.displayName,
ABoxButton( onLogout = onLogout,
onClick = onContinue, actionInProgress = actionInProgress,
modifier = Modifier errorMessage = errorMessage,
.fillMaxWidth() devicesSlot = {
.padding(top = 24.dp), DeviceListScreen(
onInfraredClick = {
navController.navigate("infrared_remote/${profile.id.value}")
},
)
},
)
}
composable(
route = "infrared_remote/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) { ) {
Text(stringResource(R.string.continue_action)) InfraredRemoteRoute(
onBack = { navController.popBackStack() },
onOpenSettings = {
navController.navigate("infrared_settings/${profile.id.value}")
},
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_settings/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
InfraredControlRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = onLogout,
)
} }
} }
} }
@Composable private fun AppSessionUiState.screenKey(): String = when (this) {
private fun MessageScreen( AppSessionUiState.Loading -> "loading"
title: String, AppSessionUiState.ProviderSelection -> "providers"
description: String, is AppSessionUiState.Login -> "login:${profile.id.value}"
) { is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}"
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = title,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
} }
@@ -0,0 +1,95 @@
package com.flagship.abox.manager.ui.devicecontrollist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxTheme
@Composable
fun DeviceListScreen(
modifier: Modifier = Modifier,
onSocketClick: () -> Unit = {},
onTemperatureHumidityClick: () -> Unit = {},
onInfraredClick: () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.device_list_title),
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.device_list_description),
modifier = Modifier.padding(top = 12.dp, bottom = 32.dp),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
DeviceEntryButton(
text = stringResource(R.string.device_socket_title),
description = stringResource(R.string.device_socket_description),
onClick = onSocketClick,
)
DeviceEntryButton(
text = stringResource(R.string.device_temperature_humidity_title),
description = stringResource(R.string.device_temperature_humidity_description),
onClick = onTemperatureHumidityClick,
)
DeviceEntryButton(
text = stringResource(R.string.device_infrared_title),
description = stringResource(R.string.device_infrared_description),
onClick = onInfraredClick,
)
}
}
@Composable
private fun DeviceEntryButton(
text: String,
description: String,
onClick: () -> Unit,
) {
ABoxButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
) {
Column(
modifier = Modifier.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = text, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodySmall)
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceListScreenPreview() {
ABoxTheme {
DeviceListScreen()
}
}
@@ -0,0 +1,228 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.compose.BackHandler
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderType
@Composable
fun LoginRoute(
profile: ProviderProfile,
bootstrapError: String? = null,
actionError: String? = null,
actionInProgress: Boolean = false,
onSwitchProvider: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
val state = viewModel.uiState.collectAsStateWithLifecycle().value
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
?: actionError
?: bootstrapError
LoginScreen(
profile = profile,
state = state,
errorText = errorText,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onRememberUsernameChange = viewModel::setRememberUsername,
onAutoLoginChange = viewModel::setAutoLogin,
onSubmit = viewModel::submit,
onSwitchProvider = onSwitchProvider,
switchingProvider = actionInProgress,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
profile: ProviderProfile,
state: LoginUiState,
errorText: String? = null,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onRememberUsernameChange: (Boolean) -> Unit,
onAutoLoginChange: (Boolean) -> Unit,
onSubmit: () -> Unit,
onSwitchProvider: () -> Unit,
switchingProvider: Boolean = false,
) {
val formEnabled = !state.isSubmitting && !state.isLoadingPreferences
val navigationEnabled = formEnabled && !switchingProvider
BackHandler {
if (navigationEnabled) onSwitchProvider()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.login_title, providerName(profile.type))) },
navigationIcon = {
IconButton(
onClick = onSwitchProvider,
enabled = navigationEnabled,
modifier = Modifier.semantics {
contentDescription = "back-to-providers"
},
) {
Text(
text = "‹",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 4.dp),
)
}
},
)
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 520.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
stringResource(R.string.login_provider_context, providerName(profile.type)),
style = MaterialTheme.typography.titleMedium,
)
Text(
providerDescription(profile.type),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.login_username)) },
enabled = formEnabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrect = false,
),
enabled = formEnabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ToggleRow(
label = stringResource(R.string.login_remember_username),
checked = state.rememberUsername,
enabled = formEnabled,
onCheckedChange = onRememberUsernameChange,
)
ToggleRow(
label = stringResource(R.string.login_auto_login),
checked = state.autoLogin,
enabled = formEnabled,
onCheckedChange = onAutoLoginChange,
)
errorText?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onSubmit,
enabled = formEnabled && !switchingProvider,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting || state.isLoadingPreferences || switchingProvider) {
ABoxLoadingIndicator()
}
else Text(stringResource(R.string.action_login))
}
}
}
}
}
@Composable
private fun ToggleRow(
label: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
Text(label)
}
}
private fun LoginErrorMessage.stringResource(): Int = when (this) {
LoginErrorMessage.REQUIRED_FIELDS -> R.string.login_error_required
LoginErrorMessage.INVALID_CREDENTIALS -> R.string.login_error_invalid_credentials
LoginErrorMessage.NETWORK_UNAVAILABLE -> R.string.login_error_network
LoginErrorMessage.SESSION_EXPIRED -> R.string.login_error_session_expired
LoginErrorMessage.PREFERENCES_UNAVAILABLE -> R.string.login_error_preferences
LoginErrorMessage.LOGIN_FAILED -> R.string.login_error_failed
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name
},
)
@Composable
private fun providerDescription(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
@@ -0,0 +1,181 @@
package com.flagship.abox.manager.ui.login
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.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class LoginViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private var profile: ProviderProfile? = null
private var loadingJob: Job? = null
private var submitJob: Job? = null
private var usernameEdited = false
private var rememberUsernameEdited = false
private var autoLoginEdited = false
private var loadedProfileId: com.flagship.abox.manager.domain.ProviderProfileId? = null
fun loadProfile(value: ProviderProfile) {
if (loadedProfileId == value.id ||
profile?.id == value.id &&
(loadingJob?.isActive == true || submitJob?.isActive == true)
) return
loadingJob?.cancel()
profile = value
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState(isLoadingPreferences = true)
loadingJob = viewModelScope.launch {
try {
val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch
_uiState.value = _uiState.value.copy(
username = if (usernameEdited) {
_uiState.value.username
} else {
preferences.rememberedUsername?.value.orEmpty()
},
rememberUsername = if (rememberUsernameEdited) {
_uiState.value.rememberUsername
} else {
preferences.rememberUsername
},
autoLogin = if (autoLoginEdited) {
_uiState.value.autoLogin
} else {
preferences.autoLoginEnabled
},
isLoadingPreferences = false,
)
loadedProfileId = value.id
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
if (profile?.id == value.id) {
_uiState.value = _uiState.value.copy(
isLoadingPreferences = false,
errorMessage = LoginErrorMessage.PREFERENCES_UNAVAILABLE,
)
}
} finally {
loadingJob = null
}
}
}
fun clearSensitiveState() {
loadingJob?.cancel()
loadingJob = null
if (submitJob?.isActive == true) {
_uiState.value = _uiState.value.copy(
password = "",
errorMessage = null,
isSubmitting = true,
)
return
}
profile = null
loadedProfileId = null
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState()
}
fun setUsername(value: String) {
usernameEdited = true
_uiState.value = _uiState.value.copy(username = value, errorMessage = null)
}
fun setPassword(value: String) {
_uiState.value = _uiState.value.copy(password = value, errorMessage = null)
}
fun setRememberUsername(value: Boolean) {
rememberUsernameEdited = true
_uiState.value = _uiState.value.copy(rememberUsername = value)
}
fun setAutoLogin(value: Boolean) {
autoLoginEdited = true
_uiState.value = _uiState.value.copy(autoLogin = value)
}
fun submit() {
if (submitJob?.isActive == true) return
val activeProfile = profile ?: return
val state = _uiState.value
if (state.username.isBlank() ||
state.password.isEmpty() ||
state.isSubmitting ||
state.isLoadingPreferences
) {
_uiState.value = state.copy(errorMessage = LoginErrorMessage.REQUIRED_FIELDS)
return
}
submitJob = viewModelScope.launch {
try {
_uiState.value = state.copy(isSubmitting = true, errorMessage = null)
val result = coordinator.login(
activeProfile.id,
LoginCredentials(Username(state.username.trim()), Password(state.password)),
rememberUsername = state.rememberUsername,
autoLogin = state.autoLogin,
)
_uiState.value = _uiState.value.copy(
password = "",
isSubmitting = false,
errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(),
)
} finally {
submitJob = null
}
}
}
private fun DomainError.toUiMessage(): LoginErrorMessage = when (this) {
is DomainError.Authentication -> LoginErrorMessage.INVALID_CREDENTIALS
is DomainError.Network -> LoginErrorMessage.NETWORK_UNAVAILABLE
is DomainError.TokenInvalid -> LoginErrorMessage.SESSION_EXPIRED
else -> LoginErrorMessage.LOGIN_FAILED
}
}
enum class LoginErrorMessage {
REQUIRED_FIELDS,
INVALID_CREDENTIALS,
NETWORK_UNAVAILABLE,
SESSION_EXPIRED,
PREFERENCES_UNAVAILABLE,
LOGIN_FAILED,
}
data class LoginUiState(
val username: String = "",
val password: String = "",
val rememberUsername: Boolean = false,
val autoLogin: Boolean = false,
val isLoadingPreferences: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: LoginErrorMessage? = null,
)
@@ -0,0 +1,10 @@
package com.flagship.abox.manager.ui.navigation
object Routes {
const val STARTUP = "startup"
const val PROVIDER_SELECTION = "provider_selection"
const val LOGIN = "login"
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
const val AUTHENTICATED = "authenticated"
}
@@ -0,0 +1,183 @@
package com.flagship.abox.manager.ui.provider
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderType
@Composable
fun ProviderSelectionRoute(
viewModel: ProviderSelectionViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
ProviderSelectionScreen(state = state, onSelect = viewModel::select)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProviderSelectionScreen(
state: ProviderSelectionUiState,
onSelect: (ProviderProfile) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.provider_selection_title)) })
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 640.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.provider_selection_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
state.providers.forEach { profile ->
ProviderOptionCard(
profile = profile,
selecting = state.isSelecting,
onSelect = onSelect,
)
}
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
if (state.isSelecting) {
ABoxLoadingIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp),
)
}
Spacer(Modifier.padding(bottom = 12.dp))
}
}
}
}
@Composable
private fun ProviderOptionCard(
profile: ProviderProfile,
selecting: Boolean,
onSelect: (ProviderProfile) -> Unit,
) {
val displayName = providerName(profile.type)
val description = providerDescription(profile.type)
OutlinedCard(
onClick = { onSelect(profile) },
enabled = !selecting,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.fillMaxWidth()
.semantics {
role = Role.Button
contentDescription = displayName
},
) {
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Text(
text = providerMark(profile.type),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(displayName, style = MaterialTheme.typography.titleMedium)
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"›",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name
},
)
@Composable
private fun providerDescription(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
private fun providerMark(type: ProviderType): String = when (type) {
ProviderType.ABOX -> "A"
ProviderType.LOCAL_EXPERIENCE -> "L"
}
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.ui.provider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class ProviderSelectionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProviderSelectionUiState(providers = PROFILES))
val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow()
fun select(profile: ProviderProfile) {
if (_uiState.value.isSelecting) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null)
val result = coordinator.selectProvider(profile)
_uiState.value = _uiState.value.copy(
isSelecting = false,
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
)
}
}
companion object {
private val PROFILES = listOf(
ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"),
ProviderProfile(
ProviderProfileId("local-experience"),
ProviderType.LOCAL_EXPERIENCE,
"Local experience",
),
)
}
}
data class ProviderSelectionUiState(
val providers: List<ProviderProfile>,
val isSelecting: Boolean = false,
val errorMessage: String? = null,
)
@@ -0,0 +1,131 @@
package com.flagship.abox.manager.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AppSessionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null)
val uiState: StateFlow<AppSessionUiState> = combine(
bootstrapResult,
profileStore.observeActiveProfile(),
coordinator.observeActiveSession(),
) { bootstrap, profile, session ->
if (bootstrap == null) AppSessionUiState.Loading
else stateFor(
profile = profile,
session = session,
bootstrapError = bootstrap.errorFor(profile?.id),
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppSessionUiState.Loading,
)
init {
viewModelScope.launch {
try {
val activeProfile = profileStore.activeProfile()
bootstrapResult.value = ProviderBootstrapResult(
profileId = activeProfile?.id,
result = coordinator.bootstrap(),
)
} catch (error: kotlinx.coroutines.CancellationException) {
throw error
} catch (error: Exception) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = null,
result = DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown(
detail = error.message ?: "Unable to load provider settings",
),
),
)
}
}
viewModelScope.launch {
coordinator.observeActiveSession()
.map { it?.profileId }
.distinctUntilChanged()
.collect { sessionProfileId ->
if (sessionProfileId != null && bootstrapResult.value?.profileId == sessionProfileId) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = sessionProfileId,
result = DomainResult.Success(null),
)
}
}
}
viewModelScope.launch {
profileStore.observeActiveProfile()
.map { it?.id }
.distinctUntilChanged()
.collect { activeProfileId ->
val currentBootstrap = bootstrapResult.value
if (currentBootstrap != null && currentBootstrap.profileId != activeProfileId) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = activeProfileId,
result = DomainResult.Success(null),
)
}
}
}
}
private fun stateFor(
profile: ProviderProfile?,
session: Session?,
bootstrapError: String?,
): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection
session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile, session.username)
else -> AppSessionUiState.Login(profile, bootstrapError)
}
}
private data class ProviderBootstrapResult(
val profileId: ProviderProfileId?,
val result: DomainResult<Session?>,
) {
fun errorFor(currentProfileId: ProviderProfileId?): String? =
if (profileId == currentProfileId) {
(result as? DomainResult.Failure)?.error?.detail
} else {
null
}
}
sealed interface AppSessionUiState {
data object Loading : AppSessionUiState
data object ProviderSelection : AppSessionUiState
data class Login(
val profile: ProviderProfile,
val bootstrapError: String? = null,
) : AppSessionUiState
data class Authenticated(
val profile: ProviderProfile,
val username: Username,
) : AppSessionUiState
}
@@ -0,0 +1,44 @@
package com.flagship.abox.manager.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.SessionCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SessionActionsViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
) : ViewModel() {
private val _uiState = MutableStateFlow(SessionActionsUiState())
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
fun logout() = runAction(coordinator::logout)
fun switchProvider() = runAction(coordinator::switchProvider)
fun dismissError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
private fun runAction(action: suspend () -> DomainResult<Unit>) {
if (_uiState.value.inProgress) return
viewModelScope.launch {
_uiState.value = SessionActionsUiState(inProgress = true)
val result = action()
_uiState.value = SessionActionsUiState(
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
)
}
}
}
data class SessionActionsUiState(
val inProgress: Boolean = false,
val errorMessage: String? = null,
)
@@ -0,0 +1,183 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
/**
* Account and Provider management bottom sheet triggered by clicking the top bar user avatar.
* Allows viewing current session info and signing out.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountProviderBottomSheet(
userName: String,
providerName: String,
onDismissRequest: () -> Unit,
onLogout: () -> Unit,
actionInProgress: Boolean,
errorMessage: String?,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Header with large avatar & user info
ABoxUserAvatar(
name = userName,
onClick = {},
size = 56.dp,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = userName.ifBlank { stringResource(R.string.account_sheet_title) },
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.account_sheet_signed_in_as),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
// Provider Info Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.account_sheet_provider),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = providerName,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Action button
ABoxButton(
onClick = onLogout,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.account_sheet_logout))
}
errorMessage?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
TextButton(
onClick = onDismissRequest,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.account_sheet_close),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,179 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
import com.flagship.abox.manager.designsystem.ABoxTopPillBar
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
/**
* Main authenticated application shell.
*
* Provides:
* 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet.
* 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs.
* 3. Clear composable slot boundaries for team member integrations.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainAppShell(
userName: String,
providerName: String,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
initialTab: MainTab = MainTab.DEVICES,
onTabSelected: (MainTab) -> Unit = {},
onAddClick: (() -> Unit)? = null,
actionInProgress: Boolean = false,
errorMessage: String? = null,
devicesSlot: @Composable () -> Unit = { DeviceListScreen() },
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
) {
var activeTab by rememberSaveable { mutableStateOf(initialTab) }
var showAccountSheet by remember { mutableStateOf(false) }
val tabStateHolder = rememberSaveableStateHolder()
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
ABoxTopPillBar(
title = stringResource(R.string.top_bar_prompt_format, providerName),
userName = userName,
onAvatarClick = { showAccountSheet = true },
onAddClick = onAddClick,
addActionContentDescription = stringResource(R.string.action_add),
avatarContentDescription = stringResource(R.string.action_account),
)
}
},
bottomBar = {
ABoxNavigationBar {
MainTab.values().forEach { tab ->
val isSelected = (tab == activeTab)
ABoxNavigationBarItem(
selected = isSelected,
onClick = {
activeTab = tab
onTabSelected(tab)
},
icon = {
Icon(
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
)
},
label = {
Text(text = stringResource(tab.labelRes))
},
)
}
}
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Crossfade(
targetState = activeTab,
label = "MainTabCrossfade",
) { tab ->
tabStateHolder.SaveableStateProvider(tab) {
when (tab) {
MainTab.DEVICES -> devicesSlot()
MainTab.SCENES -> scenesSlot()
MainTab.ACTIVITY -> activitySlot()
}
}
}
}
}
if (showAccountSheet) {
AccountProviderBottomSheet(
userName = userName,
providerName = providerName,
onDismissRequest = { showAccountSheet = false },
onLogout = onLogout,
actionInProgress = actionInProgress,
errorMessage = errorMessage,
)
}
}
@Composable
private fun DefaultScenesSlot() {
SlotPlaceholder(
title = stringResource(R.string.scenes_placeholder_title),
description = stringResource(R.string.scenes_placeholder_description),
)
}
@Composable
private fun DefaultActivitySlot() {
SlotPlaceholder(
title = stringResource(R.string.activity_placeholder_title),
description = stringResource(R.string.activity_placeholder_description),
)
}
@Composable
private fun SlotPlaceholder(
title: String,
description: String,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@@ -0,0 +1,41 @@
package com.flagship.abox.manager.ui.shell
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.ViewAgenda
import androidx.compose.ui.graphics.vector.ImageVector
import com.flagship.abox.manager.R
/**
* Main bottom navigation tabs for the authenticated application shell.
*/
enum class MainTab(
@StringRes val labelRes: Int,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val route: String,
) {
DEVICES(
labelRes = R.string.tab_devices,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
route = "shell/devices",
),
SCENES(
labelRes = R.string.tab_scenes,
selectedIcon = Icons.Filled.AutoAwesome,
unselectedIcon = Icons.Outlined.AutoAwesome,
route = "shell/scenes",
),
ACTIVITY(
labelRes = R.string.tab_activity,
selectedIcon = Icons.Filled.ViewAgenda,
unselectedIcon = Icons.Outlined.ViewAgenda,
route = "shell/activity",
),
}
@@ -1,8 +1,56 @@
<resources> <resources>
<string name="app_name">ABox Manager</string> <string name="app_name">ABox Manager</string>
<string name="foundation_title">工程基础已就绪</string> <string name="provider_selection_title">选择提供方</string>
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string> <string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
<string name="continue_action">继续</string> <string name="provider_abox_name">ABox</string>
<string name="placeholder_title">功能模块可以开始开发</string> <string name="provider_abox_description">通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string> <string name="provider_local_name">本地体验</string>
<string name="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
<string name="login_title">登录 %1$s</string>
<string name="login_provider_context">当前提供方:%1$s</string>
<string name="login_username">用户名</string>
<string name="login_password">密码</string>
<string name="login_remember_username">记住用户名</string>
<string name="login_auto_login">自动登录</string>
<string name="login_error_required">请输入用户名和密码。</string>
<string name="login_error_invalid_credentials">用户名或密码不正确。</string>
<string name="login_error_network">网络不可用,请重试。</string>
<string name="login_error_session_expired">会话已失效,请重新登录。</string>
<string name="login_error_preferences">无法加载已保存的登录设置。</string>
<string name="login_error_failed">登录失败,请重试。</string>
<string name="action_login">登录</string>
<string name="action_logout">退出登录</string>
<string name="action_switch_provider">切换提供方</string>
<string name="authenticated_title">已连接到 %1$s</string>
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
<!-- 认证后壳层 -->
<string name="tab_devices">设备</string>
<string name="tab_scenes">情景</string>
<string name="tab_activity">活动记录</string>
<string name="top_bar_prompt_format">问问“%1$s”&#8230;</string>
<string name="action_add">添加</string>
<string name="action_account">账户与提供方</string>
<string name="account_sheet_title">账户与提供方</string>
<string name="account_sheet_signed_in_as">当前登录为</string>
<string name="account_sheet_provider">当前提供方</string>
<string name="account_sheet_logout">退出登录</string>
<string name="account_sheet_close">关闭</string>
<string name="devices_placeholder_title">设备列表</string>
<string name="devices_placeholder_description">设备列表与控制卡片插槽(由 alZerNest 模块实现)。</string>
<string name="scenes_placeholder_title">情景模式</string>
<string name="scenes_placeholder_description">情景动作与执行插槽(由 tzh 模块实现)。</string>
<string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string>
<!-- 设备列表 -->
<string name="device_list_title">设备列表</string>
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
<string name="device_socket_title">智能插座</string>
<string name="device_socket_description">进入智能插座相关功能。</string>
<string name="device_temperature_humidity_title">温湿度传感器</string>
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
<string name="device_infrared_title">红外控制器</string>
<string name="device_infrared_description">进入红外控制器相关功能。</string>
<string name="infrared_control_action">红外控制</string>
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
</resources> </resources>
+53 -5
View File
@@ -1,8 +1,56 @@
<resources> <resources>
<string name="app_name">ABox Manager</string> <string name="app_name">ABox Manager</string>
<string name="foundation_title">Project foundation ready</string> <string name="provider_selection_title">Choose a provider</string>
<string name="foundation_description">The shared domain contracts and local development providers are available.</string> <string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
<string name="continue_action">Continue</string> <string name="provider_abox_name">ABox</string>
<string name="placeholder_title">Feature development can start</string> <string name="provider_abox_description">Connect to the ABox smart-home gateway through ABSDK on the current local network.</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string> <string name="provider_local_name">Local experience</string>
<string name="provider_local_description">Explore the app with simulated local devices and no network connection.</string>
<string name="login_title">Sign in to %1$s</string>
<string name="login_provider_context">Selected provider: %1$s</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_remember_username">Remember username</string>
<string name="login_auto_login">Sign in automatically</string>
<string name="login_error_required">Username and password are required.</string>
<string name="login_error_invalid_credentials">Invalid username or password.</string>
<string name="login_error_network">Network unavailable. Try again.</string>
<string name="login_error_session_expired">Session expired. Sign in again.</string>
<string name="login_error_preferences">Unable to load saved login settings.</string>
<string name="login_error_failed">Sign-in failed. Try again.</string>
<string name="action_login">Sign in</string>
<string name="action_logout">Sign out</string>
<string name="action_switch_provider">Switch provider</string>
<string name="authenticated_title">Connected to %1$s</string>
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
<!-- Authenticated shell -->
<string name="tab_devices">Devices</string>
<string name="tab_scenes">Scenes</string>
<string name="tab_activity">Activity</string>
<string name="top_bar_prompt_format">Ask "%1$s"&#8230;</string>
<string name="action_add">Add</string>
<string name="action_account">Account &amp; Provider</string>
<string name="account_sheet_title">Account &amp; Provider</string>
<string name="account_sheet_signed_in_as">Signed in as</string>
<string name="account_sheet_provider">Active Provider</string>
<string name="account_sheet_logout">Sign Out</string>
<string name="account_sheet_close">Close</string>
<string name="devices_placeholder_title">Device List</string>
<string name="devices_placeholder_description">Device list and control cards slot for alZerNest.</string>
<string name="scenes_placeholder_title">Scene Mode</string>
<string name="scenes_placeholder_description">Scene actions and execution slot for tzh.</string>
<string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string>
<!-- Device list -->
<string name="device_list_title">Device List</string>
<string name="device_list_description">Select an ABox terminal device to control or check status.</string>
<string name="device_socket_title">Smart Socket</string>
<string name="device_socket_description">Access smart socket features.</string>
<string name="device_temperature_humidity_title">Temperature &amp; Humidity Sensor</string>
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
<string name="device_infrared_title">Infrared Controller</string>
<string name="device_infrared_description">Access infrared controller features.</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,230 @@
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.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderRoutingGatewaysTest {
@Test
fun socketRoutesAboxToRealAndLocalToSimulation() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingSocketGateway()
val simulation = RecordingSocketGateway()
val router = RoutingSocketGateway(store, real, simulation)
val aboxId = activate(store, ProviderType.ABOX)
router.getStatus(aboxId, DeviceName("socket-1"))
assertEquals(1, real.calls)
assertEquals(0, simulation.calls)
val localId = activate(store, ProviderType.LOCAL_EXPERIENCE)
router.getStatus(localId, DeviceName("socket-1"))
assertEquals(1, real.calls)
assertEquals(1, simulation.calls)
}
@Test
fun temperatureHumidityRoutesAboxToRealAndLocalToSimulation() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingTemperatureHumidityGateway()
val simulation = RecordingTemperatureHumidityGateway()
val router = RoutingTemperatureHumidityGateway(store, real, simulation)
val aboxId = activate(store, ProviderType.ABOX)
router.getReading(aboxId, DeviceName("th-1"))
assertEquals(1, real.calls)
assertEquals(0, simulation.calls)
val localId = activate(store, ProviderType.LOCAL_EXPERIENCE)
router.getReading(localId, DeviceName("th-1"))
assertEquals(1, real.calls)
assertEquals(1, simulation.calls)
}
@Test
fun infraredRoutesAboxToRealAndLocalToSimulation() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingInfraredGateway()
val simulation = RecordingInfraredGateway()
val router = RoutingInfraredGateway(store, real, simulation)
val aboxId = activate(store, ProviderType.ABOX)
router.sendKey(aboxId, DeviceName("ir-1"), "power")
assertEquals(1, real.calls)
assertEquals(0, simulation.calls)
val localId = activate(store, ProviderType.LOCAL_EXPERIENCE)
router.sendKey(localId, DeviceName("ir-1"), "power")
assertEquals(1, real.calls)
assertEquals(1, simulation.calls)
}
@Test
fun missingActiveProfileReturnsInvalidRequest() = runTest {
val router = RoutingSocketGateway(
FakeProviderProfileStore(),
RecordingSocketGateway(),
RecordingSocketGateway(),
)
val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1"))
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
}
@Test
fun mismatchedActiveProfileReturnsInvalidRequest() = runTest {
val store = FakeProviderProfileStore()
val real = RecordingSocketGateway()
val simulation = RecordingSocketGateway()
val router = RoutingSocketGateway(store, real, simulation)
activate(store, ProviderType.ABOX)
val result = router.getStatus(ProviderProfileId("other"), DeviceName("socket-1"))
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(0, real.calls)
assertEquals(0, simulation.calls)
}
@Test
fun cancellationFromProfileStoreIsRethrown() = runTest {
val router = RoutingSocketGateway(
CancellingProviderProfileStore(),
RecordingSocketGateway(),
RecordingSocketGateway(),
)
try {
router.getStatus(ProviderProfileId("any"), DeviceName("socket-1"))
error("Expected CancellationException")
} catch (expected: CancellationException) {
// Rethrown so coroutine cancellation is never swallowed.
}
}
@Test
fun unexpectedProfileStoreErrorReturnsUnknown() = runTest {
val router = RoutingSocketGateway(
FailingProviderProfileStore(),
RecordingSocketGateway(),
RecordingSocketGateway(),
)
val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1"))
assertTrue(result is DomainResult.Failure)
val error = (result as DomainResult.Failure).error
assertTrue(error is DomainError.Unknown)
assertEquals("storage exploded", error.detail)
}
private suspend fun activate(
store: FakeProviderProfileStore,
type: ProviderType,
): ProviderProfileId {
val id = ProviderProfileId(type.name.lowercase())
store.saveProfile(ProviderProfile(id, type, type.name))
store.setActiveProfile(id)
return id
}
}
private class RecordingSocketGateway : SocketGateway {
var calls: Int = 0
private set
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> {
calls += 1
return DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
calls += 1
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
}
private class RecordingTemperatureHumidityGateway : TemperatureHumidityGateway {
var calls: Int = 0
private set
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> {
calls += 1
return DomainResult.Success(TemperatureHumidityReading(profileId, deviceName, "0", "0"))
}
}
private class RecordingInfraredGateway : InfraredGateway {
var calls: Int = 0
private set
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls += 1
return DomainResult.Success(Unit)
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls += 1
return DomainResult.Success(Unit)
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
calls += 1
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
private class CancellingProviderProfileStore(
private val delegate: ProviderProfileStore = FakeProviderProfileStore(),
) : ProviderProfileStore by delegate {
override suspend fun activeProfile(): Nothing = throw CancellationException("cancelled")
}
private class FailingProviderProfileStore(
private val delegate: ProviderProfileStore = FakeProviderProfileStore(),
) : ProviderProfileStore by delegate {
override suspend fun activeProfile(): Nothing =
throw IllegalStateException("storage exploded")
}
@@ -0,0 +1,23 @@
package com.flagship.abox.manager.testing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
@@ -0,0 +1,51 @@
package com.flagship.abox.manager.ui.provider
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ProviderSelectionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val coordinator = SessionCoordinator(
profileStore = FakeProviderProfileStore(),
credentialStore = FakeCredentialStore(),
remoteGateway = FakeSessionGateway(),
localGateway = FakeLocalSessionGateway(),
)
@Test
fun exposesExactlyAboxAndLocalExperience() {
val viewModel = ProviderSelectionViewModel(coordinator)
assertEquals(
listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE),
viewModel.uiState.value.providers.map { it.type },
)
}
@Test
fun selectingLocalExperienceEstablishesSessionImmediately() = runTest {
val viewModel = ProviderSelectionViewModel(coordinator)
val local = viewModel.uiState.value.providers
.single { it.type == ProviderType.LOCAL_EXPERIENCE }
viewModel.select(local)
advanceUntilIdle()
assertEquals(local.id, coordinator.observeActiveSession().first()?.profileId)
}
}
@@ -0,0 +1,129 @@
package com.flagship.abox.manager.ui.session
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* Regression tests for real ABox login gating: the authenticated UI may appear only when the
* active provider profile owns a successful session, and a failed ABox login stays on Login.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AppSessionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profiles = FakeProviderProfileStore()
private val remote = FakeSessionGateway()
private val local = FakeLocalSessionGateway()
private val credentials = LoginCredentials(
username = Username("user"),
password = Password("password"),
)
private fun newCoordinator(remoteGateway: SessionGateway = remote) = SessionCoordinator(
profileStore = profiles,
credentialStore = FakeCredentialStore(),
remoteGateway = remoteGateway,
localGateway = local,
)
private suspend fun saveAndActivateAboxProfile(id: ProviderProfileId) {
profiles.saveProfile(ProviderProfile(id, ProviderType.ABOX, id.value))
profiles.setActiveProfile(id)
}
@Test
fun failedAboxLoginLeavesUiOnLogin() = runTest {
saveAndActivateAboxProfile(aboxProfileId)
remote.failLogin(aboxProfileId, DomainError.Authentication())
val coordinator = newCoordinator()
val viewModel = AppSessionViewModel(coordinator, profiles)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
advanceUntilIdle()
coordinator.login(
profileId = aboxProfileId,
credentials = credentials,
rememberUsername = false,
autoLogin = false,
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
}
@Test
fun sessionForDifferentProfileCannotAuthenticateActiveProfile() = runTest {
profiles.saveProfile(ProviderProfile(otherProfileId, ProviderType.ABOX, otherProfileId.value))
remote.login(otherProfileId, credentials)
saveAndActivateAboxProfile(aboxProfileId)
// The gateway exposes the other profile's session no matter which profile is observed.
val crossProfileGateway = object : SessionGateway by remote {
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> =
remote.observeSession(otherProfileId)
}
val viewModel = AppSessionViewModel(newCoordinator(crossProfileGateway), profiles)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
advanceUntilIdle()
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
}
@Test
fun successfulAboxLoginAuthenticatesActiveProfile() = runTest {
saveAndActivateAboxProfile(aboxProfileId)
val coordinator = newCoordinator()
val viewModel = AppSessionViewModel(coordinator, profiles)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
advanceUntilIdle()
coordinator.login(
profileId = aboxProfileId,
credentials = credentials,
rememberUsername = false,
autoLogin = false,
)
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state is AppSessionUiState.Authenticated)
val authenticated = state as AppSessionUiState.Authenticated
assertEquals(credentials.username, authenticated.username)
}
private companion object {
val aboxProfileId = ProviderProfileId("abox-default")
val otherProfileId = ProviderProfileId("abox-other")
}
}
@@ -0,0 +1,28 @@
package com.flagship.abox.manager.ui.shell
import com.flagship.abox.manager.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class MainTabTest {
@Test
fun `all main tabs have distinct non-empty routes and valid label resources`() {
val tabs = MainTab.values()
assertEquals(3, tabs.size)
val routes = tabs.map { it.route }
assertEquals(routes.distinct().size, tabs.size)
tabs.forEach { tab ->
assertNotNull(tab.selectedIcon)
assertNotNull(tab.unselectedIcon)
assert(tab.route.isNotBlank())
}
assertEquals(R.string.tab_devices, MainTab.DEVICES.labelRes)
assertEquals(R.string.tab_scenes, MainTab.SCENES.labelRes)
assertEquals(R.string.tab_activity, MainTab.ACTIVITY.labelRes)
}
}
@@ -34,5 +34,6 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
} }
@@ -1,12 +1,43 @@
package com.flagship.abox.manager.designsystem package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable @Composable
fun ABoxSurface( fun ABoxSurface(
@@ -36,3 +67,162 @@ fun ABoxButton(
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier) CircularProgressIndicator(modifier = modifier)
} }
/**
* Circular user avatar displaying the initial character of username or a default person icon.
* Styled with an accented profile ring.
*/
@Composable
fun ABoxUserAvatar(
name: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 36.dp,
contentDescription: String? = null,
) {
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape,
)
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
} else {
Icon(
imageVector = Icons.Default.Person,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f),
)
}
}
}
/**
* Material 3 pill top bar containing leading icon, title/provider label,
* an optional '+' action button, and user avatar.
*/
@Composable
fun ABoxTopPillBar(
title: String,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
userName: String? = null,
leadingIcon: ImageVector = Icons.Default.LocationOn,
onAddClick: (() -> Unit)? = null,
addActionContentDescription: String? = "Add",
avatarContentDescription: String? = "Account",
) {
Surface(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = leadingIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onAddClick != null) {
IconButton(
onClick = onAddClick,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = addActionContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
ABoxUserAvatar(
name = userName,
onClick = onAvatarClick,
size = 36.dp,
contentDescription = avatarContentDescription,
)
}
}
}
/**
* ABox standard Material 3 Navigation Bar with pill indicator styling.
*/
@Composable
fun ABoxNavigationBar(
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.surface,
content: @Composable RowScope.() -> Unit,
) {
NavigationBar(
modifier = modifier,
containerColor = containerColor,
content = content,
)
}
/**
* ABox standard Navigation Bar Item.
*/
@Composable
fun RowScope.ABoxNavigationBarItem(
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
label: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
NavigationBarItem(
selected = selected,
onClick = onClick,
icon = icon,
label = label,
modifier = modifier,
enabled = enabled,
colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.onSurface,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.session"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
api(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.datastore.preferences)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.session.credential
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AndroidKeystoreCredentialCipherTest {
@Test
fun encryptsWithRandomIvAndProfileBoundAad() {
val cipher = AndroidKeystoreCredentialCipher(
keyAlias = "abox_manager_test_${System.nanoTime()}",
)
val profile = ProviderProfileId("profile")
val plaintext = "secret".toByteArray()
val first = cipher.encrypt(profile, plaintext)
val second = cipher.encrypt(profile, plaintext)
assertFalse(first.contentEquals(second))
assertArrayEquals(plaintext, cipher.decrypt(profile, first))
assertArrayEquals(plaintext, cipher.decrypt(profile, second))
}
@Test(expected = IllegalArgumentException::class)
fun rejectsTruncatedPayloadBeforeReadingHeader() {
AndroidKeystoreCredentialCipher(
keyAlias = "abox_manager_test_${System.nanoTime()}",
).decrypt(ProviderProfileId("profile"), byteArrayOf(1))
}
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,75 @@
package com.flagship.abox.manager.session.credential
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.flagship.abox.manager.domain.ProviderProfileId
import java.nio.ByteBuffer
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AndroidKeystoreCredentialCipher(
private val keyAlias: String = KEY_ALIAS,
) : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext)
return ByteBuffer.allocate(2 + cipher.iv.size + ciphertext.size)
.put(VERSION)
.put(cipher.iv.size.toByte())
.put(cipher.iv)
.put(ciphertext)
.array()
}
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
require(encrypted.size >= HEADER_SIZE) { "Credential payload is truncated" }
val buffer = ByteBuffer.wrap(encrypted)
require(buffer.get() == VERSION) { "Unsupported credential format" }
val ivLength = buffer.get().toInt() and 0xFF
require(ivLength in 12..16 && buffer.remaining() > ivLength) { "Invalid credential payload" }
val iv = ByteArray(ivLength).also(buffer::get)
val ciphertext = ByteArray(buffer.remaining()).also(buffer::get)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
return cipher.doFinal(ciphertext)
}
override fun resetPermanentlyInvalidatedKey() {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias)
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run {
init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(KEY_SIZE_BITS)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
companion object {
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALIAS = "abox_manager_credentials_v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val KEY_SIZE_BITS = 256
private const val GCM_TAG_BITS = 128
private const val HEADER_SIZE = 2
private const val VERSION: Byte = 1
}
}
@@ -0,0 +1,11 @@
package com.flagship.abox.manager.session.credential
import com.flagship.abox.manager.domain.ProviderProfileId
interface CredentialCipher {
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
fun resetPermanentlyInvalidatedKey()
}
@@ -0,0 +1,138 @@
package com.flagship.abox.manager.session.credential
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.CredentialStore
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.Username
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import android.security.keystore.KeyPermanentlyInvalidatedException
import java.util.Base64
import javax.crypto.AEADBadTagException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
class KeystoreCredentialStore(
private val dataStore: DataStore<Preferences>,
private val cipher: CredentialCipher,
) : CredentialStore {
override suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit> = operation {
val encrypted = cipher.encrypt(profileId, encode(credentials))
dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) }
}
override suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?> {
return try {
val value = dataStore.data.first()[credentialKey(profileId)]
?: return DomainResult.Success(null)
val encrypted = Base64.getDecoder().decode(value)
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (error.isPermanentCredentialFailure()) {
try {
if (error.hasPermanentlyInvalidatedKey()) {
cipher.resetPermanentlyInvalidatedKey()
}
dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null)
} catch (cleanupError: CancellationException) {
throw cleanupError
} catch (cleanupError: Exception) {
DomainResult.Failure(
DomainError.Unknown(
detail = cleanupError.message ?: cleanupError::class.java.simpleName,
),
)
}
} else {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
}
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> = operation {
dataStore.edit { it.remove(credentialKey(profileId)) }
}
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
DataOutputStream(output).use { data ->
data.writeByte(CREDENTIAL_FORMAT_VERSION)
data.writeByteArray(credentials.username.value.toByteArray(Charsets.UTF_8))
data.writeByteArray(credentials.password.value.toByteArray(Charsets.UTF_8))
}
output.toByteArray()
}
private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
ByteArrayInputStream(value),
).use { data ->
require(data.readUnsignedByte() == CREDENTIAL_FORMAT_VERSION) {
"Unsupported credential data format"
}
val username = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8)
val password = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8)
require(data.available() == 0) { "Unexpected credential data" }
LoginCredentials(Username(username), Password(password))
}
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
companion object {
private const val CREDENTIAL_FORMAT_VERSION = 1
private const val MAX_CREDENTIAL_FIELD_BYTES = 1_048_576
private fun credentialKey(profileId: ProviderProfileId) =
stringPreferencesKey("profile_${profileId.value}_credential")
}
}
private fun DataOutputStream.writeByteArray(value: ByteArray) {
require(value.size <= 1_048_576) { "Credential field is too large" }
writeInt(value.size)
write(value)
}
private fun DataInputStream.readByteArray(maxSize: Int): ByteArray {
val length = readInt()
require(length in 0..maxSize && length <= available()) { "Invalid credential field length" }
return ByteArray(length).also(::readFully)
}
private fun Throwable.hasPermanentlyInvalidatedKey(): Boolean =
this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException,
is KeyPermanentlyInvalidatedException,
is IllegalArgumentException,
-> true
else -> cause?.isPermanentCredentialFailure() == true
}
@@ -0,0 +1,138 @@
package com.flagship.abox.manager.session.profile
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore {
private val safeData: Flow<Preferences>
get() = dataStore.data
.retryWhen { cause, attempt ->
val retry = cause is IOException && attempt < MAX_READ_RETRIES
if (retry) delay(READ_RETRY_DELAY_MILLIS)
retry
}
.distinctUntilChanged()
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
safeData.map { preferencesFrom(it, profileId) }
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(safeData.first())
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(safeData.first())
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferencesFrom(safeData.first(), profileId)
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
preferences[PROFILE_IDS] = preferences[PROFILE_IDS].orEmpty() + profile.id.value
preferences[profileTypeKey(profile.id)] = profile.type.name
preferences[profileNameKey(profile.id)] = profile.displayName
}
}
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
else preferences[ACTIVE_PROFILE_ID] = profileId.value
}
}
override suspend fun saveLoginPreferences(
preferences: LoginPreferences,
): DomainResult<Unit> = operation {
dataStore.edit { values ->
val profileId = preferences.profileId
values[rememberUsernameKey(profileId)] = preferences.rememberUsername
values[autoLoginKey(profileId)] = preferences.autoLoginEnabled
val username = preferences.rememberedUsername?.value
if (username == null) values.remove(usernameKey(profileId))
else values[usernameKey(profileId)] = username
}
}
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
preferences[PROFILE_IDS].orEmpty().mapNotNull { idValue ->
val id = ProviderProfileId(idValue)
val type = preferences[profileTypeKey(id)]
?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() }
?: return@mapNotNull null
val displayName = preferences[profileNameKey(id)] ?: return@mapNotNull null
ProviderProfile(id, type, displayName)
}.sortedBy { it.id.value }
private fun activeProfileFrom(preferences: Preferences): ProviderProfile? {
val activeId = preferences[ACTIVE_PROFILE_ID] ?: return null
return profilesFrom(preferences).firstOrNull { it.id.value == activeId }
}
private fun preferencesFrom(
preferences: Preferences,
profileId: ProviderProfileId,
): LoginPreferences = LoginPreferences(
profileId = profileId,
rememberedUsername = preferences[usernameKey(profileId)]?.let(::Username),
rememberUsername = preferences[rememberUsernameKey(profileId)] ?: false,
autoLoginEnabled = preferences[autoLoginKey(profileId)] ?: false,
)
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown(
detail = error.message ?: error::class.java.simpleName,
),
)
}
companion object {
private const val MAX_READ_RETRIES = 2L
private const val READ_RETRY_DELAY_MILLIS = 100L
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
private fun profileTypeKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_type")
private fun profileNameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_name")
private fun usernameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_username")
private fun rememberUsernameKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_remember_username")
private fun autoLoginKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_auto_login")
}
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.session.profile
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
@@ -0,0 +1,45 @@
package com.flagship.abox.manager.session
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CredentialCleanupFailureTest {
@Test
fun cleanupFailureReturnsDomainFailureInsteadOfThrowing() = runTest {
val store = KeystoreCredentialStore(FailingCleanupDataStore(), UnusedCipher())
val result = store.loadCredentials(ProviderProfileId("profile"))
assertTrue(result is DomainResult.Failure)
assertEquals("disk unavailable", (result as DomainResult.Failure).error.detail)
}
private class FailingCleanupDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = flow {
throw IllegalArgumentException("corrupt credential")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = throw IOException("disk unavailable")
}
private class UnusedCipher : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted
override fun resetPermanentlyInvalidatedKey() = Unit
}
}
@@ -0,0 +1,89 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
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.Username
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CredentialStoreTest {
@Test
fun storesClearsAndIsolatesCredentials() = runTest {
val file = File.createTempFile("credentials", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = KeystoreCredentialStore(dataStore, ReversingCipher())
val first = ProviderProfileId("alpha")
val second = ProviderProfileId("bravo")
val firstCredentials = LoginCredentials(Username("one"), Password("secret-one"))
val secondCredentials = LoginCredentials(Username("two"), Password("secret-two"))
store.saveCredentials(first, firstCredentials)
store.saveCredentials(second, secondCredentials)
store.clearCredentials(first)
assertNull((store.loadCredentials(first) as DomainResult.Success).value)
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
}
@Test
fun credentialsLargerThanWriteUtfLimitRoundTrip() = runTest {
val file = File.createTempFile("large-credentials", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = KeystoreCredentialStore(dataStore, ReversingCipher())
val profile = ProviderProfileId("large")
val credentials = LoginCredentials(
Username("u".repeat(70_000)),
Password("p".repeat(70_000)),
)
assertEquals(DomainResult.Success(Unit), store.saveCredentials(profile, credentials))
assertEquals(credentials, (store.loadCredentials(profile) as DomainResult.Success).value)
}
@Test
fun malformedCredentialIsRemoved() = runTest {
val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val profile = ProviderProfileId("profile")
val store = KeystoreCredentialStore(dataStore, RejectingCipher())
store.saveCredentials(
profile,
LoginCredentials(Username("demo"), Password("secret")),
)
assertNull((store.loadCredentials(profile) as DomainResult.Success).value)
assertNull((store.loadCredentials(profile) as DomainResult.Success).value)
}
private class RejectingCipher : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = byteArrayOf(1)
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
throw IllegalArgumentException("Credential payload is truncated")
}
override fun resetPermanentlyInvalidatedKey() = Unit
}
private class ReversingCipher : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray =
(profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
val decoded = encrypted.reversedArray()
val delimiter = decoded.indexOf(0)
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
return decoded.copyOfRange(delimiter + 1, decoded.size)
}
override fun resetPermanentlyInvalidatedKey() = Unit
}
}
@@ -0,0 +1,43 @@
package com.flagship.abox.manager.session
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertFalse
import org.junit.Test
class ProviderProfileStoreFailureTest {
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Test
fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest {
val store = ProviderProfileStoreImpl(FailingReadDataStore())
var completed = false
backgroundScope.launch {
runCatching { withTimeout(250) { store.activeProfile() } }
completed = true
}
advanceTimeBy(200)
assertFalse(completed)
}
private class FailingReadDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = flow {
throw IOException("disk unavailable")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(emptyPreferences())
}
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class ProviderProfileStoreTest {
@Test
fun persistsActiveProfileAndLoginPreferences() = runTest {
val file = File.createTempFile("profiles", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = ProviderProfileStoreImpl(dataStore)
val id = ProviderProfileId("abox")
val profile = ProviderProfile(id, ProviderType.ABOX, "ABox")
val preferences = LoginPreferences(id, Username("demo"), true, true)
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
store.setActiveProfile(id)
store.saveLoginPreferences(preferences)
assertEquals(profile, store.observeActiveProfile().first())
assertEquals(preferences, store.observeLoginPreferences(id).first())
}
}
@@ -0,0 +1,32 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class RemovedProviderProfileTest {
@Test
fun removedCompatibleProfileIsIgnoredAndCannotRemainActive() = runTest {
val file = File.createTempFile("removed-provider", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
dataStore.edit { values ->
values[stringSetPreferencesKey("profile_ids")] = setOf("compatible-default")
values[stringPreferencesKey("profile_compatible-default_type")] = "COMPATIBLE_BACKEND"
values[stringPreferencesKey("profile_compatible-default_name")] = "Compatible backend"
values[stringPreferencesKey("active_profile_id")] = "compatible-default"
}
val store = ProviderProfileStoreImpl(dataStore)
assertTrue(store.profiles().isEmpty())
assertNull(store.activeProfile())
assertNull(store.observeActiveProfile().first())
}
}
+76
View File
@@ -0,0 +1,76 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager.infrared"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
kapt {
correctErrorTypes = true
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
kapt(libs.room.compiler)
implementation(libs.datastore.preferences)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(project(":testing:fakes"))
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -0,0 +1,136 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "be673e12eb4c5275aaa6ef6fc189a861",
"entities": [
{
"tableName": "infrared_keys",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sdkKey` TEXT NOT NULL, `status` TEXT NOT NULL, `lastErrorCode` TEXT, `lastErrorDetail` TEXT, `createdAtEpochMillis` INTEGER NOT NULL, `updatedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceName",
"columnName": "deviceName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sdkKey",
"columnName": "sdkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastErrorCode",
"columnName": "lastErrorCode",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastErrorDetail",
"columnName": "lastErrorDetail",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAtEpochMillis",
"columnName": "createdAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAtEpochMillis",
"columnName": "updatedAtEpochMillis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_infrared_keys_profileId_deviceName",
"unique": false,
"columnNames": [
"profileId",
"deviceName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName` ON `${TABLE_NAME}` (`profileId`, `deviceName`)"
},
{
"name": "index_infrared_keys_profileId_deviceName_sdkKey",
"unique": true,
"columnNames": [
"profileId",
"deviceName",
"sdkKey"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName_sdkKey` ON `${TABLE_NAME}` (`profileId`, `deviceName`, `sdkKey`)"
}
],
"foreignKeys": []
},
{
"tableName": "infrared_device_configuration",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, PRIMARY KEY(`profileId`))",
"fields": [
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceName",
"columnName": "deviceName",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"profileId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be673e12eb4c5275aaa6ef6fc189a861')"
]
}
}
@@ -0,0 +1,175 @@
package com.flagship.abox.manager.infrared.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomInfraredKeyRepositoryTest {
private lateinit var database: InfraredDatabase
private lateinit var repository: RoomInfraredKeyRepository
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, InfraredDatabase::class.java)
.allowMainThreadQueries()
.build()
repository = RoomInfraredKeyRepository(database.infraredDao())
}
@After
fun teardown() {
database.close()
}
@Test
fun observesOnlyRequestedProfileAndDevice() = runTest {
repository.insert(testKey(profile = "one", device = "ir-1", id = "a", sdkKey = "key_a"))
repository.insert(testKey(profile = "one", device = "ir-2", id = "b", sdkKey = "key_b"))
repository.insert(testKey(profile = "two", device = "ir-1", id = "c", sdkKey = "key_c"))
val result = repository.observeKeys(
ProviderProfileId("one"),
DeviceName("ir-1"),
).first() as DomainResult.Success
assertEquals(listOf(InfraredKeyId("a")), result.value.map { it.id })
}
@Test
fun rejectsDuplicateSdkBinding() = runTest {
val first = repository.insert(testKey(id = "a", sdkKey = "app_ir_power"))
assertTrue(first is DomainResult.Success)
val duplicate = repository.insert(testKey(id = "b", sdkKey = "app_ir_power"))
assertTrue(duplicate is DomainResult.Failure)
}
@Test
fun transitionsStatusCorrectly() = runTest {
val key = testKey(id = "a", status = InfraredKeyStatus.LEARNING)
repository.insert(key)
val transitionResult = repository.transitionStatus(
ref = InfraredKeyRef(key.profileId, key.id),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = 2_000L,
)
assertTrue(transitionResult is DomainResult.Success)
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, fetched.status)
assertEquals(2_000L, fetched.updatedAtEpochMillis)
}
@Test
fun renamesKeyWithoutChangingSdkKey() = runTest {
val key = testKey(id = "a", displayName = "Old Name", sdkKey = "app_ir_power")
repository.insert(key)
val renameResult = repository.rename(
ref = InfraredKeyRef(key.profileId, key.id),
displayName = "New Name",
updatedAtEpochMillis = 3_000L,
)
assertTrue(renameResult is DomainResult.Success)
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
assertEquals("New Name", fetched.displayName)
assertEquals("app_ir_power", fetched.sdkKey)
assertEquals(3_000L, fetched.updatedAtEpochMillis)
}
@Test
fun deleteRejectsLearningKeyAndRemovesReadyKey() = runTest {
val learningKey = testKey(id = "learning", status = InfraredKeyStatus.LEARNING)
val readyKey = testKey(id = "ready", sdkKey = "ready_sdk_key", status = InfraredKeyStatus.READY)
repository.insert(learningKey)
repository.insert(readyKey)
val deleteLearning = repository.deleteIfNotLearning(InfraredKeyRef(learningKey.profileId, learningKey.id))
assertTrue(deleteLearning is DomainResult.Failure)
val deleteReady = repository.deleteIfNotLearning(InfraredKeyRef(readyKey.profileId, readyKey.id))
assertTrue(deleteReady is DomainResult.Success)
val fetchedReady = (repository.getKey(InfraredKeyRef(readyKey.profileId, readyKey.id)) as DomainResult.Success).value
assertNull(fetchedReady)
}
@Test
fun failsInterruptedLearningKeys() = runTest {
repository.insert(testKey(id = "k1", sdkKey = "k1", status = InfraredKeyStatus.LEARNING))
repository.insert(testKey(id = "k2", sdkKey = "k2", status = InfraredKeyStatus.LEARNING))
repository.insert(testKey(id = "k3", sdkKey = "k3", status = InfraredKeyStatus.READY))
val countResult = repository.failInterruptedLearning(ProviderProfileId("profile1"), 5_000L) as DomainResult.Success
assertEquals(2, countResult.value)
val k1 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, k1.status)
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, k1.lastErrorDetail)
assertNull(k1.lastErrorCode)
assertEquals(5_000L, k1.updatedAtEpochMillis)
val k3 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k3"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, k3.status)
}
@Test
fun persistsAndObservesDeviceName() = runTest {
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
assertNull(initial.value)
val setResult = repository.setDeviceName(ProviderProfileId("profile1"), DeviceName("ir_main"))
assertTrue(setResult is DomainResult.Success)
val observed = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
assertEquals(DeviceName("ir_main"), observed.value)
}
private fun testKey(
profile: String = "profile1",
device: String = "ir-1",
id: String = "key-1",
displayName: String = "Power",
sdkKey: String = "app_ir_key1",
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
): SavedInfraredKey {
return SavedInfraredKey(
id = InfraredKeyId(id),
profileId = ProviderProfileId(profile),
deviceName = DeviceName(device),
displayName = displayName,
sdkKey = sdkKey,
status = status,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 1_000L,
updatedAtEpochMillis = 1_000L,
)
}
}
@@ -0,0 +1,45 @@
package com.flagship.abox.manager.infrared.remote
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.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = {},
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
}
}
@@ -0,0 +1,121 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredControlScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displaysModeAndDeviceInformation() {
val state = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.FAKE,
effectiveProfileId = ProviderProfileId("abox:infrared-fake"),
savedDeviceName = DeviceName("ir-living"),
loading = false,
)
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 1,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = {},
onRename = { _, _ -> },
onDelete = {},
onModeChanged = {},
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed()
}
@Test
fun rendersKeysWithCorrectActionButtons() {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k-ready"),
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Power Key",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k-failed"),
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Volume Up",
sdkKey = "2",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = null,
createdAtEpochMillis = 200L,
updatedAtEpochMillis = 200L,
)
val state = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.REAL_ABSDK,
savedDeviceName = DeviceName("ir-1"),
keys = listOf(readyKey, failedKey),
loading = false,
)
var sentKeyId: InfraredKeyId? = null
var retriedKeyId: InfraredKeyId? = null
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 3,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = { retriedKeyId = it },
onSend = { sentKeyId = it },
onRename = { _, _ -> },
onDelete = {},
onModeChanged = {},
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick()
assertEquals(InfraredKeyId("k-ready"), sentKeyId)
composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick()
assertEquals(InfraredKeyId("k-failed"), retriedKeyId)
}
}
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,32 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val externalScope: CoroutineScope,
) {
fun learn(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
) {
externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
}
fun retry(ref: InfraredKeyRef) {
externalScope.launch {
retryInfraredKeyLearning(ref)
}
}
}
@@ -0,0 +1,105 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface InfraredDao {
@Query(
"""
SELECT * FROM infrared_keys
WHERE profileId = :profileId AND deviceName = :deviceName
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeKeys(profileId: String, deviceName: String): Flow<List<InfraredKeyEntity>>
@Query(
"""
SELECT * FROM infrared_keys
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
suspend fun getKey(profileId: String, id: String): InfraredKeyEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertKey(entity: InfraredKeyEntity)
@Query(
"""
UPDATE infrared_keys
SET status = :targetStatus,
lastErrorCode = :lastErrorCode,
lastErrorDetail = :lastErrorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId
AND id = :id
AND status IN (:expectedStatuses)
""",
)
suspend fun transitionStatus(
profileId: String,
id: String,
expectedStatuses: List<String>,
targetStatus: String,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
UPDATE infrared_keys
SET displayName = :displayName,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun renameKey(
profileId: String,
id: String,
displayName: String,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
DELETE FROM infrared_keys
WHERE profileId = :profileId AND id = :id AND status != 'LEARNING'
""",
)
suspend fun deleteKeyIfNotLearning(profileId: String, id: String): Int
@Query(
"""
UPDATE infrared_keys
SET status = :targetStatus,
lastErrorCode = NULL,
lastErrorDetail = :errorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND status = 'LEARNING'
""",
)
suspend fun failInterruptedLearning(
profileId: String,
targetStatus: String,
errorDetail: String,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
SELECT * FROM infrared_device_configuration
WHERE profileId = :profileId
LIMIT 1
""",
)
fun observeDeviceName(profileId: String): Flow<InfraredDeviceConfigurationEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
}
@@ -0,0 +1,16 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [
InfraredKeyEntity::class,
InfraredDeviceConfigurationEntity::class,
],
version = 1,
exportSchema = true,
)
abstract class InfraredDatabase : RoomDatabase() {
abstract fun infraredDao(): InfraredDao
}
@@ -0,0 +1,33 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "infrared_keys",
indices = [
Index(value = ["profileId", "deviceName"]),
Index(value = ["profileId", "deviceName", "sdkKey"], unique = true),
],
)
data class InfraredKeyEntity(
@PrimaryKey
val id: String,
val profileId: String,
val deviceName: String,
val displayName: String,
val sdkKey: String,
val status: String,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@Entity(tableName = "infrared_device_configuration")
data class InfraredDeviceConfigurationEntity(
@PrimaryKey
val profileId: String,
val deviceName: String,
)
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.infrared.data.local
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
fun InfraredKeyEntity.toDomain(): SavedInfraredKey {
return SavedInfraredKey(
id = InfraredKeyId(id),
profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName),
displayName = displayName,
sdkKey = sdkKey,
status = InfraredKeyStatus.valueOf(status),
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
fun SavedInfraredKey.toEntity(): InfraredKeyEntity {
return InfraredKeyEntity(
id = id.value,
profileId = profileId.value,
deviceName = deviceName.value,
displayName = displayName,
sdkKey = sdkKey,
status = status.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
@@ -0,0 +1,190 @@
package com.flagship.abox.manager.infrared.data.local
import android.database.sqlite.SQLiteConstraintException
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
class RoomInfraredKeyRepository(
private val dao: InfraredDao,
) : InfraredKeyRepository, InfraredDeviceConfigurationRepository {
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> {
return dao.observeKeys(profileId.value, deviceName.value)
.map<List<InfraredKeyEntity>, DomainResult<List<SavedInfraredKey>>> { entities ->
DomainResult.Success(entities.map { it.toDomain() })
}
.catch { throwable ->
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
override fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> {
return dao.observeDeviceName(profileId.value)
.map<InfraredDeviceConfigurationEntity?, DomainResult<DeviceName?>> { entity ->
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
}
.catch { throwable ->
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
return try {
val entity = dao.getKey(ref.profileId.value, ref.keyId.value)
DomainResult.Success(entity?.toDomain())
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
return try {
dao.insertKey(key.toEntity())
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
return try {
val updated = dao.transitionStatus(
profileId = ref.profileId.value,
id = ref.keyId.value,
expectedStatuses = expectedStatuses.map { it.name },
targetStatus = targetStatus.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
if (updated > 0) {
DomainResult.Success(Unit)
} else {
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
if (existing == null) {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
} else {
DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Cannot transition key from ${existing.status} to ${targetStatus.name}",
),
)
}
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Display name cannot be blank"))
}
return try {
val updated = dao.renameKey(
profileId = ref.profileId.value,
id = ref.keyId.value,
displayName = trimmed,
updatedAtEpochMillis = updatedAtEpochMillis,
)
if (updated > 0) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
return try {
val deleted = dao.deleteKeyIfNotLearning(ref.profileId.value, ref.keyId.value)
if (deleted > 0) {
DomainResult.Success(Unit)
} else {
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
if (existing == null) {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
} else {
DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete key while learning is in progress"),
)
}
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
return try {
val count = dao.failInterruptedLearning(
profileId = profileId.value,
targetStatus = InfraredKeyStatus.FAILED.name,
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
DomainResult.Success(count)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit> {
val trimmed = deviceName.value.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name cannot be blank"))
}
return try {
dao.setDeviceName(
InfraredDeviceConfigurationEntity(
profileId = profileId.value,
deviceName = trimmed,
),
)
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
return when (throwable) {
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
else -> DomainError.Unknown(detail = "Database operation failed")
}
}
}
@@ -0,0 +1,184 @@
package com.flagship.abox.manager.infrared.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.data.local.InfraredDao
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
import com.flagship.abox.manager.infrared.runtime.DataStoreInfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.util.UUID
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredApplicationScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredDataStore
@Module
@InstallIn(SingletonComponent::class)
object InfraredFeatureModule {
@Provides
@Singleton
fun provideInfraredDatabase(
@ApplicationContext context: Context,
): InfraredDatabase {
return Room.databaseBuilder(
context,
InfraredDatabase::class.java,
"abox_infrared.db",
).build()
}
@Provides
@Singleton
fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao()
@Provides
@Singleton
fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository =
RoomInfraredKeyRepository(dao)
@Provides
@Singleton
fun provideInfraredKeyRepository(
repository: RoomInfraredKeyRepository,
): InfraredKeyRepository = repository
@Provides
@Singleton
fun provideInfraredDeviceConfigurationRepository(
repository: RoomInfraredKeyRepository,
): InfraredDeviceConfigurationRepository = repository
@Provides
@Singleton
@InfraredDataStore
fun provideInfraredPreferencesDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("infrared_preferences") },
)
}
@Provides
@Singleton
fun provideInfraredRuntimeModeStore(
@InfraredDataStore dataStore: DataStore<Preferences>,
): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore)
@Provides
@Singleton
fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver =
InfraredRuntimeProfileResolver()
@Provides
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
@Provides
fun provideEpochMillisClock(): EpochMillisClock =
EpochMillisClock { System.currentTimeMillis() }
@Provides
@Singleton
fun provideInfraredKeyFactory(
idGenerator: InfraredKeyIdGenerator,
clock: EpochMillisClock,
): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock)
@Provides
@Singleton
@InfraredApplicationScope
fun provideInfraredApplicationScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun provideLearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
factory: InfraredKeyFactory,
clock: EpochMillisClock,
): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock)
@Provides
@Singleton
fun provideRetryInfraredKeyLearning(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock)
@Provides
@Singleton
fun provideSendInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
): SendInfraredKey = SendInfraredKey(repository, gateway)
@Provides
@Singleton
fun provideRenameInfraredKey(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RenameInfraredKey = RenameInfraredKey(repository, clock)
@Provides
@Singleton
fun provideDeleteInfraredKey(
repository: InfraredKeyRepository,
): DeleteInfraredKey = DeleteInfraredKey(repository)
@Provides
@Singleton
fun provideRecoverInterruptedInfraredLearning(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
@Provides
@Singleton
fun provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning,
@InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning,
externalScope = scope,
)
}
@@ -0,0 +1,214 @@
package com.flagship.abox.manager.infrared.remote
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.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 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
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @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,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), 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))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { args: Array<Any?> ->
val scene = args[0] as RemoteScene
val isLearning = args[1] as Boolean
val profileAndMode = args[2] as Pair<ProviderProfileId?, InfraredRuntimeMode>
val deviceResult = args[3] as DomainResult<DeviceName?>
val keysResult = args[4] as DomainResult<List<SavedInfraredKey>>
val sendingSlot = args[5] as String?
val learningSlot = args[6] as String?
val msg = args[7] as RemoteUiMessage?
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (isLearningModeFlow.value) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU,
)
}
}
@@ -0,0 +1,172 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@Composable
fun AcRemotePanel(
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
modifier: Modifier = Modifier,
) {
RemoteChassis(modifier = modifier) {
// 顶部虚拟 LCD 液晶显示区
AcLcdDisplay()
// 电源与温度控制行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// 电源按键
val powerSpec = RemoteSceneConfig.AirConditioner.POWER
val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY
val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot
RemoteCircleButton(
spec = powerSpec,
isLearned = isPowerLearned,
isLoading = isPowerLoading,
isLearningMode = isLearningMode,
onClick = { onButtonClick(powerSpec) },
size = 72.dp,
containerColor = Color(0xFFE53935),
contentColor = Color.White,
)
// 垂直胶囊温度调节器
val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP
val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN
VerticalCapsuleRocker(
upSpec = tempUpSpec,
downSpec = tempDownSpec,
isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot,
isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(tempUpSpec) },
onDownClick = { onButtonClick(tempDownSpec) },
label = "温度调节",
)
}
Spacer(modifier = Modifier.height(4.dp))
// 功能按键网格 (模式 / 风速 / 扫风 / 定时)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
}
}
}
@Composable
private fun AcGridButton(
spec: RemoteButtonSpec,
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
) {
RemoteRectButton(
spec = spec,
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(spec) },
width = 120.dp,
height = 52.dp,
)
}
@Composable
private fun AcLcdDisplay() {
Card(
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
.shadow(4.dp, shape = RoundedCornerShape(16.dp)),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B),
),
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "❄️ 制冷模式",
color = Color(0xFF38BDF8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = "💨 自动风速 | ↔ 扫风",
color = Color(0xFF94A3B8),
fontSize = 12.sp,
)
}
Text(
text = "26°C",
color = Color(0xFF4ADE80),
fontSize = 38.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
}
}
@@ -0,0 +1,246 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteScene
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
@Composable
fun InfraredRemoteRoute(
onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredRemoteViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredRemoteScreen(
state = state,
onBack = onBack,
onOpenSettings = onOpenSettings,
onSessionInvalid = onSessionInvalid,
onSceneSelected = viewModel::switchScene,
onToggleLearningMode = viewModel::toggleLearningMode,
onButtonClick = viewModel::onButtonClick,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredRemoteScreen(
state: InfraredRemoteUiState,
onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
onSceneSelected: (RemoteScene) -> Unit,
onToggleLearningMode: (Boolean) -> Unit,
onButtonClick: (RemoteButtonSpec) -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is RemoteUiMessage.Info -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is RemoteUiMessage.Error -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is RemoteUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "红外遥控器",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
},
navigationIcon = {
TextButton(onClick = onBack) {
Text("返回")
}
},
actions = {
// 学习模式开关
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = if (state.isLearningMode) "录制中" else "学习模式",
fontSize = 12.sp,
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = state.isLearningMode,
onCheckedChange = onToggleLearningMode,
modifier = Modifier.testTag("remote_learning_mode_switch"),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.error,
checkedTrackColor = MaterialTheme.colorScheme.errorContainer,
),
)
}
// 设置齿轮按钮
IconButton(
onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 学习模式顶部横幅提示
if (state.isLearningMode) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
shape = RoundedCornerShape(12.dp),
)
.padding(vertical = 8.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "🔴 红外学习模式已开启:点击任意按键即可录制信号",
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.SemiBold,
),
)
}
}
// 场景切换 Tab (空调 | 电视)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
label = { Text("❄️ 空调遥控") },
modifier = Modifier
.padding(end = 8.dp)
.testTag("scene_tab_ac"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
FilterChip(
selected = state.selectedScene == RemoteScene.TELEVISION,
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
label = { Text("📺 电视遥控") },
modifier = Modifier.testTag("scene_tab_tv"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}
// 对应场景遥控器面板
when (state.selectedScene) {
RemoteScene.AIR_CONDITIONER -> {
AcRemotePanel(
learnedSlotMap = state.learnedSlotMap,
activeLearningSlot = state.activeLearningSlot,
sendingKeySlot = state.sendingKeySlot,
isLearningMode = state.isLearningMode,
onButtonClick = onButtonClick,
)
}
RemoteScene.TELEVISION -> {
TvRemotePanel(
learnedSlotMap = state.learnedSlotMap,
activeLearningSlot = state.activeLearningSlot,
sendingKeySlot = state.sendingKeySlot,
isLearningMode = state.isLearningMode,
onButtonClick = onButtonClick,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@@ -0,0 +1,407 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
@Composable
fun RemoteChassis(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.shadow(12.dp, shape = RoundedCornerShape(32.dp)),
shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
content()
}
}
}
@Composable
fun RemoteCircleButton(
spec: RemoteButtonSpec,
isLearned: Boolean,
isLoading: Boolean,
isLearningMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 64.dp,
containerColor: Color? = null,
contentColor: Color? = null,
) {
val defaultContainer = when {
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
isLearned -> containerColor ?: MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.surface
}
val defaultContent = when {
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
val borderModifier = if (isLearningMode) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
} else if (!isLearned) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
} else {
Modifier
}
Surface(
modifier = modifier
.size(size)
.then(borderModifier)
.testTag("remote_button_${spec.id}"),
shape = CircleShape,
color = defaultContainer,
contentColor = defaultContent,
shadowElevation = if (isLearned) 4.dp else 1.dp,
onClick = onClick,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(size * 0.45f),
strokeWidth = 2.5.dp,
color = defaultContent,
)
} else {
Text(
text = spec.symbol,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp,
fontWeight = FontWeight.Bold,
),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
fun RemoteRectButton(
spec: RemoteButtonSpec,
isLearned: Boolean,
isLoading: Boolean,
isLearningMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 100.dp,
height: Dp = 48.dp,
) {
val containerColor = when {
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
isLearned -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
}
val contentColor = when {
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
isLearned -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
val borderModifier = if (isLearningMode) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp))
} else if (!isLearned) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
} else {
Modifier
}
Surface(
modifier = modifier
.width(width)
.height(height)
.then(borderModifier)
.testTag("remote_button_${spec.id}"),
shape = RoundedCornerShape(16.dp),
color = containerColor,
contentColor = contentColor,
shadowElevation = if (isLearned) 3.dp else 1.dp,
onClick = onClick,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = contentColor,
)
} else {
Text(
text = spec.symbol,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
fun VerticalCapsuleRocker(
upSpec: RemoteButtonSpec,
downSpec: RemoteButtonSpec,
isUpLearned: Boolean,
isDownLearned: Boolean,
isUpLoading: Boolean,
isDownLoading: Boolean,
isLearningMode: Boolean,
onUpClick: () -> Unit,
onDownClick: () -> Unit,
modifier: Modifier = Modifier,
label: String = "",
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Card(
modifier = Modifier
.width(68.dp)
.height(136.dp)
.shadow(4.dp, shape = RoundedCornerShape(34.dp)),
shape = RoundedCornerShape(34.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
// Top half
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp))
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isUpLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(
text = upSpec.symbol,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Bottom half
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp))
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isDownLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(
text = downSpec.symbol,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
}
}
}
}
if (label.isNotBlank()) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun RemoteDpadWheel(
upSpec: RemoteButtonSpec,
downSpec: RemoteButtonSpec,
leftSpec: RemoteButtonSpec,
rightSpec: RemoteButtonSpec,
okSpec: RemoteButtonSpec,
isUpLearned: Boolean,
isDownLearned: Boolean,
isLeftLearned: Boolean,
isRightLearned: Boolean,
isOkLearned: Boolean,
isUpLoading: Boolean,
isDownLoading: Boolean,
isLeftLoading: Boolean,
isRightLoading: Boolean,
isOkLoading: Boolean,
isLearningMode: Boolean,
onUpClick: () -> Unit,
onDownClick: () -> Unit,
onLeftClick: () -> Unit,
onRightClick: () -> Unit,
onOkClick: () -> Unit,
modifier: Modifier = Modifier,
wheelSize: Dp = 190.dp,
) {
Card(
modifier = modifier
.size(wheelSize)
.shadow(6.dp, shape = CircleShape),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
// Up Button
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.size(54.dp)
.clip(CircleShape)
.clickable(onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Down Button
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
.size(54.dp)
.clip(CircleShape)
.clickable(onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Left Button
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 10.dp)
.size(54.dp)
.clip(CircleShape)
.clickable(onClick = onLeftClick)
.testTag("remote_button_${leftSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Right Button
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 10.dp)
.size(54.dp)
.clip(CircleShape)
.clickable(onClick = onRightClick)
.testTag("remote_button_${rightSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Center OK Button
RemoteCircleButton(
spec = okSpec,
isLearned = isOkLearned,
isLoading = isOkLoading,
isLearningMode = isLearningMode,
onClick = onOkClick,
size = 62.dp,
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@@ -0,0 +1,161 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@Composable
fun TvRemotePanel(
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
modifier: Modifier = Modifier,
) {
RemoteChassis(modifier = modifier) {
// 顶部电源与静音行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val powerSpec = RemoteSceneConfig.Television.POWER
RemoteCircleButton(
spec = powerSpec,
isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(powerSpec) },
size = 56.dp,
containerColor = Color(0xFFE53935),
contentColor = Color.White,
)
val muteSpec = RemoteSceneConfig.Television.MUTE
RemoteCircleButton(
spec = muteSpec,
isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(muteSpec) },
size = 56.dp,
)
}
// 中心圆形方向轮盘 (D-Pad Wheel)
val upSpec = RemoteSceneConfig.Television.NAV_UP
val downSpec = RemoteSceneConfig.Television.NAV_DOWN
val leftSpec = RemoteSceneConfig.Television.NAV_LEFT
val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT
val okSpec = RemoteSceneConfig.Television.OK
RemoteDpadWheel(
upSpec = upSpec,
downSpec = downSpec,
leftSpec = leftSpec,
rightSpec = rightSpec,
okSpec = okSpec,
isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot,
isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot,
isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot,
isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot,
isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(upSpec) },
onDownClick = { onButtonClick(downSpec) },
onLeftClick = { onButtonClick(leftSpec) },
onRightClick = { onButtonClick(rightSpec) },
onOkClick = { onButtonClick(okSpec) },
)
// 系统功能按键行 (返回 / 主页 / 菜单)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
Spacer(modifier = Modifier.height(4.dp))
// 音量与频道双摇杆胶囊
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// 音量控制
val volUp = RemoteSceneConfig.Television.VOL_UP
val volDown = RemoteSceneConfig.Television.VOL_DOWN
VerticalCapsuleRocker(
upSpec = volUp,
downSpec = volDown,
isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot,
isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(volUp) },
onDownClick = { onButtonClick(volDown) },
label = "音量 VOL",
)
// 频道控制
val chUp = RemoteSceneConfig.Television.CH_UP
val chDown = RemoteSceneConfig.Television.CH_DOWN
VerticalCapsuleRocker(
upSpec = chUp,
downSpec = chDown,
isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot,
isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(chUp) },
onDownClick = { onButtonClick(chDown) },
label = "频道 CH",
)
}
}
}
@Composable
private fun TvActionButton(
spec: RemoteButtonSpec,
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
) {
RemoteRectButton(
spec = spec,
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(spec) },
width = 84.dp,
height = 44.dp,
)
}
@@ -0,0 +1,58 @@
package com.flagship.abox.manager.infrared.runtime
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.IOException
class DataStoreInfraredRuntimeModeStore(
private val dataStore: DataStore<Preferences>,
) : InfraredRuntimeModeStore {
companion object {
private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode")
}
override val mode: Flow<InfraredRuntimeMode> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(androidx.datastore.preferences.core.emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val storedName = preferences[KEY_RUNTIME_MODE]
if (storedName != null) {
try {
InfraredRuntimeMode.valueOf(storedName)
} catch (_: IllegalArgumentException) {
InfraredRuntimeMode.FAKE
}
} else {
InfraredRuntimeMode.FAKE
}
}
override suspend fun currentMode(): InfraredRuntimeMode {
return mode.first()
}
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
return try {
dataStore.edit { preferences ->
preferences[KEY_RUNTIME_MODE] = mode.name
}
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
}
}
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
enum class InfraredRuntimeMode {
FAKE,
REAL_ABSDK,
}
interface InfraredRuntimeModeStore {
val mode: Flow<InfraredRuntimeMode>
suspend fun currentMode(): InfraredRuntimeMode
suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit>
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
class InfraredRuntimeProfileResolver {
fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId {
return when (mode) {
InfraredRuntimeMode.REAL_ABSDK -> realProfileId
InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake")
}
}
}
@@ -0,0 +1,466 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
@Composable
fun InfraredControlRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredControlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredControlScreen(
state = state,
nextSlot = viewModel.nextAvailableKeySlot(),
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
onSaveDeviceName = viewModel::saveDeviceName,
onLearn = viewModel::learnKey,
onRetry = viewModel::retryLearning,
onSend = viewModel::sendKey,
onRename = viewModel::renameKey,
onDelete = viewModel::deleteKey,
onModeChanged = viewModel::setRuntimeMode,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
nextSlot: Int,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onLearn: (String, String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit,
onDelete: (InfraredKeyId) -> Unit,
onModeChanged: (InfraredRuntimeMode) -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
var showLearnDialog by remember { mutableStateOf(false) }
var keyToRename by remember { mutableStateOf<SavedInfraredKey?>(null) }
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is InfraredUiMessage.Info -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is InfraredUiMessage.Error -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is InfraredUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.infrared_title)) },
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Mode indicator and toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
stringResource(R.string.infrared_mode_fake)
} else {
stringResource(R.string.infrared_mode_real)
}
Text(
text = modeText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("infrared_mode_label"),
)
OutlinedButton(
onClick = {
val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
InfraredRuntimeMode.REAL_ABSDK
} else {
InfraredRuntimeMode.FAKE
}
onModeChanged(target)
},
enabled = !state.hasActiveLearning,
) {
Text(stringResource(R.string.infrared_switch_mode))
}
}
// Device name configuration card
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val saved = state.savedDeviceName
Text(
text = if (saved != null) {
stringResource(R.string.infrared_saved_device, saved.value)
} else {
stringResource(R.string.infrared_no_device_saved)
},
style = MaterialTheme.typography.titleMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = state.deviceNameInput,
onValueChange = onDeviceNameChanged,
label = { Text(stringResource(R.string.infrared_device_name_label)) },
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
singleLine = true,
modifier = Modifier
.weight(1f)
.testTag("infrared_device_name_input"),
)
ABoxButton(
onClick = onSaveDeviceName,
modifier = Modifier.testTag("infrared_save_device"),
) {
Text(stringResource(R.string.infrared_save_device))
}
}
}
}
// Learned Keys Section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_keys_title),
style = MaterialTheme.typography.titleMedium,
)
ABoxButton(
onClick = { showLearnDialog = true },
enabled = state.savedDeviceName != null,
modifier = Modifier.testTag("infrared_learn_new_key"),
) {
Text(stringResource(R.string.infrared_learn_new_key))
}
}
if (state.keys.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_no_keys),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.keys, key = { it.id.value }) { key ->
InfraredKeyRow(
key = key,
isSending = state.sendingKeyIds.contains(key.id),
onSend = { onSend(key.id) },
onRetry = { onRetry(key.id) },
onRename = { keyToRename = key },
onDelete = { keyToDelete = key },
)
}
}
}
}
}
}
if (showLearnDialog) {
var keyName by remember { mutableStateOf("") }
var keySlot by remember { mutableStateOf(nextSlot.toString()) }
AlertDialog(
onDismissRequest = { showLearnDialog = false },
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_learn_dialog_prompt))
OutlinedTextField(
value = keyName,
onValueChange = { keyName = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = keySlot,
onValueChange = { keySlot = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
onLearn(keyName, keySlot)
showLearnDialog = false
},
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
) {
Text(stringResource(R.string.infrared_learn_dialog_start))
}
},
dismissButton = {
TextButton(onClick = { showLearnDialog = false }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToRename?.let { targetKey ->
var newName by remember { mutableStateOf(targetKey.displayName) }
AlertDialog(
onDismissRequest = { keyToRename = null },
title = { Text(stringResource(R.string.infrared_rename_dialog_title)) },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
Button(
onClick = {
onRename(targetKey.id, newName)
keyToRename = null
},
enabled = newName.isNotBlank(),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToRename = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToDelete?.let { targetKey ->
AlertDialog(
onDismissRequest = { keyToDelete = null },
title = { Text(stringResource(R.string.infrared_delete_dialog_title)) },
text = { Text(stringResource(R.string.infrared_delete_dialog_message)) },
confirmButton = {
Button(
onClick = {
onDelete(targetKey.id)
keyToDelete = null
},
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToDelete = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
}
@Composable
private fun InfraredKeyRow(
key: SavedInfraredKey,
isSending: Boolean,
onSend: () -> Unit,
onRetry: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("infrared_key_${key.id.value}"),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = key.displayName,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val statusLabel = when (key.status) {
InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning)
InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready)
InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed)
}
Text(
text = statusLabel,
style = MaterialTheme.typography.bodyMedium,
color = when (key.status) {
InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary
InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary
InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error
},
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (key.status == InfraredKeyStatus.READY) {
Button(
onClick = onSend,
enabled = !isSending,
modifier = Modifier.testTag("infrared_send_${key.id.value}"),
) {
if (isSending) {
Text(stringResource(R.string.infrared_sending))
} else {
Text(stringResource(R.string.infrared_send))
}
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status == InfraredKeyStatus.FAILED) {
Button(
onClick = onRetry,
modifier = Modifier.testTag("infrared_retry_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_retry))
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status != InfraredKeyStatus.LEARNING) {
OutlinedButton(onClick = onRename) {
Text(stringResource(R.string.infrared_rename))
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(onClick = onDelete) {
Text(stringResource(R.string.infrared_delete))
}
} else {
CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp))
}
}
}
}
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.infrared.ui
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
sealed interface InfraredUiMessage {
data class Info(val text: String) : InfraredUiMessage
data class Error(val text: String) : InfraredUiMessage
data object SessionInvalid : InfraredUiMessage
}
data class InfraredControlUiState(
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName? = null,
val keys: List<SavedInfraredKey> = emptyList(),
val loading: Boolean = true,
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
val message: InfraredUiMessage? = null,
) {
val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
}
@@ -0,0 +1,342 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredControlViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
private val renameInfraredKey: RenameInfraredKey,
private val deleteInfraredKey: DeleteInfraredKey,
private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning,
private val clock: EpochMillisClock,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val deviceNameInputFlow = MutableStateFlow("")
private val sendingKeyIdsFlow = MutableStateFlow<Set<InfraredKeyId>>(emptySet())
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.mapModeToProfile()
private fun Flow<InfraredRuntimeMode>.mapModeToProfile(): Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> {
return flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode)
flowOf(Pair(effectiveProfile, mode))
}
}
}
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) {
flowOf(DomainResult.Success(null))
} else {
deviceConfigurationRepository.observeDeviceName(profile)
}
}
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) {
flowOf(DomainResult.Success(emptyList()))
} else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) {
flowOf(DomainResult.Success(emptyList()))
} else {
keyRepository.observeKeys(profile, device)
}
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
private data class ServerState(
val profile: ProviderProfileId?,
val mode: InfraredRuntimeMode,
val deviceResult: DomainResult<DeviceName?>,
val keysResult: DomainResult<List<SavedInfraredKey>>,
)
private val serverStateFlow: Flow<ServerState> = combine(
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
) { profileAndMode, deviceResult, keysResult ->
ServerState(
profile = profileAndMode.first,
mode = profileAndMode.second,
deviceResult = deviceResult,
keysResult = keysResult,
)
}
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
sendingKeyIdsFlow,
messageFlow,
) { serverState, input, sendingIds, msg ->
val savedDevice = (serverState.deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (serverState.keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val loading = serverState.profile == null ||
serverState.deviceResult !is DomainResult.Success ||
serverState.keysResult !is DomainResult.Success
InfraredControlUiState(
runtimeMode = serverState.mode,
effectiveProfileId = serverState.profile,
deviceNameInput = input,
savedDeviceName = savedDevice,
keys = keys,
loading = loading,
sendingKeyIds = sendingIds,
message = if (authenticatedProfileId == null) {
InfraredUiMessage.Error("Invalid or missing profile ID")
} else {
msg
},
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.FAKE,
effectiveProfileId = null,
loading = true,
),
)
init {
if (authenticatedProfileId != null) {
viewModelScope.launch {
val currentMode = runtimeModeStore.currentMode()
val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode)
recoverInterruptedInfraredLearning(effectiveProfile)
}
}
}
fun onDeviceNameChanged(value: String) {
deviceNameInputFlow.value = value
}
fun saveDeviceName() {
val currentProfile = state.value.effectiveProfileId ?: return
val trimmed = deviceNameInputFlow.value.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank")
return
}
viewModelScope.launch {
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Device name saved")
deviceNameInputFlow.value = ""
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun nextAvailableKeySlot(): Int {
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
return (1..100).firstOrNull { it !in usedSlots } ?: 1
}
fun learnKey(displayName: String, keySlotInput: String? = null) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first")
return
}
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
return
}
val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) {
val slotInt = keySlotInput.trim().toIntOrNull()
if (slotInt == null || slotInt !in 1..100) {
messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100")
return
}
slotInt.toString()
} else {
nextAvailableKeySlot().toString()
}
learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString)
}
fun retryLearning(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId))
}
fun sendKey(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
if (state.value.sendingKeyIds.contains(keyId)) return
val key = state.value.keys.find { it.id == keyId }
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = InfraredUiMessage.Error("Key is not ready to send")
return
}
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Signal sent successfully")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId
}
}
}
fun renameKey(keyId: InfraredKeyId, displayName: String) {
val currentProfile = state.value.effectiveProfileId ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Name cannot be blank")
return
}
viewModelScope.launch {
when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Key renamed")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun deleteKey(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
viewModelScope.launch {
when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Key removed locally")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun setRuntimeMode(mode: InfraredRuntimeMode) {
if (state.value.hasActiveLearning) {
messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active")
return
}
viewModelScope.launch {
when (val result = runtimeModeStore.setMode(mode)) {
is DomainResult.Success -> {
if (authenticatedProfileId != null) {
val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode)
recoverInterruptedInfraredLearning(newEffectiveProfile)
}
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = InfraredUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = InfraredUiMessage.Error("Infrared controller is offline (code: ${error.externalCode ?: "20206"})")
}
is DomainError.DeviceNotFound -> {
messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})")
}
is DomainError.InvalidRequest -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request")
}
is DomainError.OperationFailed -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})")
}
is DomainError.Network -> {
messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}")
}
is DomainError.NoData -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found")
}
is DomainError.Unknown -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error")
}
}
}
}
@@ -0,0 +1,35 @@
<resources>
<string name="infrared_title">红外控制</string>
<string name="infrared_mode_fake">模式:模拟 / Fake</string>
<string name="infrared_mode_real">模式:真实 ABSDK</string>
<string name="infrared_switch_mode">切换模式</string>
<string name="infrared_device_name_label">红外设备名称</string>
<string name="infrared_device_name_hint">例如:ir-living</string>
<string name="infrared_saved_device">已保存设备:%1$s</string>
<string name="infrared_no_device_saved">未配置红外设备</string>
<string name="infrared_save_device">保存设备名</string>
<string name="infrared_keys_title">已学习按键</string>
<string name="infrared_no_keys">暂无保存的红外按键</string>
<string name="infrared_learn_new_key">学习新按键</string>
<string name="infrared_status_learning">学习中…</string>
<string name="infrared_status_ready">就绪</string>
<string name="infrared_status_failed">失败</string>
<string name="infrared_send">发射</string>
<string name="infrared_sending">发射中…</string>
<string name="infrared_retry">重试</string>
<string name="infrared_rename">重命名</string>
<string name="infrared_delete">删除</string>
<string name="infrared_learn_dialog_title">学习红外按键</string>
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
<string name="infrared_learn_dialog_slot_label">按键编号(1-100)</string>
<string name="infrared_key_slot_label">按键编号:%1$s</string>
<string name="infrared_learn_dialog_start">开始学习</string>
<string name="infrared_rename_dialog_title">重命名按键</string>
<string name="infrared_rename_dialog_input_label">新按键名称</string>
<string name="infrared_delete_dialog_title">删除按键</string>
<string name="infrared_delete_dialog_message">仅从本机列表移除,ABox 服务端可能仍保留该按键。</string>
<string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</string>
</resources>
@@ -0,0 +1,35 @@
<resources>
<string name="infrared_title">Infrared Control</string>
<string name="infrared_mode_fake">Mode: Fake / Simulated</string>
<string name="infrared_mode_real">Mode: Real ABSDK</string>
<string name="infrared_switch_mode">Switch Mode</string>
<string name="infrared_device_name_label">Infrared Device Name</string>
<string name="infrared_device_name_hint">e.g. ir-living</string>
<string name="infrared_saved_device">Saved Device: %1$s</string>
<string name="infrared_no_device_saved">No infrared device configured</string>
<string name="infrared_save_device">Save Device</string>
<string name="infrared_keys_title">Learned Keys</string>
<string name="infrared_no_keys">No infrared keys saved</string>
<string name="infrared_learn_new_key">Learn New Key</string>
<string name="infrared_status_learning">Learning…</string>
<string name="infrared_status_ready">Ready</string>
<string name="infrared_status_failed">Failed</string>
<string name="infrared_send">Send</string>
<string name="infrared_sending">Sending…</string>
<string name="infrared_retry">Retry</string>
<string name="infrared_rename">Rename</string>
<string name="infrared_delete">Delete</string>
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
<string name="infrared_learn_dialog_start">Start Learning</string>
<string name="infrared_rename_dialog_title">Rename Key</string>
<string name="infrared_rename_dialog_input_label">New Display Name</string>
<string name="infrared_delete_dialog_title">Delete Key</string>
<string name="infrared_delete_dialog_message">Only removes the key locally from this app. ABox server may retain the learned key.</string>
<string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</string>
</resources>
@@ -0,0 +1,76 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredLearningCoordinatorTest {
private val profile = ProviderProfileId("profile1")
private val device = DeviceName("ir-1")
private val clock = EpochMillisClock { 1_000L }
private val repository = FakeInfraredKeyRepository()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
@Test
fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, this)
coordinator.learn(profile, device, "Power")
advanceUntilIdle()
val keysResult = repository.observeKeys(profile, device).first()
assertTrue(keysResult is DomainResult.Success)
val keys = (keysResult as DomainResult.Success).value
assertEquals(1, keys.size)
assertEquals(InfraredKeyStatus.READY, keys.single().status)
}
@Test
fun retryRunsAsynchronouslyInExternalScope() = runTest {
val failedKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(failedKey)
val coordinator = InfraredLearningCoordinator(learn, retry, this)
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
}
@@ -0,0 +1,139 @@
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
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.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.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.Before
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)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 21..50 })
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertEquals
import org.junit.Test
class InfraredRuntimeProfileResolverTest {
@Test
fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() {
val resolver = InfraredRuntimeProfileResolver()
assertEquals(
ProviderProfileId("abox"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK),
)
assertEquals(
ProviderProfileId("abox:infrared-fake"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE),
)
}
}
@@ -0,0 +1,294 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class FakeRuntimeModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredControlViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val baseProfile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepository = FakeInfraredKeyRepository()
private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository()
private val runtimeModeStore = FakeRuntimeModeStore()
private val profileResolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock)
private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock)
private val sendUseCase = SendInfraredKey(keyRepository, gateway)
private val renameUseCase = RenameInfraredKey(keyRepository, clock)
private val deleteUseCase = DeleteInfraredKey(keyRepository)
private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel {
val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap())
return InfraredControlViewModel(
savedStateHandle = handle,
keyRepository = keyRepository,
deviceConfigurationRepository = deviceConfigRepository,
runtimeModeStore = runtimeModeStore,
profileResolver = profileResolver,
learningCoordinator = coordinator,
sendInfraredKey = sendUseCase,
renameInfraredKey = renameUseCase,
deleteInfraredKey = deleteUseCase,
recoverInterruptedInfraredLearning = recoverUseCase,
clock = clock,
)
}
@Test
fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(fakeProfile, vm.state.value.effectiveProfileId)
assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode)
vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK)
advanceUntilIdle()
assertEquals(baseProfile, vm.state.value.effectiveProfileId)
assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode)
job.cancel()
}
@Test
fun blankDeviceNameReturnsValidationError() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ")
vm.saveDeviceName()
advanceUntilIdle()
val msg = vm.state.value.message
assertTrue(msg is InfraredUiMessage.Error)
assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text)
job.cancel()
}
@Test
fun saveDeviceNamePersistsAndTrims() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ir-main ")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName)
assertEquals("", vm.state.value.deviceNameInput)
assertTrue(vm.state.value.message is InfraredUiMessage.Info)
job.cancel()
}
@Test
fun learnWithoutSavedDeviceFails() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.learnKey("Power")
advanceUntilIdle()
val msg = vm.state.value.message
assertTrue(msg is InfraredUiMessage.Error)
assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text)
job.cancel()
}
@Test
fun learnWithSavedDeviceSucceeds() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged("ir-1")
vm.saveDeviceName()
advanceUntilIdle()
vm.learnKey("Air Conditioner")
advanceUntilIdle()
val keys = vm.state.value.keys
assertEquals(1, keys.size)
assertEquals("Air Conditioner", keys.first().displayName)
assertEquals(InfraredKeyStatus.READY, keys.first().status)
job.cancel()
}
@Test
fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
assertTrue(vm.state.value.sendingKeyIds.isEmpty())
assertTrue(vm.state.value.message is InfraredUiMessage.Info)
assertEquals(1, gateway.sentKeys.size)
job.cancel()
}
@Test
fun sendFailurePreservesReadyStatus() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206"))
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
val fetched = vm.state.value.keys.single()
assertEquals(InfraredKeyStatus.READY, fetched.status)
assertTrue(vm.state.value.message is InfraredUiMessage.Error)
job.cancel()
}
@Test
fun setModeBlockedWhileLearningIsActive() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
advanceUntilIdle()
assertTrue(vm.state.value.hasActiveLearning)
vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK)
advanceUntilIdle()
assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode)
assertTrue(vm.state.value.message is InfraredUiMessage.Error)
job.cancel()
}
}
+1
View File
@@ -6,3 +6,4 @@ plugins {
alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt.android) apply false alias(libs.plugins.hilt.android) apply false
} }
+72 -24
View File
@@ -2,7 +2,7 @@
## 1. 文档范围 ## 1. 文档范围
本文记录第一轮工程基础已经建立的模块边界、依赖方向和并行开发接口。ABSDK 0630 的初始化、协议、签名、连接重定向和会话清理等未确认内容不在本文中固化。 本文记录已建立的模块边界、依赖方向、提供方路由和并行开发接口,覆盖 ABox(局域网 ABSDK)与本地体验两条连接路径。ABSDK 0630 的初始化、签名细节和会话清理等未确认内容不在本文中固化,协议观测证据见 `docs/protocol/absdk-0630.md`。
产品范围和固定分工以 `docs/project_plan.md` 为准,原始简要需求继续保留在 `docs/requirement_spec.md`。 产品范围和固定分工以 `docs/project_plan.md` 为准,原始简要需求继续保留在 `docs/requirement_spec.md`。
@@ -11,10 +11,14 @@
```text ```text
abox-mgr/ abox-mgr/
├── android/ ├── android/
│ ├── app/ # Android 应用与依赖注入组合根 │ ├── app/ # Android 应用、提供方路由与依赖注入组合根
│ └── core/designsystem/ # Compose Material 3 设计系统基础 │ └── core/
│ ├── designsystem/ # Compose Material 3 设计系统基础
│ └── session/ # 提供方 profile、凭据 Keystore 持久化
├── domain/ # 纯 Kotlin 领域模型和公共端口 ├── domain/ # 纯 Kotlin 领域模型和公共端口
├── testing/fakes/ # 确定性的公共 Fake ├── absdk-bridge/ # ABSDK 0630 防腐层(生产)
├── runtime/simulation/ # 本地体验模拟运行时(生产)
├── testing/fakes/ # 测试专用 Fake(仅测试依赖)
├── docs/ ├── docs/
└── samples/ # 历史示例,仅供参考 └── samples/ # 历史示例,仅供参考
``` ```
@@ -23,10 +27,13 @@ abox-mgr/
```text ```text
android/app ────────────────┬──> android/core/designsystem android/app ────────────────┬──> android/core/designsystem
├──> android/core/session ──> domain
├──> domain ├──> domain
└──> testing/fakes ──> domain ├──> absdk-bridge ──> domain
└──> runtime/simulation ──> domain
android/core/designsystem ─────> Compose Material 3 android/core/designsystem ─────> Compose Material 3
testing/fakes(仅测试)────────> domain
domain ────────────────────────> Kotlin 与 Coroutines domain ────────────────────────> Kotlin 与 Coroutines
``` ```
@@ -34,21 +41,29 @@ domain ────────────────────────>
## 3. 提供方与数据隔离 ## 3. 提供方与数据隔离
应用计划支持 ABox、自有兼容后端和本地体验三种提供方。每个连接配置由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。 认证前应用只提供两种入口,两者在所有构建变体中始终可用:
1. **ABox**:通过进程内唯一的真实 `AbsdkBridge` 连接当前局域网中的智能家居网关,登录成功后进入应用;
2. **本地体验**:由 `:runtime:simulation` 直接建立模拟会话并进入应用,不请求凭据、不访问网络、不调用 ABSDK。
每个提供方 profile 由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。
约束: 约束:
- 任一时刻只启用一个提供方; - 任一时刻只启用一个提供方;
- 切换远端提供方前必须退出当前会话; - 切换提供方前必须退出当前会话;
- 不同连接配置的数据、凭据和设备状态不得互相混用; - 不同提供方的数据、凭据和设备状态不得互相混用;
- 本地体验不调用 ABSDK,也不启动本地 HTTP 服务; - 本地体验不调用 ABSDK,也不启动本地 HTTP 服务;
- Fake 使用相同标识隔离规则,便于在没有真实环境时验证边界。 - Release 不是特殊情况:debug 与 release 暴露相同的提供方和路由行为。
## 4. 公共领域端口 ## 4. 公共领域端口
`domain` 当前提供以下最小端口: `domain` 当前提供以下最小端口:
- `SessionGateway`:登录、观察会话、验证会话和退出; - `SessionGateway`:登录、观察会话、验证会话和退出;
- `LocalSessionGateway`:无凭据本地会话的标记端口,继承 `SessionGateway`;
- `ProviderProfileStore`:提供方 profile、活动 profile 和登录偏好持久化;
- `CredentialStore`:自动登录凭据的加密存取;
- `DeviceCatalog`:按提供方观察设备目录; - `DeviceCatalog`:按提供方观察设备目录;
- `SocketGateway`:查询和设置插座状态; - `SocketGateway`:查询和设置插座状态;
- `TemperatureHumidityGateway`:读取温度和湿度; - `TemperatureHumidityGateway`:读取温度和湿度;
@@ -63,9 +78,9 @@ domain ────────────────────────>
- `Success<T>` 携带类型化领域值; - `Success<T>` 携带类型化领域值;
- `Failure` 携带 `DomainError`; - `Failure` 携带 `DomainError`;
- `DomainError.externalCode` 可以保留外部 SDK 错误码用于诊断; - `DomainError.externalCode` 可以保留外部 SDK 错误码用于诊断;
- UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或服务端数据库模型。 - UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或任何服务端数据库模型。
真实 ABSDK 适配层必须在后续 `feat/absdk-bridge` 分支中完成同步阻塞调用的后台串行化、返回解析和错误映射。 ABSDK 防腐层已在 `:absdk-bridge` 中完成同步阻塞调用的后台串行化、返回解析和错误映射。
## 6. Fake 边界 ## 6. Fake 边界
@@ -77,42 +92,75 @@ domain ────────────────────────>
- 记录红外学习、发送和下载调用; - 记录红外学习、发送和下载调用;
- 可直接用于其他模块的 ViewModel 与 UI 测试。 - 可直接用于其他模块的 ViewModel 与 UI 测试。
Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。 Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。`:testing:fakes` 是测试专用模块,不得加入应用生产依赖。
## 7. Android 组合根 一个例外是 `FakeDeviceCatalog`:ABSDK 不提供设备目录,而提供方作用域的生产 ABox 设备目录由设备目录模块负责,尚未落地。在该模块交付前,debug 构建的组合根暂时用 `FakeDeviceCatalog` 充当 ABox 目录占位。这是一个临时的目录所有权边界:不得把 ABox 目录请求路由到本地体验的模拟目录,release 构建在出现真实消费方之前不声明 `DeviceCatalog` 绑定。
## 7. 本地体验模拟运行时
`:runtime:simulation` 是生产 Kotlin/JVM 模块,为本地体验提供确定性实现:
- `SimulationSessionGateway`:直接建立无凭据的本地会话;
- `SimulationDeviceCatalog`:内置的本地体验设备目录;
- `SimulationSocketGateway`、`SimulationTemperatureHumidityGateway`、`SimulationInfraredGateway`:固定行为的模拟设备操作。
模拟实现不暴露脚本化失败注入、调用记录或断言辅助;这些能力只存在于 `:testing:fakes`。
## 8. Android 组合根与提供方路由
`android/app` 是单 Activity Compose 应用,负责: `android/app` 是单 Activity Compose 应用,负责:
- `ABoxManagerApplication` 与 Hilt 组合根; - `ABoxManagerApplication` 与 Hilt 组合根;
- `MainActivity`; - `MainActivity`;
- 根 `NavHost`; - 根 `NavHost` 与提供方选择、登录、认证后路由;
- 仅在 debug 构建中将领域端口绑定到当前开发阶段的 Fake,release 构建不包含 Fake;
- 默认英文和简体中文资源; - 默认英文和简体中文资源;
- 使用 `android/core/designsystem` 的主题和基础组件。 - 使用 `android/core/designsystem` 的主题和基础组件。
组合根按端口绑定生产实现:
```text
SessionGateway -> AbsdkBridge(进程内单例)
LocalSessionGateway -> SimulationSessionGateway
SocketGateway -> RoutingSocketGateway
TemperatureHumidityGateway -> RoutingTemperatureHumidityGateway
InfraredGateway -> RoutingInfraredGateway
```
`AbsdkBridge` 以 Hilt 单例提供并在所有构建变体中相同:会话与插座/温湿度/红外操作共享同一个 Bridge 实例,使互斥锁和 Bridge 本地会话与厂商 SDK 单例状态保持一致。三个 Routing 网关读取活动 profile 并按提供方分发——ABox 转发到共享的 AbsdkBridge 实现,本地体验转发到模拟实现。任何功能 UI 都不直接选择真实或 Fake 实现。
设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。 设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。
## 8. ABSDK Bridge ## 9. ABSDK Bridge
`:absdk-bridge` 是纯 Kotlin/JVM 防腐层,依赖 `domain` 并实现会话、插座、温湿度和红外 Gateway。该模块: `:absdk-bridge` 是纯 Kotlin/JVM 防腐层,依赖 `domain` 并实现会话、插座、温湿度和红外 Gateway。该模块:
- 只在 `VendorAbsdkClient` 中接触 `ABSDK`、`ABRet` 和 `IrInfo`; - 只在 `VendorAbsdkClient` 中接触 `ABSDK`、`ABRet` 和 `IrInfo`;
- 将所有同步调用移到后台并串行进入全局 SDK; - 将所有同步调用移到后台并串行进入全局 SDK;
- 将返回值和错误码转换为 `DomainResult` 和领域模型; - 将返回值和错误码转换为 `DomainResult` 和领域模型;
- 不提供设备目录;
- 不调用未确认的 `setHostInfo`; - 不调用未确认的 `setHostInfo`;
- logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter; - logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter;
- 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致; - 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致;
- 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险; - 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险;
- 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制。 - 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制;
- ABSDK 0630 没有设备目录能力,Bridge 因此也不提供设备目录或设备发现。
Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 App 组合根已通过 Hilt 单例绑定该 Bridge,绑定处以 `@CleartextAbsdkTransport` 显式 opt-in 承认厂商 JAR 的硬编码明文 HTTP 风险;App 同时声明 `INTERNET` 权限并允许明文传输(`usesCleartextTraffic`),这是项目所有者对当前局域网集成约束的明确风险接受,不代表传输具备机密性或完整性。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。
## 9. 后续边界 ## 10. Provider Session
后续 KremeCN 工作保持为独立短期分支: `:android:core:session` 负责提供方登录的持久化与 Android 安全边界:
- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定; - `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关;已移除的提供方类型(如历史兼容后端值)被忽略,不会成为活动 profile;
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。 - `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文;
- `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换;
- 本地体验的会话由 `:runtime:simulation` 的 `SimulationSessionGateway` 提供,不调用 HTTP 或 ABSDK。
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。ABox 登录成功必须来自注入的真实 Bridge 返回 `DomainResult.Success<Session>`;无效凭据和网络错误停留在登录页并显示本地化错误。ABox 远端退出清除密码和 Session,但保留 profile 与记住的用户名以便再次显示登录页;本地体验退出清除本地会话和活动 profile,回到提供方选择。
两个提供方在所有构建变体中始终可用,不存在按 debug/release 区分的可用性开关。
登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。
## 11. 后续边界
Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块交付后替换 debug 构建中的临时 `FakeDeviceCatalog` 占位。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
@@ -0,0 +1,195 @@
# Device List Compose UI Change Log
Branch: `feature/device-control-compose-ui`
## Summary
This branch adds only the Kotlin Jetpack Compose device list page owned by this feature area.
The implementation provides a reusable Compose screen that displays the ABox terminal device categories:
- Smart socket
- Temperature/humidity sensor
- Infrared controller
This branch intentionally does not modify the app entry point or app shell routing. The app shell, login flow, and detail-feature routing are owned by other feature branches.
## Added Files
### `android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt`
Adds the device list page.
The page displays entries for:
- Smart socket
- Temperature/humidity sensor
- Infrared controller
Current scope:
- Display the device categories as list entry UI.
- Keep the UI in Kotlin Compose.
- Expose click callbacks for each device category.
- Avoid adding app-shell routing, detail routes, or detail control pages in this branch.
Integration API:
```kotlin
DeviceListScreen(
onSocketClick = { /* route to socket module later */ },
onTemperatureHumidityClick = { /* route to temperature/humidity module later */ },
onInfraredClick = { /* route to infrared controller module later */ },
)
```
Future integration point:
- The app shell or feature owner responsible for routing can host this screen and connect these callbacks to their own routes/modules later.
## Architecture Notes
This branch follows the current repository direction:
- Kotlin instead of Java
- Jetpack Compose instead of XML layouts
- Single-Activity UI instead of multiple Activities
- Navigation Compose compatibility through external callbacks
- Existing design-system wrappers where practical
This branch intentionally does not copy the previous Java/XML experiment module into the team repository.
## Scope Intentionally Not Included
This branch does not include:
- App entry point changes
- App shell or login routing changes
- A device-control navigation graph file
- Socket control screen implementation
- Temperature/humidity status screen implementation
- Infrared controller screen implementation
- Shared detail-control component scaffolding
- Direct ABSDK calls from Compose UI
- New Java Activity classes
- XML layout files
- A separate Android module copied from the experiment project
- Full session/login integration for device gateway calls
- Persistent infrared key storage
These should be added by the corresponding feature owners in later branches or follow-up commits after team architecture review.
## Verification
- Built and ran `android.app` from Android Studio successfully during local validation.
- `:android:app:assembleDebug` completed successfully after trimming the feature scope.
## Files Intended for Commit
The intended commit scope is limited to:
```text
android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt
docs/device-control-compose-ui-change-log.md
```
`settings.gradle.kts` is intentionally excluded from this feature commit. Local Gradle/JDK/toolchain setup should be handled on the developer machine and should not modify the team-provided project configuration in this branch.
---
# 设备列表 Compose UI 修改说明(中文)
分支:`feature/device-control-compose-ui`
## 概要
本分支只新增当前分工负责的 Kotlin Jetpack Compose 设备列表页面。
设备列表展示的设备类别包括:
- 智能插座
- 温湿度传感器
- 红外控制器
本分支故意不修改 App 入口或应用壳路由。程序入口、登录流程和各设备详情模块路由由其他功能分支负责。
## 新增文件
### `android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt`
新增设备列表页面。
页面展示三个设备入口:
- 智能插座
- 温湿度传感器
- 红外控制器
当前实现范围:
- 展示设备类别入口 UI。
- 保持 Kotlin Compose 实现。
- 为每个设备类别暴露点击回调。
- 不在本分支实现 App 入口接入、设备详情 route 或设备控制页面。
对接 API:
```kotlin
DeviceListScreen(
onSocketClick = { /* 后续跳转到智能插座模块 */ },
onTemperatureHumidityClick = { /* 后续跳转到温湿度模块 */ },
onInfraredClick = { /* 后续跳转到红外控制器模块 */ },
)
```
后续接入点:
- 由负责 App shell 或对应设备模块的同学,在各自分支中挂载这个页面,并把这些回调连接到他们自己的详情路由或功能模块。
## 架构说明
本分支遵循当前小组仓库的技术方向:
- 使用 Kotlin,而不是 Java。
- 使用 Jetpack Compose,而不是 XML layout。
- 使用单 Activity 架构,而不是多个 Activity 跳转。
- 通过外部回调保持与 Navigation Compose 的兼容性。
- 尽量复用已有 design system 中的组件封装。
本分支没有把之前个人实验项目中的 Java/XML 模块直接复制进团队仓库。
## 本分支未包含的内容
本分支暂不包含:
- App 入口修改。
- App shell 或登录路由修改。
- 设备控制 navigation graph 文件。
- 智能插座控制页面。
- 温湿度状态页面。
- 红外控制器操作页面。
- 设备详情控制页面共用组件。
- Compose UI 中直接调用 ABSDK。
- 新增 Java Activity。
- 新增 XML layout。
- 从个人实验项目复制独立 Android 模块。
- 完整登录/session 与设备 gateway 的联动。
- 红外学习按键的持久化存储。
这些内容建议由对应模块负责人在后续 branch 或后续 commit 中继续实现。
## 验证
- 本地验证过程中已从 Android Studio 成功 build/run `android.app`。
- 修剪职责范围后,`:android:app:assembleDebug` 构建成功。
## 预期提交范围
本次提交范围应限制在:
```text
android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt
docs/device-control-compose-ui-change-log.md
```
`settings.gradle.kts` 当前不属于本次设备列表 UI 功能提交范围。本地 Gradle/JDK/toolchain 配置应在开发者本机处理,不应修改团队提供的项目配置文件。
@@ -0,0 +1,133 @@
# WIP: Add device list Compose screen
## Summary
This MR adds only the reusable Kotlin Jetpack Compose device list screen for my assigned scope.
The screen displays the three ABox device categories:
- Smart socket
- Temperature/humidity sensor
- Infrared controller
This MR intentionally does not modify the app entry point, login flow, app shell routing, or any device detail/control screen. Those parts are handled by other feature branches.
## Changes
- Add `DeviceListScreen`.
- Expose click callbacks for each device category:
- `onSocketClick`
- `onTemperatureHumidityClick`
- `onInfraredClick`
- Update the device-list change log document.
- Remove the earlier out-of-scope temporary device control screens from this branch.
## Scope
Included:
- Device list Compose UI component
- Device category entry UI
- Callback API for future route integration
Not included:
- App entry point changes
- Login flow changes
- Device control navigation graph
- Smart socket control screen
- Temperature/humidity status screen
- Infrared controller screen
- Direct ABSDK calls
- Java Activity classes
- XML layout files
## Integration Notes
The screen is designed to be hosted by the app shell or routing owner later:
```kotlin
DeviceListScreen(
onSocketClick = { /* route to socket module */ },
onTemperatureHumidityClick = { /* route to temperature/humidity module */ },
onInfraredClick = { /* route to infrared controller module */ },
)
```
The current branch keeps these callbacks external so other feature owners can connect their modules without this MR taking ownership of routing.
## Verification
- Built and ran `android.app` locally from Android Studio.
- `:android:app:assembleDebug` completed successfully after trimming this branch to the device-list component scope.
## Notes
`settings.gradle.kts` is intentionally not part of this MR. Local Gradle/JDK/toolchain setup is handled on the developer machine and should not modify the team-provided project configuration.
---
# WIP: 新增设备列表 Compose 页面
## 概要
本 MR 只新增我当前分工负责的 Kotlin Jetpack Compose 设备列表页面组件。
设备列表展示三类 ABox 设备入口:
- 智能插座
- 温湿度传感器
- 红外控制器
本 MR 故意不修改 App 入口、登录流程、应用壳路由,也不实现任何设备详情/控制页面。这些部分由其他功能分支负责。
## 修改内容
- 新增 `DeviceListScreen`。
- 为三类设备入口暴露点击回调:
- `onSocketClick`
- `onTemperatureHumidityClick`
- `onInfraredClick`
- 更新设备列表变更说明文档。
- 从本分支中移除早期临时加入、但不属于当前分工范围的设备控制页面。
## 本 MR 包含
- 设备列表 Compose UI 组件
- 设备类别入口 UI
- 后续路由接入所需的 callback API
## 本 MR 不包含
- App 入口修改
- 登录流程修改
- 设备控制 navigation graph
- 智能插座控制页面
- 温湿度状态页面
- 红外控制器页面
- Compose UI 中直接调用 ABSDK
- Java Activity
- XML layout
## 对接说明
该页面后续可以由 App shell 或路由负责人挂载:
```kotlin
DeviceListScreen(
onSocketClick = { /* 后续跳转到智能插座模块 */ },
onTemperatureHumidityClick = { /* 后续跳转到温湿度模块 */ },
onInfraredClick = { /* 后续跳转到红外控制器模块 */ },
)
```
当前分支只提供页面和回调 API,不负责具体跳转目标,以便其他模块负责人对接自己的功能页面。
## 验证
- 已在本地通过 Android Studio 成功 build/run `android.app`。
- 修剪为设备列表组件范围后,`:android:app:assembleDebug` 构建成功。
## 备注
`settings.gradle.kts` 不属于本 MR 范围。本地 Gradle/JDK/toolchain 配置在开发者本机处理,不修改团队提供的项目配置文件。
+50 -58
View File
@@ -13,18 +13,20 @@
仓库目前主要包含: 仓库目前主要包含:
- 产品需求概要; - 产品需求概要;
- ABSDK 0630 接口文档; - ABSDK 0630 接口文档与协议观测记录;
- AsyncTask 登录示例; - 正式 Android 应用工程(含提供方登录与本地体验运行时);
- ABSDK 防腐层、领域接口、模拟运行时和测试 Fake;
- 清理归档后的 ABSDK Android Sample; - 清理归档后的 ABSDK Android Sample;
- Git 和 AI 工具协作规则。 - Git 和 AI 工具协作规则。
当前还没有正式的 Android 应用工程、自有后端、自动化测试或 CI Runner。 当前还没有设备目录、插座温湿度、红外和情景模式的正式实现,也没有 CI Runner。
重要参考资料: 重要参考资料:
```text ```text
docs/requirement_spec.md docs/requirement_spec.md
docs/ABSDK接口部分_0630.md docs/protocol/absdk-0630.md
docs/architecture.md
docs/examples/ docs/examples/
samples/abox-sdk-sample/ samples/abox-sdk-sample/
``` ```
@@ -49,42 +51,46 @@ ABox Manager 是面向 Android 手机和平板的智能家居管理应用,首
## 4. MVP 范围 ## 4. MVP 范围
### 4.1 登录 ### 4.1 ABox 登录
以下登录要求只适用于 ABox 提供方;本地体验不需要凭据,也不显示登录页面。
- 支持用户名和密码登录; - 支持用户名和密码登录;
- 支持记住用户名; - 支持记住用户名;
- 支持按连接配置独立开启自动登录; - 支持独立开启自动登录;
- 自动登录所需密码必须通过 Android Keystore 安全保存; - 自动登录所需密码必须通过 Android Keystore 安全保存;
- ABSDK Token 由 SDK 自身管理;进程重启或 Token 失效后,通过保存的凭据重新登录; - ABSDK Token 由 SDK 自身管理;进程重启或 Token 失效后,通过保存的凭据重新登录;
- 退出登录时保留非敏感连接信息和用户名,清除密码与会话; - 退出登录时保留用户名,清除密码与会话。
- 切换后端提供方前必须退出当前会话。
### 4.2 提供方模式 ### 4.2 提供方模式
应用计划兼容三种相互独立的模式: 应用提供两种入口,认证前必须选择其一:
1. **ABox 后端**:使用现有 ABSDK 0630 连接 ABox 服务; 1. **ABox**:通过 ABSDK 0630 连接当前局域网中的智能家居网关;
2. **自有兼容后端**:连接自行实现的 ABSDK 0630 协议兼容服务; 2. **本地体验**:不访问网络或 ABSDK,通过本地模拟数据直接进入完整应用流程。
3. **本地体验**:不连接远端服务,通过本地模拟数据体验完整应用流程。
首版不提供自有兼容后端入口,也不再规划兼容服务端。
约束: 约束:
- 任一时刻只使用一个提供方; - 任一时刻只启用一个提供方;
- 不要求同时连接两个后端; - 切换提供方前必须退出当前会话;
- ABox 与自有后端的账号、会话和数据不互相绑定; - 每个提供方的会话、设备、遥控器、情景和凭据空间互不混用;
- 每个连接配置拥有独立的设备、遥控器、情景和凭据空间;
- 本地体验不启动 HTTP 服务,也不调用 ABSDK。 - 本地体验不启动 HTTP 服务,也不调用 ABSDK。
### 4.3 设备目录 ### 4.3 设备目录
ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录采用: ABSDK 0630 当前没有已确认的设备列表接口,也不提供设备目录。首版设备目录按提供方区分:
- ABox:由设备目录模块负责的本地持久化目录(二维码批量导入,用户手工添加、编辑和删除);
- 本地体验:由模拟运行时内置的模拟设备目录。
共同约束:
- 二维码批量导入;
- 用户手工添加、编辑和删除;
- 设备名称作为调用 ABSDK 的设备标识; - 设备名称作为调用 ABSDK 的设备标识;
- 设备类型首版只包含插座、温湿度和红外。 - 设备类型首版只包含插座、温湿度和红外。
不得假设存在尚未确认的新版 SDK 或设备发现接口。 不得假设存在尚未确认的新版 SDK 或远程设备发现接口。
### 4.4 插座 ### 4.4 插座
@@ -178,25 +184,15 @@ ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录
- 切换提供方前必须结束当前会话; - 切换提供方前必须结束当前会话;
- `docs/examples/` 和 `samples/abox-sdk-sample/` 只作为参考,不直接作为正式架构。 - `docs/examples/` 和 `samples/abox-sdk-sample/` 只作为参考,不直接作为正式架构。
### 5.3 自有兼容后端 ### 5.3 模拟运行时
自有后端目标是兼容 ABSDK 0630 的必要服务端行为。 本地体验由生产模块 `:runtime:simulation` 提供确定性模拟实现:
暂定方向: - 不依赖网络、ABSDK 或 Android API;
- 内置固定的模拟设备目录和设备状态;
- 与 ABox 共用同一套领域端口,UI 不感知差异。
- Kotlin 和 Ktor; 测试专用的脚本化 Fake 保留在 `:testing:fakes`,不进入生产依赖。
- 旧协议只存在于兼容适配边界;
- 内部使用类型化领域模型;
- 密码使用强哈希保存;
- 酒店与房间作为数据隔离边界;
- 管理操作通过内置 CLI 完成;
- 提供模拟设备驱动;
- 为未来真实设备驱动预留统一接口;
- SQLite 用于轻量部署;
- PostgreSQL 用于较大规模部署;
- Docker 作为标准交付方式。
具体协议、签名和连接方式必须先验证,不在本文档中提前固化。
--- ---
@@ -222,21 +218,20 @@ ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录
- 建立 Compose、Navigation、Hilt、Room 和 DataStore 基础; - 建立 Compose、Navigation、Hilt、Room 和 DataStore 基础;
- 建立公共设计系统基础; - 建立公共设计系统基础;
- 接入和封装 ABSDK 0630; - 接入和封装 ABSDK 0630;
- 建立统一领域接口、错误模型和 Fake 实现; - 建立统一领域接口、错误模型和测试 Fake;
- 实现 ABox、自有兼容后端和本地体验三个入口; - 实现 ABox 和本地体验两个入口,以及按活动提供方的设备操作路由;
- 实现记住用户名、自动登录、Keystore 凭据保存和退出; - 实现记住用户名、自动登录、Keystore 凭据保存和退出;
- 处理 Token 失效与重新登录; - 处理 Token 失效与重新登录;
- 研究 SDK 已确认行为和协议边界; - 研究 SDK 已确认行为和协议边界。
- 建立自有兼容后端基础、测试宿主、CLI 和 Docker 基础。
主要交付: 主要交付:
```text ```text
工程基础 工程基础
SDK 适配层 SDK 适配层(AbsdkBridge)
本地体验模拟运行时
登录与提供方选择 登录与提供方选择
公共领域接口与 Fake 公共领域接口与测试 Fake
兼容后端基础
``` ```
### 6.2 alZerNest:设备列表导航 ### 6.2 alZerNest:设备列表导航
@@ -402,8 +397,7 @@ tzh:
退出条件: 退出条件:
- Android App 可以使用 Fake 完成主要页面导航; - Android App 可以使用 Fake 或本地体验完成主要页面导航;
- 服务端骨架可以独立启动;
- 所有人拥有可独立测试的模块。 - 所有人拥有可独立测试的模块。
### 阶段 2:完整功能实现 ### 阶段 2:完整功能实现
@@ -412,8 +406,7 @@ tzh:
- 完成登录和提供方隔离; - 完成登录和提供方隔离;
- 完成设备目录导入和维护; - 完成设备目录导入和维护;
- 完成插座、温湿度和红外; - 完成插座、温湿度和红外;
- 完成情景模式; - 完成情景模式。
- 完成自有兼容后端的已确认协议范围。
退出条件: 退出条件:
@@ -423,8 +416,7 @@ tzh:
### 阶段 3:真实集成 ### 阶段 3:真实集成
- 连接真实 ABox 环境; - 在网关局域网内连接真实 ABox 环境;
- 连接自有兼容后端;
- 使用真实插座、温湿度和红外设备; - 使用真实插座、温湿度和红外设备;
- 验证 Token 失效、设备离线和错误码; - 验证 Token 失效、设备离线和错误码;
- 验证手机和平板布局。 - 验证手机和平板布局。
@@ -432,7 +424,7 @@ tzh:
退出条件: 退出条件:
- 三类真实硬件核心流程通过; - 三类真实硬件核心流程通过;
- ABox 与自有后端连接行为明确; - ABox 局域网连接行为明确;
- 已知限制完整记录。 - 已知限制完整记录。
### 阶段 4:发布验收 ### 阶段 4:发布验收
@@ -459,7 +451,6 @@ tzh:
feat/project-foundation feat/project-foundation
feat/absdk-bridge feat/absdk-bridge
feat/provider-login feat/provider-login
feat/compatible-server
``` ```
### alZerNest ### alZerNest
@@ -530,17 +521,18 @@ feat/app-integration
- 领域模型和执行逻辑单元测试; - 领域模型和执行逻辑单元测试;
- SDK 返回和错误映射测试; - SDK 返回和错误映射测试;
- 提供方选择、会话协调和设备操作路由测试;
- 模拟运行时行为测试;
- ViewModel 状态测试; - ViewModel 状态测试;
- Room 持久化测试; - Room 持久化测试;
- Compose 核心页面测试; - Compose 核心页面测试;
- 情景顺序执行和失败继续测试; - 情景顺序执行和失败继续测试。
- 自有兼容后端认证、存储和模拟驱动测试;
- Docker 和 CLI 验证。
### 11.2 手工验收 ### 11.2 手工验收
- 登录、自动登录和退出; - ABox 登录、自动登录和退出;
- 三种提供方入口; - 本地体验直接进入应用;
- 两种提供方入口;
- 数据和凭据隔离; - 数据和凭据隔离;
- 设备目录导入和维护; - 设备目录导入和维护;
- 插座状态和控制; - 插座状态和控制;
@@ -573,11 +565,11 @@ Runner 就绪后,再将相同命令配置为受保护 `main` 的必要检查
- ABSDK 0630 的完整初始化流程; - ABSDK 0630 的完整初始化流程;
- `setHostInfo` 的正式业务含义; - `setHostInfo` 的正式业务含义;
- ABox 官方部署所需参数; - ABox 官方部署所需参数;
- 自有后端与原始 JAR 的最终连接方式;
- SDK 的完整签名算法和参数规则; - SDK 的完整签名算法和参数规则;
- SDK 线程安全与会话清理行为; - SDK 线程安全与会话清理行为;
- 真实测试账号、设备名称和硬件环境; - 真实测试账号、设备名称和硬件环境;
- 自有后端连接真实设备所使用的网关协议; - 授权凭据下完整的 ABox 登录与设备操作冒烟验证;
- JAR 硬编码域名与局域网网关地址之间的映射机制(详见 `docs/protocol/absdk-0630.md`);
- ABSDK 二进制的生产分发许可。 - ABSDK 二进制的生产分发许可。
上述事项必须通过厂商资料、授权分析、实验或真实环境验证后,再更新本文档或对应技术文档。 上述事项必须通过厂商资料、授权分析、实验或真实环境验证后,再更新本文档或对应技术文档。
+28 -13
View File
@@ -11,7 +11,7 @@
- 历史 Sample:`samples/abox-sdk-sample/login-mac/`; - 历史 Sample:`samples/abox-sdk-sample/login-mac/`;
- 公开 API 使用 `javap` 验证,协议细节通过授权字节码分析验证。 - 公开 API 使用 `javap` 验证,协议细节通过授权字节码分析验证。
本文不是自有兼容后端的最终规范。真实连接参数、部署行为和硬件结果仍须通过授权测试环境验证。 本文只记录厂商 JAR 与局域网环境的已确认行为,不定义任何兼容服务端规范。尚未验证的内容见文末清单,不得作为产品实现承诺。
## 2. JAR 公开类型 ## 2. JAR 公开类型
@@ -108,7 +108,7 @@ commons-logging-1.1.3.jar
JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。 JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。
真实 Bridge 工厂使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并且当前没有接入 Android App。该标记只防止误接入,不会为链路提供加密。生产集成前必须确定并批准 HTTPS、受控加密代理或其他能够提供机密性与完整性的传输控制;在此之前不得启用真实 ABox 入口。 App 组合根绑定真实 Bridge 时使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并声明 `INTERNET` 权限与明文传输许可。该标记只防止误接入,不会为链路提供加密;允许明文是项目所有者对当前局域网集成约束的明确风险接受,不代表传输具备机密性或完整性。
## 6. `setHostInfo` 的已确认行为 ## 6. `setHostInfo` 的已确认行为
@@ -127,7 +127,7 @@ roomNo
apikey apikey
``` ```
其中第一个参数只写入请求字段 `url`,不会修改 SDK 的硬编码 HTTP 基础地址。因此原始 JAR 不能通过该公开 API 直接切换到任意自有后端。 其中第一个参数只写入请求字段 `url`,不会修改 SDK 的硬编码 HTTP 基础地址(见第 5 节)。因此该公开 API 不能用于切换请求目标。
仍未确认: 仍未确认:
@@ -136,9 +136,23 @@ apikey
- hotel、room 和 API Key 的正式取值; - hotel、room 和 API Key 的正式取值;
- 初始化失败对后续登录和设备操作的影响。 - 初始化失败对后续登录和设备操作的影响。
所以 `feat/absdk-bridge` 不实现或调用 `setHostInfo`。 Bridge 不实现或调用 `setHostInfo`。
## 7. MVP 端点与数据 ## 7. 局域网网关观测证据(2026-08-24)
以下为 2026-08-24 在网关所在局域网内获得的观测结果,仅描述当天环境,不构成通用承诺:
- 网关地址 `192.168.100.1` 在端口 80 和 443 上可达;
- 未携带 Token 直接 `POST http://192.168.100.1/abox/index.php/abjhsdk/user/validate` 返回 `code=00000`、`status=0`;
- `feat/moler-infrared-control` 分支的真实硬件测试表明:当前网络环境下,厂商 JAR 能够到达真实网关并完成红外相关操作。
同时必须保留以下边界:
- JAR 仍然硬编码基础地址 `a-box.com.cn`;它在该环境中被映射到局域网网关的机制属于外部环境依赖,机制未知,且不受 `setHostInfo` 影响;
- 尚未建立 JAR 可用的通用基础地址或主机配置能力,也没有完成携带授权凭据的登录冒烟验证;
- 观测结果不能外推为“任意网络下均可连通”,后续环境变化须重新验证。
## 8. MVP 端点与数据
| SDK 方法 | 端点 | 关键参数或返回 | | SDK 方法 | 端点 | 关键参数或返回 |
|---|---|---| |---|---|---|
@@ -154,7 +168,7 @@ apikey
`downloadIrCodes(List<IrInfo>)` 并不是单次批量 HTTP 请求。JAR 按输入顺序逐项调用下载端点,并返回每项 `status`。只要有任一项失败,总体代码为 `20204`,同时 `dicDatas["ret"]` 仍包含逐项结果。 `downloadIrCodes(List<IrInfo>)` 并不是单次批量 HTTP 请求。JAR 按输入顺序逐项调用下载端点,并返回每项 `status`。只要有任一项失败,总体代码为 `20204`,同时 `dicDatas["ret"]` 仍包含逐项结果。
## 8. 请求签名 ## 9. 请求签名
字节码显示 SDK 会: 字节码显示 SDK 会:
@@ -165,9 +179,9 @@ apikey
5. 按时间戳计算的位置插入时间片段并添加固定前缀; 5. 按时间戳计算的位置插入时间片段并添加固定前缀;
6. 在请求的 `system` 数据中提交签名、时间戳和 Token。 6. 在请求的 `system` 数据中提交签名、时间戳和 Token。
这些事实用于判断协议边界,不在本轮重新实现签名算法或对外宣称兼容性。正式兼容后端开始前,必须建立脱敏签名测试向量并复核每个字段和编码细节。 这些事实用于判断协议边界,本轮不重新实现签名算法,也不对外宣称兼容性。任何需要复现该协议行为的工作开始前,必须先建立脱敏签名测试向量并复核每个字段和编码细节。
## 9. 响应结构 ## 10. 响应结构
字节码解析的响应结构为: 字节码解析的响应结构为:
@@ -183,7 +197,7 @@ apikey
当前 Bridge 对成功数据进行防御性解析:缺失字段、未知状态或错误类型不会导致 `ClassCastException` 泄漏到调用方。 当前 Bridge 对成功数据进行防御性解析:缺失字段、未知状态或错误类型不会导致 `ClassCastException` 泄漏到调用方。
## 10. 错误码来源 ## 11. 错误码来源
完整错误码表来自厂商文档和历史 `AboxConstants.java`。Bridge 保留原始 `externalCode`,并映射为以下领域分类: 完整错误码表来自厂商文档和历史 `AboxConstants.java`。Bridge 保留原始 `externalCode`,并映射为以下领域分类:
@@ -199,7 +213,7 @@ apikey
错误 `msg` 只作为诊断 detail,不直接作为最终 UI 文案。 错误 `msg` 只作为诊断 detail,不直接作为最终 UI 文案。
## 11. 历史 Sample 观察 ## 12. 历史 Sample 观察
历史 Sample: 历史 Sample:
@@ -212,14 +226,15 @@ apikey
正式实现只复用已确认的 SDK 调用事实,不复制 AsyncTask、明文凭据或全局明文网络配置。 正式实现只复用已确认的 SDK 调用事实,不复制 AsyncTask、明文凭据或全局明文网络配置。
## 12. 仍待确认 ## 13. 仍待确认
- ABox 官方初始化的完整流程和参数; - ABox 官方初始化的完整流程和参数;
- 文档所称 Token 两小时有效期在真实环境中的精确表现; - 文档所称 Token 两小时有效期在真实环境中的精确表现;
- Token 失效时各设备接口实际返回的代码; - Token 失效时各设备接口实际返回的代码;
- SDK 的厂商线程安全保证; - SDK 的厂商线程安全保证;
- 真实账号、设备名称和硬件环境; - 授权凭据下完整的 ABox 登录与设备操作冒烟验证;
- 原始 JAR 指向自有兼容后端的最终授权方案; - JAR 硬编码域名与局域网网关地址之间的映射机制(第 7 节);
- 网络环境变化(不同网段、DHCP、路由策略)下连通性是否保持;
- ABSDK 和附属 JAR 的生产分发许可; - ABSDK 和附属 JAR 的生产分发许可;
- 所有端点的完整表单编码和可重复签名测试向量。 - 所有端点的完整表单编码和可重复签名测试向量。
+8 -3
View File
@@ -1,8 +1,13 @@
## 需求规格 ## 需求规格
**登录** **连接方式**
1. 记住已登录用户信息功能:记住我 1. ABox:通过 ABSDK 连接当前局域网中的智能家居网关,登录成功后进入应用。
2. 自动登录:选中自动登录之后 2. 本地体验:不访问网络或 ABSDK,直接建立模拟会话并进入应用。
3. 首版不提供自有兼容后端入口。
**ABox 登录**
1. 记住已登录用户信息功能:记住我(仅适用于 ABox)
2. 自动登录:选中自动登录之后(仅适用于 ABox)
**设备列表** **设备列表**
1. 设备列表:显示设备名称(插座、温湿度、红外) 1. 设备列表:显示设备名称(插座、温湿度、红外)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,589 @@
# Infrared Remote Control UI & Multi-Scene Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation.
**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`.
**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines.
**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md`
## Global Constraints
- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces.
- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50).
- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`.
- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names.
- Follow TDD with unit and Compose UI tests.
---
### Task 1: Define Remote Scenes and Key Specs
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt`
**Interfaces:**
- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`.
- [ ] **Step 1: Write test for predefined AC and TV remote button specifications**
```kotlin
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 21..50 })
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `RemoteModels.kt`**
```kotlin
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU
)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt
git commit -m "feat: define infrared remote scenes and button specifications"
```
---
### Task 2: Implement Infrared Remote ViewModel
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt`
**Interfaces:**
- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`.
- Produces: `StateFlow<InfraredRemoteUiState>` with `onButtonClick`, `toggleLearningMode`, `switchScene`.
- [ ] **Step 1: Write failing ViewModel test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
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.*
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.*
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
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)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`**
```kotlin
package com.flagship.abox.manager.infrared.remote
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.*
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.*
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @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,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow = 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))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg ->
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (state.value.isLearningMode) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: implement infrared remote control viewmodel"
```
---
### Task 3: Build Custom Remote Visual Components
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt`
- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt`
- [ ] **Step 1: Implement Compose visual elements**
- `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`.
- Compose panels for `AcRemotePanel` and `TvRemotePanel`.
- Assemble `InfraredRemoteScreen` with top learning switch and settings action.
- [ ] **Step 2: Write Compose UI Test**
```kotlin
package com.flagship.abox.manager.infrared.remote
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.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = {},
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
}
}
```
- [ ] **Step 3: Run compilation and tests**
Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: add infrared remote control Compose screen and panels"
```
---
### Task 4: Integrate Navigation and App Flow
**Files:**
- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt`
- Modify: `android/app/src/main/res/values/strings.xml`
- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml`
- [ ] **Step 1: Wire Navigation**
- Route `infrared/{profileId}` now loads `InfraredRemoteRoute`.
- Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view).
- Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`.
- [ ] **Step 2: Run Full Verification Suite & Build APK**
Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug`
Expected: BUILD SUCCESSFUL.
- [ ] **Step 3: Commit and Push**
```bash
git add android/app android/feature/infrared
git commit -m "feat: wire infrared remote screen and settings navigation"
git push origin feat/moler-infrared-control
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,606 @@
# 红外学习与发射控制设计
## 1. 背景与目标
本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环:
1. 在登录成功后的现有页面提供一个临时入口;
2. 手动配置并记住一个 ABSDK 红外设备名;
3. 创建并命名多个红外按键;
4. 通过 ABSDK 发起红外学习;
5. 保存可重复使用的按键绑定;
6. 通过 ABSDK 发射已学习按键;
7. 支持 Fake 和真实 ABSDK 调试模式;
8. 为后续生活场景保留稳定按键引用。
首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。
## 2. 已确认的 ABSDK 能力边界
ABSDK 0630 提供以下红外公开方法:
```java
ABRet sendIr(String irDevName, String irKey)
ABRet studyIrByIrDevName(String irDevName, String irKey)
ABRet downloadIrCodes(List<IrInfo> irInfos)
```
现有领域层已将其隔离为 `InfraredGateway`:
```kotlin
interface InfraredGateway {
suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit>
suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit>
suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>>
}
```
本设计必须遵守以下事实:
- 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值;
- ABSDK 没有公开停止学习方法;
- ABSDK 没有公开获取学习结果或读取已学习原始码的方法;
- `downloadIrCodes()` 是把调用方已经拥有的红外码写入主机,不是读取学习结果;
- ABSDK 没有设备扫描或设备列表接口;
- 红外方法按 `irDevName + irKey` 寻址;
- SDK 调用为同步阻塞调用,现有 Bridge 在 `Dispatchers.IO` 上通过全局 `Mutex` 串行执行;
- SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。
因此首版保存的是本地按键绑定,而不是原始红外波形或码值:
```text
ProviderProfileId + DeviceName + sdkKey + displayName + learning status
```
真实硬件闭环成立的判据是:
```text
learnKey(deviceName, sdkKey) 成功
↓
sendKey(deviceName, sdkKey)
↓
实体被控设备产生正确响应
```
## 3. 范围
### 3.1 本阶段包含
- 红外功能的领域模型和本地持久化端口;
- Room 数据库、DAO 和 Repository;
- 学习、重试、发射、删除和恢复应用用例;
- 红外 ViewModel 和简单 Compose 页面;
- 登录成功页面的临时红外入口;
- 手动输入并记住一个 `irDevName`;
- 单红外控制器、多按键;
- Fake/Real ABSDK 显式调试模式;
- 自动化测试;
- 真机验收清单;
- 面向未来生活场景的稳定按键引用。
### 3.2 本阶段不包含
- 最终 UI 视觉规范或复杂遥控器布局;
- 多个遥控器管理界面;
- ABSDK 不存在的设备扫描;
- 原始红外码读取或持久化;
- 红外码库导入 UI;
- 硬件停止学习;
- 完整生活场景模型、规则引擎或场景执行器;
- Release 中默认启用真实 ABSDK;
- 绕过或隐藏厂商明文 HTTP 风险;
- 删除 ABox 服务端已学习按键。
## 4. 架构
采用独立应用编排层,保持现有 `InfraredGateway` 作为纯硬件能力端口:
```text
登录成功页面
└─ 临时入口:红外控制
↓
InfraredControlScreen
↓
InfraredControlViewModel
↓
应用用例
├─ LearnInfraredKey
├─ RetryInfraredKeyLearning
├─ SendInfraredKey
├─ DeleteInfraredKey
├─ RecoverInterruptedInfraredLearning
└─ ObserveInfraredKeys
↙ ↘
InfraredKeyRepository InfraredGateway
↓ ├─ FakeInfraredGateway
RoomInfraredKeyRepository └─ AbsdkBridge
↓
ABSDK 0630
```
### 4.1 边界原则
- `InfraredGateway` 只负责学习、发射和码库下载,不负责 Room、页面状态或遥控器管理;
- 不修改 `learnKey()` 返回类型,不虚构 ABSDK 原始码结果;
- 不把 `ABSDK`、`ABRet`、`IrInfo` 或原始 Map 暴露给上层;
- ViewModel 不直接编排数据库和硬件之间的一致性,统一通过应用用例;
- Room Entity 不作为领域或 UI 公共模型;
- Fake 和真实 Bridge 必须共用相同的领域接口和用例;
- 所有真实 Gateway 接口必须指向同一个进程级 `AbsdkBridge` 实例,保持全局会话和互斥锁一致。
## 5. 数据模型
### 5.1 稳定标识
```kotlin
@JvmInline
value class InfraredKeyId(val value: String)
data class InfraredKeyRef(
val profileId: ProviderProfileId,
val keyId: InfraredKeyId,
)
```
`InfraredKeyRef` 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。
### 5.2 学习状态
```kotlin
enum class InfraredKeyStatus {
LEARNING,
READY,
FAILED,
}
```
状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。
### 5.3 领域模型
```kotlin
data class SavedInfraredKey(
val id: InfraredKeyId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val sdkKey: String,
val status: InfraredKeyStatus,
val lastErrorCode: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
```
字段约束:
- `id` 在 App 内稳定,供页面、删除和未来生活场景引用;
- `profileId` 用于不同登录配置的数据隔离;
- `deviceName` 是传给 ABSDK 的真实红外设备名;
- `displayName` 是用户可见名称,可以修改;
- `sdkKey` 是传给 ABSDK 的稳定 Key,学习后不随显示名改变;
- `lastErrorCode` 仅保存脱敏厂商错误码,不保存密码、Token、原始 Map 或红外载荷;
- 时间字段由应用层时钟提供,测试中使用可控时钟。
### 5.4 SDK Key
App 自动生成 SDK Key,不允许用户直接编辑。格式应:
- 仅包含安全 ASCII 字符;
- 在同一 Provider 和红外设备下唯一;
- 重试学习时复用;
- 修改显示名时保持不变;
- 不直接使用中文、空格或特殊字符。
建议格式:
```text
app_ir_<normalized-infrared-key-id>
```
实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。
## 6. 本地持久化
采用 Room。Repository 边界至少支持:
```kotlin
interface InfraredKeyRepository {
fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>>
suspend fun getKey(
ref: InfraredKeyRef,
): DomainResult<SavedInfraredKey?>
suspend fun insertLearningKey(
key: SavedInfraredKey,
): DomainResult<Unit>
suspend fun updateStatus(
ref: InfraredKeyRef,
status: InfraredKeyStatus,
lastErrorCode: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun renameKey(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun deleteKey(
ref: InfraredKeyRef,
): DomainResult<Unit>
suspend fun findInterruptedLearning(
profileId: ProviderProfileId,
): DomainResult<List<SavedInfraredKey>>
}
```
具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。
### 6.1 数据库约束
- 主键:`InfraredKeyId`;
- 索引:`ProviderProfileId + DeviceName`;
- 唯一约束:`ProviderProfileId + DeviceName + sdkKey`;
- 查询和删除必须包含 Provider 隔离;
- 不保存原始红外码;
- 不把 Room Entity 暴露给领域层;
- 首版预留 schema version 和迁移测试基础。
### 6.2 单设备策略
页面记住一个当前 `irDevName`,并只展示该设备下的按键。切换当前设备名不会迁移或重写旧按键;旧按键仍绑定学习时使用的设备名。
设备名可以保存到 Room 配置表或 DataStore。若采用 DataStore,只用于当前设备名和 debug 模式等轻量配置,不承载红外按键集合。
## 7. 学习流程
`LearnInfraredKey` 的流程:
1. 验证用户已登录且存在有效 `ProviderProfileId`;
2. 验证 `deviceName` 和 `displayName` 非空;
3. 生成稳定 `InfraredKeyId` 和 `sdkKey`;
4. 先写入状态为 `LEARNING` 的记录;
5. 在应用级协程作用域调用 `InfraredGateway.learnKey()`;
6. 成功时更新为 `READY` 并清空错误码;
7. 失败时更新为 `FAILED` 并保存脱敏错误码;
8. 如果状态更新失败,记录可恢复的本地数据错误,但不重新生成 SDK Key;
9. 页面通过 Room Flow 观察最终状态。
### 7.1 重试
重试必须:
- 使用原 `InfraredKeyId`;
- 使用原 `sdkKey`;
- 先将状态更新为 `LEARNING`;
- 再调用 ABSDK;
- 不创建重复按键记录。
### 7.2 页面离开
学习调用开始后,页面可以退出。任务继续在应用级作用域等待 SDK 返回,并更新 Room。ViewModel 销毁不应直接取消已经提交的学习编排。
这不是后台系统任务保证:如果进程被系统杀死,底层同步网络请求及设备状态无法可靠恢复。
### 7.3 进程恢复
App 启动或登录恢复后,查找遗留的 `LEARNING` 记录:
```text
LEARNING → FAILED
错误提示:上次学习结果未确认,请重新学习
```
不得自动标记为 `READY`,也不得自动发射来试探设备状态。
### 7.4 取消语义
ABSDK 没有停止学习 API。首版 UI 不提供或宣称“停止硬件学习”。用户可以离开页面,但学习调用仍等待成功、失败或 SDK 超时。
## 8. 发射流程
`SendInfraredKey` 的流程:
1. 使用 `InfraredKeyRef` 查询本地记录;
2. 记录不存在时返回领域错误;
3. 仅允许 `READY` 状态发射;
4. 取得记录中的 `profileId + deviceName + sdkKey`;
5. 调用 `InfraredGateway.sendKey()`;
6. 成功时向 UI 返回一次性成功事件;
7. 失败时保留按键 `READY` 状态,仅报告本次错误;
8. 不自动重试,避免重复操作电视、空调等实体设备。
未来生活场景通过稳定 `InfraredKeyRef` 调用同一发送用例或薄执行器:
```kotlin
interface InfraredActionExecutor {
suspend fun execute(ref: InfraredKeyRef): DomainResult<Unit>
}
```
本阶段只定义或保证稳定引用边界,不实现 Scene 模型、规则引擎或场景执行顺序。
## 9. 删除与重命名
### 9.1 删除
- `FAILED` 和 `READY` 记录可以删除;
- `LEARNING` 中不允许删除,避免后台结果写入已不存在的记录;
- 删除只移除本地引用;
- ABSDK 没有删除服务端红外 Key 的公开接口;
- UI 必须提示“ABox 服务端可能仍保留该按键”;
- 删除后未来场景引用解析失败,不再发射。
### 9.2 重命名
- 只修改 `displayName`;
- 不修改 `id`、`deviceName` 或 `sdkKey`;
- 重命名后学习和发射能力保持不变;
- 场景引用保持有效。
## 10. Android 前端
### 10.1 临时入口
在当前登录成功后的页面添加“红外控制”按钮,导航到独立 `InfraredControlScreen`。未来正式信息架构完成后只迁移入口。
### 10.2 页面内容
首版页面只包含:
- 返回按钮和标题;
- 当前数据源标识:Fake 或真实 ABox;
- 红外控制器设备名输入和保存;
- 已学习按键列表;
- 学习新按键按钮;
- 按键状态;
- READY 按键的发射按钮;
- FAILED 按键的重试按钮;
- 删除入口和确认提示;
- 基础错误反馈。
不加入复杂遥控器模板、图标系统、网格布局、品牌色或动画规范。
### 10.3 设备名
- ABSDK 没有设备扫描能力;
- 首次进入时手动填写 `irDevName`;
- 保存后本地记住;
- 没有设备名时禁止学习;
- 修改设备名前提示旧按键仍绑定旧设备;
- 列表只显示当前设备名下的按键。
### 10.4 学习交互
用户输入显示名称后开始学习。页面提示:
```text
请将实体遥控器对准红外控制器并按下目标按键。
```
按键显示 `LEARNING`、`READY` 或 `FAILED`。允许离开页面,但不显示“停止硬件学习”。
### 10.5 发射交互
- 只有 `READY` 按键可发射;
- 发射期间禁用该按键,防止重复点击;
- 成功显示短暂提示;
- 失败显示具体错误;
- 不自动重试;
- 单次失败不删除或降级按键状态。
### 10.6 删除提示
删除确认文案应明确:
```text
仅从本机列表移除,ABox 服务端可能仍保留该按键。
```
## 11. Debug Fake/Real 路由
Debug 构建提供显式运行模式:
- `FAKE`:页面开发、自动化测试和错误模拟;
- `REAL_ABSDK`:复用当前登录会话,调用真实 `AbsdkBridge`。
约束:
- 页面持续显示当前模式;
- 不允许在学习任务进行中切换模式;
- Fake 和 Real 共用同一 ViewModel、应用用例和 Room Repository;
- 模式和 Provider 数据必须隔离,避免 Fake 按键误用于真实设备;
- Fake 支持按 `ProviderProfileId + DeviceName + sdkKey` 配置成功或失败;
- 真实模式的所有 Gateway 接口绑定同一 `AbsdkBridge` 单例;
- 真实模式必须显式 opt in `CleartextAbsdkTransport`;
- Release 暂不启用真实 Bridge,除非项目明确批准传输安全方案。
真实 App 接入还必须补齐:
- `:absdk-bridge` 依赖;
- Hilt 绑定;
- `INTERNET` 权限;
- 经批准的明文网络配置;
- 登录会话与红外 Gateway 的同一 Bridge 实例;
- Token 失效后的页面返回登录流程。
## 12. 错误处理
继续使用现有 `DomainResult` 和 `DomainError`。不为首版扩充 sealed 类型,优先利用已有分类和 `externalCode`。
| 情况 | UI 反馈 | 本地状态 |
|---|---|---|
| Token 无效 | 登录已失效,返回登录 | 学习按键为 FAILED;发射按键仍为 READY |
| 红外设备不存在 | 检查设备名 | 学习按键为 FAILED |
| 红外设备离线 | 检查供电和网络 | 学习按键为 FAILED;发射按键仍为 READY |
| KEY/码值不存在 | 建议重新学习 | 发射按键仍为 READY,由用户决定重学 |
| 红外发射失败 | 显示失败,可手动重试 | READY |
| 网络超时 | 网络或 ABox 服务不可用 | 学习按键为 FAILED;发射按键仍为 READY |
| 本地保存失败 | 无法开始学习 | 不调用硬件学习 |
| 学习成功但最终状态写入失败 | 显示数据保存异常并保留恢复信息 | 不生成新 sdkKey |
| 未知错误 | 通用提示及脱敏错误码 | 按操作类型处理 |
日志不得包含:
- 用户密码;
- Token;
- 原始 SDK Map;
- 原始红外码或载荷;
- 可用于重放的敏感字段。
## 13. 自动化测试
### 13.1 应用用例
覆盖:
1. 学习前先保存 `LEARNING`;
2. 学习成功更新为 `READY`;
3. 学习失败更新为 `FAILED`;
4. 重试复用原 `sdkKey`;
5. 修改显示名不改变 `sdkKey`;
6. 只有 `READY` 可以发射;
7. 发射使用保存的 `deviceName + sdkKey`;
8. 不同 Provider 数据隔离;
9. 遗留 `LEARNING` 恢复为 `FAILED`;
10. 删除后稳定引用失效;
11. ViewModel 销毁后应用级学习任务仍可落库;
12. 发射失败不改变 READY;
13. 进行中的学习阻止运行模式切换。
### 13.2 Room
覆盖:
- 新增、观察、查询、重命名和删除;
- `ProviderProfileId + DeviceName` 过滤;
- `sdkKey` 唯一约束;
- Provider 隔离;
- 状态转换;
- 稳定 ID 跨重命名不变;
- 首版 schema 和迁移测试基础;
- 不存储原始红外载荷。
### 13.3 Fake
扩展现有 Fake,覆盖:
- 按 Provider、设备和 Key 脚本化结果;
- 调用参数与顺序;
- 连续响应;
- 成功、离线、超时和通用失败;
- 不同 Provider 互不影响。
### 13.4 Bridge
保留现有覆盖并补充:
- 学习参数和调用顺序;
- 发射参数和调用顺序;
- 红外错误码 `20201` 至 `20206` 的映射;
- 学习、发射与其他设备调用的全局串行;
- Token 失效清理会话;
- SDK 空值和异常防御;
- 不改变 `downloadCodes()` 的部分成功语义。
### 13.5 ViewModel 与 Compose
只验证功能,不验证最终视觉:
- 无设备名不能学习;
- 保存和恢复设备名;
- 创建学习任务;
- 显示学习中、成功和失败;
- 重试学习;
- 点击发射并防重复点击;
- 删除确认;
- Fake/Real 模式标识;
- 登录失效引导;
- 基本中文和英文资源。
## 14. 真机验收
第一轮使用现有 ABox 主机和红外控制器:
1. 登录真实账号;
2. 输入后台提供的准确 `irDevName`;
3. 创建唯一按键,例如“测试电源”;
4. 调用学习并记录耗时、返回码和消息;
5. 学习成功后立即发射;
6. 验证实体电器是否响应;
7. 用同一按键重复发射;
8. 重新学习同一 `sdkKey`,验证覆盖或报错行为;
9. 红外控制器断电后验证离线错误;
10. 不按实体遥控器,验证实际超时行为;
11. 页面退出后验证后台结果落库;
12. 进程被杀后验证遗留 LEARNING 恢复;
13. Token 失效后验证返回登录;
14. 记录 SDK 是否存在未文档化 `dicDatas`,但不将内容写入生产日志。
### 14.1 必须验证的未知项
- 学习调用是立即返回还是阻塞到捕获完成;
- 学习成功是否自动绑定 `sdkKey`;
- 同一 `sdkKey` 重学是否覆盖;
- 无信号时实际超时时间;
- 页面离开后底层调用是否可完成;
- 设备名格式、大小写和特殊字符规则;
- 服务端是否永久保留已学习 Key;
- SDK 是否返回未文档化数据;
- `sdkKey` 的长度限制;
- 真实模式所需初始化信息和调用顺序。
这些未知项不得通过业务层假设隐藏。真机结果应更新协议文档和后续设计。
## 15. 验收标准
本阶段完成需要满足:
- 登录成功页面可以进入简单红外页面;
- 可以手动配置并恢复一个红外设备名;
- 可以创建多个命名按键;
- 学习调用前已持久化 LEARNING;
- 学习成功或失败能正确落库;
- READY 按键可以调用发射;
- 显示名修改不影响发射;
- Fake 模式可以覆盖主要状态和错误;
- Real 模式明确标识并调用真实 Bridge;
- 页面退出后学习任务仍可更新状态;
- 进程中断后遗留学习状态可以保守恢复;
- 自动化测试通过;
- 真机至少完成一次“学习成功 → 发射 → 实体响应”;
- 未确认硬件语义被记录,不伪装为已支持;
- 未来生活场景可以通过稳定 `InfraredKeyRef` 引用按键。
## 16. 后续工作
本设计完成后再分别规划:
1. 多遥控器和房间组织;
2. 统一产品视觉和正式导航入口;
3. 红外码库导入;
4. 生活场景编排;
5. Scene 顺序执行和单项结果;
6. 厂商明文 HTTP 的正式安全方案;
7. 真机验证后对学习语义、超时和错误模型的修订。
@@ -0,0 +1,115 @@
# 红外遥控器界面与多场景控制设计规范 / Infrared Remote Control UI Specification
## 1. 背景与目标 (Background & Goals)
在完成了底层红外学习、发射、Room 数据库、1~100 整数 Key 槽位管理以及 Fake/Real ABSDK 路由的基础上,本项目需要提供专业且直观的前端遥控器人机交互界面。
### 核心目标
1. **多场景遥控面板**:提供空调 (Air Conditioner) 与电视 (Television) 两大经典场景遥控器界面,使用圆形、圆角矩形、胶囊、圆形方向盘等拟物/现代图形组件构建真实遥控器外观。
2. **学习模式与控制模式切换**:右上角提供“红外学习模式”开关,在控制模式下点击按键发射红外,在学习模式下点击按键录制红外。
3. **固定槽位绑定 (Fixed Slot Binding)**:遥控器上的每一个功能按键具有预设唯一的 1~100 整数槽位编号(例如空调电源为 Slot 1,电视电源为 Slot 21)。
4. **简洁的符号化设计**:遥控器按键表面只展示标准图标与简写符号(⏻, +, -, ∧, ∨, ❄, 🔊 等),不展示底层数据库中文长名称。
5. **设置页面解耦与导航**:原“红外控制页面”定位为“红外学习设置页面”(Infrared Settings),通过遥控器右上角设置图标 ⚙️ 进入,用于配置红外设备名和管理全部已学按键列表。
---
## 2. 槽位与按键分配定义 (Slot & Button Mapping)
每个按键定义为一个 `RemoteButtonSpec`,包含按键唯一标识 `id`、对应默认槽位 `defaultSlot`、默认显示名称 `displayName`、图标及在遥控器面板中的图形位置。
### 2.1 空调遥控器 (Air Conditioner: Slots 1–20)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `AC_POWER` | 电源 ⏻ | `1` | `空调-电源` | 顶部圆形独立红色高亮按键 |
| `AC_TEMP_UP` | 温度+ 🔼 | `2` | `空调-温度+` | 垂直胶囊上半部分 |
| `AC_TEMP_DOWN` | 温度- 🔽 | `3` | `空调-温度-` | 垂直胶囊下半部分 |
| `AC_MODE` | 模式 ❄/☀ | `4` | `空调-模式` | 圆角矩形 |
| `AC_FAN` | 风速 💨 | `5` | `空调-风速` | 圆角矩形 |
| `AC_SWING` | 扫风 ↔️ | `6` | `空调-扫风` | 圆角矩形 |
| `AC_TIMER` | 定时 ⏱️ | `7` | `空调-定时` | 圆角矩形 |
### 2.2 电视遥控器 (Television: Slots 21–50)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `TV_POWER` | 电源 ⏻ | `21` | `电视-电源` | 顶部圆形红色按键 |
| `TV_MUTE` | 静音 🔇 | `22` | `电视-静音` | 顶部圆形灰色按键 |
| `TV_NAV_UP` | 方向上 🔼 | `23` | `电视-上` | 方向环形盘上弧段 |
| `TV_NAV_DOWN` | 方向下 🔽 | `24` | `电视-下` | 方向环形盘下弧段 |
| `TV_NAV_LEFT` | 方向左 ◀️ | `25` | `电视-左` | 方向环形盘左弧段 |
| `TV_NAV_RIGHT` | 方向右 ▶️ | `26` | `电视-右` | 方向环形盘右弧段 |
| `TV_OK` | 确认 🆗 | `27` | `电视-确定` | 方向盘中心圆形 |
| `TV_VOL_UP` | 音量+ 🔊+ | `28` | `电视-音量+` | 垂直胶囊上半部分 |
| `TV_VOL_DOWN` | 音量- 🔉- | `29` | `电视-音量-` | 垂直胶囊下半部分 |
| `TV_CH_UP` | 频道+ 📺+ | `30` | `电视-频道+` | 垂直胶囊上半部分 |
| `TV_CH_DOWN` | 频道- 📺- | `31` | `电视-频道-` | 垂直胶囊下半部分 |
| `TV_HOME` | 主页 🏠 | `32` | `电视-主页` | 中部圆角胶囊按键 |
| `TV_BACK` | 返回 ↩️ | `33` | `电视-返回` | 中部圆角胶囊按键 |
| `TV_MENU` | 菜单 ☰ | `34` | `电视-菜单` | 中部圆角胶囊按键 |
---
## 3. 架构与组件设计 (Architecture & Component Design)
### 3.1 导航结构
```text
FoundationScreen (首页/网关登录)
↓ 点击 "红外控制" (infrared/{profileId})
InfraredRemoteScreen (遥控器主界面,默认空调/电视)
├─ 学习模式开关 (Toggle: Learning Mode)
├─ 场景切换器 (Tab: ❄️ 空调 | 📺 电视)
├─ 空调遥控器面板 (AcRemotePanel)
├─ 电视遥控器面板 (TvRemotePanel)
└─ 点击右上角 ⚙️ 设置图标
↓ (infrared_settings/{profileId})
InfraredSettingsScreen (原红外设置/按键管理列表页面)
```
### 3.2 状态管理 (`InfraredRemoteViewModel` & `InfraredRemoteUiState`)
- `InfraredRemoteUiState`:
- `selectedScene`: `RemoteScene.AIR_CONDITIONER` 或 `RemoteScene.TELEVISION`
- `isLearningMode`: `Boolean`(是否处于学习录制模式)
- `activeLearningKeySlot`: `String?`(当前正在进行学习请求的按键槽位)
- `sendingKeySlot`: `String?`(当前正在发射信号的按键槽位)
- `learnedSlots`: `Map<String, SavedInfraredKey>`(已在 Room 数据库中记录为 READY 的槽位映射)
- `savedDeviceName`: `DeviceName?`(当前配置的红外设备名,如 `红外控制器`)
- `runtimeMode`: `InfraredRuntimeMode`(Fake 或 Real ABSDK)
- `message`: `UiMessage?`(提示信息或错误)
---
## 4. 交互与视觉规范 (Interaction & Visuals)
### 4.1 视觉造型
1. **遥控器本体外壳 (Remote Body Chassis)**:
- 居中放置的圆角卡片,具有微阴影、圆角半径 `28.dp`、外框及哑光质感。
2. **空调 LCD 虚拟屏**:
- 顶部黑色圆角框,内部呈现液晶感绿色/白色字样(如当前目标温度 `26°C` 与工作状态)。
3. **垂直双向胶囊摇杆 (Vertical Capsule Rockers)**:
- 上半区与下半区独立响应点击,中间由细微分割线隔开,常用于“温度 +/-”、“音量 +/-”及“频道 +/-”。
4. **圆形导航方向盘 (D-Pad Wheel)**:
- 直径约 `180.dp` 的圆形组合组件,外环包含 4 个扇区方向按键,内嵌独立的圆形 OK 键。
### 4.2 按键状态机反馈
1. **控制模式 (学习开关 OFF)**:
- **已录制 (READY)**:按键实体呈现饱满质感,点击触发波纹反馈,调用 `sendKey` 发射红外,提示“已发射”。
- **未录制 (UNLEARNED)**:按键显示浅色未激活状态;点击弹出轻量提示:`“按键尚未学习,请打开右上角学习模式”`。
2. **学习录制模式 (学习开关 ON)**:
- 顶部呈现橙色高亮提示条:`“🔴 学习模式已开启:点击任意按键开始录制”`。
- 所有按键进入可录制状态(带提示外框)。
- 点击目标按键后:
- 按键上显示微型 Loading 光圈。
- 调用 `learnKey(deviceName, defaultSlot, displayName)`。
- 手机或控制器捕获红外信号成功后,按键变为 READY 并伴随成功提示。
---
## 5. 验证与测试标准 (Verification & Acceptance Criteria)
1. **单元测试 (Unit Tests)**:
- `InfraredRemoteViewModelTest` 覆盖场景切换、学习模式开关、按键已学/未学状态映射、点击发射以及点击学习请求的转发。
2. **UI 测试 (Compose Tests)**:
- 空调与电视遥控器按键正常渲染并带正确 Tag。
- 学习模式切换后 UI 状态正确切换。
3. **真机与编译验收**:
- 模块与 App 通过 JVM 测试与打包验证。
- 手机上点击空调/电视按键能发起正确的 Slot 编号学习与发射。
@@ -0,0 +1,238 @@
# LAN ABSDK and Local Experience Design
## Status
Approved on 2026-08-24 by the project owner. This specification replaces the three-provider model in `feat/provider-login`.
## Goal
ABox Manager exposes exactly two choices before authentication:
1. **ABox** uses the real vendor ABSDK bridge to communicate with the smart-home gateway available on the current LAN.
2. **Local experience** uses production-safe simulation implementations, establishes a simulated session immediately, and enters the authenticated app without requesting credentials.
The app no longer offers or plans a self-hosted ABSDK-compatible backend.
## Confirmed Environment and Constraints
- The observed gateway address is `192.168.100.1`.
- The gateway responds to the ABSDK-compatible route `POST /abox/index.php/abjhsdk/user/validate`.
- `libABSDK0630.jar` hardcodes `a-box.com.cn/abox/index.php/` and does not expose a supported base-URL setter.
- `setHostInfo(url, hotelName, roomNo, apikey)` submits `url` as an initialization form field and does not change the HTTP target.
- Hardware testing on `feat/moler-infrared-control` confirms that the current network environment allows the vendor ABSDK to reach the real gateway.
- Debug and release builds expose the same provider choices and routing behavior. Release is not treated as a special case.
- The vendor JAR uses cleartext HTTP. The existing `@CleartextAbsdkTransport` opt-in remains mandatory, and the Android application must explicitly permit the required LAN transport.
- Fake and real sessions, devices, state, and credentials must never be mixed.
## Provider Model
`ProviderType` contains only:
```kotlin
enum class ProviderType {
ABOX,
LOCAL_EXPERIENCE,
}
```
The built-in profiles are:
```text
abox-default -> ProviderType.ABOX
local-experience -> ProviderType.LOCAL_EXPERIENCE
```
The persisted profile store remains tolerant of removed enum values. A stored `COMPATIBLE_BACKEND` profile is ignored. If it was active, the app returns to provider selection rather than attempting migration to another provider.
## User Flow
### ABox
```text
Provider selection
-> choose ABox
-> save and activate ABox profile
-> restore automatic-login preferences when available
-> otherwise show username/password login
-> SessionCoordinator delegates to the process-wide AbsdkBridge
-> enter authenticated app only after DomainResult.Success<Session>
```
Invalid credentials, network errors, and token failures remain on the login screen and display localized errors.
### Local experience
```text
Provider selection
-> choose Local experience
-> save and activate local profile
-> SimulationSessionGateway establishes Username("local") session
-> enter authenticated app immediately
```
Local experience:
- never displays the credential form;
- never calls ABSDK;
- never reads or writes Keystore credentials;
- uses production-safe simulation device gateways;
- can later be removed as a single provider/module boundary if the team decides not to ship it.
## Module Boundaries
### `:absdk-bridge`
Remains the anti-corruption layer around the vendor JAR and implements:
- `SessionGateway`;
- `SocketGateway`;
- `TemperatureHumidityGateway`;
- `InfraredGateway`.
A single process-wide `AbsdkBridge` instance is shared by session and device operations so the mutex and vendor singleton Token remain aligned.
### New `:runtime:simulation`
A production Kotlin/JVM module implementing:
- `LocalSessionGateway` as `SimulationSessionGateway`;
- `DeviceCatalog` as `SimulationDeviceCatalog`;
- `SocketGateway` as `SimulationSocketGateway`;
- `TemperatureHumidityGateway` as `SimulationTemperatureHumidityGateway`;
- `InfraredGateway` as `SimulationInfraredGateway`.
These implementations provide deterministic local experience behavior but do not expose test-only failure injection, mutable call-recording APIs, or assertion helpers.
### `:testing:fakes`
Remains test-only. It keeps scriptable errors, call recording, and other testing controls. It must not be added as an application runtime dependency.
### `:android:core:session`
Keeps provider/profile persistence, Keystore credential storage, and `LocalSessionGatewayImpl` only until the new simulation gateway replaces it. After migration, the old local gateway implementation is removed to prevent two production local-session implementations.
### `:android:app`
Owns the Hilt composition root and provider-aware routing. It injects:
```text
SessionGateway -> AbsdkBridge
LocalSessionGateway -> SimulationSessionGateway
```
For ABSDK-backed device operations, the app exposes provider-routing adapters:
```text
RoutingSocketGateway
RoutingTemperatureHumidityGateway
RoutingInfraredGateway
```
Each adapter reads the active `ProviderProfile` and delegates:
```text
ABOX -> real AbsdkBridge implementation
LOCAL_EXPERIENCE -> simulation implementation
```
`DeviceCatalog` is not implemented by ABSDK 0630. The ABox catalog remains a provider-scoped local persistence responsibility owned by the device-catalog module; `SimulationDeviceCatalog` supplies only the built-in local-experience catalog. This change does not invent a remote discovery API or take ownership of the final ABox catalog implementation from its module owner.
No feature UI selects Real or Fake directly.
## Session and Credential Rules
- `SessionCoordinator` continues to select the session gateway from `ProviderType`.
- ABox credentials can use remember-username and automatic-login settings.
- Automatic-login passwords are stored through Android Keystore.
- Local experience cannot persist credentials or login preferences requiring a password.
- Switching provider logs out the current gateway before activating another profile.
- Logging out of ABox clears credentials and the Bridge-local session but leaves the ABox profile active so the login page can be shown again.
- Logging out of local experience clears the local session and active profile, returning to provider selection.
- Only a session whose `profileId` matches the active profile can produce the authenticated UI state.
## Provider Availability
As implemented, `ProviderAvailability` was deleted entirely: both remaining options are always enabled, so an availability model with disabled states has no caller. The provider selection screen lists exactly ABox and local experience in every build type, with no per-provider availability state or disabled reason.
## Android Transport
The application declares:
```xml
<uses-permission android:name="android.permission.INTERNET" />
```
and permits the vendor cleartext transport required by the current ABSDK. This is an explicit acceptance of the current LAN integration constraint, not a claim that the transport is encrypted.
No password, Token, API key, raw vendor response, or infrared payload may be logged.
## UI and Copy
Provider selection presents exactly:
- **ABox** — connects to the ABox smart-home gateway through ABSDK on the current LAN;
- **Local experience** — uses simulated local devices without network access.
All compatibility-backend copy, status labels, and branches are removed from English and Simplified Chinese resources.
## Error Handling
- ABox authentication failure maps to the localized invalid-credentials message and does not create a session.
- Network failure maps to the localized network message and does not create a session.
- Token invalidation clears the Bridge-local session and returns the app to login.
- Selecting local experience fails visibly if profile persistence or simulated-session creation fails.
- Routing adapters return `DomainError.InvalidRequest` when no active profile exists or the profile type is unsupported.
- Cancellation is rethrown rather than converted to a normal failure.
## Testing Strategy
### Unit tests
- `ProviderType` and provider selection expose exactly ABox and local experience.
- Removed persisted compatible profiles are ignored and cannot become active.
- `SessionCoordinator` routes ABox to `SessionGateway` and local experience to `LocalSessionGateway`.
- Arbitrary ABox credentials do not succeed unless the injected real/fake test gateway returns success.
- Local experience selection creates a session without calling the ABox gateway or credential store.
- Each socket, temperature/humidity, and infrared routing adapter delegates ABox calls to the real test double and local calls to simulation.
- The local-experience device catalog is supplied by simulation without asserting that ABSDK provides ABox device discovery.
- Switching providers logs out the previous session and isolates state.
### Integration and build verification
- Debug and release compile with the same real ABSDK and simulation modules.
- Hilt resolves one binding for each public gateway.
- Android lint validates Internet/cleartext configuration.
- Full command: `./gradlew check testDebugUnitTest test`.
### Authorized hardware validation
On a device connected to the gateway LAN:
1. Choose ABox.
2. Enter a known-invalid credential pair and verify the app stays on login.
3. Enter an authorized valid credential pair and verify authenticated navigation.
4. Validate Token using the shared Bridge.
5. Perform one read-only device query before any control operation.
6. Choose local experience and verify no ABSDK call or network dependency is required.
Sensitive credentials and Tokens are never captured in test reports.
## Documentation Changes
The implementation updates:
- `docs/requirement_spec.md`;
- `docs/project_plan.md`;
- `docs/architecture.md`;
- `docs/protocol/absdk-0630.md`.
The documents must no longer state that the product connects to an ABox cloud service or a self-hosted compatible backend. They must distinguish confirmed LAN behavior from the JAR's still-hardcoded host behavior.
## Out of Scope
- Reimplementing the ABSDK HTTP/signature protocol;
- patching or rewriting the vendor JAR;
- adding a self-hosted compatibility server;
- adding an in-app gateway IP field that the current JAR cannot consume;
- merging the authenticated app shell PR;
- deciding whether local experience remains in the final product beyond preserving it as a removable module boundary.
@@ -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` 安全阻断。
@@ -15,6 +15,41 @@ interface SessionGateway {
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit>
} }
interface ProviderProfileStore {
fun observeProfiles(): Flow<List<ProviderProfile>>
fun observeActiveProfile(): Flow<ProviderProfile?>
fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences>
suspend fun profiles(): List<ProviderProfile>
suspend fun activeProfile(): ProviderProfile?
suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences
suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit>
suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit>
suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit>
}
interface CredentialStore {
suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit>
suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?>
suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit>
}
interface LocalSessionGateway : SessionGateway
interface DeviceCatalog { interface DeviceCatalog {
fun observeDevices( fun observeDevices(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -25,7 +25,6 @@ value class Password(val value: String) {
enum class ProviderType { enum class ProviderType {
ABOX, ABOX,
COMPATIBLE_BACKEND,
LOCAL_EXPERIENCE, LOCAL_EXPERIENCE,
} }
@@ -53,6 +52,13 @@ data class LoginCredentials(
val password: Password, val password: Password,
) )
data class LoginPreferences(
val profileId: ProviderProfileId,
val rememberedUsername: Username? = null,
val rememberUsername: Boolean = false,
val autoLoginEnabled: Boolean = false,
)
data class Session( data class Session(
val profileId: ProviderProfileId, val profileId: ProviderProfileId,
val username: Username, val username: Username,
@@ -0,0 +1,206 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalCoroutinesApi::class)
class SessionCoordinator(
private val profileStore: ProviderProfileStore,
private val credentialStore: CredentialStore,
private val remoteGateway: SessionGateway,
private val localGateway: LocalSessionGateway,
) {
fun observeActiveSession(): Flow<Session?> = profileStore.observeActiveProfile()
.flatMapLatest { profile ->
profile?.gateway()?.observeSession(profile.id) ?: flowOf(null)
}
suspend fun bootstrap(): DomainResult<Session?> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(null)
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
return@storeOperation localGateway.login(profile.id, localCredentials())
.mapSuccess { it }
}
val preferences = profileStore.loginPreferences(profile.id)
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
when (val credentials = credentialStore.loadCredentials(profile.id)) {
is DomainResult.Failure -> credentials
is DomainResult.Success -> {
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
if (profileStore.activeProfile()?.id != profile.id) {
return@storeOperation DomainResult.Success(null)
}
profile.gateway().login(profile.id, savedCredentials).mapSuccess { it }
}
}
}
suspend fun selectProvider(profile: ProviderProfile): DomainResult<Session?> = storeOperation {
val current = profileStore.activeProfile()
if (current?.id != profile.id && current != null) {
when (val switched = switchProvider()) {
is DomainResult.Failure -> return@storeOperation switched
is DomainResult.Success -> Unit
}
}
when (val saved = profileStore.saveProfile(profile)) {
is DomainResult.Failure -> return@storeOperation saved
is DomainResult.Success -> Unit
}
when (val activated = profileStore.setActiveProfile(profile.id)) {
is DomainResult.Failure -> return@storeOperation activated
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
localGateway.login(profile.id, localCredentials()).mapSuccess { it }
} else {
DomainResult.Success(null)
}
}
suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
rememberUsername: Boolean,
autoLogin: Boolean,
): DomainResult<Session> = storeOperation {
val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Failure(
DomainError.InvalidRequest(detail = "No active provider profile"),
)
if (profile.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
return@storeOperation DomainResult.Failure(
DomainError.InvalidRequest(detail = "The active provider does not require this login"),
)
}
when (val result = remoteGateway.login(profileId, credentials)) {
is DomainResult.Failure -> result
is DomainResult.Success -> {
val previousPreferences = profileStore.loginPreferences(profileId)
val previousCredentials = credentialStore.loadCredentials(profileId)
if (previousCredentials is DomainResult.Failure) {
remoteGateway.logout(profileId)
return@storeOperation previousCredentials
}
val preferences = LoginPreferences(
profileId = profileId,
rememberedUsername = credentials.username.takeIf { rememberUsername },
rememberUsername = rememberUsername,
autoLoginEnabled = autoLogin,
)
val credentialResult = if (autoLogin) {
credentialStore.saveCredentials(profileId, credentials)
} else {
credentialStore.clearCredentials(profileId)
}
if (credentialResult is DomainResult.Failure) {
remoteGateway.logout(profileId)
return@storeOperation credentialResult
}
when (val preferencesResult = profileStore.saveLoginPreferences(preferences)) {
is DomainResult.Success -> result
is DomainResult.Failure -> {
when (previousCredentials) {
is DomainResult.Success -> previousCredentials.value?.let {
credentialStore.saveCredentials(profileId, it)
} ?: credentialStore.clearCredentials(profileId)
is DomainResult.Failure -> Unit
}
profileStore.saveLoginPreferences(previousPreferences)
remoteGateway.logout(profileId)
preferencesResult
}
}
}
}
}
suspend fun recoverSession(profileId: ProviderProfileId): DomainResult<Session?> = storeOperation {
val profile = profileStore.activeProfile()
if (profile?.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
return@storeOperation DomainResult.Success(null)
}
val preferences = profileStore.loginPreferences(profileId)
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
when (val credentials = credentialStore.loadCredentials(profileId)) {
is DomainResult.Failure -> credentials
is DomainResult.Success -> {
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
when (val result = remoteGateway.login(profileId, savedCredentials)) {
is DomainResult.Success -> result.mapSuccess { it }
is DomainResult.Failure -> {
if (result.error is DomainError.Authentication ||
result.error is DomainError.TokenInvalid
) {
credentialStore.clearCredentials(profileId)
profileStore.saveLoginPreferences(
preferences.copy(autoLoginEnabled = false),
)
}
result
}
}
}
}
}
suspend fun logout(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
is DomainResult.Failure -> return@storeOperation credentialResult
is DomainResult.Success -> Unit
}
val preferences = profileStore.loginPreferences(profile.id)
when (
val preferencesResult = profileStore.saveLoginPreferences(
preferences.copy(autoLoginEnabled = false),
)
) {
is DomainResult.Failure -> return@storeOperation preferencesResult
is DomainResult.Success -> Unit
}
when (val logoutResult = profile.gateway().logout(profile.id)) {
is DomainResult.Failure -> return@storeOperation logoutResult
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
profileStore.setActiveProfile(null)
} else {
DomainResult.Success(Unit)
}
}
suspend fun switchProvider(): DomainResult<Unit> = storeOperation {
when (val logoutResult = logout()) {
is DomainResult.Failure -> logoutResult
is DomainResult.Success -> profileStore.setActiveProfile(null)
}
}
private fun ProviderProfile.gateway(): SessionGateway =
if (type == ProviderType.LOCAL_EXPERIENCE) localGateway else remoteGateway
private fun localCredentials(): LoginCredentials = LoginCredentials(
username = Username("local"),
password = Password(""),
)
private suspend fun <T> storeOperation(block: suspend () -> DomainResult<T>): DomainResult<T> =
try {
block()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
private inline fun <T, R> DomainResult<T>.mapSuccess(transform: (T) -> R): DomainResult<R> = when (this) {
is DomainResult.Failure -> this
is DomainResult.Success -> DomainResult.Success(transform(value))
}
@@ -0,0 +1,86 @@
package com.flagship.abox.manager.domain.infrared
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
fun interface InfraredKeyIdGenerator {
fun nextId(): InfraredKeyId
}
fun interface EpochMillisClock {
fun now(): Long
}
class InfraredKeyFactory(
private val idGenerator: InfraredKeyIdGenerator,
private val clock: EpochMillisClock,
) {
fun create(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim()
if (
profileId.value.isBlank() ||
profileId.value != profileId.value.trim() ||
deviceName.value.isBlank() ||
deviceName.value != deviceName.value.trim() ||
normalizedName.isBlank()
) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
)
}
val id = idGenerator.nextId()
val canonicalId = canonicalizeId(id.value)
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
)
val finalSdkKey = if (sdkKey != null) {
val trimmed = sdkKey.trim()
val intVal = trimmed.toIntOrNull()
if (intVal == null || intVal !in 1..100) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
)
}
intVal.toString()
} else {
"app_ir_$canonicalId"
}
val now = clock.now()
return DomainResult.Success(
SavedInfraredKey(
id = id,
profileId = profileId,
deviceName = deviceName,
displayName = normalizedName,
sdkKey = finalSdkKey,
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
),
)
}
private fun canonicalizeId(value: String): String? = when {
ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase()
STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase()
else -> null
}
private companion object {
val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$")
val STANDARD_UUID = Regex(
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
)
}
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
@JvmInline
value class InfraredKeyId(val value: String)
data class InfraredKeyRef(
val profileId: ProviderProfileId,
val keyId: InfraredKeyId,
)
enum class InfraredKeyStatus {
LEARNING,
READY,
FAILED,
}
const val INTERRUPTED_LEARNING_ERROR_DETAIL =
"Interrupted learning result is unknown; retry required"
/**
* Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned,
* sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there.
*/
data class SavedInfraredKey(
val id: InfraredKeyId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val sdkKey: String,
val status: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
interface InfraredKeyRepository {
fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>>
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
/**
* Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor
* error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
*/
suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
updatedAtEpochMillis: Long,
): DomainResult<Int>
}
interface InfraredDeviceConfigurationRepository {
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit>
}
@@ -0,0 +1,138 @@
package com.flagship.abox.manager.domain.infrared
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.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class LearnInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val factory: InfraredKeyFactory,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<InfraredKeyId> {
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
when (val result = repository.insert(key)) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
return when (
val result = gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
) {
is DomainResult.Success -> when (
val transition = repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> DomainResult.Success(key.id)
is DomainResult.Failure -> transition
}
is DomainResult.Failure -> {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RetryInfraredKeyLearning(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
)
}
when (
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.FAILED,
InfraredKeyStatus.READY,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
return when (
val result = gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
private fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,65 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class SendInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.NoData(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
)
}
return gateway.sendKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
}
}
class RenameInfraredKey(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
ref: InfraredKeyRef,
displayName: String,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isEmpty()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
)
}
return repository.rename(ref, trimmed, clock.now())
}
}
class DeleteInfraredKey(
private val repository: InfraredKeyRepository,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
repository.deleteIfNotLearning(ref)
}
class RecoverInterruptedInfraredLearning(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
repository.failInterruptedLearning(profileId, clock.now())
}
@@ -0,0 +1,232 @@
package com.flagship.abox.manager.domain.infrared
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
data class InfraredLearningCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredStatusTransitionCall(
val ref: InfraredKeyRef,
val expectedStatuses: Set<InfraredKeyStatus>,
val targetStatus: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val updatedAtEpochMillis: Long,
)
data class InfraredSendCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredRenameCall(
val ref: InfraredKeyRef,
val displayName: String,
val updatedAtEpochMillis: Long,
)
data class InfraredRecoveryCall(
val profileId: ProviderProfileId,
val updatedAtEpochMillis: Long,
)
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
var getError: DomainError? = null
var insertError: DomainError? = null
var renameError: DomainError? = null
var deleteError: DomainError? = null
var recoveryError: DomainError? = null
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
var onInsert: ((SavedInfraredKey) -> Unit)? = null
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
val insertedKeys = mutableListOf<SavedInfraredKey>()
val getCalls = mutableListOf<InfraredKeyRef>()
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
val renameCalls = mutableListOf<InfraredRenameCall>()
val deleteCalls = mutableListOf<InfraredKeyRef>()
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
DomainResult.Success(
values.filter { it.profileId == profileId && it.deviceName == deviceName },
)
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
getCalls += ref
getError?.let { return DomainResult.Failure(it) }
return DomainResult.Success(keys[ref])
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
insertedKeys += key
onInsert?.invoke(key)
insertError?.let { return DomainResult.Failure(it) }
keys[key.ref()] = key
publish()
return DomainResult.Success(Unit)
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val call = InfraredStatusTransitionCall(
ref = ref,
expectedStatuses = expectedStatuses,
targetStatus = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
transitionCalls += call
onTransition?.invoke(call)
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status !in expectedStatuses) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key status changed"),
)
}
keys[ref] = existing.copy(
status = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
renameError?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
keys[ref] = existing.copy(
displayName = displayName,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
deleteCalls += ref
deleteError?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
)
}
keys.remove(ref)
publish()
return DomainResult.Success(Unit)
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
recoveryError?.let { return DomainResult.Failure(it) }
var changed = 0
keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
changed += 1
key.copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} else {
key
}
}
publish()
return DomainResult.Success(changed)
}
fun seed(key: SavedInfraredKey) {
keys[key.ref()] = key
publish()
}
fun onlyKey(): SavedInfraredKey = keys.values.single()
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
private fun publish() {
state.value = keys.values.toList()
}
}
class RecordingInfraredGateway : InfraredGateway {
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
val learnedKeys = mutableListOf<InfraredLearningCall>()
val sentKeys = mutableListOf<InfraredSendCall>()
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
sentKeys += InfraredSendCall(profileId, deviceName, key)
return sendResult
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
}
fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)

Some files were not shown because too many files have changed in this diff Show More