diff --git a/.gitignore b/.gitignore
index 372b268..e37fdf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,12 @@ Desktop.ini
# Build artifacts / binaries / caches
+.gradle/
+gradle/gradle-daemon-jvm.properties
+local.properties
+captures/
+.externalNativeBuild/
+.cxx/
tmp/
dumps/
.builds/
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..9991fb1
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -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)
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..3c9a764
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Project-specific R8 rules belong here when release shrinking is enabled.
diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt
new file mode 100644
index 0000000..4c1e6c9
--- /dev/null
+++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt
@@ -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()
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f3b7d57
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt
new file mode 100644
index 0000000..e23073c
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt
@@ -0,0 +1,7 @@
+package com.flagship.abox.manager
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class ABoxManagerApplication : Application()
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt
new file mode 100644
index 0000000..77a62c1
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt
@@ -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()
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt
new file mode 100644
index 0000000..e31f830
--- /dev/null
+++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt
@@ -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,
+ )
+ }
+}
diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..70e8bd8
--- /dev/null
+++ b/android/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..bc378ad
--- /dev/null
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,8 @@
+
+ ABox Manager
+ 工程基础已就绪
+ 公共领域接口和本地开发提供方已经可用。
+ 继续
+ 功能模块可以开始开发
+ 设备、红外和情景页面将由对应模块负责人实现。
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..ec5c8d7
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+ ABox Manager
+ Project foundation ready
+ The shared domain contracts and local development providers are available.
+ Continue
+ Feature development can start
+ Device, infrared and scene screens will be added by their module owners.
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..402d238
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts
new file mode 100644
index 0000000..ed36165
--- /dev/null
+++ b/android/core/designsystem/build.gradle.kts
@@ -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)
+}
diff --git a/android/core/designsystem/src/main/AndroidManifest.xml b/android/core/designsystem/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..cc947c5
--- /dev/null
+++ b/android/core/designsystem/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt
new file mode 100644
index 0000000..b05599d
--- /dev/null
+++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt
@@ -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)
+}
diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt
new file mode 100644
index 0000000..5bc6176
--- /dev/null
+++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt
@@ -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),
+)
diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt
new file mode 100644
index 0000000..7d83a34
--- /dev/null
+++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt
@@ -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,
+ )
+}
diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt
new file mode 100644
index 0000000..0048478
--- /dev/null
+++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt
@@ -0,0 +1,5 @@
+package com.flagship.abox.manager.designsystem
+
+import androidx.compose.material3.Typography
+
+val ABoxTypography = Typography()
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..9b1f2d2
--- /dev/null
+++ b/build.gradle.kts
@@ -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
+}
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..67ca28b
--- /dev/null
+++ b/docs/architecture.md
@@ -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 文档已经确认的行为。设备目录的增删改与二维码导入、遥控器布局、情景动作和持久化模型将在对应模块提出后增量加入,不在公共层提前假定。
+
+## 5. 结果与错误边界
+
+所有端口返回 `DomainResult`:
+
+- `Success` 携带类型化领域值;
+- `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 调用不得进入正式架构。
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
new file mode 100644
index 0000000..989739b
--- /dev/null
+++ b/domain/build.gradle.kts
@@ -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)
+}
diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DomainResult.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DomainResult.kt
new file mode 100644
index 0000000..f4d11dd
--- /dev/null
+++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DomainResult.kt
@@ -0,0 +1,57 @@
+package com.flagship.abox.manager.domain
+
+sealed interface DomainResult {
+ data class Success(val value: T) : DomainResult
+
+ data class Failure(val error: DomainError) : DomainResult
+}
+
+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
+}
diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt
new file mode 100644
index 0000000..c388d62
--- /dev/null
+++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt
@@ -0,0 +1,61 @@
+package com.flagship.abox.manager.domain
+
+import kotlinx.coroutines.flow.Flow
+
+interface SessionGateway {
+ fun observeSession(profileId: ProviderProfileId): Flow
+
+ suspend fun login(
+ profileId: ProviderProfileId,
+ credentials: LoginCredentials,
+ ): DomainResult
+
+ suspend fun validateSession(profileId: ProviderProfileId): DomainResult
+
+ suspend fun logout(profileId: ProviderProfileId): DomainResult
+}
+
+interface DeviceCatalog {
+ fun observeDevices(
+ profileId: ProviderProfileId,
+ ): Flow>>
+}
+
+interface SocketGateway {
+ suspend fun getStatus(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ ): DomainResult
+
+ suspend fun setPower(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ powerState: SocketPowerState,
+ ): DomainResult
+}
+
+interface TemperatureHumidityGateway {
+ suspend fun getReading(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ ): DomainResult
+}
+
+interface InfraredGateway {
+ suspend fun sendKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult
+
+ suspend fun learnKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult
+
+ suspend fun downloadCodes(
+ profileId: ProviderProfileId,
+ codes: List,
+ ): DomainResult>
+}
diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt
new file mode 100644
index 0000000..3a6db52
--- /dev/null
+++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt
@@ -0,0 +1,86 @@
+package com.flagship.abox.manager.domain
+
+@JvmInline
+value class ProviderProfileId(val value: String)
+
+@JvmInline
+value class DeviceId(val value: String)
+
+@JvmInline
+value class DeviceName(val value: String)
+
+@JvmInline
+value class Username(val value: String)
+
+@JvmInline
+value class Password(val value: String)
+
+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 profileId: ProviderProfileId,
+ val deviceName: DeviceName,
+ val key: String,
+ val code: String,
+)
+
+data class InfraredCodeDownloadResult(
+ val infraredCode: InfraredCode,
+ val successful: Boolean,
+)
diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DomainResultTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DomainResultTest.kt
new file mode 100644
index 0000000..9f7db8f
--- /dev/null
+++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DomainResultTest.kt
@@ -0,0 +1,23 @@
+package com.flagship.abox.manager.domain
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DomainResultTest {
+ @Test
+ fun successContainsTypedValue() {
+ val result: DomainResult = DomainResult.Success(SocketPowerState.ON)
+
+ assertEquals(SocketPowerState.ON, (result as DomainResult.Success).value)
+ }
+
+ @Test
+ fun failureKeepsExternalCodeWithoutSdkTypes() {
+ val error = DomainError.DeviceOffline(externalCode = "20504")
+ val result: DomainResult = DomainResult.Failure(error)
+
+ assertTrue(result is DomainResult.Failure)
+ assertEquals("20504", (result as DomainResult.Failure).error.externalCode)
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..badfccb
--- /dev/null
+++ b/gradle.properties
@@ -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
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..d6f33fb
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..496c514
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..ef07e01
--- /dev/null
+++ b/gradlew
@@ -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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -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
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..88d4f61
--- /dev/null
+++ b/settings.gradle.kts
@@ -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")
diff --git a/testing/fakes/build.gradle.kts b/testing/fakes/build.gradle.kts
new file mode 100644
index 0000000..c2eed16
--- /dev/null
+++ b/testing/fakes/build.gradle.kts
@@ -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)
+}
diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceCatalog.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceCatalog.kt
new file mode 100644
index 0000000..e55602c
--- /dev/null
+++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceCatalog.kt
@@ -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>>,
+ >()
+
+ override fun observeDevices(
+ profileId: ProviderProfileId,
+ ): Flow>> = deviceFlow(profileId)
+
+ fun setDevices(profileId: ProviderProfileId, value: List) {
+ 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>> = devices.getOrPut(profileId) {
+ MutableStateFlow(DomainResult.Success(defaultDevices(profileId)))
+ }
+
+ private fun defaultDevices(profileId: ProviderProfileId): List = 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,
+ ),
+ )
+}
diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt
new file mode 100644
index 0000000..ae2622a
--- /dev/null
+++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt
@@ -0,0 +1,134 @@
+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()
+ private val errors = ConcurrentHashMap()
+
+ override suspend fun getStatus(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ ): DomainResult = resultFor(profileId, deviceName)
+
+ override suspend fun setPower(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ powerState: SocketPowerState,
+ ): DomainResult {
+ 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 {
+ 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>()
+
+ override suspend fun getReading(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ ): DomainResult = 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 = Collections.synchronizedList(mutableListOf())
+ val learnedKeys: MutableList = Collections.synchronizedList(mutableListOf())
+ val downloadedCodes: MutableList = Collections.synchronizedList(mutableListOf())
+
+ @Volatile
+ var sendResult: DomainResult = DomainResult.Success(Unit)
+
+ @Volatile
+ var learnResult: DomainResult = DomainResult.Success(Unit)
+
+ @Volatile
+ var downloadError: DomainError? = null
+
+ override suspend fun sendKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult {
+ sentKeys += InfraredCall(profileId, deviceName, key)
+ return sendResult
+ }
+
+ override suspend fun learnKey(
+ profileId: ProviderProfileId,
+ deviceName: DeviceName,
+ key: String,
+ ): DomainResult {
+ learnedKeys += InfraredCall(profileId, deviceName, key)
+ return learnResult
+ }
+
+ override suspend fun downloadCodes(
+ profileId: ProviderProfileId,
+ codes: List,
+ ): DomainResult> {
+ require(codes.all { it.profileId == profileId }) {
+ "All infrared codes must belong to the supplied provider profile"
+ }
+ downloadError?.let { return DomainResult.Failure(it) }
+ downloadedCodes += codes
+ return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
+ }
+}
+
+data class InfraredCall(
+ val profileId: ProviderProfileId,
+ val deviceName: DeviceName,
+ val key: String,
+)
+
+private data class DeviceKey(
+ val profileId: ProviderProfileId,
+ val deviceName: DeviceName,
+)
diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSessionGateway.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSessionGateway.kt
new file mode 100644
index 0000000..1ad0998
--- /dev/null
+++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSessionGateway.kt
@@ -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>()
+ private val loginResults = ConcurrentHashMap>()
+ private val validationResults = ConcurrentHashMap>()
+
+ override fun observeSession(profileId: ProviderProfileId): Flow = sessionFlow(profileId)
+
+ override suspend fun login(
+ profileId: ProviderProfileId,
+ credentials: LoginCredentials,
+ ): DomainResult {
+ 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 = validationResults[profileId]
+ ?: DomainResult.Success(
+ if (sessionFlow(profileId).value == null) {
+ SessionValidity.INVALID
+ } else {
+ SessionValidity.VALID
+ },
+ )
+
+ override suspend fun logout(profileId: ProviderProfileId): DomainResult {
+ sessionFlow(profileId).value = null
+ return DomainResult.Success(Unit)
+ }
+
+ fun setLoginResult(profileId: ProviderProfileId, result: DomainResult) {
+ loginResults[profileId] = result
+ }
+
+ fun setValidationResult(
+ profileId: ProviderProfileId,
+ result: DomainResult,
+ ) {
+ validationResults[profileId] = result
+ }
+
+ fun failLogin(
+ profileId: ProviderProfileId,
+ error: DomainError = DomainError.Authentication(),
+ ) {
+ setLoginResult(profileId, DomainResult.Failure(error))
+ }
+
+ private fun sessionFlow(profileId: ProviderProfileId): MutableStateFlow =
+ sessions.getOrPut(profileId) { MutableStateFlow(null) }
+}
diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceCatalogTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceCatalogTest.kt
new file mode 100644
index 0000000..255f6f5
--- /dev/null
+++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceCatalogTest.kt
@@ -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,
+ ),
+ ),
+ )
+ }
+}
diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt
new file mode 100644
index 0000000..70ff75d
--- /dev/null
+++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt
@@ -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(profileOne, 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(listOf(code), gateway.downloadedCodes)
+ assertTrue(download is DomainResult.Success)
+ }
+}
diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSessionGatewayTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSessionGatewayTest.kt
new file mode 100644
index 0000000..17ab0ac
--- /dev/null
+++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSessionGatewayTest.kt
@@ -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),
+ )
+ }
+}