feat: establish Android project foundation

This commit is contained in:
KremeCN
2026-08-22 22:06:50 +08:00
parent e651f008e0
commit 117f87f735
39 changed files with 1653 additions and 0 deletions
+6
View File
@@ -54,6 +54,12 @@ Desktop.ini
# Build artifacts / binaries / caches
.gradle/
gradle/gradle-daemon-jvm.properties
local.properties
captures/
.externalNativeBuild/
.cxx/
tmp/
dumps/
.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 文档已经确认的行为。设备目录的增删改与二维码导入、遥控器布局、情景动作和持久化模型将在对应模块提出后增量加入,不在公共层提前假定。
## 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 调用不得进入正式架构。
+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,61 @@
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>>>
}
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,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,
)
@@ -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<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)
}
}
+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,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<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 downloadedCodes: MutableList<InfraredCode> = 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>> {
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,
)
@@ -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(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)
}
}
@@ -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),
)
}
}