Compare commits

..
13 Commits
150 changed files with 5441 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# AI Coding Tools — workspace / state / cache
.claude
.cursor
.trae/tasks
.trae/summary
.trae/documents
.windsurf
.cline/
.aider*
.codebuddy
.qoder
.serena
.sisyphus
.factory
.ace-tool/
.omo/
.omc/
.omx/
.chunkhound/
.kiro
.agent/summary
*.agent/summary
openspec
# IDEs & Editors
.idea/
*.iml
*.ipr
*.iws
/out/
*target*
.idea_modules/
atlassian-ide-plugin.xml
.vscode
*.code-workspace
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.classpath
.project
.settings/
# OS-specific junk
.DS_Store
Thumbs.db
Desktop.ini
.fuse_hidden*
.directory
# Build artifacts / binaries / caches
.gradle/
gradle/gradle-daemon-jvm.properties
local.properties
captures/
.externalNativeBuild/
.cxx/
tmp/
dumps/
.builds/
*.out
*.test
*.prof
*.bin
dist/
build/
.gomodcache
.gocache
*.sum
logs/
*.log
.env
.env.*
!.env.example
# Databases (local dev)
*.sqlite
*.sqlite3
*.db
*-journal
*.db-shm
*.db-wal
*.db3
# CI / Lint / Coverage artifacts
coverage.out
coverage.txt
coverage.html
test-results/
*.trx
# Misc / project-agnostic
*.csv
*.b64
*.backup
*.bak
crashlytics.properties
crashlytics-build.properties
com_crashlytics_export_strings.xml
+1
View File
@@ -0,0 +1 @@
@AGENTS.md Please read the project root AGENTS.md file for complete project rules.
+19
View File
@@ -0,0 +1,19 @@
# 仓库规则 / Repository Rules
## 受保护的 main 分支工作流
- `main` 是受保护分支,不得用作工作分支。
- Maintainers、其他贡献者以及 AI 或自动化工具不得直接在 `main` 上开发或推送提交。
- 开始工作前,先更新本地 `main` 分支,再基于它创建专用工作分支。
- 与任务相关的所有修改和提交都必须保留在该工作分支上。
- 工作完成后,必须创建以 `main` 为目标分支的 Pull Request。
- 只能通过 Pull Request 工作流合并修改,并遵守所有已配置的分支保护、代码审查和必要检查规则;不得绕过这些保护措施。
## Protected Main Branch Workflow
- `main` is a protected branch and must not be used as a working branch.
- Maintainers, other contributors, and AI or automation tools must not develop or push commits directly on `main`.
- Before starting work, update the local `main` branch and create a dedicated working branch from it.
- Keep all changes and commits for the task on that working branch.
- When the work is complete, create a pull request targeting `main`.
- Merge changes only through the pull request workflow and comply with all configured branch protection, review, and required-check rules. Do not bypass these protections.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md Please read the project root AGENTS.md file for complete project rules.
+77
View File
@@ -0,0 +1,77 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager"
compileSdk = 34
defaultConfig {
applicationId = "com.flagship.abox.manager"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
kapt {
correctErrorTypes = true
}
dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
debugImplementation(project(":testing:fakes"))
implementation(platform(libs.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
}
+1
View File
@@ -0,0 +1 @@
# Project-specific R8 rules belong here when release shrinking is enabled.
@@ -0,0 +1,42 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FoundationModule {
@Provides
@Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
@Provides
@Singleton
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
@Provides
@Singleton
fun provideSocketGateway(): SocketGateway = FakeSocketGateway()
@Provides
@Singleton
fun provideTemperatureHumidityGateway(): TemperatureHumidityGateway =
FakeTemperatureHumidityGateway()
@Provides
@Singleton
fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway()
}
+17
View File
@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".ABoxManagerApplication"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ABoxManager">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,7 @@
package com.flagship.abox.manager
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class ABoxManagerApplication : Application()
@@ -0,0 +1,19 @@
package com.flagship.abox.manager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.flagship.abox.manager.ui.ABoxManagerApp
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ABoxManagerApp()
}
}
}
@@ -0,0 +1,94 @@
package com.flagship.abox.manager.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder"
@Composable
fun ABoxManagerApp() {
ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = FOUNDATION_ROUTE,
) {
composable(FOUNDATION_ROUTE) {
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
}
composable(PLACEHOLDER_ROUTE) {
MessageScreen(
title = stringResource(R.string.placeholder_title),
description = stringResource(R.string.placeholder_description),
)
}
}
}
}
}
@Composable
private fun FoundationScreen(onContinue: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
MessageScreen(
title = stringResource(R.string.foundation_title),
description = stringResource(R.string.foundation_description),
)
ABoxButton(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
Text(stringResource(R.string.continue_action))
}
}
}
@Composable
private fun MessageScreen(
title: String,
description: String,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = title,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.ABoxManager" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">工程基础已就绪</string>
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string>
<string name="continue_action">继续</string>
<string name="placeholder_title">功能模块可以开始开发</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">Project foundation ready</string>
<string name="foundation_description">The shared domain contracts and local development providers are available.</string>
<string name="continue_action">Continue</string>
<string name="placeholder_title">Feature development can start</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.ABoxManager" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.designsystem"
compileSdk = 34
defaultConfig {
minSdk = 26
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun ABoxSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(modifier = modifier, content = content)
}
@Composable
fun ABoxButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
content = content,
)
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier)
}
@@ -0,0 +1,11 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val ABoxShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
)
@@ -0,0 +1,41 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF006780),
onPrimary = Color.White,
primaryContainer = Color(0xFFB8EAFF),
onPrimaryContainer = Color(0xFF001F29),
secondary = Color(0xFF4D616A),
surface = Color(0xFFF8FAFC),
background = Color(0xFFF8FAFC),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD5F8),
onPrimary = Color(0xFF003544),
primaryContainer = Color(0xFF004D61),
onPrimaryContainer = Color(0xFFB8EAFF),
secondary = Color(0xFFB4CBD4),
surface = Color(0xFF101416),
background = Color(0xFF101416),
)
@Composable
fun ABoxTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = ABoxTypography,
shapes = ABoxShapes,
content = content,
)
}
@@ -0,0 +1,5 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.material3.Typography
val ABoxTypography = Typography()
+8
View File
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt.android) apply false
}
+103
View File
@@ -0,0 +1,103 @@
# ABox Manager 架构基线
## 1. 文档范围
本文记录第一轮工程基础已经建立的模块边界、依赖方向和并行开发接口。ABSDK 0630 的初始化、协议、签名、连接重定向和会话清理等未确认内容不在本文中固化。
产品范围和固定分工以 `docs/project_plan.md` 为准,原始简要需求继续保留在 `docs/requirement_spec.md`
## 2. 当前模块
```text
abox-mgr/
├── android/
│ ├── app/ # Android 应用与依赖注入组合根
│ └── core/designsystem/ # Compose Material 3 设计系统基础
├── domain/ # 纯 Kotlin 领域模型和公共端口
├── testing/fakes/ # 确定性的公共 Fake
├── docs/
└── samples/ # 历史示例,仅供参考
```
依赖方向:
```text
android/app ────────────────┬──> android/core/designsystem
├──> domain
└──> testing/fakes ──> domain
android/core/designsystem ─────> Compose Material 3
domain ────────────────────────> Kotlin 与 Coroutines
```
`domain` 不依赖 Android、Compose、Room、ABSDK 或具体网络实现。业务 UI 只依赖领域端口和类型化结果。
## 3. 提供方与数据隔离
应用计划支持 ABox、自有兼容后端和本地体验三种提供方。每个连接配置由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。
约束:
- 任一时刻只启用一个提供方;
- 切换远端提供方前必须退出当前会话;
- 不同连接配置的数据、凭据和设备状态不得互相混用;
- 本地体验不调用 ABSDK,也不启动本地 HTTP 服务;
- Fake 使用相同标识隔离规则,便于在没有真实环境时验证边界。
## 4. 公共领域端口
`domain` 当前提供以下最小端口:
- `SessionGateway`:登录、观察会话、验证会话和退出;
- `DeviceCatalog`:按提供方观察设备目录;
- `SocketGateway`:查询和设置插座状态;
- `TemperatureHumidityGateway`:读取温度和湿度;
- `InfraredGateway`:发送、学习和下载红外码。
端口只覆盖需求和 ABSDK 0630 文档已经确认的行为。`DeviceId` 是 App 本地目录中的稳定记录标识;设备操作必须使用 ABSDK 0630 实际接受的 `DeviceName`。同一提供方配置和设备类型内,`DeviceName` 必须唯一,设备目录在添加、编辑和导入时负责强制该约束。设备目录的增删改与二维码导入、遥控器布局、情景动作和持久化模型将在对应模块提出后增量加入,不在公共层提前假定。
## 5. 结果与错误边界
所有端口返回 `DomainResult<T>`
- `Success<T>` 携带类型化领域值;
- `Failure` 携带 `DomainError`
- `DomainError.externalCode` 可以保留外部 SDK 错误码用于诊断;
- UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或服务端数据库模型。
真实 ABSDK 适配层必须在后续 `feat/absdk-bridge` 分支中完成同步阻塞调用的后台串行化、返回解析和错误映射。
## 6. Fake 边界
`testing/fakes` 提供会话、设备目录、插座、温湿度和红外 Fake。它们:
- 不使用网络、Android API、真实时间或随机数;
- 支持成功、失败、无数据、离线和 Token 无效场景;
- 对插座状态和设备目录按 `ProviderProfileId` 隔离;
- 记录红外学习、发送和下载调用;
- 可直接用于其他模块的 ViewModel 与 UI 测试。
Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。
## 7. Android 组合根
`android/app` 是单 Activity Compose 应用,负责:
- `ABoxManagerApplication` 与 Hilt 组合根;
- `MainActivity`
-`NavHost`
- 仅在 debug 构建中将领域端口绑定到当前开发阶段的 Fake,release 构建不包含 Fake
- 默认英文和简体中文资源;
- 使用 `android/core/designsystem` 的主题和基础组件。
设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。
## 8. 后续边界
后续 KremeCN 工作保持为独立短期分支:
- `feat/absdk-bridge`:真实 JAR 防腐层和协议验证;
- `feat/provider-login`:提供方入口、安全凭据、自动登录和重新登录;
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
+33
View File
@@ -0,0 +1,33 @@
<!-- LoginAsyncTaskDemo 配套布局文件 -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/buttonLoginAbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="260dp"
android:layout_marginBottom="48dp"
android:text="Button"
app:layout_constraintBottom_toTopOf="@+id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
+598
View File
@@ -0,0 +1,598 @@
# ABox Manager 项目计划
## 1. 文档目的
本文档是 ABox Manager 的项目执行蓝图,用于统一产品范围、技术方向、五人固定分工、模块依赖、实施阶段和验收原则。
本文档只记录已经确认的方向。尚未确认的 SDK 行为、协议细节和外部环境必须保留为待确认事项,不得将推测直接作为实现依据。
---
## 2. 项目现状
仓库目前主要包含:
- 产品需求概要;
- ABSDK 0630 接口文档;
- AsyncTask 登录示例;
- 清理归档后的 ABSDK Android Sample
- Git 和 AI 工具协作规则。
当前还没有正式的 Android 应用工程、自有后端、自动化测试或 CI Runner。
重要参考资料:
```text
docs/requirement_spec.md
docs/ABSDK接口部分_0630.md
docs/examples/
samples/abox-sdk-sample/
```
---
## 3. 产品目标
ABox Manager 是面向 Android 手机和平板的智能家居管理应用,首版覆盖以下核心能力:
1. 登录和会话管理;
2. 设备列表与页面导航;
3. 插座状态查询和开关控制;
4. 温湿度查询和展示;
5. 红外遥控器创建、学习和发送;
6. 基于插座和红外动作的情景模式;
7. 数据本地持久化;
8. 简体中文和英文界面;
9. 手机和平板响应式界面。
---
## 4. MVP 范围
### 4.1 登录
- 支持用户名和密码登录;
- 支持记住用户名;
- 支持按连接配置独立开启自动登录;
- 自动登录所需密码必须通过 Android Keystore 安全保存;
- ABSDK Token 由 SDK 自身管理;进程重启或 Token 失效后,通过保存的凭据重新登录;
- 退出登录时保留非敏感连接信息和用户名,清除密码与会话;
- 切换后端提供方前必须退出当前会话。
### 4.2 提供方模式
应用计划兼容三种相互独立的模式:
1. **ABox 后端**:使用现有 ABSDK 0630 连接 ABox 服务;
2. **自有兼容后端**:连接自行实现的 ABSDK 0630 协议兼容服务;
3. **本地体验**:不连接远端服务,通过本地模拟数据体验完整应用流程。
约束:
- 任一时刻只使用一个提供方;
- 不要求同时连接两个后端;
- ABox 与自有后端的账号、会话和数据不互相绑定;
- 每个连接配置拥有独立的设备、遥控器、情景和凭据空间;
- 本地体验不启动 HTTP 服务,也不调用 ABSDK。
### 4.3 设备目录
ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录采用:
- 二维码批量导入;
- 用户手工添加、编辑和删除;
- 设备名称作为调用 ABSDK 的设备标识;
- 设备类型首版只包含插座、温湿度和红外。
不得假设存在尚未确认的新版 SDK 或设备发现接口。
### 4.4 插座
- 查询当前状态;
- 显示打开、关闭、加载、错误和离线状态;
- 控制打开和关闭;
- 控制完成后确认实际状态;
- 防止重复操作;
- 记录操作结果。
### 4.5 温湿度
- 查询温度;
- 查询湿度;
- 分别展示温度和湿度;
- 支持刷新;
- 显示加载、无数据、错误和离线状态;
- 温湿度设备是只读设备,不提供控制动作。
### 4.6 红外遥控器
- 创建、编辑和删除遥控器;
- 设置遥控器名称和视觉标识;
- 添加、重命名、排序和删除按键;
- 学习红外按键;
- 发送红外按键;
- 下载红外码;
- 持久化遥控器布局和按键;
- 为情景模式提供稳定的红外按键动作引用。
### 4.7 情景模式
- 创建、编辑和删除情景;
- 设置情景名称、图标和颜色;
- 添加插座开关动作;
- 添加红外按键动作;
- 调整动作顺序;
- 按顺序执行动作;
- 单个动作失败后继续执行后续动作;
- 最后逐项汇总执行结果;
- 防止同一情景被重复触发;
- 按提供方隔离情景数据。
首版不实现:
- 定时触发;
- 条件判断;
- 温湿度或其他传感器自动触发;
- 复杂规则引擎。
---
## 5. 技术方向
### 5.1 Android
正式 Android 应用采用:
- Kotlin
- Jetpack Compose
- Material 3
- 单 Activity
- Navigation Compose
- ViewModel
- StateFlow
- Coroutines
- Hilt
- Room
- DataStore
- Android Keystore。
目标:
- applicationId 为 `com.flagship.abox.manager`
- 最低 Android 版本暂按 API 26
- 支持手机和平板;
- 支持深色和浅色主题;
- 支持简体中文和英文;
- 其他系统语言回退到英文;
- 满足基本无障碍要求;
- 建立独立的 ABox 视觉设计系统。
### 5.2 ABSDK 接入原则
- 正式业务代码不得直接散落调用 `ABSDK.getInstance()`
- 必须通过统一适配层调用 SDK
- SDK 同步阻塞调用必须在后台线程执行;
- `ABRet`、原始 `Map` 和字符串错误码不得泄漏到 UI 层;
- 适配层将 SDK 返回转换为类型化领域结果;
- SDK 的 Host、Token 和线程状态按全局共享状态处理;
- 切换提供方前必须结束当前会话;
- `docs/examples/``samples/abox-sdk-sample/` 只作为参考,不直接作为正式架构。
### 5.3 自有兼容后端
自有后端目标是兼容 ABSDK 0630 的必要服务端行为。
暂定方向:
- Kotlin 和 Ktor
- 旧协议只存在于兼容适配边界;
- 内部使用类型化领域模型;
- 密码使用强哈希保存;
- 酒店与房间作为数据隔离边界;
- 管理操作通过内置 CLI 完成;
- 提供模拟设备驱动;
- 为未来真实设备驱动预留统一接口;
- SQLite 用于轻量部署;
- PostgreSQL 用于较大规模部署;
- Docker 作为标准交付方式。
具体协议、签名和连接方式必须先验证,不在本文档中提前固化。
---
## 6. 固定五人分工
项目总体分工固定为以下五个模块。后续新增工作必须归入这五个模块,不再以协议、服务端或基础架构为理由重划总体职责。
| 成员 | 固定模块 |
|---|---|
| KremeCN | 工程 SDK 登录 |
| alZerNest | 设备列表导航 |
| Aurum | 插座温湿度 |
| Moler | 红外遥控器 |
| tzh | 情景模式集成 |
### 6.1 KremeCN:工程 SDK 登录
负责项目公共基础、SDK 以及登录体系。
主要工作:
- 创建 Android 工程和公共构建配置;
- 建立 Compose、Navigation、Hilt、Room 和 DataStore 基础;
- 建立公共设计系统基础;
- 接入和封装 ABSDK 0630
- 建立统一领域接口、错误模型和 Fake 实现;
- 实现 ABox、自有兼容后端和本地体验三个入口;
- 实现记住用户名、自动登录、Keystore 凭据保存和退出;
- 处理 Token 失效与重新登录;
- 研究 SDK 已确认行为和协议边界;
- 建立自有兼容后端基础、测试宿主、CLI 和 Docker 基础。
主要交付:
```text
工程基础
SDK 适配层
登录与提供方选择
公共领域接口与 Fake
兼容后端基础
```
### 6.2 alZerNest:设备列表导航
负责登录后的主界面、设备目录和页面导航。
主要工作:
- 设备模型和三种设备类型;
- 设备列表;
- 二维码导入设备目录;
- 手工添加、编辑和删除设备;
- 按提供方隔离设备数据;
- 手机单栏导航;
- 平板列表详情双栏布局;
- 点击设备进入正确详情页;
- 空列表、未知设备和离线状态;
- Room 设备目录持久化。
主要交付:
```text
设备目录
设备列表
主导航
手机和平板导航
```
### 6.3 Aurum:插座温湿度
负责插座和温湿度的完整功能。
主要工作:
- 插座状态查询和开关控制;
- 控制后的状态确认;
- 插座加载、错误、超时和离线状态;
- 温度和湿度查询、格式化和刷新;
- 温湿度加载、错误、无数据和离线状态;
- 操作日志接入;
- 对应 ViewModel、单元测试和 UI 测试。
主要交付:
```text
插座控制页面
温湿度页面
相关状态管理和测试
```
### 6.4 Moler:红外遥控器
负责全部红外相关能力。
主要工作:
- 遥控器列表;
- 遥控器创建、编辑和删除;
- 按键添加、重命名、排序和删除;
- 红外按键学习;
- 红外发送;
- 红外码下载;
- 红外失败和离线状态;
- Room 持久化;
- 为情景模式提供稳定的红外动作引用。
主要交付:
```text
红外遥控器
红外学习和发送
红外本地持久化
```
### 6.5 tzh:情景模式集成
负责情景模式和最终体验整合。
主要工作:
- 情景列表;
- 情景创建、编辑和删除;
- 插座动作和红外动作选择;
- 动作排序;
- 顺序执行和失败后继续;
- 执行结果逐项汇总;
- 情景 Room 持久化;
- 中英文资源整合;
- 空状态、错误状态和加载状态统一;
- 深浅色、无障碍和最终集成测试;
- 用户使用说明。
主要交付:
```text
情景编辑器
情景执行器
最终体验整合和验收
```
---
## 7. 分工依赖关系
```text
KremeCN 工程 SDK 登录
├── alZerNest 设备列表导航
├── Aurum 插座温湿度
└── Moler 红外遥控器
└────────────┐
Aurum 插座温湿度 ───────┤
tzh 情景模式集成
```
开发阶段必须通过 Fake 接口解除等待:
- KremeCN 优先提供领域接口和 Fake;
- alZerNest、Aurum、Moler、tzh 使用 Fake 并行开发;
- 真实 SDK 接口准备好后,各模块只替换数据源,不重写 UI;
- tzh 不等待全部硬件联调完成才开始情景页面。
---
## 8. 执行阶段
### 阶段 0:需求和接口基线
- 扩充产品需求;
- 区分已确认事实与待确认事项;
- 定义最小公共领域接口;
- 提供 Fake 登录、设备目录和设备操作;
- 五人共同评审接口。
退出条件:
- 五个模块都能在 Fake 上开始开发;
- 不确定的 SDK 协议没有被写死;
- 各模块数据所有权明确。
### 阶段 1:工程和 UI 骨架
KremeCN
- 建立工程、设计系统、SDK 适配和登录骨架。
alZerNest
- 建立设备列表和导航。
Aurum
- 建立插座和温湿度页面状态。
Moler
- 建立红外遥控器页面和数据结构。
tzh
- 建立情景列表、编辑器和 Fake 动作执行。
退出条件:
- Android App 可以使用 Fake 完成主要页面导航;
- 服务端骨架可以独立启动;
- 所有人拥有可独立测试的模块。
### 阶段 2:完整功能实现
- 接入 Room、DataStore 和 Keystore
- 完成登录和提供方隔离;
- 完成设备目录导入和维护;
- 完成插座、温湿度和红外;
- 完成情景模式;
- 完成自有兼容后端的已确认协议范围。
退出条件:
- 本地体验模式完整可用;
- 所有模块在 Fake 或模拟后端下通过测试;
- 提供方切换不会混用数据和凭据。
### 阶段 3:真实集成
- 连接真实 ABox 环境;
- 连接自有兼容后端;
- 使用真实插座、温湿度和红外设备;
- 验证 Token 失效、设备离线和错误码;
- 验证手机和平板布局。
退出条件:
- 三类真实硬件核心流程通过;
- ABox 与自有后端连接行为明确;
- 已知限制完整记录。
### 阶段 4:发布验收
- 中英文检查;
- 深浅色检查;
- 无障碍检查;
- 安全检查;
- 自动化测试和手工验收;
- 安装和部署文档;
- 发布包和回滚方案。
---
## 9. 分支与 PR 规则
总体模块固定,但具体工作应使用短期功能分支,不应为每个人保留一个持续到项目结束的巨大分支。
建议分支示例:
### KremeCN
```text
feat/project-foundation
feat/absdk-bridge
feat/provider-login
feat/compatible-server
```
### alZerNest
```text
feat/device-catalog
feat/device-navigation
feat/device-import
```
### Aurum
```text
feat/socket-control
feat/temperature-display
```
### Moler
```text
feat/infrared-remote
feat/infrared-learning
```
### tzh
```text
feat/scene-editor
feat/scene-execution
feat/app-integration
```
所有工作遵循 `AGENTS.md`
- 从最新 `main` 创建工作分支;
- 修改和提交保留在工作分支;
- 完成后创建目标为 `main` 的 Pull Request
- 不绕过分支保护、评审和必要检查。
---
## 10. 公共文件所有权
| 公共内容 | 主要维护者 |
|---|---|
| 根构建配置 | KremeCN |
| 公共领域接口 | KremeCN,其他成员共同评审 |
| 设计系统基础 | KremeCN |
| 主导航与设备导航 | alZerNest |
| 设备模型 | alZerNest 提出,KremeCN 合入公共层 |
| 插座与温湿度命令 | Aurum 提出,KremeCN 合入公共层 |
| 红外命令与按键引用 | Moler 提出,KremeCN 合入公共层 |
| 情景动作模型 | tzh 提出,KremeCN 合入公共层 |
| 中英文公共文案检查 | tzh |
公共接口变更必须:
1. 单独说明影响范围;
2. 更新对应 Fake
3. 由受影响模块负责人评审;
4. 合并后再由功能分支更新基线。
---
## 11. 测试与验收
### 11.1 自动测试
- 领域模型和执行逻辑单元测试;
- SDK 返回和错误映射测试;
- ViewModel 状态测试;
- Room 持久化测试;
- Compose 核心页面测试;
- 情景顺序执行和失败继续测试;
- 自有兼容后端认证、存储和模拟驱动测试;
- Docker 和 CLI 验证。
### 11.2 手工验收
- 登录、自动登录和退出;
- 三种提供方入口;
- 数据和凭据隔离;
- 设备目录导入和维护;
- 插座状态和控制;
- 温湿度读取;
- 红外学习和发送;
- 情景创建和执行;
- Token 失效与设备离线;
- 手机和平板;
- 中文和英文;
- 深色和浅色。
### 11.3 CI
当前 Gitea 暂无 Runner。
在 Runner 就绪前:
- 提供统一的本地验证命令;
- 每个 PR 附带实际测试结果;
- 不得以未来 CI 会运行作为跳过验证的理由。
Runner 就绪后,再将相同命令配置为受保护 `main` 的必要检查。
---
## 12. 待确认事项
以下内容不能提前假定:
- ABSDK 0630 的完整初始化流程;
- `setHostInfo` 的正式业务含义;
- ABox 官方部署所需参数;
- 自有后端与原始 JAR 的最终连接方式;
- SDK 的完整签名算法和参数规则;
- SDK 线程安全与会话清理行为;
- 真实测试账号、设备名称和硬件环境;
- 自有后端连接真实设备所使用的网关协议;
- ABSDK 二进制的生产分发许可。
上述事项必须通过厂商资料、授权分析、实验或真实环境验证后,再更新本文档或对应技术文档。
---
## 13. 当前下一步
本计划落盘后,不立即开始全部功能开发。
正式开工前建议依次完成:
1. 五人确认固定分工;
2. 将 MVP 需求补充到 `docs/requirement_spec.md`
3. 建立任务看板;
4. 明确第一轮短期分支和 PR
5. 由 KremeCN 先提供最小公共接口和 Fake;
6. 五人进入第一轮并行开发。
+13
View File
@@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(21)
}
dependencies {
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.domain
sealed interface DomainResult<out T> {
data class Success<T>(val value: T) : DomainResult<T>
data class Failure(val error: DomainError) : DomainResult<Nothing>
}
sealed interface DomainError {
val externalCode: String?
val detail: String?
data class Authentication(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class TokenInvalid(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class Network(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class DeviceNotFound(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class DeviceOffline(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class InvalidRequest(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class OperationFailed(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class NoData(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
data class Unknown(
override val externalCode: String? = null,
override val detail: String? = null,
) : DomainError
}
@@ -0,0 +1,62 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.Flow
interface SessionGateway {
fun observeSession(profileId: ProviderProfileId): Flow<Session?>
suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session>
suspend fun validateSession(profileId: ProviderProfileId): DomainResult<SessionValidity>
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit>
}
interface DeviceCatalog {
fun observeDevices(
profileId: ProviderProfileId,
): Flow<DomainResult<List<DeviceDescriptor>>>
}
/** Operates vendor devices by [DeviceName], the identifier accepted by ABSDK 0630. */
interface SocketGateway {
suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus>
suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus>
}
interface TemperatureHumidityGateway {
suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading>
}
interface InfraredGateway {
suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit>
suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit>
suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>>
}
@@ -0,0 +1,93 @@
package com.flagship.abox.manager.domain
@JvmInline
value class ProviderProfileId(val value: String)
@JvmInline
value class DeviceId(val value: String)
/**
* Vendor-facing device identifier.
*
* Device names must be unique within the same provider profile and device type because
* ABSDK 0630 addresses devices by name rather than by the app's local [DeviceId].
*/
@JvmInline
value class DeviceName(val value: String)
@JvmInline
value class Username(val value: String)
@JvmInline
value class Password(val value: String) {
override fun toString(): String = "Password(***)"
}
enum class ProviderType {
ABOX,
COMPATIBLE_BACKEND,
LOCAL_EXPERIENCE,
}
data class ProviderProfile(
val id: ProviderProfileId,
val type: ProviderType,
val displayName: String,
)
enum class DeviceType {
SOCKET,
TEMPERATURE_HUMIDITY,
INFRARED,
}
data class DeviceDescriptor(
val id: DeviceId,
val profileId: ProviderProfileId,
val name: DeviceName,
val type: DeviceType,
)
data class LoginCredentials(
val username: Username,
val password: Password,
)
data class Session(
val profileId: ProviderProfileId,
val username: Username,
)
enum class SessionValidity {
VALID,
INVALID,
}
enum class SocketPowerState {
OFF,
ON,
}
data class SocketStatus(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val powerState: SocketPowerState,
)
data class TemperatureHumidityReading(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val temperature: String,
val humidity: String,
)
data class InfraredCode(
val deviceName: DeviceName,
val key: String,
val code: String,
)
data class InfraredCodeDownloadResult(
val infraredCode: InfraredCode,
val successful: Boolean,
)
@@ -0,0 +1,35 @@
package com.flagship.abox.manager.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DomainResultTest {
@Test
fun successContainsTypedValue() {
val result: DomainResult<SocketPowerState> = DomainResult.Success(SocketPowerState.ON)
assertEquals(SocketPowerState.ON, (result as DomainResult.Success).value)
}
@Test
fun failureKeepsExternalCodeWithoutSdkTypes() {
val error = DomainError.DeviceOffline(externalCode = "20504")
val result: DomainResult<Unit> = DomainResult.Failure(error)
assertTrue(result is DomainResult.Failure)
assertEquals("20504", (result as DomainResult.Failure).error.externalCode)
}
@Test
fun credentialStringsDoNotExposePassword() {
val plaintext = "secret-password"
val password = Password(plaintext)
val credentials = LoginCredentials(Username("demo"), password)
assertFalse(password.toString().contains(plaintext))
assertFalse(credentials.toString().contains(plaintext))
assertTrue(credentials.toString().contains("Password(***)"))
}
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=false
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+46
View File
@@ -0,0 +1,46 @@
[versions]
agp = "8.7.3"
kotlin = "1.9.24"
composeBom = "2024.06.00"
composeCompiler = "1.5.14"
coroutines = "1.8.1"
hilt = "2.51.1"
navigationCompose = "2.7.7"
activityCompose = "1.9.0"
lifecycle = "2.8.2"
room = "2.6.1"
datastore = "1.1.1"
junit = "4.13.2"
androidxJunit = "1.2.1"
espresso = "3.6.1"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=false
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+77
View File
@@ -0,0 +1,77 @@
# ABox SDK Sample
## 中文说明
本目录归档了原 `aboxtest` Android 工程,用于参考 ABSDK 0630 的接入和调用方式。归档只修改了外层目录名,原工程名、模块名、包名和源码结构均保持不变。
### 模块
- `login-mac`:主要参考模块,演示 ABSDK 登录、记住密码、自动登录、错误码映射和插座控制。
- `myapplication`:原工程附带的 Android Studio 示例模块,主要包含基础导航、登录界面和 SQLite 辅助类骨架。
### 重要文件
- `login-mac/libs/libABSDK0630.jar`ABSDK 0630 二进制依赖。
- `login-mac/src/main/java/tr/dhee/abox/demo/data/LoginDataSource.java`SDK 登录调用。
- `login-mac/src/main/java/tr/dhee/abox/demo/data/SwitchDataSource.java`:插座控制调用。
- `login-mac/src/main/java/tr/dhee/abox/demo/ui/login/LoginActivity.java`:登录、记住密码和自动登录流程。
- `login-mac/src/main/java/tr/dhee/abox/demo/ui/switchcontrol/SwitchControlActivity.java`:插座控制界面。
- `login-mac/src/main/java/tr/dhee/abox/demo/utils/AboxConstants.java`ABSDK 错误码映射。
### 归档清理
归档时已排除以下本机状态、构建产物和敏感文件:
- `.gradle/``.idea/` 和所有 `build/` 目录;
- `local.properties``.DS_Store`
- APK、keystore、JKS、备份文件和损坏的 JAR 备份。
### 使用限制
本目录仅作为历史实现和 SDK 行为参考,不代表 ABox Manager 正式应用的目标架构,也不保证能够在当前工具链中直接构建。正式代码不应直接沿用以下做法:
- `AsyncTask` 或旧式线程管理;
- 使用普通 `SharedPreferences` 明文保存密码;
- 全局允许明文网络流量;
- 未经封装直接调用同步阻塞的 SDK API。
正式实现应通过类型化适配层、后台协程、安全凭据存储和受限网络安全配置接入 ABSDK。
---
## English
This directory archives the original `aboxtest` Android project as a reference for integrating and calling ABSDK 0630. Only the outer directory name was changed; the original project name, module names, package names, and source layout remain unchanged.
### Modules
- `login-mac`: The primary reference module. It demonstrates ABSDK login, password remembering, automatic login, error-code mapping, and socket control.
- `myapplication`: An Android Studio sample module included in the original project, containing basic navigation, a login screen, and a SQLite helper skeleton.
### Notable files
- `login-mac/libs/libABSDK0630.jar`: The ABSDK 0630 binary dependency.
- `login-mac/src/main/java/tr/dhee/abox/demo/data/LoginDataSource.java`: SDK login invocation.
- `login-mac/src/main/java/tr/dhee/abox/demo/data/SwitchDataSource.java`: Socket control invocation.
- `login-mac/src/main/java/tr/dhee/abox/demo/ui/login/LoginActivity.java`: Login, password remembering, and automatic-login flow.
- `login-mac/src/main/java/tr/dhee/abox/demo/ui/switchcontrol/SwitchControlActivity.java`: Socket control UI.
- `login-mac/src/main/java/tr/dhee/abox/demo/utils/AboxConstants.java`: ABSDK error-code mapping.
### Archive cleanup
The following machine-specific state, build output, and sensitive files were excluded from the archive:
- `.gradle/`, `.idea/`, and all `build/` directories;
- `local.properties` and `.DS_Store`;
- APKs, keystores, JKS files, backup files, and the corrupted JAR backup.
### Usage limitations
This directory is provided only as a historical implementation and SDK behavior reference. It does not represent the target architecture of ABox Manager and is not guaranteed to build with the current toolchain. Production code should not directly reuse the following practices:
- `AsyncTask` or legacy thread management;
- storing passwords in plain `SharedPreferences`;
- globally allowing cleartext network traffic;
- calling synchronous, blocking SDK APIs without an adapter.
The production implementation should integrate ABSDK through a typed adapter, background coroutines, secure credential storage, and narrowly scoped network security configuration.
+4
View File
@@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
}
+21
View File
@@ -0,0 +1,21 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# When enabled, the Configuration Cache allows Gradle to skip the configuration
# phase entirely if nothing that affects the build configuration (such as build scripts)
# has changed. Additionally, Gradle applies performance optimizations to task execution.
# AGP 8.7.3 的 data binding 任务不支持配置缓存序列化, 关闭以免构建失败。
org.gradle.configuration-cache=false
# 启用 AndroidX (模板依赖 androidx.* 库, 必须开启)
android.useAndroidX=true
android.nonTransitiveRClass=true
@@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=21
@@ -0,0 +1,31 @@
[versions]
agp = "8.7.3"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
constraintlayout = "2.1.4"
activityKtx = "1.8.0"
navigationFragment = "2.6.0"
navigationUi = "2.6.0"
annotation = "1.6.0"
lifecycleLivedataKtx = "2.6.1"
lifecycleViewmodelKtx = "2.6.1"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "navigationFragment" }
navigation-ui = { group = "androidx.navigation", name = "navigation-ui", version.ref = "navigationUi" }
annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }
lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Binary file not shown.
@@ -0,0 +1,8 @@
#Tue Aug 11 15:31:21 CST 2026
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
+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" "$@"
+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
@@ -0,0 +1 @@
/build
@@ -0,0 +1,59 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace 'tr.dhee.abox.demo'
compileSdk 34
// SDK(libABSDK0630.jar) 内部依赖 Apache HttpClient(org.apache.http)。
// 自包含方案: 四个 Apache jar 直接打进 APK, 不依赖设备系统库(任意机型可装)。
defaultConfig {
applicationId "tr.dhee.abox.demo"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
buildFeatures {
viewBinding true
}
// 多个 Apache HTTP 相关 jar 均带 META-INF 元数据文件, 打包进同一 APK 会冲突, 排除这些重复元数据(不影响运行时类)。
packagingOptions {
resources {
excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt',
'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/ASL2.0',
'META-INF/license.txt', 'META-INF/notice.txt']
}
}
}
dependencies {
// 引入 libs 下全部 jar: SDK(libABSDK0630.jar) + Apache HttpClient 四件套(自包含, 不依赖系统库)。
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libs.activity.ktx
implementation libs.annotation
implementation libs.appcompat
implementation libs.constraintlayout
implementation libs.lifecycle.livedata.ktx
implementation libs.lifecycle.viewmodel.ktx
implementation libs.material
testImplementation libs.junit
androidTestImplementation libs.espresso.core
androidTestImplementation libs.ext.junit
}
@@ -0,0 +1,26 @@
package tr.dhee.abox.demo;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("tr.dhee.abox.demo", appContext.getPackageName());
}
}
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Aboxtest">
<activity
android:name=".ui.login.LoginActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.switchcontrol.SwitchControlActivity"
android:exported="false"
android:label="@string/switch_control_title" />
</application>
</manifest>
@@ -0,0 +1,43 @@
package tr.dhee.abox.demo.data;
import tr.dhee.abox.demo.data.model.LoggedInUser;
import tr.dhee.abox.demo.utils.AboxConstants;
import com.dhc.absdk.ABRet;
import com.dhc.absdk.ABSDK;
import java.io.IOException;
/**
* Class that handles authentication w/ login credentials and retrieves user information.
* 接 base 真登录: 调用 libABSDK0630.jar 的 ABSDK.getInstance().loginWithUsername()。
*/
public class LoginDataSource {
public Result<LoggedInUser> login(String username, String password) {
try {
// base 登录核心: 同步阻塞网络调用, 在 LoginRepository 的后台线程执行(不会 ANR)
ABRet abRet = ABSDK.getInstance().loginWithUsername(username, password);
if (abRet != null && "00000".equals(abRet.getCode())) {
// 成功: 用用户名作为展示名(与 base 行为一致, base 未返回用户信息)
LoggedInUser user = new LoggedInUser(username, username);
return new Result.Success<>(user);
} else {
// 失败: 用 AboxConstants.codeMap 把返回码映射成中文提示
String code = abRet != null ? abRet.getCode() : "unknown";
String msg = AboxConstants.codeMap.get(code);
if (msg == null) {
msg = "登录失败: " + code;
}
return new Result.Error(new IOException(msg));
}
} catch (Exception e) {
// 网络异常 / SDK 初始化异常等
return new Result.Error(new IOException("登录异常: " + e.getMessage(), e));
}
}
public void logout() {
// TODO: revoke authentication
}
}
@@ -0,0 +1,66 @@
package tr.dhee.abox.demo.data;
import tr.dhee.abox.demo.data.model.LoggedInUser;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Class that requests authentication and user information from the remote data source and
* maintains an in-memory cache of login status and user credentials information.
* 登录在后台线程执行(SDK 的 loginWithUsername 是同步网络调用, 不能在主线程跑, 否则 ANR)。
*/
public class LoginRepository {
private static volatile LoginRepository instance;
private LoginDataSource dataSource;
private final Executor executor = Executors.newSingleThreadExecutor();
// If user credentials will be cached in local storage, it is recommended it be encrypted
// @see https://developer.android.com/training/articles/keystore
private LoggedInUser user = null;
// private constructor : singleton access
private LoginRepository(LoginDataSource dataSource) {
this.dataSource = dataSource;
}
public static LoginRepository getInstance(LoginDataSource dataSource) {
if (instance == null) {
instance = new LoginRepository(dataSource);
}
return instance;
}
public boolean isLoggedIn() {
return user != null;
}
public void logout() {
user = null;
dataSource.logout();
}
private void setLoggedInUser(LoggedInUser user) {
this.user = user;
// If user credentials will be cached in local storage, it is recommended it be encrypted
// @see https://developer.android.com/training/articles/keystore
}
public void login(String username, String password, LoginCallback callback) {
executor.execute(() -> {
Result<LoggedInUser> result = dataSource.login(username, password);
if (result instanceof Result.Success) {
setLoggedInUser(((Result.Success<LoggedInUser>) result).getData());
}
if (callback != null) {
callback.onComplete(result);
}
});
}
public interface LoginCallback {
void onComplete(Result<LoggedInUser> result);
}
}
@@ -0,0 +1,48 @@
package tr.dhee.abox.demo.data;
/**
* A generic class that holds a result success w/ data or an error exception.
*/
public class Result<T> {
// hide the private constructor to limit subclass types (Success, Error)
private Result() {
}
@Override
public String toString() {
if (this instanceof Result.Success) {
Result.Success success = (Result.Success) this;
return "Success[data=" + success.getData().toString() + "]";
} else if (this instanceof Result.Error) {
Result.Error error = (Result.Error) this;
return "Error[exception=" + error.getError().toString() + "]";
}
return "";
}
// Success sub-class
public final static class Success<T> extends Result {
private T data;
public Success(T data) {
this.data = data;
}
public T getData() {
return this.data;
}
}
// Error sub-class
public final static class Error extends Result {
private Exception error;
public Error(Exception error) {
this.error = error;
}
public Exception getError() {
return this.error;
}
}
}
@@ -0,0 +1,45 @@
package tr.dhee.abox.demo.data;
import com.dhc.absdk.ABRet;
import com.dhc.absdk.ABSDK;
import tr.dhee.abox.demo.utils.AboxConstants;
import java.io.IOException;
/**
* Class that handles socket device control via sockCtrl API.
* Must be called after login (ABSDK already has the session).
*/
public class SwitchDataSource {
public Result<String> sockCtrl(String soDevName, String status) {
try {
// 同步阻塞调用 SDK 的 sockCtrl 接口, 在 SwitchRepository 的后台线程执行
ABRet abRet = ABSDK.getInstance().sockCtrl(soDevName, status);
if (abRet != null && "00000".equals(abRet.getCode())) {
// 成功: 返回状态描述
String desc = "操作成功: " + status;
return new Result.Success<>(desc);
} else {
// 失败: 映射返回码到中文提示, 并始终携带原始错误码
String code = abRet != null ? abRet.getCode() : "unknown";
String abMsg = abRet != null ? abRet.getMsg() : "";
String mapped = AboxConstants.codeMap.get(code);
String msg;
if (mapped != null) {
msg = mapped + " (" + code + ")";
} else if (abMsg != null && !abMsg.isEmpty()) {
msg = abMsg + " (" + code + ")";
} else {
msg = "控制失败: " + code;
}
return new Result.Error(new IOException(msg));
}
} catch (Exception e) {
// 网络异常 / SDK 异常等
String detail = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
return new Result.Error(new IOException("控制异常: " + detail, e));
}
}
}
@@ -0,0 +1,55 @@
package tr.dhee.abox.demo.data;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Class that handles socket device control via SwitchDataSource.
* Executes SDK calls on a background thread to avoid ANR.
*/
public class SwitchRepository {
private static volatile SwitchRepository instance;
private final SwitchDataSource dataSource;
private final Executor executor = Executors.newSingleThreadExecutor();
private SwitchRepository(SwitchDataSource dataSource) {
this.dataSource = dataSource;
}
public static SwitchRepository getInstance(SwitchDataSource dataSource) {
if (instance == null) {
instance = new SwitchRepository(dataSource);
}
return instance;
}
/**
* 控制插座开关, 在后台线程执行 SDK 调用
*
* @param soDevName 插座设备名称
* @param status 状态: "1"(开) 或 "0"(关)
* @param callback 结果回调 (在主线程调用)
*/
public void sockCtrl(String soDevName, String status, SwitchCallback callback) {
executor.execute(() -> {
Result<String> result = dataSource.sockCtrl(soDevName, status);
if (callback != null) {
callback.onComplete(result);
}
});
executor.execute(new Runnable() {
@Override
public void run() {
}
});
}
public interface SwitchCallback {
void onComplete(Result<String> result);
}
}
@@ -0,0 +1,23 @@
package tr.dhee.abox.demo.data.model;
/**
* Data class that captures user information for logged in users retrieved from LoginRepository
*/
public class LoggedInUser {
private String userId;
private String displayName;
public LoggedInUser(String userId, String displayName) {
this.userId = userId;
this.displayName = displayName;
}
public String getUserId() {
return userId;
}
public String getDisplayName() {
return displayName;
}
}
@@ -0,0 +1,17 @@
package tr.dhee.abox.demo.ui.login;
/**
* Class exposing authenticated user details to the UI.
*/
class LoggedInUserView {
private String displayName;
//... other data fields that may be accessible to the UI
LoggedInUserView(String displayName) {
this.displayName = displayName;
}
String getDisplayName() {
return displayName;
}
}
@@ -0,0 +1,173 @@
package tr.dhee.abox.demo.ui.login;
import androidx.activity.EdgeToEdge;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import tr.dhee.abox.demo.R;
import tr.dhee.abox.demo.ui.switchcontrol.SwitchControlActivity;
import tr.dhee.abox.demo.databinding.ActivityLoginBinding;
import tr.dhee.abox.demo.utils.AboxConstants;
public class LoginActivity extends AppCompatActivity {
private LoginViewModel loginViewModel;
private ActivityLoginBinding binding;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
binding = ActivityLoginBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
loginViewModel = new ViewModelProvider(this, new LoginViewModelFactory())
.get(LoginViewModel.class);
final EditText usernameEditText = binding.username;
final EditText passwordEditText = binding.password;
final CheckBox checkBoxAutoLogin = binding.checkBoxAutoLogin;
final Button loginButton = binding.login;
final ProgressBar loadingProgressBar = binding.loading;
// 自动登录: 读取上次保存的凭据填充, 若勾选过"记住密码"则直接发起登录
SharedPreferences sp = getSharedPreferences(AboxConstants.AUTOLOGINFILE, Context.MODE_PRIVATE);
boolean autoFlag = sp.getBoolean(AboxConstants.AutoLoginClass.AUTOLOGIN_FLAG, false);
String savedUser = sp.getString(AboxConstants.AutoLoginClass.AUTOLOGIN_USERNAME, "");
String savedPwd = sp.getString(AboxConstants.AutoLoginClass.AUTOLOGIN_USERPWD, "");
if (!savedUser.isEmpty()) {
usernameEditText.setText(savedUser);
passwordEditText.setText(savedPwd);
checkBoxAutoLogin.setChecked(autoFlag);
// 显式触发表单校验, 确保预填充凭据后登录按钮可用
loginViewModel.loginDataChanged(savedUser, savedPwd);
if (autoFlag && !savedPwd.isEmpty()) {
// 延迟一帧, 等观察者注册完再自动登录
usernameEditText.post(() -> loginViewModel.login(savedUser, savedPwd));
}
}
loginViewModel.getLoginFormState().observe(this, new Observer<LoginFormState>() {
@Override
public void onChanged(@Nullable LoginFormState loginFormState) {
if (loginFormState == null) {
return;
}
loginButton.setEnabled(loginFormState.isDataValid());
if (loginFormState.getUsernameError() != null) {
usernameEditText.setError(getString(loginFormState.getUsernameError()));
}
if (loginFormState.getPasswordError() != null) {
passwordEditText.setError(getString(loginFormState.getPasswordError()));
}
}
});
loginViewModel.getLoginResult().observe(this, new Observer<LoginResult>() {
@Override
public void onChanged(@Nullable LoginResult loginResult) {
if (loginResult == null) {
return;
}
loadingProgressBar.setVisibility(View.GONE);
if (loginResult.getError() != null) {
showLoginFailed(loginResult.getError());
}
if (loginResult.getSuccess() != null) {
// 登录成功: 按勾选状态保存凭据到 SharedPreferences(与 base 行为一致)
String user = usernameEditText.getText().toString();
String pwd = passwordEditText.getText().toString();
boolean remember = checkBoxAutoLogin.isChecked();
SharedPreferences sp = getSharedPreferences(AboxConstants.AUTOLOGINFILE, Context.MODE_PRIVATE);
sp.edit().putString(AboxConstants.AutoLoginClass.AUTOLOGIN_USERNAME, user)
.putString(AboxConstants.AutoLoginClass.AUTOLOGIN_USERPWD, pwd)
.putBoolean(AboxConstants.AutoLoginClass.AUTOLOGIN_FLAG, remember)
.apply();
updateUiWithUser(loginResult.getSuccess());
// 跳转到插座控制页面
Intent intent = new Intent(LoginActivity.this, SwitchControlActivity.class);
startActivity(intent);
finish();
}
}
});
TextWatcher afterTextChangedListener = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// ignore
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
// ignore
}
@Override
public void afterTextChanged(Editable s) {
loginViewModel.loginDataChanged(usernameEditText.getText().toString(),
passwordEditText.getText().toString());
}
};
usernameEditText.addTextChangedListener(afterTextChangedListener);
passwordEditText.addTextChangedListener(afterTextChangedListener);
passwordEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_DONE) {
loginViewModel.login(usernameEditText.getText().toString(),
passwordEditText.getText().toString());
}
return false;
}
});
loginButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
loadingProgressBar.setVisibility(View.VISIBLE);
loginViewModel.login(usernameEditText.getText().toString(),
passwordEditText.getText().toString());
}
});
}
private void updateUiWithUser(LoggedInUserView model) {
String welcome = getString(R.string.welcome) + model.getDisplayName();
Toast.makeText(getApplicationContext(), welcome, Toast.LENGTH_LONG).show();
}
private void showLoginFailed(String errorString) {
Toast.makeText(getApplicationContext(), errorString, Toast.LENGTH_SHORT).show();
}
}
@@ -0,0 +1,40 @@
package tr.dhee.abox.demo.ui.login;
import androidx.annotation.Nullable;
/**
* Data validation state of the login form.
*/
class LoginFormState {
@Nullable
private Integer usernameError;
@Nullable
private Integer passwordError;
private boolean isDataValid;
LoginFormState(@Nullable Integer usernameError, @Nullable Integer passwordError) {
this.usernameError = usernameError;
this.passwordError = passwordError;
this.isDataValid = false;
}
LoginFormState(boolean isDataValid) {
this.usernameError = null;
this.passwordError = null;
this.isDataValid = isDataValid;
}
@Nullable
Integer getUsernameError() {
return usernameError;
}
@Nullable
Integer getPasswordError() {
return passwordError;
}
boolean isDataValid() {
return isDataValid;
}
}
@@ -0,0 +1,31 @@
package tr.dhee.abox.demo.ui.login;
import androidx.annotation.Nullable;
/**
* Authentication result : success (user details) or error message.
*/
class LoginResult {
@Nullable
private LoggedInUserView success;
@Nullable
private String error;
LoginResult(@Nullable String error) {
this.error = error;
}
LoginResult(@Nullable LoggedInUserView success) {
this.success = success;
}
@Nullable
LoggedInUserView getSuccess() {
return success;
}
@Nullable
String getError() {
return error;
}
}
@@ -0,0 +1,64 @@
package tr.dhee.abox.demo.ui.login;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import tr.dhee.abox.demo.R;
import tr.dhee.abox.demo.data.LoginRepository;
import tr.dhee.abox.demo.data.Result;
import tr.dhee.abox.demo.data.model.LoggedInUser;
public class LoginViewModel extends ViewModel {
private MutableLiveData<LoginFormState> loginFormState = new MutableLiveData<>();
private MutableLiveData<LoginResult> loginResult = new MutableLiveData<>();
private LoginRepository loginRepository;
LoginViewModel(LoginRepository loginRepository) {
this.loginRepository = loginRepository;
}
LiveData<LoginFormState> getLoginFormState() {
return loginFormState;
}
LiveData<LoginResult> getLoginResult() {
return loginResult;
}
public void login(String username, String password) {
// 后台线程执行 SDK 登录, 结果回到主线程再更新 LiveData
loginRepository.login(username, password, result -> {
if (result instanceof Result.Success) {
LoggedInUser data = ((Result.Success<LoggedInUser>) result).getData();
loginResult.postValue(new LoginResult(new LoggedInUserView(data.getDisplayName())));
} else {
// 取出 LoginDataSource 里封装的中文错误提示
Exception err = ((Result.Error) result).getError();
loginResult.postValue(new LoginResult(err != null ? err.getMessage() : "登录失败"));
}
});
}
public void loginDataChanged(String username, String password) {
if (!isUserNameValid(username)) {
loginFormState.setValue(new LoginFormState(R.string.invalid_username, null));
} else if (!isPasswordValid(password)) {
loginFormState.setValue(new LoginFormState(null, R.string.invalid_password));
} else {
loginFormState.setValue(new LoginFormState(true));
}
}
// 仅校验不为空
private boolean isUserNameValid(String username) {
return username != null && !username.trim().isEmpty();
}
// 仅校验不为空
private boolean isPasswordValid(String password) {
return password != null && !password.trim().isEmpty();
}
}
@@ -0,0 +1,26 @@
package tr.dhee.abox.demo.ui.login;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import androidx.annotation.NonNull;
import tr.dhee.abox.demo.data.LoginDataSource;
import tr.dhee.abox.demo.data.LoginRepository;
/**
* ViewModel provider factory to instantiate LoginViewModel.
* Required given LoginViewModel has a non-empty constructor
*/
public class LoginViewModelFactory implements ViewModelProvider.Factory {
@NonNull
@Override
@SuppressWarnings("unchecked")
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
if (modelClass.isAssignableFrom(LoginViewModel.class)) {
return (T) new LoginViewModel(LoginRepository.getInstance(new LoginDataSource()));
} else {
throw new IllegalArgumentException("Unknown ViewModel class");
}
}
}
@@ -0,0 +1,91 @@
package tr.dhee.abox.demo.ui.switchcontrol;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.lifecycle.ViewModelProvider;
import tr.dhee.abox.demo.databinding.ActivitySwitchControlBinding;
public class SwitchControlActivity extends AppCompatActivity {
private SwitchViewModel switchViewModel;
private ActivitySwitchControlBinding binding;
// 设备名称, 可根据实际需要调整
private static final String DEVICE_NAME = "智能插座";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
binding = ActivitySwitchControlBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
switchViewModel = new ViewModelProvider(this, new SwitchViewModelFactory())
.get(SwitchViewModel.class);
final Button btnOn = binding.btnOn;
final Button btnOff = binding.btnOff;
final ProgressBar loadingProgressBar = binding.loading;
final TextView statusText = binding.statusText;
// 观察控制结果
switchViewModel.getSwitchResult().observe(this, switchResult -> {
if (switchResult == null) {
return;
}
loadingProgressBar.setVisibility(View.GONE);
btnOn.setEnabled(true);
btnOff.setEnabled(true);
if (switchResult.getSuccess() != null) {
statusText.setVisibility(View.VISIBLE);
statusText.setText(switchResult.getSuccess());
statusText.setTextColor(ContextCompat.getColor(SwitchControlActivity.this,
android.R.color.holo_green_dark));
Toast.makeText(this, switchResult.getSuccess(), Toast.LENGTH_SHORT).show();
}
if (switchResult.getError() != null) {
statusText.setVisibility(View.VISIBLE);
statusText.setText(switchResult.getError());
statusText.setTextColor(ContextCompat.getColor(SwitchControlActivity.this,
android.R.color.holo_red_dark));
Toast.makeText(this, switchResult.getError(), Toast.LENGTH_SHORT).show();
}
});
// "开" 按钮
btnOn.setOnClickListener(v -> {
loadingProgressBar.setVisibility(View.VISIBLE);
btnOn.setEnabled(false);
btnOff.setEnabled(false);
switchViewModel.controlSwitch(DEVICE_NAME, "1");
});
// "关" 按钮
btnOff.setOnClickListener(v -> {
loadingProgressBar.setVisibility(View.VISIBLE);
btnOn.setEnabled(false);
btnOff.setEnabled(false);
switchViewModel.controlSwitch(DEVICE_NAME, "0");
});
}
}
@@ -0,0 +1,28 @@
package tr.dhee.abox.demo.ui.switchcontrol;
import androidx.annotation.Nullable;
/**
* Switch control result: success message or error message.
*/
class SwitchResult {
@Nullable
private String success;
@Nullable
private String error;
SwitchResult(@Nullable String success, @Nullable String error) {
this.success = success;
this.error = error;
}
@Nullable
String getSuccess() {
return success;
}
@Nullable
String getError() {
return error;
}
}
@@ -0,0 +1,41 @@
package tr.dhee.abox.demo.ui.switchcontrol;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import tr.dhee.abox.demo.data.Result;
import tr.dhee.abox.demo.data.SwitchRepository;
public class SwitchViewModel extends ViewModel {
private final MutableLiveData<SwitchResult> switchResult = new MutableLiveData<>();
private final SwitchRepository switchRepository;
SwitchViewModel(SwitchRepository switchRepository) {
this.switchRepository = switchRepository;
}
LiveData<SwitchResult> getSwitchResult() {
return switchResult;
}
/**
* 控制插座开关
*
* @param soDevName 插座设备名称
* @param status 状态: "1"(开) 或 "0"(关)
*/
public void controlSwitch(String soDevName, String status) {
switchRepository.sockCtrl(soDevName, status, result -> {
if (result instanceof Result.Success) {
String data = ((Result.Success<String>) result).getData();
switchResult.postValue(new SwitchResult(data, null));
} else {
Exception err = ((Result.Error) result).getError();
switchResult.postValue(new SwitchResult(null,
err != null ? err.getMessage() : "控制失败"));
}
});
}
}
@@ -0,0 +1,25 @@
package tr.dhee.abox.demo.ui.switchcontrol;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import androidx.annotation.NonNull;
import tr.dhee.abox.demo.data.SwitchDataSource;
import tr.dhee.abox.demo.data.SwitchRepository;
/**
* ViewModel provider factory to instantiate SwitchViewModel.
*/
public class SwitchViewModelFactory implements ViewModelProvider.Factory {
@NonNull
@Override
@SuppressWarnings("unchecked")
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
if (modelClass.isAssignableFrom(SwitchViewModel.class)) {
return (T) new SwitchViewModel(SwitchRepository.getInstance(new SwitchDataSource()));
} else {
throw new IllegalArgumentException("Unknown ViewModel class");
}
}
}
@@ -0,0 +1,71 @@
package tr.dhee.abox.demo.utils;
import java.util.HashMap;
/**
* 登录相关常量: SDK 返回码 -> 中文提示; 自动登录 SharedPreferences key。
* 移植自 base 工程的 AboxConstants(仅保留登录部分)。
*/
public class AboxConstants {
public static final HashMap<String, String> codeMap = new HashMap<String, String>();
public static final String AUTOLOGINFILE = "autoLogin";
public static class AutoLoginClass {
public static final String AUTOLOGIN_USERNAME = "userName";
public static final String AUTOLOGIN_USERPWD = "userPwd";
public static final String AUTOLOGIN_FLAG = "flag";
}
static {
codeMap.put("00000", "处理成功");
codeMap.put("00001", "数据库操作失败");
codeMap.put("10001", "TOKEN不存在或长度不足");
codeMap.put("10002", "TOKEN已失效");
codeMap.put("10003", "TOKEN中的SN号不正");
codeMap.put("10004", "签名不存在");
codeMap.put("10005", "时间戳不存在");
codeMap.put("10006", "签名不正确");
codeMap.put("20000", "请求超时");
codeMap.put("20001", "用户名或密码不存在");
codeMap.put("20002", "用户不存在或已暂停使用");
codeMap.put("20003", "登录失败次数过多");
codeMap.put("20004", "用户名或密码不正确");
codeMap.put("20005", "本地用户权限不足");
codeMap.put("20101", "服务器端URL或酒店名或房间号或APIKEY不存在");
codeMap.put("20201", "红外KEY不存在");
codeMap.put("20202", "红外码值不存在");
codeMap.put("20203", "红外设备不存在");
codeMap.put("20204", "红外码库导入失败");
codeMap.put("20205", "红外发射失败");
codeMap.put("20206", "红外设备已离线");
codeMap.put("20301", "参数不正_CMD0不存在");
codeMap.put("20302", "参数不正_CMD1不存在");
codeMap.put("20303", "参数不正_PAYLOAD不存在");
codeMap.put("20304", "窗帘设备不存在");
codeMap.put("20305", "窗帘设备控制失败");
codeMap.put("20306", "窗帘设备已离线");
codeMap.put("20501", "参数不正_插座状态不存在");
codeMap.put("20502", "插座设备不存在");
codeMap.put("20503", "插座设备控制失败");
codeMap.put("20504", "插座设备已离线");
codeMap.put("20601", "参数不正_开关状态不存在");
codeMap.put("20602", "参数不正_开关类型不存在(标识几开开关)");
codeMap.put("20603", "开关设备不存在");
codeMap.put("20604", "开关设备控制失败");
codeMap.put("20605", "开关设备已掉线");
codeMap.put("20701", "参数不正_射灯操作类型不存在");
codeMap.put("20702", "参数不正_射灯操作类型不正确");
codeMap.put("20703", "参数不正_射灯开关状态不存在");
codeMap.put("20704", "参数不正_射灯色调饱和度不存在");
codeMap.put("20705", "参数不正_射灯亮度不存在");
codeMap.put("20706", "射灯设备不存在");
codeMap.put("20707", "射灯设备控制失败");
codeMap.put("20801", "参数不正_设备名称不存在");
codeMap.put("20802", "端末设备不存在");
codeMap.put("20803", "端末状态属性不存在");
codeMap.put("20804", "参数不正_报警时长不正确");
codeMap.put("20805", "端末设备已离线");
codeMap.put("20806", "设备控制失败");
}
}
@@ -0,0 +1,12 @@
# Add project specific R8 rules here.
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
#
# For more details, see
# https://d.android.com/r/tools/r8/keep-rules
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.login.LoginActivity">
<EditText
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="96dp"
android:autofillHints="@string/prompt_email"
android:hint="@string/prompt_email"
android:inputType="textEmailAddress"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:autofillHints="@string/prompt_password"
android:hint="@string/prompt_password"
android:imeActionLabel="@string/action_sign_in_short"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/username" />
<Button
android:id="@+id/login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginTop="16dp"
android:layout_marginBottom="64dp"
android:enabled="false"
android:text="@string/action_sign_in"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password"
app:layout_constraintVertical_bias="0.2" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="64dp"
android:layout_marginBottom="64dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/password"
app:layout_constraintStart_toStartOf="@+id/password"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.3" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.login.LoginActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="840dp"
android:layout_height="match_parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<EditText
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="96dp"
android:autofillHints="@string/prompt_email"
android:hint="@string/prompt_email"
android:inputType="textEmailAddress"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:autofillHints="@string/prompt_password"
android:hint="@string/prompt_password"
android:imeActionLabel="@string/action_sign_in_short"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/username" />
<Button
android:id="@+id/login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginTop="16dp"
android:layout_marginBottom="64dp"
android:enabled="false"
android:text="@string/action_sign_in"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password"
app:layout_constraintVertical_bias="0.2" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="64dp"
android:layout_marginBottom="64dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/password"
app:layout_constraintStart_toStartOf="@+id/password"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.login.LoginActivity">
<EditText
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="96dp"
android:autofillHints="@string/prompt_email"
android:hint="@string/prompt_email"
android:inputType="textEmailAddress"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:autofillHints="@string/prompt_password"
android:hint="@string/prompt_password"
android:imeActionLabel="@string/action_sign_in_short"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/username" />
<Button
android:id="@+id/login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:enabled="false"
android:text="@string/action_sign_in"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password"
app:layout_constraintVertical_bias="0.2" />
<CheckBox
android:id="@+id/checkBoxAutoLogin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/auto_login"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/login" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="64dp"
android:layout_marginBottom="64dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/password"
app:layout_constraintStart_toStartOf="@+id/password"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.3" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.switchcontrol.SwitchControlActivity">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:text="@string/switch_control_title"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn_on"
android:layout_width="160dp"
android:layout_height="120dp"
android:layout_marginTop="64dp"
android:text="@string/switch_on"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/guideline_center"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title" />
<Button
android:id="@+id/btn_off"
android:layout_width="160dp"
android:layout_height="120dp"
android:text="@string/switch_off"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/guideline_center"
app:layout_constraintTop_toTopOf="@+id/btn_on" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btn_on" />
<TextView
android:id="@+id/status_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:gravity="center"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loading" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,3 @@
<resources>
<dimen name="activity_horizontal_margin">48dp</dimen>
</resources>
@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Aboxtest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,3 @@
<resources>
<dimen name="activity_horizontal_margin">200dp</dimen>
</resources>
@@ -0,0 +1,3 @@
<resources>
<dimen name="activity_horizontal_margin">48dp</dimen>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
</resources>
@@ -0,0 +1,16 @@
<resources>
<string name="app_name">login</string>
<!-- Strings related to login -->
<string name="prompt_email">Email</string>
<string name="prompt_password">Password</string>
<string name="action_sign_in">Sign in or register</string>
<string name="action_sign_in_short">Sign in</string>
<string name="welcome">"Welcome !"</string>
<string name="invalid_username">用户名不能为空</string>
<string name="invalid_password">密码不能为空</string>
<string name="login_failed">"Login failed"</string>
<string name="auto_login">记住密码</string>
<string name="switch_control_title">插座控制</string>
<string name="switch_on"></string>
<string name="switch_off"></string>
</resources>
@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Aboxtest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,17 @@
package tr.dhee.abox.demo;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

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