Compare commits

...
8 Commits
40 changed files with 2274 additions and 0 deletions
+6
View File
@@ -54,6 +54,12 @@ Desktop.ini
# Build artifacts / binaries / caches # Build artifacts / binaries / caches
.gradle/
gradle/gradle-daemon-jvm.properties
local.properties
captures/
.externalNativeBuild/
.cxx/
tmp/ tmp/
dumps/ dumps/
.builds/ .builds/
+77
View File
@@ -0,0 +1,77 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager"
compileSdk = 34
defaultConfig {
applicationId = "com.flagship.abox.manager"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
kapt {
correctErrorTypes = true
}
dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
debugImplementation(project(":testing:fakes"))
implementation(platform(libs.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
}
+1
View File
@@ -0,0 +1 @@
# Project-specific R8 rules belong here when release shrinking is enabled.
@@ -0,0 +1,42 @@
package com.flagship.abox.manager.di
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.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.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FoundationModule {
@Provides
@Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
@Provides
@Singleton
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
@Provides
@Singleton
fun provideSocketGateway(): SocketGateway = FakeSocketGateway()
@Provides
@Singleton
fun provideTemperatureHumidityGateway(): TemperatureHumidityGateway =
FakeTemperatureHumidityGateway()
@Provides
@Singleton
fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway()
}
+17
View File
@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".ABoxManagerApplication"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ABoxManager">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,7 @@
package com.flagship.abox.manager
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class ABoxManagerApplication : Application()
@@ -0,0 +1,19 @@
package com.flagship.abox.manager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.flagship.abox.manager.ui.ABoxManagerApp
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ABoxManagerApp()
}
}
}
@@ -0,0 +1,94 @@
package com.flagship.abox.manager.ui
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.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.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder"
@Composable
fun ABoxManagerApp() {
ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = FOUNDATION_ROUTE,
) {
composable(FOUNDATION_ROUTE) {
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
}
composable(PLACEHOLDER_ROUTE) {
MessageScreen(
title = stringResource(R.string.placeholder_title),
description = stringResource(R.string.placeholder_description),
)
}
}
}
}
}
@Composable
private fun FoundationScreen(onContinue: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
MessageScreen(
title = stringResource(R.string.foundation_title),
description = stringResource(R.string.foundation_description),
)
ABoxButton(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
Text(stringResource(R.string.continue_action))
}
}
}
@Composable
private fun MessageScreen(
title: String,
description: String,
) {
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,8 @@
<resources>
<style name="Theme.ABoxManager" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">工程基础已就绪</string>
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string>
<string name="continue_action">继续</string>
<string name="placeholder_title">功能模块可以开始开发</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">Project foundation ready</string>
<string name="foundation_description">The shared domain contracts and local development providers are available.</string>
<string name="continue_action">Continue</string>
<string name="placeholder_title">Feature development can start</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.ABoxManager" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.designsystem"
compileSdk = 34
defaultConfig {
minSdk = 26
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun ABoxSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(modifier = modifier, content = content)
}
@Composable
fun ABoxButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
content = content,
)
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier)
}
@@ -0,0 +1,11 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val ABoxShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
)
@@ -0,0 +1,41 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF006780),
onPrimary = Color.White,
primaryContainer = Color(0xFFB8EAFF),
onPrimaryContainer = Color(0xFF001F29),
secondary = Color(0xFF4D616A),
surface = Color(0xFFF8FAFC),
background = Color(0xFFF8FAFC),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD5F8),
onPrimary = Color(0xFF003544),
primaryContainer = Color(0xFF004D61),
onPrimaryContainer = Color(0xFFB8EAFF),
secondary = Color(0xFFB4CBD4),
surface = Color(0xFF101416),
background = Color(0xFF101416),
)
@Composable
fun ABoxTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = ABoxTypography,
shapes = ABoxShapes,
content = content,
)
}
@@ -0,0 +1,5 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.material3.Typography
val ABoxTypography = Typography()
+8
View File
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt.android) apply false
}
+103
View File
@@ -0,0 +1,103 @@
# ABox Manager 架构基线
## 1. 文档范围
本文记录第一轮工程基础已经建立的模块边界、依赖方向和并行开发接口。ABSDK 0630 的初始化、协议、签名、连接重定向和会话清理等未确认内容不在本文中固化。
产品范围和固定分工以 `docs/project_plan.md` 为准,原始简要需求继续保留在 `docs/requirement_spec.md`
## 2. 当前模块
```text
abox-mgr/
├── android/
│ ├── app/ # Android 应用与依赖注入组合根
│ └── core/designsystem/ # Compose Material 3 设计系统基础
├── domain/ # 纯 Kotlin 领域模型和公共端口
├── testing/fakes/ # 确定性的公共 Fake
├── docs/
└── samples/ # 历史示例,仅供参考
```
依赖方向:
```text
android/app ────────────────┬──> android/core/designsystem
├──> domain
└──> testing/fakes ──> domain
android/core/designsystem ─────> Compose Material 3
domain ────────────────────────> Kotlin 与 Coroutines
```
`domain` 不依赖 Android、Compose、Room、ABSDK 或具体网络实现。业务 UI 只依赖领域端口和类型化结果。
## 3. 提供方与数据隔离
应用计划支持 ABox、自有兼容后端和本地体验三种提供方。每个连接配置由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。
约束:
- 任一时刻只启用一个提供方;
- 切换远端提供方前必须退出当前会话;
- 不同连接配置的数据、凭据和设备状态不得互相混用;
- 本地体验不调用 ABSDK,也不启动本地 HTTP 服务;
- Fake 使用相同标识隔离规则,便于在没有真实环境时验证边界。
## 4. 公共领域端口
`domain` 当前提供以下最小端口:
- `SessionGateway`:登录、观察会话、验证会话和退出;
- `DeviceCatalog`:按提供方观察设备目录;
- `SocketGateway`:查询和设置插座状态;
- `TemperatureHumidityGateway`:读取温度和湿度;
- `InfraredGateway`:发送、学习和下载红外码。
端口只覆盖需求和 ABSDK 0630 文档已经确认的行为。`DeviceId` 是 App 本地目录中的稳定记录标识;设备操作必须使用 ABSDK 0630 实际接受的 `DeviceName`。同一提供方配置和设备类型内,`DeviceName` 必须唯一,设备目录在添加、编辑和导入时负责强制该约束。设备目录的增删改与二维码导入、遥控器布局、情景动作和持久化模型将在对应模块提出后增量加入,不在公共层提前假定。
## 5. 结果与错误边界
所有端口返回 `DomainResult<T>`
- `Success<T>` 携带类型化领域值;
- `Failure` 携带 `DomainError`
- `DomainError.externalCode` 可以保留外部 SDK 错误码用于诊断;
- UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或服务端数据库模型。
真实 ABSDK 适配层必须在后续 `feat/absdk-bridge` 分支中完成同步阻塞调用的后台串行化、返回解析和错误映射。
## 6. Fake 边界
`testing/fakes` 提供会话、设备目录、插座、温湿度和红外 Fake。它们:
- 不使用网络、Android API、真实时间或随机数;
- 支持成功、失败、无数据、离线和 Token 无效场景;
- 对插座状态和设备目录按 `ProviderProfileId` 隔离;
- 记录红外学习、发送和下载调用;
- 可直接用于其他模块的 ViewModel 与 UI 测试。
Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。
## 7. Android 组合根
`android/app` 是单 Activity Compose 应用,负责:
- `ABoxManagerApplication` 与 Hilt 组合根;
- `MainActivity`
-`NavHost`
- 仅在 debug 构建中将领域端口绑定到当前开发阶段的 Fake,release 构建不包含 Fake
- 默认英文和简体中文资源;
- 使用 `android/core/designsystem` 的主题和基础组件。
设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。
## 8. 后续边界
后续 KremeCN 工作保持为独立短期分支:
- `feat/absdk-bridge`:真实 JAR 防腐层和协议验证;
- `feat/provider-login`:提供方入口、安全凭据、自动登录和重新登录;
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
+598
View File
@@ -0,0 +1,598 @@
# ABox Manager 项目计划
## 1. 文档目的
本文档是 ABox Manager 的项目执行蓝图,用于统一产品范围、技术方向、五人固定分工、模块依赖、实施阶段和验收原则。
本文档只记录已经确认的方向。尚未确认的 SDK 行为、协议细节和外部环境必须保留为待确认事项,不得将推测直接作为实现依据。
---
## 2. 项目现状
仓库目前主要包含:
- 产品需求概要;
- ABSDK 0630 接口文档;
- AsyncTask 登录示例;
- 清理归档后的 ABSDK Android Sample
- Git 和 AI 工具协作规则。
当前还没有正式的 Android 应用工程、自有后端、自动化测试或 CI Runner。
重要参考资料:
```text
docs/requirement_spec.md
docs/ABSDK接口部分_0630.md
docs/examples/
samples/abox-sdk-sample/
```
---
## 3. 产品目标
ABox Manager 是面向 Android 手机和平板的智能家居管理应用,首版覆盖以下核心能力:
1. 登录和会话管理;
2. 设备列表与页面导航;
3. 插座状态查询和开关控制;
4. 温湿度查询和展示;
5. 红外遥控器创建、学习和发送;
6. 基于插座和红外动作的情景模式;
7. 数据本地持久化;
8. 简体中文和英文界面;
9. 手机和平板响应式界面。
---
## 4. MVP 范围
### 4.1 登录
- 支持用户名和密码登录;
- 支持记住用户名;
- 支持按连接配置独立开启自动登录;
- 自动登录所需密码必须通过 Android Keystore 安全保存;
- ABSDK Token 由 SDK 自身管理;进程重启或 Token 失效后,通过保存的凭据重新登录;
- 退出登录时保留非敏感连接信息和用户名,清除密码与会话;
- 切换后端提供方前必须退出当前会话。
### 4.2 提供方模式
应用计划兼容三种相互独立的模式:
1. **ABox 后端**:使用现有 ABSDK 0630 连接 ABox 服务;
2. **自有兼容后端**:连接自行实现的 ABSDK 0630 协议兼容服务;
3. **本地体验**:不连接远端服务,通过本地模拟数据体验完整应用流程。
约束:
- 任一时刻只使用一个提供方;
- 不要求同时连接两个后端;
- ABox 与自有后端的账号、会话和数据不互相绑定;
- 每个连接配置拥有独立的设备、遥控器、情景和凭据空间;
- 本地体验不启动 HTTP 服务,也不调用 ABSDK。
### 4.3 设备目录
ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录采用:
- 二维码批量导入;
- 用户手工添加、编辑和删除;
- 设备名称作为调用 ABSDK 的设备标识;
- 设备类型首版只包含插座、温湿度和红外。
不得假设存在尚未确认的新版 SDK 或设备发现接口。
### 4.4 插座
- 查询当前状态;
- 显示打开、关闭、加载、错误和离线状态;
- 控制打开和关闭;
- 控制完成后确认实际状态;
- 防止重复操作;
- 记录操作结果。
### 4.5 温湿度
- 查询温度;
- 查询湿度;
- 分别展示温度和湿度;
- 支持刷新;
- 显示加载、无数据、错误和离线状态;
- 温湿度设备是只读设备,不提供控制动作。
### 4.6 红外遥控器
- 创建、编辑和删除遥控器;
- 设置遥控器名称和视觉标识;
- 添加、重命名、排序和删除按键;
- 学习红外按键;
- 发送红外按键;
- 下载红外码;
- 持久化遥控器布局和按键;
- 为情景模式提供稳定的红外按键动作引用。
### 4.7 情景模式
- 创建、编辑和删除情景;
- 设置情景名称、图标和颜色;
- 添加插座开关动作;
- 添加红外按键动作;
- 调整动作顺序;
- 按顺序执行动作;
- 单个动作失败后继续执行后续动作;
- 最后逐项汇总执行结果;
- 防止同一情景被重复触发;
- 按提供方隔离情景数据。
首版不实现:
- 定时触发;
- 条件判断;
- 温湿度或其他传感器自动触发;
- 复杂规则引擎。
---
## 5. 技术方向
### 5.1 Android
正式 Android 应用采用:
- Kotlin
- Jetpack Compose
- Material 3
- 单 Activity
- Navigation Compose
- ViewModel
- StateFlow
- Coroutines
- Hilt
- Room
- DataStore
- Android Keystore。
目标:
- applicationId 为 `com.flagship.abox.manager`
- 最低 Android 版本暂按 API 26
- 支持手机和平板;
- 支持深色和浅色主题;
- 支持简体中文和英文;
- 其他系统语言回退到英文;
- 满足基本无障碍要求;
- 建立独立的 ABox 视觉设计系统。
### 5.2 ABSDK 接入原则
- 正式业务代码不得直接散落调用 `ABSDK.getInstance()`
- 必须通过统一适配层调用 SDK
- SDK 同步阻塞调用必须在后台线程执行;
- `ABRet`、原始 `Map` 和字符串错误码不得泄漏到 UI 层;
- 适配层将 SDK 返回转换为类型化领域结果;
- SDK 的 Host、Token 和线程状态按全局共享状态处理;
- 切换提供方前必须结束当前会话;
- `docs/examples/``samples/abox-sdk-sample/` 只作为参考,不直接作为正式架构。
### 5.3 自有兼容后端
自有后端目标是兼容 ABSDK 0630 的必要服务端行为。
暂定方向:
- Kotlin 和 Ktor
- 旧协议只存在于兼容适配边界;
- 内部使用类型化领域模型;
- 密码使用强哈希保存;
- 酒店与房间作为数据隔离边界;
- 管理操作通过内置 CLI 完成;
- 提供模拟设备驱动;
- 为未来真实设备驱动预留统一接口;
- SQLite 用于轻量部署;
- PostgreSQL 用于较大规模部署;
- Docker 作为标准交付方式。
具体协议、签名和连接方式必须先验证,不在本文档中提前固化。
---
## 6. 固定五人分工
项目总体分工固定为以下五个模块。后续新增工作必须归入这五个模块,不再以协议、服务端或基础架构为理由重划总体职责。
| 成员 | 固定模块 |
|---|---|
| KremeCN | 工程 SDK 登录 |
| alZerNest | 设备列表导航 |
| Aurum | 插座温湿度 |
| Moler | 红外遥控器 |
| tzh | 情景模式集成 |
### 6.1 KremeCN:工程 SDK 登录
负责项目公共基础、SDK 以及登录体系。
主要工作:
- 创建 Android 工程和公共构建配置;
- 建立 Compose、Navigation、Hilt、Room 和 DataStore 基础;
- 建立公共设计系统基础;
- 接入和封装 ABSDK 0630
- 建立统一领域接口、错误模型和 Fake 实现;
- 实现 ABox、自有兼容后端和本地体验三个入口;
- 实现记住用户名、自动登录、Keystore 凭据保存和退出;
- 处理 Token 失效与重新登录;
- 研究 SDK 已确认行为和协议边界;
- 建立自有兼容后端基础、测试宿主、CLI 和 Docker 基础。
主要交付:
```text
工程基础
SDK 适配层
登录与提供方选择
公共领域接口与 Fake
兼容后端基础
```
### 6.2 alZerNest:设备列表导航
负责登录后的主界面、设备目录和页面导航。
主要工作:
- 设备模型和三种设备类型;
- 设备列表;
- 二维码导入设备目录;
- 手工添加、编辑和删除设备;
- 按提供方隔离设备数据;
- 手机单栏导航;
- 平板列表详情双栏布局;
- 点击设备进入正确详情页;
- 空列表、未知设备和离线状态;
- Room 设备目录持久化。
主要交付:
```text
设备目录
设备列表
主导航
手机和平板导航
```
### 6.3 Aurum:插座温湿度
负责插座和温湿度的完整功能。
主要工作:
- 插座状态查询和开关控制;
- 控制后的状态确认;
- 插座加载、错误、超时和离线状态;
- 温度和湿度查询、格式化和刷新;
- 温湿度加载、错误、无数据和离线状态;
- 操作日志接入;
- 对应 ViewModel、单元测试和 UI 测试。
主要交付:
```text
插座控制页面
温湿度页面
相关状态管理和测试
```
### 6.4 Moler:红外遥控器
负责全部红外相关能力。
主要工作:
- 遥控器列表;
- 遥控器创建、编辑和删除;
- 按键添加、重命名、排序和删除;
- 红外按键学习;
- 红外发送;
- 红外码下载;
- 红外失败和离线状态;
- Room 持久化;
- 为情景模式提供稳定的红外动作引用。
主要交付:
```text
红外遥控器
红外学习和发送
红外本地持久化
```
### 6.5 tzh:情景模式集成
负责情景模式和最终体验整合。
主要工作:
- 情景列表;
- 情景创建、编辑和删除;
- 插座动作和红外动作选择;
- 动作排序;
- 顺序执行和失败后继续;
- 执行结果逐项汇总;
- 情景 Room 持久化;
- 中英文资源整合;
- 空状态、错误状态和加载状态统一;
- 深浅色、无障碍和最终集成测试;
- 用户使用说明。
主要交付:
```text
情景编辑器
情景执行器
最终体验整合和验收
```
---
## 7. 分工依赖关系
```text
KremeCN 工程 SDK 登录
├── alZerNest 设备列表导航
├── Aurum 插座温湿度
└── Moler 红外遥控器
└────────────┐
Aurum 插座温湿度 ───────┤
tzh 情景模式集成
```
开发阶段必须通过 Fake 接口解除等待:
- KremeCN 优先提供领域接口和 Fake;
- alZerNest、Aurum、Moler、tzh 使用 Fake 并行开发;
- 真实 SDK 接口准备好后,各模块只替换数据源,不重写 UI;
- tzh 不等待全部硬件联调完成才开始情景页面。
---
## 8. 执行阶段
### 阶段 0:需求和接口基线
- 扩充产品需求;
- 区分已确认事实与待确认事项;
- 定义最小公共领域接口;
- 提供 Fake 登录、设备目录和设备操作;
- 五人共同评审接口。
退出条件:
- 五个模块都能在 Fake 上开始开发;
- 不确定的 SDK 协议没有被写死;
- 各模块数据所有权明确。
### 阶段 1:工程和 UI 骨架
KremeCN
- 建立工程、设计系统、SDK 适配和登录骨架。
alZerNest
- 建立设备列表和导航。
Aurum
- 建立插座和温湿度页面状态。
Moler
- 建立红外遥控器页面和数据结构。
tzh
- 建立情景列表、编辑器和 Fake 动作执行。
退出条件:
- Android App 可以使用 Fake 完成主要页面导航;
- 服务端骨架可以独立启动;
- 所有人拥有可独立测试的模块。
### 阶段 2:完整功能实现
- 接入 Room、DataStore 和 Keystore
- 完成登录和提供方隔离;
- 完成设备目录导入和维护;
- 完成插座、温湿度和红外;
- 完成情景模式;
- 完成自有兼容后端的已确认协议范围。
退出条件:
- 本地体验模式完整可用;
- 所有模块在 Fake 或模拟后端下通过测试;
- 提供方切换不会混用数据和凭据。
### 阶段 3:真实集成
- 连接真实 ABox 环境;
- 连接自有兼容后端;
- 使用真实插座、温湿度和红外设备;
- 验证 Token 失效、设备离线和错误码;
- 验证手机和平板布局。
退出条件:
- 三类真实硬件核心流程通过;
- ABox 与自有后端连接行为明确;
- 已知限制完整记录。
### 阶段 4:发布验收
- 中英文检查;
- 深浅色检查;
- 无障碍检查;
- 安全检查;
- 自动化测试和手工验收;
- 安装和部署文档;
- 发布包和回滚方案。
---
## 9. 分支与 PR 规则
总体模块固定,但具体工作应使用短期功能分支,不应为每个人保留一个持续到项目结束的巨大分支。
建议分支示例:
### KremeCN
```text
feat/project-foundation
feat/absdk-bridge
feat/provider-login
feat/compatible-server
```
### alZerNest
```text
feat/device-catalog
feat/device-navigation
feat/device-import
```
### Aurum
```text
feat/socket-control
feat/temperature-display
```
### Moler
```text
feat/infrared-remote
feat/infrared-learning
```
### tzh
```text
feat/scene-editor
feat/scene-execution
feat/app-integration
```
所有工作遵循 `AGENTS.md`
- 从最新 `main` 创建工作分支;
- 修改和提交保留在工作分支;
- 完成后创建目标为 `main` 的 Pull Request
- 不绕过分支保护、评审和必要检查。
---
## 10. 公共文件所有权
| 公共内容 | 主要维护者 |
|---|---|
| 根构建配置 | KremeCN |
| 公共领域接口 | KremeCN,其他成员共同评审 |
| 设计系统基础 | KremeCN |
| 主导航与设备导航 | alZerNest |
| 设备模型 | alZerNest 提出,KremeCN 合入公共层 |
| 插座与温湿度命令 | Aurum 提出,KremeCN 合入公共层 |
| 红外命令与按键引用 | Moler 提出,KremeCN 合入公共层 |
| 情景动作模型 | tzh 提出,KremeCN 合入公共层 |
| 中英文公共文案检查 | tzh |
公共接口变更必须:
1. 单独说明影响范围;
2. 更新对应 Fake
3. 由受影响模块负责人评审;
4. 合并后再由功能分支更新基线。
---
## 11. 测试与验收
### 11.1 自动测试
- 领域模型和执行逻辑单元测试;
- SDK 返回和错误映射测试;
- ViewModel 状态测试;
- Room 持久化测试;
- Compose 核心页面测试;
- 情景顺序执行和失败继续测试;
- 自有兼容后端认证、存储和模拟驱动测试;
- Docker 和 CLI 验证。
### 11.2 手工验收
- 登录、自动登录和退出;
- 三种提供方入口;
- 数据和凭据隔离;
- 设备目录导入和维护;
- 插座状态和控制;
- 温湿度读取;
- 红外学习和发送;
- 情景创建和执行;
- Token 失效与设备离线;
- 手机和平板;
- 中文和英文;
- 深色和浅色。
### 11.3 CI
当前 Gitea 暂无 Runner。
在 Runner 就绪前:
- 提供统一的本地验证命令;
- 每个 PR 附带实际测试结果;
- 不得以未来 CI 会运行作为跳过验证的理由。
Runner 就绪后,再将相同命令配置为受保护 `main` 的必要检查。
---
## 12. 待确认事项
以下内容不能提前假定:
- ABSDK 0630 的完整初始化流程;
- `setHostInfo` 的正式业务含义;
- ABox 官方部署所需参数;
- 自有后端与原始 JAR 的最终连接方式;
- SDK 的完整签名算法和参数规则;
- SDK 线程安全与会话清理行为;
- 真实测试账号、设备名称和硬件环境;
- 自有后端连接真实设备所使用的网关协议;
- ABSDK 二进制的生产分发许可。
上述事项必须通过厂商资料、授权分析、实验或真实环境验证后,再更新本文档或对应技术文档。
---
## 13. 当前下一步
本计划落盘后,不立即开始全部功能开发。
正式开工前建议依次完成:
1. 五人确认固定分工;
2. 将 MVP 需求补充到 `docs/requirement_spec.md`
3. 建立任务看板;
4. 明确第一轮短期分支和 PR
5. 由 KremeCN 先提供最小公共接口和 Fake;
6. 五人进入第一轮并行开发。
+13
View File
@@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(21)
}
dependencies {
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.domain
sealed interface DomainResult<out T> {
data class Success<T>(val value: T) : DomainResult<T>
data class Failure(val error: DomainError) : DomainResult<Nothing>
}
sealed interface DomainError {
val externalCode: String?
val detail: String?
data class Authentication(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class TokenInvalid(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class Network(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class DeviceNotFound(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class DeviceOffline(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class InvalidRequest(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class OperationFailed(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class NoData(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class Unknown(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
}
@@ -0,0 +1,62 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.Flow
interface SessionGateway {
fun observeSession(profileId: ProviderProfileId): Flow<Session?>
suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session>
suspend fun validateSession(profileId: ProviderProfileId): DomainResult<SessionValidity>
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit>
}
interface DeviceCatalog {
fun observeDevices(
profileId: ProviderProfileId,
): Flow<DomainResult<List<DeviceDescriptor>>>
}
/** Operates vendor devices by [DeviceName], the identifier accepted by ABSDK 0630. */
interface SocketGateway {
suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus>
suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus>
}
interface TemperatureHumidityGateway {
suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading>
}
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>>
}
@@ -0,0 +1,93 @@
package com.flagship.abox.manager.domain
@JvmInline
value class ProviderProfileId(val value: String)
@JvmInline
value class DeviceId(val value: String)
/**
* Vendor-facing device identifier.
*
* Device names must be unique within the same provider profile and device type because
* ABSDK 0630 addresses devices by name rather than by the app's local [DeviceId].
*/
@JvmInline
value class DeviceName(val value: String)
@JvmInline
value class Username(val value: String)
@JvmInline
value class Password(val value: String) {
override fun toString(): String = "Password(***)"
}
enum class ProviderType {
ABOX,
COMPATIBLE_BACKEND,
LOCAL_EXPERIENCE,
}
data class ProviderProfile(
val id: ProviderProfileId,
val type: ProviderType,
val displayName: String,
)
enum class DeviceType {
SOCKET,
TEMPERATURE_HUMIDITY,
INFRARED,
}
data class DeviceDescriptor(
val id: DeviceId,
val profileId: ProviderProfileId,
val name: DeviceName,
val type: DeviceType,
)
data class LoginCredentials(
val username: Username,
val password: Password,
)
data class Session(
val profileId: ProviderProfileId,
val username: Username,
)
enum class SessionValidity {
VALID,
INVALID,
}
enum class SocketPowerState {
OFF,
ON,
}
data class SocketStatus(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val powerState: SocketPowerState,
)
data class TemperatureHumidityReading(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val temperature: String,
val humidity: String,
)
data class InfraredCode(
val deviceName: DeviceName,
val key: String,
val code: String,
)
data class InfraredCodeDownloadResult(
val infraredCode: InfraredCode,
val successful: Boolean,
)
@@ -0,0 +1,35 @@
package com.flagship.abox.manager.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DomainResultTest {
@Test
fun successContainsTypedValue() {
val result: DomainResult<SocketPowerState> = DomainResult.Success(SocketPowerState.ON)
assertEquals(SocketPowerState.ON, (result as DomainResult.Success).value)
}
@Test
fun failureKeepsExternalCodeWithoutSdkTypes() {
val error = DomainError.DeviceOffline(externalCode = "20504")
val result: DomainResult<Unit> = DomainResult.Failure(error)
assertTrue(result is DomainResult.Failure)
assertEquals("20504", (result as DomainResult.Failure).error.externalCode)
}
@Test
fun credentialStringsDoNotExposePassword() {
val plaintext = "secret-password"
val password = Password(plaintext)
val credentials = LoginCredentials(Username("demo"), password)
assertFalse(password.toString().contains(plaintext))
assertFalse(credentials.toString().contains(plaintext))
assertTrue(credentials.toString().contains("Password(***)"))
}
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=false
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+46
View File
@@ -0,0 +1,46 @@
[versions]
agp = "8.7.3"
kotlin = "1.9.24"
composeBom = "2024.06.00"
composeCompiler = "1.5.14"
coroutines = "1.8.1"
hilt = "2.51.1"
navigationCompose = "2.7.7"
activityCompose = "1.9.0"
lifecycle = "2.8.2"
room = "2.6.1"
datastore = "1.1.1"
junit = "4.13.2"
androidxJunit = "1.2.1"
espresso = "3.6.1"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=false
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+28
View File
@@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "abox-manager"
include(":domain")
include(":testing:fakes")
include(":android:core:designsystem")
include(":android:app")
+14
View File
@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(21)
}
dependencies {
api(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,62 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ConcurrentHashMap
class FakeDeviceCatalog : DeviceCatalog {
private val devices = ConcurrentHashMap<
ProviderProfileId,
MutableStateFlow<DomainResult<List<DeviceDescriptor>>>,
>()
override fun observeDevices(
profileId: ProviderProfileId,
): Flow<DomainResult<List<DeviceDescriptor>>> = deviceFlow(profileId)
fun setDevices(profileId: ProviderProfileId, value: List<DeviceDescriptor>) {
require(value.all { it.profileId == profileId }) {
"All devices must belong to the supplied provider profile"
}
deviceFlow(profileId).value = DomainResult.Success(value)
}
fun setError(profileId: ProviderProfileId, error: DomainError) {
deviceFlow(profileId).value = DomainResult.Failure(error)
}
private fun deviceFlow(
profileId: ProviderProfileId,
): MutableStateFlow<DomainResult<List<DeviceDescriptor>>> = devices.getOrPut(profileId) {
MutableStateFlow(DomainResult.Success(defaultDevices(profileId)))
}
private fun defaultDevices(profileId: ProviderProfileId): List<DeviceDescriptor> = listOf(
DeviceDescriptor(
id = DeviceId("${profileId.value}:socket-1"),
profileId = profileId,
name = DeviceName("socket-1"),
type = DeviceType.SOCKET,
),
DeviceDescriptor(
id = DeviceId("${profileId.value}:th-1"),
profileId = profileId,
name = DeviceName("th-1"),
type = DeviceType.TEMPERATURE_HUMIDITY,
),
DeviceDescriptor(
id = DeviceId("${profileId.value}:ir-1"),
profileId = profileId,
name = DeviceName("ir-1"),
type = DeviceType.INFRARED,
),
)
}
@@ -0,0 +1,137 @@
package com.flagship.abox.manager.testing.fakes
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.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 java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = resultFor(profileId, deviceName)
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
val key = DeviceKey(profileId, deviceName)
errors[key]?.let { return DomainResult.Failure(it) }
states[key] = powerState
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
val key = DeviceKey(profileId, deviceName)
if (error == null) errors.remove(key) else errors[key] = error
}
private fun resultFor(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> {
val key = DeviceKey(profileId, deviceName)
errors[key]?.let { return DomainResult.Failure(it) }
val state = states.getOrPut(key) { SocketPowerState.OFF }
return DomainResult.Success(SocketStatus(profileId, deviceName, state))
}
}
class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
private val readings = ConcurrentHashMap<DeviceKey, DomainResult<TemperatureHumidityReading>>()
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> = readings.getOrPut(DeviceKey(profileId, deviceName)) {
DomainResult.Success(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "23.5",
humidity = "45",
),
)
}
fun setReading(reading: TemperatureHumidityReading) {
readings[DeviceKey(reading.profileId, reading.deviceName)] = DomainResult.Success(reading)
}
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError) {
readings[DeviceKey(profileId, deviceName)] = DomainResult.Failure(error)
}
}
class FakeInfraredGateway : InfraredGateway {
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val downloadCalls: MutableList<InfraredDownloadCall> =
Collections.synchronizedList(mutableListOf())
@Volatile
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
@Volatile
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
@Volatile
var downloadError: DomainError? = null
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
sentKeys += InfraredCall(profileId, deviceName, key)
return sendResult
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredCall(profileId, deviceName, key)
return learnResult
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
downloadError?.let { return DomainResult.Failure(it) }
downloadCalls += InfraredDownloadCall(profileId, codes)
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
data class InfraredCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredDownloadCall(
val profileId: ProviderProfileId,
val codes: List<InfraredCode>,
)
private data class DeviceKey(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
)
@@ -0,0 +1,70 @@
package com.flagship.abox.manager.testing.fakes
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.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ConcurrentHashMap
class FakeSessionGateway : SessionGateway {
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
private val loginResults = ConcurrentHashMap<ProviderProfileId, DomainResult<Session>>()
private val validationResults = ConcurrentHashMap<ProviderProfileId, DomainResult<SessionValidity>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = sessionFlow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> {
val result = loginResults[profileId] ?: DomainResult.Success(
Session(profileId = profileId, username = credentials.username),
)
if (result is DomainResult.Success) {
sessionFlow(profileId).value = result.value
}
return result
}
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = validationResults[profileId]
?: DomainResult.Success(
if (sessionFlow(profileId).value == null) {
SessionValidity.INVALID
} else {
SessionValidity.VALID
},
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
sessionFlow(profileId).value = null
return DomainResult.Success(Unit)
}
fun setLoginResult(profileId: ProviderProfileId, result: DomainResult<Session>) {
loginResults[profileId] = result
}
fun setValidationResult(
profileId: ProviderProfileId,
result: DomainResult<SessionValidity>,
) {
validationResults[profileId] = result
}
fun failLogin(
profileId: ProviderProfileId,
error: DomainError = DomainError.Authentication(),
) {
setLoginResult(profileId, DomainResult.Failure(error))
}
private fun sessionFlow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
sessions.getOrPut(profileId) { MutableStateFlow(null) }
}
@@ -0,0 +1,47 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class FakeDeviceCatalogTest {
@Test
fun defaultDevicesAreIsolatedByProviderProfile() = runTest {
val gateway = FakeDeviceCatalog()
val aboxProfile = ProviderProfileId("abox")
val localProfile = ProviderProfileId("local")
val aboxDevices = (gateway.observeDevices(aboxProfile).first() as DomainResult.Success).value
val localDevices = (gateway.observeDevices(localProfile).first() as DomainResult.Success).value
assertEquals(3, aboxDevices.size)
assertEquals(3, localDevices.size)
assertNotEquals(aboxDevices.first().id, localDevices.first().id)
assertEquals(aboxProfile, aboxDevices.first().profileId)
assertEquals(localProfile, localDevices.first().profileId)
}
@Test(expected = IllegalArgumentException::class)
fun setDevicesRejectsAnotherProvidersData() {
val gateway = FakeDeviceCatalog()
gateway.setDevices(
ProviderProfileId("one"),
listOf(
DeviceDescriptor(
id = DeviceId("two:socket"),
profileId = ProviderProfileId("two"),
name = DeviceName("socket"),
type = DeviceType.SOCKET,
),
),
)
}
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.testing.fakes
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.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeDeviceGatewaysTest {
private val profileOne = ProviderProfileId("one")
private val profileTwo = ProviderProfileId("two")
private val socket = DeviceName("socket-1")
@Test
fun socketStateChangesWithoutCrossingProviderBoundary() = runTest {
val gateway = FakeSocketGateway()
gateway.setPower(profileOne, socket, SocketPowerState.ON)
val one = gateway.getStatus(profileOne, socket) as DomainResult.Success
val two = gateway.getStatus(profileTwo, socket) as DomainResult.Success
assertEquals(SocketPowerState.ON, one.value.powerState)
assertEquals(SocketPowerState.OFF, two.value.powerState)
}
@Test
fun temperatureGatewaySupportsNoDataAndErrors() = runTest {
val gateway = FakeTemperatureHumidityGateway()
gateway.setError(profileOne, DeviceName("empty"), DomainError.NoData())
gateway.setError(profileOne, DeviceName("offline"), DomainError.DeviceOffline())
assertTrue(gateway.getReading(profileOne, DeviceName("empty")) is DomainResult.Failure)
assertTrue(gateway.getReading(profileOne, DeviceName("offline")) is DomainResult.Failure)
assertTrue(gateway.getReading(profileOne, DeviceName("th-1")) is DomainResult.Success)
}
@Test
fun infraredGatewayRecordsCallsAndResults() = runTest {
val gateway = FakeInfraredGateway()
val deviceName = DeviceName("ir-1")
val code = InfraredCode(deviceName, "power", "code-value")
gateway.sendKey(profileOne, deviceName, "power")
gateway.learnKey(profileOne, deviceName, "volume-up")
val download = gateway.downloadCodes(profileOne, listOf(code))
assertEquals(InfraredCall(profileOne, deviceName, "power"), gateway.sentKeys.single())
assertEquals("volume-up", gateway.learnedKeys.single().key)
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
assertTrue(download is DomainResult.Success)
}
}
@@ -0,0 +1,58 @@
package com.flagship.abox.manager.testing.fakes
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.SessionValidity
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeSessionGatewayTest {
private val profileId = ProviderProfileId("abox")
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun loginPublishesSessionAndLogoutClearsIt() = runTest {
val gateway = FakeSessionGateway()
val result = gateway.login(profileId, credentials)
assertTrue(result is DomainResult.Success)
assertEquals(Username("demo"), gateway.observeSession(profileId).first()?.username)
gateway.logout(profileId)
assertNull(gateway.observeSession(profileId).first())
}
@Test
fun failedLoginDoesNotCreateSession() = runTest {
val gateway = FakeSessionGateway().apply {
failLogin(profileId, DomainError.Authentication(externalCode = "20004"))
}
val result = gateway.login(profileId, credentials)
assertTrue(result is DomainResult.Failure)
assertNull(gateway.observeSession(profileId).first())
}
@Test
fun validationCanSimulateInvalidToken() = runTest {
val gateway = FakeSessionGateway().apply {
setValidationResult(profileId, DomainResult.Success(SessionValidity.INVALID))
}
assertEquals(
DomainResult.Success(SessionValidity.INVALID),
gateway.validateSession(profileId),
)
}
}