chore: 初始化 iotsaas 版本库基线

纳入范围:
- backend/  Spring Boot 多模块后端(iot-common/device/rule/data/alarm/auth)
- frontend/admin/  Vue3 + Element Plus 管理后台
- Android/   涂鸦原生 APP (com.xhai.iot)
- backend/sql/  Flyway 风格迁移脚本(V2~V21)
- docs/       对接文档与设计文档

排除范围:
- 构建产物: node_modules/ target/ build/ dist/ .gradle/
- 签名密钥: *.jks *.keystore (严禁入库)
- 本机配置: Android/local.properties
- 运行日志: logs/ *.log
- AI 协作本地数据 .codebuddy/ 与第三方 demo tuya-demo/

.gitattributes 统一以 LF 存储, Windows 批处理保留 CRLF
This commit is contained in:
2995840376 2026-10-27 12:00:41 +08:00
commit 0ce191c448
2250 changed files with 659695 additions and 0 deletions

41
.gitattributes vendored Normal file
View File

@ -0,0 +1,41 @@
# ============================================================
# iotsaas · 换行符与二进制属性
# 统一以 LF 存储,避免 Windows/Linux 跨端 diff 噪声
# ============================================================
* text=auto eol=lf
# Windows 批处理脚本需 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Shell 脚本需 LF
*.sh text eol=lf
# 二进制文件,禁止行尾转换
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.jar binary
*.aar binary
*.apk binary
*.aab binary
*.dex binary
*.jks binary
*.keystore binary
*.so binary
*.zip binary
*.gz binary
*.pdf binary
*.docx binary
*.xlsx binary
*.pptx binary
*.woff binary
*.woff2 binary
*.ttf binary
*.mp3 binary
*.mp4 binary

75
.gitignore vendored Normal file
View File

@ -0,0 +1,75 @@
# ============================================================
# iotsaas · IoT SaaS 平台
# ============================================================
# ---- AI 协作本地数据(仅本机,不纳入版本管理)----
.codebuddy/
# ---- 第三方 demo(自带独立 git 仓库,保留其原历史,不并入本仓)----
tuya-demo/
# ---- 构建产物 ----
**/node_modules/
**/target/
**/build/
**/dist/
**/out/
**/.gradle/
**/.cxx/
**/coverage/
# ---- Android ----
# 本机 SDK 绝对路径,不可移植
Android/local.properties
# 反编译/扫描临时产物
Android/tmp_scan*/
# 应用签名密钥:严禁入库
*.jks
*.keystore
*.p12
*.pfx
# 打包产物
*.apk
*.aab
*.dex
# ---- 后端 ----
# 构建出的 jar;保留 Gradle Wrapper 需要的 jar
*.jar
!**/gradle/wrapper/gradle-wrapper.jar
# 运行期目录
backend/**/logs/
xxl-job-logs/
nohup.out
# ---- 日志与运行时 ----
logs/
*.log
*.log.*
# ---- IDE ----
.idea/
*.iml
*.ipr
*.iws
.classpath
.project
.settings/
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
# ---- OS ----
.DS_Store
Thumbs.db
desktop.ini
ehthumbs.db
# ---- 临时产物 ----
# uiautomator dump / 截图 等调试残留
ui*.xml
screencap*.png
$null
*.tmp
*.bak
*.orig

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic"
}

102
Android/README.md Normal file
View File

@ -0,0 +1,102 @@
# XHai 智能物联 - Android 客户端
基于涂鸦 **Thing Smart Home SDK 7.5.0** 原生开发的 Android 客户端。
实现:**用户登录(iotsaas 后端)+ 涂鸦账号绑定/授权**。
---
## 一、功能范围(当前阶段)
| 功能 | 状态 | 说明 |
|------|------|------|
| 用户登录 | ✅ | 对接 iotsaas 后端 `POST /api/v1/auth/member/login` |
| 用户注册 | ✅ | 对接 `POST /api/v1/auth/member/register` |
| 涂鸦账号绑定 | ✅ | 涂鸦原生 SDK 登录,绑定后同步 `tuya_user_binding` |
| 涂鸦账号解绑 | ✅ | 对接 `POST /api/v1/tuya/auth/unbind` |
| 绑定状态查询 | ✅ | 对接 `GET /api/v1/tuya/auth/status` |
| 设备管理/控制 | ⏳ | 走通绑定后再开发 |
---
## 二、环境要求
- **Android Studio**(Hedgehog 或更新版本)
- **JDK 17**(涂鸦 SDK 需要)
- **Android SDK**(API 34)
- **NDK**(`arm64-v8a`、`armeabi-v7a`)
---
## 三、涂鸦平台配置(必须完成)
> ⚠️ **关键**:涂鸦 SDK 初始化要求 `AppKey`、`AppSecret`、**包名**、**签名 keystore 的 SHA256** 四项全部与涂鸦平台登记一致,否则报 `illegal client`。
### 1. 确认 App 授权信息
登录 [涂鸦开发者平台](https://developer.tuya.com/cn/) → 云开发 → 你的项目 → **App 授权**:
- **AppKey**(Client ID):`wuvw788psccvxndpyxnv`
- **AppSecret**(Client Secret):需确认
- **包名**:`com.xhai.iot`
### 2. 配置签名证书 SHA256
涂鸦 SDK 校验应用签名。必须把**打包用 keystore 的 SHA256** 配置到涂鸦平台对应 App 的"Android 签名证书"里。
本项目已生成开发用 keystore:`app/xhai_iot.jks`(密码 `xhai123456`,alias `xhai`)
- **SHA256**:`F0:D1:85:41:CD:D2:21:E2:84:77:FF:E1:A4:CF:F7:19:41:D4:8A:BD:B7:77:83:F2:C0:DB:CE:FB:69:25:F6:13`
- **SHA1**(涂鸦 identifier 用):`0C44B4A0C696BC823B5BEFD52B16CFBF7D169B78`
> ⚠️ **注意**:涂鸦平台"App授权"里的 identifier(SHA1+包名)必须与打包签名一致。若涂鸦后台已配置了其他 keystore 的 SHA1,请使用那个 keystore 替换 `app/xhai_iot.jks`,或在涂鸦后台更新 SHA1。
---
## 四、构建步骤
### 方式一:Android Studio(推荐)
1. 用 Android Studio 打开 `f:/work/iotsaas/Android` 文件夹
2. Android Studio 会自动下载 Gradle、AGP、涂鸦 SDK 依赖(需网络)
3. 等待首次 Gradle Sync 完成
4. 连接手机,点击 Run
### 方式二:命令行
```bash
cd Android
gradle assembleDebug
```
---
## 五、关键配置文件
| 文件 | 作用 |
|------|------|
| `app/build.gradle` | 依赖、签名、ABI 配置 |
| `app/src/main/AndroidManifest.xml` | AppKey/AppSecret、Activity 注册 |
| `app/src/main/java/.../data/ApiConfig.java` | 后端地址、涂鸦密钥 |
| `app/src/main/java/.../TuyaApplication.java` | 涂鸦 SDK 初始化 |
| `app/src/main/java/.../ui/TuyaBindActivity.java` | 涂鸦登录+绑定 |
---
## 六、API 对接(iotsaas 后端)
| 后端接口 | 方法 | 作用 |
|---------|------|------|
| `/api/v1/auth/member/login` | POST | 登录 |
| `/api/v1/auth/member/register` | POST | 注册 |
| `/api/v1/tuya/auth/status` | GET | 涂鸦绑定状态 |
| `/api/v1/tuya/auth/bind-native` | POST | 绑定同步 |
| `/api/v1/tuya/auth/unbind` | POST | 解绑 |
> 所有涂鸦接口需要 `Authorization: Bearer <token>` 头。
---
## 七、常见问题
### Q1: 启动报 `illegal client`
检查 AppKey/AppSecret/包名/keystore SHA256 四项与涂鸦平台是否一致。
### Q2: 绑定同步失败
检查后端是否已登录(token 有效)、`tuya_user_binding` 是否写入。
### Q3: 涂鸦登录失败
确认涂鸦账号存在、网络正常、SDK 已初始化成功。

168
Android/app/build.gradle Normal file
View File

@ -0,0 +1,168 @@
plugins {
id 'com.android.application'
id 'com.google.dagger.hilt.android'
}
android {
namespace 'com.xhai.iot'
compileSdk 34
defaultConfig {
applicationId "com.xhai.iot"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// 多包名支持(涂鸦SDK需要)
multiDexEnabled true
// 配置 ABI(涂鸦SDK依赖较多,按需裁剪)
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a'
}
}
// Java 8 支持
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
// 签名配置(必须与涂鸦平台登记的 keystore SHA256 一致)
signingConfigs {
release {
storeFile file("xhai_iot.jks")
storePassword "xhai123456"
keyAlias "xhai"
keyPassword "xhai123456"
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
minifyEnabled false
signingConfig signingConfigs.release
}
}
// 全局排除:涂鸦摄像头/RTSP 模块(面板 RN 容器会注册这些包,但
// thingsmart-mediaplayer:4.5.0-rc.1 缺 MediaPlayerListener 类导致
// create_react_context 崩溃。我们不需要摄像头功能,直接排除。)
configurations.all {
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctrtspmediamanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctrtspmediaplayermanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameramanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameraviewmanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameraaudiomanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameramessagemanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameramessagemediaplayermanager'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameraplayer'
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameratimelineviewmanager'
}
// 解决涂鸦SDK中 assets/res 合并冲突
packagingOptions {
// 涂鸦SDK so 库冲突处理
pickFirst 'lib/*/libc++_shared.so'
pickFirst 'lib/*/libyuv.so'
pickFirst 'lib/*/libopenh264.so'
pickFirst 'lib/*/libgnustl_shared.so'
pickFirst 'lib/*/libv8android.so'
pickFirst 'lib/*/libv8wrapper.so'
pickFirst 'lib/*/liblog.so'
pickFirst 'lib/*/libbytehook.so'
pickFirst 'lib/*/libxdl.so'
pickFirst 'lib/*/libsqlcipher.so'
resources {
excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt',
'META-INF/license.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt',
'META-INF/notice.txt', 'META-INF/ASL2.0', 'META-INF/INDEX.LIST',
'META-INF/versions/**']
}
}
lint {
abortOnError false
}
}
dependencies {
// 本地 AAR 依赖
implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'])
// AndroidX
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
// 网络请求(对接 iotsaas 后端)
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Gson
implementation 'com.google.code.gson:gson:2.10.1'
// 图片加载(用于加载涂鸦设备图标)
implementation 'com.github.bumptech.glide:glide:4.16.0'
// 二维码扫码(中控屏会员自助绑定 P4):自带 CaptureActivity,无需声明额外权限/Activity
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// 涂鸦 SDK 依赖(升级至 7.8.0 + UI BizBundle)
implementation 'com.alibaba:fastjson:1.1.67.android'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.14.9'
// 涂鸦主 SDK 7.8.0
implementation('com.thingclips.smart:thingsmart:7.8.0') {
// 排除幽灵 SNAPSHOT 组件(编译期注解,运行时不需要)
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
}
// 涂鸦 UI BizBundle(设备控制面板 + 家庭服务)
// BOM 统一管理各 BizBundle 版本
api enforcedPlatform('com.thingclips.smart:thingsmart-BizBundlesBom:7.8.14')
// 设备控制业务包(RN 容器,原生面板核心)
implementation 'com.thingclips.smart:thingsmart-bizbundle-panel'
// 基础扩展能力
implementation('com.thingclips.smart:thingsmart-bizbundle-basekit') {
exclude group: 'com.thingclips.smart', module: 'thingplugin-annotation'
}
// 涂鸦业务扩展能力
implementation 'com.thingclips.smart:thingsmart-bizbundle-bizkit'
// 设备控制相关能力
implementation 'com.thingclips.smart:thingsmart-bizbundle-devicekit'
// 家庭服务(面板必需)
implementation 'com.thingclips.smart:thingsmart-bizbundle-family'
// 小程序业务包(提供 AbsMiniAppConfigInjectService 注入服务,
// 面板 RN 容器依赖 MiniAppConfig,缺失会导致 ThingRCTSmartPanelActivity.onCreate NPE 崩溃)
implementation 'com.thingclips.smart:thingsmart-bizbundle-miniapp'
// 涂鸦扩展 SDK(提供配网/激活能力)
implementation('com.thingclips.smart:thingsmart-expansion-sdk:7.8.0') {
exclude group: 'com.thingclips.android.module', module: 'thingmodule-annotation'
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
}
// 涂鸦 Matter 配网能力(设备委任 API:CommissioningParameters / MatterActivatorCallback 等)
// 主 thingsmart aar 不含这些类,需显式引入 matterlib-api(编译期类型)与 matterlib(运行期实现)
implementation 'com.thingclips.smart:thingsmart-matterlib-api:7.8.0'
implementation 'com.thingclips.smart:thingsmart-matterlib:7.8.0'
// Hilt(BizBundle 依赖注入)
implementation 'com.google.dagger:hilt-android:2.58'
annotationProcessor 'com.google.dagger:hilt-android-compiler:2.58'
// 多Dex
implementation 'androidx.multidex:multidex:2.0.1'
}

View File

@ -0,0 +1,118 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.xhai.iot'
compileSdk 34
defaultConfig {
applicationId "com.xhai.iot"
minSdk 23
targetSdk 34
versionCode 1
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// 多包名支持(涂鸦SDK需要)
multiDexEnabled true
// 配置 ABI(涂鸦SDK依赖较多,按需裁剪)
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a'
}
}
// Java 8 支持
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
// 签名配置(必须与涂鸦平台登记的 keystore SHA256 一致)
signingConfigs {
release {
storeFile file("xhai_iot.jks")
storePassword "xhai123456"
keyAlias "xhai"
keyPassword "xhai123456"
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
minifyEnabled false
signingConfig signingConfigs.release
}
}
// 解决涂鸦SDK中 assets/res 合并冲突
packagingOptions {
// 涂鸦SDK so 库冲突处理
pickFirst 'lib/*/libc++_shared.so'
pickFirst 'lib/*/libyuv.so'
pickFirst 'lib/*/libopenh264.so'
resources {
excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt',
'META-INF/license.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt',
'META-INF/notice.txt', 'META-INF/ASL2.0']
}
}
lint {
abortOnError false
}
}
dependencies {
// 本地 AAR 依赖
implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'])
// AndroidX
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
// 网络请求(对接 iotsaas 后端)
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Gson
implementation 'com.google.code.gson:gson:2.10.1'
// 图片加载(用于加载涂鸦设备图标)
implementation 'com.github.bumptech.glide:glide:4.16.0'
// 涂鸦 SDK 依赖(来自 SDK 包 dependencies.txt)
implementation 'com.alibaba:fastjson:1.1.67.android'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.14.9'
implementation('com.thingclips.smart:thingsmart:6.11.1') {
// 排除幽灵 SNAPSHOT 组件(编译期注解,运行时不需要)
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
// 注意:不能排除 thingsmart-asynclib!
// 6.11.1 的 ThingHomeListCache 运行时需要 com.thingclips.smart.asynclib.schedulers.ThreadEnv
// 排除会导致点击"我的设备"时 NoClassDefFoundError 闪退
}
// 注意:涂鸦 panel SDK (thingsmart-panel:6.11.1) 在公开 Maven 仓库不可用,
// 需从涂鸦开发者平台私有仓库下载后本地集成。
// 当前通过反射调用 gotoPanelActivity,SDK 可用时自动生效,无需改代码。
// compileOnly 'com.thingclips.smart:thingsmart-panel:6.11.1' // 待私有仓库集成后启用
// 涂鸦扩展 SDK(提供配网/激活能力 ThingActivatorCoreKit)
// 排除编译期注解 SNAPSHOT 组件(运行时不需要)
implementation('com.thingclips.smart:thingsmart-expansion-sdk:6.11.1') {
exclude group: 'com.thingclips.android.module', module: 'thingmodule-annotation'
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
}
// 多Dex
implementation 'androidx.multidex:multidex:2.0.1'
}

Binary file not shown.

18
Android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,18 @@
# 涂鸦 SDK 混淆规则
-keep class com.thingclips.** { *; }
-keep class com.tuya.** { *; }
-keep class com.thingclips.smart.** { *; }
-dontwarn com.thingclips.**
-dontwarn com.tuya.**
# fastjson
-keep class com.alibaba.fastjson.** { *; }
-dontwarn com.alibaba.fastjson.**
# okhttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Gson
-keep class com.google.gson.** { *; }
-keep class com.xhai.iot.model.** { *; }

View File

@ -0,0 +1,192 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- Android 12+ 蓝牙扫描/连接权限(配网必需) -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 蓝牙 Mesh 本地通道需要发送广播唤醒设备,缺失会导致 Mesh 设备无法识别在线 -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- 移除涂鸦SDK的桌面快捷方式权限:禁止 SDK 自行创建桌面快捷方式 -->
<uses-permission
android:name="com.android.launcher.permission.INSTALL_SHORTCUT"
tools:node="remove" />
<!-- 涂鸦SDK所需 -->
<uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" />
<!-- Android 11+ 包可见性:AI 助手语音输入需要发现系统语音识别服务,否则 SpeechRecognizer 不可用 -->
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application
android:name=".TuyaApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.XhaiIoT"
android:usesCleartextTraffic="true"
tools:replace="android:allowBackup,android:supportsRtl,android:theme"
tools:targetApi="31">
<!-- 涂鸦 SDK AppKey/AppSecret 配置(涂鸦SDK自动读取) -->
<meta-data
android:name="tuya_sdk_appkey"
android:value="cex9ntw3dsr3tywkrggp" />
<meta-data
android:name="tuya_sdk_appsecret"
android:value="9cqqk43gksxdkueppjsexnvjttd5evaf" />
<!-- 涂鸦 UI BizBundle 所需配置 -->
<meta-data
android:name="THING_SMART_APPKEY"
android:value="cex9ntw3dsr3tywkrggp" />
<meta-data
android:name="THING_SMART_SECRET"
android:value="9cqqk43gksxdkueppjsexnvjttd5evaf" />
<meta-data
android:name="PACKAGE_NAME"
android:value="${applicationId}" />
<meta-data
android:name="APP_SCHEME_NAME"
android:value="xhaiiot" />
<!-- 登录页 -->
<activity
android:name=".ui.LoginActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 注册页 -->
<activity
android:name=".ui.RegisterActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 涂鸦绑定页 -->
<activity
android:name=".ui.TuyaBindActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 主界面 -->
<activity
android:name=".ui.MainActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 设备列表 -->
<activity
android:name=".ui.DeviceActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 操控动态列表页 -->
<activity
android:name=".ui.DeviceActivityActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 设备控制 -->
<activity
android:name=".ui.DeviceControlActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 网关面板(展示子设备列表,网关与子设备关系由我们后端维护) -->
<activity
android:name=".ui.GatewayPanelActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 添加子设备 -->
<activity
android:name=".ui.AddSubDeviceActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 场景详情 -->
<activity
android:name=".ui.SceneDetailActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 添加设备(配网) -->
<activity
android:name=".ui.AddDeviceActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 家庭管理 -->
<activity
android:name=".ui.HomeManagerActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 创建场景 -->
<activity
android:name=".ui.SceneActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 房间管理 -->
<activity
android:name=".ui.RoomManagerActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 设备管理 -->
<activity
android:name=".ui.DeviceManagerActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- HomeAI 助手 -->
<activity
android:name=".ui.AiAssistantActivity"
android:exported="false"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" />
<!-- 我的中控屏(会员扫码自助绑定) -->
<activity
android:name=".ui.PanelListActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 扫码页(自定义,带关闭按钮,继承应用 DayNight 主题) -->
<activity
android:name=".ui.ZxingScanActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.XhaiIoT" />
</application>
</manifest>

Binary file not shown.

View File

@ -0,0 +1,374 @@
{
"my":[
{
"type" :"avatar"
},
{
"type" :"empty",
"height" :16
},
{
"type" :"thirdService"
},
{
"type" :"empty",
"height" :16
},
{
"type" :"family"
},
{
"type" :"empty",
"height" : 16
},
{
"type": "deviceList"
},
{
"type" :"outdoorOfflineStore"
},
{
"type" :"message"
},
{
"type" :"healthCenter"
},
{
"type" :"help"
},
{
"type" :"moreService"
},
{
"type" :"mall"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"setting"
},
{
"type" :"empty",
"height" : 16,
"bindTypes": ["exit"]
},
{
"type" :"exit"
}
],
"account":[
{
"type" :"empty",
"height" : 16
},
{
"type" :"avatar"
},
{
"type" :"nick"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"timeZone"
}
],
"accountSecurity":[
{
"type" :"empty",
"height" : 16
},
{
"type" :"region"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"phoneNumber"
},
{
"type": "bindPhone"
},
{
"type" :"email"
},
{
"type" :"bindEmail"
},
{
"type" :"thirdAccount"
},
{
"type" :"cloudProject"
},
{
"type" :"manageAuthorization"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"password"
},
{
"type": "fingerLogin"
},
{
"type" :"gestureSwitch"
},
{
"type" :"gestureChange"
},
{
"type" :"gestureSetting"
},
{
"type" :"loginVertify"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"close"
}
],
"setting":[
{
"type" :"empty",
"height" : 16
},
{
"type" :"account"
},
{
"type":"security"
},
{
"type": "deviceUpgrades"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"sound"
},
{
"type" :"push"
},
{
"type": "scan"
},
{
"type": "active"
},
{
"type" :"darkColorMode"
},
{
"type" :"unit"
},
{
"type" : "pad"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"about"
},
{
"type" :"privacySetting"
},
{
"type" :"privacyPolicySetting"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"network"
},
{
"type" :"cache"
},
{
"type" :"language-debug"
},
{
"type": "empty",
"bindTypes": ["language"],
"height": 16
},
{
"type": "language"
},
{
"type" :"empty",
"height" : 26
},
{
"type" :"logout"
}
],
"about":[
{
"type" :"empty",
"height" : 16
},
{
"type" :"rateUs"
},
{
"type" :"aboutUs"
},
{
"type" :"source"
},
{
"type" :"uploadLog"
},
{
"type" :"currentVersion"
},
{
"type" :"checkVersion"
},
{
"type" :"testflight"
},
{
"type" :"environment"
},
{
"type" :"empty",
"height" : 16
},
{
"type" :"consumerHotline"
}
],
"deviceDetail":[
{
"type":"header"
},
{
"type": "device_info"
},
{
"type": "device_net_info"
},
{
"type": "connect_cloud_activation"
},
{
"type":"group_edit_devices"
},
{
"type": "cellular_management"
},
{
"type":"scene_and_automation"
},
{
"type":"device_evaluation"
},
{
"type":"recommend_products"
},
{
"type":"section_third-party_control"
},
{
"type":"third-party_control"
},
{
"type":"section_off_line_warn"
},
{
"type":"off_line_warn"
},
{
"type":"section_other"
},
{
"type":"device_from"
},
{
"type":"bind_multi_control_link"
},
{
"type":"sync_control"
},
{
"type":"panel_share_group_or_device"
},
{
"type":"group_create"
},
{
"type":"link_device"
},
{
"type":"location_access_authorization"
},
{
"type":"product_instruction"
},
{
"type":"help_and_feedback"
},
{
"type":"add_icon_to_home_screen"
},
{
"type":"show_infrared_gateway_sub_device"
},
{
"type":"device_add_to_family"
},
{
"type":"check_device_network"
},
{
"type":"accessories_manager"
},
{
"type":"check_firmware_update"
},
{
"type":"sub_device_migrate"
},
{
"type":"empty",
"height":10
},
{
"type":"footer"
}
],
"devBaseInfo": [
{
"type": "dev_icon"
},
{
"type": "set_icon"
},
{
"type": "set_name"
},
{
"type": "set_position"
}
]
}

View File

@ -0,0 +1,541 @@
{
"id": "3757359",
"name": "default_iconfont",
"font_family": "default_iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "35597351",
"name": "navbar_security_IC3_N1",
"font_class": "navbar_security_IC3_N1",
"unicode": "e9b6",
"unicode_decimal": 59830
},
{
"icon_id": "32775867",
"name": "home_card_on_IC1_N6",
"font_class": "home_card_on_IC1_N6",
"unicode": "e97a",
"unicode_decimal": 59770
},
{
"icon_id": "32775868",
"name": "smart_card_off_IC5_N6",
"font_class": "smart_card_off_IC5_N6",
"unicode": "e97d",
"unicode_decimal": 59773
},
{
"icon_id": "32775869",
"name": "smart_card_on_IC5_M3",
"font_class": "smart_card_on_IC5_M3",
"unicode": "e98c",
"unicode_decimal": 59788
},
{
"icon_id": "32775870",
"name": "home_card_off_IC1_N6",
"font_class": "home_card_off_IC1_N6",
"unicode": "e992",
"unicode_decimal": 59794
},
{
"icon_id": "32775871",
"name": "home_card_bluetooth_IC5_N6",
"font_class": "home_card_bluetooth_IC5_N6",
"unicode": "e9a0",
"unicode_decimal": 59808
},
{
"icon_id": "32750453",
"name": "me_cell_online_customer_service",
"font_class": "me_cell_online_customer_service",
"unicode": "e9a4",
"unicode_decimal": 59812
},
{
"icon_id": "32750105",
"name": "home_card_switch_on_IC5_M3",
"font_class": "home_card_switch_on_IC5_M3",
"unicode": "e99c",
"unicode_decimal": 59804
},
{
"icon_id": "32750106",
"name": "smart_card_add_IC4_N1",
"font_class": "smart_card_add_IC4_N1",
"unicode": "e99d",
"unicode_decimal": 59805
},
{
"icon_id": "32750107",
"name": "smart_card_arrow_right_IC3_N4",
"font_class": "smart_card_arrow_right_IC3_N4",
"unicode": "e99e",
"unicode_decimal": 59806
},
{
"icon_id": "32750108",
"name": "popover_management_IC3_N1",
"font_class": "popover_management_IC3_N1",
"unicode": "e99f",
"unicode_decimal": 59807
},
{
"icon_id": "32750110",
"name": "navbar_scanning_IC3_N1",
"font_class": "navbar_scanning_IC3_N1",
"unicode": "e9a1",
"unicode_decimal": 59809
},
{
"icon_id": "32750111",
"name": "segement_more_IC3_N3",
"font_class": "segement_more_IC3_N3",
"unicode": "e9a2",
"unicode_decimal": 59810
},
{
"icon_id": "32742211",
"name": "switch_new",
"font_class": "switch_new",
"unicode": "e99b",
"unicode_decimal": 59803
},
{
"icon_id": "32734381",
"name": "addDevice_switch_IC3_N6",
"font_class": "addDevice_switch_IC3_N6",
"unicode": "e957",
"unicode_decimal": 59735
},
{
"icon_id": "32734382",
"name": "card_edit_IC3_N3",
"font_class": "card_edit_IC3_N3",
"unicode": "e958",
"unicode_decimal": 59736
},
{
"icon_id": "32734383",
"name": "addDevice_bluetooth_IC3_N6",
"font_class": "addDevice_bluetooth_IC3_N6",
"unicode": "e959",
"unicode_decimal": 59737
},
{
"icon_id": "32734384",
"name": "addDevice_wifi_IC3_N6",
"font_class": "addDevice_wifi_IC3_N6",
"unicode": "e95a",
"unicode_decimal": 59738
},
{
"icon_id": "32734385",
"name": "addDevice_arrow_IC3_N6",
"font_class": "addDevice_arrow_IC3_N6",
"unicode": "e95b",
"unicode_decimal": 59739
},
{
"icon_id": "32734386",
"name": "common_remove_IC3_M2",
"font_class": "common_remove_IC3_M2",
"unicode": "e95c",
"unicode_decimal": 59740
},
{
"icon_id": "32734387",
"name": "card_notselected_IC3_N6",
"font_class": "card_notselected_IC3_N6",
"unicode": "e95d",
"unicode_decimal": 59741
},
{
"icon_id": "32734388",
"name": "home_card_arrow_up_IC3_N4",
"font_class": "home_card_arrow_up_IC3_N4",
"unicode": "e95e",
"unicode_decimal": 59742
},
{
"icon_id": "32734389",
"name": "home_card_line_IC1_N6",
"font_class": "home_card_line_IC1_N6",
"unicode": "e95f",
"unicode_decimal": 59743
},
{
"icon_id": "32734390",
"name": "device_popup_note_s_IC1_M2",
"font_class": "device_popup_note_s_IC1_M2",
"unicode": "e960",
"unicode_decimal": 59744
},
{
"icon_id": "32734391",
"name": "home_card_exclamation",
"font_class": "home_card_exclamation",
"unicode": "e961",
"unicode_decimal": 59745
},
{
"icon_id": "32734392",
"name": "home_card_down_s_IC1_N6",
"font_class": "home_card_down_s_IC1_N6",
"unicode": "e962",
"unicode_decimal": 59746
},
{
"icon_id": "32734393",
"name": "common_add_IC3_M3",
"font_class": "common_add_IC3_M3",
"unicode": "e963",
"unicode_decimal": 59747
},
{
"icon_id": "32734394",
"name": "home_card_group_IC1_N6",
"font_class": "home_card_group_IC1_N6",
"unicode": "e964",
"unicode_decimal": 59748
},
{
"icon_id": "32734395",
"name": "card_select_IC3_M1",
"font_class": "card_select_IC3_M1",
"unicode": "e965",
"unicode_decimal": 59749
},
{
"icon_id": "32734397",
"name": "device_popup_bluetooth_IC4_N4",
"font_class": "device_popup_bluetooth_IC4_N4",
"unicode": "e967",
"unicode_decimal": 59751
},
{
"icon_id": "32734398",
"name": "home_topbar_add_IC4_M1",
"font_class": "home_topbar_add_IC4_M1",
"unicode": "e968",
"unicode_decimal": 59752
},
{
"icon_id": "32734399",
"name": "home_card_switch_off_IC5_N6",
"font_class": "home_card_switch_off_IC5_N6",
"unicode": "e969",
"unicode_decimal": 59753
},
{
"icon_id": "32734400",
"name": "light_segment_manage",
"font_class": "light_segment_manage",
"unicode": "e96a",
"unicode_decimal": 59754
},
{
"icon_id": "32734401",
"name": "home_weather_arrow_IC1_N6",
"font_class": "home_weather_arrow_IC1_N6",
"unicode": "e96b",
"unicode_decimal": 59755
},
{
"icon_id": "32734402",
"name": "mine_cell_FAQ_IC3_S6",
"font_class": "mine_cell_FAQ_IC3_S6",
"unicode": "e96c",
"unicode_decimal": 59756
},
{
"icon_id": "32734403",
"name": "input_arrow_down_IC3_N4",
"font_class": "input_arrow_down_IC3_N4",
"unicode": "e96d",
"unicode_decimal": 59757
},
{
"icon_id": "32734404",
"name": "home_topbar_voice_IC3_N2",
"font_class": "home_topbar_voice_IC3_N2",
"unicode": "e96e",
"unicode_decimal": 59758
},
{
"icon_id": "32734405",
"name": "home_topbar_gateway_IC3_N2",
"font_class": "home_topbar_gateway_IC3_N2",
"unicode": "e96f",
"unicode_decimal": 59759
},
{
"icon_id": "32734406",
"name": "mine_cell_arrow_right_IC3_N4",
"font_class": "mine_cell_arrow_right_IC3_N4",
"unicode": "e970",
"unicode_decimal": 59760
},
{
"icon_id": "32734407",
"name": "home_btn_delete_IC2_N6",
"font_class": "home_btn_delete_IC2_N6",
"unicode": "e971",
"unicode_decimal": 59761
},
{
"icon_id": "32734408",
"name": "home_topbar_energy_IC3_N2",
"font_class": "home_topbar_energy_IC3_N2",
"unicode": "e972",
"unicode_decimal": 59762
},
{
"icon_id": "32734409",
"name": "mine_cell_home_IC3_S11",
"font_class": "mine_cell_home_IC3_S11",
"unicode": "e973",
"unicode_decimal": 59763
},
{
"icon_id": "32734410",
"name": "home_topbar_ipc_IC3_N2",
"font_class": "home_topbar_ipc_IC3_N2",
"unicode": "e974",
"unicode_decimal": 59764
},
{
"icon_id": "32734411",
"name": "mine_cell_features_IC3_S5",
"font_class": "mine_cell_features_IC3_S5",
"unicode": "e975",
"unicode_decimal": 59765
},
{
"icon_id": "32734412",
"name": "home_card_arrow_down_IC3_N4",
"font_class": "home_card_arrow_down_IC3_N4",
"unicode": "e976",
"unicode_decimal": 59766
},
{
"icon_id": "32734413",
"name": "me_cell_device",
"font_class": "me_cell_device",
"unicode": "e977",
"unicode_decimal": 59767
},
{
"icon_id": "32734414",
"name": "mine_cell_iwatch_IC3_S12",
"font_class": "mine_cell_iwatch_IC3_S12",
"unicode": "e978",
"unicode_decimal": 59768
},
{
"icon_id": "32734415",
"name": "smart_card_attention_IC4_N3",
"font_class": "smart_card_attention_IC4_N3",
"unicode": "e979",
"unicode_decimal": 59769
},
{
"icon_id": "32734417",
"name": "smart_card_loading1_IC3_N4",
"font_class": "smart_card_loading1_IC3_N4",
"unicode": "e97b",
"unicode_decimal": 59771
},
{
"icon_id": "32734419",
"name": "home_topbar_security_IC3_N2",
"font_class": "home_topbar_security_IC3_N2",
"unicode": "e97c",
"unicode_decimal": 59772
},
{
"icon_id": "32734421",
"name": "smart_topbar_add_IC4_N1",
"font_class": "smart_topbar_add_IC4_N1",
"unicode": "e97e",
"unicode_decimal": 59774
},
{
"icon_id": "32734422",
"name": "smart_card_arrow_IC5_N6",
"font_class": "smart_card_arrow_IC5_N6",
"unicode": "e97f",
"unicode_decimal": 59775
},
{
"icon_id": "32734423",
"name": "popover_list_IC3_N1",
"font_class": "popover_list_IC3_N1",
"unicode": "e980",
"unicode_decimal": 59776
},
{
"icon_id": "32734424",
"name": "popover_grid_IC3_N1",
"font_class": "popover_grid_IC3_N1",
"unicode": "e981",
"unicode_decimal": 59777
},
{
"icon_id": "32734425",
"name": "popover_add_device_IC3_N1",
"font_class": "popover_add_device_IC3_N1",
"unicode": "e982",
"unicode_decimal": 59778
},
{
"icon_id": "32734426",
"name": "popover_log_IC3_N1",
"font_class": "popover_log_IC3_N1",
"unicode": "e983",
"unicode_decimal": 59779
},
{
"icon_id": "32734427",
"name": "mine_cell_message_IC3_S1",
"font_class": "mine_cell_message_IC3_S1",
"unicode": "e984",
"unicode_decimal": 59780
},
{
"icon_id": "32734428",
"name": "navbar_arrow_left_IC3_N1",
"font_class": "navbar_arrow_left_IC3_N1",
"unicode": "e985",
"unicode_decimal": 59781
},
{
"icon_id": "32734429",
"name": "topbar_arrow_down_IC2_N4-1",
"font_class": "topbar_arrow_down_IC2_N4-1",
"unicode": "e986",
"unicode_decimal": 59782
},
{
"icon_id": "32734430",
"name": "navbar_management_IC3_N1",
"font_class": "navbar_management_IC3_N1",
"unicode": "e987",
"unicode_decimal": 59783
},
{
"icon_id": "32734431",
"name": "popup_arrow_up_IC3_N4",
"font_class": "popup_arrow_up_IC3_N4",
"unicode": "e988",
"unicode_decimal": 59784
},
{
"icon_id": "32734432",
"name": "navbar_scanning_IC3_N1-1",
"font_class": "navbar_scanning_IC3_N1-1",
"unicode": "e989",
"unicode_decimal": 59785
},
{
"icon_id": "32734433",
"name": "popover_room_IC3_N1",
"font_class": "popover_room_IC3_N1",
"unicode": "e98a",
"unicode_decimal": 59786
},
{
"icon_id": "32734434",
"name": "popup_arrow_down_IC3_N4",
"font_class": "popup_arrow_down_IC3_N4",
"unicode": "e98b",
"unicode_decimal": 59787
},
{
"icon_id": "32734436",
"name": "toolbar_top_IC3_M4",
"font_class": "toolbar_top_IC3_M4",
"unicode": "e98d",
"unicode_decimal": 59789
},
{
"icon_id": "32734437",
"name": "topbar_arrow_down_IC2_N4",
"font_class": "topbar_arrow_down_IC2_N4",
"unicode": "e98e",
"unicode_decimal": 59790
},
{
"icon_id": "32734438",
"name": "smart_card_fire_IC1_N3",
"font_class": "smart_card_fire_IC1_N3",
"unicode": "e98f",
"unicode_decimal": 59791
},
{
"icon_id": "32734439",
"name": "popover_add_scenario_IC3_N1",
"font_class": "popover_add_scenario_IC3_N1",
"unicode": "e990",
"unicode_decimal": 59792
},
{
"icon_id": "32734442",
"name": "mine_cell_homekit_IC3_S10",
"font_class": "mine_cell_homekit_IC3_S10",
"unicode": "e993",
"unicode_decimal": 59795
},
{
"icon_id": "32734443",
"name": "mine_cell_mall_IC3_S9",
"font_class": "mine_cell_mall_IC3_S9",
"unicode": "e994",
"unicode_decimal": 59796
},
{
"icon_id": "32734444",
"name": "segement_more_IC3_N3-1",
"font_class": "segement_more_IC3_N3-1",
"unicode": "e995",
"unicode_decimal": 59797
},
{
"icon_id": "32734445",
"name": "popover_device_IC3_N1",
"font_class": "popover_device_IC3_N1",
"unicode": "e996",
"unicode_decimal": 59798
},
{
"icon_id": "32734446",
"name": "input_arrow_up_IC3_N4",
"font_class": "input_arrow_up_IC3_N4",
"unicode": "e997",
"unicode_decimal": 59799
},
{
"icon_id": "32734447",
"name": "mine_cell_health_IC3_S9",
"font_class": "mine_cell_health_IC3_S9",
"unicode": "e998",
"unicode_decimal": 59800
},
{
"icon_id": "32734448",
"name": "toolbar_delete_IC3_M2",
"font_class": "toolbar_delete_IC3_M2",
"unicode": "e999",
"unicode_decimal": 59801
},
{
"icon_id": "32734495",
"name": "me_cell_store",
"font_class": "me_cell_store",
"unicode": "e99a",
"unicode_decimal": 59802
}
]
}

Binary file not shown.

View File

@ -0,0 +1,541 @@
{
"id": "3757359",
"name": "default_iconfont",
"font_family": "default_iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "35597351",
"name": "navbar_security_IC3_N1",
"font_class": "navbar_security_IC3_N1",
"unicode": "e9b6",
"unicode_decimal": 59830
},
{
"icon_id": "32775867",
"name": "home_card_on_IC1_N6",
"font_class": "home_card_on_IC1_N6",
"unicode": "e97a",
"unicode_decimal": 59770
},
{
"icon_id": "32775868",
"name": "smart_card_off_IC5_N6",
"font_class": "smart_card_off_IC5_N6",
"unicode": "e97d",
"unicode_decimal": 59773
},
{
"icon_id": "32775869",
"name": "smart_card_on_IC5_M3",
"font_class": "smart_card_on_IC5_M3",
"unicode": "e98c",
"unicode_decimal": 59788
},
{
"icon_id": "32775870",
"name": "home_card_off_IC1_N6",
"font_class": "home_card_off_IC1_N6",
"unicode": "e992",
"unicode_decimal": 59794
},
{
"icon_id": "32775871",
"name": "home_card_bluetooth_IC5_N6",
"font_class": "home_card_bluetooth_IC5_N6",
"unicode": "e9a0",
"unicode_decimal": 59808
},
{
"icon_id": "32750453",
"name": "me_cell_online_customer_service",
"font_class": "me_cell_online_customer_service",
"unicode": "e9a4",
"unicode_decimal": 59812
},
{
"icon_id": "32750105",
"name": "home_card_switch_on_IC5_M3",
"font_class": "home_card_switch_on_IC5_M3",
"unicode": "e99c",
"unicode_decimal": 59804
},
{
"icon_id": "32750106",
"name": "smart_card_add_IC4_N1",
"font_class": "smart_card_add_IC4_N1",
"unicode": "e99d",
"unicode_decimal": 59805
},
{
"icon_id": "32750107",
"name": "smart_card_arrow_right_IC3_N4",
"font_class": "smart_card_arrow_right_IC3_N4",
"unicode": "e99e",
"unicode_decimal": 59806
},
{
"icon_id": "32750108",
"name": "popover_management_IC3_N1",
"font_class": "popover_management_IC3_N1",
"unicode": "e99f",
"unicode_decimal": 59807
},
{
"icon_id": "32750110",
"name": "navbar_scanning_IC3_N1",
"font_class": "navbar_scanning_IC3_N1",
"unicode": "e9a1",
"unicode_decimal": 59809
},
{
"icon_id": "32750111",
"name": "segement_more_IC3_N3",
"font_class": "segement_more_IC3_N3",
"unicode": "e9a2",
"unicode_decimal": 59810
},
{
"icon_id": "32742211",
"name": "switch_new",
"font_class": "switch_new",
"unicode": "e99b",
"unicode_decimal": 59803
},
{
"icon_id": "32734381",
"name": "addDevice_switch_IC3_N6",
"font_class": "addDevice_switch_IC3_N6",
"unicode": "e957",
"unicode_decimal": 59735
},
{
"icon_id": "32734382",
"name": "card_edit_IC3_N3",
"font_class": "card_edit_IC3_N3",
"unicode": "e958",
"unicode_decimal": 59736
},
{
"icon_id": "32734383",
"name": "addDevice_bluetooth_IC3_N6",
"font_class": "addDevice_bluetooth_IC3_N6",
"unicode": "e959",
"unicode_decimal": 59737
},
{
"icon_id": "32734384",
"name": "addDevice_wifi_IC3_N6",
"font_class": "addDevice_wifi_IC3_N6",
"unicode": "e95a",
"unicode_decimal": 59738
},
{
"icon_id": "32734385",
"name": "addDevice_arrow_IC3_N6",
"font_class": "addDevice_arrow_IC3_N6",
"unicode": "e95b",
"unicode_decimal": 59739
},
{
"icon_id": "32734386",
"name": "common_remove_IC3_M2",
"font_class": "common_remove_IC3_M2",
"unicode": "e95c",
"unicode_decimal": 59740
},
{
"icon_id": "32734387",
"name": "card_notselected_IC3_N6",
"font_class": "card_notselected_IC3_N6",
"unicode": "e95d",
"unicode_decimal": 59741
},
{
"icon_id": "32734388",
"name": "home_card_arrow_up_IC3_N4",
"font_class": "home_card_arrow_up_IC3_N4",
"unicode": "e95e",
"unicode_decimal": 59742
},
{
"icon_id": "32734389",
"name": "home_card_line_IC1_N6",
"font_class": "home_card_line_IC1_N6",
"unicode": "e95f",
"unicode_decimal": 59743
},
{
"icon_id": "32734390",
"name": "device_popup_note_s_IC1_M2",
"font_class": "device_popup_note_s_IC1_M2",
"unicode": "e960",
"unicode_decimal": 59744
},
{
"icon_id": "32734391",
"name": "home_card_exclamation",
"font_class": "home_card_exclamation",
"unicode": "e961",
"unicode_decimal": 59745
},
{
"icon_id": "32734392",
"name": "home_card_down_s_IC1_N6",
"font_class": "home_card_down_s_IC1_N6",
"unicode": "e962",
"unicode_decimal": 59746
},
{
"icon_id": "32734393",
"name": "common_add_IC3_M3",
"font_class": "common_add_IC3_M3",
"unicode": "e963",
"unicode_decimal": 59747
},
{
"icon_id": "32734394",
"name": "home_card_group_IC1_N6",
"font_class": "home_card_group_IC1_N6",
"unicode": "e964",
"unicode_decimal": 59748
},
{
"icon_id": "32734395",
"name": "card_select_IC3_M1",
"font_class": "card_select_IC3_M1",
"unicode": "e965",
"unicode_decimal": 59749
},
{
"icon_id": "32734397",
"name": "device_popup_bluetooth_IC4_N4",
"font_class": "device_popup_bluetooth_IC4_N4",
"unicode": "e967",
"unicode_decimal": 59751
},
{
"icon_id": "32734398",
"name": "home_topbar_add_IC4_M1",
"font_class": "home_topbar_add_IC4_M1",
"unicode": "e968",
"unicode_decimal": 59752
},
{
"icon_id": "32734399",
"name": "home_card_switch_off_IC5_N6",
"font_class": "home_card_switch_off_IC5_N6",
"unicode": "e969",
"unicode_decimal": 59753
},
{
"icon_id": "32734400",
"name": "light_segment_manage",
"font_class": "light_segment_manage",
"unicode": "e96a",
"unicode_decimal": 59754
},
{
"icon_id": "32734401",
"name": "home_weather_arrow_IC1_N6",
"font_class": "home_weather_arrow_IC1_N6",
"unicode": "e96b",
"unicode_decimal": 59755
},
{
"icon_id": "32734402",
"name": "mine_cell_FAQ_IC3_S6",
"font_class": "mine_cell_FAQ_IC3_S6",
"unicode": "e96c",
"unicode_decimal": 59756
},
{
"icon_id": "32734403",
"name": "input_arrow_down_IC3_N4",
"font_class": "input_arrow_down_IC3_N4",
"unicode": "e96d",
"unicode_decimal": 59757
},
{
"icon_id": "32734404",
"name": "home_topbar_voice_IC3_N2",
"font_class": "home_topbar_voice_IC3_N2",
"unicode": "e96e",
"unicode_decimal": 59758
},
{
"icon_id": "32734405",
"name": "home_topbar_gateway_IC3_N2",
"font_class": "home_topbar_gateway_IC3_N2",
"unicode": "e96f",
"unicode_decimal": 59759
},
{
"icon_id": "32734406",
"name": "mine_cell_arrow_right_IC3_N4",
"font_class": "mine_cell_arrow_right_IC3_N4",
"unicode": "e970",
"unicode_decimal": 59760
},
{
"icon_id": "32734407",
"name": "home_btn_delete_IC2_N6",
"font_class": "home_btn_delete_IC2_N6",
"unicode": "e971",
"unicode_decimal": 59761
},
{
"icon_id": "32734408",
"name": "home_topbar_energy_IC3_N2",
"font_class": "home_topbar_energy_IC3_N2",
"unicode": "e972",
"unicode_decimal": 59762
},
{
"icon_id": "32734409",
"name": "mine_cell_home_IC3_S11",
"font_class": "mine_cell_home_IC3_S11",
"unicode": "e973",
"unicode_decimal": 59763
},
{
"icon_id": "32734410",
"name": "home_topbar_ipc_IC3_N2",
"font_class": "home_topbar_ipc_IC3_N2",
"unicode": "e974",
"unicode_decimal": 59764
},
{
"icon_id": "32734411",
"name": "mine_cell_features_IC3_S5",
"font_class": "mine_cell_features_IC3_S5",
"unicode": "e975",
"unicode_decimal": 59765
},
{
"icon_id": "32734412",
"name": "home_card_arrow_down_IC3_N4",
"font_class": "home_card_arrow_down_IC3_N4",
"unicode": "e976",
"unicode_decimal": 59766
},
{
"icon_id": "32734413",
"name": "me_cell_device",
"font_class": "me_cell_device",
"unicode": "e977",
"unicode_decimal": 59767
},
{
"icon_id": "32734414",
"name": "mine_cell_iwatch_IC3_S12",
"font_class": "mine_cell_iwatch_IC3_S12",
"unicode": "e978",
"unicode_decimal": 59768
},
{
"icon_id": "32734415",
"name": "smart_card_attention_IC4_N3",
"font_class": "smart_card_attention_IC4_N3",
"unicode": "e979",
"unicode_decimal": 59769
},
{
"icon_id": "32734417",
"name": "smart_card_loading1_IC3_N4",
"font_class": "smart_card_loading1_IC3_N4",
"unicode": "e97b",
"unicode_decimal": 59771
},
{
"icon_id": "32734419",
"name": "home_topbar_security_IC3_N2",
"font_class": "home_topbar_security_IC3_N2",
"unicode": "e97c",
"unicode_decimal": 59772
},
{
"icon_id": "32734421",
"name": "smart_topbar_add_IC4_N1",
"font_class": "smart_topbar_add_IC4_N1",
"unicode": "e97e",
"unicode_decimal": 59774
},
{
"icon_id": "32734422",
"name": "smart_card_arrow_IC5_N6",
"font_class": "smart_card_arrow_IC5_N6",
"unicode": "e97f",
"unicode_decimal": 59775
},
{
"icon_id": "32734423",
"name": "popover_list_IC3_N1",
"font_class": "popover_list_IC3_N1",
"unicode": "e980",
"unicode_decimal": 59776
},
{
"icon_id": "32734424",
"name": "popover_grid_IC3_N1",
"font_class": "popover_grid_IC3_N1",
"unicode": "e981",
"unicode_decimal": 59777
},
{
"icon_id": "32734425",
"name": "popover_add_device_IC3_N1",
"font_class": "popover_add_device_IC3_N1",
"unicode": "e982",
"unicode_decimal": 59778
},
{
"icon_id": "32734426",
"name": "popover_log_IC3_N1",
"font_class": "popover_log_IC3_N1",
"unicode": "e983",
"unicode_decimal": 59779
},
{
"icon_id": "32734427",
"name": "mine_cell_message_IC3_S1",
"font_class": "mine_cell_message_IC3_S1",
"unicode": "e984",
"unicode_decimal": 59780
},
{
"icon_id": "32734428",
"name": "navbar_arrow_left_IC3_N1",
"font_class": "navbar_arrow_left_IC3_N1",
"unicode": "e985",
"unicode_decimal": 59781
},
{
"icon_id": "32734429",
"name": "topbar_arrow_down_IC2_N4-1",
"font_class": "topbar_arrow_down_IC2_N4-1",
"unicode": "e986",
"unicode_decimal": 59782
},
{
"icon_id": "32734430",
"name": "navbar_management_IC3_N1",
"font_class": "navbar_management_IC3_N1",
"unicode": "e987",
"unicode_decimal": 59783
},
{
"icon_id": "32734431",
"name": "popup_arrow_up_IC3_N4",
"font_class": "popup_arrow_up_IC3_N4",
"unicode": "e988",
"unicode_decimal": 59784
},
{
"icon_id": "32734432",
"name": "navbar_scanning_IC3_N1-1",
"font_class": "navbar_scanning_IC3_N1-1",
"unicode": "e989",
"unicode_decimal": 59785
},
{
"icon_id": "32734433",
"name": "popover_room_IC3_N1",
"font_class": "popover_room_IC3_N1",
"unicode": "e98a",
"unicode_decimal": 59786
},
{
"icon_id": "32734434",
"name": "popup_arrow_down_IC3_N4",
"font_class": "popup_arrow_down_IC3_N4",
"unicode": "e98b",
"unicode_decimal": 59787
},
{
"icon_id": "32734436",
"name": "toolbar_top_IC3_M4",
"font_class": "toolbar_top_IC3_M4",
"unicode": "e98d",
"unicode_decimal": 59789
},
{
"icon_id": "32734437",
"name": "topbar_arrow_down_IC2_N4",
"font_class": "topbar_arrow_down_IC2_N4",
"unicode": "e98e",
"unicode_decimal": 59790
},
{
"icon_id": "32734438",
"name": "smart_card_fire_IC1_N3",
"font_class": "smart_card_fire_IC1_N3",
"unicode": "e98f",
"unicode_decimal": 59791
},
{
"icon_id": "32734439",
"name": "popover_add_scenario_IC3_N1",
"font_class": "popover_add_scenario_IC3_N1",
"unicode": "e990",
"unicode_decimal": 59792
},
{
"icon_id": "32734442",
"name": "mine_cell_homekit_IC3_S10",
"font_class": "mine_cell_homekit_IC3_S10",
"unicode": "e993",
"unicode_decimal": 59795
},
{
"icon_id": "32734443",
"name": "mine_cell_mall_IC3_S9",
"font_class": "mine_cell_mall_IC3_S9",
"unicode": "e994",
"unicode_decimal": 59796
},
{
"icon_id": "32734444",
"name": "segement_more_IC3_N3-1",
"font_class": "segement_more_IC3_N3-1",
"unicode": "e995",
"unicode_decimal": 59797
},
{
"icon_id": "32734445",
"name": "popover_device_IC3_N1",
"font_class": "popover_device_IC3_N1",
"unicode": "e996",
"unicode_decimal": 59798
},
{
"icon_id": "32734446",
"name": "input_arrow_up_IC3_N4",
"font_class": "input_arrow_up_IC3_N4",
"unicode": "e997",
"unicode_decimal": 59799
},
{
"icon_id": "32734447",
"name": "mine_cell_health_IC3_S9",
"font_class": "mine_cell_health_IC3_S9",
"unicode": "e998",
"unicode_decimal": 59800
},
{
"icon_id": "32734448",
"name": "toolbar_delete_IC3_M2",
"font_class": "toolbar_delete_IC3_M2",
"unicode": "e999",
"unicode_decimal": 59801
},
{
"icon_id": "32734495",
"name": "me_cell_store",
"font_class": "me_cell_store",
"unicode": "e99a",
"unicode_decimal": 59802
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
{
"black_theme":{
"color_alpha":{
"N1":1,
"N2":0.7,
"N3":0.5,
"N4":0.3,
"N5":0.7,
"N6":0.2,
"N7":0.1,
"N8":0.7,
"N9":0.1
},
"colors":{
"B1":"#000000",
"B2":"#1A1A1A",
"B3":"#1A1A1A",
"B4":"#1A1A1A",
"B5":"#1A1A1A",
"B6":"",
"M1":"#FF5A28",
"M2":"#FF4444",
"M3":"#23D1A5",
"M4":"#1989FA",
"M5":"#FF5A28"
},
"corner":{
"alert_corner_type":"angle",
"button_corner_type":"angle",
"card_corner_type":"angle"
},
"custom_colors":{
"B_Activator":"#000000",
"B_Login":"#000000"
},
"font":{
"app_bold_font":"",
"app_font":""
},
"maskAlpha":0.7
},
"color_alpha":{
"N1":0.9,
"N2":0.7,
"N3":0.5,
"N4":0.3,
"N5":0.7,
"N6":0.2,
"N7":0.1,
"N8":0.4,
"N9":0.03
},
"colors":{
"B1":"#F6F7FB",
"B2":"#FFFFFF",
"B3":"#FFFFFF",
"B4":"#FFFFFF",
"B5":"#FFFFFF",
"B6":"",
"M1":"#FF5A28",
"M2":"#FF4444",
"M3":"#23D1A5",
"M4":"#1989FA",
"M5":"#FF5A28"
},
"corner":{
"alert_corner_type":"angle",
"button_corner_type":"angle",
"card_corner_type":"angle"
},
"custom_colors":{
"B_Activator":"#FFFFFF",
"B_Login":"#FFFFFF"
},
"font":{
"app_bold_font":"",
"app_font":""
},
"maskAlpha":0
}

View File

@ -0,0 +1 @@
[{"npmName":"WearKit","version":"3.0.4","PBTGroupName":"TUNIWearKit"},{"npmName":"HomeKit","version":"3.17.3","PBTGroupName":"TUNIHomeKit"},{"npmName":"SweeperKit","version":"2.0.0","PBTGroupName":"TUNISweeperKit"},{"npmName":"BizKit","version":"4.29.12","PBTGroupName":"TUNIBizKit"},{"npmName":"IPCKit","version":"7.7.12","PBTGroupName":"TUNIIPCKit"},{"npmName":"P2PKit","version":"7.7.6","PBTGroupName":"TUNIP2PKit"},{"npmName":"MapKit","version":"7.8.2","PBTGroupName":"TUNIMapKit"},{"npmName":"AIKit","version":"2.1.2","PBTGroupName":"TUNIAIKit"},{"npmName":"PlayNetKit","version":"7.3.0","PBTGroupName":"TUNIPlayNetKit"},{"npmName":"DeviceKit","version":"7.8.1","PBTGroupName":"TUNIDeviceKit"},{"npmName":"BaseKit","version":"3.36.3","PBTGroupName":"TUNIBaseKit"},{"npmName":"MediaKit","version":"3.6.3","PBTGroupName":"TUNIMediaKit"},{"npmName":"HealthKit","version":"7.2.1","PBTGroupName":"TUNIHealthKit"},{"npmName":"LightKit","version":"1.0.18","PBTGroupName":"TUNILightKit"},{"npmName":"CategoryCommonBizKit","version":"6.4.1","PBTGroupName":"TUNICategoryCommonBizKit"},{"npmName":"AIStreamKit","version":"2.2.2","PBTGroupName":"TUNIAIStreamKit"},{"npmName":"ThirdPartyDeviceKit","version":"1.0.0-rc.4","PBTGroupName":"TUNIThirdPartyDeviceKit"},{"npmName":"ThirdAuthKit","version":"1.0.14","PBTGroupName":"TUNIThirdAuthKit"},{"npmName":"AVideoKit","version":"1.0.9","PBTGroupName":"TUNIAVideoKit"},{"npmName":"MediaPlayerKit","version":"1.0.34","PBTGroupName":"TUNIMediaPlayerKit"},{"npmName":"MiniKit","version":"3.33.1","PBTGroupName":"TUNIMiniKit"}]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
package com.xhai.iot;
import androidx.annotation.Keep;
/**
* 涂鸦 BizBundle 应用层代理配置
* 控制配网能力开关(热点/蓝牙/BLE Mesh)
*/
@Keep
public class AppConfig {
/** 主题色支持深色模式的开关 */
public static final boolean is_darkMode_support = true;
/** 华为包支持开关 */
public static final boolean is_huawei_pkg = false;
/** 热点配网支持的热点前缀列表 */
public static final String ap_mode_ssid = "SmartLife";
/** 是否支持蓝牙设备配网功能 */
public static final boolean is_need_ble_support = true;
/** 是否支持蓝牙 Mesh 设备配网功能 */
public static final boolean is_need_blemesh_support = true;
/** 是否支持列表页面右上角扫描配网功能 */
public static final boolean is_scan_support = true;
}

View File

@ -0,0 +1,123 @@
package com.xhai.iot;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import androidx.multidex.MultiDex;
import com.thingclips.smart.api.MicroContext;
import com.thingclips.smart.api.router.UrlBuilder;
import com.thingclips.smart.api.service.RedirectService;
import com.thingclips.smart.api.service.RouteEventListener;
import com.thingclips.smart.api.service.ServiceEventListener;
import com.thingclips.smart.bizbundle.initializer.BizBundleInitializer;
import com.thingclips.smart.commonbiz.bizbundle.family.api.AbsBizBundleFamilyService;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
import com.thingclips.smart.thingpackconfig.PackConfig;
import com.xhai.iot.data.ApiConfig;
import com.xhai.iot.service.BizBundleFamilyServiceImpl;
import dagger.hilt.android.HiltAndroidApp;
/**
* 应用入口:初始化涂鸦 SDK + UI BizBundle
*/
@HiltAndroidApp
public class TuyaApplication extends Application {
private static final String TAG = "TuyaApplication";
/** 全局上下文:供进程级常驻逻辑(如设备DP上报)使用,不依赖任何页面生命周期 */
private static volatile Context appContext;
public static Context getAppContext() {
return appContext;
}
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
appContext = getApplicationContext();
// 多Dex支持
MultiDex.install(this);
}
@Override
public void onCreate() {
super.onCreate();
// 进程启动即设定全局日夜模式(跟随 ThemeManager 持久化偏好),
// 避免强退重启后首个 Activity 落到默认亮色(尤其在扫码返回等场景)
try {
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
} catch (Throwable t) {
Log.e(TAG, "applyNightMode 失败: " + t.getMessage());
}
// 涂鸦 SDK 初始化开关(调试阶段先关掉,测我们自己的登录/绑定流程)
if (!ApiConfig.TUYA_SDK_ENABLED) {
Log.i(TAG, "涂鸦SDK已禁用(调试模式),仅测试应用自身流程");
return;
}
try {
// 打开涂鸦 SDK 日志(调试面板问题:RNLog/L 默认关闭)
com.thingclips.smart.android.common.utils.L.setLogSwitcher(true);
// 业务包代理配置(配网能力开关等)
PackConfig.addValueDelegate(AppConfig.class);
// SoLoader 初始化(RN 面板容器必需;初始化后需删除旧 App 重装)
try {
com.facebook.soloader.SoLoader.init(this, false);
} catch (Throwable soErr) {
Log.e(TAG, "SoLoader 初始化失败: " + soErr.getMessage());
}
// BizBundle 统一初始化(内部会初始化 ThingHomeSdk)
BizBundleInitializer.init(this, new RouteEventListener() {
@Override
public void onFaild(int errorCode, UrlBuilder urlBuilder) {
Log.e(TAG, "路由未实现 target=" + urlBuilder.target + " params=" + urlBuilder.params);
}
}, new ServiceEventListener() {
@Override
public void onFaild(String serviceName) {
Log.e(TAG, "服务未实现: " + serviceName);
}
});
// 注册家庭服务(Panel 等 BizBundle 必需)
BizBundleInitializer.registerService(AbsBizBundleFamilyService.class,
new BizBundleFamilyServiceImpl());
// 注册路由拦截器(面板跳转依赖路由系统,必须放行所有路由)
try {
RedirectService rs = MicroContext.getServiceManager()
.findServiceByInterface(RedirectService.class.getName());
if (rs != null) {
rs.registerUrlInterceptor(new RedirectService.UrlInterceptor() {
@Override
public void forUrlBuilder(UrlBuilder urlBuilder,
RedirectService.InterceptorCallback interceptorCallback) {
interceptorCallback.onContinue(urlBuilder);
}
});
}
} catch (Throwable rsErr) {
Log.e(TAG, "注册路由拦截器失败: " + rsErr.getMessage());
}
Log.i(TAG, "涂鸦BizBundle初始化成功");
} catch (Exception e) {
Log.e(TAG, "涂鸦BizBundle初始化失败: " + e.getMessage(), e);
// 兜底:如果 BizBundle 初始化失败,尝试用基础 SDK 初始化
try {
ThingHomeSdk.init(this, ApiConfig.TUYA_APP_KEY, ApiConfig.TUYA_APP_SECRET);
} catch (Exception ex) {
Log.e(TAG, "基础SDK初始化也失败: " + ex.getMessage(), ex);
}
}
}
}

View File

@ -0,0 +1,200 @@
package com.xhai.iot.data;
import android.util.Log;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
/**
* Retrofit 网络客户端
*/
public class ApiClient {
private static final String TAG = "ApiClient";
private static ApiService apiService;
private static ApiService sceneApiService;
public static ApiService getApiService() {
if (apiService == null) {
synchronized (ApiClient.class) {
if (apiService == null) {
apiService = build(ApiConfig.BASE_URL);
}
}
}
return apiService;
}
/** 场景服务(规则引擎服务 iot-rule,独立端口) */
public static ApiService getSceneApiService() {
if (sceneApiService == null) {
synchronized (ApiClient.class) {
if (sceneApiService == null) {
sceneApiService = build(ApiConfig.SCENE_BASE_URL);
}
}
}
return sceneApiService;
}
/** 静默刷新并发锁(多个请求同时 401 时只刷新一次) */
private static final Object REFRESH_LOCK = new Object();
private static ApiService build(String baseUrl) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(msg ->
Log.d(TAG, msg));
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
// 401 统一兜底处理:静默刷新也失败(refresh token 也过期)时,
// 清除登录态,提示并跳转登录页。这是最后一道防线——正常情况下
// authenticator 已在 401 时用 refresh token 静默续期并重试成功。
okhttp3.Interceptor unauthorizedHandler = chain -> {
okhttp3.Response response = chain.proceed(chain.request());
if (response.code() == 401) {
try {
com.xhai.iot.util.SessionManager session =
com.xhai.iot.util.SessionManager.getInstance(null);
// 只有携带 token 的请求返回 401 才是登录过期(登录接口失败不带 token)
if (session.getBearerToken() != null && session.handleTokenExpired()) {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
android.widget.Toast.makeText(session.appContext(),
"登录已过期,请重新登录",
android.widget.Toast.LENGTH_LONG).show();
android.content.Intent intent = new android.content.Intent(
session.appContext(), com.xhai.iot.ui.LoginActivity.class);
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
| android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK);
session.appContext().startActivity(intent);
});
}
} catch (Exception e) {
Log.w(TAG, "处理 401 失败: " + e.getMessage());
}
}
return response;
};
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.authenticator(tokenAuthenticator())
.addInterceptor(unauthorizedHandler)
.addInterceptor(logging)
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build();
return retrofit.create(ApiService.class);
}
// ==================== refresh token 静默续期 ====================
/**
* 401 时自动用 refresh token 换取新 token 并重试原请求(用户无感知)。
*
* 并发说明:多个请求同时 401 时,只有第一个进入锁的会真正刷新,
* 其他请求发现 session 里的 token 已更新(与失败请求的不同),
* 直接用新 token 重试,不会重复刷新。
*
* 刷新失败(refresh token 也过期/无效)→ 返回 null → 请求以 401 返回,
* 由 unauthorizedHandler 兜底清会话跳登录页。
*/
private static okhttp3.Authenticator tokenAuthenticator() {
return (route, response) -> {
// 已经重试过一次仍 401 → 不再重试(交给 401 兜底处理)
if (response.request().header("X-Auth-Retried") != null) {
return null;
}
com.xhai.iot.util.SessionManager session;
try {
session = com.xhai.iot.util.SessionManager.getInstance(null);
} catch (Exception e) {
return null;
}
synchronized (REFRESH_LOCK) {
String failedAuthHeader = response.request().header("Authorization");
String currentBearer = session.getBearerToken();
// 其他线程已完成刷新(session 里的 token 与失败请求的不同)→ 直接重试
if (currentBearer != null && !currentBearer.equals(failedAuthHeader)) {
Log.i(TAG, "token 已被其他请求刷新,直接重试");
return retryWithToken(response.request(), currentBearer);
}
// 静默刷新
String refreshToken = session.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty()) {
Log.i(TAG, "无 refresh token(旧版本登录),401 走过期处理");
return null;
}
String[] tokens = refreshSync(refreshToken);
if (tokens == null) {
Log.i(TAG, "静默刷新失败(refresh token 也过期),401 走过期处理");
return null;
}
session.updateTokens(tokens[0], tokens[1]);
Log.i(TAG, "token 静默续期成功,重试原请求");
return retryWithToken(response.request(), "Bearer " + tokens[0]);
}
};
}
private static okhttp3.Request retryWithToken(okhttp3.Request request, String bearer) {
return request.newBuilder()
.header("Authorization", bearer)
.header("X-Auth-Retried", "1")
.build();
}
/**
* 同步调用刷新接口(须在 REFRESH_LOCK 内调用)。
*
* @return [新token, 新refreshToken],失败返回 null
*/
private static String[] refreshSync(String refreshToken) {
try {
okhttp3.OkHttpClient bare = new okhttp3.OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
okhttp3.RequestBody body = okhttp3.RequestBody.create(
"{\"refreshToken\":\"" + refreshToken + "\"}",
okhttp3.MediaType.parse("application/json"));
okhttp3.Request request = new okhttp3.Request.Builder()
.url(com.xhai.iot.data.ApiConfig.BASE_URL + "v1/auth/member/refresh")
.post(body)
.build();
okhttp3.Response resp = bare.newCall(request).execute();
String respBody = resp.body() != null ? resp.body().string() : null;
if (resp.isSuccessful() && respBody != null) {
com.google.gson.JsonObject root = com.google.gson.JsonParser
.parseString(respBody).getAsJsonObject();
if (root.has("data") && root.get("data").isJsonObject()
&& root.get("success").isJsonNull() == false
&& root.get("success").getAsBoolean()) {
com.google.gson.JsonObject data = root.getAsJsonObject("data");
String token = data.has("token") && !data.get("token").isJsonNull()
? data.get("token").getAsString() : null;
String refresh = data.has("refreshToken") && !data.get("refreshToken").isJsonNull()
? data.get("refreshToken").getAsString() : null;
if (token != null) {
return new String[]{token, refresh};
}
}
Log.w(TAG, "刷新token业务失败: " + respBody);
} else {
Log.w(TAG, "刷新token HTTP失败: " + resp.code());
}
} catch (Exception e) {
Log.w(TAG, "刷新token异常: " + e.getMessage());
}
return null;
}
}

View File

@ -0,0 +1,30 @@
package com.xhai.iot.data;
/**
* 后端 API 配置
*/
public class ApiConfig {
// iotsaas 后端服务地址(生产环境经 nginx 按路径分发到各微服务)
public static final String BASE_URL = "https://mum.tonzou.cn/api/";
// 规则引擎服务地址(同样走公网域名,nginx 按 /api/v1/scenes|rules 分发到 iot-rule 9083)
public static final String SCENE_BASE_URL = "https://mum.tonzou.cn/api/";
// 登录后保存 token 的 SharedPreferences key
public static final String PREF_NAME = "xhai_session";
public static final String KEY_TOKEN = "token";
public static final String KEY_MEMBER_ID = "memberId";
public static final String KEY_USERNAME = "username";
public static final String KEY_NICKNAME = "nickname";
public static final String KEY_TUYA_BOUND = "tuya_bound";
// 涂鸦 SDK 配置(智能生活App SDK,瑞华物联 com.xhai.iot)
public static final String TUYA_APP_KEY = "cex9ntw3dsr3tywkrggp";
public static final String TUYA_APP_SECRET = "9cqqk43gksxdkueppjsexnvjttd5evaf";
// 启用涂鸦 SDK 初始化(已使用涂鸦后台真实凭证)
public static final boolean TUYA_SDK_ENABLED = true;
private ApiConfig() {}
}

View File

@ -0,0 +1,453 @@
package com.xhai.iot.data;
import com.xhai.iot.model.AiCommandResult;
import com.xhai.iot.model.AiDeviceInfo;
import com.xhai.iot.model.BackendDevice;
import com.xhai.iot.model.ControlSchema;
import com.xhai.iot.model.DeviceActivityItem;
import com.xhai.iot.model.LoginData;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.SceneItem;
import com.xhai.iot.model.SpeechAuth;
import com.xhai.iot.model.TuyaDevice;
import com.xhai.iot.model.TuyaHome;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.Header;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.Field;
/**
* 后端 API 接口定义
*/
public interface ApiService {
// ============ 会员登录 ============
/**
* 会员登录
* POST /api/v1/auth/member/login
* body: { "username": "xxx", "password": "xxx" }
*/
@POST("v1/auth/member/login")
Call<Result<LoginData>> login(@Body Map<String, String> body);
/**
* 会员注册
* POST /api/v1/auth/member/register
* body: { "username", "password", "nickname", "phone" }
*/
@POST("v1/auth/member/register")
Call<Result<Void>> register(@Body Map<String, String> body);
// ============ 中控屏会员自助绑定(APP 扫码) ============
/**
* 扫码后预检某 SN 是否已预置/启用/已绑定
* GET /api/v1/member/panels/bindable?serialNumber=
*/
@GET("v1/member/panels/bindable")
Call<Result<com.xhai.iot.model.PanelBindable>> getPanelBindable(
@Header("Authorization") String auth,
@Query("serialNumber") String serialNumber);
/**
* 绑定中控屏到家庭
* POST /api/v1/member/panels/bind
* body: { "serialNumber", "homeId" }
*/
@POST("v1/member/panels/bind")
Call<Result<com.xhai.iot.model.PanelBindResult>> bindPanel(
@Header("Authorization") String auth,
@Body Map<String, Object> body);
/** 列出当前会员绑定的所有中控屏 GET /api/v1/member/panels */
@GET("v1/member/panels")
Call<Result<List<com.xhai.iot.model.PanelInfo>>> getMyPanels(
@Header("Authorization") String auth);
/** 解绑中控屏 POST /api/v1/member/panels/{panelId}/unbind */
@POST("v1/member/panels/{panelId}/unbind")
Call<Result<Void>> unbindPanel(
@Header("Authorization") String auth,
@Path("panelId") long panelId);
/** 获取 Matter Fabric 配网凭证 POST /api/v1/tuya/config/matter-token */
@FormUrlEncoded
@POST("v1/tuya/config/matter-token")
Call<Result<com.xhai.iot.model.MatterTokenResp>> getMatterToken(
@Header("Authorization") String auth,
@Field("homeId") long homeId);
// ============ 设备列表(本平台后端,不依赖涂鸦云端) ============
/**
* 获取本平台设备列表
* GET /api/v1/devices?page=1&size=20
* @param parentDeviceId 可选:按父网关查子设备
* @param isGateway 可选:1=仅网关
*/
@GET("v1/devices")
Call<Result<PageData<BackendDevice>>> getDevices(
@Header("Authorization") String auth,
@Query("page") int page,
@Query("size") int size,
@Query("parentDeviceId") String parentDeviceId,
@Query("isGateway") Integer isGateway,
@Query("homeId") Long homeId);
/** 查询某网关下的子设备 */
default Call<Result<PageData<BackendDevice>>> getSubDevices(
String auth, int page, int size, String gatewayId) {
return getDevices(auth, page, size, gatewayId, null, null);
}
// ============ 天气(首页环境卡) ============
/**
* 获取室外天气
* GET /api/v1/weather?lat=&lon=(Open-Meteo 代理,服务端缓存 30 分钟)
* 返回:{ temp, humidity, weather, weatherCode, source }
*/
@GET("v1/weather")
Call<Result<java.util.Map<String, Object>>> getWeather(
@Header("Authorization") String auth,
@Query("lat") double lat,
@Query("lon") double lon);
// ============ 家庭/房间(本平台后端,不对接涂鸦) ============
/** 我的家庭列表 GET /api/v1/homes?memberId= */
@GET("v1/homes")
Call<Result<List<com.xhai.iot.model.BackendHome>>> getMyHomes(
@Header("Authorization") String auth,
@Query("memberId") long memberId);
/** 创建家庭 POST /api/v1/homes?memberId= body: {"name": "..."} */
@POST("v1/homes")
Call<Result<com.xhai.iot.model.BackendHome>> createHome(
@Header("Authorization") String auth,
@Query("memberId") long memberId,
@Body Map<String, String> body);
/** 删除家庭 DELETE /api/v1/homes/{id}?memberId=(仅 owner) */
@DELETE("v1/homes/{id}")
Call<Result<Void>> deleteHome(
@Header("Authorization") String auth,
@Path("id") long homeId,
@Query("memberId") long memberId);
/** 房间列表 GET /api/v1/homes/{homeId}/rooms?memberId= */
@GET("v1/homes/{homeId}/rooms")
Call<Result<List<com.xhai.iot.model.BackendRoom>>> getRooms(
@Header("Authorization") String auth,
@Path("homeId") long homeId,
@Query("memberId") long memberId);
/** 添加房间 POST /api/v1/homes/{homeId}/rooms?memberId= body: {"name": "..."} */
@POST("v1/homes/{homeId}/rooms")
Call<Result<com.xhai.iot.model.BackendRoom>> addRoom(
@Header("Authorization") String auth,
@Path("homeId") long homeId,
@Query("memberId") long memberId,
@Body Map<String, String> body);
/** 删除房间 DELETE /api/v1/homes/{homeId}/rooms/{roomId}?memberId= */
@DELETE("v1/homes/{homeId}/rooms/{roomId}")
Call<Result<Void>> deleteRoom(
@Header("Authorization") String auth,
@Path("homeId") long homeId,
@Path("roomId") long roomId,
@Query("memberId") long memberId);
// ============ 网关-子设备关系 ============
/**
* 绑定/解绑父网关(添加子设备)
* POST /api/v1/devices/{deviceId}/parent body: {"parentDeviceId":"网关deviceId"}
*/
@POST("v1/devices/{deviceId}/parent")
Call<Result<Integer>> bindDeviceParent(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId,
@Body Map<String, String> body);
// ============ 涂鸦绑定 ============
/**
* 涂鸦绑定状态查询
* GET /api/v1/tuya/auth/status
*/
@GET("v1/tuya/auth/status")
Call<Result<Map<String, Object>>> getTuyaBindStatus(
@Header("Authorization") String auth);
/**
* 原生SDK绑定涂鸦账号
* POST /api/v1/tuya/auth/bind-native
* body: { "tuyaUid", "account", "loginMethod", "platform" }
*/
@POST("v1/tuya/auth/bind-native")
Call<Result<Map<String, Object>>> bindNative(
@Header("Authorization") String auth,
@Body Map<String, String> body);
/**
* 解绑涂鸦账号
* POST /api/v1/tuya/auth/unbind
*/
@POST("v1/tuya/auth/unbind")
Call<Result<Void>> unbindTuya(@Header("Authorization") String auth);
// ============ 涂鸦家庭 ============
/**
* 获取涂鸦家庭列表
* GET /api/v1/tuya/homes
*/
@GET("v1/tuya/homes")
Call<Result<List<TuyaHome>>> getTuyaHomes(@Header("Authorization") String auth);
// ============ 涂鸦设备 ============
/**
* 获取涂鸦设备列表
* GET /api/v1/tuya/devices?homeId=xxx
*/
@GET("v1/tuya/devices")
Call<Result<List<TuyaDevice>>> getTuyaDevices(
@Header("Authorization") String auth,
@Query("homeId") Long homeId);
/**
* 绑定涂鸦设备到本平台
* POST /api/v1/tuya/devices/bind
*/
@POST("v1/tuya/devices/bind")
Call<Result<Integer>> bindTuyaDevices(
@Header("Authorization") String auth,
@Body Map<String, Object> body);
/**
* 设备状态实时上报(监听涂鸦SDK的上下线、DP变化)
* POST /api/v1/tuya/devices/status
*/
@POST("v1/tuya/devices/status")
Call<Result<Integer>> reportDeviceStatus(
@Header("Authorization") String auth,
@Body Map<String, Object> body);
/**
* 查询涂鸦云端设备在线状态(探活兜底)
* GET /api/v1/tuya/devices/{deviceId}/cloud-status
* 返回涂鸦 IoT 平台的设备激活状态(online 字段),比 SDK 本地快照权威
*/
@GET("v1/tuya/devices/{deviceId}/cloud-status")
Call<Result<Map<String, Object>>> getTuyaCloudStatus(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId);
/**
* 首页近期动态(最近操控记录,最多 20 条)
* GET /api/v1/devices/activities/recent
*/
@GET("v1/devices/activities/recent")
Call<Result<List<DeviceActivityItem>>> getRecentActivities(
@Header("Authorization") String auth,
@Query("homeId") Long homeId,
@Query("limit") int limit);
/**
* 操控动态分页查询(动态详情页,可按设备过滤)
* GET /api/v1/devices/activities
*/
@GET("v1/devices/activities")
Call<Result<PageData<DeviceActivityItem>>> getDeviceActivities(
@Header("Authorization") String auth,
@Query("homeId") Long homeId,
@Query("deviceId") String deviceId,
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize);
/**
* 发送设备控制指令
* POST /api/v1/tuya/devices/{deviceId}/commands
*/
@POST("v1/tuya/devices/{deviceId}/commands")
Call<Result<Boolean>> sendTuyaCommand(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId,
@Body List<Map<String, Object>> commands);
/**
* 获取设备状态
* GET /api/v1/tuya/devices/{deviceId}/status
*/
@GET("v1/tuya/devices/{deviceId}/status")
Call<Result<List<Map<String, Object>>>> getTuyaDeviceStatus(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId);
/**
* 上报设备控制动态(APP 走 SDK 直连不经后端,需主动上报首页"控制记录")
* POST /api/v1/devices/{deviceId}/activity
*/
@POST("v1/devices/activities/{deviceId}/activity")
Call<Result<Void>> reportDeviceActivity(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId,
@Body Map<String, Object> body);
/**
* 设备标准控制模型(由产品物模型生成,跨平台统一渲染)
* GET /api/v1/devices/{deviceId}/control-schema
*/
@GET("v1/devices/{deviceId}/control-schema")
Call<Result<ControlSchema>> getDeviceControlSchema(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId);
/**
* 重命名涂鸦设备(同步到本平台 device 表)
* POST /api/v1/tuya/devices/{deviceId}/rename
*/
@POST("v1/tuya/devices/{deviceId}/rename")
Call<Result<Void>> renameTuyaDevice(
@Header("Authorization") String auth,
@Path("deviceId") String deviceId,
@Body Map<String, String> body);
// ============ 场景 ============
/**
* 分页查询场景列表
* GET /api/v1/scenes?pageNum=1&pageSize=50
*/
@GET("v1/scenes")
Call<Result<PageData<SceneItem>>> getScenes(
@Header("Authorization") String auth,
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize);
/**
* 创建场景
* POST /api/v1/scenes
*/
@POST("v1/scenes")
Call<Result<Long>> createScene(
@Header("Authorization") String auth,
@Body Map<String, Object> body);
/**
* 一键执行场景
* POST /api/v1/scenes/{sceneId}/execute
*/
@POST("v1/scenes/{sceneId}/execute")
Call<Result<List<Map<String, Object>>>> executeScene(
@Header("Authorization") String auth,
@Path("sceneId") Long sceneId);
/**
* 删除场景
* DELETE /api/v1/scenes/{sceneId}
*/
@retrofit2.http.DELETE("v1/scenes/{sceneId}")
Call<Result<Void>> deleteScene(
@Header("Authorization") String auth,
@Path("sceneId") Long sceneId);
// ============ 规则 ============
/**
* 查询某场景下的规则列表
* GET /api/v1/rules?sceneId=xxx
*/
@GET("v1/rules")
Call<Result<PageData<Map<String, Object>>>> getRules(
@Header("Authorization") String auth,
@Query("sceneId") Long sceneId,
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize);
/**
* 创建规则
* POST /api/v1/rules
*/
@POST("v1/rules")
Call<Result<Long>> createRule(
@Header("Authorization") String auth,
@Body Map<String, Object> body);
/**
* 删除规则
* DELETE /api/v1/rules/{ruleId}
*/
@retrofit2.http.DELETE("v1/rules/{ruleId}")
Call<Result<Void>> deleteRule(
@Header("Authorization") String auth,
@Path("ruleId") Long ruleId);
/**
* 启用/禁用规则
*/
@POST("v1/rules/{ruleId}/{action}")
Call<Result<Void>> toggleRule(
@Header("Authorization") String auth,
@Path("ruleId") Long ruleId,
@Path("action") String action);
// ============ AI 助手(iot-rule 服务,端口 9083) ============
// 注意:这些接口必须用 ApiClient.getSceneApiService() 调用
/**
* 获取讯飞语音听写鉴权地址
* GET /api/v1/ai/speech/auth
* <p>
* 返回 { enabled, appId, url }:enabled=false 表示后台未启用(回退系统识别/键盘);
* url 为带签名的临时 wss 地址(签名时效约 5 分钟,领取后立即使用),APISecret 全程不出后端。
*/
@GET("v1/ai/speech/auth")
Call<Result<SpeechAuth>> getSpeechAuth(
@Header("Authorization") String auth);
/**
* 执行自然语言指令
* POST /api/v1/ai/command
* body: { "text": "打开客厅灯", "targetType": "device", "targetId": "xxx", "learn": true }
* <p>
* targetType/targetId 仅在上一轮返回 needConfirm=true、用户点选候选后传入,
* 服务端据此消歧并自动把该说法学习为设备别名。
*/
@POST("v1/ai/command")
Call<Result<AiCommandResult>> aiCommand(
@Header("Authorization") String auth,
@Body Map<String, Object> body);
/**
* 查询当前用户可被 AI 控制的设备(用于生成快捷指令与目标提示)
* GET /api/v1/ai/devices
*/
@GET("v1/ai/devices")
Call<Result<List<AiDeviceInfo>>> getAiDevices(
@Header("Authorization") String auth);
/**
* AI 指令历史记录
* GET /api/v1/ai/logs?limit=20
*/
@GET("v1/ai/logs")
Call<Result<List<Map<String, Object>>>> getAiLogs(
@Header("Authorization") String auth,
@Query("limit") int limit);
}

View File

@ -0,0 +1,154 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
import java.util.ArrayList;
import java.util.List;
/**
* AI 自然语言指令执行结果
* 对应后端 iot-rule 的 POST /api/v1/ai/command → AiCommandResponse
*/
public class AiCommandResult {
/** 是否执行成功 */
@SerializedName("success")
private Boolean success;
/** 给用户的自然语言回复,可直接用于界面展示/语音播报 */
@SerializedName("reply")
private String reply;
/** 意图类型: device_control / scene_execute / unknown */
@SerializedName("intentType")
private String intentType;
/** 动作: turn_on / turn_off / set_value / execute */
@SerializedName("action")
private String action;
/** 解析来源: rule(规则模板) / llm(大模型) */
@SerializedName("parseSource")
private String parseSource;
/** 命中目标类型: device / scene */
@SerializedName("matchedType")
private String matchedType;
/** 命中目标ID */
@SerializedName("matchedId")
private String matchedId;
/** 命中目标名称 */
@SerializedName("matchedName")
private String matchedName;
/** 置信度 0-1 */
@SerializedName("confidence")
private Double confidence;
/** 是否需要用户二次确认(存在歧义候选) */
@SerializedName("needConfirm")
private Boolean needConfirm;
/** 是否已执行 */
@SerializedName("executed")
private Boolean executed;
/** 歧义候选列表(needConfirm=true 时返回) */
@SerializedName("candidates")
private List<Candidate> candidates = new ArrayList<>();
/** 耗时(毫秒) */
@SerializedName("costMs")
private Integer costMs;
public Boolean getSuccess() { return success; }
public void setSuccess(Boolean success) { this.success = success; }
public String getReply() { return reply; }
public void setReply(String reply) { this.reply = reply; }
public String getIntentType() { return intentType; }
public void setIntentType(String intentType) { this.intentType = intentType; }
public String getAction() { return action; }
public void setAction(String action) { this.action = action; }
public String getParseSource() { return parseSource; }
public void setParseSource(String parseSource) { this.parseSource = parseSource; }
public String getMatchedType() { return matchedType; }
public void setMatchedType(String matchedType) { this.matchedType = matchedType; }
public String getMatchedId() { return matchedId; }
public void setMatchedId(String matchedId) { this.matchedId = matchedId; }
public String getMatchedName() { return matchedName; }
public void setMatchedName(String matchedName) { this.matchedName = matchedName; }
public Double getConfidence() { return confidence; }
public void setConfidence(Double confidence) { this.confidence = confidence; }
public Boolean getNeedConfirm() { return needConfirm; }
public void setNeedConfirm(Boolean needConfirm) { this.needConfirm = needConfirm; }
public Boolean getExecuted() { return executed; }
public void setExecuted(Boolean executed) { this.executed = executed; }
public List<Candidate> getCandidates() { return candidates; }
public void setCandidates(List<Candidate> candidates) { this.candidates = candidates; }
public Integer getCostMs() { return costMs; }
public void setCostMs(Integer costMs) { this.costMs = costMs; }
public boolean isSuccess() { return success != null && success; }
public boolean isNeedConfirm() { return needConfirm != null && needConfirm; }
public boolean isExecuted() { return executed != null && executed; }
public boolean hasCandidates() { return candidates != null && !candidates.isEmpty(); }
public String getReplyOrEmpty() { return reply == null ? "" : reply; }
/** 解析来源中文标签,用于界面标注「由什么理解出来的」 */
public String getParseSourceLabel() {
if ("llm".equalsIgnoreCase(parseSource)) return "大模型";
if ("rule".equalsIgnoreCase(parseSource)) return "规则模板";
if ("alias".equalsIgnoreCase(parseSource)) return "已学习别名";
return null;
}
/** 歧义候选目标 */
public static class Candidate {
/** 目标类型: device / scene */
@SerializedName("targetType")
private String targetType;
/** 目标ID */
@SerializedName("targetId")
private String targetId;
/** 目标名称 */
@SerializedName("targetName")
private String targetName;
/** 匹配分数 */
@SerializedName("score")
private Double score;
public String getTargetType() { return targetType; }
public void setTargetType(String targetType) { this.targetType = targetType; }
public String getTargetId() { return targetId; }
public void setTargetId(String targetId) { this.targetId = targetId; }
public String getTargetName() { return targetName; }
public void setTargetName(String targetName) { this.targetName = targetName; }
public Double getScore() { return score; }
public void setScore(Double score) { this.score = score; }
}
}

View File

@ -0,0 +1,80 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 可被 AI 控制的设备(供 AI 助手展示与生成快捷指令)
* 对应后端 iot-rule 的 GET /api/v1/ai/devices → AiDeviceView
*/
public class AiDeviceInfo {
/** 设备业务ID(下发指令用) */
@SerializedName("deviceId")
private String deviceId;
/** 设备名称 */
@SerializedName("deviceName")
private String deviceName;
/** 设备类型(涂鸦设备常为空) */
@SerializedName("deviceType")
private String deviceType;
/** 位置描述(如:客厅) */
@SerializedName("location")
private String location;
/** 所属房间ID */
@SerializedName("roomId")
private Long roomId;
/** 所属家庭ID */
@SerializedName("homeId")
private Long homeId;
/** 接入平台: tuya/local/... */
@SerializedName("platform")
private String platform;
/** 在线状态: online / offline */
@SerializedName("status")
private String status;
/** 使用频次(后端统计,用于常用设备排序) */
@SerializedName("useCount")
private int useCount;
/** 最近使用时间(后端统计,用于常用设备排序) */
@SerializedName("lastUsedTime")
private String lastUsedTime;
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getDeviceName() { return deviceName; }
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
public String getDeviceType() { return deviceType; }
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public Long getRoomId() { return roomId; }
public void setRoomId(Long roomId) { this.roomId = roomId; }
public Long getHomeId() { return homeId; }
public void setHomeId(Long homeId) { this.homeId = homeId; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public int getUseCount() { return useCount; }
public void setUseCount(int useCount) { this.useCount = useCount; }
public String getLastUsedTime() { return lastUsedTime; }
public void setLastUsedTime(String lastUsedTime) { this.lastUsedTime = lastUsedTime; }
}

View File

@ -0,0 +1,68 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 本平台后端设备(device 表),首页设备列表数据源。
* 家庭/房间/设备清单由我们后端管理,涂鸦 SDK 仅用于设备控制。
*/
public class BackendDevice {
@SerializedName("deviceId")
private String deviceId;
@SerializedName("deviceName")
private String deviceName;
@SerializedName("platform")
private String platform;
@SerializedName("status")
private String status;
@SerializedName("icon")
private String icon;
@SerializedName("ownerId")
private String ownerId;
@SerializedName("deviceType")
private String deviceType;
@SerializedName("parentDeviceId")
private String parentDeviceId;
@SerializedName("isGateway")
private Integer isGateway;
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getDeviceName() { return deviceName; }
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public boolean isOnline() { return "online".equalsIgnoreCase(status); }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public String getOwnerId() { return ownerId; }
public void setOwnerId(String ownerId) { this.ownerId = ownerId; }
public String getDeviceType() { return deviceType; }
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
public String getParentDeviceId() { return parentDeviceId; }
public void setParentDeviceId(String parentDeviceId) { this.parentDeviceId = parentDeviceId; }
public Integer getIsGateway() { return isGateway; }
public void setIsGateway(Integer isGateway) { this.isGateway = isGateway; }
public boolean isGateway() { return isGateway != null && isGateway == 1; }
}

View File

@ -0,0 +1,28 @@
package com.xhai.iot.model;
/**
* 本平台家庭(后端 home 表)
* 对应 GET /api/v1/homes 返回
*/
public class BackendHome {
private Long id;
private String name;
private Long ownerId;
private Integer deviceCount;
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Long getOwnerId() {
return ownerId;
}
public Integer getDeviceCount() {
return deviceCount;
}
}

View File

@ -0,0 +1,28 @@
package com.xhai.iot.model;
/**
* 本平台房间(后端 room 表)
* 对应 GET /api/v1/homes/{homeId}/rooms 返回
*/
public class BackendRoom {
private Long id;
private Long homeId;
private String name;
private Integer deviceCount;
public Long getId() {
return id;
}
public Long getHomeId() {
return homeId;
}
public String getName() {
return name;
}
public Integer getDeviceCount() {
return deviceCount;
}
}

View File

@ -0,0 +1,42 @@
package com.xhai.iot.model;
import java.util.List;
/**
* 标准控制模型(由产品物模型 TSL 推导,后端 GET /api/v1/devices/{deviceId}/control-schema)
* 跨平台统一:涂鸦/Matter/华为等统一按此模型渲染控制界面,无物模型时 available=false 回退原渲染。
*/
public class ControlSchema {
public long productId;
public String productName;
public String platform;
public int specVersion;
public String mainSwitchCode;
public boolean available;
public List<ControlItem> items;
public List<ControlItem> readOnly;
public static class ControlItem {
public String code;
public String name;
public String type;
public String dpId;
/** switch / slider / segmented / number / text / color */
public String widget;
public Double min;
public Double max;
public Double step;
public String unit;
public List<String> enumValues;
public String defaultValue;
public Integer sort;
public boolean writable;
public Object value;
public String key() {
// 下发优先用 DP 映射,没有则用功能点编码
return (dpId != null && !dpId.isEmpty()) ? dpId : code;
}
}
}

View File

@ -0,0 +1,106 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 设备操控动态(对应后端 device_activity_log)
*/
public class DeviceActivityItem {
@SerializedName("id")
private long id;
@SerializedName("homeId")
private Long homeId;
@SerializedName("deviceId")
private String deviceId;
@SerializedName("deviceName")
private String deviceName;
/** switch_on/switch_off/adjust/status_change/online/offline/scene */
@SerializedName("action")
private String action;
/** 人类可读描述,如"打开 客厅灯" */
@SerializedName("actionDesc")
private String actionDesc;
/** app/panel/voice/scene/cloud_push */
@SerializedName("source")
private String source;
@SerializedName("detail")
private String detail;
/** ISO 时间字符串,如 2026-09-17T15:30:00 */
@SerializedName("createTime")
private String createTime;
public long getId() { return id; }
public Long getHomeId() { return homeId; }
public String getDeviceId() { return deviceId; }
public String getDeviceName() { return deviceName; }
public String getAction() { return action; }
public String getActionDesc() { return actionDesc; }
public String getSource() { return source; }
public String getDetail() { return detail; }
public String getCreateTime() { return createTime; }
/** 动作对应的展示图标 */
public String icon() {
if (action == null) return "🔄";
switch (action) {
case "switch_on": return "💡";
case "switch_off": return "🌙";
case "adjust": return "🎚";
case "status_change": return "🔄";
case "online": return "✅";
case "offline": return "⛔";
case "scene": return "🎬";
default: return "🔄";
}
}
/** 来源的展示文案 */
public String sourceLabel() {
if (source == null) return "";
switch (source) {
case "app": return "APP 操作";
case "panel": return "面板/手动";
case "voice": return "AI 语音";
case "scene": return "场景联动";
case "cloud_push": return "设备上报";
default: return source;
}
}
/** ISO 时间 → 相对时间(刚刚/x 分钟前/x 小时前/昨天 MM:mm/MM-dd/MM-dd 以前) */
public String relativeTime() {
if (createTime == null || createTime.isEmpty()) return "";
try {
java.text.SimpleDateFormat in = new java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.CHINA);
java.util.Date d = in.parse(createTime);
if (d == null) return createTime;
long diff = System.currentTimeMillis() - d.getTime();
if (diff < 0) diff = 0;
long min = diff / 60000;
if (min < 1) return "刚刚";
if (min < 60) return min + " 分钟前";
long h = min / 60;
if (h < 24) return h + " 小时前";
long day = h / 24;
java.text.SimpleDateFormat hm =
new java.text.SimpleDateFormat("HH:mm", java.util.Locale.CHINA);
if (day == 1) return "昨天 " + hm.format(d);
if (day < 30) {
return new java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.CHINA).format(d);
}
return new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.CHINA).format(d);
} catch (Exception e) {
return createTime;
}
}
}

View File

@ -0,0 +1,57 @@
package com.xhai.iot.model;
/**
* 智能家居设备平台枚举
* 与后端 Constants.Platform 对齐,支持多平台扩展
*/
public enum DevicePlatform {
/** 涂鸦 */
TUYA("tuya"),
/** 谷歌 Home (Matter/Thread) */
GOOGLE_HOME("google_home"),
/** 华为 HiLink/鸿蒙智联 */
HUAWEI("huawei"),
/** 小米米家 */
XIAOMI("miot"),
/** 小度 */
DUEROS("dueros"),
/** Matter */
MATTER("matter"),
/** 本地/自研设备 */
LOCAL("local");
private final String code;
DevicePlatform(String code) {
this.code = code;
}
public String getCode() {
return code;
}
/** 由后端平台码解析枚举 */
public static DevicePlatform fromCode(String code) {
if (code == null) return LOCAL;
for (DevicePlatform p : values()) {
if (p.code.equalsIgnoreCase(code)) {
return p;
}
}
return LOCAL;
}
/** 平台显示名称 */
public String getDisplayName() {
switch (this) {
case TUYA: return "涂鸦";
case GOOGLE_HOME: return "Google Home";
case HUAWEI: return "华为智联";
case XIAOMI: return "小米米家";
case DUEROS: return "小度";
case MATTER: return "Matter";
default: return "本地";
}
}
}

View File

@ -0,0 +1,51 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 登录接口返回的 data 结构
*/
public class LoginData {
@SerializedName("token")
private String token;
/** 刷新令牌:access token 过期后用它静默换取新 token(30天有效) */
@SerializedName("refreshToken")
private String refreshToken;
@SerializedName("memberId")
private long memberId;
@SerializedName("username")
private String username;
@SerializedName("nickname")
private String nickname;
@SerializedName("avatar")
private String avatar;
@SerializedName("phone")
private String phone;
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
public long getMemberId() { return memberId; }
public void setMemberId(long memberId) { this.memberId = memberId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
}

View File

@ -0,0 +1,9 @@
package com.xhai.iot.model;
/** 后端 /api/v1/tuya/config/matter-token 返回的 Matter Fabric 凭证 */
public class MatterTokenResp {
/** 涂鸦云 Matter Fabric 配网 token */
public String token;
/** Fabric / space 标识(一般为数字字符串,作为 commissioning 的 spaceId) */
public String uuid;
}

View File

@ -0,0 +1,41 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
import java.util.List;
/**
* 分页数据模型(对应 MyBatis-Plus Page)
*/
public class PageData<T> {
@SerializedName("records")
private List<T> records;
@SerializedName("total")
private long total;
@SerializedName("current")
private long current;
@SerializedName("size")
private long size;
@SerializedName("pages")
private long pages;
public List<T> getRecords() { return records; }
public void setRecords(List<T> records) { this.records = records; }
public long getTotal() { return total; }
public void setTotal(long total) { this.total = total; }
public long getCurrent() { return current; }
public void setCurrent(long current) { this.current = current; }
public long getSize() { return size; }
public void setSize(long size) { this.size = size; }
public long getPages() { return pages; }
public void setPages(long pages) { this.pages = pages; }
}

View File

@ -0,0 +1,29 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 绑定结果(POST /api/v1/member/panels/bind)
* { panelId, serialNumber, homeId, memberId, role, boundAt }
*/
public class PanelBindResult {
@SerializedName("panelId")
private Long panelId;
@SerializedName("serialNumber")
private String serialNumber;
@SerializedName("homeId")
private Long homeId;
@SerializedName("memberId")
private Long memberId;
@SerializedName("role")
private String role;
@SerializedName("boundAt")
private String boundAt;
public Long getPanelId() { return panelId; }
public String getSerialNumber() { return serialNumber; }
public Long getHomeId() { return homeId; }
public Long getMemberId() { return memberId; }
public String getRole() { return role; }
public String getBoundAt() { return boundAt; }
}

View File

@ -0,0 +1,23 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 扫码预检返回(GET /api/v1/member/panels/bindable)
* { exists, enabled, bound, panelId }
*/
public class PanelBindable {
@SerializedName("exists")
private boolean exists;
@SerializedName("enabled")
private boolean enabled;
@SerializedName("bound")
private boolean bound;
@SerializedName("panelId")
private Long panelId;
public boolean isExists() { return exists; }
public boolean isEnabled() { return enabled; }
public boolean isBound() { return bound; }
public Long getPanelId() { return panelId; }
}

View File

@ -0,0 +1,38 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 已绑定的中控屏条目(GET /api/v1/member/panels)
* { panelId, serialNumber, homeId, memberId, role, enabled, online, model, lastSeenAt }
*/
public class PanelInfo {
@SerializedName("panelId")
private Long panelId;
@SerializedName("serialNumber")
private String serialNumber;
@SerializedName("homeId")
private Long homeId;
@SerializedName("memberId")
private Long memberId;
@SerializedName("role")
private String role;
@SerializedName("enabled")
private boolean enabled;
@SerializedName("online")
private boolean online;
@SerializedName("model")
private String model;
@SerializedName("lastSeenAt")
private String lastSeenAt;
public Long getPanelId() { return panelId; }
public String getSerialNumber() { return serialNumber; }
public Long getHomeId() { return homeId; }
public Long getMemberId() { return memberId; }
public String getRole() { return role; }
public boolean isEnabled() { return enabled; }
public boolean isOnline() { return online; }
public String getModel() { return model; }
public String getLastSeenAt() { return lastSeenAt; }
}

View File

@ -0,0 +1,57 @@
package com.xhai.iot.model;
/**
* 统一智能设备模型
* 聚合多平台(涂鸦/谷歌/华为/小米等)设备,用于设备列表聚合展示
*/
public class PlatformDevice {
/** 设备唯一ID(平台内唯一) */
private String deviceId;
/** 设备名称 */
private String name;
/** 所属平台 */
private DevicePlatform platform;
/** 是否在线 */
private boolean online;
/** 设备类型 */
private String category;
/** 产品ID */
private String productId;
/** 图标URL */
private String iconUrl;
/** 本平台家庭ID */
private long homeId;
/** 家庭名称 */
private String homeName;
public PlatformDevice() {
}
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public DevicePlatform getPlatform() { return platform; }
public void setPlatform(DevicePlatform platform) { this.platform = platform; }
public boolean isOnline() { return online; }
public void setOnline(boolean online) { this.online = online; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
public String getIconUrl() { return iconUrl; }
public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; }
public long getHomeId() { return homeId; }
public void setHomeId(long homeId) { this.homeId = homeId; }
public String getHomeName() { return homeName; }
public void setHomeName(String homeName) { this.homeName = homeName; }
}

View File

@ -0,0 +1,39 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 后端统一响应结构
* { code, message, data, timestamp }
*/
public class Result<T> {
@SerializedName("code")
private int code;
@SerializedName("message")
private String message;
@SerializedName("data")
private T data;
@SerializedName("timestamp")
private long timestamp;
public int getCode() { return code; }
public void setCode(int code) { this.code = code; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public T getData() { return data; }
public void setData(T data) { this.data = data; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
/** 后端 code=200 或 code=0 视为成功 */
public boolean isSuccess() {
return code == 200 || code == 0;
}
}

View File

@ -0,0 +1,63 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 场景数据模型(对应后端 SceneEntity)
*/
public class SceneItem {
@SerializedName("id")
private Long id;
@SerializedName("sceneName")
private String sceneName;
@SerializedName("sceneCode")
private String sceneCode;
@SerializedName("sceneType")
private String sceneType;
@SerializedName("icon")
private String icon;
@SerializedName("description")
private String description;
@SerializedName("enabled")
private Boolean enabled;
@SerializedName("sortOrder")
private Integer sortOrder;
@SerializedName("ownerId")
private Long ownerId;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSceneName() { return sceneName; }
public void setSceneName(String sceneName) { this.sceneName = sceneName; }
public String getSceneCode() { return sceneCode; }
public void setSceneCode(String sceneCode) { this.sceneCode = sceneCode; }
public String getSceneType() { return sceneType; }
public void setSceneType(String sceneType) { this.sceneType = sceneType; }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Long getOwnerId() { return ownerId; }
public void setOwnerId(Long ownerId) { this.ownerId = ownerId; }
}

View File

@ -0,0 +1,48 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 后端 GET /api/v1/ai/speech/auth 的响应体(data 部分)
*
* <p>安全设计:APISecret 只留在服务端,App 领取带签名的临时 wss 地址后直连讯飞;
* enabled=false 表示后台未启用讯飞语音听写,App 应回退系统识别 / 键盘输入。</p>
*/
public class SpeechAuth {
/** 讯飞语音听写是否已启用 */
@SerializedName("enabled")
private boolean enabled;
/** 讯飞 APPID(WebSocket 首帧 common.app_id 使用) */
@SerializedName("appId")
private String appId;
/** 带 HMAC-SHA256 签名的 wss 地址(签名时效约 5 分钟,领取后请立即使用) */
@SerializedName("url")
private String url;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}

View File

@ -0,0 +1,101 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
import java.util.List;
import java.util.Map;
/**
* 涂鸦设备信息
*/
public class TuyaDevice {
@SerializedName("id")
private String id;
@SerializedName("name")
private String name;
@SerializedName("online")
private boolean online;
@SerializedName("productId")
private String productId;
@SerializedName("productName")
private String productName;
@SerializedName("category")
private String category;
@SerializedName("icon")
private String icon;
@SerializedName("status")
private List<Map<String, Object>> status;
@SerializedName("bound")
private boolean bound;
@SerializedName("localDeviceId")
private Long localDeviceId;
@SerializedName("platform")
private String platform;
/** 设备实时 DP 状态(code → value),用于卡片开关同步显示 */
@SerializedName("dps")
private Map<String, Object> dps;
/** 父网关设备ID(本平台维护"网关→子设备"关系) */
@SerializedName("parentDeviceId")
private String parentDeviceId;
/** 是否为网关 */
@SerializedName("isGateway")
private Integer isGateway;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public boolean isOnline() { return online; }
public void setOnline(boolean online) { this.online = online; }
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public List<Map<String, Object>> getStatus() { return status; }
public void setStatus(List<Map<String, Object>> status) { this.status = status; }
public boolean isBound() { return bound; }
public void setBound(boolean bound) { this.bound = bound; }
public Long getLocalDeviceId() { return localDeviceId; }
public void setLocalDeviceId(Long localDeviceId) { this.localDeviceId = localDeviceId; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public Map<String, Object> getDps() { return dps; }
public void setDps(Map<String, Object> dps) { this.dps = dps; }
public String getParentDeviceId() { return parentDeviceId; }
public void setParentDeviceId(String parentDeviceId) { this.parentDeviceId = parentDeviceId; }
public Integer getIsGateway() { return isGateway; }
public void setIsGateway(Integer isGateway) { this.isGateway = isGateway; }
public boolean isGateway() { return isGateway != null && isGateway == 1; }
}

View File

@ -0,0 +1,39 @@
package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
/**
* 涂鸦家庭信息
*/
public class TuyaHome {
@SerializedName("homeId")
private long homeId;
@SerializedName("name")
private String name;
@SerializedName("geoName")
private String geoName;
@SerializedName("role")
private String role;
@SerializedName("deviceCount")
private int deviceCount;
public long getHomeId() { return homeId; }
public void setHomeId(long homeId) { this.homeId = homeId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getGeoName() { return geoName; }
public void setGeoName(String geoName) { this.geoName = geoName; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public int getDeviceCount() { return deviceCount; }
public void setDeviceCount(int deviceCount) { this.deviceCount = deviceCount; }
}

View File

@ -0,0 +1,66 @@
package com.xhai.iot.service;
import android.util.Log;
import com.thingclips.smart.api.MicroContext;
import com.thingclips.smart.commonbiz.bizbundle.family.api.AbsBizBundleFamilyService;
import com.thingclips.smart.commonbiz.relation.api.AbsRelationService;
/**
* 涂鸦 UI BizBundle 家庭服务实现。
* 面板(Panel)等 BizBundle 需要该服务来获取当前家庭。
* <p>
* 重要:除了 AbsBizBundleFamilyService,涂鸦面板内部(gotoRNActivity)还会读取
* AbsRelationService.getCurrentGid(),若 gid==0 会静默 return 不打开面板。
* 因此 {@link #updateCurrentHomeId(long, String)} 会同时同步这两个服务。
*/
public class BizBundleFamilyServiceImpl extends AbsBizBundleFamilyService {
private static final String TAG = "BizBundleFamily";
private static volatile long sCurrentHomeId;
private static volatile String sCurrentHomeName;
/**
* 供 App 内其它模块同步当前家庭。
* 同时同步 RelationService 的 gid(面板跳转依赖,缺失会导致面板静默打不开)。
*/
public static void updateCurrentHomeId(long homeId, String homeName) {
sCurrentHomeId = homeId;
sCurrentHomeName = homeName;
try {
AbsRelationService relationService = MicroContext.getServiceManager()
.findServiceByInterface(AbsRelationService.class.getName());
if (relationService != null) {
relationService.shiftCurrentRelation(homeId, homeName != null ? homeName : "");
Log.i(TAG, "RelationService gid 已同步: " + homeId);
} else {
Log.w(TAG, "AbsRelationService 未注册,面板可能无法打开");
}
} catch (Throwable t) {
Log.e(TAG, "同步 RelationService 失败: " + t.getMessage());
}
}
/** 兼容旧调用(仅 homeId) */
public static void updateCurrentHomeId(long homeId) {
updateCurrentHomeId(homeId, sCurrentHomeName);
}
/** 获取当前同步的涂鸦家庭 gid(静态,供面板路由判断是否需要同步) */
public static long getCurrentGid() {
return sCurrentHomeId;
}
@Override
public long getCurrentHomeId() {
return sCurrentHomeId;
}
@Override
public void shiftCurrentFamily(long familyId, String curName) {
super.shiftCurrentFamily(familyId, curName);
sCurrentHomeId = familyId;
sCurrentHomeName = curName;
}
}

View File

@ -0,0 +1,62 @@
package com.xhai.iot.service;
import com.xhai.iot.model.DevicePlatform;
import com.xhai.iot.model.PlatformDevice;
import java.util.List;
import java.util.Map;
/**
* 智能家居平台服务接口
* 每个平台(涂鸦/谷歌/华为/小米等)实现此接口,提供统一的能力抽象。
* 设计目标:App 端通过 PlatformServiceManager 统一调用,实现多平台设备聚合接入。
*/
public interface IPlatformService {
/** 当前服务对应的平台 */
DevicePlatform getPlatform();
/** 该平台是否已绑定账号 */
boolean isBound();
/** 获取平台家庭列表 */
List<PlatformHomeInfo> getHomes();
/** 获取指定家庭下的设备列表 */
List<PlatformDevice> getDevices(long homeId);
/** 获取该平台所有设备(跨家庭聚合) */
List<PlatformDevice> getAllDevices();
/**
* 下发设备控制指令
* @param deviceId 设备ID
* @param commands 指令(dp/属性映射,如 {"1": true} 或 {"power": "on"})
*/
void controlDevice(String deviceId, Map<String, Object> commands,
ControlCallback callback);
/**
* 解绑平台账号
*/
void unbind();
/** 简单家庭信息 */
class PlatformHomeInfo {
public long homeId;
public String name;
public int deviceCount;
public PlatformHomeInfo(long homeId, String name, int deviceCount) {
this.homeId = homeId;
this.name = name;
this.deviceCount = deviceCount;
}
}
/** 控制结果回调 */
interface ControlCallback {
void onSuccess();
void onError(String code, String error);
}
}

View File

@ -0,0 +1,159 @@
package com.xhai.iot.service;
import android.content.Context;
import android.util.Log;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.DevicePlatform;
import com.xhai.iot.model.PlatformDevice;
import com.xhai.iot.model.Result;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 平台服务管理器
* 统一管理各平台服务实例,向 UI 层提供多平台设备聚合能力。
* 新增平台时:在 register 中注册对应 IPlatformService 实现即可。
*/
public class PlatformServiceManager {
private static final String TAG = "PlatformServiceManager";
private static PlatformServiceManager instance;
private final Map<DevicePlatform, IPlatformService> services = new LinkedHashMap<>();
private final Context context;
private PlatformServiceManager(Context context) {
this.context = context.getApplicationContext();
register(new TuyaPlatformService(this.context));
// 后续新增平台在此注册:
// register(new GooglePlatformService(this.context));
// register(new HuaweiPlatformService(this.context));
// register(new XiaomiPlatformService(this.context));
}
public static synchronized PlatformServiceManager getInstance(Context context) {
if (instance == null) {
instance = new PlatformServiceManager(context);
}
return instance;
}
/** 注册平台服务 */
public void register(IPlatformService service) {
services.put(service.getPlatform(), service);
}
/** 获取指定平台服务 */
public IPlatformService getService(DevicePlatform platform) {
return services.get(platform);
}
/** 获取所有已注册平台服务 */
public List<IPlatformService> getAllServices() {
return new ArrayList<>(services.values());
}
/** 获取所有已绑定平台的服务 */
public List<IPlatformService> getBoundServices() {
List<IPlatformService> bound = new ArrayList<>();
for (IPlatformService s : services.values()) {
if (s.isBound()) {
bound.add(s);
}
}
return bound;
}
/** 聚合所有已绑定平台的设备 */
public List<PlatformDevice> getAllBoundDevices() {
List<PlatformDevice> result = new ArrayList<>();
for (IPlatformService s : getBoundServices()) {
try {
List<PlatformDevice> devices = s.getAllDevices();
if (devices != null) {
result.addAll(devices);
}
} catch (Exception e) {
// 单个平台异常不影响其他平台
}
}
return result;
}
/** 下发控制指令(自动按设备平台分发) */
public void controlDevice(PlatformDevice device, Map<String, Object> commands,
IPlatformService.ControlCallback callback) {
IPlatformService service = getService(device.getPlatform());
if (service == null) {
if (callback != null) {
callback.onError("unsupported", "不支持的设备平台: " + device.getPlatform());
}
return;
}
// 统一在成功回调里上报控制动态:APP 走 SDK 直连不经后端,
// 不加这一层的话首页「控制记录/近期动态」永远为空(所有控制入口都经过这里)
service.controlDevice(device.getDeviceId(), commands,
new IPlatformService.ControlCallback() {
@Override
public void onSuccess() {
reportActivity(device, commands);
if (callback != null) callback.onSuccess();
}
@Override
public void onError(String code, String error) {
if (callback != null) callback.onError(code, error);
}
});
}
/** 上报控制动态到后端(失败静默,不影响控制) */
private void reportActivity(PlatformDevice device, Map<String, Object> commands) {
try {
Context ctx = context;
if (ctx == null || device == null) return;
String auth = SessionManager.getInstance(ctx).getBearerToken();
if (auth == null) return;
String action = "adjust";
for (Map.Entry<String, Object> en : commands.entrySet()) {
if (en.getValue() instanceof Boolean) {
action = ((Boolean) en.getValue()) ? "switch_on" : "switch_off";
break;
}
}
String name = device.getName() != null ? device.getName() : "设备";
String desc;
if ("switch_on".equals(action)) desc = "打开 " + name;
else if ("switch_off".equals(action)) desc = "关闭 " + name;
else desc = "调节 " + name;
Map<String, Object> body = new HashMap<>();
body.put("action", action);
body.put("actionDesc", desc);
body.put("detail", commands);
body.put("commandId", java.util.UUID.randomUUID().toString());
ApiClient.getApiService()
.reportDeviceActivity(auth, device.getDeviceId(), body)
.enqueue(new retrofit2.Callback<Result<Void>>() {
@Override
public void onResponse(retrofit2.Call<Result<Void>> call,
retrofit2.Response<Result<Void>> response) {
}
@Override
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
Log.w(TAG, "上报控制动态失败: " + t.getMessage());
}
});
} catch (Exception e) {
Log.w(TAG, "上报控制动态异常: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,199 @@
package com.xhai.iot.service;
import android.content.Context;
import android.util.Log;
import com.alibaba.fastjson.JSONObject;
import com.thingclips.smart.android.blemesh.api.IThingBlueMeshDevice;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
import com.thingclips.smart.sdk.api.IResultCallback;
import com.thingclips.smart.sdk.api.IThingDevice;
import com.thingclips.smart.sdk.bean.DeviceBean;
import com.thingclips.sdk.os.ThingOSMesh;
import com.xhai.iot.model.DevicePlatform;
import com.xhai.iot.model.PlatformDevice;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 涂鸦平台服务实现
* 基于涂鸦 thingsmart SDK,实现 IPlatformService 统一接口。
* 这是多平台架构的第一个实现,后续谷歌/华为/小米按同样接口扩展。
*/
public class TuyaPlatformService implements IPlatformService {
private static final String TAG = "TuyaPlatformService";
private final Context context;
public TuyaPlatformService(Context context) {
this.context = context.getApplicationContext();
}
@Override
public DevicePlatform getPlatform() {
return DevicePlatform.TUYA;
}
@Override
public boolean isBound() {
return SessionManager.getInstance(context).isTuyaBound();
}
@Override
public List<PlatformHomeInfo> getHomes() {
List<PlatformHomeInfo> result = new ArrayList<>();
try {
ThingHomeSdk.getHomeManagerInstance().queryHomeList(
new com.thingclips.smart.home.sdk.callback.IThingGetHomeListCallback() {
@Override
public void onSuccess(
List<com.thingclips.smart.home.sdk.bean.HomeBean> homes) {
if (homes != null) {
for (com.thingclips.smart.home.sdk.bean.HomeBean home : homes) {
int count = home.getDeviceList() != null
? home.getDeviceList().size() : 0;
result.add(new PlatformHomeInfo(
home.getHomeId(), home.getName(), count));
}
}
}
@Override
public void onError(String code, String error) {
Log.e(TAG, "获取家庭列表失败: " + code + " " + error);
}
});
} catch (Exception e) {
Log.e(TAG, "获取家庭列表异常", e);
}
return result;
}
@Override
public List<PlatformDevice> getDevices(long homeId) {
List<PlatformDevice> result = new ArrayList<>();
try {
com.thingclips.smart.home.sdk.api.IThingHome home =
ThingHomeSdk.newHomeInstance(homeId);
com.thingclips.smart.home.sdk.bean.HomeBean homeBean = home.getHomeBean();
if (homeBean != null && homeBean.getDeviceList() != null) {
for (DeviceBean dev : homeBean.getDeviceList()) {
result.add(toPlatformDevice(dev));
}
}
} catch (Exception e) {
Log.e(TAG, "获取设备列表异常", e);
}
return result;
}
@Override
public List<PlatformDevice> getAllDevices() {
List<PlatformDevice> result = new ArrayList<>();
try {
// 遍历所有家庭,聚合设备
List<PlatformHomeInfo> homes = getHomes();
for (PlatformHomeInfo home : homes) {
List<DeviceBean> devices =
ThingHomeSdk.getDataInstance().getHomeDeviceList(home.homeId);
if (devices != null) {
for (DeviceBean dev : devices) {
PlatformDevice pd = toPlatformDevice(dev);
pd.setHomeId(home.homeId);
pd.setHomeName(home.name);
result.add(pd);
}
}
}
} catch (Exception e) {
Log.e(TAG, "获取全部设备异常", e);
}
return result;
}
private PlatformDevice toPlatformDevice(DeviceBean dev) {
PlatformDevice pd = new PlatformDevice();
pd.setDeviceId(dev.getDevId());
pd.setName(dev.getName());
pd.setPlatform(DevicePlatform.TUYA);
pd.setOnline(Boolean.TRUE.equals(dev.getIsOnline()));
pd.setCategory(dev.getCategory());
pd.setProductId(dev.getProductId());
pd.setIconUrl(dev.getIconUrl());
return pd;
}
@Override
public void controlDevice(String deviceId, Map<String, Object> commands,
ControlCallback callback) {
try {
DeviceBean dev = ThingHomeSdk.getDataInstance().getDeviceBean(deviceId);
// 蓝牙 Mesh 子设备:走 Mesh 本地控制通道(云端 publishDps 对离线 Mesh 子设备会失败)
if (dev != null && dev.isSigMesh()) {
controlSigMeshDevice(dev, commands, callback);
return;
}
// 普通设备:云端 publishDps(统一把 DP 数字 id 转换为涂鸦 code)
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
Map<String, Object> codeCommands =
TuyaSdkService.convertDpIdToCode(deviceId, commands);
String json = JSONObject.toJSONString(codeCommands);
device.publishDps(json, new IResultCallback() {
@Override
public void onSuccess() {
if (callback != null) callback.onSuccess();
}
@Override
public void onError(String code, String error) {
if (callback != null) callback.onError(code, error);
}
});
} catch (Exception e) {
if (callback != null) callback.onError("ex", e.getMessage());
}
}
/** 控制蓝牙 Mesh 子设备(走 Mesh 本地通道) */
private void controlSigMeshDevice(DeviceBean dev, Map<String, Object> commands,
ControlCallback callback) {
try {
String meshId = dev.getMeshId();
String nodeId = dev.getNodeId();
String category = dev.getCategory() != null ? dev.getCategory() : dev.getCategoryCode();
// Mesh publishCommands 需要的是涂鸦 DP code("switch_1"),不是数字 id("1")
Map<String, Object> codeCommands =
TuyaSdkService.convertDpIdToCode(dev.getDevId(), commands);
Log.i(TAG, "Mesh子设备控制 meshId=" + meshId + " nodeId=" + nodeId
+ " category=" + category
+ " cmd=" + codeCommands);
IThingBlueMeshDevice meshDev = ThingOSMesh.newDevice(meshId);
meshDev.publishCommands(nodeId, category, codeCommands, new IResultCallback() {
@Override
public void onSuccess() {
Log.i(TAG, "Mesh控制成功 nodeId=" + nodeId);
if (callback != null) callback.onSuccess();
}
@Override
public void onError(String code, String error) {
Log.e(TAG, "Mesh控制失败 nodeId=" + nodeId + " code=" + code
+ " err=" + error);
if (callback != null) callback.onError(code, error);
}
});
} catch (Exception e) {
Log.e(TAG, "Mesh控制异常: " + e.getMessage(), e);
if (callback != null) callback.onError("ex", e.getMessage());
}
}
@Override
public void unbind() {
SessionManager.getInstance(context).setTuyaBound(false);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,478 @@
package com.xhai.iot.speech;
import android.annotation.SuppressLint;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
/**
* 讯飞「语音听写(流式版)」WebAPI 客户端。
*
* <p>链路:AudioRecord 采 16kHz/16bit/单声道 PCM → 按 40ms(1280 字节)分帧 base64
* 通过 WebSocket 推给 iat-api.xfyun.cn → 边收边通过 {@link Listener#onPartial} 回传中间结果,
* 服务端检测到停顿(eos)后返回 status=2,以 {@link Listener#onFinal} 通知最终文本。
*
* <p>所有回调都在主线程;使用完必须调用 {@link #release()} 释放录音与连接。
*
* <p>鉴权方式:调用方先通过后端 {@code GET /api/v1/ai/speech/auth} 领取带签名的临时
* wss 地址(APISecret 全程不出后端),再传入 {@link #start(String, String)}。
*/
public class XfyunIatClient {
/** 识别结果回调,均在主线程触发 */
public interface Listener {
/** 实时中间结果(动态修正后的完整文本) */
void onPartial(String text);
/** 最终结果;用户全程没说话时可能为空串 */
void onFinal(String text);
/** 失败原因,已是可直接展示给用户的文案 */
void onError(String message);
}
private static final String TAG = "XfyunIat";
private static final int SAMPLE_RATE = 16000;
/** 16k / 16bit / 单声道下每 40ms 的字节数,官方建议的发送节奏 */
private static final int FRAME_BYTES = 1280;
/** 服务端静音检测时长,停顿超过该时长自动结束本轮识别 */
private static final int EOS_MS = 3000;
/** 握手超时 */
private static final long HANDSHAKE_TIMEOUT_MS = 10_000L;
/** 单次识别总时长上限(官方限制 60s),留出收尾余量 */
private static final long MAX_DURATION_MS = 55_000L;
/** 主动结束后等待服务端吐完结果的时间 */
private static final long FLUSH_TIMEOUT_MS = 2_000L;
private final Listener listener;
private final Handler main = new Handler(Looper.getMainLooper());
private final OkHttpClient client;
/** sn -> 该片段文本,用有序表按序拼接,并在动态修正(rpl)时按 rg 区间删除 */
private final TreeMap<Integer, String> segments = new TreeMap<>();
private final AtomicBoolean firstFrame = new AtomicBoolean(true);
private final AtomicBoolean finished = new AtomicBoolean(false);
/** 后端签发的讯飞 APPID(首帧 common.app_id 用) */
private String appId;
private WebSocket socket;
private AudioRecord recorder;
private Thread recordThread;
private volatile boolean recording;
private long startedAt;
private final Runnable handshakeTimeout = () -> finishWithError("连接讯飞超时,请检查网络后重试");
private final Runnable flushTimeout = () -> finishNormal();
private final Runnable maxDurationTimeout = () -> {
// 到达上限还没结束,按当前已识别内容收尾
if (!finished.get()) {
stop();
}
};
public XfyunIatClient(Listener listener) {
this.listener = listener;
this.client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
// WebSocket 是长连接,不能设读超时,否则空闲时会被断开
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build();
}
/**
* 开始一轮识别(异步,结果通过 listener 回调)。
*
* @param appId 讯飞 APPID(后端签发)
* @param wssUrl 后端签发的带签名 wss 地址(时效约 5 分钟,领取后立即使用)
*/
@SuppressLint("MissingPermission")
public void start(String appId, String wssUrl) {
this.appId = appId;
if (wssUrl == null || wssUrl.trim().isEmpty()) {
finishWithError("未获取到语音服务鉴权地址,请稍后重试");
return;
}
int minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
if (minBuffer <= 0) {
finishWithError("当前设备不支持 16kHz 录音,请改用文字输入");
return;
}
try {
recorder = new AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
Math.max(minBuffer, FRAME_BYTES * 4));
} catch (Exception e) {
Log.w(TAG, "AudioRecord init failed", e);
finishWithError("录音初始化失败,请检查录音权限");
return;
}
if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
releaseRecorder();
finishWithError("录音初始化失败,请检查录音权限");
return;
}
startedAt = System.currentTimeMillis();
socket = client.newWebSocket(new Request.Builder().url(wssUrl).build(), new WsListener());
main.postDelayed(handshakeTimeout, HANDSHAKE_TIMEOUT_MS);
}
/**
* 主动结束本轮识别(用户再点一次麦克风,或到达时长上限)。
* 会先停止录音并通知服务端收尾,随后以已识别内容回调 onFinal。
*/
public void stop() {
if (finished.get()) {
return;
}
releaseRecorder();
WebSocket ws = socket;
if (ws != null) {
try {
JSONObject root = new JSONObject();
JSONObject data = new JSONObject();
data.put("status", 2);
data.put("format", "audio/L16;rate=" + SAMPLE_RATE);
data.put("encoding", "raw");
data.put("audio", "");
root.put("data", data);
ws.send(root.toString());
} catch (Exception e) {
Log.w(TAG, "send empty tail frame failed", e);
}
}
// 服务端正常会立刻回 status=2;万一没回,用当前结果收尾,避免一直卡在"聆听中"
main.postDelayed(flushTimeout, FLUSH_TIMEOUT_MS);
}
/** 释放录音与网络资源,务必在 Activity.onDestroy 中调用 */
public void release() {
finished.set(true);
recording = false;
main.removeCallbacksAndMessages(null);
releaseRecorder();
closeSocket();
try {
client.connectionPool().evictAll();
} catch (Exception ignored) {
}
}
// ==================== WebSocket ====================
private class WsListener extends WebSocketListener {
@Override
public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) {
if (finished.get()) {
return;
}
main.removeCallbacks(handshakeTimeout);
main.postDelayed(maxDurationTimeout, MAX_DURATION_MS);
startRecording();
}
@Override
public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
handleMessage(text);
}
@Override
public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable t,
@Nullable Response response) {
if (finished.get()) {
return;
}
int code = response == null ? 0 : response.code();
String detail = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
finishWithError("连接讯飞失败:" + detail + (code > 0 ? "(HTTP " + code + ")" : ""));
}
@Override
public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
finishNormal();
}
}
private void handleMessage(String text) {
try {
JSONObject root = new JSONObject(text);
int code = root.optInt("code", -1);
if (code != 0) {
finishWithError(friendlyError(code, root.optString("message", "")));
return;
}
JSONObject data = root.optJSONObject("data");
if (data == null) {
return;
}
JSONObject result = data.optJSONObject("result");
if (result != null) {
handleResult(result);
}
if (data.optInt("status", 0) == 2) {
finishNormal();
}
} catch (Exception e) {
Log.w(TAG, "handleMessage failed: " + text, e);
}
}
private void handleResult(JSONObject result) {
int sn = result.optInt("sn", 0);
String pgs = result.optString("pgs", "");
// 动态修正:rpl 表示要替换 rg 区间内已经吐出的片段
if ("rpl".equals(pgs)) {
JSONArray rg = result.optJSONArray("rg");
if (rg != null && rg.length() >= 2) {
for (int i = rg.optInt(0); i <= rg.optInt(1); i++) {
segments.remove(i);
}
}
}
String piece = joinWords(result.optJSONArray("ws"));
if (!piece.isEmpty()) {
segments.put(sn, piece);
}
// 先取好快照再切回主线程,避免多线程读写同一集合
final String snapshot = joinSegments();
main.post(() -> {
if (!finished.get()) {
listener.onPartial(snapshot);
}
});
}
/** 把 ws 数组里每个词的候选(取第一个)拼成本次片段文本 */
private String joinWords(JSONArray ws) {
if (ws == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ws.length(); i++) {
JSONObject word = ws.optJSONObject(i);
if (word == null) {
continue;
}
JSONArray candidates = word.optJSONArray("cw");
if (candidates == null || candidates.length() == 0) {
continue;
}
JSONObject first = candidates.optJSONObject(0);
if (first != null) {
sb.append(first.optString("w", ""));
}
}
return sb.toString();
}
private String joinSegments() {
StringBuilder sb = new StringBuilder();
for (Map.Entry<Integer, String> entry : segments.entrySet()) {
sb.append(entry.getValue());
}
return sb.toString();
}
// ==================== 录音发送 ====================
private void startRecording() {
AudioRecord target = recorder;
if (target == null) {
finishWithError("录音初始化失败,请检查录音权限");
return;
}
try {
target.startRecording();
} catch (Exception e) {
Log.w(TAG, "startRecording failed", e);
finishWithError("录音启动失败,请重试");
return;
}
recording = true;
recordThread = new Thread(this::recordLoop, "xfyun-record");
recordThread.start();
}
private void recordLoop() {
byte[] buffer = new byte[FRAME_BYTES];
while (recording && !finished.get()) {
AudioRecord target = recorder;
if (target == null) {
break;
}
int read;
try {
read = target.read(buffer, 0, buffer.length);
} catch (Exception e) {
Log.w(TAG, "read failed", e);
break;
}
if (read <= 0) {
continue;
}
boolean last = System.currentTimeMillis() - startedAt >= MAX_DURATION_MS;
sendFrame(buffer, read, last);
if (last) {
break;
}
}
}
private void sendFrame(byte[] data, int length, boolean last) {
WebSocket ws = socket;
if (ws == null || finished.get()) {
return;
}
boolean isFirst = firstFrame.compareAndSet(true, false);
try {
JSONObject root = new JSONObject();
if (isFirst) {
JSONObject common = new JSONObject();
common.put("app_id", appId);
JSONObject business = new JSONObject();
business.put("language", "zh_cn");
business.put("domain", "iat");
business.put("accent", "mandarin");
// wpgs:开启动态修正,边说边出字(配合上面的 rpl 处理)
business.put("dwa", "wpgs");
// 停顿多久算一句话结束
business.put("eos", EOS_MS);
// 数字转阿拉伯,便于后端解析"调到25度"这类指令
business.put("nunum", 1);
// 自动加标点,提升可读性
business.put("ptt", 1);
root.put("common", common);
root.put("business", business);
}
JSONObject payload = new JSONObject();
payload.put("status", last ? 2 : (isFirst ? 0 : 1));
payload.put("format", "audio/L16;rate=" + SAMPLE_RATE);
payload.put("encoding", "raw");
payload.put("audio", Base64.encodeToString(data, 0, length, Base64.NO_WRAP));
root.put("data", payload);
ws.send(root.toString());
} catch (Exception e) {
Log.w(TAG, "sendFrame failed", e);
}
}
// ==================== 收尾 ====================
private void finishNormal() {
if (!finished.compareAndSet(false, true)) {
return;
}
main.removeCallbacks(handshakeTimeout);
main.removeCallbacks(flushTimeout);
main.removeCallbacks(maxDurationTimeout);
releaseRecorder();
closeSocket();
final String text = joinSegments();
main.post(() -> listener.onFinal(text));
}
private void finishWithError(String message) {
if (!finished.compareAndSet(false, true)) {
return;
}
main.removeCallbacks(handshakeTimeout);
main.removeCallbacks(flushTimeout);
main.removeCallbacks(maxDurationTimeout);
releaseRecorder();
closeSocket();
main.post(() -> listener.onError(message));
}
private synchronized void releaseRecorder() {
recording = false;
AudioRecord target = recorder;
recorder = null;
if (target == null) {
return;
}
try {
if (target.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
target.stop();
}
} catch (Exception ignored) {
}
try {
target.release();
} catch (Exception ignored) {
}
}
private void closeSocket() {
WebSocket ws = socket;
socket = null;
if (ws != null) {
try {
ws.close(1000, "bye");
} catch (Exception ignored) {
}
}
}
/** 讯飞错误码转成用户能看懂的提示 */
private String friendlyError(int code, String message) {
switch (code) {
case 10161:
return "讯飞鉴权失败,请检查 APIKey / APISecret,并确认手机时间准确";
case 10313:
return "讯飞 AppID 与 Key 不匹配,请核对凭证配置";
case 10202:
return "讯飞授权已失效,请重新申请";
case 11201:
return "讯飞识别额度已用尽,请改用文字输入";
case 10163:
case 10160:
return "讯飞请求参数错误,请核对音频参数与凭证";
case 10165:
return "识别会话超时,请重试";
case 10700:
return "讯飞识别引擎未初始化,请稍后重试";
default:
String suffix = message == null || message.isEmpty() ? "" : ":" + message;
return "讯飞识别失败(" + code + ")" + suffix;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,203 @@
package com.xhai.iot.ui;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.BackendDevice;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 添加子设备:把未归属的设备绑定到当前网关(device.parent_device_id = 网关id)。
* 真实硬件配网时:子设备入网后会自动出现在本平台设备列表,再在此处选择绑定;
* 列表可点"刷新"重新加载。
*/
public class AddSubDeviceActivity extends AppCompatActivity {
private String gatewayId;
private String gatewayName;
private SessionManager session;
private TextView tvGatewayHint;
private TextView tvEmpty;
private RecyclerView rvAvailable;
private AvailableAdapter adapter;
private final List<BackendDevice> available = new ArrayList<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_sub_device);
gatewayId = getIntent().getStringExtra("deviceId");
gatewayName = getIntent().getStringExtra("deviceName");
if (gatewayId == null) {
finish();
return;
}
session = SessionManager.getInstance(this);
tvGatewayHint = findViewById(R.id.tv_gateway_hint);
tvEmpty = findViewById(R.id.tv_empty);
rvAvailable = findViewById(R.id.rv_available);
tvGatewayHint.setText("当前网关:" + (gatewayName != null ? gatewayName : gatewayId));
findViewById(R.id.tv_back).setOnClickListener(v -> finish());
findViewById(R.id.tv_refresh).setOnClickListener(v -> loadAvailable());
adapter = new AvailableAdapter();
rvAvailable.setLayoutManager(new LinearLayoutManager(this));
rvAvailable.setAdapter(adapter);
loadAvailable();
}
/** 加载"未归属任何网关且非网关"的设备(可选设备) */
private void loadAvailable() {
String auth = session.getBearerToken();
if (auth == null) return;
tvEmpty.setText("加载中...");
tvEmpty.setVisibility(View.VISIBLE);
ApiClient.getApiService().getDevices(auth, 1, 200, null, null, null)
.enqueue(new Callback<Result<PageData<BackendDevice>>>() {
@Override
public void onResponse(Call<Result<PageData<BackendDevice>>> call,
Response<Result<PageData<BackendDevice>>> response) {
if (!isFinishing() && !isDestroyed()) {
handleResponse(response);
}
}
@Override
public void onFailure(Call<Result<PageData<BackendDevice>>> call, Throwable t) {
if (!isFinishing() && !isDestroyed()) {
tvEmpty.setText("加载失败: " + t.getMessage());
tvEmpty.setVisibility(View.VISIBLE);
}
}
});
}
private void handleResponse(Response<Result<PageData<BackendDevice>>> response) {
if (response.isSuccessful() && response.body() != null && response.body().isSuccess()
&& response.body().getData() != null) {
available.clear();
List<BackendDevice> all = response.body().getData().getRecords();
if (all != null) {
for (BackendDevice d : all) {
// 非网关 + 未绑定父网关 = 可选
if (!d.isGateway() && (d.getParentDeviceId() == null
|| d.getParentDeviceId().isEmpty())) {
available.add(d);
}
}
}
adapter.notifyDataSetChanged();
tvEmpty.setVisibility(available.isEmpty() ? View.VISIBLE : View.GONE);
if (available.isEmpty()) {
tvEmpty.setText("暂无可添加的设备");
}
}
}
/** 绑定设备到网关 */
private void bindToGateway(BackendDevice device) {
String auth = session.getBearerToken();
if (auth == null) return;
Map<String, String> body = new HashMap<>();
body.put("parentDeviceId", gatewayId);
ApiClient.getApiService().bindDeviceParent(auth, device.getDeviceId(), body)
.enqueue(new Callback<Result<Integer>>() {
@Override
public void onResponse(Call<Result<Integer>> call,
Response<Result<Integer>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(AddSubDeviceActivity.this,
"已将" + device.getDeviceName() + "添加到网关",
Toast.LENGTH_SHORT).show();
// 刷新列表并返回网关面板
setResult(RESULT_OK);
finish();
} else {
Toast.makeText(AddSubDeviceActivity.this,
"添加失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(Call<Result<Integer>> call, Throwable t) {
if (!isFinishing() && !isDestroyed()) {
Toast.makeText(AddSubDeviceActivity.this,
"添加失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
}
});
}
private class AvailableAdapter extends RecyclerView.Adapter<AvailableAdapter.VH> {
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_gateway_sub_device, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
BackendDevice d = available.get(position);
holder.tvName.setText(d.getDeviceName() != null ? d.getDeviceName() : d.getDeviceId());
String type = d.getDeviceType() != null && !d.getDeviceType().isEmpty()
? d.getDeviceType() : d.getPlatform();
boolean online = d.isOnline();
holder.tvStatus.setText((type != null ? type : "设备") + " · "
+ (online ? "在线" : "离线"));
holder.tvStatus.setTextColor(online ? 0xFF07C160 : 0xFF909399);
holder.itemView.setOnClickListener(v -> bindToGateway(d));
}
@Override
public int getItemCount() {
return available.size();
}
class VH extends RecyclerView.ViewHolder {
TextView tvName;
TextView tvStatus;
VH(@NonNull View itemView) {
super(itemView);
tvName = itemView.findViewById(R.id.tv_sub_name);
tvStatus = itemView.findViewById(R.id.tv_sub_status);
}
}
}
}

View File

@ -0,0 +1,937 @@
package com.xhai.iot.ui;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.speech.RecognitionListener;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.AiCommandResult;
import com.xhai.iot.model.AiDeviceInfo;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.SpeechAuth;
import com.xhai.iot.speech.XfyunIatClient;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.ThemeManager;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* HomeAI 助手(设计稿 AIPage)
* <p>
* 真实对接后端 iot-rule 的 AI 指令解析链路:
* · POST /api/v1/ai/command 自然语言指令(L0 规则模板 + L1 大模型兜底,由服务端决定)
* · GET /api/v1/ai/devices 可被 AI 控制的设备(用于生成快捷建议)
* 特性:
* · 展示解析来源(规则模板 / 大模型)与实际耗时
* · 指令存在歧义时,服务端返回 needConfirm + candidates,界面给出候选按钮,
* 用户点选后携带 targetType/targetId 重新下发,服务端会顺势学习该说法为别名
* · 语音输入使用系统 SpeechRecognizer,识别结果自动发送
*/
public class AiAssistantActivity extends AppCompatActivity implements RecognitionListener {
private static final int REQ_AUDIO = 1001;
/** 由首页长按 AI 图标进入时置位:进页后自动唤起一次语音输入 */
public static final String EXTRA_AUTO_VOICE = "extra_auto_voice";
private static final String HINT_DEFAULT = "控制设备、查询状态、执行场景…";
private static final int MAX_SUGGESTION_DEVICES = 3;
/** 后端不可用时的兜底建议 */
private static final String[] FALLBACK_SUGGESTIONS = {
"打开客厅的灯", "空调调到26度", "启动就寝模式", "帮我关掉所有灯"
};
/** 消息体 */
private static class Msg {
final boolean user;
String text;
final String time;
/** 解析来源 · 耗时(仅 AI 消息) */
String meta;
/** 正在思考/执行中 */
boolean thinking;
/** 产生歧义时用户的原始说法,点选候选后据此重新下发 */
String confirmSourceText;
/** 歧义候选 */
List<AiCommandResult.Candidate> candidates;
Msg(boolean user, String text, String time) {
this.user = user;
this.text = text;
this.time = time;
}
}
private final List<Msg> messages = new ArrayList<>();
private MsgAdapter adapter;
private RecyclerView rvMessages;
private EditText etInput;
private TextView tvStatus;
private TextView btnSend;
private SessionManager session;
private int accentColor = 0xFF6B7280;
private int accentTintBg = 0x1F6B7280;
private int mutedColor = 0xFF9CA3AF;
private int normalFgColor = 0xFF111827;
private SpeechRecognizer speechRecognizer;
/** 讯飞语音听写客户端,仅在讯飞识别进行期间非空 */
private XfyunIatClient iatClient;
private boolean listening = false;
private boolean sending = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeManager.getInstance(this).applyNightMode();
setContentView(R.layout.activity_ai_assistant);
ThemeManager.getInstance(this).applySystemBars(this);
ThemeManager tm = ThemeManager.getInstance(this);
accentColor = tm.accent().value;
accentTintBg = tm.accentTintBg();
mutedColor = ContextCompat.getColor(this, R.color.c_muted_fg);
normalFgColor = ContextCompat.getColor(this, R.color.c_fg);
session = SessionManager.getInstance(this);
rvMessages = findViewById(R.id.rv_messages);
etInput = findViewById(R.id.et_input);
tvStatus = findViewById(R.id.tv_status);
btnSend = findViewById(R.id.btn_send);
adapter = new MsgAdapter();
rvMessages.setLayoutManager(new LinearLayoutManager(this));
rvMessages.setAdapter(adapter);
messages.add(welcome());
// 返回 / 清空 / 指令记录
findViewById(R.id.btn_back).setOnClickListener(v -> finish());
findViewById(R.id.btn_clear).setOnClickListener(v -> {
sending = false;
messages.clear();
messages.add(welcome());
adapter.notifyDataSetChanged();
scrollToBottom();
});
findViewById(R.id.btn_history).setOnClickListener(v -> showHistory());
// 发送
btnSend.getBackground().mutate().setTint(accentColor);
btnSend.setOnClickListener(v -> send(etInput.getText().toString()));
// 语音输入(系统语音识别)
findViewById(R.id.btn_voice).setOnClickListener(v -> onVoiceClicked());
adapter.notifyDataSetChanged();
loadSuggestions();
// 首页长按 AI 图标进入:稍后自动唤起语音输入。
// 延后一小段时间等页面转场完成,否则 Activity 尚未获得焦点,识别窗口可能弹不出来。
if (getIntent().getBooleanExtra(EXTRA_AUTO_VOICE, false)) {
etInput.postDelayed(() -> {
if (!isFinishing() && !isDestroyed()) {
onVoiceClicked();
}
}, 350);
}
}
private Msg welcome() {
return new Msg(false,
"你好!我是 HomeAI 助手 🏠\n直接用日常说法就能控制设备,例如:\n"
+ "· 打开客厅的灯\n"
+ "· 空调调到 26 度\n"
+ "· 启动就寝模式",
now());
}
// ==================== 指令下发 ====================
private void send(String raw) {
String text = raw == null ? "" : raw.trim();
if (text.isEmpty()) return;
if (sending) {
Toast.makeText(this, "正在处理上一条指令,请稍候", Toast.LENGTH_SHORT).show();
return;
}
etInput.setText("");
messages.add(new Msg(true, text, now()));
adapter.notifyItemInserted(messages.size() - 1);
int thinkIdx = addThinking("正在理解你的指令…");
scrollToBottom();
dispatch(text, null, null, thinkIdx);
}
/** 新增一条「思考中」气泡,返回其下标 */
private int addThinking(String text) {
Msg thinking = new Msg(false, text, now());
thinking.thinking = true;
messages.add(thinking);
adapter.notifyItemInserted(messages.size() - 1);
return messages.size() - 1;
}
/**
* 调用后端 AI 指令接口
*
* @param targetType/targetId 仅歧义消解时传入(用户已点选候选)
* @param thinkIdx 思考气泡下标,结果回来后就地替换
*/
private void dispatch(String text, String targetType, String targetId, int thinkIdx) {
String auth = session == null ? null : session.getBearerToken();
if (auth == null) {
bindAi(thinkIdx, "请先登录后再使用 AI 助手。", null, null, null);
return;
}
Map<String, Object> body = new HashMap<>();
body.put("text", text);
if (!TextUtils.isEmpty(targetType) && !TextUtils.isEmpty(targetId)) {
body.put("targetType", targetType);
body.put("targetId", targetId);
// 让服务端记住这次消歧结果,下次同样的说法可直接命中
body.put("learn", true);
}
setSending(true);
final String sourceText = text;
ApiClient.getSceneApiService().aiCommand(auth, body)
.enqueue(new Callback<Result<AiCommandResult>>() {
@Override
public void onResponse(@NonNull Call<Result<AiCommandResult>> call,
@NonNull Response<Result<AiCommandResult>> response) {
setSending(false);
if (isFinishing() || isDestroyed()) return;
Result<AiCommandResult> res = response.body();
if (res != null && res.isSuccess() && res.getData() != null) {
applyResult(thinkIdx, sourceText, res.getData());
} else {
String msg = (res != null && !TextUtils.isEmpty(res.getMessage()))
? res.getMessage()
: ("HTTP " + response.code());
bindAi(thinkIdx, "抱歉,指令处理失败:" + msg, null, null, null);
}
}
@Override
public void onFailure(@NonNull Call<Result<AiCommandResult>> call,
@NonNull Throwable t) {
setSending(false);
if (isFinishing() || isDestroyed()) return;
bindAi(thinkIdx,
"网络异常,没能连上 AI 服务:" + (t.getMessage() == null ? "" : t.getMessage()),
null, null, null);
}
});
}
/** 把后端结果渲染到思考气泡上 */
private void applyResult(int idx, String sourceText, AiCommandResult r) {
String meta = null;
String label = r.getParseSourceLabel();
if (label != null) {
meta = "由" + label + "理解";
if (r.getCostMs() != null) {
meta = meta + " · " + r.getCostMs() + "ms";
}
}
List<AiCommandResult.Candidate> candidates =
r.isNeedConfirm() ? r.getCandidates() : null;
if (candidates != null && candidates.isEmpty()) {
candidates = null;
}
String reply = r.getReplyOrEmpty();
if (TextUtils.isEmpty(reply)) {
reply = "已处理你的指令。";
}
bindAi(idx, reply, meta, candidates, sourceText);
}
/** 就地更新某条 AI 消息 */
private void bindAi(int idx, String text, String meta,
List<AiCommandResult.Candidate> candidates, String confirmSourceText) {
if (idx < 0 || idx >= messages.size()) return;
Msg m = messages.get(idx);
m.text = text;
m.meta = meta;
m.candidates = candidates;
m.confirmSourceText = confirmSourceText;
m.thinking = false;
adapter.notifyItemChanged(idx);
scrollToBottom();
}
/** 用户点选歧义候选:携带目标再次下发,服务端会学习该别名 */
private void onCandidateChosen(Msg msg, AiCommandResult.Candidate c) {
if (msg.candidates == null) return;
int idx = messages.indexOf(msg);
if (idx < 0) return;
msg.candidates = null;
msg.meta = null;
msg.text = "你指的是哪一个?";
adapter.notifyItemChanged(idx);
messages.add(new Msg(true, c.getTargetName(), now()));
adapter.notifyItemInserted(messages.size() - 1);
int thinkIdx = addThinking("正在执行…");
scrollToBottom();
dispatch(msg.confirmSourceText, c.getTargetType(), c.getTargetId(), thinkIdx);
}
private void setSending(boolean value) {
sending = value;
if (btnSend != null) {
btnSend.setAlpha(value ? 0.5f : 1f);
}
}
// ==================== 快捷建议 ====================
/** 拉取可控设备,生成贴近真实设备的快捷指令 */
private void loadSuggestions() {
String auth = session == null ? null : session.getBearerToken();
if (auth == null) {
renderSuggestions(null);
return;
}
ApiClient.getSceneApiService().getAiDevices(auth)
.enqueue(new Callback<Result<List<AiDeviceInfo>>>() {
@Override
public void onResponse(@NonNull Call<Result<List<AiDeviceInfo>>> call,
@NonNull Response<Result<List<AiDeviceInfo>>> response) {
if (isFinishing() || isDestroyed()) return;
Result<List<AiDeviceInfo>> res = response.body();
List<AiDeviceInfo> devices =
(res != null && res.isSuccess()) ? res.getData() : null;
renderSuggestions(devices);
}
@Override
public void onFailure(@NonNull Call<Result<List<AiDeviceInfo>>> call,
@NonNull Throwable t) {
if (isFinishing() || isDestroyed()) return;
renderSuggestions(null);
}
});
}
private void renderSuggestions(List<AiDeviceInfo> devices) {
LinearLayout box = findViewById(R.id.ll_suggestions);
if (box == null) return;
box.removeAllViews();
List<String> items = new ArrayList<>();
if (devices != null && !devices.isEmpty()) {
// 在线优先、常用类型优先、名称短的优先
List<AiDeviceInfo> sorted = new ArrayList<>(devices);
Collections.sort(sorted, AiAssistantActivity::devicePriorityComparator);
// 取前 N 个设备生成单设备快捷指令
int limit = Math.min(sorted.size(), MAX_SUGGESTION_DEVICES);
for (int i = 0; i < limit; i++) {
AiDeviceInfo d = sorted.get(i);
if (d == null || TextUtils.isEmpty(d.getDeviceName())) continue;
items.add("打开" + d.getDeviceName());
}
// 追加聚合建议(家里有对应类型设备时才出现)
if (hasDeviceType(sorted, "light")) {
items.add("打开所有灯");
items.add("关闭所有灯");
}
if (hasDeviceType(sorted, "switch")) {
items.add("打开所有开关");
items.add("关闭所有开关");
}
if (hasDeviceType(sorted, "air_conditioner")) {
items.add("打开所有空调");
}
if (tvStatus != null) {
int online = countOnlineDevices(devices);
tvStatus.setText("在线 " + online + " 台 · 可控设备 " + devices.size() + " 台");
}
} else if (tvStatus != null) {
tvStatus.setText("在线 · 由 AI 驱动");
}
if (items.isEmpty()) {
Collections.addAll(items, FALLBACK_SUGGESTIONS);
} else {
items.add("启动就寝模式");
}
LayoutInflater inflater = LayoutInflater.from(this);
for (String s : items) {
TextView chip = (TextView) inflater.inflate(R.layout.item_room_chip, box, false);
chip.setText(s);
chip.setOnClickListener(v -> send(s));
box.addView(chip);
}
}
/** 设备推荐排序:在线 > 常用类型 > 使用频次 > 名称短 */
private static int devicePriorityComparator(AiDeviceInfo a, AiDeviceInfo b) {
int onlineA = "online".equalsIgnoreCase(a.getStatus()) ? 1 : 0;
int onlineB = "online".equalsIgnoreCase(b.getStatus()) ? 1 : 0;
if (onlineA != onlineB) return Integer.compare(onlineB, onlineA);
int typeA = deviceTypePriority(a.getDeviceType());
int typeB = deviceTypePriority(b.getDeviceType());
if (typeA != typeB) return Integer.compare(typeA, typeB);
int useA = a.getUseCount();
int useB = b.getUseCount();
if (useA != useB) return Integer.compare(useB, useA);
String nameA = a.getDeviceName() == null ? "" : a.getDeviceName();
String nameB = b.getDeviceName() == null ? "" : b.getDeviceName();
return Integer.compare(nameA.length(), nameB.length());
}
/** 设备类型常用优先级(数字越小越靠前) */
private static int deviceTypePriority(String type) {
if (type == null) return 99;
switch (type.toLowerCase(Locale.ROOT)) {
case "light": return 1;
case "switch": return 2;
case "socket": return 3;
case "air_conditioner": return 4;
case "fan": return 5;
case "curtain": return 6;
case "sensor": return 7;
default: return 99;
}
}
private static boolean hasDeviceType(List<AiDeviceInfo> devices, String type) {
if (devices == null || type == null) return false;
String lower = type.toLowerCase(Locale.ROOT);
for (AiDeviceInfo d : devices) {
if (d != null && lower.equals(d.getDeviceType() == null ? "" : d.getDeviceType().toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
private static int countOnlineDevices(List<AiDeviceInfo> devices) {
int count = 0;
if (devices == null) return 0;
for (AiDeviceInfo d : devices) {
if (d != null && "online".equalsIgnoreCase(d.getStatus())) {
count++;
}
}
return count;
}
// ==================== 语音输入 ====================
private void onVoiceClicked() {
// 聆听中再次点击 = 主动结束,不必等静音检测
if (listening) {
if (iatClient != null) {
iatClient.stop();
} else if (speechRecognizer != null) {
speechRecognizer.stopListening();
}
return;
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.RECORD_AUDIO}, REQ_AUDIO);
return;
}
startListening();
}
/**
* 设备没有可用的语音识别服务(国内 ROM 未内置 GMS 语音服务时很常见)时的兜底:
* 聚焦输入框并拉起软键盘,避免「长按 AI 图标后什么都没发生」。
*/
private void fallbackToKeyboard(String tip) {
Toast.makeText(this, tip, Toast.LENGTH_LONG).show();
if (etInput == null) return;
etInput.setFocusableInTouchMode(true);
etInput.requestFocus();
InputMethodManager imm =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.showSoftInput(etInput, InputMethodManager.SHOW_IMPLICIT);
}
}
/**
* 讯飞语音听写(流式版):边说边出字,停顿一下自动结束;聆听中再点一次麦克风可提前结束。
* 识别完成后自动填入输入框并直接发送,无需用户再点发送。
*/
private void startXfyunListening(String appId, String wssUrl) {
listening = true;
etInput.setHint("正在聆听…(停顿一下自动结束)");
iatClient = new XfyunIatClient(new XfyunIatClient.Listener() {
@Override
public void onPartial(String text) {
if (isFinishing() || isDestroyed() || !listening) return;
etInput.setText(text);
etInput.setSelection(etInput.getText().length());
}
@Override
public void onFinal(String text) {
if (isFinishing() || isDestroyed()) return;
stopListeningUi();
if (TextUtils.isEmpty(text)) {
Toast.makeText(AiAssistantActivity.this, "没有听清,请再说一次",
Toast.LENGTH_SHORT).show();
return;
}
etInput.setText(text);
send(text);
}
@Override
public void onError(String message) {
if (isFinishing() || isDestroyed()) return;
stopListeningUi();
Toast.makeText(AiAssistantActivity.this, message, Toast.LENGTH_LONG).show();
}
});
iatClient.start(appId, wssUrl);
}
private void startListening() {
if (listening) return;
String auth = session == null ? null : session.getBearerToken();
if (auth == null) {
fallbackToKeyboard("请先登录后再使用语音输入");
return;
}
// 优先走后端签发的讯飞语音听写:凭证只在服务端,App 领取签名地址后直连;
// 国内 ROM 普遍没有系统识别服务,且讯飞支持边说边出字
listening = true;
etInput.setHint("正在连接语音服务…");
ApiClient.getSceneApiService().getSpeechAuth(auth)
.enqueue(new Callback<Result<SpeechAuth>>() {
@Override
public void onResponse(@NonNull Call<Result<SpeechAuth>> call,
@NonNull Response<Result<SpeechAuth>> response) {
if (isFinishing() || isDestroyed()) return;
Result<SpeechAuth> res = response.body();
if (res != null && res.isSuccess() && res.getData() != null
&& res.getData().isEnabled()) {
startXfyunListening(res.getData().getAppId(), res.getData().getUrl());
} else {
// 后台未启用讯飞:回退系统识别 / 键盘
startSystemListening();
}
}
@Override
public void onFailure(@NonNull Call<Result<SpeechAuth>> call,
@NonNull Throwable t) {
if (isFinishing() || isDestroyed()) return;
// 鉴权接口异常不影响使用,静默回退系统识别 / 键盘
startSystemListening();
}
});
}
/**
* 系统语音识别(后端未启用讯飞 / 鉴权接口异常时的回退)。
* 国内无 GMS 的 ROM 普遍没有内置识别服务,此时进一步退化为键盘输入。
*/
private void startSystemListening() {
if (!SpeechRecognizer.isRecognitionAvailable(this)) {
stopListeningUi();
fallbackToKeyboard("当前设备未内置语音识别服务,请直接输入指令");
return;
}
if (speechRecognizer == null) {
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this);
speechRecognizer.setRecognitionListener(this);
}
Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN");
intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1);
try {
etInput.setHint("正在聆听…");
speechRecognizer.startListening(intent);
listening = true;
} catch (Exception e) {
stopListeningUi();
Toast.makeText(this, "语音识别启动失败:" + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
private void stopListeningUi() {
listening = false;
if (iatClient != null) {
iatClient.release();
iatClient = null;
}
if (etInput != null) {
etInput.setHint(HINT_DEFAULT);
}
}
private String firstResult(Bundle bundle) {
if (bundle == null) return null;
ArrayList<String> list = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
if (list != null && !list.isEmpty()) {
return list.get(0);
}
return null;
}
private String errorText(int error) {
switch (error) {
case SpeechRecognizer.ERROR_NO_MATCH:
return "没有听清";
case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
return "未检测到说话";
case SpeechRecognizer.ERROR_NETWORK:
case SpeechRecognizer.ERROR_NETWORK_TIMEOUT:
return "网络异常";
case SpeechRecognizer.ERROR_AUDIO:
return "录音异常";
case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS:
return "缺少录音权限";
default:
return "错误码 " + error;
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQ_AUDIO) {
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startListening();
} else {
Toast.makeText(this, "需要录音权限才能使用语音输入", Toast.LENGTH_SHORT).show();
}
}
}
// ==================== RecognitionListener ====================
@Override
public void onReadyForSpeech(Bundle params) {
}
@Override
public void onBeginningOfSpeech() {
}
@Override
public void onRmsChanged(float rmsdB) {
}
@Override
public void onBufferReceived(byte[] buffer) {
}
@Override
public void onEndOfSpeech() {
}
@Override
public void onError(int error) {
stopListeningUi();
Toast.makeText(this, "语音识别失败(" + errorText(error) + "),请重试或改用文字输入",
Toast.LENGTH_SHORT).show();
}
@Override
public void onResults(Bundle results) {
stopListeningUi();
String text = firstResult(results);
if (TextUtils.isEmpty(text)) {
Toast.makeText(this, "没有听清,请再说一次", Toast.LENGTH_SHORT).show();
return;
}
etInput.setText(text);
send(text);
}
@Override
public void onPartialResults(Bundle partialResults) {
String text = firstResult(partialResults);
if (!TextUtils.isEmpty(text) && etInput != null) {
etInput.setText(text);
etInput.setSelection(etInput.getText().length());
}
}
@Override
public void onEvent(int eventType, Bundle params) {
}
@Override
protected void onDestroy() {
if (iatClient != null) {
iatClient.release();
iatClient = null;
}
if (speechRecognizer != null) {
speechRecognizer.destroy();
speechRecognizer = null;
}
super.onDestroy();
}
// ==================== 指令记录 ====================
/** 查看最近的 AI 指令记录(每条都标注解析来源与耗时,便于回溯效果) */
private void showHistory() {
String auth = session == null ? null : session.getBearerToken();
if (auth == null) {
Toast.makeText(this, "请先登录后再查看记录", Toast.LENGTH_SHORT).show();
return;
}
ApiClient.getSceneApiService().getAiLogs(auth, 20)
.enqueue(new Callback<Result<List<Map<String, Object>>>>() {
@Override
public void onResponse(@NonNull Call<Result<List<Map<String, Object>>>> call,
@NonNull Response<Result<List<Map<String, Object>>>> response) {
if (isFinishing() || isDestroyed()) return;
Result<List<Map<String, Object>>> res = response.body();
List<Map<String, Object>> list =
(res != null && res.isSuccess()) ? res.getData() : null;
showHistoryDialog(list);
}
@Override
public void onFailure(@NonNull Call<Result<List<Map<String, Object>>>> call,
@NonNull Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(AiAssistantActivity.this,
"读取记录失败:" + (t.getMessage() == null ? "" : t.getMessage()),
Toast.LENGTH_SHORT).show();
}
});
}
private void showHistoryDialog(List<Map<String, Object>> list) {
if (isFinishing() || isDestroyed()) return;
String content;
if (list == null || list.isEmpty()) {
content = "暂无 AI 指令记录";
} else {
StringBuilder sb = new StringBuilder();
int index = 0;
for (Map<String, Object> item : list) {
if (item == null) continue;
index++;
String input = str(item.get("inputText"));
String reply = str(item.get("reply"));
String source = str(item.get("parseSource"));
sb.append(index).append(". ").append(input.isEmpty() ? "(空)" : input);
if ("llm".equalsIgnoreCase(source)) {
sb.append(" [大模型]");
} else if ("rule".equalsIgnoreCase(source)) {
sb.append(" [规则]");
}
if (item.get("costMs") != null) {
sb.append(" ").append(item.get("costMs")).append("ms");
}
if (Boolean.FALSE.equals(item.get("success"))) {
sb.append(" ✗ 未执行");
}
sb.append("\n → ").append(reply.isEmpty() ? "-" : reply).append("\n");
}
content = sb.toString();
}
new AlertDialog.Builder(this)
.setTitle("最近 AI 指令记录")
.setMessage(content)
.setPositiveButton("关闭", null)
.show();
}
private String str(Object value) {
return value == null ? "" : String.valueOf(value);
}
// ==================== 列表 ====================
private void scrollToBottom() {
if (rvMessages == null || adapter == null) return;
rvMessages.post(() -> {
int count = adapter.getItemCount();
if (count > 0) {
rvMessages.scrollToPosition(count - 1);
}
});
}
private String now() {
return new SimpleDateFormat("HH:mm", Locale.CHINA).format(new Date());
}
/** 消息适配器 */
private class MsgAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int TYPE_AI = 0;
private static final int TYPE_USER = 1;
@Override
public int getItemViewType(int position) {
return messages.get(position).user ? TYPE_USER : TYPE_AI;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
if (viewType == TYPE_USER) {
return new VH(inflater.inflate(R.layout.item_ai_msg_user, parent, false));
}
return new AiVH(inflater.inflate(R.layout.item_ai_msg_ai, parent, false));
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
Msg msg = messages.get(position);
if (holder instanceof VH) {
VH vh = (VH) holder;
vh.tvMsg.setText(msg.text);
vh.tvTime.setText(msg.time);
vh.tvMsg.getBackground().mutate().setTint(accentColor);
return;
}
AiVH vh = (AiVH) holder;
vh.tvMsg.setText(msg.text);
vh.tvTime.setText(msg.time);
vh.tvMsg.setTextColor(msg.thinking ? mutedColor : normalFgColor);
// 解析来源 · 耗时
if (TextUtils.isEmpty(msg.meta)) {
vh.tvMeta.setVisibility(View.GONE);
} else {
vh.tvMeta.setText(msg.meta);
vh.tvMeta.setVisibility(View.VISIBLE);
}
// 歧义候选
vh.llCandidates.removeAllViews();
if (msg.candidates == null || msg.candidates.isEmpty()) {
vh.svCandidates.setVisibility(View.GONE);
} else {
vh.svCandidates.setVisibility(View.VISIBLE);
LayoutInflater inflater = LayoutInflater.from(AiAssistantActivity.this);
for (AiCommandResult.Candidate c : msg.candidates) {
TextView chip = (TextView) inflater.inflate(
R.layout.item_room_chip, vh.llCandidates, false);
chip.setText(c.getTargetName());
chip.getBackground().mutate().setTint(accentTintBg);
chip.setTextColor(accentColor);
chip.setOnClickListener(v -> onCandidateChosen(msg, c));
vh.llCandidates.addView(chip);
}
}
}
@Override
public int getItemCount() {
return messages.size();
}
/** 用户消息 */
class VH extends RecyclerView.ViewHolder {
TextView tvMsg;
TextView tvTime;
VH(@NonNull View itemView) {
super(itemView);
tvMsg = itemView.findViewById(R.id.tv_msg);
tvTime = itemView.findViewById(R.id.tv_time);
}
}
/** AI 消息(含来源标注与歧义候选) */
class AiVH extends RecyclerView.ViewHolder {
TextView tvMsg;
TextView tvTime;
TextView tvMeta;
HorizontalScrollView svCandidates;
LinearLayout llCandidates;
AiVH(@NonNull View itemView) {
super(itemView);
tvMsg = itemView.findViewById(R.id.tv_msg);
tvTime = itemView.findViewById(R.id.tv_time);
tvMeta = itemView.findViewById(R.id.tv_meta);
svCandidates = itemView.findViewById(R.id.sv_candidates);
llCandidates = itemView.findViewById(R.id.ll_candidates);
}
}
}
}

View File

@ -0,0 +1,178 @@
package com.xhai.iot.ui;
import android.graphics.Color;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.GridLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.xhai.iot.R;
import com.xhai.iot.util.ThemeManager;
/**
* 外观与主题色(设计稿 AppearanceSheet)
* 显示模式(浅色/深色/跟随系统) + 8 种主题色预设 + 自定义 HEX 颜色
*/
public class AppearanceSheetFragment extends BottomSheetDialogFragment {
private ThemeManager theme;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.sheet_appearance, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
theme = ThemeManager.getInstance(requireContext());
TextView btnLight = view.findViewById(R.id.btn_mode_light);
TextView btnDark = view.findViewById(R.id.btn_mode_dark);
TextView btnSystem = view.findViewById(R.id.btn_mode_system);
EditText etHex = view.findViewById(R.id.et_custom_hex);
TextView btnApply = view.findViewById(R.id.btn_custom_apply);
// 显示模式
View.OnClickListener modeClick = v -> {
String mode;
if (v == btnLight) mode = ThemeManager.MODE_LIGHT;
else if (v == btnDark) mode = ThemeManager.MODE_DARK;
else mode = ThemeManager.MODE_SYSTEM;
theme.setMode(mode);
styleModeButtons(btnLight, btnDark, btnSystem);
notifyHost();
Toast.makeText(requireContext(), "已切换显示模式", Toast.LENGTH_SHORT).show();
};
btnLight.setOnClickListener(modeClick);
btnDark.setOnClickListener(modeClick);
btnSystem.setOnClickListener(modeClick);
styleModeButtons(btnLight, btnDark, btnSystem);
// 主题色网格
buildAccentGrid();
// 自定义颜色
btnApply.getBackground().mutate().setTint(theme.accent().value);
btnApply.setOnClickListener(v -> {
String hex = etHex.getText().toString().trim();
if (hex.length() != 6) {
Toast.makeText(requireContext(), "请输入 6 位 HEX 颜色值",
Toast.LENGTH_SHORT).show();
return;
}
String full = "#" + hex.toUpperCase();
try {
Color.parseColor(full);
} catch (Exception e) {
Toast.makeText(requireContext(), "颜色格式不正确", Toast.LENGTH_SHORT).show();
return;
}
theme.setAccent("自定义", full);
Toast.makeText(requireContext(), "已应用自定义颜色", Toast.LENGTH_SHORT).show();
buildAccentGrid();
notifyHost();
});
}
/** 主题变化后通知宿主(ProfileFragment 重渲染 + MainActivity 刷新导航色) */
private void notifyHost() {
androidx.fragment.app.Fragment parent = getParentFragment();
if (parent instanceof ProfileFragment) {
((ProfileFragment) parent).refreshTheme();
} else if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).refreshNavTint();
}
}
/** 构建/刷新主题色网格 */
private void buildAccentGrid() {
View root = getView();
if (root == null) return;
GridLayout grid = root.findViewById(R.id.grid_accents);
TextView tvName = root.findViewById(R.id.tv_accent_name);
grid.removeAllViews();
ThemeManager.Accent current = theme.accent();
tvName.setText(current.name);
for (int i = 0; i < ThemeManager.PRESETS.length; i++) {
final int idx = i;
ThemeManager.Accent p = ThemeManager.PRESETS[i];
boolean active = p.value == current.value;
TextView swatch = new TextView(requireContext());
GridLayout.LayoutParams params = new GridLayout.LayoutParams();
params.width = 0;
params.height = dp(74);
params.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f);
params.setMargins(dp(6), dp(6), dp(6), dp(6));
swatch.setLayoutParams(params);
swatch.setGravity(android.view.Gravity.CENTER);
swatch.setTextSize(11);
swatch.setTextColor(p.fg);
swatch.setText(active ? "✓" : p.name);
GradientDrawable bg = new GradientDrawable();
bg.setCornerRadius(dp(16));
bg.setColor(p.value);
if (active) {
bg.setStroke(dp(2), 0xFFFFFFFF);
}
swatch.setBackground(bg);
swatch.setOnClickListener(v -> {
ThemeManager.Accent selected = ThemeManager.PRESETS[idx];
theme.setAccent(selected.name,
String.format("#%06X", 0xFFFFFF & selected.value));
Toast.makeText(requireContext(), "主题色:" + selected.name,
Toast.LENGTH_SHORT).show();
buildAccentGrid();
notifyHost();
});
grid.addView(swatch);
}
// 自定义按钮也随主题色变化
TextView btnApply = root.findViewById(R.id.btn_custom_apply);
if (btnApply.getBackground() != null) {
btnApply.getBackground().mutate().setTint(current.value);
}
}
private void styleModeButtons(TextView btnLight, TextView btnDark, TextView btnSystem) {
String mode = theme.mode();
styleMode(btnLight, ThemeManager.MODE_LIGHT.equals(mode));
styleMode(btnDark, ThemeManager.MODE_DARK.equals(mode));
styleMode(btnSystem, ThemeManager.MODE_SYSTEM.equals(mode));
}
private void styleMode(TextView btn, boolean active) {
btn.setBackgroundResource(active
? R.drawable.bg_chip_accent : R.drawable.bg_chip_muted);
if (active) {
android.graphics.drawable.Drawable bg = btn.getBackground();
if (bg != null) {
bg = bg.mutate();
bg.setTint(theme.accent().value);
btn.setBackground(bg);
}
btn.setTextColor(0xFFFFFFFF);
} else {
btn.setTextColor(requireContext().getColor(R.color.c_muted_fg));
}
}
private int dp(int v) {
return Math.round(getResources().getDisplayMetrics().density * v);
}
}

View File

@ -0,0 +1,490 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.SceneItem;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.ThemeManager;
import com.xhai.iot.ui.widget.PillSwitch;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 自动化页(设计稿 AutomationPage)
* 统计卡 + 规则列表(规则 = 场景下的联动规则,开关走 toggleRule,执行走 executeScene)
*/
public class AutomationFragment extends Fragment {
/** 规则列表项 */
private static class RuleRow {
Long ruleId;
Long sceneId;
String sceneName;
String ruleName;
String condition;
String actionDesc;
boolean enabled;
String icon = "⚡";
}
private RecyclerView rvAuto;
private ProgressBar progressBar;
private TextView tvEmpty;
private TextView tvSummary;
private TextView tvStatScenes;
private TextView tvStatNormal;
private TextView btnNewScene;
private SessionManager session;
private final List<RuleRow> rows = new ArrayList<>();
private RuleAdapter adapter;
private int accentColor = 0xFF6B7280;
private int sceneCount = 0;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_automation, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
session = SessionManager.getInstance(requireContext());
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
rvAuto = view.findViewById(R.id.rv_automation);
progressBar = view.findViewById(R.id.progress_auto);
tvEmpty = view.findViewById(R.id.tv_empty);
tvSummary = view.findViewById(R.id.tv_auto_summary);
tvStatScenes = view.findViewById(R.id.tv_stat_scenes);
tvStatNormal = view.findViewById(R.id.tv_stat_normal);
btnNewScene = view.findViewById(R.id.btn_new_scene);
// 新建按钮染主题色
btnNewScene.setBackgroundResource(R.drawable.bg_btn_accent);
android.graphics.drawable.Drawable bg2 = btnNewScene.getBackground();
if (bg2 != null) {
bg2 = bg2.mutate();
bg2.setTint(accentColor);
btnNewScene.setBackground(bg2);
}
btnNewScene.setOnClickListener(v -> showCreateDialog());
adapter = new RuleAdapter();
rvAuto.setLayoutManager(new LinearLayoutManager(requireContext()));
rvAuto.setAdapter(adapter);
}
@Override
public void onResume() {
super.onResume();
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
refreshAccentUi();
loadScenes();
}
/** Tab 用 hide/show 切换时不走 onResume,需在此刷新主题色 */
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
if (!hidden && isAdded()) {
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
refreshAccentUi();
}
}
/** 刷新强调控件主色(新建按钮 + 列表) */
private void refreshAccentUi() {
if (btnNewScene != null && btnNewScene.getBackground() != null) {
android.graphics.drawable.Drawable bg = btnNewScene.getBackground().mutate();
bg.setTint(accentColor);
btnNewScene.setBackground(bg);
}
if (adapter != null) {
adapter.notifyDataSetChanged();
}
}
private void showLoading(boolean show) {
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
}
/** 拉取场景列表,然后聚合每个场景下的规则 */
private void loadScenes() {
showLoading(true);
String auth = session.getBearerToken();
if (auth == null) {
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("请先登录");
return;
}
ApiClient.getSceneApiService().getScenes(auth, 1, 100)
.enqueue(new retrofit2.Callback<Result<PageData<SceneItem>>>() {
@Override
public void onResponse(retrofit2.Call<Result<PageData<SceneItem>>> call,
retrofit2.Response<Result<PageData<SceneItem>>> response) {
if (!isAdded()) return;
List<SceneItem> scenes = response.isSuccessful()
&& response.body() != null && response.body().isSuccess()
&& response.body().getData() != null
? response.body().getData().getRecords()
: new ArrayList<>();
sceneCount = scenes.size();
tvStatScenes.setText(String.valueOf(sceneCount));
tvSummary.setText(sceneCount + " 个场景");
loadAllRules(scenes);
}
@Override
public void onFailure(retrofit2.Call<Result<PageData<SceneItem>>> call, Throwable t) {
if (!isAdded()) return;
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("连接服务器失败: " + t.getMessage());
}
});
}
/** 聚合所有场景的规则 */
private void loadAllRules(List<SceneItem> scenes) {
rows.clear();
if (scenes.isEmpty()) {
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvStatNormal.setText("正常");
adapter.notifyDataSetChanged();
return;
}
final int[] pending = {scenes.size()};
for (SceneItem scene : scenes) {
ApiClient.getSceneApiService().getRules(session.getBearerToken(),
scene.getId(), 1, 50)
.enqueue(new retrofit2.Callback<Result<PageData<Map<String, Object>>>>() {
@Override
public void onResponse(
retrofit2.Call<Result<PageData<Map<String, Object>>>> call,
retrofit2.Response<Result<PageData<Map<String, Object>>>> response) {
if (!isAdded()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()
&& response.body().getData() != null) {
List<Map<String, Object>> records =
response.body().getData().getRecords();
if (records != null) {
for (Map<String, Object> rule : records) {
rows.add(toRow(rule, scene));
}
}
}
pending[0]--;
if (pending[0] <= 0) {
showLoading(false);
adapter.notifyDataSetChanged();
tvEmpty.setVisibility(rows.isEmpty()
? View.VISIBLE : View.GONE);
updateStats();
}
}
@Override
public void onFailure(
retrofit2.Call<Result<PageData<Map<String, Object>>>> call,
Throwable t) {
if (!isAdded()) return;
pending[0]--;
if (pending[0] <= 0) {
showLoading(false);
adapter.notifyDataSetChanged();
tvEmpty.setVisibility(rows.isEmpty()
? View.VISIBLE : View.GONE);
updateStats();
}
}
});
}
}
private RuleRow toRow(Map<String, Object> rule, SceneItem scene) {
RuleRow row = new RuleRow();
Object id = rule.get("id");
if (id instanceof Number) {
row.ruleId = ((Number) id).longValue();
} else if (id != null) {
try {
row.ruleId = Long.parseLong(id.toString());
} catch (Exception ignored) {
}
}
row.sceneId = scene.getId();
row.sceneName = scene.getSceneName();
String name = str(rule.get("ruleName"));
row.ruleName = !name.isEmpty() ? name : "未命名规则";
row.condition = str(rule.get("condition"));
String ak = str(rule.get("actionKey"));
String av = str(rule.get("actionValue"));
row.actionDesc = ak.isEmpty() ? "动作" : ak + (av.isEmpty() ? "" : " = " + av);
Boolean en = rule.get("enabled") instanceof Boolean
? (Boolean) rule.get("enabled") : true;
row.enabled = Boolean.TRUE.equals(en);
row.icon = pickIcon(row.condition + row.actionDesc);
return row;
}
private String str(Object o) {
return o != null ? o.toString() : "";
}
/** 根据条件/动作关键字选图标 */
private String pickIcon(String text) {
if (text.contains("light") || text.contains("开关") || text.contains("switch")) {
return "💡";
}
if (text.contains("温度") || text.contains("temp") || text.contains("空调")) {
return "❄️";
}
if (text.contains("门锁") || text.contains("lock") || text.contains("安防")) {
return "🔒";
}
if (text.contains("窗帘") || text.contains("curtain")) {
return "🪟";
}
if (text.contains("pm25") || text.contains("空气") || text.contains("湿度")) {
return "🌿";
}
return "⚡";
}
private void updateStats() {
int enabled = 0;
for (RuleRow r : rows) {
if (r.enabled) enabled++;
}
tvStatNormal.setText(rows.isEmpty() ? "正常" : enabled + "/" + rows.size() + " 启用");
}
/** 新建场景对话框(走 createScene API) */
private void showCreateDialog() {
android.widget.EditText input = new android.widget.EditText(requireContext());
input.setHint("如:回家模式");
input.setPadding(dp(20), dp(12), dp(20), dp(12));
new androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle("新建场景")
.setView(input)
.setPositiveButton("创建", (d, w) -> {
String name = input.getText().toString().trim();
if (name.isEmpty()) {
Toast.makeText(requireContext(), "请输入场景名称",
Toast.LENGTH_SHORT).show();
return;
}
createScene(name, name + "-一键场景");
})
.setNegativeButton("取消", null)
.show();
}
private void createScene(String name, String desc) {
String auth = session.getBearerToken();
if (auth == null) return;
Map<String, Object> body = new java.util.HashMap<>();
body.put("sceneName", name);
body.put("sceneType", "one_click");
body.put("description", desc);
ApiClient.getSceneApiService().createScene(auth, body)
.enqueue(new retrofit2.Callback<Result<Long>>() {
@Override
public void onResponse(retrofit2.Call<Result<Long>> call,
retrofit2.Response<Result<Long>> response) {
if (!isAdded()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(requireContext(), "场景创建成功",
Toast.LENGTH_SHORT).show();
loadScenes();
} else {
Toast.makeText(requireContext(), "创建失败: "
+ (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<Long>> call, Throwable t) {
if (!isAdded()) return;
Toast.makeText(requireContext(), "创建失败: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
}
/** 启用/禁用规则 */
private void toggleRule(RuleRow row, boolean enable) {
String auth = session.getBearerToken();
if (auth == null || row.ruleId == null) return;
ApiClient.getSceneApiService().toggleRule(auth, row.ruleId,
enable ? "enable" : "disable")
.enqueue(new retrofit2.Callback<Result<Void>>() {
@Override
public void onResponse(retrofit2.Call<Result<Void>> call,
retrofit2.Response<Result<Void>> response) {
if (!isAdded()) return;
boolean ok = response.isSuccessful() && response.body() != null
&& response.body().isSuccess();
if (ok) {
row.enabled = enable;
adapter.notifyDataSetChanged();
updateStats();
} else {
adapter.notifyDataSetChanged();
Toast.makeText(requireContext(), "操作失败",
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
if (!isAdded()) return;
adapter.notifyDataSetChanged();
Toast.makeText(requireContext(), "操作失败: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
}
/** 一键执行场景 */
private void executeScene(RuleRow row) {
if (row.sceneId == null) return;
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getSceneApiService().executeScene(auth, row.sceneId)
.enqueue(new retrofit2.Callback<Result<List<Map<String, Object>>>>() {
@Override
public void onResponse(
retrofit2.Call<Result<List<Map<String, Object>>>> call,
retrofit2.Response<Result<List<Map<String, Object>>>> response) {
if (!isAdded()) return;
boolean ok = response.isSuccessful() && response.body() != null
&& response.body().isSuccess();
Toast.makeText(requireContext(),
ok ? "已执行「" + row.sceneName + "」" : "执行失败",
Toast.LENGTH_SHORT).show();
}
@Override
public void onFailure(
retrofit2.Call<Result<List<Map<String, Object>>>> call,
Throwable t) {
if (!isAdded()) return;
Toast.makeText(requireContext(), "执行失败: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
}
private int dp(int v) {
return Math.round(getResources().getDisplayMetrics().density * v);
}
/** 规则列表适配器 */
private class RuleAdapter extends RecyclerView.Adapter<RuleAdapter.VH> {
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_automation, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH h, int position) {
RuleRow row = rows.get(position);
h.tvIcon.setText(row.icon);
h.tvName.setText(row.ruleName);
String desc = (row.condition == null || row.condition.isEmpty()
? "手动触发" : row.condition)
+ " · " + row.actionDesc;
h.tvDesc.setText(desc);
h.vIconBg.setBackgroundResource(R.drawable.bg_icon_square_pastel);
android.graphics.drawable.Drawable iconBg = h.vIconBg.getBackground();
if (iconBg != null) {
iconBg = iconBg.mutate();
if (row.enabled) {
// 开启:12% 透明主色(设计稿 rgba(primary,0.12))
iconBg.setTint(android.graphics.Color.argb(
Math.round(255 * 0.12f),
android.graphics.Color.red(accentColor),
android.graphics.Color.green(accentColor),
android.graphics.Color.blue(accentColor)));
} else {
iconBg.setTint(requireContext().getColor(R.color.c_muted));
}
h.vIconBg.setBackground(iconBg);
}
h.pill.setAccentColor(accentColor);
h.pill.setChecked(row.enabled);
h.pill.setOnUserToggleListener(checked -> toggleRule(row, checked));
h.btnRun.getBackground().mutate().setTint(accentColor);
h.btnRun.setOnClickListener(v -> executeScene(row));
h.itemView.setOnClickListener(v -> {
// 进入场景详情管理规则
if (row.sceneId != null) {
Intent it = new Intent(requireContext(), SceneDetailActivity.class);
it.putExtra("sceneId", row.sceneId);
it.putExtra("sceneName", row.sceneName);
startActivity(it);
}
});
}
@Override
public int getItemCount() {
return rows.size();
}
class VH extends RecyclerView.ViewHolder {
View vIconBg;
TextView tvIcon;
TextView tvName;
TextView tvDesc;
TextView btnRun;
PillSwitch pill;
VH(@NonNull View itemView) {
super(itemView);
vIconBg = itemView.findViewById(R.id.v_icon_bg);
tvIcon = itemView.findViewById(R.id.tv_scene_icon);
tvName = itemView.findViewById(R.id.tv_scene_name);
tvDesc = itemView.findViewById(R.id.tv_scene_desc);
btnRun = itemView.findViewById(R.id.btn_run);
pill = itemView.findViewById(R.id.pill_switch);
}
}
}
}

View File

@ -0,0 +1,145 @@
package com.xhai.iot.ui;
import android.app.Activity;
import android.widget.EditText;
import android.widget.Toast;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
import com.thingclips.smart.sdk.api.IResultCallback;
import com.thingclips.smart.sdk.api.IThingDevice;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.util.SessionManager;
import java.util.HashMap;
import java.util.Map;
/**
* 设备操作对话框:长按设备弹出"编辑(重命名)/ 删除"
* SDK 操作 + 后端同步,供首页/设备页/详情页复用
*/
public final class DeviceActionDialogs {
public interface Callback {
/** 重命名成功(UI 更新名称) */
void onRenamed(String newName);
/** 删除成功(UI 移除该项) */
void onDeleted();
}
private DeviceActionDialogs() {
}
/** 弹出"重命名 / 删除设备"选择 */
public static void showActions(Activity activity, String deviceId,
String deviceName, Callback callback) {
new androidx.appcompat.app.AlertDialog.Builder(activity)
.setTitle(deviceName)
.setItems(new String[]{"✏️ 重命名", "🗑 删除设备"}, (d, which) -> {
if (which == 0) {
showRenameDialog(activity, deviceId, deviceName, callback);
} else {
confirmDelete(activity, deviceId, deviceName, callback);
}
})
.show();
}
/** 重命名对话框:SDK 改名 + 同步后端 */
public static void showRenameDialog(Activity activity, String deviceId,
String deviceName, Callback callback) {
EditText input = new EditText(activity);
input.setText(deviceName);
input.setSelection(input.getText().length());
input.setPadding(56, 32, 56, 32);
new androidx.appcompat.app.AlertDialog.Builder(activity)
.setTitle("重命名设备")
.setView(input)
.setPositiveButton("保存", (d, w) -> {
String newName = input.getText().toString().trim();
if (newName.isEmpty()) {
Toast.makeText(activity, "请输入名称", Toast.LENGTH_SHORT).show();
return;
}
doRename(activity, deviceId, newName, callback);
})
.setNegativeButton("取消", null)
.show();
}
private static void doRename(Activity activity, String deviceId,
String newName, Callback callback) {
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
device.renameDevice(newName, new IResultCallback() {
@Override
public void onSuccess() {
syncRenameToBackend(activity, deviceId, newName);
activity.runOnUiThread(() -> {
Toast.makeText(activity, "重命名成功", Toast.LENGTH_SHORT).show();
if (callback != null) callback.onRenamed(newName);
});
}
@Override
public void onError(String code, String error) {
activity.runOnUiThread(() -> Toast.makeText(activity,
"重命名失败: " + (error != null ? error : code),
Toast.LENGTH_LONG).show());
}
});
}
/** 重命名后同步到后端 device 表 */
private static void syncRenameToBackend(Activity activity, String deviceId, String newName) {
try {
String auth = SessionManager.getInstance(activity).getBearerToken();
if (auth == null) return;
Map<String, String> body = new HashMap<>();
body.put("deviceName", newName);
ApiClient.getApiService().renameTuyaDevice(auth, deviceId, body)
.enqueue(new retrofit2.Callback<com.xhai.iot.model.Result<Void>>() {
@Override
public void onResponse(retrofit2.Call<com.xhai.iot.model.Result<Void>> call,
retrofit2.Response<com.xhai.iot.model.Result<Void>> response) {
}
@Override
public void onFailure(retrofit2.Call<com.xhai.iot.model.Result<Void>> call,
Throwable t) {
}
});
} catch (Throwable ignored) {
}
}
/** 删除确认 + SDK 移除 */
private static void confirmDelete(Activity activity, String deviceId,
String deviceName, Callback callback) {
new androidx.appcompat.app.AlertDialog.Builder(activity)
.setTitle("删除设备")
.setMessage("确定删除设备「" + deviceName + "」吗?")
.setPositiveButton("删除", (d, w) -> doDelete(activity, deviceId, callback))
.setNegativeButton("取消", null)
.show();
}
private static void doDelete(Activity activity, String deviceId, Callback callback) {
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
device.removeDevice(new IResultCallback() {
@Override
public void onSuccess() {
activity.runOnUiThread(() -> {
Toast.makeText(activity, "设备已删除", Toast.LENGTH_SHORT).show();
if (callback != null) callback.onDeleted();
});
}
@Override
public void onError(String code, String error) {
activity.runOnUiThread(() -> Toast.makeText(activity,
"删除失败: " + (error != null ? error : code),
Toast.LENGTH_LONG).show());
}
});
}
}

View File

@ -0,0 +1,241 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.Result;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.adapter.DeviceGridAdapter;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 设备列表页(用涂鸦 SDK 拉取设备,并同步到后端)
*/
public class DeviceActivity extends AppCompatActivity {
private RecyclerView rvDevices;
private ProgressBar progressBar;
private TextView tvEmpty;
private TextView tvHomeName;
private TextView tvDeviceCount;
private SessionManager session;
private DeviceGridAdapter adapter;
private long currentHomeId;
private final List<TuyaSdkService.SdkHome> homeCache = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_device);
session = SessionManager.getInstance(this);
initViews();
loadTuyaDevices();
}
private void initViews() {
rvDevices = findViewById(R.id.rv_devices);
progressBar = findViewById(R.id.progress_devices);
tvEmpty = findViewById(R.id.tv_empty);
tvHomeName = findViewById(R.id.tv_home_name);
tvDeviceCount = findViewById(R.id.tv_device_count);
TextView tvAddDevice = findViewById(R.id.tv_add_device);
// "+" 添加设备(配网)
tvAddDevice.setOnClickListener(v ->
startActivity(new Intent(DeviceActivity.this, AddDeviceActivity.class)));
// 点击家庭名切换家庭
tvHomeName.setOnClickListener(v -> showHomeSwitchMenu(v));
// 管理入口(进设备管理页:重命名/删除)
TextView tvManage = findViewById(R.id.tv_manage);
tvManage.setOnClickListener(v -> {
Intent intent = new Intent(DeviceActivity.this, DeviceManagerActivity.class);
intent.putExtra("homeId", currentHomeId);
startActivity(intent);
});
adapter = new DeviceGridAdapter(new ArrayList<>(), (device, position) -> {
// 点击设备进入控制页
Intent intent = new Intent(DeviceActivity.this, DeviceControlActivity.class);
intent.putExtra("deviceId", device.getId());
intent.putExtra("deviceName", device.getName());
startActivity(intent);
});
rvDevices.setLayoutManager(new GridLayoutManager(this, 2));
rvDevices.setAdapter(adapter);
}
/** 用涂鸦 SDK 拉取设备列表 */
private void loadTuyaDevices() {
showLoading(true);
TuyaSdkService.getHomes(new TuyaSdkService.HomeListCallback() {
@Override
public void onSuccess(List<TuyaSdkService.SdkHome> homes) {
if (homes == null || homes.isEmpty()) {
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("暂无家庭");
return;
}
homeCache.clear();
homeCache.addAll(homes);
// 使用当前选中家庭(默认第一个)
TuyaSdkService.SdkHome home = null;
for (TuyaSdkService.SdkHome h : homeCache) {
if (h.homeId == currentHomeId) { home = h; break; }
}
if (home == null) home = homeCache.get(0);
currentHomeId = home.homeId;
tvHomeName.setText(home.name);
// 同步家庭给涂鸦 BizBundle(面板跳转依赖)
com.xhai.iot.service.BizBundleFamilyServiceImpl
.updateCurrentHomeId(currentHomeId, home.name);
// 拉取该家庭设备
TuyaSdkService.getDevices(home.homeId, new TuyaSdkService.DeviceListCallback() {
@Override
public void onSuccess(List<TuyaSdkService.SdkDevice> devices) {
showLoading(false);
// 先同步后端(必须在"空列表"分支之前):
// 涂鸦账号下设备被全部移除时,也要靠这次全量同步
// 把本地残留记录对账清理掉,否则旧设备会一直显示在列表里
syncToBackend(devices, currentHomeId);
if (devices == null || devices.isEmpty()) {
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("暂无设备");
return;
}
tvDeviceCount.setText(devices.size() + " 台设备");
tvEmpty.setVisibility(View.GONE);
// 显示设备
displayDevices(devices);
}
@Override
public void onError(String error) {
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("获取设备失败: " + error);
}
});
}
@Override
public void onError(String error) {
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("获取家庭失败: " + error);
}
});
}
/** 家庭切换下拉菜单 */
private void showHomeSwitchMenu(View anchor) {
if (homeCache.isEmpty()) {
Toast.makeText(this, "暂无家庭", Toast.LENGTH_SHORT).show();
return;
}
android.widget.PopupMenu popup = new android.widget.PopupMenu(this, anchor);
android.view.Menu menu = popup.getMenu();
int idx = 1;
for (TuyaSdkService.SdkHome home : homeCache) {
String label = (home.homeId == currentHomeId ? "✓ " : "  ") + home.name;
menu.add(0, idx++, android.view.Menu.NONE, label);
}
popup.setOnMenuItemClickListener(item -> {
int homeIdx = item.getItemId() - 1;
if (homeIdx >= 0 && homeIdx < homeCache.size()) {
TuyaSdkService.SdkHome selected = homeCache.get(homeIdx);
currentHomeId = selected.homeId;
tvHomeName.setText(selected.name);
com.xhai.iot.service.BizBundleFamilyServiceImpl
.updateCurrentHomeId(currentHomeId, selected.name);
loadTuyaDevices();
}
return true;
});
popup.show();
}
/** 显示设备(SDK 设备转成 UI 模型) */
private void displayDevices(List<TuyaSdkService.SdkDevice> devices) {
List<com.xhai.iot.model.TuyaDevice> uiDevices = new ArrayList<>();
for (TuyaSdkService.SdkDevice sdk : devices) {
com.xhai.iot.model.TuyaDevice dev = new com.xhai.iot.model.TuyaDevice();
dev.setId(sdk.devId);
dev.setName(sdk.name);
dev.setOnline(sdk.online);
dev.setCategory(sdk.category);
dev.setProductId(sdk.productId);
dev.setIcon(sdk.iconUrl);
uiDevices.add(dev);
}
adapter.update(uiDevices);
}
/** 把设备同步到后端 device 表(全量同步,后端会据此对账清理) */
private void syncToBackend(List<TuyaSdkService.SdkDevice> devices, long homeId) {
String auth = session.getBearerToken();
if (auth == null) return;
// 构造设备详情列表(后端依赖前端数据)
List<Map<String, Object>> devList = new ArrayList<>();
if (devices != null) {
for (TuyaSdkService.SdkDevice sdk : devices) {
Map<String, Object> info = new HashMap<>();
info.put("deviceId", sdk.devId);
info.put("deviceName", sdk.name);
info.put("category", sdk.category);
info.put("online", sdk.online);
info.put("productId", sdk.productId);
devList.add(info);
}
}
Map<String, Object> body = new HashMap<>();
body.put("homeId", homeId);
body.put("devices", devList);
// 标记为全量同步:后端据此对账,清理已从涂鸦账号移除的设备。
// 只有 SDK 成功返回了完整列表才会走到这里,所以可以安全置 true;
// 空列表同样要发,否则被解绑/重置到其它账号的设备会永久残留。
body.put("fullSync", true);
ApiClient.getApiService().bindTuyaDevices(auth, body)
.enqueue(new Callback<Result<Integer>>() {
@Override
public void onResponse(Call<Result<Integer>> call, Response<Result<Integer>> response) {
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
// 同步成功(后端会自动对账:已不存在的设备会被清理)
}
}
@Override
public void onFailure(Call<Result<Integer>> call, Throwable t) {
// 同步失败不阻塞显示
}
});
}
private void showLoading(boolean show) {
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
}
}

View File

@ -0,0 +1,181 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.DeviceActivityItem;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.List;
/**
* 操控动态列表页(首页"近期动态"点击进入)
* 分页加载 + 下拉刷新,支持滚动到底自动加载更多。
*/
public class DeviceActivityActivity extends AppCompatActivity {
private RecyclerView rvActivities;
private SwipeRefreshLayout swipeRefresh;
private TextView tvEmpty;
private final List<DeviceActivityItem> items = new ArrayList<>();
private ActivityAdapter adapter;
private int pageNum = 1;
private boolean loading = false;
private boolean hasMore = true;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_device_activity);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle("操控动态");
}
rvActivities = findViewById(R.id.rv_activities);
swipeRefresh = findViewById(R.id.swipe_refresh);
adapter = new ActivityAdapter(items);
rvActivities.setLayoutManager(new LinearLayoutManager(this));
rvActivities.setAdapter(adapter);
swipeRefresh.setOnRefreshListener(this::refresh);
rvActivities.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
if (lm == null || loading || !hasMore) return;
int last = lm.findLastVisibleItemPosition();
if (last >= items.size() - 2 && items.size() > 0) {
pageNum++;
load(false);
}
}
});
refresh();
}
private void refresh() {
pageNum = 1;
hasMore = true;
load(true);
}
private void load(boolean showSwipe) {
String auth = SessionManager.getInstance(this).getBearerToken();
if (auth == null) {
finish();
return;
}
long homeId = SessionManager.getInstance(this).getCurrentHomeId();
loading = true;
if (showSwipe) swipeRefresh.setRefreshing(true);
ApiClient.getApiService()
.getDeviceActivities(auth, homeId > 0 ? homeId : null, null, pageNum, 20)
.enqueue(new retrofit2.Callback<Result<PageData<DeviceActivityItem>>>() {
@Override
public void onResponse(retrofit2.Call<Result<PageData<DeviceActivityItem>>> call,
retrofit2.Response<Result<PageData<DeviceActivityItem>>> response) {
loading = false;
swipeRefresh.setRefreshing(false);
Result<PageData<DeviceActivityItem>> body = response.body();
if (body == null || !body.isSuccess() || body.getData() == null) {
if (items.isEmpty()) tvEmpty.setVisibility(View.VISIBLE);
return;
}
if (pageNum == 1) items.clear();
List<DeviceActivityItem> records = body.getData().getRecords();
if (records != null) items.addAll(records);
hasMore = body.getData().getTotal() > items.size();
adapter.notifyDataSetChanged();
if (items.isEmpty() && tvEmpty != null) {
tvEmpty.setVisibility(View.VISIBLE);
}
}
@Override
public void onFailure(retrofit2.Call<Result<PageData<DeviceActivityItem>>> call,
Throwable t) {
loading = false;
swipeRefresh.setRefreshing(false);
if (items.isEmpty() && tvEmpty != null) {
tvEmpty.setVisibility(View.VISIBLE);
}
}
});
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
/** 列表适配器(复用首页动态行布局) */
private static class ActivityAdapter extends RecyclerView.Adapter<VH> {
private final List<DeviceActivityItem> items;
ActivityAdapter(List<DeviceActivityItem> items) {
this.items = items;
}
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_activity_row, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH h, int position) {
DeviceActivityItem item = items.get(position);
h.tvIcon.setText(item.icon());
h.tvDesc.setText(item.getActionDesc() != null ? item.getActionDesc()
: (item.getDeviceName() != null ? item.getDeviceName() : item.getDeviceId()));
h.tvSource.setText(item.sourceLabel());
h.tvTime.setText(item.relativeTime());
}
@Override
public int getItemCount() {
return items.size();
}
}
private static class VH extends RecyclerView.ViewHolder {
final TextView tvIcon, tvDesc, tvSource, tvTime;
VH(@NonNull View v) {
super(v);
tvIcon = v.findViewById(R.id.tv_act_icon);
tvDesc = v.findViewById(R.id.tv_act_desc);
tvSource = v.findViewById(R.id.tv_act_source);
tvTime = v.findViewById(R.id.tv_act_time);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,198 @@
package com.xhai.iot.ui;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
import com.thingclips.smart.sdk.api.IResultCallback;
import com.thingclips.smart.sdk.api.IThingDevice;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.Result;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.adapter.DeviceManageAdapter;
import com.xhai.iot.util.SessionManager;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 设备管理页
* 家庭设备列表 + 重命名 + 删除
*/
public class DeviceManagerActivity extends AppCompatActivity {
private long homeId;
private RecyclerView rvDevices;
private TextView tvEmpty;
private TextView tvDeviceCount;
private DeviceManageAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_device_manager);
homeId = getIntent().getLongExtra("homeId", 0);
rvDevices = findViewById(R.id.rv_devices);
tvEmpty = findViewById(R.id.tv_empty);
tvDeviceCount = findViewById(R.id.tv_device_count);
TextView tvAddDevice = findViewById(R.id.tv_add_device);
// "+" 添加设备(配网)
tvAddDevice.setOnClickListener(v ->
startActivity(new Intent(this, AddDeviceActivity.class)));
adapter = new DeviceManageAdapter();
adapter.setOnEditListener(device -> showRenameDialog(device));
adapter.setOnDeleteListener(device -> confirmDelete(device));
rvDevices.setLayoutManager(new LinearLayoutManager(this));
rvDevices.setAdapter(adapter);
loadDevices();
}
private void loadDevices() {
TuyaSdkService.getDevices(homeId, new TuyaSdkService.DeviceListCallback() {
@Override
public void onSuccess(List<TuyaSdkService.SdkDevice> devices) {
runOnUiThread(() -> {
if (devices == null || devices.isEmpty()) {
tvEmpty.setVisibility(View.VISIBLE);
tvDeviceCount.setText("0 台设备");
} else {
tvEmpty.setVisibility(View.GONE);
tvDeviceCount.setText(devices.size() + " 台设备");
}
adapter.update(devices);
});
}
@Override
public void onError(String error) {
runOnUiThread(() -> Toast.makeText(DeviceManagerActivity.this,
"加载设备失败: " + error, Toast.LENGTH_SHORT).show());
}
});
}
/** 重命名对话框 */
private void showRenameDialog(TuyaSdkService.SdkDevice device) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("重命名设备");
final EditText etName = new EditText(this);
etName.setHint("请输入新名称");
etName.setText(device.name);
builder.setView(etName);
builder.setPositiveButton("确定", (dialog, which) -> {
String name = etName.getText().toString().trim();
if (TextUtils.isEmpty(name)) {
Toast.makeText(this, "请输入名称", Toast.LENGTH_SHORT).show();
return;
}
renameDevice(device, name);
});
builder.setNegativeButton("取消", null);
builder.show();
}
/** 重命名设备(SDK 改名 + 同步后端) */
private void renameDevice(TuyaSdkService.SdkDevice device, String newName) {
try {
IThingDevice thingDevice = ThingHomeSdk.newDeviceInstance(device.devId);
thingDevice.renameDevice(newName, new IResultCallback() {
@Override
public void onSuccess() {
// 涂鸦 SDK 改名成功,同步到本平台后端 device 表
syncRenameToBackend(device.devId, newName);
runOnUiThread(() -> {
Toast.makeText(DeviceManagerActivity.this,
"重命名成功", Toast.LENGTH_SHORT).show();
loadDevices();
});
}
@Override
public void onError(String code, String error) {
runOnUiThread(() -> Toast.makeText(DeviceManagerActivity.this,
"重命名失败: " + (error != null ? error : code),
Toast.LENGTH_LONG).show());
}
});
} catch (Exception e) {
Toast.makeText(this, "重命名失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
/** 重命名后同步到后端 device 表 */
private void syncRenameToBackend(String devId, String newName) {
String auth = SessionManager.getInstance(this).getBearerToken();
if (auth == null) return;
Map<String, String> body = new HashMap<>();
body.put("deviceName", newName);
ApiClient.getApiService().renameTuyaDevice(auth, devId, body)
.enqueue(new Callback<Result<Void>>() {
@Override
public void onResponse(Call<Result<Void>> call,
Response<Result<Void>> response) {
// 同步成功或失败都不影响前端显示
}
@Override
public void onFailure(Call<Result<Void>> call, Throwable t) {
}
});
}
/** 确认删除设备 */
private void confirmDelete(TuyaSdkService.SdkDevice device) {
new AlertDialog.Builder(this)
.setTitle("删除设备")
.setMessage("确定删除设备「" + device.name + "」吗?")
.setPositiveButton("删除", (dialog, which) -> removeDevice(device))
.setNegativeButton("取消", null)
.show();
}
/** 删除设备 */
private void removeDevice(TuyaSdkService.SdkDevice device) {
try {
IThingDevice thingDevice = ThingHomeSdk.newDeviceInstance(device.devId);
thingDevice.removeDevice(new IResultCallback() {
@Override
public void onSuccess() {
runOnUiThread(() -> {
Toast.makeText(DeviceManagerActivity.this,
"设备已删除", Toast.LENGTH_SHORT).show();
loadDevices();
});
}
@Override
public void onError(String code, String error) {
runOnUiThread(() -> Toast.makeText(DeviceManagerActivity.this,
"删除失败: " + (error != null ? error : code),
Toast.LENGTH_LONG).show());
}
});
} catch (Exception e) {
Toast.makeText(this, "删除失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
}

View File

@ -0,0 +1,524 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.BackendDevice;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.TuyaDevice;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.adapter.DeviceRowAdapter;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.ThemeManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 设备页(设计稿 DevicesPage)
* 标题统计 + 搜索 + 房间过滤 chips + 设备列表(行卡 + 胶囊开关直接控制)
*/
public class DevicesFragment extends Fragment {
private static final String[] ROOMS = {"全部", "客厅", "卧室", "厨房", "书房", "阳台"};
private RecyclerView rvDevices;
private ProgressBar progressBar;
private TextView tvEmpty;
private TextView tvSummary;
private EditText etSearch;
private LinearLayout llRoomChips;
private SessionManager session;
private DeviceRowAdapter adapter;
private List<TuyaDevice> allDevices = new ArrayList<>();
private String activeRoom = "全部";
private String keyword = "";
private int accentColor = 0xFF6B7280;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_devices, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
session = SessionManager.getInstance(requireContext());
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
rvDevices = view.findViewById(R.id.rv_devices);
progressBar = view.findViewById(R.id.progress_devices);
tvEmpty = view.findViewById(R.id.tv_empty);
tvSummary = view.findViewById(R.id.tv_devices_summary);
etSearch = view.findViewById(R.id.et_search);
llRoomChips = view.findViewById(R.id.ll_room_chips);
adapter = new DeviceRowAdapter((device, position) -> {
if (device.isGateway()) {
Intent gw = new Intent(requireContext(), GatewayPanelActivity.class);
gw.putExtra("deviceId", device.getId());
gw.putExtra("deviceName", device.getName());
gw.putExtra("online", device.isOnline());
startActivity(gw);
return;
}
com.xhai.iot.ui.panel.PanelRouter.openPanel(
(androidx.appcompat.app.AppCompatActivity) requireActivity(),
device.getId(),
device.getPlatform(),
device.getName());
});
adapter.setAccentColor(accentColor);
// 长按设备:编辑(重命名)/ 删除
adapter.setOnDeviceLongClickListener((device, position) ->
DeviceActionDialogs.showActions(requireActivity(),
device.getId(), device.getName(),
new DeviceActionDialogs.Callback() {
@Override
public void onRenamed(String newName) {
if (rvDevices != null) {
rvDevices.post(() -> loadDevices());
}
}
@Override
public void onDeleted() {
if (rvDevices != null) {
rvDevices.post(() -> loadDevices());
}
}
}));
rvDevices.setLayoutManager(new LinearLayoutManager(requireContext()));
rvDevices.setAdapter(adapter);
// 添加设备(染主题色)
View addBtn = view.findViewById(R.id.btn_add_device);
android.graphics.drawable.Drawable addBg = addBtn.getBackground();
if (addBg != null) {
addBg = addBg.mutate();
addBg.setTint(accentColor);
addBtn.setBackground(addBg);
}
addBtn.setOnClickListener(v ->
startActivity(new Intent(requireContext(), AddDeviceActivity.class)));
// 搜索
etSearch.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
@Override public void onTextChanged(CharSequence s, int a, int b, int c) {}
@Override
public void afterTextChanged(Editable s) {
keyword = s.toString().trim();
applyFilter();
}
});
// 房间 chips
renderRoomChips();
// 设备状态变化时刷新(HomeFragment 已注册设备级监听,这里刷新 UI 即可)
TuyaSdkService.registerStatusCallback(statusCallback);
loadDevices();
}
@Override
public void onResume() {
super.onResume();
refreshTheme();
// 每次回到设备页允许补同步一次(把涂鸦侧新增但本平台缺失的设备写回)
sdkMissingSyncDone = false;
loadDevices();
}
/** Tab 用 hide/show 切换时不走 onResume,需在此刷新主题色 */
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
if (!hidden && isAdded()) {
refreshTheme();
}
}
/** 重新读取主题色并刷新所有强调控件 */
private void refreshTheme() {
if (!isAdded()) return;
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
adapter.setAccentColor(accentColor);
adapter.notifyDataSetChanged();
renderRoomChips();
// 添加按钮染主题色
View addBtn = getView() != null ? getView().findViewById(R.id.btn_add_device) : null;
if (addBtn != null && addBtn.getBackground() != null) {
android.graphics.drawable.Drawable bg = addBtn.getBackground().mutate();
bg.setTint(accentColor);
addBtn.setBackground(bg);
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
TuyaSdkService.unregisterStatusCallback(statusCallback);
}
private final TuyaSdkService.DeviceStatusCallback statusCallback =
new TuyaSdkService.DeviceStatusCallback() {
@Override
public void onDpUpdate(String devId, java.util.Map<String, Object> dps) {
if (rvDevices == null) return;
rvDevices.post(() -> {
if (!isAdded()) return;
// 只更新该设备的 DP 并局部刷新该行。
// 原来每次 DP 变化都整表重载(网络请求 + 全量 notifyDataSetChanged),
// 会造成设备行重绘与顺序跳变,视觉上表现为"设备排列会闪"
TuyaDevice target = findDevice(devId);
if (target != null && dps != null) {
Map<String, Object> merged = target.getDps() == null
? new java.util.HashMap<>()
: new java.util.HashMap<>(target.getDps());
merged.putAll(dps);
target.setDps(merged);
}
adapter.refreshDevice(devId);
updateSummary();
});
}
@Override
public void onStatusChanged(String devId, boolean online) {
if (rvDevices == null) return;
rvDevices.post(() -> {
if (!isAdded()) return;
TuyaDevice target = findDevice(devId);
if (target != null) {
target.setOnline(online);
}
// 在线状态会影响"在线/离线"分组排序,重新应用过滤(DiffUtil 增量,无闪烁)
applyFilter();
});
}
};
private void renderRoomChips() {
llRoomChips.removeAllViews();
LayoutInflater inflater = LayoutInflater.from(requireContext());
for (String room : ROOMS) {
TextView chip = (TextView) inflater.inflate(
R.layout.item_room_chip, llRoomChips, false);
chip.setText(room);
chip.setOnClickListener(v -> {
activeRoom = room;
styleChips();
applyFilter();
});
llRoomChips.addView(chip);
}
styleChips();
}
private void styleChips() {
for (int i = 0; i < llRoomChips.getChildCount(); i++) {
View v = llRoomChips.getChildAt(i);
if (!(v instanceof TextView)) continue;
TextView chip = (TextView) v;
boolean active = ROOMS[i].equals(activeRoom);
chip.setBackgroundResource(active
? R.drawable.bg_chip_accent : R.drawable.bg_chip_muted);
if (active) {
// 染运行时主题色
android.graphics.drawable.Drawable bg = chip.getBackground();
if (bg != null) {
bg = bg.mutate();
bg.setTint(accentColor);
chip.setBackground(bg);
}
chip.setTextColor(0xFFFFFFFF);
} else {
chip.setTextColor(requireContext().getColor(R.color.c_muted_fg));
}
}
}
/** 设备上次显示顺序(deviceId → 序号),用于稳定排序,避免位置来回跳动 */
private final Map<String, Integer> orderMemo = new java.util.HashMap<>();
/** 按设备 id 查找设备 */
private TuyaDevice findDevice(String devId) {
if (devId == null) return null;
for (TuyaDevice d : allDevices) {
if (devId.equals(d.getId())) return d;
}
return null;
}
/** 按房间(设备名近似匹配)+ 关键字过滤 */
private void applyFilter() {
List<TuyaDevice> filtered = new ArrayList<>();
for (TuyaDevice d : allDevices) {
boolean mr = "全部".equals(activeRoom)
|| (d.getName() != null && d.getName().contains(activeRoom));
boolean ms = keyword.isEmpty()
|| (d.getName() != null && d.getName().contains(keyword))
|| (d.getCategory() != null && d.getCategory().contains(keyword));
if (mr && ms) filtered.add(d);
}
// 在线设备排前面,离线排后面;组内保持"上次显示顺序"。
// 后端返回顺序不稳定时会造成设备位置来回跳动(视觉上的"排列会闪"),故用 orderMemo 固定
final Map<String, Integer> memo = orderMemo;
filtered.sort((a, b) -> {
int c = Boolean.compare(!a.isOnline(), !b.isOnline());
if (c != 0) return c;
int oa = memo.getOrDefault(a.getId(), Integer.MAX_VALUE);
int ob = memo.getOrDefault(b.getId(), Integer.MAX_VALUE);
if (oa != ob) return Integer.compare(oa, ob);
String na = a.getName() != null ? a.getName() : "";
String nb = b.getName() != null ? b.getName() : "";
return na.compareTo(nb);
});
// 记录本次顺序,作为下次的稳定排序依据
orderMemo.clear();
for (int i = 0; i < filtered.size(); i++) {
if (filtered.get(i).getId() != null) {
orderMemo.put(filtered.get(i).getId(), i);
}
}
adapter.update(filtered);
updateSummary();
tvEmpty.setVisibility(filtered.isEmpty() ? View.VISIBLE : View.GONE);
}
private void updateSummary() {
int on = 0;
for (TuyaDevice d : allDevices) {
if (d.isOnline() && hasSwitchOn(d)) on++;
}
tvSummary.setText(allDevices.size() + " 台设备 · " + on + " 台运行中");
}
private boolean hasSwitchOn(TuyaDevice d) {
try {
String dpId = TuyaSdkService.findSwitchDpId(d.getId());
if (dpId == null || d.getDps() == null) return false;
Map<String, Object> probe = new java.util.HashMap<>();
probe.put(dpId, true);
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe);
Object v = null;
if (codeMap != null && !codeMap.isEmpty()) {
v = d.getDps().get(codeMap.keySet().iterator().next());
}
if (v == null) v = d.getDps().get(dpId);
return Boolean.TRUE.equals(v);
} catch (Throwable e) {
return false;
}
}
private void loadDevices() {
// 已有数据时不再显示 loading,避免每次刷新闪一下
if (allDevices.isEmpty()) showLoading(true);
String auth = session.getBearerToken();
if (auth == null) {
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("请先登录");
return;
}
Long homeId = session.getCurrentHomeId() > 0
? session.getCurrentHomeId() : null;
ApiClient.getApiService().getDevices(auth, 1, 100, null, null, homeId)
.enqueue(new retrofit2.Callback<Result<PageData<BackendDevice>>>() {
@Override
public void onResponse(retrofit2.Call<Result<PageData<BackendDevice>>> call,
retrofit2.Response<Result<PageData<BackendDevice>>> response) {
if (!isAdded()) return;
showLoading(false);
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
List<BackendDevice> list = response.body().getData() != null
? response.body().getData().getRecords() : null;
allDevices = toTuyaDevices(list);
applyFilter();
// 补同步:涂鸦侧已存在但本平台缺失的设备(如蓝牙配网/涂鸦App新增)写回后端
maybeSyncMissingSdkDevices(homeId, list);
} else {
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("获取设备失败");
}
}
@Override
public void onFailure(retrofit2.Call<Result<PageData<BackendDevice>>> call,
Throwable t) {
if (!isAdded()) return;
showLoading(false);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("连接服务器失败: " + t.getMessage());
}
});
}
/** 每次进入页面只允许补同步一次,避免「刷新→同步→刷新」递归 */
private boolean sdkMissingSyncDone = false;
/**
* 涂鸦侧设备已存在、但本平台 device 表缺失时,增量写回后端后刷新列表。
* 典型场景:蓝牙配网成功但没落库、或在涂鸦官方 App 里新增的设备。
*/
/**
* platformHomeId 是本平台家庭ID(用于写库与过滤);
* 读取设备要用「涂鸦侧默认家庭」的 homeId —— 两套 ID 体系不同,不能混用。
*/
private void maybeSyncMissingSdkDevices(final Long platformHomeId, List<BackendDevice> backendList) {
if (sdkMissingSyncDone || platformHomeId == null || !isAdded()) return;
sdkMissingSyncDone = true;
com.xhai.iot.service.TuyaSdkService.getHomes(
new com.xhai.iot.service.TuyaSdkService.HomeListCallback() {
@Override
public void onSuccess(List<com.xhai.iot.service.TuyaSdkService.SdkHome> homes) {
if (!isAdded() || homes == null || homes.isEmpty()) return;
// 涂鸦侧统一默认家庭(设备数最多)
com.xhai.iot.service.TuyaSdkService.SdkHome best = homes.get(0);
for (com.xhai.iot.service.TuyaSdkService.SdkHome h : homes) {
if (h != null && best != null && h.deviceCount > best.deviceCount) best = h;
}
final long tuyaHomeId = (best == null) ? 0L : best.homeId;
if (tuyaHomeId <= 0) return;
com.xhai.iot.service.TuyaSdkService.getDevices(tuyaHomeId,
new com.xhai.iot.service.TuyaSdkService.DeviceListCallback() {
@Override
public void onSuccess(List<com.xhai.iot.service.TuyaSdkService.SdkDevice> devices) {
bindMissingDevices(platformHomeId, backendList, devices);
}
@Override
public void onError(String error) {
android.util.Log.w("DevicesFragment", "补同步读取SDK设备失败: " + error);
}
});
}
@Override
public void onError(String error) {
android.util.Log.w("DevicesFragment", "补同步读取家庭失败: " + error);
}
});
}
private void bindMissingDevices(Long platformHomeId, List<BackendDevice> backendList,
List<com.xhai.iot.service.TuyaSdkService.SdkDevice> devices) {
if (!isAdded() || devices == null || devices.isEmpty()) return;
java.util.Set<String> known = new java.util.HashSet<>();
if (backendList != null) {
for (BackendDevice b : backendList) {
if (b.getDeviceId() != null) known.add(b.getDeviceId());
}
}
java.util.List<java.util.Map<String, Object>> missing = new java.util.ArrayList<>();
for (com.xhai.iot.service.TuyaSdkService.SdkDevice d : devices) {
if (d == null || d.devId == null || known.contains(d.devId)) continue;
java.util.Map<String, Object> info = new java.util.HashMap<>();
info.put("deviceId", d.devId);
info.put("deviceName", d.name);
info.put("category", d.category);
info.put("online", d.online);
info.put("productId", d.productId);
missing.add(info);
}
if (missing.isEmpty()) return;
android.util.Log.i("DevicesFragment", "补同步缺失设备 count=" + missing.size());
String auth = session.getBearerToken();
if (auth == null) return;
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("homeId", platformHomeId);
body.put("devices", missing);
body.put("fullSync", false);
com.xhai.iot.data.ApiClient.getApiService().bindTuyaDevices(auth, body)
.enqueue(new retrofit2.Callback<Result<Integer>>() {
@Override
public void onResponse(retrofit2.Call<Result<Integer>> call,
retrofit2.Response<Result<Integer>> response) {
if (isAdded()) loadDevices();
}
@Override
public void onFailure(retrofit2.Call<Result<Integer>> call, Throwable t) {
android.util.Log.w("DevicesFragment", "补同步失败: " + t.getMessage());
}
});
}
private List<TuyaDevice> toTuyaDevices(List<BackendDevice> list) {
List<TuyaDevice> result = new ArrayList<>();
if (list == null) return result;
for (BackendDevice bd : list) {
TuyaDevice td = new TuyaDevice();
td.setId(bd.getDeviceId());
td.setName(bd.getDeviceName());
td.setOnline(bd.isOnline());
td.setIcon(bd.getIcon());
td.setPlatform(bd.getPlatform());
td.setParentDeviceId(bd.getParentDeviceId());
td.setIsGateway(bd.getIsGateway());
// 后端设备类型作为兜底(涂鸦设备若 SDK 有 category 会覆盖)
if (bd.getDeviceType() != null && !bd.getDeviceType().isEmpty()) {
td.setCategory(bd.getDeviceType());
}
try {
com.thingclips.smart.sdk.bean.DeviceBean devBean =
com.thingclips.smart.home.sdk.ThingHomeSdk.getDataInstance()
.getDeviceBean(bd.getDeviceId());
if (devBean != null) {
// 在线状态以涂鸦 SDK 实时数据为准(后端 status 可能未及时刷新)
boolean sdkOnline = com.xhai.iot.service.TuyaSdkService.resolveDeviceOnline(
bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId());
td.setOnline(sdkOnline);
if (sdkOnline != bd.isOnline()) {
com.xhai.iot.service.TuyaSdkService
.reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
}
if (devBean.getDps() != null) {
td.setDps(devBean.getDps());
}
if (devBean.getCategory() != null && !devBean.getCategory().isEmpty()) {
td.setCategory(devBean.getCategory());
}
if (devBean.getProductId() != null) {
td.setProductId(devBean.getProductId());
}
}
} catch (Throwable ignored) {
}
result.add(td);
}
return result;
}
private void showLoading(boolean show) {
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
}
}

View File

@ -0,0 +1,80 @@
package com.xhai.iot.ui;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.xhai.iot.R;
/**
* 发现页面(AI 应用列表)
* 纯静态展示:AI 应用列表(图标 + 标题 + 描述 + 箭头)
*/
public class DiscoverFragment extends Fragment {
/** AI 应用列表(标题 + 描述 + 表情符号图标) */
private static final AiItem[] ITEMS = {
new AiItem("🛡️", "AI 安全守护", "全天候智能安全诊断,精准识别潜在风险"),
new AiItem("☁️", "AI 云存储", "录像云端存储,支持自定义告警、智能检索"),
new AiItem("⚡", "AI 节能", "了解家庭智能设备的能耗状况,获取节能建议"),
new AiItem("💬", "短信和电话通知", "接收设备告警和场景通知消息"),
new AiItem("💡", "AI 调光", "快速生成个性化灯效组合,高效打造灯光场景"),
new AiItem("🥗", "AI 卡路里", "扫一扫识别食物卡路里"),
new AiItem("📔", "AI 笔记", "录音转写,一键总结"),
new AiItem("🅰️", "AI 翻译", "AI 大模型驱动,准确又自然"),
};
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_discover, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
LinearLayout container = view.findViewById(R.id.ll_discover_container);
LayoutInflater inflater = LayoutInflater.from(requireContext());
// 插入分隔区
for (int i = 0; i < ITEMS.length; i++) {
if (i > 0) {
View spacer = new View(requireContext());
LinearLayout.LayoutParams sp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 12);
container.addView(spacer, sp);
}
View item = inflater.inflate(R.layout.item_discover_ai, container, false);
TextView ivIcon = item.findViewById(R.id.iv_ai_icon);
TextView tvTitle = item.findViewById(R.id.tv_ai_title);
TextView tvDesc = item.findViewById(R.id.tv_ai_desc);
AiItem ai = ITEMS[i];
ivIcon.setText(ai.icon);
tvTitle.setText(ai.title);
tvDesc.setText(ai.desc);
container.addView(item);
}
}
/** AI 应用数据 */
private static class AiItem {
final String icon;
final String title;
final String desc;
AiItem(String icon, String title, String desc) {
this.icon = icon;
this.title = title;
this.desc = desc;
}
}
}

View File

@ -0,0 +1,280 @@
package com.xhai.iot.ui;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.BackendDevice;
import com.xhai.iot.model.DevicePlatform;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.PlatformDevice;
import com.xhai.iot.model.Result;
import com.xhai.iot.service.IPlatformService;
import com.xhai.iot.service.PlatformServiceManager;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.util.SessionManager;
import java.util.HashMap;
import java.util.Map;
import java.util.ArrayList;
import java.util.List;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 网关面板(我们自己的网关页面,不依赖涂鸦原生面板)
*
* 架构:网关与子设备的父子关系由我们后端维护(device.parent_device_id),
* 该页面展示某网关下的子设备列表(真实数量/在线状态),
* 点击子设备进入通用控制面板(DeviceControlActivity,涂鸦 SDK 控制)。
*/
public class GatewayPanelActivity extends AppCompatActivity {
private static final int REQ_ADD_SUB = 1001;
private String gatewayId;
private String gatewayName;
private SessionManager session;
private TextView tvTitle;
private TextView tvGatewayName;
private TextView tvGatewayStatus;
private TextView tvSubDeviceCount;
private TextView tvEmpty;
private RecyclerView rvSubDevices;
private SubDeviceAdapter adapter;
private final List<BackendDevice> subDevices = new ArrayList<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_gateway_panel);
gatewayId = getIntent().getStringExtra("deviceId");
gatewayName = getIntent().getStringExtra("deviceName");
if (gatewayId == null) {
finish();
return;
}
session = SessionManager.getInstance(this);
tvTitle = findViewById(R.id.tv_gateway_title);
tvGatewayName = findViewById(R.id.tv_gateway_name);
tvGatewayStatus = findViewById(R.id.tv_gateway_status);
tvSubDeviceCount = findViewById(R.id.tv_sub_device_count);
tvEmpty = findViewById(R.id.tv_empty);
rvSubDevices = findViewById(R.id.rv_sub_devices);
tvTitle.setText(gatewayName != null ? gatewayName : "网关");
tvGatewayName.setText(gatewayName != null ? gatewayName : "网关");
findViewById(R.id.tv_back).setOnClickListener(v -> finish());
// 网关在线状态从 Intent 带过(默认在线,稍后从子设备统计可校验)
boolean online = getIntent().getBooleanExtra("online", true);
updateGatewayStatus(online);
adapter = new SubDeviceAdapter();
rvSubDevices.setLayoutManager(new LinearLayoutManager(this));
rvSubDevices.setAdapter(adapter);
// 添加子设备:进入添加页面(选择未归属设备绑定,或等待真实配网入网后刷新)
findViewById(R.id.btn_add_sub_device).setOnClickListener(v -> {
android.content.Intent it = new android.content.Intent(this,
AddSubDeviceActivity.class);
it.putExtra("deviceId", gatewayId);
it.putExtra("deviceName", gatewayName);
startActivityForResult(it, REQ_ADD_SUB);
});
}
@Override
protected void onResume() {
super.onResume();
// 每次回到网关面板刷新子设备(从添加页返回/添加成功后显示最新)
loadSubDevices();
}
@Override
protected void onActivityResult(int requestCode, int resultCode,
@Nullable android.content.Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQ_ADD_SUB && resultCode == RESULT_OK) {
loadSubDevices();
}
}
private void updateGatewayStatus(boolean online) {
tvGatewayStatus.setText(online ? "● 在线" : "● 离线");
tvGatewayStatus.setTextColor(online ? 0xFF07C160 : 0xFF909399);
}
/** 从后端查该网关下的子设备(device.parent_device_id = 网关id) */
private void loadSubDevices() {
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().getDevices(auth, 1, 200, gatewayId, null, null)
.enqueue(new Callback<Result<PageData<BackendDevice>>>() {
@Override
public void onResponse(Call<Result<PageData<BackendDevice>>> call,
Response<Result<PageData<BackendDevice>>> response) {
if (!isFinishing() && !isDestroyed()) {
handleResult(response);
}
}
@Override
public void onFailure(Call<Result<PageData<BackendDevice>>> call, Throwable t) {
if (!isFinishing() && !isDestroyed()) {
tvEmpty.setText("加载子设备失败: " + t.getMessage());
tvEmpty.setVisibility(View.VISIBLE);
}
}
});
}
private void handleResult(Response<Result<PageData<BackendDevice>>> response) {
if (response.isSuccessful() && response.body() != null && response.body().isSuccess()
&& response.body().getData() != null) {
List<BackendDevice> list = response.body().getData().getRecords();
subDevices.clear();
if (list != null) subDevices.addAll(list);
adapter.notifyDataSetChanged();
int onlineCount = 0;
for (BackendDevice d : subDevices) {
if (d.isOnline()) onlineCount++;
}
tvSubDeviceCount.setText("在线 " + onlineCount + " 个 / 共 " + subDevices.size() + " 个");
tvEmpty.setVisibility(subDevices.isEmpty() ? View.VISIBLE : View.GONE);
} else {
tvEmpty.setText("获取子设备失败");
tvEmpty.setVisibility(View.VISIBLE);
}
}
/** 子设备列表适配器 */
private class SubDeviceAdapter extends RecyclerView.Adapter<SubDeviceAdapter.VH> {
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_gateway_sub_device, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
BackendDevice d = subDevices.get(position);
holder.tvName.setText(d.getDeviceName() != null ? d.getDeviceName() : d.getDeviceId());
String type = d.getDeviceType() != null && !d.getDeviceType().isEmpty()
? d.getDeviceType() : d.getPlatform();
boolean online = d.isOnline();
holder.tvStatus.setText((type != null ? type : "设备") + " · " + (online ? "在线" : "离线"));
holder.tvStatus.setTextColor(online ? 0xFF07C160 : 0xFF909399);
// 加载子设备图标
String icon = d.getIcon();
if (icon != null && !icon.isEmpty()) {
Glide.with(holder.itemView.getContext())
.load(icon)
.placeholder(R.drawable.ic_device_default)
.error(R.drawable.ic_device_default)
.into(holder.ivIcon);
} else {
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
}
// 快捷开关(tuya 在线且有开关功能才显示)
holder.swQuick.setOnCheckedChangeListener(null);
holder.swQuick.setVisibility(View.GONE);
boolean canSwitch = "tuya".equals(d.getPlatform()) && online;
String switchDpId = canSwitch
? TuyaSdkService.findSwitchDpId(d.getDeviceId()) : null;
if (switchDpId != null) {
holder.swQuick.setVisibility(View.VISIBLE);
holder.swQuick.setEnabled(true);
holder.swQuick.setChecked(false);
holder.swQuick.setOnCheckedChangeListener((b, isChecked) -> {
if (!b.isPressed()) return;
Map<String, Object> cmd = new HashMap<>();
cmd.put(switchDpId, isChecked);
PlatformServiceManager.getInstance(GatewayPanelActivity.this)
.controlDevice(asPlatformDevice(d), cmd,
new IPlatformService.ControlCallback() {
@Override
public void onSuccess() {
android.util.Log.i("GatewayPanel",
"子设备控制成功: " + d.getDeviceId());
}
@Override
public void onError(String code, String error) {
runOnUiThread(() -> {
holder.swQuick.setChecked(!isChecked);
Toast.makeText(GatewayPanelActivity.this,
"控制失败: " + (error != null ? error : code),
Toast.LENGTH_LONG).show();
});
}
});
});
}
// 子设备点击:进入控制面板(通用面板/原生面板)
holder.itemView.setOnClickListener(v -> com.xhai.iot.ui.panel.PanelRouter.openPanel(
GatewayPanelActivity.this,
d.getDeviceId(),
d.getPlatform(),
d.getDeviceName()));
}
@Override
public int getItemCount() {
return subDevices.size();
}
class VH extends RecyclerView.ViewHolder {
ImageView ivIcon;
TextView tvName;
TextView tvStatus;
Switch swQuick;
VH(@NonNull View itemView) {
super(itemView);
ivIcon = itemView.findViewById(R.id.iv_sub_icon);
tvName = itemView.findViewById(R.id.tv_sub_name);
tvStatus = itemView.findViewById(R.id.tv_sub_status);
swQuick = itemView.findViewById(R.id.sw_sub_quick);
}
}
}
/** BackendDevice → PlatformDevice(控制链路用) */
private PlatformDevice asPlatformDevice(BackendDevice d) {
PlatformDevice pd = new PlatformDevice();
pd.setDeviceId(d.getDeviceId());
pd.setName(d.getDeviceName());
pd.setPlatform(DevicePlatform.TUYA);
pd.setOnline(d.isOnline());
pd.setIconUrl(d.getIcon());
return pd;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,209 @@
package com.xhai.iot.ui;
import android.app.AlertDialog;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.BackendHome;
import com.xhai.iot.model.Result;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.adapter.HomeListAdapter;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 家庭管理页(数据源:本平台后端 /api/v1/homes,不对接涂鸦)
* 家庭列表 + 添加家庭 + 删除家庭
*/
public class HomeManagerActivity extends AppCompatActivity {
private RecyclerView rvHomes;
private TextView tvEmpty;
private HomeListAdapter adapter;
private SessionManager session;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
setContentView(R.layout.activity_home_manager);
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
session = SessionManager.getInstance(this);
rvHomes = findViewById(R.id.rv_homes);
tvEmpty = findViewById(R.id.tv_empty);
TextView tvAddHome = findViewById(R.id.tv_add_home);
adapter = new HomeListAdapter();
adapter.setOnDeleteListener((home, position) -> confirmDelete(home));
rvHomes.setLayoutManager(new LinearLayoutManager(this));
rvHomes.setAdapter(adapter);
tvAddHome.setOnClickListener(v -> showAddHomeDialog());
loadHomes();
}
/** 从后端加载我的家庭列表 */
private void loadHomes() {
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().getMyHomes(auth, session.getMemberId())
.enqueue(new retrofit2.Callback<Result<List<BackendHome>>>() {
@Override
public void onResponse(retrofit2.Call<Result<List<BackendHome>>> call,
retrofit2.Response<Result<List<BackendHome>>> response) {
if (isFinishing() || isDestroyed()) return;
List<BackendHome> homes = response.isSuccessful()
&& response.body() != null && response.body().isSuccess()
? response.body().getData() : null;
if (homes == null) {
Toast.makeText(HomeManagerActivity.this,
"加载家庭失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_SHORT).show();
return;
}
runOnUiThread(() -> {
tvEmpty.setVisibility(homes.isEmpty()
? View.VISIBLE : View.GONE);
adapter.update(toSdkHomes(homes));
});
}
@Override
public void onFailure(retrofit2.Call<Result<List<BackendHome>>> call,
Throwable t) {
if (isFinishing() || isDestroyed()) return;
runOnUiThread(() -> Toast.makeText(HomeManagerActivity.this,
"加载家庭失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
}
});
}
/** 后端家庭 → 列表适配器模型(复用现有 UI) */
private List<TuyaSdkService.SdkHome> toSdkHomes(List<BackendHome> homes) {
List<TuyaSdkService.SdkHome> result = new ArrayList<>();
if (homes != null) {
for (BackendHome h : homes) {
if (h.getId() == null) continue;
result.add(new TuyaSdkService.SdkHome(h.getId(),
h.getName() != null ? h.getName() : "未命名家庭",
h.getDeviceCount() != null ? h.getDeviceCount() : 0));
}
}
return result;
}
/** 添加家庭对话框 */
private void showAddHomeDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("添加家庭");
final EditText etName = new EditText(this);
etName.setHint("请输入家庭名称");
builder.setView(etName);
builder.setPositiveButton("确定", (dialog, which) -> {
String name = etName.getText().toString().trim();
if (TextUtils.isEmpty(name)) {
Toast.makeText(this, "请输入家庭名称", Toast.LENGTH_SHORT).show();
return;
}
createHome(name);
});
builder.setNegativeButton("取消", null);
builder.show();
}
/** 创建家庭(后端) */
private void createHome(String name) {
String auth = session.getBearerToken();
if (auth == null) return;
Map<String, String> body = new HashMap<>();
body.put("name", name);
ApiClient.getApiService().createHome(auth, session.getMemberId(), body)
.enqueue(new retrofit2.Callback<Result<BackendHome>>() {
@Override
public void onResponse(retrofit2.Call<Result<BackendHome>> call,
retrofit2.Response<Result<BackendHome>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(HomeManagerActivity.this,
"家庭创建成功", Toast.LENGTH_SHORT).show();
loadHomes();
} else {
Toast.makeText(HomeManagerActivity.this,
"创建失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<BackendHome>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(HomeManagerActivity.this,
"创建失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
/** 确认删除家庭 */
private void confirmDelete(TuyaSdkService.SdkHome home) {
new AlertDialog.Builder(this)
.setTitle("删除家庭")
.setMessage("确定删除家庭「" + home.name + "」吗?")
.setPositiveButton("删除", (dialog, which) -> removeHome(home))
.setNegativeButton("取消", null)
.show();
}
/** 删除家庭(后端,仅 owner) */
private void removeHome(TuyaSdkService.SdkHome home) {
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().deleteHome(auth, home.homeId, session.getMemberId())
.enqueue(new retrofit2.Callback<Result<Void>>() {
@Override
public void onResponse(retrofit2.Call<Result<Void>> call,
retrofit2.Response<Result<Void>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(HomeManagerActivity.this,
"家庭已删除", Toast.LENGTH_SHORT).show();
loadHomes();
} else {
Toast.makeText(HomeManagerActivity.this,
"删除失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(HomeManagerActivity.this,
"删除失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
}

View File

@ -0,0 +1,194 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.LoginData;
import com.xhai.iot.model.Result;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.ThemeManager;
import java.util.HashMap;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 会员登录页(对接 iotsaas 后端)
*/
public class LoginActivity extends AppCompatActivity {
private EditText etUsername;
private EditText etPassword;
private Button btnLogin;
private ProgressBar progressBar;
private ImageButton btnClearPassword;
private ImageButton btnTogglePassword;
private boolean passwordVisible = false;
private SessionManager session;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
session = SessionManager.getInstance(this);
// 首次启动请求把 MumIot 固定到主屏幕(登录页弹出,避免被后续跳转覆盖)
android.content.SharedPreferences sp = getSharedPreferences("home_shortcut", MODE_PRIVATE);
if (!sp.getBoolean("mumiot_pinned_requested", false)) {
com.xhai.iot.util.HomeShortcutHelper.requestPinMumIot(this);
sp.edit().putBoolean("mumiot_pinned_requested", true).apply();
}
// 已登录直接进入主界面
if (session.isLoggedIn()) {
goMain();
return;
}
initViews();
}
private void initViews() {
etUsername = findViewById(R.id.et_username);
etPassword = findViewById(R.id.et_password);
btnLogin = findViewById(R.id.btn_login);
progressBar = findViewById(R.id.progress_login);
TextView btnRegister = findViewById(R.id.btn_go_register);
btnClearPassword = findViewById(R.id.btn_clear_password);
btnTogglePassword = findViewById(R.id.btn_toggle_password);
btnLogin.setOnClickListener(v -> doLogin());
btnRegister.setOnClickListener(v ->
startActivity(new Intent(this, RegisterActivity.class)));
applyThemeColors();
setupPasswordActions();
}
/** 应用主题色到登录按钮和注册链接 */
private void applyThemeColors() {
ThemeManager tm = ThemeManager.getInstance(this);
int accent = tm.accent().value;
int accentFg = tm.accentFgOnBg();
btnLogin.setTextColor(accentFg);
GradientDrawable gd = new GradientDrawable();
gd.setColor(accent);
gd.setCornerRadius(26f * getResources().getDisplayMetrics().density);
btnLogin.setBackground(gd);
TextView btnRegister = findViewById(R.id.btn_go_register);
btnRegister.setTextColor(accent);
}
/** 密码框右侧操作:清空 + 显示/隐藏密码 */
private void setupPasswordActions() {
btnClearPassword.setOnClickListener(v -> {
etPassword.setText("");
etPassword.requestFocus();
});
btnTogglePassword.setOnClickListener(v -> {
passwordVisible = !passwordVisible;
int type = InputType.TYPE_CLASS_TEXT
| (passwordVisible ? InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
: InputType.TYPE_TEXT_VARIATION_PASSWORD);
etPassword.setInputType(type);
etPassword.setSelection(etPassword.getText().length());
btnTogglePassword.setImageResource(passwordVisible ? R.drawable.ic_eye : R.drawable.ic_eye_off);
});
etPassword.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
boolean hasText = s != null && s.length() > 0;
btnClearPassword.setVisibility(hasText ? View.VISIBLE : View.GONE);
btnTogglePassword.setVisibility(hasText ? View.VISIBLE : View.GONE);
}
});
}
private void doLogin() {
String username = etUsername.getText().toString().trim();
String password = etPassword.getText().toString().trim();
if (TextUtils.isEmpty(username)) {
Toast.makeText(this, "请输入用户名", Toast.LENGTH_SHORT).show();
return;
}
if (TextUtils.isEmpty(password)) {
Toast.makeText(this, "请输入密码", Toast.LENGTH_SHORT).show();
return;
}
showLoading(true);
Map<String, String> body = new HashMap<>();
body.put("username", username);
body.put("password", password);
ApiService api = ApiClient.getApiService();
api.login(body).enqueue(new Callback<Result<LoginData>>() {
@Override
public void onResponse(Call<Result<LoginData>> call, Response<Result<LoginData>> response) {
showLoading(false);
if (response.isSuccessful() && response.body() != null) {
Result<LoginData> result = response.body();
if (result.isSuccess() && result.getData() != null) {
LoginData data = result.getData();
session.saveLogin(data.getToken(), data.getRefreshToken(),
data.getMemberId(), data.getUsername(), data.getNickname());
Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();
goMain();
} else {
Toast.makeText(LoginActivity.this,
result.getMessage() != null ? result.getMessage() : "登录失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(LoginActivity.this, "网络错误,请重试", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<Result<LoginData>> call, Throwable t) {
showLoading(false);
Toast.makeText(LoginActivity.this,
"连接服务器失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
private void showLoading(boolean show) {
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
btnLogin.setEnabled(!show);
}
private void goMain() {
startActivity(new Intent(this, MainActivity.class));
finish();
}
}

View File

@ -0,0 +1,437 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.xhai.iot.R;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.ThemeManager;
/**
* 主界面(底部 4 Tab + AI 悬浮按钮,对应设计稿 BottomNav/AIPage)
* Tab1 - 首页 / Tab2 - 设备 / Tab3 - 自动化 / Tab4 - 我的
*/
public class MainActivity extends AppCompatActivity {
private final Fragment homeFragment = new HomeFragment();
private final Fragment devicesFragment = new DevicesFragment();
private final Fragment automationFragment = new AutomationFragment();
private final Fragment profileFragment = new ProfileFragment();
private Fragment currentFragment;
/** "开启蓝牙"提示框引用:蓝牙恢复开启时自动收起,避免提示框残留打扰 */
private androidx.appcompat.app.AlertDialog btDialog;
/** 蓝牙开关状态广播:蓝牙被打开(含下拉通知栏快捷开关)时自动收起提示 */
private final android.content.BroadcastReceiver btStateReceiver =
new android.content.BroadcastReceiver() {
@Override
public void onReceive(android.content.Context context,
android.content.Intent intent) {
int state = intent.getIntExtra(
android.bluetooth.BluetoothAdapter.EXTRA_STATE, -1);
if (state == android.bluetooth.BluetoothAdapter.STATE_ON) {
dismissBtDialogIfShowing();
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.util.Log.i("DiagTuya", "==== onCreate 进入 ====");
// 应用用户选择的主题(浅色/深色/跟随系统)
ThemeManager.getInstance(this).applyNightMode();
setContentView(R.layout.activity_main);
// 状态栏/导航栏颜色跟随背景色(修复深色模式顶部白条)
ThemeManager.getInstance(this).applySystemBars(this);
// 未登录则返回登录页
if (!SessionManager.getInstance(this).isLoggedIn()) {
finish();
return;
}
// 同步涂鸦绑定状态:涂鸦 SDK 登录态独立于 member 会话,
// 退出登录不清涂鸦 token,重新登录后自动恢复绑定(无需重绑)。
// 涂鸦会话过期(refreshToken 失效)时,用本地加密凭据自动续登,绑定一次永久有效。
try {
boolean tuyaLoggedIn = com.thingclips.smart.home.sdk.ThingHomeSdk
.getUserInstance().isLogin();
SessionManager.getInstance(this).setTuyaBound(tuyaLoggedIn);
if (!tuyaLoggedIn && com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(this)) {
com.xhai.iot.util.TuyaAutoRelogin.relogin(this,
new com.xhai.iot.util.TuyaAutoRelogin.Callback() {
@Override
public void onSuccess(com.thingclips.smart.android.user.bean.User user) {
SessionManager.getInstance(MainActivity.this).setTuyaBound(true);
}
@Override
public void onError(String message) {
SessionManager.getInstance(MainActivity.this).setTuyaBound(false);
}
});
}
} catch (Exception e) {
// 涂鸦 SDK 未初始化等情况,保持原状态
}
// 涂鸦会话校验:isLogin()=true 但云端 token 已过期(USER_SESSION_LOSS)时
// SDK 不会自愈,主动用轻量接口校验一次,失效则用本地凭据静默续登
ensureTuyaSession();
BottomNavigationView bottomNav = findViewById(R.id.bottom_nav);
applyNavTint(bottomNav);
bottomNav.setOnItemSelectedListener(item -> {
int id = item.getItemId();
if (id == R.id.nav_home) {
switchTo(homeFragment, "home");
return true;
} else if (id == R.id.nav_devices) {
switchTo(devicesFragment, "devices");
return true;
} else if (id == R.id.nav_automation) {
switchTo(automationFragment, "automation");
return true;
} else if (id == R.id.nav_profile) {
switchTo(profileFragment, "profile");
return true;
}
return false;
});
// AI 悬浮按钮:单击进入 HomeAI 助手,长按直接开始语音控制
View btnAiFloat = findViewById(R.id.btn_ai_float);
btnAiFloat.setOnClickListener(v ->
startActivity(new Intent(this, AiAssistantActivity.class)));
btnAiFloat.setOnLongClickListener(v -> {
// 返回 true 消费事件,松手后不会再补一次单击(触觉反馈系统会自动发送)
startActivity(new Intent(this, AiAssistantActivity.class)
.putExtra(AiAssistantActivity.EXTRA_AUTO_VOICE, true));
return true;
});
// 默认显示首页
bottomNav.setSelectedItemId(R.id.nav_home);
// 申请蓝牙"附近设备"权限(Mesh 子设备本地通道必需,缺失会导致面板显示设备离线)
requestNearbyPermissions();
// 蓝牙未开启时提示用户打开(蓝牙 Mesh/BLE 子设备的本地通道依赖手机蓝牙)
maybePromptBluetooth();
// 监听蓝牙开关:用户打开蓝牙(无论从本 APP 的引导、系统设置还是
// 下拉通知栏快捷开关)时,自动收起"开启蓝牙"提示
androidx.core.content.ContextCompat.registerReceiver(this, btStateReceiver,
new android.content.IntentFilter(
android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED);
}
@Override
protected void onDestroy() {
super.onDestroy();
try {
unregisterReceiver(btStateReceiver);
} catch (Throwable ignored) {
}
}
private static final int REQ_NEARBY_PERMISSIONS = 2001;
private static final int REQ_ENABLE_BT = 2002;
/**
* 蓝牙未开启时提示用户打开。
* <p>
* 蓝牙 Mesh / BLE 子设备(如 JD6BTSA 这类 SigMesh 插座)的本地通道依赖手机蓝牙:
* 蓝牙关闭时手机与设备无法建立本地直连,只能依赖云端路由(且这类低功耗设备
* 大部分时间处于休眠、不在云端路由),在设备列表和涂鸦面板里就会表现为
* "设备离线"或控制无响应。因此打开 APP 时检测并引导开启。
* <p>
* 注意:Android 12+ 上弹出系统"启用蓝牙"对话框需要 BLUETOOTH_CONNECT
* 运行时权限,权限未授予前先跳过({@link #requestNearbyPermissions()} 的
* 授权回调里会再检查一次)。
*/
private void maybePromptBluetooth() {
try {
android.bluetooth.BluetoothManager bm =
getSystemService(android.bluetooth.BluetoothManager.class);
if (bm == null || bm.getAdapter() == null) {
return; // 设备没有蓝牙模块,无从提示
}
if (bm.getAdapter().isEnabled()) {
return; // 已开启
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
&& androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.BLUETOOTH_CONNECT)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
android.util.Log.i("MainActivity",
"蓝牙未开启但缺少 BLUETOOTH_CONNECT 权限,等权限授予后再提示");
return;
}
androidx.appcompat.app.AlertDialog dialog =
new com.google.android.material.dialog.MaterialAlertDialogBuilder(this)
.setTitle("开启蓝牙")
.setMessage("手机蓝牙未开启。蓝牙 Mesh / 蓝牙子设备需要通过手机蓝牙"
+ "直连,开启后设备才能正常显示在线并进行本地控制。")
.setPositiveButton("去开启", (d, w) -> {
try {
startActivityForResult(new Intent(
android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE),
REQ_ENABLE_BT);
} catch (Throwable e) {
android.util.Log.w("MainActivity",
"请求开启蓝牙失败: " + e.getMessage());
}
})
.setNegativeButton("暂不", null)
.create();
dialog.show();
btDialog = dialog;
android.util.Log.i("MainActivity", "蓝牙未开启,已弹窗提示");
} catch (Throwable e) {
android.util.Log.w("MainActivity", "检查蓝牙状态失败: " + e.getMessage());
}
}
/** 蓝牙已开启时收起"开启蓝牙"提示(提示框不再打扰已开蓝牙的用户) */
private void dismissBtDialogIfShowing() {
try {
android.bluetooth.BluetoothManager bm =
getSystemService(android.bluetooth.BluetoothManager.class);
boolean btOn = bm != null && bm.getAdapter() != null && bm.getAdapter().isEnabled();
if (btDialog != null && btDialog.isShowing() && btOn) {
btDialog.dismiss();
android.util.Log.i("MainActivity", "蓝牙已开启,自动收起提示");
}
} catch (Throwable ignored) {
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQ_ENABLE_BT && resultCode == RESULT_OK) {
// 蓝牙已开启:重建 Mesh 本地通道(此前可能因蓝牙关闭而连接失败)
try {
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
if (gid > 0) {
com.xhai.iot.service.TuyaSdkService.ensureMeshConnected(gid);
}
} catch (Throwable e) {
android.util.Log.w("MainActivity",
"开启蓝牙后重建 Mesh 失败: " + e.getMessage());
}
}
}
/**
* 申请蓝牙"附近设备"权限。
* <p>
* Android 12+ 上 BLUETOOTH_SCAN / BLUETOOTH_CONNECT / BLUETOOTH_ADVERTISE 均为运行时权限,
* 缺少时涂鸦 SDK 的 Mesh 客户端 {@code ThingSigMeshClient.startClient} 会抛 SecurityException,
* Mesh 子设备无法建立本地通道,在涂鸦原生面板里就表现为"设备不在线"。
* 因此启动时统一申请一次(授权后会自动重建 Mesh 连接)。
*/
private void requestNearbyPermissions() {
try {
java.util.List<String> need = new java.util.ArrayList<>();
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.BLUETOOTH_SCAN)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
need.add(android.Manifest.permission.BLUETOOTH_SCAN);
}
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.BLUETOOTH_CONNECT)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
need.add(android.Manifest.permission.BLUETOOTH_CONNECT);
}
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.BLUETOOTH_ADVERTISE)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
need.add(android.Manifest.permission.BLUETOOTH_ADVERTISE);
}
} else if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.ACCESS_FINE_LOCATION)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
// Android 11 及以下:BLE 扫描依赖定位权限
need.add(android.Manifest.permission.ACCESS_FINE_LOCATION);
}
if (!need.isEmpty()) {
android.util.Log.i("MainActivity", "申请蓝牙权限: " + need);
androidx.core.app.ActivityCompat.requestPermissions(this,
need.toArray(new String[0]), REQ_NEARBY_PERMISSIONS);
}
} catch (Throwable e) {
android.util.Log.w("MainActivity", "申请蓝牙权限失败: " + e.getMessage());
}
}
/** 蓝牙权限申请结果:授权后重建 Mesh 网络(此前因缺权限连接失败) */
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode != REQ_NEARBY_PERMISSIONS) return;
boolean allGranted = grantResults.length > 0;
for (int result : grantResults) {
if (result != android.content.pm.PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
android.util.Log.i("MainActivity", "蓝牙权限申请结果: allGranted=" + allGranted);
if (allGranted) {
try {
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
if (gid > 0) {
com.xhai.iot.service.TuyaSdkService.ensureMeshConnected(gid);
}
} catch (Throwable e) {
android.util.Log.w("MainActivity", "重建 Mesh 网络失败: " + e.getMessage());
}
// Android 12+:弹系统蓝牙启用对话框需要 BLUETOOTH_CONNECT 权限,
// 首次安装时 onCreate 阶段权限还没批下来,这里授权后再补一次检查
maybePromptBluetooth();
}
}
/** 底部导航选中色跟随主题色 */
private void applyNavTint(BottomNavigationView nav) {
int accent = ThemeManager.getInstance(this).accent().value;
android.content.res.ColorStateList sl = new android.content.res.ColorStateList(
new int[][]{
{android.R.attr.state_checked},
{}
},
new int[]{accent, 0xFF8A8F99});
nav.setItemIconTintList(sl);
nav.setItemTextColor(sl);
}
/** 供 Fragment 在主题色变化后刷新底部导航(弹窗关闭不触发 onResume,需手动调) */
public void refreshNavTint() {
BottomNavigationView nav = findViewById(R.id.bottom_nav);
if (nav != null) {
applyNavTint(nav);
}
}
@Override
public void onResume() {
super.onResume();
// 主题可能已在外观设置中修改,回来时刷新导航色
applyNavTint((BottomNavigationView) findViewById(R.id.bottom_nav));
// 用户可能在系统设置里开了蓝牙再回来,提示框自动收起
dismissBtDialogIfShowing();
// 登录后家庭 gid 就绪时,注册家庭级/设备级监听(捕获物理开关/原生面板操作/
// 设备上下线的 DP 变化并上报后端)。家庭是异步加载的,首帧 onResume 时 gid
// 可能仍为 0,因此用轮询重试,直到 gid 就绪(最多 ~15 秒)。idempotent:
// 同一家庭只注册一次。
tryRegisterDeviceListener();
}
/** 轮询重试注册设备监听,直到涂鸦家庭 gid 就绪 */
private int mRegisterRetry = 0;
private static final int MAX_REGISTER_RETRY = 30; // 30 * 500ms ≈ 15s
private void tryRegisterDeviceListener() {
try {
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
if (gid > 0) {
com.xhai.iot.service.TuyaSdkService.registerDeviceListener(gid);
mRegisterRetry = 0;
} else if (mRegisterRetry < MAX_REGISTER_RETRY) {
mRegisterRetry++;
new Handler(Looper.getMainLooper()).postDelayed(
this::tryRegisterDeviceListener, 500);
}
} catch (Throwable e) {
android.util.Log.w("MainActivity", "注册家庭设备监听失败: " + e.getMessage());
}
}
/** 供首页快捷方式等跳转到指定 Tab */
public void switchToTab(int navItemId) {
BottomNavigationView nav = findViewById(R.id.bottom_nav);
if (nav.getSelectedItemId() != navItemId) {
nav.setSelectedItemId(navItemId);
}
}
/** 异步校验涂鸦会话:USER_SESSION_LOSS 时用本地凭据静默续登(不阻塞 UI) */
private void ensureTuyaSession() {
try {
if (!com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(this)) return;
com.thingclips.smart.home.sdk.ThingHomeSdk.getHomeManagerInstance()
.queryHomeList(new com.thingclips.smart.home.sdk.callback.IThingGetHomeListCallback() {
@Override
public void onSuccess(java.util.List<com.thingclips.smart.home.sdk.bean.HomeBean> homes) {
// 会话正常
}
@Override
public void onError(String code, String error) {
if (code != null && code.toUpperCase().contains("SESSION_LOSS")) {
android.util.Log.w("MainActivity",
"涂鸦会话已过期,自动续登");
com.xhai.iot.util.TuyaAutoRelogin.relogin(MainActivity.this,
new com.xhai.iot.util.TuyaAutoRelogin.Callback() {
@Override
public void onSuccess(com.thingclips.smart.android.user.bean.User user) {
SessionManager.getInstance(MainActivity.this)
.setTuyaBound(true);
}
@Override
public void onError(String msg) {
android.util.Log.w("MainActivity",
"涂鸦自动续登失败: " + msg);
}
});
}
}
});
} catch (Throwable ignored) {
}
}
/**
* 切换到指定 Fragment
* 注意:Activity 重建后 FragmentManager 会恢复旧实例(与字段实例不同),
* 必须遍历隐藏除目标外的所有实例,否则旧页面会叠在上层导致"Tab 点不动"。
*/
private void switchTo(Fragment target, String tag) {
FragmentManager fm = getSupportFragmentManager();
FragmentTransaction transaction = fm.beginTransaction();
boolean targetHandled = false;
for (Fragment f : fm.getFragments()) {
if (f == target) {
transaction.show(f);
targetHandled = true;
} else {
transaction.hide(f);
}
}
if (!targetHandled) {
transaction.add(R.id.fragment_container, target, tag);
}
currentFragment = target;
transaction.commit();
}
}

View File

@ -0,0 +1,416 @@
package com.xhai.iot.ui;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.BackendHome;
import com.xhai.iot.model.PanelBindable;
import com.xhai.iot.model.PanelBindResult;
import com.xhai.iot.model.PanelInfo;
import com.xhai.iot.model.Result;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 我的中控屏:列表 + 解绑 + 扫码绑定(会员自助绑定 P4)
* 流程:扫码 → 预检 bindable → 选家庭 → bind;支持解绑。
*/
public class PanelListActivity extends AppCompatActivity {
private RecyclerView rvPanels;
private TextView tvEmpty;
private PanelAdapter adapter;
private SessionManager session;
private ApiService api;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
setContentView(R.layout.activity_panel_list);
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
session = SessionManager.getInstance(this);
api = ApiClient.getApiService();
rvPanels = findViewById(R.id.rv_panels);
tvEmpty = findViewById(R.id.tv_empty);
TextView btnScan = findViewById(R.id.btn_scan_bind);
adapter = new PanelAdapter();
adapter.setOnUnbindListener(this::confirmUnbind);
rvPanels.setLayoutManager(new LinearLayoutManager(this));
rvPanels.setAdapter(adapter);
btnScan.setOnClickListener(v -> startScan());
TextView btnManual = findViewById(R.id.btn_manual_bind);
btnManual.setOnClickListener(v -> showManualInput());
findViewById(R.id.btn_back).setOnClickListener(v -> finish());
// 按钮跟随主题色
applyAccent();
loadPanels();
}
/** 返回/重建后重新应用日夜模式与状态栏,避免扫码返回后主题异常 */
@Override
protected void onResume() {
super.onResume();
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
applyAccent();
}
/** 顶部操作按钮跟随当前主题色(切换主题色后返回本页即时生效) */
private void applyAccent() {
int accent = com.xhai.iot.util.ThemeManager.getInstance(this).accent().value;
int[] ids = {R.id.btn_scan_bind, R.id.btn_manual_bind};
for (int id : ids) {
TextView b = findViewById(id);
if (b == null) continue;
android.graphics.drawable.Drawable bg = b.getBackground();
if (bg == null) continue;
bg = bg.mutate();
bg.setTint(accent);
b.setBackground(bg);
}
}
/** 加载我绑定的中控屏列表 */
private void loadPanels() {
String auth = session.getBearerToken();
if (auth == null) return;
api.getMyPanels(auth).enqueue(new retrofit2.Callback<Result<List<PanelInfo>>>() {
@Override
public void onResponse(retrofit2.Call<Result<List<PanelInfo>>> call,
retrofit2.Response<Result<List<PanelInfo>>> response) {
if (isFinishing() || isDestroyed()) return;
List<PanelInfo> list = response.isSuccessful() && response.body() != null
&& response.body().isSuccess() && response.body().getData() != null
? response.body().getData() : null;
if (list == null) {
Toast.makeText(PanelListActivity.this,
"加载失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_SHORT).show();
return;
}
runOnUiThread(() -> {
tvEmpty.setVisibility(list.isEmpty() ? View.VISIBLE : View.GONE);
adapter.update(list);
});
}
@Override
public void onFailure(retrofit2.Call<Result<List<PanelInfo>>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
runOnUiThread(() -> Toast.makeText(PanelListActivity.this,
"加载失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
}
});
}
/** 启动扫码 */
private void startScan() {
new IntentIntegrator(this)
.setCaptureActivity(ZxingScanActivity.class)
.setPrompt("将中控屏二维码放入框内")
.setOrientationLocked(false)
.setBeepEnabled(true)
.initiateScan();
}
/** 兜底:手动输入 SN 绑定(屏固件暂未渲染二维码时使用) */
private void showManualInput() {
final android.widget.EditText input = new android.widget.EditText(this);
input.setHint("请输入中控屏序列号 SN");
input.setSingleLine(true);
input.setPadding(40, 24, 40, 24);
new AlertDialog.Builder(this)
.setTitle("手动绑定中控屏")
.setMessage("若屏上暂无二维码,可手动输入屏的序列号(SN)进行绑定。")
.setView(input)
.setPositiveButton("下一步", (dialog, which) -> {
String sn = (input.getText() == null ? "" : input.getText().toString()).trim();
if (sn.isEmpty()) {
Toast.makeText(this, "请输入序列号 SN", Toast.LENGTH_SHORT).show();
return;
}
checkBindable(sn);
})
.setNegativeButton("取消", null)
.show();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
if (result != null && result.getContents() != null) {
String sn = extractSn(result.getContents());
if (sn.isEmpty()) {
Toast.makeText(this, "无法识别二维码内容", Toast.LENGTH_SHORT).show();
return;
}
checkBindable(sn);
}
}
/** 从扫码内容提取 serialNumber(兼容「纯 SN」或「https://...?sn=XXX」) */
private String extractSn(String raw) {
if (raw == null) return "";
String s = raw.trim();
try {
android.net.Uri uri = android.net.Uri.parse(s);
if (uri != null && uri.getQueryParameter("sn") != null) {
return uri.getQueryParameter("sn");
}
} catch (Exception ignored) {
}
int idx = s.toLowerCase().indexOf("sn=");
if (idx >= 0) {
String sub = s.substring(idx + 3).trim();
int end = sub.indexOf('&');
return (end >= 0 ? sub.substring(0, end) : sub).trim();
}
return s;
}
/** 预检 SN 是否可绑定 */
private void checkBindable(String sn) {
String auth = session.getBearerToken();
if (auth == null) return;
api.getPanelBindable(auth, sn).enqueue(new retrofit2.Callback<Result<PanelBindable>>() {
@Override
public void onResponse(retrofit2.Call<Result<PanelBindable>> call,
retrofit2.Response<Result<PanelBindable>> response) {
if (isFinishing() || isDestroyed()) return;
if (!response.isSuccessful() || response.body() == null
|| !response.body().isSuccess()) {
Toast.makeText(PanelListActivity.this,
"预检失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_SHORT).show();
return;
}
PanelBindable b = response.body().getData();
if (b == null || !b.isExists()) {
Toast.makeText(PanelListActivity.this,
"该屏未激活或不存在", Toast.LENGTH_LONG).show();
return;
}
if (!b.isEnabled()) {
Toast.makeText(PanelListActivity.this,
"该屏已禁用,无法绑定", Toast.LENGTH_LONG).show();
return;
}
if (b.isBound()) {
Toast.makeText(PanelListActivity.this,
"该屏已被其它账号绑定", Toast.LENGTH_LONG).show();
return;
}
showHomePicker(sn);
}
@Override
public void onFailure(retrofit2.Call<Result<PanelBindable>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(PanelListActivity.this,
"预检失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/** 选择要绑定的家庭后调用 bind */
private void showHomePicker(String sn) {
String auth = session.getBearerToken();
if (auth == null) return;
api.getMyHomes(auth, session.getMemberId())
.enqueue(new retrofit2.Callback<Result<List<BackendHome>>>() {
@Override
public void onResponse(retrofit2.Call<Result<List<BackendHome>>> call,
retrofit2.Response<Result<List<BackendHome>>> response) {
if (isFinishing() || isDestroyed()) return;
List<BackendHome> homes = response.isSuccessful()
&& response.body() != null && response.body().isSuccess()
&& response.body().getData() != null
? response.body().getData() : null;
if (homes == null || homes.isEmpty()) {
Toast.makeText(PanelListActivity.this,
"请先在「我的」中创建家庭", Toast.LENGTH_LONG).show();
return;
}
CharSequence[] items = new CharSequence[homes.size()];
for (int i = 0; i < homes.size(); i++) {
items[i] = homes.get(i).getName() != null
? homes.get(i).getName() : "未命名家庭";
}
new AlertDialog.Builder(PanelListActivity.this)
.setTitle("绑定到哪个家庭")
.setItems(items, (dialog, which) ->
bindPanel(sn, homes.get(which).getId()))
.setNegativeButton("取消", null)
.show();
}
@Override
public void onFailure(retrofit2.Call<Result<List<BackendHome>>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(PanelListActivity.this,
"获取家庭失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/** 绑定中控屏 */
private void bindPanel(String sn, long homeId) {
String auth = session.getBearerToken();
if (auth == null) return;
Map<String, Object> body = new HashMap<>();
body.put("serialNumber", sn);
body.put("homeId", homeId);
api.bindPanel(auth, body).enqueue(new retrofit2.Callback<Result<PanelBindResult>>() {
@Override
public void onResponse(retrofit2.Call<Result<PanelBindResult>> call,
retrofit2.Response<Result<PanelBindResult>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(PanelListActivity.this,
"绑定成功,请让中控屏重新联网", Toast.LENGTH_LONG).show();
loadPanels();
} else {
String msg = response.body() != null ? response.body().getMessage() : "网络错误";
Toast.makeText(PanelListActivity.this,
"绑定失败: " + msg, Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<PanelBindResult>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(PanelListActivity.this,
"绑定失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
/** 解绑确认 */
private void confirmUnbind(PanelInfo panel) {
new AlertDialog.Builder(this)
.setTitle("解绑中控屏")
.setMessage("确定解绑「" + panel.getSerialNumber() + "」吗?解绑后该屏需重新绑定才能使用。")
.setPositiveButton("解绑", (dialog, which) -> doUnbind(panel))
.setNegativeButton("取消", null)
.show();
}
/** 解绑中控屏 */
private void doUnbind(PanelInfo panel) {
if (panel.getPanelId() == null) return;
String auth = session.getBearerToken();
if (auth == null) return;
api.unbindPanel(auth, panel.getPanelId()).enqueue(new retrofit2.Callback<Result<Void>>() {
@Override
public void onResponse(retrofit2.Call<Result<Void>> call,
retrofit2.Response<Result<Void>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(PanelListActivity.this, "已解绑", Toast.LENGTH_SHORT).show();
loadPanels();
} else {
Toast.makeText(PanelListActivity.this,
"解绑失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(PanelListActivity.this,
"解绑失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
// ==================== 列表适配器 ====================
private static class PanelAdapter extends RecyclerView.Adapter<PanelAdapter.VH> {
private List<PanelInfo> items = new ArrayList<>();
private OnUnbindListener unbindListener;
void update(List<PanelInfo> list) {
items = list != null ? list : new ArrayList<>();
notifyDataSetChanged();
}
void setOnUnbindListener(OnUnbindListener l) { unbindListener = l; }
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_panel, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH h, int pos) {
PanelInfo p = items.get(pos);
h.tvSn.setText(p.getSerialNumber());
StringBuilder sub = new StringBuilder();
sub.append("角色: ").append(p.getRole() != null ? p.getRole() : "-");
sub.append(" · ").append(p.isOnline() ? "在线" : "离线");
if (p.getModel() != null && !p.getModel().isEmpty()) {
sub.append(" · ").append(p.getModel());
}
h.tvSub.setText(sub.toString());
h.btnUnbind.setOnClickListener(v -> {
if (unbindListener != null) unbindListener.onUnbind(p);
});
}
@Override
public int getItemCount() { return items.size(); }
static class VH extends RecyclerView.ViewHolder {
TextView tvSn, tvSub, btnUnbind;
VH(View v) {
super(v);
tvSn = v.findViewById(R.id.tv_sn);
tvSub = v.findViewById(R.id.tv_sub);
btnUnbind = v.findViewById(R.id.btn_unbind);
}
}
interface OnUnbindListener {
void onUnbind(PanelInfo panel);
}
}
}

View File

@ -0,0 +1,254 @@
package com.xhai.iot.ui;
import android.content.Intent;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.SceneItem;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.ThemeManager;
/**
* 我的页(设计稿 ProfilePage)
* 头像统计卡 + 分组菜单(外观/家庭与房间/账号绑定/设置/帮助) + 退出登录
*/
public class ProfileFragment extends Fragment {
private SessionManager session;
private ThemeManager theme;
private View root;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_profile, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
root = view;
session = SessionManager.getInstance(requireContext());
theme = ThemeManager.getInstance(requireContext());
render();
}
@Override
public void onResume() {
super.onResume();
if (root != null) {
render();
}
}
/** Tab 用 hide/show 切换时不走 onResume,需在此刷新 */
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
if (!hidden && root != null) {
render();
}
}
/** 外观设置弹窗修改主题后调用:重渲染本页 + 刷新底部导航色 */
public void refreshTheme() {
if (root != null) {
render();
}
if (getActivity() instanceof MainActivity) {
((MainActivity) getActivity()).refreshNavTint();
}
}
/** 渲染整页(主题/绑定状态可能已变化) */
private void render() {
int accent = theme.accent().value;
// 用户信息
String nickname = session.getNickname();
String username = session.getUsername();
TextView tvNickname = root.findViewById(R.id.tv_nickname);
TextView tvUsername = root.findViewById(R.id.tv_username);
TextView tvAvatar = root.findViewById(R.id.tv_avatar);
tvNickname.setText(nickname != null && !nickname.isEmpty() ? nickname : "用户");
tvUsername.setText(username != null ? username : "");
tvAvatar.setText(nickname != null && !nickname.isEmpty()
? nickname.substring(0, 1) : "用");
GradientDrawable avatarBg = new GradientDrawable();
avatarBg.setCornerRadius(dp(16));
avatarBg.setColor(accent);
tvAvatar.setBackground(avatarBg);
// 会员徽章染主题色
TextView badge = root.findViewById(R.id.tv_badge_admin);
badge.getBackground().mutate().setTint(accent);
// 分组菜单
LinearLayout gAppearance = root.findViewById(R.id.ll_group_appearance);
LinearLayout gManage = root.findViewById(R.id.ll_group_manage);
LinearLayout gOther = root.findViewById(R.id.ll_group_other);
gAppearance.removeAllViews();
gManage.removeAllViews();
gOther.removeAllViews();
// 个性化:外观与主题色(右侧显示当前主题色圆点)
addRow(gAppearance, "🎨", "外观与主题色",
modeLabel(), accent, true, v -> {
new AppearanceSheetFragment().show(
getChildFragmentManager(), "appearance");
});
// 管理:家庭管理 / 房间管理 / 账号绑定 / 我的设备
addRow(gManage, "🏠", "家庭管理", "切换与创建家庭", 0, false,
v -> startActivity(new Intent(requireContext(), HomeManagerActivity.class)));
addRow(gManage, "🗂️", "房间管理", "房间分组设置", 0, false,
v -> startActivity(new Intent(requireContext(), RoomManagerActivity.class)));
addRow(gManage, "🔗", "账号绑定", "涂鸦账号",
0, false,
v -> startActivity(new Intent(requireContext(), TuyaBindActivity.class)));
addRow(gManage, "📱", "我的设备", "设备管理", 0, false,
v -> startActivity(new Intent(requireContext(), DeviceActivity.class)));
// 中控屏(会员扫码自助绑定):10 寸屏作为本地执行网关,与 Matter 配网是两条并行接入路线
addRow(gManage, "🖥️", "我的中控屏", "中控屏绑定与管理", 0, false,
v -> startActivity(new Intent(requireContext(), PanelListActivity.class)));
// 其他:设置 / 帮助与支持
addRow(gOther, "⚙️", "设置", "通知、隐私、安全", 0, false,
v -> Toast.makeText(requireContext(), "设置(待实现)",
Toast.LENGTH_SHORT).show());
addRow(gOther, "📖", "帮助与支持", "常见问题、联系我们", 0, false,
v -> showHelp());
// 退出登录
root.findViewById(R.id.btn_logout).setOnClickListener(v -> doLogout());
// 统计:设备数 / 场景数
loadStats();
}
/** 拉取设备/场景数量 */
private void loadStats() {
String auth = session.getBearerToken();
if (auth == null) return;
TextView tvDevices = root.findViewById(R.id.tv_stat_devices);
TextView tvScenes = root.findViewById(R.id.tv_stat_scenes);
TextView tvRules = root.findViewById(R.id.tv_stat_rules);
ApiClient.getApiService().getDevices(auth, 1, 1, null, null, null)
.enqueue(new retrofit2.Callback<Result<PageData<com.xhai.iot.model.BackendDevice>>>() {
@Override
public void onResponse(retrofit2.Call<Result<PageData<com.xhai.iot.model.BackendDevice>>> call,
retrofit2.Response<Result<PageData<com.xhai.iot.model.BackendDevice>>> response) {
if (!isAdded() || response.body() == null
|| !response.body().isSuccess()) return;
PageData<?> data = response.body().getData();
tvDevices.setText(data != null
? String.valueOf(data.getTotal()) : "0");
}
@Override
public void onFailure(retrofit2.Call<Result<PageData<com.xhai.iot.model.BackendDevice>>> call,
Throwable t) {
}
});
ApiClient.getSceneApiService().getScenes(auth, 1, 100)
.enqueue(new retrofit2.Callback<Result<PageData<SceneItem>>>() {
@Override
public void onResponse(retrofit2.Call<Result<PageData<SceneItem>>> call,
retrofit2.Response<Result<PageData<SceneItem>>> response) {
if (!isAdded() || response.body() == null
|| !response.body().isSuccess()) return;
PageData<SceneItem> data = response.body().getData();
int count = data != null && data.getRecords() != null
? data.getRecords().size() : 0;
tvScenes.setText(String.valueOf(count));
tvRules.setText(String.valueOf(count));
}
@Override
public void onFailure(retrofit2.Call<Result<PageData<SceneItem>>> call,
Throwable t) {
}
});
}
/** 添加分组菜单行 */
private void addRow(LinearLayout group, String icon, String label, String desc,
int dotColor, boolean showDot, View.OnClickListener listener) {
View row = LayoutInflater.from(requireContext())
.inflate(R.layout.item_profile_row, group, false);
TextView tvIcon = row.findViewById(R.id.tv_row_icon);
TextView tvLabel = row.findViewById(R.id.tv_row_label);
TextView tvDesc = row.findViewById(R.id.tv_row_desc);
View dot = row.findViewById(R.id.v_dot);
tvIcon.setText(icon);
tvLabel.setText(label);
tvDesc.setText(desc);
if (showDot && dotColor != 0) {
GradientDrawable d = new GradientDrawable();
d.setShape(GradientDrawable.OVAL);
d.setColor(dotColor);
dot.setBackground(d);
dot.setVisibility(View.VISIBLE);
} else {
dot.setVisibility(View.GONE);
}
row.setOnClickListener(listener);
group.addView(row);
// 分隔线
if (group.getChildCount() > 1) {
View line = new View(requireContext());
line.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, dp(1)));
line.setBackgroundColor(requireContext().getColor(R.color.c_border));
group.addView(line, 0);
}
}
private String modeLabel() {
String m = theme.mode();
if (ThemeManager.MODE_DARK.equals(m)) return "🌙 深色";
if (ThemeManager.MODE_LIGHT.equals(m)) return "☀️ 浅色";
return "⚙️ 跟随系统";
}
private void showHelp() {
new androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle("帮助与支持")
.setItems(new String[]{"常见问题", "使用教程", "联系我们", "关于 IoT SaaS"},
(d, w) -> Toast.makeText(requireContext(), "敬请期待",
Toast.LENGTH_SHORT).show())
.setNegativeButton("关闭", null)
.show();
}
private void doLogout() {
session.logout();
Toast.makeText(requireContext(), "已退出登录", Toast.LENGTH_SHORT).show();
Intent intent = new Intent(requireContext(), LoginActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
requireActivity().finish();
}
private int dp(int v) {
return Math.round(getResources().getDisplayMetrics().density * v);
}
}

View File

@ -0,0 +1,116 @@
package com.xhai.iot.ui;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.Result;
import java.util.HashMap;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 会员注册页
*/
public class RegisterActivity extends AppCompatActivity {
private EditText etUsername;
private EditText etPassword;
private EditText etPassword2;
private EditText etNickname;
private EditText etPhone;
private Button btnRegister;
private ProgressBar progressBar;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_register);
initViews();
}
private void initViews() {
etUsername = findViewById(R.id.et_username);
etPassword = findViewById(R.id.et_password);
etPassword2 = findViewById(R.id.et_password2);
etNickname = findViewById(R.id.et_nickname);
etPhone = findViewById(R.id.et_phone);
btnRegister = findViewById(R.id.btn_register);
progressBar = findViewById(R.id.progress_register);
btnRegister.setOnClickListener(v -> doRegister());
}
private void doRegister() {
String username = etUsername.getText().toString().trim();
String password = etPassword.getText().toString().trim();
String password2 = etPassword2.getText().toString().trim();
String nickname = etNickname.getText().toString().trim();
String phone = etPhone.getText().toString().trim();
if (TextUtils.isEmpty(username)) {
Toast.makeText(this, "请输入用户名", Toast.LENGTH_SHORT).show();
return;
}
if (TextUtils.isEmpty(password)) {
Toast.makeText(this, "请输入密码", Toast.LENGTH_SHORT).show();
return;
}
if (!password.equals(password2)) {
Toast.makeText(this, "两次密码不一致", Toast.LENGTH_SHORT).show();
return;
}
progressBar.setVisibility(View.VISIBLE);
btnRegister.setEnabled(false);
Map<String, String> body = new HashMap<>();
body.put("username", username);
body.put("password", password);
if (!TextUtils.isEmpty(nickname)) body.put("nickname", nickname);
if (!TextUtils.isEmpty(phone)) body.put("phone", phone);
ApiClient.getApiService().register(body).enqueue(new Callback<Result<Void>>() {
@Override
public void onResponse(Call<Result<Void>> call, Response<Result<Void>> response) {
progressBar.setVisibility(View.GONE);
btnRegister.setEnabled(true);
if (response.isSuccessful() && response.body() != null) {
Result<Void> result = response.body();
if (result.isSuccess()) {
Toast.makeText(RegisterActivity.this,
"注册成功,请登录", Toast.LENGTH_SHORT).show();
finish();
} else {
Toast.makeText(RegisterActivity.this,
result.getMessage() != null ? result.getMessage() : "注册失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(RegisterActivity.this, "网络错误,请重试", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<Result<Void>> call, Throwable t) {
progressBar.setVisibility(View.GONE);
btnRegister.setEnabled(true);
Toast.makeText(RegisterActivity.this,
"连接服务器失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
}

View File

@ -0,0 +1,264 @@
package com.xhai.iot.ui;
import android.app.AlertDialog;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.BackendHome;
import com.xhai.iot.model.BackendRoom;
import com.xhai.iot.model.Result;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.adapter.RoomListAdapter;
import com.xhai.iot.util.SessionManager;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 房间管理页(数据源:本平台后端 /api/v1/homes/{homeId}/rooms,不对接涂鸦)
* 家庭房间列表 + 添加房间 + 删除房间
*/
public class RoomManagerActivity extends AppCompatActivity {
private long homeId;
private String homeName;
private RecyclerView rvRooms;
private TextView tvEmpty;
private RoomListAdapter adapter;
private SessionManager session;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
setContentView(R.layout.activity_room_manager);
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
session = SessionManager.getInstance(this);
homeId = getIntent().getLongExtra("homeId", 0);
homeName = getIntent().getStringExtra("homeName");
rvRooms = findViewById(R.id.rv_rooms);
tvEmpty = findViewById(R.id.tv_empty);
TextView tvAddRoom = findViewById(R.id.tv_add_room);
adapter = new RoomListAdapter();
adapter.setOnDeleteListener((room, position) -> confirmDelete(room));
rvRooms.setLayoutManager(new LinearLayoutManager(this));
rvRooms.setAdapter(adapter);
tvAddRoom.setOnClickListener(v -> showAddRoomDialog());
// 未指定家庭时,取后端第一个家庭
if (homeId <= 0) {
resolveDefaultHome();
} else {
loadRooms();
}
}
/** 从后端解析默认家庭(第一个),然后加载房间 */
private void resolveDefaultHome() {
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().getMyHomes(auth, session.getMemberId())
.enqueue(new retrofit2.Callback<Result<List<BackendHome>>>() {
@Override
public void onResponse(retrofit2.Call<Result<List<BackendHome>>> call,
retrofit2.Response<Result<List<BackendHome>>> response) {
if (isFinishing() || isDestroyed()) return;
List<BackendHome> homes = response.isSuccessful()
&& response.body() != null && response.body().isSuccess()
? response.body().getData() : null;
if (homes == null || homes.isEmpty()) {
runOnUiThread(() -> {
tvEmpty.setVisibility(View.VISIBLE);
((TextView) findViewById(R.id.tv_add_room)).setEnabled(false);
});
Toast.makeText(RoomManagerActivity.this,
"请先创建家庭", Toast.LENGTH_SHORT).show();
return;
}
homeId = homes.get(0).getId();
homeName = homes.get(0).getName();
runOnUiThread(this::loadRoomsOnUi);
}
private void loadRoomsOnUi() {
loadRooms();
}
@Override
public void onFailure(retrofit2.Call<Result<List<BackendHome>>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
runOnUiThread(() -> Toast.makeText(RoomManagerActivity.this,
"加载家庭失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
}
});
}
/** 从后端加载房间列表 */
private void loadRooms() {
if (homeId <= 0) {
resolveDefaultHome();
return;
}
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().getRooms(auth, homeId, session.getMemberId())
.enqueue(new retrofit2.Callback<Result<List<BackendRoom>>>() {
@Override
public void onResponse(retrofit2.Call<Result<List<BackendRoom>>> call,
retrofit2.Response<Result<List<BackendRoom>>> response) {
if (isFinishing() || isDestroyed()) return;
List<BackendRoom> rooms = response.isSuccessful()
&& response.body() != null && response.body().isSuccess()
? response.body().getData() : null;
if (rooms == null) {
Toast.makeText(RoomManagerActivity.this,
"加载房间失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_SHORT).show();
return;
}
runOnUiThread(() -> {
tvEmpty.setVisibility(rooms.isEmpty()
? View.VISIBLE : View.GONE);
adapter.update(toSdkRooms(rooms));
});
}
@Override
public void onFailure(retrofit2.Call<Result<List<BackendRoom>>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
runOnUiThread(() -> Toast.makeText(RoomManagerActivity.this,
"加载房间失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
}
});
}
/** 后端房间 → 列表适配器模型(复用现有 UI) */
private List<TuyaSdkService.SdkRoom> toSdkRooms(List<BackendRoom> rooms) {
List<TuyaSdkService.SdkRoom> result = new java.util.ArrayList<>();
if (rooms != null) {
for (BackendRoom r : rooms) {
if (r.getId() == null) continue;
result.add(new TuyaSdkService.SdkRoom(r.getId(),
r.getName() != null ? r.getName() : "未命名房间"));
}
}
return result;
}
/** 添加房间对话框 */
private void showAddRoomDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("添加房间");
final EditText etName = new EditText(this);
etName.setHint("请输入房间名称");
builder.setView(etName);
builder.setPositiveButton("确定", (dialog, which) -> {
String name = etName.getText().toString().trim();
if (TextUtils.isEmpty(name)) {
Toast.makeText(this, "请输入房间名称", Toast.LENGTH_SHORT).show();
return;
}
addRoom(name);
});
builder.setNegativeButton("取消", null);
builder.show();
}
/** 添加房间(后端) */
private void addRoom(String name) {
if (homeId <= 0) {
Toast.makeText(this, "家庭未就绪,请稍后重试", Toast.LENGTH_SHORT).show();
return;
}
String auth = session.getBearerToken();
if (auth == null) return;
Map<String, String> body = new HashMap<>();
body.put("name", name);
ApiClient.getApiService().addRoom(auth, homeId, session.getMemberId(), body)
.enqueue(new retrofit2.Callback<Result<BackendRoom>>() {
@Override
public void onResponse(retrofit2.Call<Result<BackendRoom>> call,
retrofit2.Response<Result<BackendRoom>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(RoomManagerActivity.this,
"房间添加成功", Toast.LENGTH_SHORT).show();
loadRooms();
} else {
Toast.makeText(RoomManagerActivity.this,
"添加失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<BackendRoom>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(RoomManagerActivity.this,
"添加失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
/** 确认删除房间 */
private void confirmDelete(TuyaSdkService.SdkRoom room) {
new AlertDialog.Builder(this)
.setTitle("删除房间")
.setMessage("确定删除房间「" + room.name + "」吗?")
.setPositiveButton("删除", (dialog, which) -> removeRoom(room))
.setNegativeButton("取消", null)
.show();
}
/** 删除房间(后端,逻辑删除) */
private void removeRoom(TuyaSdkService.SdkRoom room) {
if (homeId <= 0) return;
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().deleteRoom(auth, homeId, room.roomId, session.getMemberId())
.enqueue(new retrofit2.Callback<Result<Void>>() {
@Override
public void onResponse(retrofit2.Call<Result<Void>> call,
retrofit2.Response<Result<Void>> response) {
if (isFinishing() || isDestroyed()) return;
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Toast.makeText(RoomManagerActivity.this,
"房间已删除", Toast.LENGTH_SHORT).show();
loadRooms();
} else {
Toast.makeText(RoomManagerActivity.this,
"删除失败: " + (response.body() != null
? response.body().getMessage() : "网络错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(RoomManagerActivity.this,
"删除失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
}

View File

@ -0,0 +1,126 @@
package com.xhai.iot.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.xhai.iot.R;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
/**
* 创建场景页
* 展示场景列表 + 创建场景(先本地存储,后续可对接涂鸦场景 SDK)
*/
public class SceneActivity extends AppCompatActivity {
private static final String PREFS = "xhai_scene";
private static final String KEY_SCENES = "scenes";
private LinearLayout llSceneContainer;
private TextView tvEmpty;
private final List<SceneItem> scenes = new ArrayList<>();
private final Gson gson = new Gson();
/** 场景项 */
public static class SceneItem {
public String name;
public String icon;
public String desc;
public SceneItem(String name, String icon, String desc) {
this.name = name;
this.icon = icon;
this.desc = desc;
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scene);
llSceneContainer = findViewById(R.id.ll_scene_container);
tvEmpty = findViewById(R.id.tv_empty);
TextView tvCreate = findViewById(R.id.tv_create);
tvCreate.setOnClickListener(v -> showCreateDialog());
loadScenes();
renderScenes();
}
private void loadScenes() {
SharedPreferences prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String json = prefs.getString(KEY_SCENES, "[]");
try {
Type type = new TypeToken<List<SceneItem>>() {}.getType();
List<SceneItem> list = gson.fromJson(json, type);
scenes.clear();
if (list != null) scenes.addAll(list);
} catch (Exception e) {
scenes.clear();
}
}
private void saveScenes() {
SharedPreferences prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE);
prefs.edit().putString(KEY_SCENES, gson.toJson(scenes)).apply();
}
private void renderScenes() {
llSceneContainer.removeAllViews();
if (scenes.isEmpty()) {
tvEmpty.setVisibility(View.VISIBLE);
return;
}
tvEmpty.setVisibility(View.GONE);
LayoutInflater inflater = LayoutInflater.from(this);
for (int i = 0; i < scenes.size(); i++) {
SceneItem scene = scenes.get(i);
View item = inflater.inflate(R.layout.item_scene, llSceneContainer, false);
TextView tvIcon = item.findViewById(R.id.tv_scene_icon);
TextView tvName = item.findViewById(R.id.tv_scene_name);
TextView tvDesc = item.findViewById(R.id.tv_scene_desc);
tvIcon.setText(scene.icon);
tvName.setText(scene.name);
tvDesc.setText(scene.desc);
llSceneContainer.addView(item);
}
}
private void showCreateDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("创建场景");
final EditText etName = new EditText(this);
etName.setHint("请输入场景名称");
builder.setView(etName);
builder.setPositiveButton("创建", (dialog, which) -> {
String name = etName.getText().toString().trim();
if (TextUtils.isEmpty(name)) {
Toast.makeText(this, "请输入场景名称", Toast.LENGTH_SHORT).show();
return;
}
scenes.add(new SceneItem(name, "⏰", "手动执行场景"));
saveScenes();
renderScenes();
Toast.makeText(this, "场景创建成功", Toast.LENGTH_SHORT).show();
});
builder.setNegativeButton("取消", null);
builder.show();
}
}

View File

@ -0,0 +1,661 @@
package com.xhai.iot.ui;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.thingclips.smart.android.device.bean.SchemaBean;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.ControlSchema;
import com.xhai.iot.model.SceneItem;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 场景详情页:展示场景信息、规则列表、添加/删除规则、一执行场景
*/
public class SceneDetailActivity extends AppCompatActivity {
private TextView tvBack;
private TextView tvSceneTitle;
private TextView tvSceneIcon;
private TextView tvSceneName;
private TextView tvSceneDesc;
private Button btnExecute;
private TextView tvAddRule;
private LinearLayout llRuleList;
private TextView tvRuleEmpty;
private SessionManager session;
private Long sceneId;
private String sceneName;
private final List<Map<String, Object>> rules = new ArrayList<>();
private final List<TuyaSdkService.SdkDevice> deviceList = new ArrayList<>();
// 动作设计相关:保存当前动作设备可写指令集(property code -> ControlItem,平台无关)
private Spinner spActionDevice;
private Spinner spActionDp;
private android.widget.FrameLayout flActionValue;
private java.util.Map<String, ControlSchema.ControlItem> currentSchemaMap = new java.util.HashMap<>();
private final List<String> actionDpKeys = new ArrayList<>();
private int actionDpTypeIndex = 0; // 0=Bool,1=Enum,2=Value,3=String
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scene_detail);
session = SessionManager.getInstance(this);
sceneId = getIntent().getLongExtra("sceneId", 0);
sceneName = getIntent().getStringExtra("sceneName");
if (sceneId == 0) {
finish();
return;
}
tvBack = findViewById(R.id.tv_back);
tvSceneTitle = findViewById(R.id.tv_scene_title);
tvSceneIcon = findViewById(R.id.tv_scene_icon);
tvSceneName = findViewById(R.id.tv_scene_name);
tvSceneDesc = findViewById(R.id.tv_scene_desc);
btnExecute = findViewById(R.id.btn_execute_scene);
tvAddRule = findViewById(R.id.tv_add_rule);
llRuleList = findViewById(R.id.ll_rule_list);
tvRuleEmpty = findViewById(R.id.tv_rule_empty);
tvSceneTitle.setText(sceneName != null ? sceneName : "场景详情");
tvSceneName.setText(sceneName != null ? sceneName : "");
tvSceneDesc.setText("点击右上角\"添加规则\"配置联动条件");
tvBack.setOnClickListener(v -> finish());
btnExecute.setOnClickListener(v -> executeScene());
tvAddRule.setOnClickListener(v -> loadDevicesThenShowAddDialog());
loadRules();
}
@Override
protected void onResume() {
super.onResume();
loadRules();
}
/** 加载规则列表 */
private void loadRules() {
String auth = session != null ? session.getBearerToken() : null;
if (auth == null || sceneId == null) return;
ApiClient.getSceneApiService().getRules(auth, sceneId, 1, 100)
.enqueue(new Callback<Result<PageData<Map<String, Object>>>>() {
@Override
public void onResponse(Call<Result<PageData<Map<String, Object>>>> call,
Response<Result<PageData<Map<String, Object>>>> response) {
Result<PageData<Map<String, Object>>> body = response.body();
rules.clear();
if (body != null && body.isSuccess() && body.getData() != null
&& body.getData().getRecords() != null) {
rules.addAll(body.getData().getRecords());
}
renderRules();
}
@Override
public void onFailure(Call<Result<PageData<Map<String, Object>>>> call, Throwable t) {
Toast.makeText(SceneDetailActivity.this,
"加载规则失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/** 渲染规则列表 */
private void renderRules() {
llRuleList.removeAllViews();
if (rules.isEmpty()) {
tvRuleEmpty.setVisibility(View.VISIBLE);
return;
}
tvRuleEmpty.setVisibility(View.GONE);
LayoutInflater inflater = LayoutInflater.from(this);
for (Map<String, Object> rule : rules) {
View item = inflater.inflate(R.layout.item_rule_card, llRuleList, false);
TextView tvName = item.findViewById(R.id.tv_rule_name);
TextView tvStatus = item.findViewById(R.id.tv_rule_status);
TextView tvDelete = item.findViewById(R.id.tv_rule_delete);
TextView tvTrigger = item.findViewById(R.id.tv_rule_trigger);
TextView tvCondition = item.findViewById(R.id.tv_rule_condition);
TextView tvAction = item.findViewById(R.id.tv_rule_action);
String ruleName = stringOf(rule.get("ruleName"));
tvName.setText(ruleName != null && !ruleName.isEmpty() ? ruleName : "未命名规则");
Boolean enabled = rule.get("enabled") instanceof Boolean
? (Boolean) rule.get("enabled") : true;
tvStatus.setText(Boolean.TRUE.equals(enabled) ? "已启用" : "已停用");
tvStatus.setTextColor(Boolean.TRUE.equals(enabled) ? 0xFF67C23A : 0xFF909399);
tvTrigger.setText("触发设备: " + stringOf(rule.get("triggerDeviceId")));
tvCondition.setText("条件: " + stringOf(rule.get("conditionExpression")));
tvAction.setText("动作: " + (rule.get("actions") != null ? rule.get("actions").toString() : "无"));
// 删除规则
final Long ruleId = rule.get("id") instanceof Number
? ((Number) rule.get("id")).longValue() : null;
tvDelete.setOnClickListener(v -> {
if (ruleId != null) deleteRule(ruleId);
});
llRuleList.addView(item);
}
}
private String stringOf(Object obj) {
return obj == null ? "" : obj.toString();
}
/** 删除规则 */
private void deleteRule(Long ruleId) {
String auth = session != null ? session.getBearerToken() : null;
if (auth == null) return;
ApiClient.getSceneApiService().deleteRule(auth, ruleId)
.enqueue(new Callback<Result<Void>>() {
@Override
public void onResponse(Call<Result<Void>> call, Response<Result<Void>> response) {
Result<Void> body = response.body();
if (body != null && body.isSuccess()) {
Toast.makeText(SceneDetailActivity.this, "已删除", Toast.LENGTH_SHORT).show();
loadRules();
} else {
Toast.makeText(SceneDetailActivity.this,
"删除失败: " + (body != null ? body.getMessage() : "未知"),
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<Result<Void>> call, Throwable t) {
Toast.makeText(SceneDetailActivity.this,
"删除失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/** 执行场景 */
private void executeScene() {
String auth = session != null ? session.getBearerToken() : null;
if (auth == null || sceneId == null) return;
Toast.makeText(this, "正在执行场景...", Toast.LENGTH_SHORT).show();
ApiClient.getSceneApiService().executeScene(auth, sceneId)
.enqueue(new Callback<Result<List<Map<String, Object>>>>() {
@Override
public void onResponse(Call<Result<List<Map<String, Object>>>> call,
Response<Result<List<Map<String, Object>>>> response) {
Result<List<Map<String, Object>>> body = response.body();
if (body != null && body.isSuccess()) {
Toast.makeText(SceneDetailActivity.this, "执行成功", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(SceneDetailActivity.this,
"执行失败: " + (body != null ? body.getMessage() : "未知"),
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<Result<List<Map<String, Object>>>> call, Throwable t) {
Toast.makeText(SceneDetailActivity.this,
"执行失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/** 加载设备列表后弹出添加规则对话框 */
private void loadDevicesThenShowAddDialog() {
Toast.makeText(this, "加载设备...", Toast.LENGTH_SHORT).show();
TuyaSdkService.getHomes(new TuyaSdkService.HomeListCallback() {
@Override
public void onSuccess(List<TuyaSdkService.SdkHome> homes) {
if (homes == null || homes.isEmpty()) {
Toast.makeText(SceneDetailActivity.this, "暂无家庭,请先创建家庭", Toast.LENGTH_SHORT).show();
return;
}
final long homeId = homes.get(0).homeId;
TuyaSdkService.getDevices(homeId, new TuyaSdkService.DeviceListCallback() {
@Override
public void onSuccess(List<TuyaSdkService.SdkDevice> devices) {
deviceList.clear();
if (devices != null) deviceList.addAll(devices);
if (deviceList.isEmpty()) {
Toast.makeText(SceneDetailActivity.this, "暂无设备,请先绑定设备", Toast.LENGTH_SHORT).show();
return;
}
showAddRuleDialog();
}
@Override
public void onError(String error) {
Toast.makeText(SceneDetailActivity.this,
"加载设备失败: " + error, Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onError(String error) {
Toast.makeText(SceneDetailActivity.this,
"加载家庭失败: " + error, Toast.LENGTH_SHORT).show();
}
});
}
/** 弹出添加规则对话框 */
private void showAddRuleDialog() {
View view = LayoutInflater.from(this).inflate(R.layout.dialog_add_rule, null);
Spinner spTrigger = view.findViewById(R.id.sp_trigger_device);
spActionDevice = view.findViewById(R.id.sp_action_device);
spActionDp = view.findViewById(R.id.sp_action_dp);
flActionValue = view.findViewById(R.id.fl_action_value);
EditText etCondition = view.findViewById(R.id.et_condition);
EditText etRuleName = view.findViewById(R.id.et_rule_name);
// 设备下拉
List<String> deviceNames = new ArrayList<>();
for (TuyaSdkService.SdkDevice d : deviceList) {
deviceNames.add(d.name != null ? d.name : d.devId);
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, deviceNames);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spTrigger.setAdapter(adapter);
spActionDevice.setAdapter(adapter);
// 动作设备变化时,加载该设备的可写指令集(schema)
spActionDevice.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, View v, int pos, long id) {
if (pos < 0 || pos >= deviceList.size()) return;
TuyaSdkService.SdkDevice action = deviceList.get(pos);
loadActionSchema(action.devId);
}
@Override
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
});
// 动作指令(DP)变化时,根据类型动态构建动作值控件
spActionDp.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
boolean first = true;
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, View v, int pos, long id) {
if (first) { first = false; }
if (pos < 0 || pos >= actionDpKeys.size()) return;
String dpKey = actionDpKeys.get(pos);
ControlSchema.ControlItem schema = currentSchemaMap.get(dpKey);
buildActionValueControl(schema);
}
@Override
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
});
new AlertDialog.Builder(this)
.setTitle("添加联动规则")
.setView(view)
.setPositiveButton("保存", (d, w) -> {
if (spTrigger.getSelectedItemPosition() < 0
|| spActionDevice.getSelectedItemPosition() < 0) {
Toast.makeText(this, "请选择设备", Toast.LENGTH_SHORT).show();
return;
}
TuyaSdkService.SdkDevice trigger = deviceList.get(spTrigger.getSelectedItemPosition());
TuyaSdkService.SdkDevice action = deviceList.get(spActionDevice.getSelectedItemPosition());
String ruleName = etRuleName.getText().toString().trim();
if (ruleName.isEmpty()) ruleName = "联动-" + trigger.devId.substring(0, Math.min(6, trigger.devId.length()));
String condition = etCondition.getText().toString().trim();
if (condition.isEmpty()) condition = "switch_1 == true";
// 读取动作 DP 和值
String actionKey = readSelectedActionKey();
Object actionValue = readSelectedActionValue();
if (actionKey == null || actionKey.isEmpty()) {
Toast.makeText(this, "请选择动作指令", Toast.LENGTH_SHORT).show();
return;
}
createRule(ruleName, trigger, condition, action, actionKey, actionValue);
})
.setNegativeButton("取消", null)
.show();
}
/**
* 加载选中设备的可写指令集(统一用后端 control-schema,平台无关)。
* 仅当设备无物模型(available=false)或请求失败时,回退到涂鸦本地 schema。
*/
private void loadActionSchema(String deviceId) {
currentSchemaMap.clear();
actionDpKeys.clear();
String auth = session != null ? session.getBearerToken() : null;
if (auth == null) {
buildActionDpSpinner(new ArrayList<>());
return;
}
ApiClient.getApiService().getDeviceControlSchema(auth, deviceId)
.enqueue(new Callback<Result<ControlSchema>>() {
@Override
public void onResponse(Call<Result<ControlSchema>> call,
Response<Result<ControlSchema>> response) {
Result<ControlSchema> r = response.body();
if (r != null && r.isSuccess() && r.getData() != null
&& r.getData().available) {
ControlSchema schema = r.getData();
java.util.Map<String, ControlSchema.ControlItem> writable = new java.util.HashMap<>();
List<String> keys = new ArrayList<>();
if (schema.items != null) {
for (ControlSchema.ControlItem it : schema.items) {
if (it != null && it.writable && it.code != null) {
writable.put(it.code, it);
keys.add(it.code);
}
}
}
currentSchemaMap.putAll(writable);
buildActionDpSpinner(keys);
} else {
fallbackLoadTuyaSchema(deviceId);
}
}
@Override
public void onFailure(Call<Result<ControlSchema>> call, Throwable t) {
android.util.Log.e("SceneDetailActivity",
"control-schema 请求失败, 回退涂鸦 schema: " + t.getMessage());
fallbackLoadTuyaSchema(deviceId);
}
});
}
/** 物模型不可用时的回退:沿用涂鸦本地 schema(兼容旧设备) */
private void fallbackLoadTuyaSchema(String deviceId) {
new Thread(() -> {
try {
java.util.Map<String, SchemaBean> schemaMap =
ThingHomeSdk.getDataInstance().getSchema(deviceId);
runOnUiThread(() -> {
if (schemaMap == null || schemaMap.isEmpty()) {
buildActionDpSpinner(new ArrayList<>());
return;
}
java.util.Map<String, ControlSchema.ControlItem> legacy = new java.util.HashMap<>();
List<String> keys = new ArrayList<>();
for (Map.Entry<String, SchemaBean> e : schemaMap.entrySet()) {
SchemaBean bean = e.getValue();
if (bean == null || !isWritable(bean)) continue;
ControlSchema.ControlItem item = toControlItem(e.getKey(), bean);
if (item != null) {
legacy.put(item.code, item);
keys.add(item.code);
}
}
currentSchemaMap.putAll(legacy);
buildActionDpSpinner(keys);
});
} catch (Exception ex) {
runOnUiThread(() -> buildActionDpSpinner(new ArrayList<>()));
}
}).start();
}
/** 将涂鸦 SchemaBean 转换为 ControlItem(仅用于回退路径,字段尽量从 schemaType 推断) */
private ControlSchema.ControlItem toControlItem(String dpId, SchemaBean bean) {
ControlSchema.ControlItem item = new ControlSchema.ControlItem();
item.code = dpId;
item.dpId = dpId;
item.name = bean.getName() != null ? String.valueOf(bean.getName()) : dpId;
item.writable = isWritable(bean);
String type = bean.getSchemaType();
item.type = type;
item.widget = "switch".equals(type) ? "switch"
: "enum".equals(type) ? "segmented"
: ("value".equals(type) || "integer".equals(type)) ? "slider" : "text";
return item;
}
/** 判断 schema 是否可写(mode 包含 w),回退路径用 */
private boolean isWritable(SchemaBean s) {
try {
String mode = s.getMode();
return mode != null && mode.contains("w");
} catch (Exception e) {
return true;
}
}
/** 判断 ControlItem 是否可写 */
private boolean isWritable(ControlSchema.ControlItem item) {
return item != null && item.writable;
}
/** 用中文名填充动作指令下拉 */
private void buildActionDpSpinner(List<String> dpKeys) {
actionDpKeys.clear();
actionDpKeys.addAll(dpKeys);
List<String> dpNames = new ArrayList<>();
for (String k : dpKeys) {
ControlSchema.ControlItem s = currentSchemaMap.get(k);
String name = s != null && s.name != null && !s.name.isEmpty() ? s.name : k;
dpNames.add(name);
}
if (dpNames.isEmpty()) {
dpNames.add("该设备无可写指令");
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, dpNames);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spActionDp.setAdapter(adapter);
// 若只有一个可写指令,默认构建其值控件
if (!dpKeys.isEmpty()) {
buildActionValueControl(currentSchemaMap.get(dpKeys.get(0)));
} else {
flActionValue.removeAllViews();
TextView empty = new TextView(this);
empty.setText("该设备没有可设置的动作指令");
empty.setTextColor(0xFF909399);
empty.setTextSize(13);
flActionValue.addView(empty);
}
}
/** 根据指令类型动态构建动作值控件(基于 ControlItem,平台无关) */
private void buildActionValueControl(ControlSchema.ControlItem schema) {
flActionValue.removeAllViews();
if (schema == null) return;
String widget = schema.widget != null ? schema.widget : schema.type;
try {
if ("switch".equals(widget) || "bool".equals(widget)) {
// 布尔:开/关 下拉
actionDpTypeIndex = 0;
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, new String[]{"开", "关"});
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
Spinner sp = new Spinner(this);
sp.setAdapter(adapter);
flActionValue.addView(sp, new android.widget.FrameLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
} else if ("segmented".equals(widget) || "enum".equals(widget)) {
// 枚举:使用原始枚举值(ControlSchema 暂未提供中文映射)
actionDpTypeIndex = 1;
List<String> enumValues = new ArrayList<>();
if (schema.enumValues != null) {
for (Object v : schema.enumValues) enumValues.add(String.valueOf(v));
}
if (enumValues.isEmpty()) enumValues.add("无");
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, enumValues);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
Spinner sp = new Spinner(this);
sp.setAdapter(adapter);
flActionValue.addView(sp, new android.widget.FrameLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
} else if ("slider".equals(widget) || "number".equals(widget)
|| "value".equals(widget) || "integer".equals(widget)) {
// 数值:输入框(带单位/范围提示)
actionDpTypeIndex = 2;
double min = schema.min != null ? schema.min : 0;
double max = schema.max != null ? schema.max : 100;
String unit = schema.unit != null ? schema.unit : "";
EditText et = new EditText(this);
et.setHint("请输入数值" + (unit.isEmpty() ? "" : "(" + unit + ")")
+ " [" + min + "~" + max + "]");
et.setTextSize(14);
et.setInputType(android.text.InputType.TYPE_CLASS_NUMBER
| android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL);
flActionValue.addView(et, new android.widget.FrameLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
} else {
// 字符串/颜色/未知:文本输入
actionDpTypeIndex = 3;
EditText et = new EditText(this);
et.setHint("请输入值");
et.setTextSize(14);
flActionValue.addView(et, new android.widget.FrameLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
}
} catch (Exception e) {
android.util.Log.e("SceneDetailActivity", "构建动作值控件失败: " + e.getMessage(), e);
}
}
/** 枚举值本地化:ControlSchema 暂未提供中文映射,原样返回 */
private String localizeEnumValue(ControlSchema.ControlItem item, String rawValue) {
return rawValue;
}
/** 读取当前选中的动作 DP code */
private String readSelectedActionKey() {
int pos = spActionDp.getSelectedItemPosition();
if (pos < 0 || pos >= actionDpKeys.size()) return null;
return actionDpKeys.get(pos);
}
/** 读取当前选中的动作值(根据类型转换) */
private Object readSelectedActionValue() {
View valueView = flActionValue.getChildCount() > 0
? flActionValue.getChildAt(0) : null;
try {
switch (actionDpTypeIndex) {
case 0: // Bool
if (valueView instanceof Spinner) {
String sel = (String) ((Spinner) valueView).getSelectedItem();
return "开".equals(sel) || "true".equalsIgnoreCase(sel);
}
return true;
case 1: { // Enum:选中的是中文 label,需要映射回原始 value
if (valueView instanceof Spinner) {
String sel = (String) ((Spinner) valueView).getSelectedItem();
int pos = spActionDp.getSelectedItemPosition();
if (pos >= 0 && pos < actionDpKeys.size()) {
ControlSchema.ControlItem s = currentSchemaMap.get(actionDpKeys.get(pos));
return mapEnumLabelToRaw(s, sel);
}
return sel;
}
return "";
}
case 2: { // Value
if (valueView instanceof EditText) {
String txt = ((EditText) valueView).getText().toString().trim();
try {
double v = Double.parseDouble(txt);
// 整数则存 Long,否则 Double
return v == Math.floor(v) ? (long) v : v;
} catch (Exception e) {
return txt;
}
}
return "";
}
default: // String 或未知
if (valueView instanceof EditText) {
return ((EditText) valueView).getText().toString().trim();
}
return "";
}
} catch (Exception e) {
return "";
}
}
/** 枚举中文 label 映射回原始值:ControlSchema 暂未提供映射,原样返回 */
private Object mapEnumLabelToRaw(ControlSchema.ControlItem item, String label) {
return label;
}
/** 创建规则 */
private void createRule(String ruleName, TuyaSdkService.SdkDevice trigger, String condition,
TuyaSdkService.SdkDevice action, String actionKey, Object actionValue) {
String auth = session != null ? session.getBearerToken() : null;
if (auth == null) return;
// 构造 ruleCode(唯一)
String ruleCode = "rule_" + System.currentTimeMillis();
// 构造动作 JSON
List<Map<String, Object>> actions = new ArrayList<>();
Map<String, Object> actionObj = new HashMap<>();
actionObj.put("deviceId", action.devId);
Map<String, Object> params = new HashMap<>();
params.put(actionKey, actionValue);
actionObj.put("params", params);
actions.add(actionObj);
Map<String, Object> body = new HashMap<>();
body.put("ruleName", ruleName);
body.put("ruleCode", ruleCode);
body.put("sceneId", sceneId);
body.put("triggerDeviceId", trigger.devId);
body.put("conditionExpression", condition);
body.put("actions", actions);
body.put("enabled", true);
ApiClient.getSceneApiService().createRule(auth, body)
.enqueue(new Callback<Result<Long>>() {
@Override
public void onResponse(Call<Result<Long>> call, Response<Result<Long>> response) {
Result<Long> body = response.body();
if (body != null && body.isSuccess()) {
Toast.makeText(SceneDetailActivity.this, "规则已添加", Toast.LENGTH_SHORT).show();
loadRules();
} else {
Toast.makeText(SceneDetailActivity.this,
"添加失败: " + (body != null ? body.getMessage() : "未知"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(Call<Result<Long>> call, Throwable t) {
Toast.makeText(SceneDetailActivity.this,
"添加失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
}

View File

@ -0,0 +1,289 @@
package com.xhai.iot.ui;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.SceneItem;
import com.xhai.iot.util.SessionManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 智能页面(手动执行 / 自动化)
* 加载后端场景列表,支持场景创建、一键执行
*/
public class SmartFragment extends Fragment {
private TextView tabManual;
private TextView tabAutomation;
private LinearLayout llSceneList;
private LinearLayout llEmpty;
private Button btnCreate;
private SessionManager session;
private final List<SceneItem> scenes = new ArrayList<>();
private boolean isManualTab = true;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_smart, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
session = SessionManager.getInstance(requireContext());
tabManual = view.findViewById(R.id.tab_manual);
tabAutomation = view.findViewById(R.id.tab_automation);
llSceneList = view.findViewById(R.id.ll_scene_list);
llEmpty = view.findViewById(R.id.ll_empty);
btnCreate = view.findViewById(R.id.btn_create_scene);
tabManual.setOnClickListener(v -> selectTab(true));
tabAutomation.setOnClickListener(v -> selectTab(false));
btnCreate.setOnClickListener(v -> showCreateDialog());
selectTab(true);
loadScenes();
}
@Override
public void onResume() {
super.onResume();
loadScenes();
}
private void selectTab(boolean manual) {
isManualTab = manual;
if (manual) {
tabManual.setTextColor(0xFF303133);
tabManual.setTextSize(20);
tabManual.setTypeface(null, android.graphics.Typeface.BOLD);
tabAutomation.setTextColor(0xFF909399);
tabAutomation.setTextSize(20);
tabAutomation.setTypeface(null, android.graphics.Typeface.NORMAL);
} else {
tabAutomation.setTextColor(0xFF303133);
tabAutomation.setTextSize(20);
tabAutomation.setTypeface(null, android.graphics.Typeface.BOLD);
tabManual.setTextColor(0xFF909399);
tabManual.setTextSize(20);
tabManual.setTypeface(null, android.graphics.Typeface.NORMAL);
}
loadScenes();
}
/** 加载场景列表 */
private void loadScenes() {
String auth = session != null ? session.getBearerToken() : null;
if (auth == null) {
showEmpty();
return;
}
ApiClient.getSceneApiService().getScenes(auth, 1, 100)
.enqueue(new Callback<Result<PageData<SceneItem>>>() {
@Override
public void onResponse(Call<Result<PageData<SceneItem>>> call,
Response<Result<PageData<SceneItem>>> response) {
if (!isAdded()) return;
Result<PageData<SceneItem>> body = response.body();
scenes.clear();
if (body != null && body.isSuccess() && body.getData() != null
&& body.getData().getRecords() != null) {
scenes.addAll(body.getData().getRecords());
}
renderScenes();
}
@Override
public void onFailure(Call<Result<PageData<SceneItem>>> call, Throwable t) {
if (!isAdded()) return;
showEmpty();
}
});
}
/** 渲染场景列表 */
private void renderScenes() {
llSceneList.removeAllViews();
if (scenes.isEmpty()) {
showEmpty();
return;
}
llEmpty.setVisibility(View.GONE);
LayoutInflater inflater = LayoutInflater.from(requireContext());
for (SceneItem scene : scenes) {
// 按类型过滤:手动执行Tab显示 one_click,自动化Tab显示 automation
String type = scene.getSceneType() != null ? scene.getSceneType() : "automation";
if (isManualTab && !"one_click".equals(type)) continue;
if (!isManualTab && !"automation".equals(type)) continue;
View item = inflater.inflate(R.layout.item_scene_card, llSceneList, false);
TextView tvIcon = item.findViewById(R.id.tv_scene_icon);
TextView tvName = item.findViewById(R.id.tv_scene_name);
TextView tvDesc = item.findViewById(R.id.tv_scene_desc);
TextView tvRun = item.findViewById(R.id.tv_scene_run);
tvIcon.setText(scene.getIcon() != null && !scene.getIcon().isEmpty()
? scene.getIcon() : "⏰");
tvName.setText(scene.getSceneName() != null ? scene.getSceneName() : "未命名场景");
tvDesc.setText(scene.getDescription() != null && !scene.getDescription().isEmpty()
? scene.getDescription() : "点击执行场景");
if (!Boolean.TRUE.equals(scene.getEnabled())) {
tvDesc.setText("已停用");
}
// 点击卡片进入场景详情
item.setOnClickListener(v -> openSceneDetail(scene));
tvRun.setOnClickListener(v -> executeScene(scene));
llSceneList.addView(item);
}
// 如果过滤后为空
if (llSceneList.getChildCount() == 0) {
showEmpty();
}
}
private void showEmpty() {
llEmpty.setVisibility(View.VISIBLE);
}
/** 打开场景详情 */
private void openSceneDetail(SceneItem scene) {
if (scene.getId() == null) return;
android.content.Intent intent = new android.content.Intent(requireContext(),
SceneDetailActivity.class);
intent.putExtra("sceneId", scene.getId());
intent.putExtra("sceneName", scene.getSceneName());
startActivity(intent);
}
/** 一键执行场景 */
private void executeScene(SceneItem scene) {
if (scene.getId() == null) return;
String auth = session != null ? session.getBearerToken() : null;
if (auth == null) return;
Toast.makeText(requireContext(), "正在执行场景...", Toast.LENGTH_SHORT).show();
ApiClient.getSceneApiService().executeScene(auth, scene.getId())
.enqueue(new Callback<Result<List<Map<String, Object>>>>() {
@Override
public void onResponse(Call<Result<List<Map<String, Object>>>> call,
Response<Result<List<Map<String, Object>>>> response) {
if (!isAdded()) return;
Result<List<Map<String, Object>>> body = response.body();
if (body != null && body.isSuccess()) {
Toast.makeText(requireContext(),
"场景执行成功", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(),
"执行失败: " + (body != null ? body.getMessage() : "未知错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(Call<Result<List<Map<String, Object>>>> call, Throwable t) {
if (!isAdded()) return;
Toast.makeText(requireContext(), "执行失败: " + t.getMessage(),
Toast.LENGTH_LONG).show();
}
});
}
/** 创建场景对话框 */
private void showCreateDialog() {
final EditText etName = new EditText(requireContext());
etName.setHint("请输入场景名称");
LinearLayout container = new LinearLayout(requireContext());
container.setOrientation(LinearLayout.VERTICAL);
container.setPadding(dp(20), dp(8), dp(20), 0);
final EditText etDesc = new EditText(requireContext());
etDesc.setHint("场景描述(可选)");
container.addView(etName);
container.addView(etDesc);
new AlertDialog.Builder(requireContext())
.setTitle("创建智能场景")
.setView(container)
.setPositiveButton("创建", (d, w) -> {
String name = etName.getText().toString().trim();
if (name.isEmpty()) {
Toast.makeText(requireContext(), "请输入场景名称", Toast.LENGTH_SHORT).show();
return;
}
createScene(name, etDesc.getText().toString().trim());
})
.setNegativeButton("取消", null)
.show();
}
/** 调用后端创建场景 */
private void createScene(String name, String desc) {
String auth = session != null ? session.getBearerToken() : null;
if (auth == null) {
Toast.makeText(requireContext(), "未登录", Toast.LENGTH_SHORT).show();
return;
}
Map<String, Object> body = new HashMap<>();
body.put("sceneName", name);
body.put("sceneType", isManualTab ? "one_click" : "automation");
body.put("description", desc);
body.put("enabled", true);
ApiClient.getSceneApiService().createScene(auth, body)
.enqueue(new Callback<Result<Long>>() {
@Override
public void onResponse(Call<Result<Long>> call, Response<Result<Long>> response) {
if (!isAdded()) return;
Result<Long> res = response.body();
if (res != null && res.isSuccess()) {
Toast.makeText(requireContext(), "场景创建成功", Toast.LENGTH_SHORT).show();
loadScenes();
} else {
Toast.makeText(requireContext(),
"创建失败: " + (res != null ? res.getMessage() : "未知错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(Call<Result<Long>> call, Throwable t) {
if (!isAdded()) return;
Toast.makeText(requireContext(), "创建失败: " + t.getMessage(),
Toast.LENGTH_LONG).show();
}
});
}
private int dp(int value) {
return Math.round(requireContext().getResources()
.getDisplayMetrics().density * value);
}
}

View File

@ -0,0 +1,311 @@
package com.xhai.iot.ui;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
import com.thingclips.smart.android.user.api.ILoginCallback;
import com.thingclips.smart.android.user.bean.User;
import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiConfig;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.Result;
import com.xhai.iot.util.SessionManager;
import java.util.HashMap;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 涂鸦账号绑定页
* 通过涂鸦原生 SDK 登录涂鸦账号,绑定后同步到 iotsaas 后端
*/
public class TuyaBindActivity extends AppCompatActivity {
private static final String COUNTRY_CODE = "86"; // 中国区
private EditText etTuyaUsername;
private EditText etTuyaPassword;
private Button btnBind;
private ProgressBar progressBar;
private TextView tvStatus;
private SessionManager session;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
setContentView(R.layout.activity_tuya_bind);
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
session = SessionManager.getInstance(this);
initViews();
checkBindStatus();
}
private void initViews() {
etTuyaUsername = findViewById(R.id.et_tuya_username);
etTuyaPassword = findViewById(R.id.et_tuya_password);
btnBind = findViewById(R.id.btn_bind);
progressBar = findViewById(R.id.progress_bind);
tvStatus = findViewById(R.id.tv_bind_status);
View btnUnbind = findViewById(R.id.btn_unbind);
View llBindForm = findViewById(R.id.ll_bind_form);
mBindForm = llBindForm;
// 绑定按钮染主题色
android.graphics.drawable.Drawable bg = btnBind.getBackground();
if (bg != null) {
bg = bg.mutate();
bg.setTint(com.xhai.iot.util.ThemeManager.getInstance(this).accent().value);
btnBind.setBackground(bg);
}
// 返回
findViewById(R.id.btn_back).setOnClickListener(v -> finish());
btnBind.setOnClickListener(v -> doBind());
btnUnbind.setOnClickListener(v -> doUnbind());
}
private View mBindForm;
/** 检查当前是否已绑定(查询后端) */
private void checkBindStatus() {
String auth = session.getBearerToken();
if (auth == null) return;
ApiClient.getApiService().getTuyaBindStatus(auth)
.enqueue(new Callback<Result<Map<String, Object>>>() {
@Override
public void onResponse(Call<Result<Map<String, Object>>> call,
Response<Result<Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
Map<String, Object> data = response.body().getData();
if (data != null && Boolean.TRUE.equals(data.get("bound"))) {
String account = data.get("account") != null
? data.get("account").toString() : null;
showBound(true, account);
}
}
}
@Override
public void onFailure(Call<Result<Map<String, Object>>> call, Throwable t) {
// 忽略查询失败
}
});
}
/** 涂鸦SDK登录并绑定 */
private void doBind() {
String username = etTuyaUsername.getText().toString().trim();
String password = etTuyaPassword.getText().toString().trim();
if (TextUtils.isEmpty(username)) {
Toast.makeText(this, "请输入涂鸦账号(手机号/邮箱)", Toast.LENGTH_SHORT).show();
return;
}
if (TextUtils.isEmpty(password)) {
Toast.makeText(this, "请输入涂鸦密码", Toast.LENGTH_SHORT).show();
return;
}
showLoading(true);
// 调试模式:涂鸦SDK被禁用时,用模拟UID直接走通绑定流程到后端
if (!ApiConfig.TUYA_SDK_ENABLED) {
Toast.makeText(this, "调试模式:模拟绑定(不调用涂鸦SDK)", Toast.LENGTH_SHORT).show();
// 用输入账号作为模拟涂鸦UID,验证 bind-native 接口
syncToBackend("mock_" + username, username);
return;
}
// 涂鸦SDK登录(支持手机号或邮箱)
loginTuya(username, password);
}
private void loginTuya(String account, String password) {
ThingHomeSdk.getUserInstance().loginWithPhonePassword(
COUNTRY_CODE, account, password, new ILoginCallback() {
@Override
public void onSuccess(User user) {
runOnUiThread(() -> handleLoginSuccess(user));
}
@Override
public void onError(String code, String error) {
// 手机号登录失败,尝试邮箱登录
ThingHomeSdk.getUserInstance().loginWithEmail(
COUNTRY_CODE, account, password, new ILoginCallback() {
@Override
public void onSuccess(User user) {
runOnUiThread(() -> handleLoginSuccess(user));
}
@Override
public void onError(String code2, String error2) {
runOnUiThread(() -> {
showLoading(false);
Toast.makeText(TuyaBindActivity.this,
"涂鸦登录失败: " + (error2 != null ? error2 : "账号或密码错误"),
Toast.LENGTH_LONG).show();
});
}
});
}
});
}
private void handleLoginSuccess(User user) {
// 获取涂鸦 UID 和用户名
String uid = user.getUid();
String account = user.getUsername();
// 加密保存涂鸦凭据(用于会话过期自动续登,绑定一次永久有效)
new com.xhai.iot.util.TuyaCredentialStore(this).save(
etTuyaUsername.getText().toString().trim(),
etTuyaPassword.getText().toString().trim());
// 同步到 iotsaas 后端
syncToBackend(uid, account);
}
/** 同步涂鸦绑定信息到后端 tuya_user_binding */
private void syncToBackend(String tuyaUid, String account) {
String auth = session.getBearerToken();
if (auth == null) {
showLoading(false);
Toast.makeText(this, "请先登录本应用", Toast.LENGTH_SHORT).show();
return;
}
Map<String, String> body = new HashMap<>();
body.put("tuyaUid", tuyaUid);
body.put("account", account);
body.put("loginMethod", "phone");
body.put("platform", "android");
ApiClient.getApiService().bindNative(auth, body)
.enqueue(new Callback<Result<Map<String, Object>>>() {
@Override
public void onResponse(Call<Result<Map<String, Object>>> call,
Response<Result<Map<String, Object>>> response) {
showLoading(false);
if (response.isSuccessful() && response.body() != null
&& response.body().isSuccess()) {
session.setTuyaBound(true);
showBound(true);
Toast.makeText(TuyaBindActivity.this,
"涂鸦账号绑定成功!", Toast.LENGTH_SHORT).show();
// 1.5秒后自动返回主界面
new android.os.Handler(getMainLooper()).postDelayed(() -> {
finish();
}, 1500);
} else {
Toast.makeText(TuyaBindActivity.this,
"绑定同步失败: " + (response.body() != null
? response.body().getMessage() : "未知错误"),
Toast.LENGTH_LONG).show();
}
}
@Override
public void onFailure(Call<Result<Map<String, Object>>> call, Throwable t) {
showLoading(false);
Toast.makeText(TuyaBindActivity.this,
"绑定同步失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
private void doUnbind() {
String auth = session.getBearerToken();
if (auth == null) return;
showLoading(true);
ApiClient.getApiService().unbindTuya(auth)
.enqueue(new Callback<Result<Void>>() {
@Override
public void onResponse(Call<Result<Void>> call, Response<Result<Void>> response) {
showLoading(false);
session.setTuyaBound(false);
new com.xhai.iot.util.TuyaCredentialStore(TuyaBindActivity.this).clear();
// 同步登出涂鸦 SDK(修复:之前未登出导致 SDK 会话残留)
try {
ThingHomeSdk.getUserInstance().logout(
new com.thingclips.smart.android.user.api.ILogoutCallback() {
@Override
public void onSuccess() {
}
@Override
public void onError(String code, String error) {
}
});
} catch (Throwable ignored) {
}
showBound(false);
Toast.makeText(TuyaBindActivity.this,
"已解绑涂鸦账号", Toast.LENGTH_SHORT).show();
}
@Override
public void onFailure(Call<Result<Void>> call, Throwable t) {
showLoading(false);
Toast.makeText(TuyaBindActivity.this,
"解绑失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
private void showLoading(boolean show) {
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
btnBind.setEnabled(!show);
}
private void showBound(boolean bound) {
showBound(bound, null);
}
private void showBound(boolean bound, String account) {
if (bound) {
// 状态 pill:绿色"已连接"
tvStatus.setText("● 已连接");
tvStatus.setTextColor(0xFF16A34A);
tvStatus.setBackgroundResource(R.drawable.bg_pill_online);
if (account != null && !account.isEmpty()) {
etTuyaUsername.setText(account);
}
// 已连接:隐藏表单与解绑显示(解绑按钮显示)
if (mBindForm != null) {
mBindForm.setVisibility(View.GONE);
}
findViewById(R.id.btn_unbind).setVisibility(View.VISIBLE);
} else {
// 状态 pill:灰色"未连接"
tvStatus.setText("未连接");
tvStatus.setTextColor(getColor(R.color.c_muted_fg));
tvStatus.setBackgroundResource(R.drawable.bg_chip_muted);
etTuyaUsername.setText("");
etTuyaPassword.setText("");
if (mBindForm != null) {
mBindForm.setVisibility(View.VISIBLE);
}
findViewById(R.id.btn_unbind).setVisibility(View.GONE);
}
}
}

View File

@ -0,0 +1,34 @@
package com.xhai.iot.ui;
import android.graphics.Color;
import android.os.Bundle;
import android.widget.FrameLayout;
import android.widget.TextView;
import com.journeyapps.barcodescanner.CaptureActivity;
/**
* 自定义扫码页:继承 zxing CaptureActivity,叠加一个「✕ 关闭」按钮,
* 保证扫码界面始终有可见的返回/取消入口(默认 CaptureActivity 无显式返回键)。
* 主题继承应用 Theme.XhaiIoT(DayNight),跟随系统日夜模式。
*/
public class ZxingScanActivity extends CaptureActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView close = new TextView(this);
close.setText("✕ 关闭");
close.setTextColor(Color.WHITE);
close.setTextSize(16);
close.setPadding(28, 30, 28, 30);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT);
lp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
close.setLayoutParams(lp);
close.setOnClickListener(v -> finish());
addContentView(close, lp);
}
}

View File

@ -0,0 +1,215 @@
package com.xhai.iot.ui.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.ImageView;
import android.widget.Switch;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.xhai.iot.R;
import com.xhai.iot.model.TuyaDevice;
import com.xhai.iot.service.IPlatformService;
import com.xhai.iot.service.PlatformServiceManager;
import com.xhai.iot.service.TuyaSdkService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 设备网格适配器(仿照涂鸦智能 App 首页)
* 每张卡:图标 + 设备名 + 状态 + 右上角开关(直接控制)
* 卡片整体点击 → 进入详细控制页
*/
public class DeviceGridAdapter extends RecyclerView.Adapter<DeviceGridAdapter.ViewHolder> {
private final List<TuyaDevice> devices;
private final OnDeviceClick listener;
public interface OnDeviceClick {
void onClick(TuyaDevice device, int position);
}
public DeviceGridAdapter(List<TuyaDevice> devices, OnDeviceClick listener) {
this.devices = devices;
this.listener = listener;
}
public void update(List<TuyaDevice> newDevices) {
devices.clear();
devices.addAll(newDevices);
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_device, parent, false);
return new ViewHolder(view);
}
/** 卡片渐变底色(柔和淡色系,仿涂鸦智能 App) */
private static final int[][] GRADIENTS = {
{0xFFE8F4FF, 0xFFC9E4FF}, // 淡蓝
{0xFFF0ECFF, 0xFFDDD2FF}, // 淡紫
{0xFFFFEBF3, 0xFFFFD0E4}, // 淡粉
{0xFFFFF3E0, 0xFFFFE0B2}, // 淡橙
{0xFFE0F7FA, 0xFFB2EBF2}, // 淡青
{0xFFE8F5E9, 0xFFC8E6C9}, // 淡绿
};
private static final int[] OFFLINE_BG = {0xFFF5F6FA, 0xFFE9ECF2};
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TuyaDevice device = devices.get(position);
boolean online = device.isOnline();
holder.tvName.setText(device.getName());
holder.tvStatus.setText(online ? "在线" : "离线");
// 在线用主题色(跟随"我的→外观与主题色"),与设备列表保持一致
int accent = com.xhai.iot.util.ThemeManager
.getInstance(holder.itemView.getContext()).accent().value;
holder.tvStatus.setTextColor(online ? accent
: holder.itemView.getContext().getColor(R.color.offline));
// 渐变背景:按 category/索引分配色板,离线用灰色
int[] bg = online ? GRADIENTS[position % GRADIENTS.length] : OFFLINE_BG;
android.graphics.drawable.GradientDrawable gradient =
new android.graphics.drawable.GradientDrawable(
android.graphics.drawable.GradientDrawable.Orientation.TL_BR,
bg);
gradient.setCornerRadius(dp(18));
holder.itemView.setBackground(gradient);
// 离线设备图标灰化
holder.ivIcon.setAlpha(online ? 1f : 0.45f);
// 加载涂鸦设备图标(与涂鸦官方 App 一致),无图标/失败时回退默认图标
String iconUrl = device.getIcon();
if (iconUrl != null && !iconUrl.isEmpty()) {
Glide.with(holder.itemView.getContext())
.load(iconUrl)
.placeholder(R.drawable.ic_device_default)
.error(R.drawable.ic_device_default)
.transform(new CenterCrop(), new RoundedCorners(dp(12)))
.into(holder.ivIcon);
} else {
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
}
// 判断设备是否有可写的布尔(开关)DP;无开关功能的设备(如蓝牙网关)不显示开关
final String switchDpId = TuyaSdkService.findSwitchDpId(device.getId());
// 把数字 id 转换为涂鸦 code(dps map key 是 code),用于读真实开关状态
String switchDpCode = null;
if (switchDpId != null) {
Map<String, Object> probe = new HashMap<>();
probe.put(switchDpId, true);
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(device.getId(), probe);
if (codeMap != null && !codeMap.isEmpty()) {
switchDpCode = codeMap.keySet().iterator().next();
}
}
// 从 dps 读取设备真实开关状态(同步显示)
Object dpValue = null;
if (switchDpCode != null && device.getDps() != null) {
dpValue = device.getDps().get(switchDpCode);
if (dpValue == null) dpValue = device.getDps().get(switchDpId);
}
final boolean initialChecked = Boolean.TRUE.equals(dpValue);
if (switchDpId == null || !online) {
// 无开关功能或离线:隐藏/禁用开关,避免网关等设备出现无效开关
holder.swDevice.setVisibility(switchDpId == null ? View.GONE : View.VISIBLE);
holder.swDevice.setEnabled(online);
holder.swDevice.setChecked(false);
holder.swDevice.setOnCheckedChangeListener(null);
} else {
holder.swDevice.setVisibility(View.VISIBLE);
holder.swDevice.setEnabled(true);
holder.swDevice.setChecked(initialChecked);
holder.swDevice.setTag(device.getId());
// 用标准事件 setOnCheckedChangeListener 处理开关切换(用户点击触发)
holder.swDevice.setOnCheckedChangeListener((buttonView, isChecked) -> {
// 通过 isPressed 区分用户点击与代码 setChecked 触发的回调,避免误发指令
if (!buttonView.isPressed()) {
return;
}
android.util.Log.i("DeviceGridAdapter",
"switch clicked: device=" + device.getId()
+ " checked=" + isChecked + " dpId=" + switchDpId);
Map<String, Object> commands = new HashMap<>();
commands.put(switchDpId, isChecked);
PlatformServiceManager.getInstance(holder.itemView.getContext())
.controlDevice(asPlatformDevice(device), commands,
new IPlatformService.ControlCallback() {
@Override
public void onSuccess() {
android.util.Log.i("DeviceGridAdapter",
"control success device=" + device.getId());
}
@Override
public void onError(String code, String error) {
// 控制失败,回滚开关状态并提示原因
android.util.Log.e("DeviceGridAdapter",
"control fail device=" + device.getId()
+ " code=" + code + " err=" + error);
holder.swDevice.post(() ->
holder.swDevice.setChecked(false));
android.widget.Toast.makeText(holder.itemView.getContext(),
"控制失败: " + (error != null ? error : code),
android.widget.Toast.LENGTH_LONG).show();
}
});
});
}
// 卡片整体点击
holder.itemView.setOnClickListener(v -> {
if (listener != null) listener.onClick(device, position);
});
}
private static int dp(int value) {
return Math.round(android.content.res.Resources.getSystem()
.getDisplayMetrics().density * value);
}
private com.xhai.iot.model.PlatformDevice asPlatformDevice(TuyaDevice d) {
com.xhai.iot.model.PlatformDevice pd =
new com.xhai.iot.model.PlatformDevice();
pd.setDeviceId(d.getId());
pd.setName(d.getName());
pd.setPlatform(com.xhai.iot.model.DevicePlatform.TUYA);
pd.setOnline(d.isOnline());
pd.setCategory(d.getCategory());
pd.setProductId(d.getProductId());
pd.setIconUrl(d.getIcon());
return pd;
}
@Override
public int getItemCount() {
return devices.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
ImageView ivIcon;
TextView tvName;
TextView tvStatus;
Switch swDevice;
ViewHolder(@NonNull View itemView) {
super(itemView);
ivIcon = itemView.findViewById(R.id.iv_device_icon);
tvName = itemView.findViewById(R.id.tv_device_name);
tvStatus = itemView.findViewById(R.id.tv_device_status);
swDevice = itemView.findViewById(R.id.sw_device);
}
}
}

View File

@ -0,0 +1,96 @@
package com.xhai.iot.ui.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.service.TuyaSdkService;
import java.util.ArrayList;
import java.util.List;
/**
* 设备管理列表适配器
*/
public class DeviceManageAdapter extends RecyclerView.Adapter<DeviceManageAdapter.ViewHolder> {
private final List<TuyaSdkService.SdkDevice> devices = new ArrayList<>();
private OnEditListener editListener;
private OnDeleteListener deleteListener;
public interface OnEditListener {
void onEdit(TuyaSdkService.SdkDevice device);
}
public interface OnDeleteListener {
void onDelete(TuyaSdkService.SdkDevice device);
}
public void setOnEditListener(OnEditListener listener) {
this.editListener = listener;
}
public void setOnDeleteListener(OnDeleteListener listener) {
this.deleteListener = listener;
}
public void update(List<TuyaSdkService.SdkDevice> newDevices) {
devices.clear();
if (newDevices != null) devices.addAll(newDevices);
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_device_manage, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TuyaSdkService.SdkDevice device = devices.get(position);
holder.tvName.setText(device.name);
holder.tvStatus.setText(device.online ? "在线" : "离线");
// 在线用主题色(跟随"我的→外观与主题色"),与设备列表保持一致
int accent = com.xhai.iot.util.ThemeManager
.getInstance(holder.itemView.getContext()).accent().value;
holder.tvStatus.setTextColor(device.online ? accent
: holder.itemView.getContext().getColor(R.color.offline));
holder.tvEdit.setOnClickListener(v -> {
if (editListener != null) editListener.onEdit(device);
});
holder.tvDelete.setOnClickListener(v -> {
if (deleteListener != null) deleteListener.onDelete(device);
});
}
@Override
public int getItemCount() {
return devices.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
ImageView ivIcon;
TextView tvName;
TextView tvStatus;
TextView tvEdit;
TextView tvDelete;
ViewHolder(@NonNull View itemView) {
super(itemView);
ivIcon = itemView.findViewById(R.id.iv_device_icon);
tvName = itemView.findViewById(R.id.tv_device_name);
tvStatus = itemView.findViewById(R.id.tv_device_status);
tvEdit = itemView.findViewById(R.id.tv_device_edit);
tvDelete = itemView.findViewById(R.id.tv_device_delete);
}
}
}

View File

@ -0,0 +1,319 @@
package com.xhai.iot.ui.adapter;
import android.graphics.drawable.GradientDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.xhai.iot.R;
import com.xhai.iot.model.TuyaDevice;
import com.xhai.iot.service.IPlatformService;
import com.xhai.iot.service.PlatformServiceManager;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.widget.PillSwitch;
import com.xhai.iot.util.ThemeManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 设备页列表行适配器(设计稿 DevicesPage list view)
* 行:图标卡(开时淡主色底)+ 名称 + 房间·品牌 + 状态 + 胶囊开关(直接控制)
*/
public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.ViewHolder> {
private final List<TuyaDevice> devices = new ArrayList<>();
private final OnDeviceClick listener;
private OnDeviceLongClick longClickListener;
private int accentColor = 0xFF6B7280;
/** 局部刷新载荷:仅刷新开关/状态,不重载图标 */
private static final Object PAYLOAD_STATE = new Object();
public interface OnDeviceClick {
void onClick(TuyaDevice device, int position);
}
public interface OnDeviceLongClick {
void onLongClick(TuyaDevice device, int position);
}
public DeviceRowAdapter(OnDeviceClick listener) {
this.listener = listener;
}
public void setAccentColor(int color) {
this.accentColor = color;
}
public void setOnDeviceLongClickListener(OnDeviceLongClick l) {
this.longClickListener = l;
}
/**
* 增量更新(DiffUtil):只有真正变化的行才重绘。
* 原来用 notifyDataSetChanged 全量刷新,会导致设备行反复重绑(图标重载、开关重置),
* 视觉上表现为"设备排列会闪"
*/
public void update(List<TuyaDevice> newDevices) {
final List<TuyaDevice> old = new ArrayList<>(devices);
final List<TuyaDevice> neu = new ArrayList<>(newDevices);
DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
@Override
public int getOldListSize() {
return old.size();
}
@Override
public int getNewListSize() {
return neu.size();
}
@Override
public boolean areItemsTheSame(int a, int b) {
String ia = old.get(a).getId();
String ib = neu.get(b).getId();
return ia != null && ia.equals(ib);
}
@Override
public boolean areContentsTheSame(int a, int b) {
TuyaDevice x = old.get(a);
TuyaDevice y = neu.get(b);
return x.isOnline() == y.isOnline()
&& java.util.Objects.equals(x.getName(), y.getName())
&& java.util.Objects.equals(x.getIcon(), y.getIcon())
&& java.util.Objects.equals(String.valueOf(x.getDps()),
String.valueOf(y.getDps()));
}
});
devices.clear();
devices.addAll(neu);
result.dispatchUpdatesTo(this);
}
/** 只刷新指定设备所在行(状态变化时局部更新,避免整表重载导致闪烁) */
public void refreshDevice(String deviceId) {
if (deviceId == null) return;
for (int i = 0; i < devices.size(); i++) {
String id = devices.get(i).getId();
if (id != null && id.equals(deviceId)) {
notifyItemChanged(i, PAYLOAD_STATE);
return;
}
}
}
public TuyaDevice getAt(int position) {
if (position >= 0 && position < devices.size()) return devices.get(position);
return null;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_device_row, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position,
@NonNull List<Object> payloads) {
if (payloads != null && !payloads.isEmpty()) {
// 局部刷新:只更新开关与状态文字,不重载图标(避免闪)
bindSwitchState(holder, devices.get(position));
} else {
// 注意:父类两参数 onBindViewHolder 是抽象方法,不能 super 调用,需调用本类实现
onBindViewHolder(holder, position);
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TuyaDevice device = devices.get(position);
boolean online = device.isOnline();
holder.tvName.setText(device.getName());
holder.tvSub.setText((online ? "在线" : "离线") + " · 涂鸦");
// 图标
String iconUrl = device.getIcon();
if (iconUrl != null && !iconUrl.isEmpty()) {
Glide.with(holder.itemView.getContext())
.load(iconUrl)
.placeholder(R.drawable.ic_device_default)
.error(R.drawable.ic_device_default)
.into(holder.ivIcon);
} else {
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
}
holder.ivIcon.setAlpha(online ? 1f : 0.45f);
// 开关 DP + 状态(抽成独立方法,便于局部刷新时只更新开关)
bindSwitchState(holder, device);
holder.itemView.setOnClickListener(v -> {
if (listener != null) listener.onClick(device, position);
});
holder.itemView.setOnLongClickListener(v -> {
if (longClickListener != null) {
longClickListener.onLongClick(device, position);
return true;
}
return false;
});
}
/**
* 绑定胶囊开关与状态文字(抽成独立方法)。
* 局部刷新时只调用这里,不重载图标,避免列表闪烁
*/
private void bindSwitchState(ViewHolder holder, TuyaDevice device) {
boolean online = device.isOnline();
final List<TuyaSdkService.SwitchChannel> channels =
TuyaSdkService.findSwitchChannels(device.getId());
// 聚合状态:所有开关位都开才算"全开"
int onCount = 0;
for (TuyaSdkService.SwitchChannel c : channels) {
if (readSwitchState(device, c.dpId)) onCount++;
}
final int total = channels.size();
final boolean allOn = total > 0 && onCount == total;
// 图标底色:全开=淡主色,否则 muted
GradientDrawable bg = new GradientDrawable();
bg.setCornerRadius(dp(14));
bg.setColor(allOn && online ? alphaAccent() : mutedColor(holder));
holder.vIconBg.setBackground(bg);
holder.pill.setAccentColor(accentColor);
if (total == 0 || !online) {
holder.pill.setVisibility(total == 0 ? View.GONE : View.VISIBLE);
holder.pill.setEnabled(online);
holder.pill.setOnUserToggleListener(null);
holder.pill.setChecked(false);
holder.tvState.setText(online ? "在线" : "离线");
// 在线:主题色;离线:灰色
holder.tvState.setTextColor(online ? accentColor : mutedFgColor(holder));
} else {
holder.pill.setVisibility(View.VISIBLE);
holder.pill.setEnabled(true);
holder.pill.setChecked(allOn);
// 多开关位设备显示 "x/y 开";单开关位显示 开/关
holder.tvState.setText(total > 1
? (onCount + "/" + total + " 开")
: (allOn ? "开着" : "已关"));
holder.tvState.setTextColor(allOn ? accentColor : mutedFgColor(holder));
holder.pill.setOnUserToggleListener(checked -> {
// 列表页一次性联动控制该设备的所有开关位
// (单个开关位请点进设备详情页控制)
Map<String, Object> commands = new HashMap<>();
for (TuyaSdkService.SwitchChannel c : channels) {
commands.put(c.dpId, checked);
}
sendSwitchCommand(holder, device, commands, checked);
holder.tvState.setText(total > 1
? (checked ? total + "/" + total + " 开" : "0/" + total + " 开")
: (checked ? "开着" : "已关"));
holder.tvState.setTextColor(checked ? accentColor : mutedFgColor(holder));
});
}
}
/** 读取某个开关位当前的开关状态 */
private boolean readSwitchState(TuyaDevice device, String dpId) {
if (dpId == null || device.getDps() == null) return false;
Map<String, String> codeMap = TuyaSdkService.getDpCodeMap(device.getId());
String code = codeMap != null ? codeMap.get(dpId) : null;
Object v = code != null ? device.getDps().get(code) : null;
if (v == null) v = device.getDps().get(dpId);
return Boolean.TRUE.equals(v);
}
/** 下发开关指令;失败时回滚胶囊状态 */
private void sendSwitchCommand(ViewHolder holder, TuyaDevice device,
Map<String, Object> commands, boolean checked) {
PlatformServiceManager.getInstance(holder.itemView.getContext())
.controlDevice(asPlatformDevice(device), commands,
new IPlatformService.ControlCallback() {
@Override
public void onSuccess() {
}
@Override
public void onError(String code, String error) {
holder.pill.post(() -> holder.pill.setChecked(!checked));
android.widget.Toast.makeText(
holder.itemView.getContext(),
"控制失败: " + (error != null ? error : code),
android.widget.Toast.LENGTH_SHORT).show();
}
});
}
private int alphaAccent() {
int c = accentColor;
return android.graphics.Color.argb(Math.round(255 * 0.12f),
android.graphics.Color.red(c),
android.graphics.Color.green(c),
android.graphics.Color.blue(c));
}
private int mutedColor(ViewHolder h) {
return h.itemView.getContext().getColor(R.color.c_muted);
}
private int mutedFgColor(ViewHolder h) {
return h.itemView.getContext().getColor(R.color.c_muted_fg);
}
private com.xhai.iot.model.PlatformDevice asPlatformDevice(TuyaDevice d) {
com.xhai.iot.model.PlatformDevice pd = new com.xhai.iot.model.PlatformDevice();
pd.setDeviceId(d.getId());
pd.setName(d.getName());
pd.setPlatform(com.xhai.iot.model.DevicePlatform.TUYA);
pd.setOnline(d.isOnline());
pd.setCategory(d.getCategory());
pd.setProductId(d.getProductId());
pd.setIconUrl(d.getIcon());
return pd;
}
private static int dp(int value) {
return Math.round(android.content.res.Resources.getSystem()
.getDisplayMetrics().density * value);
}
@Override
public int getItemCount() {
return devices.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
View vIconBg;
ImageView ivIcon;
TextView tvName;
TextView tvSub;
TextView tvState;
PillSwitch pill;
ViewHolder(@NonNull View itemView) {
super(itemView);
vIconBg = itemView.findViewById(R.id.v_icon_bg);
ivIcon = itemView.findViewById(R.id.iv_device_icon);
tvName = itemView.findViewById(R.id.tv_device_name);
tvSub = itemView.findViewById(R.id.tv_device_sub);
tvState = itemView.findViewById(R.id.tv_device_state);
pill = itemView.findViewById(R.id.pill_switch);
}
}
}

View File

@ -0,0 +1,366 @@
package com.xhai.iot.ui.adapter;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.xhai.iot.R;
import com.xhai.iot.model.TuyaDevice;
import com.xhai.iot.service.IPlatformService;
import com.xhai.iot.service.PlatformServiceManager;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.widget.PillSwitch;
import com.xhai.iot.util.ThemeManager;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 首页 3 列常用设备小卡适配器(设计稿 HomePage 常用设备区)
* 卡片:图标 + 胶囊开关 + 名称 + 状态;点开关直接控制,点卡片进面板。
*/
public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.ViewHolder> {
private final List<TuyaDevice> devices;
private final OnDeviceClick listener;
private OnDeviceLongClick longClickListener;
private int accentColor = 0xFF6B7280;
/**
* 控制指令下发到结果回传的窗口内,锁定胶囊显示为"乐观值"。
* 否则点击后列表刷新回来的旧 dps 会把胶囊动画弹回去,再被新值弹回来,视觉上就是闪动。
*/
private static final long TOGGLE_LOCK_MS = 8000L;
private final Map<String, Boolean> pendingToggleValue = new HashMap<>();
private final Map<String, Long> pendingToggleAt = new HashMap<>();
public interface OnDeviceClick {
void onClick(TuyaDevice device, int position);
}
public interface OnDeviceLongClick {
void onLongClick(TuyaDevice device, int position);
}
public HomeDeviceAdapter(List<TuyaDevice> devices, OnDeviceClick listener) {
this.devices = devices;
this.listener = listener;
}
public void setOnDeviceLongClickListener(OnDeviceLongClick l) {
this.longClickListener = l;
}
public void setAccentColor(int color) {
this.accentColor = color;
}
public void update(List<TuyaDevice> newDevices) {
if (newDevices == null) return;
// 控制窗口内的设备先把乐观值写回数据模型,避免被后端旧状态覆盖
applyPendingToggles(newDevices);
if (isSameData(newDevices)) {
// 数据与当前完全一致:不重绑,避免整块卡片/胶囊白闪一下
android.util.Log.d("HomeDeviceAdapter", "update: 数据无变化,跳过重绑");
return;
}
devices.clear();
devices.addAll(newDevices);
notifyDataSetChanged();
}
/** 列表内容是否与当前渲染的一致(id / 名称 / 图标 / 在线态 / 类型 / dps) */
private boolean isSameData(List<TuyaDevice> newDevices) {
if (newDevices.size() != devices.size()) return false;
for (int i = 0; i < newDevices.size(); i++) {
TuyaDevice a = devices.get(i);
TuyaDevice b = newDevices.get(i);
if (a == b) continue;
if (!java.util.Objects.equals(a.getId(), b.getId())) return false;
if (a.isOnline() != b.isOnline()) return false;
if (!java.util.Objects.equals(a.getName(), b.getName())) return false;
if (!java.util.Objects.equals(a.getIcon(), b.getIcon())) return false;
if (!java.util.Objects.equals(a.getCategory(), b.getCategory())) return false;
if (!java.util.Objects.equals(a.getDps(), b.getDps())) return false;
}
return true;
}
/** 控制窗口内的设备:把开关 DP 直接写成乐观值,让数据模型与胶囊显示保持一致 */
private void applyPendingToggles(List<TuyaDevice> list) {
if (pendingToggleValue.isEmpty()) return;
long now = System.currentTimeMillis();
for (TuyaDevice d : list) {
Boolean pending = pendingToggleValue.get(d.getId());
if (pending == null) continue;
Long at = pendingToggleAt.get(d.getId());
if (at == null || now - at > TOGGLE_LOCK_MS) {
pendingToggleValue.remove(d.getId());
pendingToggleAt.remove(d.getId());
continue;
}
String switchDpId = TuyaSdkService.findSwitchDpId(d.getId());
if (switchDpId == null) continue;
Map<String, Object> dps = d.getDps();
if (dps == null) {
dps = new HashMap<>();
d.setDps(dps);
}
Map<String, Object> probe = new HashMap<>();
probe.put(switchDpId, true);
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe);
if (codeMap != null && !codeMap.isEmpty()) {
dps.put(codeMap.keySet().iterator().next(), pending);
}
dps.put(switchDpId, pending);
}
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_home_device, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TuyaDevice device = devices.get(position);
boolean online = device.isOnline();
holder.tvName.setText(device.getName());
// 图标:涂鸦设备图标,无则默认
String iconUrl = device.getIcon();
if (iconUrl != null && !iconUrl.isEmpty()) {
Glide.with(holder.itemView.getContext())
.load(iconUrl)
.placeholder(R.drawable.ic_device_default)
.error(R.drawable.ic_device_default)
// 去掉默认的 300ms 交叉淡入:列表重绑时图标会重新淡入一次,看起来就是闪一下
.dontAnimate()
.into(holder.ivIcon);
} else {
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
}
holder.ivIcon.setAlpha(online ? 1f : 0.45f);
// 开关 DP 逻辑(与 DeviceGridAdapter 一致)
final String switchDpId = TuyaSdkService.findSwitchDpId(device.getId());
String switchDpCode = null;
if (switchDpId != null) {
Map<String, Object> probe = new HashMap<>();
probe.put(switchDpId, true);
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(device.getId(), probe);
if (codeMap != null && !codeMap.isEmpty()) {
switchDpCode = codeMap.keySet().iterator().next();
}
}
Object dpValue = null;
if (switchDpCode != null && device.getDps() != null) {
dpValue = device.getDps().get(switchDpCode);
if (dpValue == null) dpValue = device.getDps().get(switchDpId);
}
// 无涂鸦 schema 的设备(平台虚拟/本地设备):从 dps 中兜底取常见开关字段
if (dpValue == null && device.getDps() != null) {
for (String key : new String[]{"switch_1", "switch", "switch1", "power", "1"}) {
Object v = device.getDps().get(key);
if (v instanceof Boolean) {
dpValue = v;
break;
}
}
}
final boolean isOn = Boolean.TRUE.equals(dpValue);
// 是否开关类:有涂鸦可写开关 DP,或设备类型(后端/涂鸦 category)属于灯/开关/插座类
final boolean switchLike = switchDpId != null
|| isSwitchLikeCategory(device.getCategory());
// 刚点击且控制结果未回传时保持乐观值,避免被列表里的旧 dps 刷回造成胶囊回弹闪动
final boolean shownOn = resolveShownState(device.getId(), isOn);
holder.pill.setAccentColor(accentColor);
if (switchDpId == null) {
holder.pill.setVisibility(View.GONE);
holder.pill.setOnUserToggleListener(null);
holder.pill.setChecked(false, false);
} else if (!online) {
holder.pill.setVisibility(View.VISIBLE);
holder.pill.setEnabled(false);
holder.pill.setOnUserToggleListener(null);
holder.pill.setChecked(false, false);
}
if (switchDpId == null || !online) {
// 开关类在线:已在下方统一渲染;此处仅处理无开关 DP / 离线
holder.tvStatus.setText(buildStatusText(holder.tvStatus, online,
switchLike, shownOn));
} else {
holder.pill.setVisibility(View.VISIBLE);
holder.pill.setEnabled(true);
// 列表绑定/刷新不走动画(否则每次刷新胶囊都会动一下);动画只由用户点击触发
holder.pill.setChecked(shownOn, false);
// 在线开关类:在线(主题色)+ 已打开/已关闭
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, shownOn));
holder.pill.setOnUserToggleListener(checked -> {
// 锁定乐观值:下发结果回传前,忽略列表刷新带回来的旧状态
lockPendingToggle(device.getId(), checked);
Map<String, Object> commands = new HashMap<>();
commands.put(switchDpId, checked);
PlatformServiceManager.getInstance(holder.itemView.getContext())
.controlDevice(asPlatformDevice(device), commands,
new IPlatformService.ControlCallback() {
@Override
public void onSuccess() {
}
@Override
public void onError(String code, String error) {
unlockPendingToggle(device.getId());
holder.pill.post(() -> holder.pill.setChecked(!checked));
android.widget.Toast.makeText(
holder.itemView.getContext(),
"控制失败: " + (error != null ? error : code),
android.widget.Toast.LENGTH_SHORT).show();
}
});
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked));
});
}
holder.itemView.setOnClickListener(v -> {
if (listener != null) listener.onClick(device, position);
});
holder.itemView.setOnLongClickListener(v -> {
if (longClickListener != null) {
longClickListener.onLongClick(device, position);
return true;
}
return false;
});
}
/**
* 计算胶囊实际应显示的状态。
* 若该设备刚被点击、且控制结果尚未回传(此时列表里的 dps 还是旧值),保持乐观值不动,
* 否则会出现"胶囊弹回原位、再弹过去"的闪动。
*/
private boolean resolveShownState(String deviceId, boolean serverOn) {
Boolean pending = pendingToggleValue.get(deviceId);
if (pending == null) return serverOn;
Long at = pendingToggleAt.get(deviceId);
boolean expired = at == null || System.currentTimeMillis() - at > TOGGLE_LOCK_MS;
if (expired || pending == serverOn) {
// 已超时,或服务端状态已追上乐观值:解除锁定,以服务端为准
pendingToggleValue.remove(deviceId);
pendingToggleAt.remove(deviceId);
return serverOn;
}
return pending;
}
private void lockPendingToggle(String deviceId, boolean value) {
pendingToggleValue.put(deviceId, value);
pendingToggleAt.put(deviceId, System.currentTimeMillis());
}
private void unlockPendingToggle(String deviceId) {
pendingToggleValue.remove(deviceId);
pendingToggleAt.remove(deviceId);
}
private com.xhai.iot.model.PlatformDevice asPlatformDevice(TuyaDevice d) {
com.xhai.iot.model.PlatformDevice pd = new com.xhai.iot.model.PlatformDevice();
pd.setDeviceId(d.getId());
pd.setName(d.getName());
pd.setPlatform(com.xhai.iot.model.DevicePlatform.TUYA);
pd.setOnline(d.isOnline());
pd.setCategory(d.getCategory());
pd.setProductId(d.getProductId());
pd.setIconUrl(d.getIcon());
return pd;
}
/**
* 组装状态文案(分段配色):
* 离线 → 「离线」(灰)
* 在线非开关类(传感器等) → 「在线」(主题色)
* 在线开关类(灯/开关/插座)→ 「在线」(主题色)+ 「 · 已打开/已关闭」
*/
private CharSequence buildStatusText(View anchor, boolean online,
boolean switchLike, boolean isOn) {
android.content.Context ctx = anchor.getContext();
int muted = ctx.getColor(R.color.c_muted_fg);
if (!online) {
SpannableString sp = new SpannableString("离线");
sp.setSpan(new ForegroundColorSpan(muted), 0, sp.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return sp;
}
if (!switchLike) {
SpannableString sp = new SpannableString("在线");
sp.setSpan(new ForegroundColorSpan(accentColor), 0, sp.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return sp;
}
String full = "在线 · " + (isOn ? "已打开" : "已关闭");
SpannableString sp = new SpannableString(full);
// 「在线」用主题色
sp.setSpan(new ForegroundColorSpan(accentColor), 0, 2,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
// 「已打开」用在线绿 / 「已关闭」用灰色
sp.setSpan(new ForegroundColorSpan(isOn ? ctx.getColor(R.color.online) : muted),
3, full.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return sp;
}
/**
* 设备类型是否为「开关类」(可开/关的设备:灯、开关、插座、排插等)
* 兼容本平台 device_type(light/socket/switch)与涂鸦 category(kg/cjkg/tdq/tgq...)
*/
static boolean isSwitchLikeCategory(String category) {
if (category == null) return false;
String c = category.trim().toLowerCase();
if (c.isEmpty()) return false;
// 本平台语义类型
if (c.equals("light") || c.equals("socket") || c.equals("switch")
|| c.equals("plug") || c.equals("switch_1") || c.equals("power")) {
return true;
}
// 涂鸦 category(含 productId 兜底场景)
return c.startsWith("kg") || c.startsWith("cjkg") || c.startsWith("tdq")
|| c.startsWith("tgq") || c.startsWith("cz") || c.startsWith("pc")
|| c.startsWith("dc") || c.startsWith("dd")
|| c.contains("light") || c.contains("switch") || c.contains("socket");
}
@Override
public int getItemCount() {
return devices.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
ImageView ivIcon;
TextView tvName;
TextView tvStatus;
PillSwitch pill;
ViewHolder(@NonNull View itemView) {
super(itemView);
ivIcon = itemView.findViewById(R.id.iv_device_icon);
tvName = itemView.findViewById(R.id.tv_device_name);
tvStatus = itemView.findViewById(R.id.tv_device_status);
pill = itemView.findViewById(R.id.pill_switch);
}
}
}

View File

@ -0,0 +1,78 @@
package com.xhai.iot.ui.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.service.TuyaSdkService;
import java.util.ArrayList;
import java.util.List;
/**
* 家庭列表适配器
*/
public class HomeListAdapter extends RecyclerView.Adapter<HomeListAdapter.ViewHolder> {
private final List<TuyaSdkService.SdkHome> homes = new ArrayList<>();
private OnDeleteListener deleteListener;
public interface OnDeleteListener {
void onDelete(TuyaSdkService.SdkHome home, int position);
}
public void setOnDeleteListener(OnDeleteListener listener) {
this.deleteListener = listener;
}
public void update(List<TuyaSdkService.SdkHome> newHomes) {
homes.clear();
if (newHomes != null) homes.addAll(newHomes);
notifyDataSetChanged();
}
public List<TuyaSdkService.SdkHome> getData() {
return homes;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_home, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TuyaSdkService.SdkHome home = homes.get(position);
holder.tvName.setText(home.name);
holder.tvDevices.setText(home.deviceCount + " 台设备");
holder.tvDelete.setOnClickListener(v -> {
if (deleteListener != null) deleteListener.onDelete(home, position);
});
}
@Override
public int getItemCount() {
return homes.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvName;
TextView tvDevices;
TextView tvDelete;
ViewHolder(@NonNull View itemView) {
super(itemView);
tvName = itemView.findViewById(R.id.tv_home_name);
tvDevices = itemView.findViewById(R.id.tv_home_devices);
tvDelete = itemView.findViewById(R.id.tv_home_delete);
}
}
}

View File

@ -0,0 +1,71 @@
package com.xhai.iot.ui.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.xhai.iot.R;
import com.xhai.iot.service.TuyaSdkService;
import java.util.ArrayList;
import java.util.List;
/**
* 房间列表适配器
*/
public class RoomListAdapter extends RecyclerView.Adapter<RoomListAdapter.ViewHolder> {
private final List<TuyaSdkService.SdkRoom> rooms = new ArrayList<>();
private OnDeleteListener deleteListener;
public interface OnDeleteListener {
void onDelete(TuyaSdkService.SdkRoom room, int position);
}
public void setOnDeleteListener(OnDeleteListener listener) {
this.deleteListener = listener;
}
public void update(List<TuyaSdkService.SdkRoom> newRooms) {
rooms.clear();
if (newRooms != null) rooms.addAll(newRooms);
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_room, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
TuyaSdkService.SdkRoom room = rooms.get(position);
holder.tvName.setText(room.name);
holder.tvDelete.setOnClickListener(v -> {
if (deleteListener != null) deleteListener.onDelete(room, position);
});
}
@Override
public int getItemCount() {
return rooms.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvName;
TextView tvDelete;
ViewHolder(@NonNull View itemView) {
super(itemView);
tvName = itemView.findViewById(R.id.tv_room_name);
tvDelete = itemView.findViewById(R.id.tv_room_delete);
}
}
}

View File

@ -0,0 +1,395 @@
package com.xhai.iot.ui.panel;
import android.app.Activity;
import android.app.Application;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.thingclips.smart.api.MicroContext;
import com.thingclips.smart.panelcaller.api.AbsPanelCallerService;
import com.xhai.iot.ui.DeviceControlActivity;
import java.lang.ref.WeakReference;
/**
* 设备面板路由:按 platform 字段分发到对应平台原生面板;不支持时降级到通用面板。
*
* 当前阶段:
* - 涂鸦 (tuya) → 涂鸦 UI BizBundle 原生面板 (AbsPanelCallerService.goPanelWithCheckAndTip)
* - 其他 / 失败 → 通用面板 DeviceControlActivity (DP Schema 动态渲染,兜底)
*
* 未来接入小米/华为:
* - xiaomi → XiaomiPanelRouter.open(...)
* - huawei → HuaweiPanelRouter.open(...)
*
* 兜底机制:goPanelWithCheckAndTip 在某些情况下会"静默失败"(不抛异常、不跳转、
* 无日志)。因此调用后启动一个超时监控:若 2.5 秒内没有新 Activity 被打开,
* 则自动降级到通用面板,保证用户体验不中断。
*/
public class PanelRouter {
private static final String TAG = "PanelRouter";
/** 原生面板超时降级时间:goPanel 启动后等待 ThingRCTSmartPanelActivity 启动的最大时长。
* 涂鸦原生面板需加载 RN bundle 并初始化 BizBundle,家庭 gid 同步、设备数据准备等可能耗时数秒,
* 较短的超时会导致面板未加载完就误降级到通用面板。延长到 5s。 */
private static final long PANEL_TIMEOUT = 5000L;
/** 平台常量 */
public static final String PLATFORM_TUYA = "tuya";
public static final String PLATFORM_XIAOMI = "xiaomi";
public static final String PLATFORM_HUAWEI = "huawei";
public static final String PLATFORM_LOCAL = "local";
public static final String PLATFORM_GENERIC = "generic";
/**
* 涂鸦原生面板开关。
* 架构调整:家庭/房间/设备由本平台后端管理,涂鸦 SDK 仅用于控制设备。
* 涂鸦原生面板依赖家庭 gid(BizBundle),家庭为空时白屏,故关闭原生面板,
* 统一走通用面板(DeviceControlActivity,涂鸦 SDK newDeviceInstance 控制,不依赖家庭)。
*/
public static final boolean ENABLE_TUYA_NATIVE_PANEL = true;
/** 静态超时监控器 */
private static final TimeoutMonitor sMonitor = new TimeoutMonitor();
public static void openPanel(AppCompatActivity ctx, String deviceId,
String platform, String name) {
if (ctx == null || deviceId == null) {
Log.w(TAG, "openPanel: 参数缺失");
return;
}
if (platform == null || platform.isEmpty()) platform = PLATFORM_TUYA;
Log.i(TAG, "openPanel: deviceId=" + deviceId + ", platform=" + platform);
switch (platform) {
case PLATFORM_TUYA:
if (ENABLE_TUYA_NATIVE_PANEL) {
openTuyaPanel(ctx, deviceId, name);
} else {
Log.i(TAG, "涂鸦原生面板已禁用(lifecycle冲突),走通用面板: " + deviceId);
openGenericPanel(ctx, deviceId, name);
}
break;
case PLATFORM_XIAOMI:
case PLATFORM_HUAWEI:
Log.w(TAG, "平台 " + platform + " 原生面板尚未接入,降级到通用面板");
openGenericPanel(ctx, deviceId, name);
break;
case PLATFORM_LOCAL:
case PLATFORM_GENERIC:
default:
openGenericPanel(ctx, deviceId, name);
break;
}
}
private static void openTuyaPanel(AppCompatActivity ctx, String deviceId, String name) {
// 本地家庭与涂鸦家庭互不同步:本地家庭只服务于本平台的业务归属,
// 涂鸦设备统一挂在涂鸦侧的家庭里,二者不做映射也不做校验。
// 唯一硬性前提:gid 不能为 0,否则 goPanelWithCheckAndTip 会静默失败。
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
if (gid > 0) {
ensureHomeDataThenOpen(ctx, deviceId, name, gid);
return;
}
Log.i(TAG, "涂鸦 gid 未同步,先同步涂鸦家庭再打开面板: " + deviceId);
syncGidAndOpen(ctx, deviceId, name);
}
/**
* 打开面板前补齐涂鸦家庭详情。
* <p>
* 涂鸦原生面板判断设备在线/离线、以及 Mesh 子设备的本地通道,都依赖 SDK 家庭缓存
* (HomeBean.deviceList / meshList / sigMeshList)。本平台首页设备来自自建后端,
* SDK 家庭详情可能从未加载(日志中可见 deviceList=null、meshList=null),
* 会导致面板拿到不完整的设备状态而显示"设备不在线"。
* 这里强制拉一次家庭详情,顺带刷新设备真实在线状态并初始化蓝牙 Mesh 网络。
*/
private static void ensureHomeDataThenOpen(AppCompatActivity ctx, String deviceId, String name, long gid) {
try {
com.xhai.iot.service.TuyaSdkService.ensureMeshConnected(gid);
} catch (Throwable e) {
Log.w(TAG, "初始化 Mesh 网络失败(忽略,继续打开面板): " + e.getMessage());
}
try {
com.thingclips.smart.home.sdk.api.IThingHome home =
com.thingclips.smart.home.sdk.ThingHomeSdk.newHomeInstance(gid);
home.getHomeDetail(new com.thingclips.smart.home.sdk.callback.IThingHomeResultCallback() {
@Override
public void onSuccess(com.thingclips.smart.home.sdk.bean.HomeBean homeBean) {
int devCount = homeBean != null && homeBean.getDeviceList() != null
? homeBean.getDeviceList().size() : -1;
Log.i(TAG, "家庭详情已加载(gid=" + gid + ", 设备数=" + devCount
+ "),准备打开面板: " + deviceId);
postToMain(() -> {
if (ctx.isFinishing() || ctx.isDestroyed()) return;
doOpenTuyaPanel(ctx, deviceId, name);
});
}
@Override
public void onError(String code, String error) {
Log.w(TAG, "家庭详情加载失败(" + code + " " + error + "),仍尝试打开面板: " + deviceId);
postToMain(() -> {
if (ctx.isFinishing() || ctx.isDestroyed()) return;
doOpenTuyaPanel(ctx, deviceId, name);
});
}
});
} catch (Throwable e) {
Log.w(TAG, "加载家庭详情异常,直接打开面板: " + e.getMessage());
doOpenTuyaPanel(ctx, deviceId, name);
}
}
private static void postToMain(Runnable action) {
new Handler(Looper.getMainLooper()).post(action);
}
/** 异步查询涂鸦家庭列表并同步 gid,然后打开原生面板;无家庭/失败则降级通用面板 */
private static void syncGidAndOpen(AppCompatActivity ctx, String deviceId, String name) {
try {
com.thingclips.smart.home.sdk.ThingHomeSdk.getHomeManagerInstance()
.queryHomeList(new com.thingclips.smart.home.sdk.callback.IThingGetHomeListCallback() {
@Override
public void onSuccess(java.util.List<com.thingclips.smart.home.sdk.bean.HomeBean> homes) {
if (homes == null || homes.isEmpty()) {
Log.w(TAG, "涂鸦无家庭(gid=0),原生面板无法打开,降级通用面板: " + deviceId);
openGenericPanel(ctx, deviceId, name);
return;
}
// 优先选「包含目标设备」的家庭,保证 gid 与设备归属一致
com.thingclips.smart.home.sdk.bean.HomeBean target = null;
for (com.thingclips.smart.home.sdk.bean.HomeBean home : homes) {
if (home.getDeviceList() == null) continue;
for (com.thingclips.smart.sdk.bean.DeviceBean dev : home.getDeviceList()) {
if (deviceId.equals(dev.getDevId())) {
target = home;
break;
}
}
if (target != null) break;
}
boolean matched = target != null;
if (target == null) target = homes.get(0);
com.xhai.iot.service.BizBundleFamilyServiceImpl
.updateCurrentHomeId(target.getHomeId(), target.getName());
Log.i(TAG, "已同步涂鸦家庭 gid=" + target.getHomeId()
+ " name=" + target.getName()
+ " 命中目标设备=" + matched
+ "(家庭总数=" + homes.size() + ")");
doOpenTuyaPanel(ctx, deviceId, name);
}
@Override
public void onError(String code, String error) {
Log.w(TAG, "查询涂鸦家庭失败(" + code + ":" + error + "): " + deviceId);
// 涂鸦会话过期(本地 token 失效,isLogin 仍为 true):
// 用本地加密凭据自动续登,成功后重试打开原生面板
if (code != null && code.toUpperCase().contains("SESSION_LOSS")) {
reloginThenOpen(ctx, deviceId, name);
return;
}
openGenericPanel(ctx, deviceId, name);
}
});
} catch (Throwable e) {
Log.w(TAG, "同步 gid 异常,降级通用面板: " + e.getMessage(), e);
openGenericPanel(ctx, deviceId, name);
}
}
/** 涂鸦会话过期:自动续登成功后重试打开原生面板,失败降级通用面板 */
private static void reloginThenOpen(AppCompatActivity ctx, String deviceId, String name) {
if (!com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(ctx)) {
// 无本地凭据(从未绑定或应用数据被清理):明确告知用户去重新绑定,
// 不再静默降级,否则用户只看到"点击没跳转到涂鸦原生面板"。
Log.w(TAG, "无本地涂鸦凭据,无法续登,提示重新绑定后降级通用面板: " + deviceId);
new Handler(Looper.getMainLooper()).post(() -> {
if (ctx.isFinishing() || ctx.isDestroyed()) return;
Toast.makeText(ctx,
"涂鸦账号登录已过期,请在「我的」页面重新绑定涂鸦账号",
Toast.LENGTH_LONG).show();
openGenericPanel(ctx, deviceId, name);
});
return;
}
Log.i(TAG, "涂鸦会话过期,自动续登后重试原生面板: " + deviceId);
com.xhai.iot.util.TuyaAutoRelogin.relogin(ctx,
new com.xhai.iot.util.TuyaAutoRelogin.Callback() {
@Override
public void onSuccess(com.thingclips.smart.android.user.bean.User user) {
// 续登成功:回到主线程重试打开面板
new Handler(Looper.getMainLooper()).post(() -> {
if (ctx.isFinishing() || ctx.isDestroyed()) return;
Log.i(TAG, "续登成功,重试打开原生面板: " + deviceId);
openTuyaPanel(ctx, deviceId, name);
});
}
@Override
public void onError(String message) {
Log.w(TAG, "续登失败(" + message + "),降级通用面板: " + deviceId);
new Handler(Looper.getMainLooper()).post(() -> {
if (ctx.isFinishing() || ctx.isDestroyed()) return;
Toast.makeText(ctx,
"涂鸦账号登录已失效,请在「我的」页面重新绑定涂鸦账号",
Toast.LENGTH_LONG).show();
openGenericPanel(ctx, deviceId, name);
});
}
});
}
private static void doOpenTuyaPanel(AppCompatActivity ctx, String deviceId, String name) {
try {
AbsPanelCallerService service = MicroContext.getServiceManager()
.findServiceByInterface(AbsPanelCallerService.class.getName());
if (service == null) {
Log.w(TAG, "AbsPanelCallerService 未注册,降级到通用面板");
openGenericPanel(ctx, deviceId, name);
return;
}
// 诊断:gid / SDK 登录态 / 设备是否在 SDK 缓存中,均为面板能否打开的关键条件
boolean sdkLogin = false;
String devInfo = "null";
try {
sdkLogin = com.thingclips.smart.home.sdk.ThingHomeSdk.getUserInstance().isLogin();
com.thingclips.smart.sdk.bean.DeviceBean bean =
com.thingclips.smart.home.sdk.ThingHomeSdk.getDataInstance().getDeviceBean(deviceId);
if (bean != null) {
// 云端在线 / 本地蓝牙在线 分开打印:Mesh 子设备常见"本地在线、云端离线"
devInfo = "name=" + bean.getName()
+ ",category=" + bean.getCategory()
+ ",online=" + bean.getIsOnline()
+ ",localOnline=" + bean.getIsLocalOnline()
+ ",parentId=" + bean.getParentId()
+ ",productId=" + bean.getProductId();
}
} catch (Throwable ignored) {
}
Log.i(TAG, "打开原生面板: deviceId=" + deviceId
+ ", gid=" + com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid()
+ ", sdkLogin=" + sdkLogin
+ ", 设备(" + devInfo + ")");
// 启动超时监控:若超时未打开新 Activity,则询问用户是否切换通用面板
sMonitor.start(ctx, deviceId, name);
service.goPanelWithCheckAndTip(ctx, deviceId);
Log.i(TAG, "已请求跳转到涂鸦原生面板: " + deviceId);
} catch (Throwable e) {
Log.w(TAG, "跳转涂鸦原生面板异常,降级到通用面板: " + e.getMessage(), e);
sMonitor.cancel();
openGenericPanel(ctx, deviceId, name);
}
}
private static void openGenericPanel(AppCompatActivity ctx, String deviceId, String name) {
Intent intent = new Intent(ctx, DeviceControlActivity.class);
intent.putExtra("deviceId", deviceId);
intent.putExtra("deviceName", name);
ctx.startActivity(intent);
}
/** 原生面板加载超时对话框:让用户选择重新加载 / 切换通用面板 / 取消 */
private static void showPanelTimeoutDialog(AppCompatActivity ctx, String deviceId, String name) {
if (ctx == null || ctx.isFinishing() || ctx.isDestroyed()) return;
try {
new androidx.appcompat.app.AlertDialog.Builder(ctx)
.setTitle("面板加载较慢")
.setMessage("原生控制面板加载超时,请选择处理方式:")
.setPositiveButton("重新加载", (d, w) -> {
d.dismiss();
Log.i(TAG, "用户选择重新加载原生面板: " + deviceId);
// 重新走原生面板流程(会启动新一轮超时监控)
doOpenTuyaPanel(ctx, deviceId, name);
})
.setNegativeButton("切换通用面板", (d, w) -> {
d.dismiss();
Log.i(TAG, "用户选择切换通用面板: " + deviceId);
openGenericPanel(ctx, deviceId, name);
})
.setNeutralButton("取消", (d, w) -> {
d.dismiss();
Log.i(TAG, "用户取消面板操作: " + deviceId);
})
.setCancelable(true)
.show();
} catch (Throwable e) {
Log.w(TAG, "弹窗失败,降级通用面板: " + e.getMessage(), e);
openGenericPanel(ctx, deviceId, name);
}
}
/**
* 面板跳转超时监控:
* goPanel 调用后,监听 Activity 生命周期;若指定时间内没有新的 Activity 打开,
* 则认为原生面板跳转失败,降级到通用面板。
*/
private static final class TimeoutMonitor {
private final Handler mHandler = new Handler(Looper.getMainLooper());
private final Application.ActivityLifecycleCallbacks mCallbacks;
private Runnable mFallback;
private boolean mArmed;
/** 是否已注册生命周期回调(避免每次点击重复注册、回调被多次触发) */
private boolean mRegistered;
/** 发起跳转的原 Activity,用于排除其自身 resume 造成的误判 */
private WeakReference<Activity> mOrigin;
TimeoutMonitor() {
mCallbacks = new Application.ActivityLifecycleCallbacks() {
@Override public void onActivityResumed(Activity activity) {
if (!mArmed) return;
Activity origin = mOrigin != null ? mOrigin.get() : null;
// 原 Activity 自身 resume(如弹窗关闭)不代表跳转成功,忽略
if (activity == origin) return;
cancel();
Log.i(TAG, "检测到新 Activity 打开,原生面板跳转成功: " + activity.getClass().getSimpleName());
}
@Override public void onActivityStarted(Activity activity) {}
@Override public void onActivityStopped(Activity activity) {}
@Override public void onActivityPaused(Activity activity) {}
@Override public void onActivitySaveInstanceState(Activity activity, android.os.Bundle outState) {}
@Override public void onActivityDestroyed(Activity activity) {}
@Override public void onActivityCreated(Activity activity, android.os.Bundle savedInstanceState) {}
};
}
void start(AppCompatActivity ctx, String deviceId, String name) {
cancel();
mArmed = true;
mOrigin = new WeakReference<>(ctx);
if (!mRegistered) {
ctx.getApplication().registerActivityLifecycleCallbacks(mCallbacks);
mRegistered = true;
}
mFallback = () -> {
Activity original = mOrigin != null ? mOrigin.get() : null;
// 若原 Activity 已不在前台(被覆盖/销毁),说明面板可能已打开,无需处理
if (original != null && (original.isFinishing() || original.isDestroyed())) {
Log.i(TAG, "原Activity已结束,面板已打开,无需处理");
sMonitor.cancel();
return;
}
// 停止监控标记,弹框期间新的 resumed 不再触发 cancel(避免误处理)
mArmed = false;
Log.w(TAG, "原生面板加载超时(" + PANEL_TIMEOUT + "ms),询问用户处理方式: " + deviceId);
// 不自动降级,弹框让用户选择:重新加载 / 切换通用面板 / 取消
showPanelTimeoutDialog(ctx, deviceId, name);
};
mHandler.postDelayed(mFallback, PANEL_TIMEOUT);
}
void cancel() {
mArmed = false;
if (mFallback != null) {
mHandler.removeCallbacks(mFallback);
mFallback = null;
}
}
}
}

View File

@ -0,0 +1,177 @@
package com.xhai.iot.ui.widget;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
/**
* 胶囊开关(对应设计稿 Toggle:36x20 圆角轨道 + 16dp 白色圆点,300ms 动画)
* accent 色轨道。仅用户点击时触发回调,代码 setChecked 不触发(与 Switch.isPressed 行为一致)。
*/
public class PillSwitch extends View {
private boolean checked = false;
private int accentColor = 0xFF6B7280;
private int trackOffColor = 0xFFF1F2F6;
private Boolean trackOffNight = null; // 缓存:轨道色解析时的深色态
private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private float thumbProgress = 0f; // 0=off 1=on
private ValueAnimator animator;
private OnUserToggleListener listener;
public interface OnUserToggleListener {
void onToggle(boolean checked);
}
public PillSwitch(Context context) {
super(context);
init();
}
public PillSwitch(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
float density = getResources().getDisplayMetrics().density;
// 36x20dp
android.view.ViewGroup.LayoutParams lp = getLayoutParams();
setMinimumWidth(Math.round(36 * density));
setMinimumHeight(Math.round(20 * density));
setClickable(true);
setFocusable(true);
}
public void setAccentColor(int color) {
this.accentColor = color;
invalidate();
}
public void setChecked(boolean checked) {
setChecked(checked, true);
}
/**
* 设置选中态。
*
* @param animate 是否播放位移动画。列表绑定/数据刷新时传 false 直接落到目标位置,
* 否则每次刷新都会重放一次 250ms 动画,视觉上表现为胶囊"闪动"。
*/
public void setChecked(boolean checked, boolean animate) {
boolean changed = this.checked != checked;
this.checked = checked;
float target = checked ? 1f : 0f;
boolean running = animator != null && animator.isRunning();
android.util.Log.d("PillSwitch", "setChecked(" + checked + ", animate=" + animate
+ ") changed=" + changed + " running=" + running + " progress=" + thumbProgress);
if (!animate) {
// 用户点击触发的动画正在进行、且状态与目标一致:不要打断,
// 否则圆点会从动画中途瞬移到终点,视觉上就是"闪一下"。
if (!changed && running) {
return;
}
// 状态没变且圆点已在目标位置:无需重绘
if (!changed && Math.abs(thumbProgress - target) < 0.01f) {
return;
}
cancelAnimator();
thumbProgress = target;
invalidate();
return;
}
// 状态没变且圆点已在目标位置:不重放动画(避免无意义的闪动)
if (!changed && Math.abs(thumbProgress - target) < 0.01f) {
return;
}
animateTo(target);
}
public boolean isChecked() {
return checked;
}
public void setOnUserToggleListener(OnUserToggleListener l) {
this.listener = l;
}
private void animateTo(float target) {
if (animator != null) {
animator.cancel();
}
animator = ValueAnimator.ofFloat(thumbProgress, target);
animator.setDuration(250);
animator.addUpdateListener(a -> {
thumbProgress = (float) a.getAnimatedValue();
invalidate();
});
animator.start();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
float density = getResources().getDisplayMetrics().density;
int w = Math.round(36 * density);
int h = Math.round(20 * density);
setMeasuredDimension(
resolveSize(w, widthMeasureSpec),
resolveSize(h, heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float w = getWidth();
float h = getHeight();
float pad = h * 0.1f; // 2dp/20dp
trackPaint.setColor(checked ? accentColor : resolveTrackOffColor());
float radius = h / 2f;
canvas.drawRoundRect(0, 0, w, h, radius, radius, trackPaint);
float thumbR = (h - pad * 2) / 2f;
float maxTravel = w - pad * 2 - thumbR * 2;
float cx = pad + thumbR + thumbProgress * maxTravel;
float cy = h / 2f;
thumbPaint.setColor(0xFFFFFFFF);
canvas.drawCircle(cx, cy, thumbR, thumbPaint);
}
@Override
public boolean performClick() {
super.performClick();
// 用户点击:播放动画反馈
setChecked(!checked, true);
if (listener != null) {
listener.onToggle(checked);
}
return true;
}
private void cancelAnimator() {
if (animator != null) {
animator.cancel();
animator = null;
}
}
/**
* 关闭态轨道色(跟随浅色/深色主题资源):
* 亮色 #F1F2F6,深色 #52525B —— 深色下用中灰,避免与白色圆点糊在一起分不清。
*/
private int resolveTrackOffColor() {
boolean night = (getContext().getResources().getConfiguration().uiMode
& android.content.res.Configuration.UI_MODE_NIGHT_MASK)
== android.content.res.Configuration.UI_MODE_NIGHT_YES;
if (trackOffNight == null || trackOffNight != night) {
trackOffColor = getContext().getColor(com.xhai.iot.R.color.c_switch_track_off);
trackOffNight = night;
}
return trackOffColor;
}
}

View File

@ -0,0 +1,55 @@
package com.xhai.iot.util;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.util.Log;
import com.xhai.iot.R;
/**
* 桌面快捷方式工具。
* <p>
* 涂鸦 SDK 会通过 INSTALL_SHORTCUT 广播自建快捷方式,故 Manifest 已用 tools:node="remove"
* 移除该权限。MumIot 图标改用 Android 8+ 官方 API
* {@link android.content.pm.ShortcutManager#requestPinShortcut} 请求固定到主屏幕,
* 会弹出系统确认框,无需任何权限。
*/
public class HomeShortcutHelper {
private static final String TAG = "HomeShortcutHelper";
private static final String SHORTCUT_ID = "mumiot_home";
/** 请求把 MumIot 固定到主屏幕(Android 8+,弹出系统确认框) */
public static void requestPinMumIot(Context context) {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// Android 8 以下无 requestPinShortcut,提示手动添加
Log.w(TAG, "Android 8 以下无法自动固定,请手动添加");
return;
}
android.content.pm.ShortcutManager sm =
context.getSystemService(android.content.pm.ShortcutManager.class);
if (sm == null || !sm.isRequestPinShortcutSupported()) {
Log.w(TAG, "系统不支持固定快捷方式,请手动添加");
return;
}
android.content.pm.ShortcutInfo info =
new android.content.pm.ShortcutInfo.Builder(context, SHORTCUT_ID)
.setShortLabel("MumIot")
.setLongLabel("MumIot")
.setIcon(Icon.createWithResource(context, R.mipmap.ic_launcher))
// 指向 LoginActivity(launcher 已导出),避免 MainActivity 未导出导致快捷方式闪退;
// LoginActivity 内部会自动处理登录态并跳转主界面
.setIntent(new Intent(context, com.xhai.iot.ui.LoginActivity.class)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER))
.build();
sm.requestPinShortcut(info, null);
Log.i(TAG, "已请求固定 MumIot 到主屏幕");
} catch (Exception e) {
Log.e(TAG, "请求固定快捷方式失败: " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,146 @@
package com.xhai.iot.util;
import android.content.Context;
import android.content.SharedPreferences;
import com.xhai.iot.data.ApiConfig;
/**
* 会话管理:保存 token / 用户信息
*/
public class SessionManager {
private static SessionManager instance;
private final SharedPreferences prefs;
private final Context appContext;
private SessionManager(Context context) {
appContext = context.getApplicationContext();
prefs = appContext
.getSharedPreferences(ApiConfig.PREF_NAME, Context.MODE_PRIVATE);
}
public static synchronized SessionManager getInstance(Context context) {
if (instance == null) {
if (context == null) {
throw new IllegalStateException(
"SessionManager 未初始化,首次调用必须传 Context");
}
instance = new SessionManager(context);
}
return instance;
}
/** 应用级 Context(供网络拦截器等无 Context 组件使用) */
public Context appContext() {
return appContext;
}
public void saveLogin(String token, String refreshToken, long memberId,
String username, String nickname) {
prefs.edit()
.putString(ApiConfig.KEY_TOKEN, token)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.putLong(ApiConfig.KEY_MEMBER_ID, memberId)
.putString(ApiConfig.KEY_USERNAME, username)
.putString(ApiConfig.KEY_NICKNAME, nickname)
.putBoolean(KEY_EXPIRED_HANDLED, false)
.apply();
}
/**
* 静默续期后只更新双 token(不动用户信息/家庭选择)
*/
public void updateTokens(String token, String refreshToken) {
prefs.edit()
.putString(ApiConfig.KEY_TOKEN, token)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.apply();
}
public String getRefreshToken() {
return prefs.getString(KEY_REFRESH_TOKEN, null);
}
public String getToken() {
return prefs.getString(ApiConfig.KEY_TOKEN, null);
}
/** 返回 "Bearer xxx" 格式的 Authorization 头 */
public String getBearerToken() {
String token = getToken();
if (token == null) return null;
return "Bearer " + token;
}
public long getMemberId() {
return prefs.getLong(ApiConfig.KEY_MEMBER_ID, 0);
}
public String getUsername() {
return prefs.getString(ApiConfig.KEY_USERNAME, "");
}
public String getNickname() {
return prefs.getString(ApiConfig.KEY_NICKNAME, "");
}
public boolean isLoggedIn() {
return getToken() != null;
}
public void setTuyaBound(boolean bound) {
prefs.edit().putBoolean(ApiConfig.KEY_TUYA_BOUND, bound).apply();
}
public boolean isTuyaBound() {
return prefs.getBoolean(ApiConfig.KEY_TUYA_BOUND, false);
}
/** 当前选中的家庭ID(0=未选择) */
public long getCurrentHomeId() {
return prefs.getLong("current_home_id", 0);
}
public void setCurrentHomeId(long homeId, String homeName) {
prefs.edit().putLong("current_home_id", homeId)
.putString("current_home_name", homeName)
.apply();
}
public String getCurrentHomeName() {
return prefs.getString("current_home_name", "");
}
public void logout() {
prefs.edit().clear().apply();
}
// ==================== token 过期统一处理 ====================
/** 401 已处理标志(防止并发多个 401 重复弹窗/跳转) */
private static final String KEY_EXPIRED_HANDLED = "expired_handled";
/** 刷新令牌存储键 */
private static final String KEY_REFRESH_TOKEN = "refresh_token";
/**
* token 过期统一处理:清除登录态(保留家庭选择与涂鸦绑定标记,
* 重新登录后可自动恢复),只执行一次。
*
* @return true=本次执行了清理(调用方应提示并跳转登录页)
*/
public boolean handleTokenExpired() {
if (prefs.getBoolean(KEY_EXPIRED_HANDLED, false)) {
return false; // 并发的其他 401 已处理过
}
prefs.edit()
.putBoolean(KEY_EXPIRED_HANDLED, true)
.remove(ApiConfig.KEY_TOKEN)
.remove(ApiConfig.KEY_MEMBER_ID)
.remove(ApiConfig.KEY_USERNAME)
.remove(ApiConfig.KEY_NICKNAME)
.apply();
return true;
}
}

View File

@ -0,0 +1,164 @@
package com.xhai.iot.util;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color;
import androidx.appcompat.app.AppCompatDelegate;
import com.xhai.iot.R;
/**
* 主题管理器(对应设计稿 AppearanceSheet / ThemePicker)
* - 主题色:8 种预设 + 自定义色(默认石墨 #6B7280)
* - 显示模式:浅色 / 深色 / 跟随系统(默认跟随系统)
* 持久化到 SharedPreferences,各页面通过 accent()/accentFg() 动态取色。
*/
public final class ThemeManager {
/** 主题色预设 */
public static class Accent {
public final String name;
public final int value;
public final int fg;
public Accent(String name, String value, String fg) {
this.name = name;
this.value = Color.parseColor(value);
this.fg = Color.parseColor(fg);
}
}
/** 8 种预设(与设计稿 ACCENT_PRESETS 一致,索引 7 = 默认石墨) */
public static final Accent[] PRESETS = {
new Accent("琥珀", "#F59E0B", "#18181A"),
new Accent("珊瑚", "#EF4444", "#FFFFFF"),
new Accent("玫瑰", "#EC4899", "#FFFFFF"),
new Accent("紫晶", "#8B5CF6", "#FFFFFF"),
new Accent("靛蓝", "#3B82F6", "#FFFFFF"),
new Accent("青碧", "#06B6D4", "#FFFFFF"),
new Accent("翠绿", "#22C55E", "#FFFFFF"),
new Accent("石墨", "#6B7280", "#FFFFFF"),
};
/** 显示模式 */
public static final String MODE_LIGHT = "light";
public static final String MODE_DARK = "dark";
public static final String MODE_SYSTEM = "system";
private static final String PREFS = "xhai_theme";
private static final String KEY_ACCENT = "accent_value";
private static final String KEY_ACCENT_NAME = "accent_name";
private static final String KEY_MODE = "theme_mode";
private static volatile ThemeManager sInstance;
private final SharedPreferences prefs;
private ThemeManager(Context context) {
prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
public static ThemeManager getInstance(Context context) {
if (sInstance == null) {
synchronized (ThemeManager.class) {
if (sInstance == null) {
sInstance = new ThemeManager(context);
}
}
}
return sInstance;
}
/** 当前主题色(默认靛蓝 #3B82F6) */
public Accent accent() {
String value = prefs.getString(KEY_ACCENT, "#3B82F6");
try {
String name = prefs.getString(KEY_ACCENT_NAME, "靛蓝");
// 命中预设:沿用预设的前景色(如琥珀用深字,保证可读)
for (Accent p : PRESETS) {
if (p.name.equals(name) && p.value == Color.parseColor(value)) {
return p;
}
}
// 自定义色:按亮度自动选择前景色(浅底深字 / 深底白字)
int v = Color.parseColor(value);
double lum = (0.299 * Color.red(v) + 0.587 * Color.green(v)
+ 0.114 * Color.blue(v)) / 255.0;
return new Accent(name, value, lum > 0.6 ? "#18181A" : "#FFFFFF");
} catch (Exception e) {
return PRESETS[4];
}
}
/** 设置主题色(预设或自定义,hex 如 #6B7280) */
public void setAccent(String name, String hexValue) {
try {
Color.parseColor(hexValue);
} catch (Exception e) {
return;
}
prefs.edit().putString(KEY_ACCENT_NAME, name)
.putString(KEY_ACCENT, hexValue)
.apply();
}
/** 当前显示模式(默认跟随系统) */
public String mode() {
return prefs.getString(KEY_MODE, MODE_SYSTEM);
}
/** 设置显示模式并立即生效 */
public void setMode(String mode) {
prefs.edit().putString(KEY_MODE, mode).apply();
applyNightMode();
}
/** 应用显示模式到全局 */
public void applyNightMode() {
String m = mode();
if (MODE_DARK.equals(m)) {
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_YES);
} else if (MODE_LIGHT.equals(m)) {
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_NO);
} else {
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
}
}
/** 主色带透明度的浅色背景(如 rgba(107,114,128,0.12)),用于图标底色 */
public int accentTintBg() {
int accent = accent().value;
int r = Color.red(accent), g = Color.green(accent), b = Color.blue(accent);
int a = Math.round(255 * 0.12f);
return Color.argb(a, r, g, b);
}
/** 主色选中态文字色(用于底色上) */
public int accentFgOnBg() {
return accent().fg;
}
/**
* 状态栏/导航栏适配页面背景:
* 颜色跟随浅色/深色模式,图标亮度自动反转(深色模式用浅色图标)。
* 在 Activity setContentView 后调用。
*/
public void applySystemBars(androidx.appcompat.app.AppCompatActivity activity) {
if (activity == null) return;
android.view.Window window = activity.getWindow();
int bg = activity.getColor(R.color.c_bg);
window.setStatusBarColor(bg);
window.setNavigationBarColor(bg);
int uiMode = activity.getResources().getConfiguration().uiMode
& android.content.res.Configuration.UI_MODE_NIGHT_MASK;
boolean night = uiMode == android.content.res.Configuration.UI_MODE_NIGHT_YES;
androidx.core.view.WindowCompat.getInsetsController(window, window.getDecorView())
.setAppearanceLightStatusBars(!night);
androidx.core.view.WindowCompat.getInsetsController(window, window.getDecorView())
.setAppearanceLightNavigationBars(!night);
}
}

View File

@ -0,0 +1,106 @@
package com.xhai.iot.util;
import android.content.Context;
import android.util.Log;
import androidx.annotation.Nullable;
import com.thingclips.smart.android.user.api.ILoginCallback;
import com.thingclips.smart.android.user.bean.User;
import com.thingclips.smart.home.sdk.ThingHomeSdk;
/**
* 涂鸦自动续登:涂鸦会话过期(refreshToken 失效)时,
* 用本地加密保存的凭据自动重新登录涂鸦 SDK,实现"绑定一次永久有效"。
*/
public class TuyaAutoRelogin {
private static final String TAG = "TuyaAutoRelogin";
private static final String COUNTRY_CODE = "86";
public interface Callback {
void onSuccess(User user);
void onError(String message);
}
/** 是否具备自动续登条件(本地有保存的凭据) */
public static boolean canAutoRelogin(Context context) {
return new TuyaCredentialStore(context).has();
}
/** 用保存的凭据自动重新登录涂鸦(先手机号,失败再邮箱) */
public static void relogin(Context context, Callback callback) {
TuyaCredentialStore store = new TuyaCredentialStore(context);
String account = store.getAccount();
String password = store.getPassword();
if (account == null || password == null) {
callback.onError("未保存涂鸦凭据,请手动绑定");
return;
}
Log.i(TAG, "涂鸦会话过期,自动续登: " + account);
loginWithPhone(account, password, new Callback() {
@Override
public void onSuccess(User user) {
Log.i(TAG, "自动续登成功");
callback.onSuccess(user);
}
@Override
public void onError(String message) {
// 手机号失败,尝试邮箱
loginWithEmail(account, password, new Callback() {
@Override
public void onSuccess(User user) {
Log.i(TAG, "自动续登成功(邮箱)");
callback.onSuccess(user);
}
@Override
public void onError(String msg2) {
Log.e(TAG, "自动续登失败: " + msg2);
callback.onError(msg2);
}
});
}
});
}
private static void loginWithPhone(String account, String password, Callback callback) {
try {
ThingHomeSdk.getUserInstance().loginWithPhonePassword(
COUNTRY_CODE, account, password, new ILoginCallback() {
@Override
public void onSuccess(User user) {
callback.onSuccess(user);
}
@Override
public void onError(String code, String error) {
callback.onError(error != null ? error : "手机号登录失败");
}
});
} catch (Exception e) {
callback.onError(e.getMessage());
}
}
private static void loginWithEmail(String account, String password, Callback callback) {
try {
ThingHomeSdk.getUserInstance().loginWithEmail(
COUNTRY_CODE, account, password, new ILoginCallback() {
@Override
public void onSuccess(User user) {
callback.onSuccess(user);
}
@Override
public void onError(String code, String error) {
callback.onError(error != null ? error : "邮箱登录失败");
}
});
} catch (Exception e) {
callback.onError(e.getMessage());
}
}
}

View File

@ -0,0 +1,103 @@
package com.xhai.iot.util;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Base64;
import android.util.Log;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* 涂鸦账号凭据加密存储。
* 首次绑定涂鸦时保存账号/密码(AES 加密),用于涂鸦会话过期时自动重新登录,
* 实现绑定一次永久有效的体验。
*/
public class TuyaCredentialStore {
private static final String TAG = "TuyaCredentialStore";
private static final String PREFS = "tuya_credential";
private static final String KEY_ACCOUNT = "account";
private static final String KEY_PASSWORD = "password";
/** 加密密钥种子(应用级,混淆后存放;升级可用 Android Keystore 增强) */
private static final byte[] SECRET = "MumIot@2026#Tuya".getBytes(StandardCharsets.UTF_8);
private final SharedPreferences prefs;
public TuyaCredentialStore(Context context) {
prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
/** 保存涂鸦凭据(AES 加密) */
public void save(String account, String password) {
try {
prefs.edit()
.putString(KEY_ACCOUNT, encrypt(account))
.putString(KEY_PASSWORD, encrypt(password))
.apply();
Log.i(TAG, "涂鸦凭据已保存");
} catch (Exception e) {
Log.e(TAG, "保存涂鸦凭据失败", e);
}
}
/** 读取涂鸦账号(未保存返回 null) */
public String getAccount() {
return decrypt(prefs.getString(KEY_ACCOUNT, null));
}
/** 读取涂鸦密码(未保存返回 null) */
public String getPassword() {
return decrypt(prefs.getString(KEY_PASSWORD, null));
}
/** 是否已保存凭据 */
public boolean has() {
return prefs.getString(KEY_ACCOUNT, null) != null
&& prefs.getString(KEY_PASSWORD, null) != null;
}
/** 清除凭据(解绑时调用) */
public void clear() {
prefs.edit().clear().apply();
}
/** AES/CBC/PKCS5Padding 加密(随机 IV,前置于密文) */
private String encrypt(String plain) throws Exception {
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(SECRET, "AES"),
new IvParameterSpec(iv));
byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + enc.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(enc, 0, out, iv.length, enc.length);
return Base64.encodeToString(out, Base64.NO_WRAP);
}
/** 解密 */
private String decrypt(String stored) {
if (stored == null || stored.isEmpty()) return null;
try {
byte[] all = Base64.decode(stored, Base64.NO_WRAP);
byte[] iv = new byte[16];
byte[] enc = new byte[all.length - 16];
System.arraycopy(all, 0, iv, 0, 16);
System.arraycopy(all, 16, enc, 0, enc.length);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(SECRET, "AES"),
new IvParameterSpec(iv));
return new String(cipher.doFinal(enc), StandardCharsets.UTF_8);
} catch (Exception e) {
Log.e(TAG, "解密涂鸦凭据失败", e);
return null;
}
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#3B82F6" android:state_checked="true" />
<item android:color="#909399" />
</selector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/c_card" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="@color/c_border" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/c_muted" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/c_muted" />
<corners android:radius="12dp" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/c_card" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="@color/c_border" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/c_primary" />
<corners android:radius="24dp" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- AI 按钮:主色到紫色 135 度渐变(设计稿 primary 至 a855f7) -->
<gradient
android:angle="315"
android:startColor="#3B82F6"
android:endColor="#A855F7" />
<corners android:radius="22dp" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F0F4FF" />
<corners android:radius="8dp" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<gradient
android:type="linear"
android:angle="45"
android:startColor="#5B8FF9"
android:centerColor="#9C5BF9"
android:endColor="#FF5BA9" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1989FA" />
<corners android:radius="24dp" />
</shape>

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