Author SHA1 Message Date
mazengfei 8fcbbb38f6 refactor(api): 优化餐盘柜接口路径和数据模型
- 删除密码登录相关代码和布局资源,简化登录流程
- 修改 ApiService 接口路径由旧项目旧路径切换为新 nutrition 模块路径
- 修改设备用户信息 EquipmentUserInfo 中 id 类型由 Long 改为 String,防止精度丢失
- 重构 RemoteRepository 中绑定和解绑接口,适配新参数结构
- 优化 UserViewModel 中获取用户信息接口,添加静默请求支持
- 改进 DiagnosticExporter,优先导出到 U 盘,不可用时回落到应用目录
- UsbStorageHelper 增强 U 盘识别算法,结合路径与文件系统类型双重校验
- 移除无用的 LoginParam 请求模型以及密码登录相关引用
- 调整网络请求相关的导入语句,清理多余依赖
- 修正设备初始化 ActiveKey 的使用方式,恢复为后台下发值
- BindPlateFragment 中绑定失败提示改为“绑定成功,开柜失败”
- OpsActivity 导出诊断包按钮弹窗改为 AlertDialog 显示结果信息
- SearchParam 默认分页参数改为 pageNum=1,pageSize=10
- 修改 SettingViewModel.unbindPlate 参数类型为 String,统一接口调用参数格式
2026-09-09 15:57:44 +08:00
mazengfei 8bcc5a0289 feat(ops): 增加运维面板及诊断导出功能
- 添加运维面板OpsActivity及对应布局,展示设备状态、网络、MQTT、人脸数据等信息
- 实现诊断包导出工具DiagnosticExporter,支持将设备信息和日志导出到U盘
- 新增设备健康信息获取工具DeviceInfoProvider,提供运行时长、内存和存储信息
- 实现运行时日志写入文件FileLoggingTree,支持按天滚动、切分与过期清理
- 增加日志文件管理LogFileManager,支持列出、读取尾部和清理日志文件
- 丰富MQTT管理器MqttManager,记录连接状态、错误、连接次数等详情并支持手动重连
- 为环境切换引入EnvironmentSwitcher,实现基础地址切换和清空本地人脸库后自动重启
- 扩展ArcFace人脸库接口,支持查询最近更新人脸及按userId精确搜索
- MainActivity增加运维面板入口,管理员列表页右上角双击时间触发打开
- 应用启动时种植FileLoggingTree,确保运行日志同时输出至文件和控制台
2026-09-02 17:47:24 +08:00
mazengfei 994bcf5bc2 feat(face): 优化人脸库更新同步及识别引擎刷新机制
- 引入进程级互斥锁 FaceSyncLock,串行化 MQTT 实时更新与 HTTP 增量轮询人脸库写操作,避免重复入库
- 优化 MQTT 实时消息处理逻辑,批内去重并加入乱序守卫,确保数据正确性与一致性
- 将识别引擎内存刷新调度防抖至 scheduleFaceRefresh,合并短时间内多次变更减少资源消耗
- 在 BaseActivity 中实现防抖刷新机制,统一 MQTT、HTTP增量和定时轮询三路触发入口
- 移除应用启动时人脸 MQTT 订阅初始化,改为首次全量同步完成后启动,防止并发竞态
- LoginByFaceActivity 和 DeviceInitActivity 适配新增机制,优化人脸数据同步及识别引擎刷新流程
2026-09-02 15:12:23 +08:00
mazengfei 1bd57e0680 feat(mqtt): 增加人脸库 MQTT 实时变更同步功能
- 新增 MQTT 客户端依赖及配置,实现设备端实时接收人脸变化推送
- 实现 FaceMqttSubscriber 单例,启动时解析环境选择对应 MQTT 配置
- 实现消息解析、乱序过滤及本地人脸库数据实时更新逻辑
- 设备端订阅人脸变更主题,连接/重连成功触发 HTTP 增量补拉兜底机制
- 在 LoginByFaceActivity 中订阅 MQTT 事件,实时刷新识别引擎内存
- MQTT 连接管理器 MqttManager 负责连接、重连、订阅及消息分发,实现完整生命周期管理
- 新增环境选择弹窗,优化初始化界面布局和按钮样式
- 配置激活码硬编码修正,避免测试环境激活码错误导致的问题
- 日志与错误提示等细节优化,提升稳定性与用户体验
2026-09-02 14:57:35 +08:00
mazengfei 04b0c85482 refactor(login): 优化人脸登录与设备初始化环境切换逻辑
- 移除DeviceInitActivity中的多点触控环境切换功能及相关依赖,简化代码结构
- 调整登录页连点触发环境切换逻辑,新增环境弹窗显示及倒计时暂停机制
- 实现登录页环境切换后自动重启应用,提升用户体验和状态一致性
- 修改NetViewModelV2分页大小从100至500,提高批量请求效率
- 注释MyApp中deviceId硬编码,恢复动态获取设备ID
- 清理UserViewModel中已废弃的人脸引擎激活代码,保持代码整洁
2026-09-02 11:11:34 +08:00
mazengfei 57345f8750 feat(environment): 新增环境切换功能及相关优化
- 新增业务环境选择弹窗 EnvironmentSelectDialog,支持本地/测试/生产环境切换
- 实现连点三次屏幕触发环境切换弹窗功能,便于快速切换接口环境
- 切换环境时清空本地人脸库并重置增量同步状态,保证环境切换的完整生效
- 业务全局 BaseUrl 支持从持久化读取,首次无值时默认测试环境
- Retrofit Client 接口 BaseUrl 优化,自动规范尾部斜杠,保证 BaseUrl 合法
- 设备初始化界面增强,增加等待框触摸穿透,优化接口请求失败时的提示信息
- 提升人脸缓存拉取接口分页大小,默认请求 500 条数据
- 修正登录界面布局细节,隐藏密码切换按钮,调整控件顺序及样式细节
2026-09-02 09:59:48 +08:00
mazengfeiandClaude Fable 5 d33f941d12 docs: 补充切环境后 ArcSoft 参数自动适配说明
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:29:53 +08:00
mazengfeiandClaude Fable 5 41cfec0583 docs: 新增业务 BaseUrl 切换设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:15:04 +08:00
mazengfei d53ea8f798 feat(api): 新增V2版本接口支持并集成至网络请求客户端
- 新增 ApiServiceV2 接口,支持人脸缓存、增量数据、设备配置等接口
- ApiClient 中新增 apiServiceV2 对象,复用 Retrofit 实例,调整超时时间至60秒
- DeviceInitActivity 改用 netViewModelV2 进行人脸缓存全量拉取及设备配置读取
- BaseActivity 新增对人脸增量同步接口调用,调用成功后刷新本地识别缓存
- BaseActivity 新增清空本地人脸库功能,异步清理数据库并刷新识别缓存
- FaceApi 增加清空本地人脸库接口,重写数据库操作逻辑
- 升级 lib_face 模块 Room 数据库版本,新增字段以支持多标识人脸实体扩展
- FaceEntity 实体扩展会员编号、用户ID、人脸ID、会员标识、更新时间等字段
- FaceDao 新增按人脸ID查询和删除接口,支持多重人脸数据操作
- 优化网络层 OkHttpClient 配置,简化拦截器写法及日志级别判断
- 升级 lib_face 模块支持 arm64-v8a 架构,增强兼容性
- 规范模块间依赖关系,统一版本管理及包路径声明
- 完善 CLAUDE.md 文档,补充项目架构、模块划分与开发流程说明
2026-08-18 17:14:06 +08:00
mazengfei 5b8db65fa2 优化 2026-03-02 16:35:36 +08:00
lvmeng 147fa08e9b 会员版修改包名 2026-03-02 15:23:52 +08:00
马增飞 582be4e9b7 绑定解绑功能调试;其它优化 2026-01-05 18:38:10 +08:00
马增飞 17d47a876f 增加配置接口;调试人脸识别;验证手机号密码登录和人脸登录功能; 2025-12-29 18:41:48 +08:00
马增飞 6c28260069 Merge remote-tracking branch 'refs/remotes/origin/main_新版_lvmeng_1222' into main_新版_lvmeng_1229 2025-12-29 09:57:05 +08:00
马增飞 7097bbf907 优化 2025-12-29 09:55:49 +08:00
lvmeng c046441d4e 优化 2025-12-26 14:16:01 +08:00
lvmeng 7a6c4012d1 餐盘柜接口改造 2025-12-26 14:05:41 +08:00
mazengfei 5e4dc4baa4 处理人脸数据不同步 2025-11-07 20:37:50 +08:00
mazengfei 4231c68cdc 添加方法 2025-11-07 15:18:15 +08:00
mazengfei 55ba11bf34 修改人脸同步逻辑 2025-11-07 10:55:48 +08:00
马增飞 abe594fb66 设备联调 2025-11-06 19:34:42 +08:00
lvmeng fb074c1aaa 修改开机初始化页面 2025-09-26 16:11:00 +08:00
lvmeng 73456046c5 增加初始化页面 2025-09-17 17:42:38 +08:00
lvmeng 7f56d4d1a1 修改待机也按钮名称 2025-09-17 15:45:00 +08:00
lvmeng 1658b6fe6f logo修改 2025-09-12 13:32:10 +08:00
lvmeng e9a366b52a 增加心跳处理人脸数据增减 2025-09-09 15:59:29 +08:00
lvmeng 3b492cb710 待机页面优化 2025-09-05 17:59:30 +08:00
lvmeng eaafeab1d8 增加余额不足判断 2025-09-05 15:29:43 +08:00
zxj 578a9aaced 添加注释 2025-08-22 16:28:20 +08:00
zxj cd375bfaf8 修复了登录成功后跳转界面错误问题 2025-08-21 15:50:05 +08:00
zxj 7a832ec40d 调整了初始化界面逻辑 2025-08-14 13:36:45 +08:00
zxj b83a442fe3 修改了配色,添加了解除绑定提示 2025-08-08 17:26:09 +08:00
zxj 2c10731b8d 修复了识别追踪信息功能 2025-08-08 10:50:11 +08:00
zxj 47f6e814a6 添加init界面,调整了认证后逻辑,调整了扫描枪扫描处理 2025-08-01 18:23:54 +08:00
zxj d20ee771b0 排序放入协程 2025-07-28 18:25:23 +08:00
zxj 6f18245c4f 密码登录后关闭密码界面 2025-07-28 18:05:06 +08:00
zxj 9a89fff9dd 工具类优化 2025-07-28 17:35:21 +08:00
zxj cc317117bc 修复了排序问题 2025-07-28 17:09:24 +08:00
zxj ca081f659b 初步实现功能 2025-07-28 15:47:55 +08:00
zxj 307fc70ae2 实现了垂直排序 2025-07-28 10:50:15 +08:00
zxj 8780f5d463 倒计时+1 2025-07-25 16:33:50 +08:00
zxj c0a628c51f 优化了键盘操作 2025-07-25 15:58:11 +08:00
zxj c2bae2f3e8 调整了餐盘柜3种情况处理 2025-07-25 13:35:17 +08:00
zxj c89efd2d6f 完善了功能 2025-07-25 10:36:30 +08:00
153 changed files with 7764 additions and 1139 deletions
Generated
+1
View File
@@ -0,0 +1 @@
SmartPlateCabinet
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>
+9 -1
View File
@@ -2,8 +2,16 @@
<project version="4"> <project version="4">
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="SmartPlateCabinet.app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-11-06T09:21:30.965681400Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=?;connection=3c39678a" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>
+1 -1
View File
@@ -6,7 +6,7 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="17" /> <option name="gradleJvm" value="jbr-21" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
+55
View File
@@ -0,0 +1,55 @@
# Repository Guidelines
本项目 **SmartPlateCabinet / SmartTakePlate(发盘机)** 是基于 Android 的智能餐盘柜控制系统,集成人脸识别(ArcFace SDK)与 PLC 硬件控制。本文档为贡献者提供统一规范。
## 项目结构与模块组织
多模块 Gradle 工程,模块定义于 `settings.gradle.kts`
- `app/` — 主应用模块,命名空间 `com.sw.platecabinet`applicationId `com.sw.take.plate`)。源码位于 `app/src/main/java/com/sw/platecabinet/`,按职责分包:`activity``fragment``viewmodel``repository``network``api`/`interceptor`/`task`)、`model``request`/`response`)、`socket``utils`PLC 实现在 `utils/mego`)、`adapter``dialog``view``receiver``ext`
- `lib_face/` — 人脸识别库模块(`com.sw.plate.utils.arcface`),封装 ArcFace SDK,含 `camera``face``facedb`Room 持久化)、`faceserver``viewmodel` 等。
- 依赖版本统一管理于 `gradle/libs.versions.toml`;本地 AAR 依赖置于 `app/libs/`(如 PLC SDK `plc_aar_104.aar`)。
- 测试目录:`app/src/test`(单元测试)、`app/src/androidTest`(仪器测试)。
## 构建、测试与开发命令
```bash
./gradlew assembleDebug # 编译 Debug APK
./gradlew installDebug # 安装到已连接设备
./gradlew clean # 清理构建产物
./gradlew :app:test # 运行单元测试
./gradlew :app:connectedAndroidTest # 运行仪器测试(需连接设备)
```
修改 Kotlin/Java 或资源后,须运行 `./gradlew assembleDebug` 验证编译通过。
## 编码规范与命名约定
- 语言:Kotlin 优先,Java 仅限历史代码维护;JVM target 11。
- 架构:MVVM。Activity 继承 `BaseActivity<VB>`Fragment 继承 `BaseFragment<VB>`ViewModel 继承 `BaseViewModel`Repository 继承 `BaseRepository`
- 命名:`XxxActivity` / `XxxFragment` / `XxxViewModel` / `XxxRepository`;工具类 `XxxUtil``XxxHelper`
- 异步:协程(`viewModelScope.launch`);PLC 操作走回调 `PlcCallback<T>`;网络请求在 ViewModel 内用 `launchRequest { }`
- 资源:布局 `activity_*` / `fragment_*`,字符串禁止硬编码到布局;依赖版本禁止硬编码于 `build.gradle.kts`,统一使用版本目录。
- 注释统一使用简体中文;标识符保持英文。
## 测试指南
框架:JUnit 4 + AndroidX TestEspresso)。业务逻辑优先在 `src/test` 编写单元测试;涉及 UI 或硬件交互的改动,须说明是否需仪器测试。测试类置于对应包路径下。
## 提交与分支约定
提交信息采用 **Conventional Commits(中文描述)**`<type>(<scope>): <中文描述>`。常见 type`feat``fix``refactor`scope 如 `login``plc``face``ui``build``api``base`。示例:
```
feat(plc): 添加餐盘平台复位功能和无餐盘提示界面
fix(login): 修复人脸识别登录界面返回时串口连接超时问题
```
分支:`main` 为基础分支;功能分支命名形如 `main_<功能>_<YYMMDD>`(如 `main_吐盘机_260701_5.0`)。合并请求需描述变更内容、关联问题;改动 UI 或硬件交互时附截图或验证说明。
## 配置与安全提示
- 签名:Debug 使用 `app/swkey.jks`(见 `app/build.gradle.kts`)。
- 明文 HTTP 流量已通过 `network_security_config.xml` 放行,请勿在受控环境外泄露配置。
- `app` 依赖 `lib_face` 时已排除其中的 `android-serialport`,由 app 自行引入,避免重复依赖。
- ABI 过滤:`armeabi-v7a``arm64-v8a`
+198
View File
@@ -0,0 +1,198 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
SmartTakePlate(发盘机)是一个基于 Android 的智能餐盘柜控制系统,集成了人脸识别和 PLC 硬件控制功能。
**应用ID**: com.sw.platecabinet.member(会员版;命名空间 com.sw.platecabinet
**最低SDK**: 24 (Android 7.0)
**目标SDK**: 35
## 构建与运行
### 构建项目
```bash
./gradlew assembleDebug
```
### 安装到设备
```bash
./gradlew installDebug
```
### 清理构建
```bash
./gradlew clean
```
**注意**: 构建需要 Java 17 环境(compileOptions 为 Java 11,但 Gradle 需 JDK 17)。直接使用系统默认 JDK 11 会报错,需使用 Android Studio 自带的 JBR 设置 `JAVA_HOME` 后再执行 gradlew。
## 项目架构
### 模块结构
- `app/` - 主应用模块(PLC控制、业务逻辑)
- `lib_face/` - 人脸识别库模块(ArcFace SDK封装)
### 架构模式
采用 MVVM 架构:
- **View**: Activity/Fragment(使用 ViewBinding
- **ViewModel**: 继承自 `BaseViewModel`,处理业务逻辑和状态管理
- **Repository**: `BaseRepository``RemoteRepository` 处理数据层
### 关键技术栈
- Kotlin 2.0.21 + Coroutines
- Retrofit 3.0.0 + OkHttp 4.12.0(网络请求)
- Room 2.2.5(人脸数据本地存储)
- CameraX 1.3.0(摄像头)
- EventBus 3.3.1(事件总线)
- PLC SDK (plc_aar_104.aar)(硬件控制)
- ArcSoft 人脸识别 SDK
## 核心功能模块
### 1. 串口硬件控制(柜锁)
**核心包**: `lib_face/com.sw.plate.utils.comn/`
关键类:
- `SerialApi` - 对外 API 门面(静态方法),串口路径 `/dev/ttyS2`、波特率 19200
- `init()` - 打开串口连接
- `openPlate(boxNumber, callback)` - 开柜(命令由 `CabinetLockCommand.generateOpenCommand` 生成)
- `close()` - 关闭串口
- `SerialPortManager` - 串口管理(发送命令、接收线程 `SerialReadThread`
- `CabinetLockCommand` - 柜锁命令生成
**调用方**: `BaseActivity``BindPlateFragment``SettingListFragment` 直接调用 `SerialApi`
**注意**: 所有串口操作都是异步的,通过 `SerialPortManager.SendCallback` 回调返回结果。
### 2. 人脸识别
**核心包**: `lib_face/com.sw.plate.utils.arcface/`
关键类:
- `FaceApi` - 人脸操作 API
- `RecognizeViewModel` - 人脸识别 ViewModel
- `FaceDatabase` - Room 数据库(存储人脸特征)
- `CameraHelper` / `DualCameraHelper` - 摄像头管理
人脸数据流程:
1. 从服务器获取用户人脸数据(`UserViewModel.getUserFaceList()`
2. 存储到本地 Room 数据库
3. 实时识别时与本地数据库对比
### 3. 网络层
**API 配置**: `GlobalData.appBaseUrl`(当前指向 `https://dev.yixiong-tech.com:8081`,切换环境直接修改该字段)
**API 响应格式**: `ApiResponse<T>`
- 成功: `code == "00000"`
- 失败: 检查 `msg` 字段
**网络客户端**: `ApiClient`Retrofit 单例)
**API 接口**: `ApiService`
### 4. 全局配置
**GlobalData** 包含关键配置:
- `arrayCross` = 2(横排数量)
- `arrayVertical` = 11(竖排数量)
- `arrayMode` = 0(0=竖直排列, 1=水平排列)
- ArcSoft SDK 配置:`appId`, `sdkKey`, `activeKey`
**SharedPreferences 键**: 见 `GlobalKey`
## 应用启动流程
1. `MyApp.onCreate()` - 初始化日志、崩溃处理、全局数据
2. `DeviceInitActivity` - 加载人脸缓存、获取设备配置
3. `LoginByFaceActivity` - 人脸识别登录
4. `MainActivity` - 主界面(Fragment 容器)
## 代码规范
### 命名约定
- Activity: `XxxActivity`
- Fragment: `XxxFragment`
- ViewModel: `XxxViewModel`
- Repository: `XxxRepository`
- 工具类: `XxxUtil``XxxHelper`
### 基类使用
- Activity 继承 `BaseActivity<VB>`VB 为 ViewBinding 类型)
- Fragment 继承 `BaseFragment<VB>`
- ViewModel 继承 `BaseViewModel`
- Repository 继承 `BaseRepository`
### 异步操作
- 使用 Kotlin Coroutines`viewModelScope.launch`
- PLC 操作使用回调(`PlcCallback<T>`
- 网络请求在 ViewModel 中调用 `launchRequest { }`
## 重要注意事项
### NDK 架构
项目仅支持 **armeabi-v7a**32位 ARM),不支持 arm64-v8a。这是因为 PLC SDK 和 ArcSoft SDK 的限制。
### 硬件依赖
- 需要串口通信支持(android-serialport
- 需要摄像头权限(人脸识别)
- 需要网络权限(API 调用)
### 调试配置
- 签名密钥: `swkey.jks`(密码: 123456
- 允许明文 HTTP 流量(`network_security_config.xml`
- ProGuard 混淆已禁用
### lib_face 依赖
app 模块依赖 lib_face 时,**排除了 android-serialport 依赖**,因为 app 模块自己引入了该库。
## 常用工具类
- `SpTool` - SharedPreferences 操作
- `Debouncer` - 防抖
- `QRCodeUtil` - 二维码生成
- `PermissionHelper` - 权限管理
- `FragmentHelper` - Fragment 管理
## Git 分支策略
- 主分支: `main`
- 功能分支命名形如 `main_<功能>_<YYMMDD>`(如 `main_吐盘机_260327`
## 最近开发重点
根据最近的提交记录,项目正在进行以下工作:
1. 会员版(已修改包名)
2. 绑定/解绑餐盘功能
3. 人脸识别登录(含手机号密码登录)
## API 调用示例
```kotlin
// 在 ViewModel 中
launchRequest(
request = { repository.someApiCall(params) },
onSuccess = { data ->
// 处理成功响应
},
onError = { code, msg ->
// 处理错误
}
)
```
## 串口开柜示例
```java
// 串口操作为异步回调(SerialPortManager.SendCallback
SerialApi.openPlate(boxNumber, new SerialPortManager.SendCallback() {
@Override
public void onSuccess() {
// 开柜命令发送成功
}
@Override
public void onFail(Exception e) {
// 开柜失败
}
});
```
+17 -7
View File
@@ -14,22 +14,22 @@ android {
keyPassword = "123456" keyPassword = "123456"
} }
} }
namespace = "com.sw.platecabinet" namespace = "com.sw.platecabinet.member"
compileSdk = 35 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.sw.platecabinet" applicationId = "com.sw.platecabinet.member"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// ndk { ndk {
// abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/)) abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/))
// } }
setProperty("archivesBaseName", "智能餐盘柜_${versionName}") setProperty("archivesBaseName", "zncpg_${versionName}")
} }
buildTypes { buildTypes {
@@ -93,4 +93,14 @@ dependencies {
implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.fragment.ktx)
// implementation(libs.adapter.coroutines) // implementation(libs.adapter.coroutines)
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation(libs.core)
implementation(libs.android.core)
implementation("org.greenrobot:eventbus:3.3.1")
// MQTT 客户端(人脸变更实时推送)
implementation(libs.paho.mqtt)
} }
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.sw.take.plate",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.1",
"outputFile": "zncpg_1.1-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/zncpg_1.1-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/zncpg_1.1-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
+31 -22
View File
@@ -14,33 +14,45 @@
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name=".MyApp" android:name="com.sw.platecabinet.MyApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_logo512"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_logo512"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SmartPlateCabinet" android:theme="@style/Theme.SmartPlateCabinet"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<!-- 注册BootReceiver,监听开机完成广播 -->
<receiver
android:name="com.sw.platecabinet.receiver.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity <activity
android:name=".activity.TestActivity" android:name="com.sw.platecabinet.activity.UnBindDialogActivity"
android:exported="true"></activity> android:exported="false"
android:theme="@style/DialogActivity" />
<activity <activity
android:name=".activity.LoginByPwdActivity" android:name="com.sw.platecabinet.activity.InitActivity"
android:exported="true" android:exported="false"
android:launchMode="singleTask"> android:launchMode="singleTask">
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.MAIN" />-->
<!-- <category android:name="android.intent.category.LAUNCHER" />-->
<!-- </intent-filter>-->
</activity> </activity>
<activity <activity
android:name=".activity.LoginByFaceActivity" android:name="com.sw.platecabinet.activity.LoginByFaceActivity"
android:exported="true"
android:launchMode="singleTask"/>
<activity
android:name="com.sw.platecabinet.activity.DeviceInitActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
@@ -50,15 +62,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".activity.MainActivity" android:name="com.sw.platecabinet.activity.MainActivity"
android:exported="true"> android:exported="false">
</activity>
<!-- <intent-filter> --> <activity
<!-- <action android:name="android.intent.action.MAIN" /> --> android:name="com.sw.platecabinet.activity.OpsActivity"
android:exported="false">
<!-- <category android:name="android.intent.category.LAUNCHER" /> -->
<!-- </intent-filter> -->
</activity> </activity>
</application> </application>
@@ -4,7 +4,8 @@ object GlobalData {
/** /**
* 同一设备全局使用的设备编号 * 同一设备全局使用的设备编号
*/ */
var globalEquipmentCode: String = "202501171634" // var globalEquipmentCode: String = "202501171634"
var globalEquipmentCode: String = "202507231144"
/** /**
* app版本号 * app版本号
@@ -14,7 +15,7 @@ object GlobalData {
/** /**
* 横排数量 * 横排数量
*/ */
var arrayCross: Int = 3 var arrayCross: Int = 2
/** /**
* 竖排数量 * 竖排数量
@@ -26,14 +27,66 @@ object GlobalData {
*/ */
var arrayMode: Int = 0 var arrayMode: Int = 0
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
var activeKey = "85Q1-1216-X3DH-QYTJ"//"085F-118G-Q3J6-35UX" //"085F-118G-Q391-53YL"
/**
* 具体业务baseurl
*/
var appBaseUrl: String = ""
/**
* 具体业务 BaseUrl
*/
const val LOCAL_BASE_URL: String = "http://192.168.10.101:24801"//开发环境
const val TEST_BASE_URL: String = "https://dev.yixiong-tech.com:8081"//测试环境
const val PROD_BASE_URL: String = "https://platform-api.uat.shuziweidao.com"//生产环境
/**
* 设备id
*/
var deviceId: String = ""
var restId: String = ""
} }
/** /**
* * 全局常量
*/
object Constants {
/**
* 自动关闭时间
*/
const val AUTO_CLOSE_TIME: Long = 3
}
/**
* key
*/ */
object GlobalKey { object GlobalKey {
const val KEY_EQUIPMENT_INFO = "equipmentInfo"
const val KEY_TOKEN = "tokenKey" const val KEY_TOKEN = "tokenKey"
const val KEY_FIRST_RUN = "firstRun" const val KEY_FIRST_RUN = "firstRun"
const val KEY_USER_INFO = "userInfoKey" const val KEY_USER_INFO = "userInfoKey"
const val PARAM_EQUIPMENT_INFO = "equipmentUserInfo"
const val KEY_BASE_URL = "baseUrlKey"
} }
/**
* 业务环境配置项
* @param name 环境名称(用于弹窗展示)
* @param url 环境 BaseUrl(不带尾斜杠)
*/
data class Environment(
val name: String,
val url: String
)
/**
* 预设环境列表(本地 / 测试 / 生产)
*/
val ENVIRONMENTS: List<Environment> = listOf(
Environment("本地", GlobalData.LOCAL_BASE_URL),
Environment("测试", GlobalData.TEST_BASE_URL),
Environment("生产", GlobalData.PROD_BASE_URL)
)
+22 -1
View File
@@ -1,7 +1,11 @@
package com.sw.platecabinet package com.sw.platecabinet
import android.util.Log
import com.sw.plate.App import com.sw.plate.App
import com.sw.plate.utils.AppUtil import com.sw.plate.utils.AppUtil
import com.sw.platecabinet.utils.CrashHandler
import com.sw.platecabinet.utils.FileLoggingTree
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber import timber.log.Timber
class MyApp : App() { class MyApp : App() {
@@ -12,15 +16,32 @@ class MyApp : App() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
// 运行时日志落盘(供运维页查看/导出),与 DebugTree 并存
Timber.plant(FileLoggingTree(this))
Timber.d("初始化") Timber.d("初始化")
initGlobalData() initGlobalData()
// 初始化崩溃处理器
CrashHandler.init(this)
} }
/** /**
* 初始化全局数据 * 初始化全局数据
*/ */
private fun initGlobalData() { private fun initGlobalData() {
GlobalData.appVersion = AppUtil.getAppVersionName(this) var deviceId = AppUtil.getUDID(this)
// deviceId = "be154831-3466-3ba2-a2ea-57652c919fed"
// deviceId="2987f0c5-5754-33e9-b00a-251db5e2e55f"
Log.d("MyApp", "initialize: deviceId=$deviceId")
GlobalData.deviceId = deviceId
GlobalData.appVersion = AppUtil.getAppVersionCode(this).toString()
GlobalData.globalEquipmentCode = "202501171634" GlobalData.globalEquipmentCode = "202501171634"
// 从持久化读取业务 BaseUrl,首次无值兜底测试环境
GlobalData.appBaseUrl = SpTool.getBaseUrl().ifBlank { GlobalData.TEST_BASE_URL }
Timber.d("initGlobalData appBaseUrl=${GlobalData.appBaseUrl}")
// GlobalData.appBaseUrl = "http://192.168.1.201:14801"
} }
} }
@@ -4,6 +4,8 @@ import android.app.Dialog
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.TextUtils import android.text.TextUtils
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
@@ -13,24 +15,33 @@ import android.widget.TextView
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.sw.inbound.utils.DateTimeUtils import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.ScanGunKeyEventHelper import com.sw.plate.utils.ScanGunKeyEventHelper
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import com.sw.plate.utils.comn.SerialApi import com.sw.plate.utils.comn.SerialApi
import com.sw.plate.utils.comn.SerialPortManager import com.sw.plate.utils.comn.SerialPortManager
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.dialog.BalanceNotEnoughDialog
import com.sw.platecabinet.ext.setClickListeners import com.sw.platecabinet.ext.setClickListeners
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.utils.IntervalExecutor
import com.sw.platecabinet.utils.PermissionHelper import com.sw.platecabinet.utils.PermissionHelper
import com.sw.platecabinet.view.CustomDialog import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.view.CustomLoadingDialog
import com.sw.platecabinet.viewmodel.NetViewModelV2
import com.sw.platecabinet.viewmodel.SettingViewModel import com.sw.platecabinet.viewmodel.SettingViewModel
import com.sw.platecabinet.viewmodel.UserViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
/** /**
@@ -41,18 +52,29 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
private var headerBinding: ItemTitleTimeBinding? = null private var headerBinding: ItemTitleTimeBinding? = null
protected lateinit var context: Context protected lateinit var context: Context
private var timeJob: Job? = null private var timeJob: Job? = null
private var mDialogWaiting: CustomDialog? = null private var mDialogWaiting: CustomLoadingDialog? = null
private val permissionHelpers = mutableMapOf<Int, PermissionHelper>() private val permissionHelpers = mutableMapOf<Int, PermissionHelper>()
protected var keyEventHelper: ScanGunKeyEventHelper? = null protected var keyEventHelper: ScanGunKeyEventHelper? = null
private val viewModel by viewModels<SettingViewModel>()
// 管理员对应的viewmodel
private val settingViewModel by viewModels<SettingViewModel>()
// 用户对应的viewModel
protected val viewModel by viewModels<UserViewModel>()
// V2 网络请求 viewModel(人脸缓存/增量同步/设备配置)
val netViewModelV2 by viewModels<NetViewModelV2>()
private var startTime: Long = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
startTime = System.currentTimeMillis()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
context = this context = this
// disableSystemUICompletely() disableSystemUICompletely()
// 确保内容延伸到导航栏区域 // 确保内容延伸到导航栏区域
// WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
//保持亮屏 //保持亮屏
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -64,6 +86,8 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
registerDataChange() registerDataChange()
initialize() initialize()
registerKeyEvent() registerKeyEvent()
val durationTime = System.currentTimeMillis() - startTime
Timber.d("启动时间:$durationTime")
} }
/** /**
@@ -84,7 +108,7 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
* 处理扫描枪数据 * 处理扫描枪数据
*/ */
protected open fun handleScanKeyInfo(scanInfo: String) { protected open fun handleScanKeyInfo(scanInfo: String) {
viewModel.findByPlateNumber(plateNumber = scanInfo) settingViewModel.findByPlateNumber(plateNumber = scanInfo)
} }
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -168,7 +192,15 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
enforceImmersiveMode() enforceImmersiveMode()
} }
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
disableSystemUICompletely()
}
}
private fun disableSystemUICompletely() { private fun disableSystemUICompletely() {
Timber.d("disableSystemUICompletely")
// 禁用系统手势(Android 10+ // 禁用系统手势(Android 10+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.systemBarsBehavior = window.insetsController?.systemBarsBehavior =
@@ -193,6 +225,7 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
private fun enforceImmersiveMode() { private fun enforceImmersiveMode() {
Timber.d("enforceImmersiveMode")
// 持续强制隐藏系统栏(防止手势触发) // 持续强制隐藏系统栏(防止手势触发)
window.decorView.postDelayed({ window.decorView.postDelayed({
window.decorView.systemUiVisibility = ( window.decorView.systemUiVisibility = (
@@ -210,7 +243,7 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
hideWaitingDialog() hideWaitingDialog()
val view = View.inflate(this, R.layout.dialog_waiting, null) val view = View.inflate(this, R.layout.dialog_waiting, null)
if (!TextUtils.isEmpty(tip)) (view.findViewById<View?>(R.id.tvTip) as TextView).setText(tip) if (!TextUtils.isEmpty(tip)) (view.findViewById<View?>(R.id.tvTip) as TextView).setText(tip)
mDialogWaiting = CustomDialog(this, view, R.style.MyDialog) mDialogWaiting = CustomLoadingDialog(this, view, R.style.MyDialog)
mDialogWaiting!!.show() mDialogWaiting!!.show()
mDialogWaiting!!.setCancelable(true) mDialogWaiting!!.setCancelable(true)
return mDialogWaiting return mDialogWaiting
@@ -234,8 +267,10 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
* 注册数据监听 * 注册数据监听
*/ */
protected open fun registerDataChange() { protected open fun registerDataChange() {
Timber.d("registerDataChange")
lifecycleScope.launch { lifecycleScope.launch {
viewModel.showLoading.collect { viewModel.showLoading.collect {
Timber.d("registerDataChange 用户 showLoading = $it")
if (it) { if (it) {
showWaitingDialog("") showWaitingDialog("")
} else { } else {
@@ -244,34 +279,44 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
} }
} }
lifecycleScope.launch { lifecycleScope.launch {
viewModel.currentUserInfo.drop(1).collect { viewModel.currentUserInfo.collect {
if (it == null) return@collect if (it == null) return@collect
if (it.equipmentBoxCode?.isNotEmpty() == true) { it.showBalanceNotEnoughDialog = true
SerialApi.openPlate( handleLoginSuccess(it, false)
it.equipmentBoxCode.toInt(),
object : SerialPortManager.SendCallback {
override fun onSuccess() {
MainActivity.start(context, pageType = PageType.PLATE_OPEN)
} }
override fun onFail(e: Exception?) {
ToastUtils.showToast("柜门打开失败")
} }
}) lifecycleScope.launch {
settingViewModel.showLoading.collect {
Timber.d("registerDataChange 管理员 showLoading = $it")
if (it) {
showWaitingDialog("")
} else { } else {
viewModel.getEquipmentList() hideWaitingDialog()
} }
} }
} }
lifecycleScope.launch { lifecycleScope.launch {
viewModel.equipmentList.drop(1).collect { settingViewModel.searchUserInfo.collect {
if (it.isEmpty()) return@collect Timber.d("registerDataChange 管理员 currentUserInfo = $it")
val unbindList = it.filter { !it.isBound() } if (it == null) return@collect
handleLoginSuccess(it, true)
}
}
// lifecycleScope.launch {
// settingViewModel.equipmentList.collect {
// loadEquipmentList(it)
// }
// }
}
private fun loadEquipmentList(items: List<EquipmentUserInfo>) {
if (items.isEmpty()) return
val unbindList = items.filter { !it.isBound() }
if (unbindList.isEmpty()) { if (unbindList.isEmpty()) {
MainActivity.start(context = context, pageType = PageType.PLATE_CABINET_FULL) MainActivity.start(context = context, pageType = PageType.PLATE_CABINET_FULL)
} else { } else {
val firstInfo = unbindList[0] val firstInfo = unbindList[0]
val currentUserInfo = viewModel.currentUserInfo.value val currentUserInfo = settingViewModel.searchUserInfo.value
firstInfo.plateNumber = currentUserInfo?.plateNumber ?: "" firstInfo.plateNumber = currentUserInfo?.plateNumber ?: ""
MainActivity.start( MainActivity.start(
context = context, context = context,
@@ -280,6 +325,62 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
) )
} }
} }
/**
* 处理登录成功操作
* @param isAdmin true 管理员 执行绑盘此操作 false 用户,提示错误
*/
open fun handleLoginSuccess(equipmentUserInfo: EquipmentUserInfo, isAdmin: Boolean) {
Timber.d("handleLoginSuccess isAdmin = $isAdmin")
equipmentUserInfo.isIntercept = false
val cardBalance = equipmentUserInfo.cardBalance ?: 0.toDouble()
val balanceIsNotEnough = cardBalance <= 0.toDouble()
if (balanceIsNotEnough && equipmentUserInfo.showBalanceNotEnoughDialog) {
//提示余额不足弹窗
equipmentUserInfo.isIntercept = true
BalanceNotEnoughDialog(this).show()
return
}
viewModel.resetUserInfo()
// if (equipmentUserInfo.isOtherEquipment()) {
// MainActivity.start(
// context,
// pageType = PageType.PLATE_TIP,
// equipmentUserInfo = equipmentUserInfo,
// isAdmin
// )
// return
// }
if (equipmentUserInfo.equipmentBoxCode?.isNotEmpty() == true) {
SerialApi.openPlate(
equipmentUserInfo.equipmentBoxCode!!.toInt(),
object : SerialPortManager.SendCallback {
override fun onSuccess() {
MainActivity.start(
context,
pageType = PageType.PLATE_TIP,
isAdmin = isAdmin
)
}
override fun onFail(e: Exception?) {
ToastUtils.showToast("柜门打开失败")
}
})
} else {
if (isAdmin) {
settingViewModel.getEquipmentList {
loadEquipmentList(it)
}
} else {
Timber.d("无餐盘信息")
MainActivity.start(
context,
pageType = PageType.PLATE_TIP,
equipmentUserInfo = equipmentUserInfo,
isAdmin = isAdmin
)
}
} }
} }
@@ -288,4 +389,65 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
keyEventHelper?.onDestroy() keyEventHelper?.onDestroy()
super.onDestroy() super.onDestroy()
} }
private val intervalExecutor by lazy { IntervalExecutor() }
private var faceTaskJob: Job? = null
// private val initialDelay = 5 * 60 * 1000L
// private val dealyMillis = 10 * 60 * 1000L
private val initialDelay = 5 * 60 * 1000L
private val dealyMillis = 5 * 60 * 1000L
val recognizeViewModel by viewModels<RecognizeViewModel>()
/**
* 引擎内存刷新防抖:合并短时间内连续的人脸变更(MQTT 实时 + HTTP 增量补拉 + 定时轮询),
* 避免多次全量重载 ArcSoft 引擎内存(removeFaceFeature(-1) + registerFaceFeature 非原子)。
*/
private val faceRefreshHandler = Handler(Looper.getMainLooper())
private val faceRefreshRunnable = Runnable { recognizeViewModel.refreshFaceList() }
/** 防抖调度引擎内存刷新(2s 内连续变更合并为一次) */
protected fun scheduleFaceRefresh() {
faceRefreshHandler.removeCallbacks(faceRefreshRunnable)
faceRefreshHandler.postDelayed(faceRefreshRunnable, 2000)
}
fun startFaceTask() {
faceTaskJob =
intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) {
getFaceIncrementList()
}
}
/**
* 人脸增量同步(V2,同步逻辑在 NetViewModelV2 内部处理)
*/
private fun getFaceIncrementList(pageNo: Int = 1) {
val timestamp = SpTool.getLastFaceTimestamp()
if (timestamp == 0L) {
return
}
netViewModelV2.getFaceIncrementList(
pageNo = pageNo,
timestamp = timestamp
) {
scheduleFaceRefresh()
}
}
/**
* 清空本地人脸库
*/
fun clearAllFace(block: () -> Unit) {
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@BaseActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
recognizeViewModel.refreshFaceList()
withContext(Dispatchers.Main) { block() }
}
}
} }
@@ -0,0 +1,92 @@
package com.sw.platecabinet.activity
import android.content.Intent
import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.member.databinding.ActivityDeviceInitBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.mqtt.FaceMqttSubscriber
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber
/**
* 设备初始化界面
*/
class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
override fun inflateViewBinding(): ActivityDeviceInitBinding {
return ActivityDeviceInitBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding? {
return null
}
override fun initialize() {
showWaitingDialog("加载中……")
// 首次启动先全量拉取人脸缓存,后续走增量同步
if (SpTool.getFirstGetFace()) {
netViewModelV2.getUserFaceCache { status, msg ->
SpTool.setFirstGetFace(false)
getDeviceConfig()
}
} else {
getDeviceConfig()
}
}
/**
* 获取设备配置(V2),并初始化虹软 SDK 激活参数
*/
private fun getDeviceConfig() {
netViewModelV2.getDeviceConfig(onSuccess = { deviceConfig ->
runOnUiThread {
if (deviceConfig == null) {
hideWaitingDialog()
ToastUtils.showToast("获取设备配置失败,请到登录页连点 3 次切换环境")
goLoginActivity()
return@runOnUiThread
}
GlobalData.appId = deviceConfig.arcsoftAppId ?: ""
GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: ""
// 测试设备后台下发的激活码不正确,手动硬编码覆盖为正确值(后台修复后可回退为下行)
GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: ""
// GlobalData.activeKey = "085F-118G-Q4V1-THBP"
hideWaitingDialog()
goLoginActivity()
}
}, onFailure = { errMsg ->
runOnUiThread {
hideWaitingDialog()
Timber.e("getDeviceConfig onFailure: $errMsg")
ToastUtils.showToast("服务连接失败,请到登录页连点 3 次切换环境")
goLoginActivity()
}
})
}
override fun registerDataChange() {
super.registerDataChange()
// lifecycleScope.launch {
// deviceViewModel.deviceInfoResult.collect { it ->
// if (it == true) {
// goLoginActivity()
// }
// }
// }
}
private fun goLoginActivity() {
// 首次全量同步(如需)已完成,此时启动人脸 MQTT 实时订阅,
// 避免与首次全量同步的 clearFaceData 产生并发写竞态
FaceMqttSubscriber.start()
val intent = Intent(this, LoginByFaceActivity::class.java)
startActivity(intent)
finish()
}
override fun onLeftDoubleClick() {
}
}
@@ -0,0 +1,42 @@
package com.sw.platecabinet.activity
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.platecabinet.member.databinding.ActivityInitBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* 待机界面
*/
class InitActivity : BaseActivity<ActivityInitBinding>() {
override fun inflateViewBinding(): ActivityInitBinding {
return ActivityInitBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding? {
return binding.includeHeader
}
override fun initialize() {
binding.main.setOnClickListener {
finish()
}
// binding.takeButton.setOnClickListener {
//
// lifecycleScope.launch(Dispatchers.IO) {
// FaceApi().deleteByUserName("288")
// }
//
// finish()
// }
}
override fun onLeftDoubleClick() {
finish()
}
}
@@ -5,6 +5,7 @@ import android.content.Intent
import android.graphics.Point import android.graphics.Point
import android.hardware.Camera import android.hardware.Camera
import android.os.Build import android.os.Build
import android.os.CountDownTimer
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -12,7 +13,6 @@ import android.view.ViewTreeObserver
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import com.arcsoft.face.ErrorInfo import com.arcsoft.face.ErrorInfo
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.ConfigUtil import com.sw.plate.utils.arcface.ConfigUtil
@@ -24,25 +24,55 @@ import com.sw.plate.utils.arcface.PreviewConfig
import com.sw.plate.utils.arcface.camera.CameraListener import com.sw.plate.utils.arcface.camera.CameraListener
import com.sw.plate.utils.arcface.camera.DualCameraHelper import com.sw.plate.utils.arcface.camera.DualCameraHelper
import com.sw.plate.utils.arcface.face.constants.LivenessType import com.sw.plate.utils.arcface.face.constants.LivenessType
import com.sw.plate.utils.arcface.face.model.CompareResult
import com.sw.plate.utils.arcface.face.model.FacePreviewInfo import com.sw.plate.utils.arcface.face.model.FacePreviewInfo
import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration
import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import com.sw.platecabinet.R import com.sw.platecabinet.utils.Debouncer
import com.sw.platecabinet.databinding.ActivityLoginFaceBinding import com.sw.platecabinet.member.R
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ActivityLoginFaceBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.mqtt.FaceChangedEvent
import com.sw.platecabinet.mqtt.FaceSyncTriggerEvent
import com.sw.platecabinet.network.task.TaskManager
import com.sw.platecabinet.utils.PermissionHelper import com.sw.platecabinet.utils.PermissionHelper
import com.sw.platecabinet.viewmodel.UserViewModel import org.greenrobot.eventbus.EventBus
import kotlinx.coroutines.flow.drop import org.greenrobot.eventbus.Subscribe
import kotlinx.coroutines.launch import org.greenrobot.eventbus.ThreadMode
import timber.log.Timber import timber.log.Timber
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.view.MotionEvent
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* 人脸识别 * 人脸识别
*/ */
class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(), class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
ViewTreeObserver.OnGlobalLayoutListener { ViewTreeObserver.OnGlobalLayoutListener {
private val viewModel by viewModels<UserViewModel>() private var countDownTimer: CountDownTimer? = null
private val recognizeViewModel by viewModels<RecognizeViewModel>()
// 连点切换环境计数与复位
private var tapCount = 0
private var switchedEnvironment = false
private val tapHandler = Handler(Looper.getMainLooper())
private val resetTapRunnable = Runnable { tapCount = 0 }
private val CAMERA_PERMISSION_REQUEST_CODE = 100 private val CAMERA_PERMISSION_REQUEST_CODE = 100
private val REQUIRED_PERMISSIONS: Array<String> = arrayOf( private val REQUIRED_PERMISSIONS: Array<String> = arrayOf(
@@ -59,31 +89,32 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
return binding.includeHeader return binding.includeHeader
} }
companion object {
private var instance: LoginByFaceActivity? = null
fun goInitActivity() {
instance?.goInitActivity()
}
}
override fun initialize() { override fun initialize() {
viewModel.activeEngine() instance = this
// V2 引擎激活(appId/sdkKey/activeKey 来自 V2 设备配置)
netViewModelV2.activeEngine()
initCountTime()
initArcViewModel() initArcViewModel()
initArcView() initArcView()
openRectInfoDraw = true openRectInfoDraw = true
recognizeViewModel.setDrawRectInfoTextValue(true) recognizeViewModel.setDrawRectInfoTextValue(true)
viewModel.generateToken()
binding.llToPwd.setOnClickListener {
val intent = Intent(this, LoginByPwdActivity::class.java)
startActivity(intent)
}
}
override fun registerDataChange() { //开启人脸增量数据定时任务(V2
super.registerDataChange() startFaceTask()
lifecycleScope.launch {
viewModel.currentUserInfo.drop(1).collect { // TODO: 测试请求
Timber.d("currentUserInfo it = $it") TaskManager.startTask()
if (it != null) {
ToastUtils.showToast("登录成功") EventBus.getDefault().register(this)
MainActivity.start(context, pageType = PageType.PLATE_CABINET_FULL)
finish()
}
}
}
} }
private fun checkCameraPermission() { private fun checkCameraPermission() {
@@ -102,6 +133,43 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
permissionHelper.checkAndRequest() permissionHelper.checkAndRequest()
} }
@Subscribe(threadMode = ThreadMode.MAIN)
fun getFaceEntity(insertEntity: FaceEntity) {
Timber.tag("performSync").e("-time=%s", insertEntity.registerTime)
recognizeViewModel.addFace(insertEntity)
Timber.tag("performSync over").e("-time=%s", insertEntity.registerTime)
}
/**
* MQTT 实时同步后的人脸库已落库,防抖重载识别引擎内存(合并短时间内连续变更)。
* scheduleFaceRefresh 定义于 BaseActivity,统一 MQTT/HTTP 增量/定时轮询三路刷新入口。
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onFaceChanged(event: FaceChangedEvent) {
Timber.d("onFaceChanged 收到人脸实时变更,2s 后刷新引擎内存")
scheduleFaceRefresh()
}
/**
* MQTT 连接/重连成功,补拉一次 HTTP 增量兜底(设备离线期间漏收的变更)
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onFaceSyncTrigger(event: FaceSyncTriggerEvent) {
val timestamp = SpTool.getLastFaceTimestamp()
if (timestamp == 0L) {
Timber.d("onFaceSyncTrigger 水位为 0,跳过增量补拉")
return
}
Timber.d("onFaceSyncTrigger MQTT 已连接,执行 HTTP 增量补拉 timestamp=$timestamp")
netViewModelV2.getFaceIncrementList(pageNo = 1, timestamp = timestamp) {
scheduleFaceRefresh()
}
}
override fun onLeftDoubleClick() {
finish()
}
private var isRecognition = false private var isRecognition = false
private var rgbCameraHelper: DualCameraHelper? = null private var rgbCameraHelper: DualCameraHelper? = null
private var rgbFaceRectTransformer: FaceRectTransformer? = null private var rgbFaceRectTransformer: FaceRectTransformer? = null
@@ -151,9 +219,12 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
Timber.i("recognizeNotice observe notice = $notice") Timber.i("recognizeNotice observe notice = $notice")
}) })
recognizeViewModel.recognizeUserId.observe(this, Observer { userId: String? -> recognizeViewModel.recognizeUserId.observe(this, Observer { result: CompareResult? ->
Timber.i("recognizeUserId observe userId = $userId") Timber.i(
viewModel.getUserInfoById(memberId = userId?.toInt() ?: 0) "recognizeUserId observe userId=${result?.faceEntity?.userName}," +
"similar=${result?.similar},userType=${result?.faceEntity?.userType}"
)
loadFaceRecognizeResult(result)
}) })
recognizeViewModel.drawRectInfoText.observe(this, Observer { info -> recognizeViewModel.drawRectInfoText.observe(this, Observer { info ->
@@ -167,7 +238,50 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
recognizeViewModel.getCompareResultList().getValue() recognizeViewModel.getCompareResultList().getValue()
} }
/**
* 识别阈值,默认值为0.8
*/
private val faceSuccessThreshold by lazy { ConfigUtil.getRecognizeThreshold(this) }
/**
* 识别成功后开柜(查询用户信息)防抖,防止重复触发
*/
private val loginDebouncer = Debouncer(3000)
/**
* 处理识别结果:达到阈值走登录流程,未达阈值仅记录(本版本无采集模式)
*/
private fun loadFaceRecognizeResult(result: CompareResult?) {
val similar = result?.similar ?: return
Timber.i(
"loadFaceRecognizeResult 阈值=$faceSuccessThreshold, similar=$similar"
)
if (similar >= faceSuccessThreshold) {
val userId = result.faceEntity?.userName
recognizeSuccess(userId)
return
}
// 未达阈值:暂不处理(无采集模式),等待下一次识别
}
/**
* 识别成功:防抖后查询用户信息,走开柜门业务流程
*/
private fun recognizeSuccess(userId: String?) {
if (userId.isNullOrBlank()) {
Timber.i("recognizeSuccess 识别成功但 userId 为空,忽略")
return
}
Timber.i("recognizeSuccess userId=$userId")
loginDebouncer.debounce {
viewModel.getUserInfoById(memberId = userId)
}
}
override fun onDestroy() { override fun onDestroy() {
Timber.d("onDestroy")
instance = null
if (rgbCameraHelper != null) { if (rgbCameraHelper != null) {
rgbCameraHelper!!.release() rgbCameraHelper!!.release()
rgbCameraHelper = null rgbCameraHelper = null
@@ -175,6 +289,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
recognizeViewModel.destroy() recognizeViewModel.destroy()
EventBus.getDefault().unregister(this)
super.onDestroy() super.onDestroy()
} }
@@ -248,25 +363,37 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
displayOrientation: Int, displayOrientation: Int,
isMirror: Boolean isMirror: Boolean
) { ) {
Timber.d("initRgbCamera Rgb onCameraOpened: cameraId = $cameraId, displayOrientation = $displayOrientation")
runOnUiThread({ runOnUiThread({
val previewSizeRgb = camera.getParameters().getPreviewSize() val previewSizeRgb = camera.getParameters().getPreviewSize()
val layoutParams = adjustPreviewViewSize( val layoutParams = adjustPreviewViewSize(
binding.dualCameraTexturePreviewRgb, binding.dualCameraTexturePreviewRgb,
binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView, binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView,
previewSizeRgb, displayOrientation, 1f previewSizeRgb, displayOrientation, 0.7f
) )
Timber.d("initRgbCamera previewSizeRgb = ${previewSizeRgb.width}-${previewSizeRgb.height}") Timber.d("initRgbCamera previewSizeRgb = ${previewSizeRgb.width}-${previewSizeRgb.height}")
Timber.d("initRgbCamera layoutParams = ${layoutParams.width}-${layoutParams.height}") Timber.d("initRgbCamera layoutParams = ${layoutParams.width}-${layoutParams.height}")
Timber.d(
"initRgbCamera isMirror = ${isMirror}, isDrawRgbRectHorizontalMirror = ${
ConfigUtil.isDrawRgbRectHorizontalMirror(
context
)
}, isDrawRgbRectVerticalMirror = ${
ConfigUtil.isDrawRgbRectVerticalMirror(
context
)
}"
)
rgbFaceRectTransformer = FaceRectTransformer( rgbFaceRectTransformer = FaceRectTransformer(
previewSizeRgb.width, previewSizeRgb.width,
previewSizeRgb.height, previewSizeRgb.height,
layoutParams.width, layoutParams.width,
layoutParams.height, layoutParams.height,
displayOrientation, 90,
cameraId, cameraId,
isMirror, isMirror,
ConfigUtil.isDrawRgbRectHorizontalMirror(context), true,
ConfigUtil.isDrawRgbRectVerticalMirror(context) true
) )
recognizeViewModel.onRgbCameraOpened(camera) recognizeViewModel.onRgbCameraOpened(camera)
@@ -289,20 +416,20 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
} }
override fun onCameraClosed() { override fun onCameraClosed() {
Timber.i("onCameraClosed: ") Timber.i("initRgbCamera onCameraClosed: ")
} }
override fun onCameraError(e: java.lang.Exception) { override fun onCameraError(e: java.lang.Exception) {
Timber.i("onCameraError: %s", e.message) Timber.i("initRgbCamera onCameraError: %s", e.message)
e.printStackTrace() e.printStackTrace()
} }
override fun onCameraConfigurationChanged(cameraID: Int, displayOrientation: Int) { override fun onCameraConfigurationChanged(cameraID: Int, displayOrientation: Int) {
Timber.i("onCameraConfigurationChanged: threadName = ${Thread.currentThread().name}") Timber.i("initRgbCamera onCameraConfigurationChanged: threadName = ${Thread.currentThread().name}")
if (rgbFaceRectTransformer != null) { if (rgbFaceRectTransformer != null) {
rgbFaceRectTransformer!!.cameraDisplayOrientation = displayOrientation rgbFaceRectTransformer!!.cameraDisplayOrientation = displayOrientation
} }
Timber.i("onCameraConfigurationChanged: $cameraID $displayOrientation") Timber.i("initRgbCamera onCameraConfigurationChanged: $cameraID $displayOrientation")
} }
} }
@@ -350,33 +477,162 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
override fun onGlobalLayout() { override fun onGlobalLayout() {
Timber.d("onGlobalLayout")
binding.dualCameraTexturePreviewRgb.getViewTreeObserver().removeOnGlobalLayoutListener(this) binding.dualCameraTexturePreviewRgb.getViewTreeObserver().removeOnGlobalLayoutListener(this)
// 请求权限 // 请求权限
checkCameraPermission() checkCameraPermission()
} }
// @Override
// protected void onResume() {
// super.onResume();
// resumeCamera();
// }
private fun resumeCamera() { private fun resumeCamera() {
val helper = rgbCameraHelper
if (helper != null && helper.isStopped) {
isRecognition = true isRecognition = true
if (rgbCameraHelper != null && rgbCameraHelper!!.isStopped) { helper.start()
rgbCameraHelper!!.start() } else {
// 相机未停止时,发送黑帧清除 ViewModel 内部识别缓存,防止恢复后使用旧数据
recognizeViewModel.onPreviewFrame(emptyFrame, true)
binding.dualCameraFaceRectView.clearFaceInfo()
binding.root.postDelayed({
isRecognition = true
}, 500)
}
}
override fun onResume() {
super.onResume()
// 清空上次识别结果和 FaceHelper 内部状态,防止短时间内再次识别无法触发
recognizeViewModel.resetFaceState()
resumeCamera()
viewModel.resetUserInfo()
countDownTimer?.let {
it.cancel()
it.start()
} }
} }
protected override fun onPause() { protected override fun onPause() {
pauseCamera() pauseCamera()
super.onPause() super.onPause()
countDownTimer?.cancel()
} }
private fun pauseCamera() { private fun pauseCamera() {
isRecognition = false isRecognition = false
recognizeViewModel.onPreviewFrame(emptyFrame, true)
recognizeViewModel.onPreviewFrame(ByteArray(1382400), true)
} }
/**
* 构造符合分辨率的 NV21 格式全黑帧
* @param width 图像宽度
* @param height 图像高度
* @return 合法的全黑帧字节数组
*/
private fun createBlackNV21Frame(width: Int, height: Int): ByteArray {
val frameSize = width * height
val nv21Data = ByteArray(frameSize * 3 / 2) // NV21 格式长度 = 宽×高×1.5
// Y 分量(亮度)设为 0(全黑),UV 分量设为 128(默认值)
nv21Data.fill(0, 0, frameSize)
nv21Data.fill(128.toByte(), frameSize, nv21Data.size)
return nv21Data
}
private val emptyFrame = createBlackNV21Frame(1280, 720)
override fun handleLoginSuccess(equipmentUserInfo: EquipmentUserInfo, isAdmin: Boolean) {
super.handleLoginSuccess(equipmentUserInfo, isAdmin)
// goInitActivity()
}
fun initCountTime() {
val totalTimeInMillis = 30L * 1000
countDownTimer = object : CountDownTimer(totalTimeInMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
Timber.d("initCountTime onTick = ${(millisUntilFinished / 1000).toInt()}")
binding.tvToInit.text = "${(millisUntilFinished / 1000).toInt() + 1}"
}
override fun onFinish() {
goInitActivity()
}
}.start()
}
fun goInitActivity() {
val intent = Intent(context, InitActivity::class.java)
startActivity(intent)
}
/**
* 连续点击 3 次(500ms 内)触发环境切换弹窗
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
tapCount++
tapHandler.removeCallbacks(resetTapRunnable)
tapHandler.postDelayed(resetTapRunnable, 500)
if (tapCount >= 3) {
tapCount = 0
tapHandler.removeCallbacks(resetTapRunnable)
showEnvironmentDialog()
return true
}
}
return super.dispatchTouchEvent(ev)
}
/**
* 弹出环境选择弹窗,弹窗期间暂停 30s 倒计时,避免被自动跳转打断
*/
private fun showEnvironmentDialog() {
countDownTimer?.cancel()
val dialog = EnvironmentSelectDialog(this) { env ->
switchedEnvironment = true
switchEnvironment(env)
}
dialog.setOnDismissListener {
// 未切换(点外部取消)时恢复倒计时;切换后走自动重启,无需恢复
if (!switchedEnvironment) countDownTimer?.start()
}
dialog.show()
}
/**
* 切换环境:同步落盘 + 清空本地人脸库 + 重置增量同步状态 + 自动重启
*/
private fun switchEnvironment(env: Environment) {
// 用 commit 同步写盘,确保自动重启前状态已持久化(apply 是异步的,重启会丢)
getSharedPreferences("default_sp", Context.MODE_PRIVATE).edit()
.putString(GlobalKey.KEY_BASE_URL, env.url)
.putLong(SpTool.LAST_FACE_TIMESTAMP, 0L)
.putBoolean(SpTool.IS_FIRST_GET_FACE, true)
.commit()
GlobalData.appBaseUrl = env.url
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@LoginByFaceActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
withContext(Dispatchers.Main) {
ToastUtils.showToast("已切换到${env.name},正在重启")
restartApp()
}
}
}
/**
* 自动重启:用 AlarmManager 拉起启动页后杀进程,实现应用自重启
*/
private fun restartApp() {
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
} ?: return
val pending = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 500, pending)
Process.killProcess(Process.myPid())
}
} }
@@ -1,64 +0,0 @@
package com.sw.platecabinet.activity
import android.content.Intent
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.face.Test
import com.sw.platecabinet.databinding.ActivityLoginByPwdBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.viewmodel.UserViewModel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import timber.log.Timber
class LoginByPwdActivity : BaseActivity<ActivityLoginByPwdBinding>() {
private val viewModel by viewModels<UserViewModel>()
override fun inflateViewBinding(): ActivityLoginByPwdBinding {
return ActivityLoginByPwdBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding {
return binding.includeHeader
}
override fun initialize() {
binding.btnLogin.setOnClickListener {
val phone = binding.etPhone.text.toString()
val pwd = binding.etPwd.text.toString()
if (phone.isEmpty() || pwd.isEmpty()) {
ToastUtils.showToast("手机或校验码不能为空")
val test = Test()
for (i in 1..1000) {
test.test1()
}
return@setOnClickListener
}
viewModel.loginWithPwd(phone = phone, password = pwd)
}
binding.tvFaceRec.setOnClickListener {
val intent = Intent(this, LoginByFaceActivity::class.java)
startActivity(intent)
// handleScanKeyInfo(binding.etPwd.text.toString())
}
}
override fun registerDataChange() {
super.registerDataChange()
lifecycleScope.launch {
viewModel.currentUserInfo
.drop(1)
.collect {
Timber.d("currentUserInfo it = $it")
if (it != null) {
// ToastUtils.showToast("登录成功")
MainActivity.start(context, pageType = PageType.PLATE_OPEN)
finish()
}
}
}
}
}
@@ -2,9 +2,9 @@ package com.sw.platecabinet.activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
import com.sw.platecabinet.databinding.ActivityMainBinding import com.sw.platecabinet.member.databinding.ActivityMainBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.fragment.BindPlateFragment import com.sw.platecabinet.fragment.BindPlateFragment
import com.sw.platecabinet.fragment.PlateCabinetFullFragment import com.sw.platecabinet.fragment.PlateCabinetFullFragment
import com.sw.platecabinet.fragment.PlateOpenFragment import com.sw.platecabinet.fragment.PlateOpenFragment
@@ -15,6 +15,9 @@ import com.sw.platecabinet.utils.FragmentHelper
typealias Callback = (String) -> Unit typealias Callback = (String) -> Unit
/**
* 主界面,内部嵌套framgnet使用
*/
class MainActivity : BaseActivity<ActivityMainBinding>() { class MainActivity : BaseActivity<ActivityMainBinding>() {
private lateinit var fragmentHelper: FragmentHelper private lateinit var fragmentHelper: FragmentHelper
private var pageType = PageType.SETTING_LIST private var pageType = PageType.SETTING_LIST
@@ -32,27 +35,31 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
companion object { companion object {
private const val PARAM_PAGE_TYPE = "pageType" private const val PARAM_PAGE_TYPE = "pageType"
private const val PARAM_EQUIPMENT_INFO = "equipmentUserInfo" private const val PARAM_EQUIPMENT_INFO = "equipmentUserInfo"
private const val PARAM_IS_ADMIN = "isAdmin"
fun start( fun start(
context: Context, context: Context,
pageType: PageType, pageType: PageType,
equipmentUserInfo: EquipmentUserInfo? = null equipmentUserInfo: EquipmentUserInfo? = null,
isAdmin: Boolean = false
) { ) {
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, MainActivity::class.java)
intent.putExtra(PARAM_PAGE_TYPE, pageType.name) intent.putExtra(PARAM_PAGE_TYPE, pageType.name)
intent.putExtra(PARAM_EQUIPMENT_INFO, equipmentUserInfo) intent.putExtra(PARAM_EQUIPMENT_INFO, equipmentUserInfo)
intent.putExtra(PARAM_IS_ADMIN, isAdmin)
context.startActivity(intent) context.startActivity(intent)
} }
} }
override fun initialize() { override fun initialize() {
val param = intent.getStringExtra(PARAM_PAGE_TYPE) val param = intent.getStringExtra(PARAM_PAGE_TYPE)
val isAdmin = intent.getBooleanExtra(PARAM_IS_ADMIN, false)
val equipmentUserInfo = intent.getParcelableExtra<EquipmentUserInfo>(PARAM_EQUIPMENT_INFO) val equipmentUserInfo = intent.getParcelableExtra<EquipmentUserInfo>(PARAM_EQUIPMENT_INFO)
pageType = param?.let { PageType.valueOf(param) } ?: PageType.SETTING_LIST pageType = param?.let { PageType.valueOf(param) } ?: PageType.SETTING_LIST
fragmentHelper = FragmentHelper(supportFragmentManager, R.id.fragment_container) fragmentHelper = FragmentHelper(supportFragmentManager, R.id.fragment_container)
val page = when (pageType) { val page = when (pageType) {
PageType.SETTING_LIST -> SettingListFragment() PageType.SETTING_LIST -> SettingListFragment()
PageType.BIND_PLATE -> BindPlateFragment.newInstance(equipmentUserInfo) PageType.BIND_PLATE -> BindPlateFragment.newInstance(equipmentUserInfo)
PageType.PLATE_OPEN -> PlateOpenFragment() PageType.PLATE_TIP -> PlateOpenFragment.newInstance(equipmentUserInfo, isAdmin)
PageType.UNBIND_PLATE -> UnBindPlateFragment.newInstance(equipmentUserInfo) PageType.UNBIND_PLATE -> UnBindPlateFragment.newInstance(equipmentUserInfo)
PageType.PLATE_CABINET_FULL -> PlateCabinetFullFragment() PageType.PLATE_CABINET_FULL -> PlateCabinetFullFragment()
} }
@@ -66,7 +73,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
} }
override fun onRightDoubleClick() { override fun onRightDoubleClick() {
// 管理员列表页右上角时间双击 → 打开运维面板
if (pageType == PageType.SETTING_LIST) {
OpsActivity.start(this)
}
} }
override fun handleScanKeyInfo(scanInfo: String) { override fun handleScanKeyInfo(scanInfo: String) {
@@ -90,9 +100,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
*/ */
enum class PageType { enum class PageType {
/** /**
* 餐盘柜打开 * 提示
*/ */
PLATE_OPEN, PLATE_TIP,
/** /**
* 设置列表 * 设置列表
@@ -0,0 +1,389 @@
package com.sw.platecabinet.activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.text.InputType
import android.widget.EditText
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.comn.SerialApi
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
import com.sw.platecabinet.member.databinding.ActivityOpsBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.mqtt.MqttManager
import com.sw.platecabinet.mqtt.MqttState
import com.sw.platecabinet.utils.CrashHandler
import com.sw.platecabinet.utils.DeviceInfoProvider
import com.sw.platecabinet.utils.DiagnosticExporter
import com.sw.platecabinet.utils.EnvironmentSwitcher
import com.sw.platecabinet.utils.LogFileManager
import com.sw.platecabinet.utils.NetStatusProvider
import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.viewmodel.OpsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 运维面板:展示网络 / MQTT / 人脸数据 / 最近更新 / 日志,并提供少量二次确认干预。
* 入口:管理员列表页(MainActivity SETTING_LIST)右上角时间双击。
*/
class OpsActivity : BaseActivity<ActivityOpsBinding>() {
private val opsViewModel by viewModels<OpsViewModel>()
override fun inflateViewBinding(): ActivityOpsBinding {
return ActivityOpsBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding? {
return binding.includeHeader
}
override fun initialize() {
checkPin()
}
/** 进入面板前校验运维密码(默认值见 SpTool,不在 UI 明文提示) */
private fun checkPin() {
val input = EditText(this).apply {
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
hint = "请输入密码"
}
AlertDialog.Builder(this)
.setTitle("请输入运维密码")
.setView(input)
.setCancelable(false)
.setNegativeButton("退出") { _, _ -> finish() }
.setPositiveButton("确定") { _, _ ->
if (input.text.toString() == SpTool.getOpsPin()) {
proceedInit()
} else {
ToastUtils.showToast("密码错误")
finish()
}
}
.show()
}
private fun proceedInit() {
renderStaticInfo()
opsViewModel.refreshFaceStats()
opsViewModel.refreshLogFiles(this)
bindActions()
startAutoRefresh()
}
/** 30s 定时刷新静态信息与人脸/日志列表 */
private fun startAutoRefresh() {
lifecycleScope.launch {
while (isActive) {
delay(30_000)
renderStaticInfo()
renderSerialInfo()
opsViewModel.refreshFaceStats()
opsViewModel.refreshLogFiles(this@OpsActivity)
}
}
}
override fun registerDataChange() {
super.registerDataChange()
// MQTT 状态与时间线变化 → 刷新 MQTT 卡
lifecycleScope.launch { opsViewModel.mqttState.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastConnectedAt.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastLostAt.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastError.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastMessageAt.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttConnectCount.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttDisconnectCount.collect { renderMqttCard() } }
// 人脸统计
lifecycleScope.launch { opsViewModel.faceCount.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.memberCount.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.nonMemberCount.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.maxUpdateTs.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.recentFaces.collect { renderRecentFaces() } }
lifecycleScope.launch { opsViewModel.searchResult.collect { renderSearchResult(it) } }
// 服务端自检与日志列表
lifecycleScope.launch { opsViewModel.serverCheck.collect { binding.tvServerCheck.text = it } }
lifecycleScope.launch { opsViewModel.logFiles.collect { renderLogFiles() } }
}
/** 左上角日期双击 → 返回 */
override fun onLeftDoubleClick() {
finish()
}
private fun renderStaticInfo() {
binding.tvNetSummary.text =
"${NetStatusProvider.connectivitySummary(this)} | 本机IP: ${NetStatusProvider.localIpv4()}"
binding.tvNetBaseUrl.text = "环境: ${opsViewModel.envName} | ${opsViewModel.appBaseUrl}"
binding.tvDeviceInfo.text =
"设备ID: ${opsViewModel.deviceId}\n设备编号: ${opsViewModel.equipmentCode} | 版本: ${opsViewModel.appVersion}"
binding.tvDeviceHealth.text =
"运行时长: ${DeviceInfoProvider.uptime()}\n内存: ${DeviceInfoProvider.memorySummary(this)}\n存储: ${DeviceInfoProvider.internalStorage(this)}"
binding.tvCameraInfo.text = DeviceInfoProvider.cameraSummary(this)
renderSerialInfo()
}
private fun renderSerialInfo() {
val state = if (SerialApi.isOpened()) "已打开" else "未打开"
binding.tvSerialInfo.text = "串口: ${SerialApi.getPath()} @ ${SerialApi.getBaudRate()} | 状态: $state"
}
private fun renderMqttCard() {
binding.tvMqttState.text = "状态: ${opsViewModel.mqttState.value.name}"
val broker = opsViewModel.mqttBrokerUrl ?: "未配置"
val clientId = opsViewModel.mqttClientId ?: "未配置"
binding.tvMqttBroker.text = "Broker: $broker\nclientId: $clientId"
val subs = MqttManager.getSubscriptions().entries
.joinToString("; ") { "${it.key}(qos=${it.value})" }
.ifBlank {
if (opsViewModel.mqttState.value == MqttState.Connected) "无(已连接但无订阅)"
else "无(未连接/尚未订阅)"
}
binding.tvMqttSubs.text = "订阅: $subs"
val connected = opsViewModel.formatTs(opsViewModel.mqttLastConnectedAt.value)
val lost = opsViewModel.formatTs(opsViewModel.mqttLastLostAt.value)
val err = opsViewModel.mqttLastError.value ?: ""
val lastMsg = ago(opsViewModel.mqttLastMessageAt.value)
binding.tvMqttTimes.text =
"连接: $connected | 丢失: $lost\n错误: $err\n最近消息: $lastMsg | 连接${opsViewModel.mqttConnectCount.value}次 / 断开${opsViewModel.mqttDisconnectCount.value}"
}
private fun ago(ts: Long?): String {
if (ts == null) return ""
val diff = System.currentTimeMillis() - ts
return when {
diff < 60_000L -> "${diff / 1000}秒前"
diff < 3_600_000L -> "${diff / 60_000}分钟前"
else -> "${diff / 3_600_000}小时前"
}
}
private fun renderFaceCard() {
binding.tvFaceCount.text =
"人脸总数: ${opsViewModel.faceCount.value} | 会员: ${opsViewModel.memberCount.value} | 非会员: ${opsViewModel.nonMemberCount.value}"
val maxTs = opsViewModel.formatTs(opsViewModel.maxUpdateTs.value)
val watermark = opsViewModel.formatTs(opsViewModel.lastFaceTimestamp)
binding.tvFaceWatermark.text = "同步水位: $watermark | 库内最大更新: $maxTs"
}
private fun renderRecentFaces() {
val list = opsViewModel.recentFaces.value
binding.llRecentList.removeAllViews()
binding.tvRecentEmpty.isVisible = list.isEmpty()
list.take(20).forEach { face ->
binding.llRecentList.addView(
TextView(this).apply {
text = buildFaceLine(face)
setTextColor(Color.parseColor("#E6E6E6"))
textSize = 13f
}
)
}
}
private fun buildFaceLine(face: FaceEntity): String {
val updateTs = opsViewModel.formatTs(face.faceUpdateTimestamp)
val insertTs = opsViewModel.formatTs(face.registerTime)
val member = if (face.isMember) "会员" else "非会员"
return "ufid=${face.userFaceId ?: "—"} uid=${face.userId ?: "—"} $member\n 更新: $updateTs | 落库: $insertTs"
}
private fun renderSearchResult(list: List<FaceEntity>) {
binding.tvSearchResult.isVisible = true
binding.tvSearchResult.text = if (list.isEmpty()) "无匹配结果"
else "匹配 ${list.size} 条:\n" + list.take(20).joinToString("\n") { buildFaceLine(it) }
}
private fun renderLogFiles() {
val files = opsViewModel.logFiles.value
val crashFiles = CrashHandler.getCrashReportFiles(this)
binding.tvLogFiles.text = "运行日志: ${files.size} 个文件" +
if (files.isEmpty()) "" else "\n最新: ${files.firstOrNull()?.name}"
val lastCrash = crashFiles.maxOfOrNull { it.lastModified() }
binding.tvCrashInfo.text = "崩溃日志: ${crashFiles.size} 个文件" +
if (lastCrash == null) "" else "\n最近崩溃: ${opsViewModel.formatTs(lastCrash)}"
}
private fun bindActions() {
binding.btnCheckServer.setOnClickListener { opsViewModel.checkServer() }
binding.btnRefreshFace.setOnClickListener { opsViewModel.refreshFaceStats() }
binding.btnSearch.setOnClickListener {
val keyword = binding.etSearch.text.toString().trim()
if (keyword.isEmpty()) {
ToastUtils.showToast("请输入 userId")
return@setOnClickListener
}
opsViewModel.searchFace(keyword)
}
binding.btnInitSerial.setOnClickListener {
SerialApi.init()
renderSerialInfo()
ToastUtils.showToast(if (SerialApi.isOpened()) "串口打开成功" else "串口打开失败")
}
binding.btnReconnectMqtt.setOnClickListener {
confirm("MQTT 手动重连", "断开并重新连接 MQTT,确定?") {
opsViewModel.reconnectMqtt()
ToastUtils.showToast("已触发重连")
}
}
binding.btnFullSync.setOnClickListener {
confirm("获取全量人脸", "将清空本地人脸库并重新全量拉取(耗时较长),确定?") {
showWaitingDialog("全量同步中…")
netViewModelV2.getUserFaceCache { success, msg ->
runOnUiThread {
hideWaitingDialog()
if (success) {
scheduleFaceRefresh()
opsViewModel.refreshFaceStats()
ToastUtils.showToast("全量同步完成")
} else {
ToastUtils.showToast("全量同步失败: $msg")
}
}
}
}
}
binding.btnTriggerSync.setOnClickListener {
val ts = SpTool.getLastFaceTimestamp()
if (ts <= 0L) {
ToastUtils.showToast("同步水位为 0,请先完成首次全量同步")
return@setOnClickListener
}
confirm("手动增量补拉", "以水位 $ts 触发一次 HTTP 增量同步,确定?") {
netViewModelV2.getFaceIncrementList(timestamp = ts) { scheduleFaceRefresh() }
ToastUtils.showToast("已触发增量补拉")
}
}
binding.btnClearFace.setOnClickListener {
confirm("清空本地人脸库", "将删除全部本地人脸特征并刷新识别引擎,确定?") {
clearAllFace { ToastUtils.showToast("已清空本地人脸库") }
}
}
binding.btnReadLog.setOnClickListener { readLatestLog() }
binding.btnReadCrash.setOnClickListener { readLatestCrash() }
binding.btnExport.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
val result = DiagnosticExporter.export(this@OpsActivity)
withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle(if (result.success) "导出成功" else "导出失败")
.setMessage(result.message)
.setPositiveButton("确定", null)
.show()
}
}
}
binding.btnClearLogs.setOnClickListener {
confirm("清空日志", "删除全部运行日志与崩溃日志,确定?") {
LogFileManager.clearLogs(this)
CrashHandler.clearCrashReports(this)
opsViewModel.refreshLogFiles(this)
ToastUtils.showToast("已清空日志")
}
}
binding.btnSwitchEnv.setOnClickListener {
val dialog = EnvironmentSelectDialog(this) { env ->
EnvironmentSwitcher.switch(this, env) {
ToastUtils.showToast("已切换到${env.name},正在重启")
}
}
dialog.show()
}
binding.btnChangePin.setOnClickListener { changePin() }
}
private fun changePin() {
val input = EditText(this).apply {
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
hint = "4 位以上数字"
}
AlertDialog.Builder(this)
.setTitle("设置新运维密码")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("确定") { _, _ ->
val pin = input.text.toString().trim()
if (pin.length < 4) {
ToastUtils.showToast("密码至少 4 位")
} else {
SpTool.setOpsPin(pin)
ToastUtils.showToast("密码已修改")
}
}
.show()
}
private fun readLatestLog() {
val file = opsViewModel.logFiles.value.firstOrNull()
if (file == null) {
ToastUtils.showToast("暂无日志文件")
return
}
lifecycleScope.launch(Dispatchers.IO) {
val content = LogFileManager.readTail(file, 200)
withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle("最新日志: ${file.name}")
.setMessage(content)
.setPositiveButton("关闭", null)
.show()
}
}
}
private fun readLatestCrash() {
val file = CrashHandler.getCrashReportFiles(this).maxByOrNull { it.lastModified() }
if (file == null) {
ToastUtils.showToast("暂无崩溃日志")
return
}
lifecycleScope.launch(Dispatchers.IO) {
val content = LogFileManager.readTail(file, 300)
withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle("最新崩溃日志: ${file.name}")
.setMessage(content)
.setPositiveButton("关闭", null)
.show()
}
}
}
private fun confirm(title: String, message: String, onOk: () -> Unit) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setNegativeButton("取消", null)
.setPositiveButton("确定") { _, _ -> onOk() }
.show()
}
companion object {
fun start(context: Context) {
context.startActivity(Intent(context, OpsActivity::class.java))
}
}
}
@@ -10,7 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
import kotlin.math.ceil import kotlin.math.ceil
class TestActivity : AppCompatActivity() { class TestActivity : AppCompatActivity() {
@@ -0,0 +1,57 @@
package com.sw.platecabinet.activity
import android.graphics.Color
import android.view.Gravity
import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.member.databinding.ActivityUnbindDialogBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.ext.dp
import com.sw.platecabinet.model.response.EquipmentUserInfo
/**
* 解绑确认弹窗界面
*/
class UnBindDialogActivity : BaseActivity<ActivityUnbindDialogBinding>() {
private var equipmentUserInfo: EquipmentUserInfo? = null
override fun inflateViewBinding(): ActivityUnbindDialogBinding {
return ActivityUnbindDialogBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding? {
return null
}
override fun initialize() {
window?.apply {
WindowCompat.setDecorFitsSystemWindows(this, false)
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
attributes = attributes.apply {
y = 314.dp
width = (context.resources.displayMetrics.widthPixels * 0.8).toInt()
height = ViewGroup.LayoutParams.MATCH_PARENT
gravity = Gravity.CENTER
}
}
equipmentUserInfo =
intent.getParcelableExtra<EquipmentUserInfo>(GlobalKey.PARAM_EQUIPMENT_INFO)
if (equipmentUserInfo == null) return
binding.tvTipInfo.text = "即将解除【${equipmentUserInfo!!.name}】的餐盘"
binding.tvCancel.setOnClickListener {
finish()
}
binding.tvConfirm.setOnClickListener {
setResult(RESULT_OK)
finish()
}
}
override fun finish() {
super.finish()
overridePendingTransition(0, 0) // 确保退出也无动画
}
}
@@ -6,7 +6,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
/** /**
* 通用分页适配器(支持ViewBinding * 通用分页适配器(支持ViewBinding
@@ -0,0 +1,36 @@
package com.sw.platecabinet.dialog
import android.app.Dialog
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Window
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.member.databinding.DialogBalanceNotEnoughBinding
import com.sw.platecabinet.ext.dp
open class BalanceNotEnoughDialog(
private var activity: FragmentActivity
) : Dialog(activity) {
private lateinit var binding: DialogBalanceNotEnoughBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogBalanceNotEnoughBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.run {
attributes = attributes.apply {
width = 480.dp
}
setBackgroundDrawable(ColorDrawable())
setCancelable(false)
}
addListener()
}
private fun addListener() {
binding.tvConfirm.setOnClickListener { dismiss() }
}
}
@@ -0,0 +1,97 @@
package com.sw.platecabinet.dialog
import android.app.Dialog
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.view.Gravity
import android.view.View
import android.view.Window
import android.widget.LinearLayout
import android.widget.RadioButton
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.ENVIRONMENTS
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.ext.dp
import com.sw.platecabinet.member.databinding.DialogEnvironmentSelectBinding
/**
* 业务环境选择弹窗
*/
class EnvironmentSelectDialog(
private val activity: FragmentActivity,
private val onConfirm: (Environment) -> Unit
) : Dialog(activity) {
private lateinit var binding: DialogEnvironmentSelectBinding
private var selected: Environment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogEnvironmentSelectBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.run {
attributes = attributes.apply { width = 480.dp }
setBackgroundDrawable(ColorDrawable())
setCancelable(true)
}
renderEnvironments()
addListener()
}
/**
* 渲染环境选项,高亮当前环境
*/
private fun renderEnvironments() {
val current = GlobalData.appBaseUrl
ENVIRONMENTS.forEach { env ->
val rb = RadioButton(activity).apply {
id = View.generateViewId()
text = buildEnvironmentLabel(env.name, env.url)
gravity = Gravity.CENTER_VERTICAL
setTextColor(Color.WHITE)
buttonTintList = ColorStateList.valueOf(Color.WHITE)
isChecked = env.url == current
setOnClickListener { selected = env }
}
binding.rgEnvironments.addView(
rb,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = 8.dp }
)
}
selected = ENVIRONMENTS.firstOrNull { it.url == current }
}
/**
* 构造环境选项文本:名称(白色 18sp)+ 换行 + 地址(灰色 13sp)
*/
private fun buildEnvironmentLabel(name: String, url: String): SpannableString {
val label = "$name\n$url"
val spannable = SpannableString(label)
val urlStart = name.length + 1 // 跳过换行符
spannable.setSpan(AbsoluteSizeSpan(18, true), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(AbsoluteSizeSpan(13, true), urlStart, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(ForegroundColorSpan(Color.GRAY), urlStart, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return spannable
}
/**
* 确认按钮:回调所选环境并关闭弹窗
*/
private fun addListener() {
binding.tvConfirm.setOnClickListener {
selected?.let(onConfirm)
dismiss()
}
}
}
@@ -0,0 +1,38 @@
package com.sw.platecabinet.dialog
import android.app.Dialog
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Window
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.member.databinding.DialogUserBindRemindBinding
import com.sw.platecabinet.ext.dp
open class UserBindRemindDialog(
private var activity: FragmentActivity,
var content:String
) : Dialog(activity) {
private lateinit var binding: DialogUserBindRemindBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogUserBindRemindBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.run {
attributes = attributes.apply {
width = 480.dp
}
setBackgroundDrawable(ColorDrawable())
setCancelable(false)
}
binding.tvBindContent.text = content
addListener()
}
private fun addListener() {
binding.tvConfirm.setOnClickListener { dismiss() }
}
}
@@ -2,6 +2,7 @@ package com.sw.platecabinet.ext
import android.content.res.Resources import android.content.res.Resources
import android.util.TypedValue import android.util.TypedValue
import android.view.View
import androidx.annotation.Dimension import androidx.annotation.Dimension
/** /**
@@ -51,3 +52,15 @@ val Float.sp: Float
fun formatNumber(num: String): String { fun formatNumber(num: String): String {
return num.padStart(2, '0') return num.padStart(2, '0')
} }
fun View.visible() {
visibility = View.VISIBLE
}
fun View.invisible() {
visibility = View.INVISIBLE
}
fun View.gone() {
visibility = View.GONE
}
@@ -11,8 +11,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
import com.sw.platecabinet.view.CustomDialog import com.sw.platecabinet.view.CustomLoadingDialog
import com.sw.platecabinet.viewmodel.SettingViewModel import com.sw.platecabinet.viewmodel.SettingViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -21,7 +21,7 @@ abstract class BaseFragment<VB : ViewBinding>(
) : Fragment() { ) : Fragment() {
private var _binding: VB? = null private var _binding: VB? = null
protected val binding get() = _binding!! protected val binding get() = _binding!!
private var mDialogWaiting: CustomDialog? = null private var mDialogWaiting: CustomLoadingDialog? = null
protected val viewModel by viewModels<SettingViewModel>() protected val viewModel by viewModels<SettingViewModel>()
override fun onCreateView( override fun onCreateView(
@@ -59,13 +59,12 @@ abstract class BaseFragment<VB : ViewBinding>(
hideWaitingDialog() hideWaitingDialog()
val view = View.inflate(requireContext(), R.layout.dialog_waiting, null) val view = View.inflate(requireContext(), R.layout.dialog_waiting, null)
if (!TextUtils.isEmpty(tip)) (view.findViewById<View?>(R.id.tvTip) as TextView).text = tip if (!TextUtils.isEmpty(tip)) (view.findViewById<View?>(R.id.tvTip) as TextView).text = tip
mDialogWaiting = CustomDialog(requireContext(), view, R.style.MyDialog) mDialogWaiting = CustomLoadingDialog(requireContext(), view, R.style.MyDialog)
mDialogWaiting!!.show() mDialogWaiting!!.show()
mDialogWaiting!!.setCancelable(true) mDialogWaiting!!.setCancelable(true)
return mDialogWaiting return mDialogWaiting
} }
/** /**
* 隐藏等待提示框 * 隐藏等待提示框
*/ */
@@ -1,28 +1,38 @@
package com.sw.platecabinet.fragment package com.sw.platecabinet.fragment
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable import android.text.Editable
import android.text.TextUtils import android.text.TextUtils
import android.text.TextWatcher import android.text.TextWatcher
import android.view.inputmethod.EditorInfo
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.R import com.sw.plate.utils.comn.SerialApi
import com.sw.plate.utils.comn.SerialPortManager
import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.MainActivity import com.sw.platecabinet.activity.MainActivity
import com.sw.platecabinet.activity.PageType
import com.sw.platecabinet.adapter.GenericItemAdapter import com.sw.platecabinet.adapter.GenericItemAdapter
import com.sw.platecabinet.adapter.GridSpacingItemDecoration import com.sw.platecabinet.adapter.GridSpacingItemDecoration
import com.sw.platecabinet.adapter.dpToPx import com.sw.platecabinet.adapter.dpToPx
import com.sw.platecabinet.databinding.FragmentBindPlateBinding import com.sw.platecabinet.member.databinding.FragmentBindPlateBinding
import com.sw.platecabinet.databinding.ItemSearchUserInfoBinding import com.sw.platecabinet.member.databinding.ItemSearchUserInfoBinding
import com.sw.platecabinet.dialog.UserBindRemindDialog
import com.sw.platecabinet.ext.maskName import com.sw.platecabinet.ext.maskName
import com.sw.platecabinet.ext.maskPhone import com.sw.platecabinet.ext.maskPhone
import com.sw.platecabinet.model.ErrorInfo
import com.sw.platecabinet.model.request.BindParam
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.SearchResult import com.sw.platecabinet.model.response.SearchResult
import kotlinx.coroutines.flow.drop import com.sw.platecabinet.utils.Debouncer
import com.sw.platecabinet.utils.KeyboardUtils
import com.sw.platecabinet.viewmodel.SettingViewModel
import com.sw.platecabinet.viewmodel.UserViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -33,28 +43,36 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
FragmentBindPlateBinding::inflate FragmentBindPlateBinding::inflate
) { ) {
private lateinit var adapter: GenericItemAdapter<SearchResult.Member, ItemSearchUserInfoBinding> private lateinit var adapter: GenericItemAdapter<SearchResult.Member, ItemSearchUserInfoBinding>
internal val ARG_PARAM1 = "param1" internal val KEY_ACTION_TYPE = "actionType"
private var info: EquipmentUserInfo? = null private var info: EquipmentUserInfo? = null
private var checkedItem: SearchResult.Member? = null private var checkedItem: SearchResult.Member? = null
private var lastText = "" private var lastText = ""
private val debouncer = Debouncer(2000)
private val userViewModel by viewModels<UserViewModel>()
companion object { companion object {
@JvmStatic @JvmStatic
fun newInstance(param1: EquipmentUserInfo?) = fun newInstance(userInfo: EquipmentUserInfo?) =
BindPlateFragment().apply { BindPlateFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putParcelable(ARG_PARAM1, param1) putParcelable(KEY_ACTION_TYPE, userInfo)
} }
} }
} }
@SuppressLint("SetTextI18n")
override fun initialize() { override fun initialize() {
arguments?.let { arguments?.let { args ->
info = it.getParcelable(ARG_PARAM1) info = args.getParcelable(KEY_ACTION_TYPE)
info?.let { info?.let {
binding.tvNum.text = "${it.equipmentName}-${it.equipmentBoxCode}" binding.tvNum.text = "${it.equipmentName}-${it.equipmentBoxCode}"
if (!TextUtils.isEmpty(it.plateNumber)) { if (!TextUtils.isEmpty(it.plateNumber)) {
binding.etCode.text = Editable.Factory.getInstance().newEditable(it.plateNumber) // binding.etCode.text = Editable.Factory.getInstance().newEditable(it.plateNumber)
binding.etCode.apply {
setText(it.plateNumber)
setSelection(length())
}
} }
} }
} }
@@ -92,21 +110,39 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
} }
}) })
(activity as MainActivity).registerKeyEventInfo { (activity as MainActivity).registerKeyEventInfo {
binding.etCode.text = Editable.Factory.getInstance().newEditable(it) // binding.etCode.text.clear()
// binding.etCode.postDelayed({
// binding.etCode.text.clear()
// binding.etCode.text = Editable.Factory.getInstance().newEditable(it)
activity?.runOnUiThread {
if (binding.etUserInfo.isFocused) {
return@runOnUiThread
}
binding.etCode.apply {
if (it.length > 6) {
setText(it.substring(0, 6))
} else {
setText(it)
}
if (length() > 0) {
setSelection(length())
}
}
binding.etUserInfo.requestFocus()
}
// }, 50)
} }
} }
private fun initListener() { private fun initListener() {
binding.etUserInfo.addTextChangedListener(object : TextWatcher { binding.etCode.addTextChangedListener(object : TextWatcher {
private val handler = Handler(Looper.getMainLooper())
private val debounceDelay = 500L // 延迟 500 毫秒
override fun beforeTextChanged( override fun beforeTextChanged(
s: CharSequence?, s: CharSequence?,
start: Int, start: Int,
count: Int, count: Int,
after: Int after: Int
) { ) {
Timber.d("beforeTextChanged s = ${s.toString()}, start = $start, count = $count, after = $after")
} }
override fun onTextChanged( override fun onTextChanged(
@@ -115,21 +151,48 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
before: Int, before: Int,
count: Int count: Int
) { ) {
Timber.d("onTextChanged s = ${s.toString()}, start = $start, count = $count, before = $before")
} }
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
val currentText = s.toString() Timber.d("afterTextChanged s = ${s.toString()}")
if (currentText == lastText) return // 内容未变化时不处理
lastText = currentText
handler.removeCallbacksAndMessages(null) // 取消之前的延迟任务
handler.postDelayed({
// if (currentText.isNotEmpty()) {
viewModel.getSearchMemberList(param = currentText, pageNum = 1)
// }
}, debounceDelay)
} }
}) })
binding.etUserInfo.setOnEditorActionListener { _, actionId, keyEvent ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val currentText = binding.etUserInfo.text.toString()
val containsNewlines = currentText.contains('\n') || currentText.contains('\r')
if (containsNewlines) {
Timber.d("非用户主动搜索")
true
}
debouncer.debounce {
// 执行搜索操作
viewModel.getSearchMemberList(param = currentText, pageNum = 1)
// 隐藏键盘
KeyboardUtils.hideKeyboard(requireActivity())
}
true // 表示已处理该事件
} else {
false // 未处理其他动作
}
}
binding.ivSearch.setOnClickListener {
debouncer.debounce {
// 执行搜索操作
viewModel.getSearchMemberList(
param = binding.etUserInfo.text.toString(),
pageNum = 1
)
// 隐藏键盘
KeyboardUtils.hideKeyboard(requireActivity())
}
}
binding.llBind.setOnClickListener { binding.llBind.setOnClickListener {
if (equipmentBoxCode.isNullOrBlank().not()) {
showBindDialog()
return@setOnClickListener
}
if (info == null || info!!.equipmentCode?.isEmpty() == true || info!!.equipmentBoxCode?.isEmpty() == true) { if (info == null || info!!.equipmentCode?.isEmpty() == true || info!!.equipmentBoxCode?.isEmpty() == true) {
ToastUtils.showToast("传入的设备信息异常") ToastUtils.showToast("传入的设备信息异常")
return@setOnClickListener return@setOnClickListener
@@ -147,13 +210,17 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
ToastUtils.showToast("选中的会员信息异常") ToastUtils.showToast("选中的会员信息异常")
return@setOnClickListener return@setOnClickListener
} }
val bindParam = BindParam(
viewModel.bindPlate( equipmentId = info?.equipmentId,
equipmentCode = info!!.equipmentCode!!, equipmentCode = info?.equipmentCode,
equipmentBoxCode = info!!.equipmentBoxCode!!, equipmentBoxCode = info?.equipmentBoxCode,
memberId = checkedItem!!.id!!, faceId = checkedItem?.faceId,
plateNumber = plateNumber plateNumber = plateNumber
) )
viewModel.bindPlate(bindParam) { pair ->
updateBindState(pair)
}
KeyboardUtils.hideKeyboard(requireActivity())
} }
binding.tvBack.setOnClickListener { activity?.finish() } binding.tvBack.setOnClickListener { activity?.finish() }
} }
@@ -164,22 +231,15 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
override fun registerDataChange() { override fun registerDataChange() {
super.registerDataChange() super.registerDataChange()
lifecycleScope.launch { lifecycleScope.launch {
viewModel.searchMemberList.drop(1).collect { viewModel.searchMemberList.collect {
adapter.updateData(it) adapter.updateData(it)
} }
} }
lifecycleScope.launch { // lifecycleScope.launch {
viewModel.bindStateChange.drop(1).collect { // viewModel.bindStateChange.collect {
if (it.first == null) return@collect // 解绑状态过滤 // updateBindState(it)
val errorInfo = it.second // }
if (errorInfo.isSuccess()) { // }
ToastUtils.showToast("绑定成功")
activity?.finish()
} else {
ToastUtils.showToast(errorInfo.msg)
}
}
}
} }
private fun createAdapter(): GenericItemAdapter<SearchResult.Member, ItemSearchUserInfoBinding> { private fun createAdapter(): GenericItemAdapter<SearchResult.Member, ItemSearchUserInfoBinding> {
@@ -199,8 +259,62 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
Timber.d("itemClick ${item.name}, position = $position") Timber.d("itemClick ${item.name}, position = $position")
checkedItem = item checkedItem = item
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
//TODO 调用接口查询用户是否已绑盘
this@BindPlateFragment.equipmentBoxCode = null
(activity as? MainActivity)?.showWaitingDialog("查询中,请稍后……")
userViewModel.getUserInfoById(memberId = item.faceId, silent = true) {
(activity as? MainActivity)?.hideWaitingDialog()
this@BindPlateFragment.equipmentBoxCode = it?.equipmentBoxCode
}
} }
} }
) )
} }
private var equipmentBoxCode:String? = null
private fun showBindDialog() {
UserBindRemindDialog(
activity = requireActivity(),
content = "您已绑定编号为${equipmentBoxCode}的柜子,请解绑后重试"
)
.show()
}
private fun updateBindState(pair: Pair<Boolean?, ErrorInfo>){
if (pair.first == null) return // 解绑状态过滤
val errorInfo = pair.second
if (errorInfo.isSuccess()) {
if (errorInfo.equipmentUserInfo == null) {
ToastUtils.showToast("数据异常")
return
}
// if (errorInfo.equipmentUserInfo.isOtherEquipment()) {
// MainActivity.start(
// requireContext(),
// pageType = PageType.PLATE_TIP,
// equipmentUserInfo = errorInfo.equipmentUserInfo,
// isAdmin = true
// )
// } else {
SerialApi.openPlate(
errorInfo.equipmentUserInfo.equipmentBoxCode!!.toInt(),
object : SerialPortManager.SendCallback {
override fun onSuccess() {
ToastUtils.showToast("绑定成功")
activity?.finish()
}
override fun onFail(e: Exception?) {
// 走到这里说明 plateBinding 接口已成功(code=00000),
// 只是开柜动作失败,不能误报「绑定失败」
ToastUtils.showToast("绑定成功,开柜失败")
activity?.finish()
}
})
// }
} else {
ToastUtils.showToast(errorInfo.msg)
}
}
} }
@@ -1,7 +1,8 @@
package com.sw.platecabinet.fragment package com.sw.platecabinet.fragment
import android.os.CountDownTimer import android.os.CountDownTimer
import com.sw.platecabinet.databinding.FragmentPlateCabinetFullBinding import com.sw.platecabinet.Constants
import com.sw.platecabinet.member.databinding.FragmentPlateCabinetFullBinding
import timber.log.Timber import timber.log.Timber
/** /**
@@ -9,7 +10,7 @@ import timber.log.Timber
*/ */
class PlateCabinetFullFragment : class PlateCabinetFullFragment :
BaseFragment<FragmentPlateCabinetFullBinding>(FragmentPlateCabinetFullBinding::inflate) { BaseFragment<FragmentPlateCabinetFullBinding>(FragmentPlateCabinetFullBinding::inflate) {
private var totalTimeInMillis: Long = 5 * 1000 private var totalTimeInMillis: Long = Constants.AUTO_CLOSE_TIME * 1000
override fun initialize() { override fun initialize() {
Timber.d("initialize") Timber.d("initialize")
@@ -19,7 +20,7 @@ class PlateCabinetFullFragment :
fun initCountTime() { fun initCountTime() {
object : CountDownTimer(totalTimeInMillis, 1000) { object : CountDownTimer(totalTimeInMillis, 1000) {
override fun onTick(millisUntilFinished: Long) { override fun onTick(millisUntilFinished: Long) {
binding.tvAutoClose.text = "${(millisUntilFinished / 1000).toInt()}秒后返回主屏" binding.tvAutoClose.text = "${(millisUntilFinished / 1000).toInt() + 1}秒后返回主屏"
} }
override fun onFinish() { override fun onFinish() {
@@ -1,27 +1,75 @@
package com.sw.platecabinet.fragment package com.sw.platecabinet.fragment
import android.content.Intent
import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import com.sw.platecabinet.databinding.FragmentPlateOpenBinding import com.sw.platecabinet.Constants
import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.InitActivity
import com.sw.platecabinet.member.databinding.FragmentPlateOpenBinding
import com.sw.platecabinet.model.response.EquipmentUserInfo
/** /**
* 餐盘柜打开界面 * 餐盘柜提示界面
*/ */
class PlateOpenFragment : class PlateOpenFragment :
BaseFragment<FragmentPlateOpenBinding>(FragmentPlateOpenBinding::inflate) { BaseFragment<FragmentPlateOpenBinding>(FragmentPlateOpenBinding::inflate) {
private var totalTimeInMillis: Long = 5 * 1000 private var totalTimeInMillis: Long = Constants.AUTO_CLOSE_TIME * 1000
internal val KEY_ACTION_TYPE = "actionType"
internal val PARAM_IS_ADMIN = "isAdmin"
private var info: EquipmentUserInfo? = null
private var isAdmin: Boolean = false
companion object {
@JvmStatic
fun newInstance(userInfo: EquipmentUserInfo?, isAdmin: Boolean) =
PlateOpenFragment().apply {
arguments = Bundle().apply {
putParcelable(KEY_ACTION_TYPE, userInfo)
putBoolean(PARAM_IS_ADMIN, isAdmin)
}
}
}
override fun initialize() { override fun initialize() {
arguments?.let {
info = it.getParcelable(KEY_ACTION_TYPE)
isAdmin = it.getBoolean(PARAM_IS_ADMIN)
info?.let {
if (!it.hasEquipmentBox()) {
binding.ivType.setImageResource(R.drawable.ic_tip_warn)
binding.tvTitle.text = "暂无餐盘信息"
binding.tvSubTitle.text = "请联系管理员"
binding.tvTitle.setTextColor(requireContext().getColor(R.color.tip_title_fail))
} else if (it.isOtherEquipment()) {
binding.ivType.setImageResource(R.drawable.ic_tip_warn)
binding.tvTitle.text = "暂无餐盘信息"
binding.tvSubTitle.text = "您的餐盘绑定在${it.equipmentName ?: " "}号柜上"
binding.tvTitle.setTextColor(requireContext().getColor(R.color.tip_title_fail))
} else {
binding.ivType.setImageResource(R.drawable.ic_tip_success)
binding.tvTitle.text = "柜门开启"
binding.tvSubTitle.text = "餐盘取出后请关闭柜门"
binding.tvTitle.setTextColor(requireContext().getColor(R.color.tip_title_success))
}
}
}
initCountTime() initCountTime()
} }
fun initCountTime() { fun initCountTime() {
object : CountDownTimer(totalTimeInMillis, 1000) { object : CountDownTimer(totalTimeInMillis, 1000) {
override fun onTick(millisUntilFinished: Long) { override fun onTick(millisUntilFinished: Long) {
binding.tvAutoClose.text = (millisUntilFinished / 1000).toInt().toString() binding.tvAutoClose.text = ((millisUntilFinished / 1000).toInt() + 1).toString()
} }
override fun onFinish() { override fun onFinish() {
activity?.finish() activity?.finish()
if (!isAdmin) {
val intent = Intent(context, InitActivity::class.java)
startActivity(intent)
}
} }
}.start() }.start()
} }
@@ -1,6 +1,7 @@
package com.sw.platecabinet.fragment package com.sw.platecabinet.fragment
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -10,22 +11,23 @@ import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.comn.SerialApi import com.sw.plate.utils.comn.SerialApi
import com.sw.plate.utils.comn.SerialPortManager import com.sw.plate.utils.comn.SerialPortManager
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.MainActivity import com.sw.platecabinet.activity.MainActivity
import com.sw.platecabinet.activity.PageType import com.sw.platecabinet.activity.PageType
import com.sw.platecabinet.adapter.GenericItemAdapter import com.sw.platecabinet.adapter.GenericItemAdapter
import com.sw.platecabinet.adapter.GenericPageAdapter import com.sw.platecabinet.adapter.GenericPageAdapter
import com.sw.platecabinet.adapter.GridSpacingItemDecoration import com.sw.platecabinet.adapter.GridSpacingItemDecoration
import com.sw.platecabinet.adapter.dpToPx import com.sw.platecabinet.adapter.dpToPx
import com.sw.platecabinet.databinding.FragmentSettingListBinding import com.sw.platecabinet.member.databinding.FragmentSettingListBinding
import com.sw.platecabinet.databinding.ItemBindViewBinding import com.sw.platecabinet.member.databinding.ItemBindViewBinding
import com.sw.platecabinet.ext.formatNumber import com.sw.platecabinet.ext.formatNumber
import com.sw.platecabinet.ext.maskName import com.sw.platecabinet.ext.maskName
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
import kotlinx.coroutines.flow.drop import com.sw.platecabinet.utils.ListArrangementUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import kotlin.math.ceil
/** /**
* 餐盘柜绑定的用户列表界面 * 餐盘柜绑定的用户列表界面
@@ -42,16 +44,15 @@ class SettingListFragment :
* 垂直滚动的adapter * 垂直滚动的adapter
*/ */
private var itemAdapter: GenericItemAdapter<EquipmentUserInfo, ItemBindViewBinding>? = null private var itemAdapter: GenericItemAdapter<EquipmentUserInfo, ItemBindViewBinding>? = null
private val itemsPerPage = GlobalData.arrayCross * GlobalData.arrayVertical // 每行2个,每列11个,共22个
override fun registerDataChange() { override fun registerDataChange() {
super.registerDataChange() super.registerDataChange()
lifecycleScope.launch { // lifecycleScope.launch {
viewModel.equipmentList.drop(1).collect { it -> // viewModel.equipmentList.collect { it ->
Timber.d("registerDateChange updateAdapter it = $it") // Timber.d("registerDateChange updateAdapter it = $it")
updateAdapter(it) // updateAdapter(it)
} // }
} // }
} }
override fun initialize() { override fun initialize() {
@@ -79,7 +80,9 @@ class SettingListFragment :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
viewModel.getEquipmentList() viewModel.getEquipmentList{
updateAdapter(it)
}
} }
private fun createItemAdapter(): GenericItemAdapter<EquipmentUserInfo, ItemBindViewBinding> { private fun createItemAdapter(): GenericItemAdapter<EquipmentUserInfo, ItemBindViewBinding> {
@@ -107,11 +110,16 @@ class SettingListFragment :
item: EquipmentUserInfo, item: EquipmentUserInfo,
position: Int position: Int
) { ) {
if (item.isPlaceholder() == true) {
this.llRoot.isVisible = false
return
}
this.llRoot.isVisible = true
this.tvNum.text = formatNumber(item.equipmentBoxCode ?: "") this.tvNum.text = formatNumber(item.equipmentBoxCode ?: "")
if (item.isBound()) { if (item.isBound()) {
val date = DateTimeUtils.parseDateTime(item.updateTime) val date = DateTimeUtils.parseDateTime(item.updateTime)
val timeInMillis = date?.time ?: 0L val timeInMillis = date?.time ?: 0L
val isOldTime = DateTimeUtils.isMoreThan36HoursFromNow(timeInMillis) val isOldTime = DateTimeUtils.isMoreThanHoursFromNow(timeInMillis)
this.llRoot.setBackgroundResource(R.drawable.grid_item_bind) this.llRoot.setBackgroundResource(R.drawable.grid_item_bind)
this.tvNum.setTextColor(resources.getColor(R.color.bind_4E535D)) this.tvNum.setTextColor(resources.getColor(R.color.bind_4E535D))
@@ -121,7 +129,7 @@ class SettingListFragment :
) )
) )
this.llOpen.setBackgroundResource(R.drawable.grid_button_bind) this.llOpen.setBackgroundResource(R.drawable.grid_button_bind)
this.tvOpen.setTextColor(resources.getColor(R.color.bind_F0C8B4)) this.tvOpen.setTextColor(resources.getColor(R.color.bind_FFCC99))
this.llBindInfo.visibility = View.VISIBLE this.llBindInfo.visibility = View.VISIBLE
this.tvUnbind.visibility = View.GONE this.tvUnbind.visibility = View.GONE
@@ -129,7 +137,7 @@ class SettingListFragment :
this.tvLastTime.text = DateTimeUtils.getTimeAgo(date) this.tvLastTime.text = DateTimeUtils.getTimeAgo(date)
} else { } else {
this.llRoot.setBackgroundResource(R.drawable.grid_item_unbind) this.llRoot.setBackgroundResource(R.drawable.grid_item_unbind)
this.tvNum.setTextColor(resources.getColor(R.color.bind_F0C8B4)) this.tvNum.setTextColor(resources.getColor(R.color.bind_FFCC99))
this.llOpen.setBackgroundResource(R.drawable.grid_button_unbind) this.llOpen.setBackgroundResource(R.drawable.grid_button_unbind)
this.tvOpen.setTextColor(resources.getColor(R.color.unbind_32283C)) this.tvOpen.setTextColor(resources.getColor(R.color.unbind_32283C))
this.llBindInfo.visibility = View.GONE this.llBindInfo.visibility = View.GONE
@@ -162,7 +170,11 @@ class SettingListFragment :
SerialApi.openPlate(equipmentBoxCode, object : SerialPortManager.SendCallback { SerialApi.openPlate(equipmentBoxCode, object : SerialPortManager.SendCallback {
override fun onSuccess() { override fun onSuccess() {
hideWaitingDialog() hideWaitingDialog()
MainActivity.start(requireContext(), pageType = PageType.PLATE_OPEN) MainActivity.start(
requireContext(),
pageType = PageType.PLATE_TIP,
isAdmin = true
)
} }
override fun onFail(e: Exception?) { override fun onFail(e: Exception?) {
@@ -174,38 +186,52 @@ class SettingListFragment :
} }
fun updateAdapter(items: List<EquipmentUserInfo>) { fun updateAdapter(items: List<EquipmentUserInfo>) {
val itemsPerPage = 2 * GlobalData.arrayVertical // 每行2个,每列11个,共22个
if (GlobalData.arrayCross > 2) { if (GlobalData.arrayCross > 2) {
lifecycleScope.launch(Dispatchers.Default) {
// 多recyclerview
var pages = if (items.size > itemsPerPage) { var pages = if (items.size > itemsPerPage) {
items.chunked(itemsPerPage) { if (GlobalData.arrayMode == 0) {
convertToColumnFirst(it, 11) val pageList = items.chunked(itemsPerPage)
pageList.mapIndexed { index, it ->
var itemList: MutableList<EquipmentUserInfo> = it.toMutableList()
if (index == pageList.size - 1) {
// 补全剩余item
itemList = (itemList + List(itemsPerPage - itemList.size) {
EquipmentUserInfo()
}) as MutableList<EquipmentUserInfo>
}
ListArrangementUtil.convertToColumnFirst(
itemList,
GlobalData.arrayVertical
)
}
} else {
val newItems =
ListArrangementUtil.horizontalSortPageItem(items, GlobalData.arrayCross)
newItems.chunked(itemsPerPage)
} }
} else { } else {
listOf( listOf(
// 根据条件判断是否需要重新排列 items
// items
convertToColumnFirst(items, 11)
) )
} }
withContext(Dispatchers.Main) {
pageAdapter?.updatePages(pages) pageAdapter?.updatePages(pages)
}
}
} else { } else {
itemAdapter?.updateData(items) // 单 recyclerview
lifecycleScope.launch(Dispatchers.Default) {
var pages = items
if (GlobalData.arrayMode == 0) {
pages = ListArrangementUtil.verticalSortItem(pages)
}
withContext(Dispatchers.Main) {
itemAdapter?.updateData(pages)
}
}
} }
binding.mainRecyclerView.scrollToPosition(0) binding.mainRecyclerView.scrollToPosition(0)
} }
/**
* 重新排列数组
*/
private fun convertToColumnFirst(
original: List<EquipmentUserInfo>,
cols: Int
): List<EquipmentUserInfo> {
val rows = ceil(original.size.toDouble() / cols).toInt()
return List(original.size) { pos ->
val row = pos % rows
val col = pos / rows
val originalPos = col + row * cols
original[originalPos]
}
}
} }
@@ -1,14 +1,19 @@
package com.sw.platecabinet.fragment package com.sw.platecabinet.fragment
import android.app.Activity
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.sw.inbound.utils.DateTimeUtils import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.databinding.FragmentUnbindPlateBinding import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.activity.UnBindDialogActivity
import com.sw.platecabinet.member.databinding.FragmentUnbindPlateBinding
import com.sw.platecabinet.ext.maskName import com.sw.platecabinet.ext.maskName
import com.sw.platecabinet.ext.maskPhone import com.sw.platecabinet.ext.maskPhone
import com.sw.platecabinet.model.ErrorInfo
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -30,9 +35,19 @@ class UnBindPlateFragment private constructor() : BaseFragment<FragmentUnbindPla
} }
} }
private val dialogLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
viewModel.unbindPlate(info?.id) { pair ->
updateBindState(pair)
}
}
}
override fun initialize() { override fun initialize() {
arguments?.let { arguments?.let { args ->
info = it.getParcelable(ARG_PARAM1) info = args.getParcelable(ARG_PARAM1)
info?.let { info?.let {
binding.tvNum.text = "${it.equipmentName}-${it.equipmentBoxCode}" binding.tvNum.text = "${it.equipmentName}-${it.equipmentBoxCode}"
binding.tvPlateNumberValue.text = it.plateNumber binding.tvPlateNumberValue.text = it.plateNumber
@@ -45,10 +60,11 @@ class UnBindPlateFragment private constructor() : BaseFragment<FragmentUnbindPla
} }
binding.llUnbind.setOnClickListener { binding.llUnbind.setOnClickListener {
if (info == null) return@setOnClickListener if (info == null) return@setOnClickListener
viewModel.unbindPlate( val intent = Intent(requireContext(), UnBindDialogActivity::class.java).apply {
equipmentBoxCode = info!!.equipmentBoxCode!!, putExtra(GlobalKey.PARAM_EQUIPMENT_INFO, info)
equipmentCode = info!!.equipmentCode!! }
) dialogLauncher.launch(intent)
requireActivity().overridePendingTransition(0, 0)
} }
binding.tvBack.setOnClickListener { binding.tvBack.setOnClickListener {
activity?.finish() activity?.finish()
@@ -57,17 +73,23 @@ class UnBindPlateFragment private constructor() : BaseFragment<FragmentUnbindPla
override fun registerDataChange() { override fun registerDataChange() {
super.registerDataChange() super.registerDataChange()
lifecycleScope.launch { // lifecycleScope.launch {
viewModel.bindStateChange.drop(1).collect { // viewModel.bindStateChange.collect {
if (it.first == null) return@collect // 绑定状态过滤 // updateBindState(it)
val errorInfo = it.second // }
// }
}
private fun updateBindState(pair: Pair<Boolean?, ErrorInfo>){
if (pair.first == null) return // 绑定状态过滤
val errorInfo = pair.second
if (errorInfo.isSuccess()) { if (errorInfo.isSuccess()) {
ToastUtils.showToast("解绑成功") ToastUtils.showToast("解绑成功")
activity?.finish() // activity?.finish()
// 关闭宿主 Activity
requireActivity().finish()
requireActivity().overridePendingTransition(0, 0)
} else { } else {
ToastUtils.showToast(errorInfo.msg) ToastUtils.showToast(errorInfo.msg)
} }
} }
} }
}
}
@@ -1,10 +1,49 @@
package com.sw.platecabinet.model package com.sw.platecabinet.model
import com.sw.platecabinet.model.response.EquipmentUserInfo
/** /**
* 错误信息 code == 200 成功 * 错误信息 code == 200 成功
*/ */
data class ErrorInfo(val code: Int = 200, val msg: String = "") { data class ErrorInfo(
val code: String = "00000",
val msg: String = "",
val equipmentUserInfo: EquipmentUserInfo? = null
) {
fun isSuccess(): Boolean { fun isSuccess(): Boolean {
return code == 200 return code == "00000"
} }
} }
data class DeviceConfig(
var arcsoftAppId: String? = null,
var arcsoftSdkKey: String? = null,
var arcsoftActiveKey: String? = null
)
/**
* V2 设备配置(人脸 5.0 体系)
*/
data class DeviceConfigV2(
/** 业务服务器 BASE URL */
val appPackageUrl: String? = null,
/** 食堂/餐厅名称 */
val canteenName: String? = null,
/** 食堂/餐厅 ID(后续所有业务 API 的 restId */
val canteenId: String? = null,
/** 虹软人脸 SDK App ID */
val arcsoftAppId: String? = null,
/** 虹软人脸 SDK Key */
val arcsoftSdkKey: String? = null,
/** 虹软人脸 SDK Active Key */
val arcsoftActiveKey: String? = null,
/** MQTT 客户端服务器 IP */
val clientServerIp: String? = null,
/** 智慧食堂服务器 IP(MQTT 端口) */
val zhstServerIp: String? = null
)
data class CodeMsg(
val code: String? = "",
val msg: String? = ""
)
@@ -12,28 +12,25 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class BindParam( data class BindParam(
/** /**
* app版本号 * 设备Id
*/ */
var appVersion: String = "", val equipmentId: String? = null,
/** /**
* 设备ID * 设备编号
*/ */
@SerializedName("equipmentCode") var equipmentCode: String? = "",
var equipmentCode: String = "",
/** /**
*设备盒子编号 *设备盒子编号
*/ */
@SerializedName("equipmentBoxCode")
val equipmentBoxCode: String? = null, val equipmentBoxCode: String? = null,
/** /**
* *
* 会员Id * 会员Id
*/ */
@SerializedName("memberId") val memberId: String? = null,
val memberId: Int? = null, val faceId: String? = null,
/** /**
*餐盘编号 *餐盘编号
*/ */
@SerializedName("plateNumber")
val plateNumber: String? = null val plateNumber: String? = null
) : Parcelable ) : Parcelable
@@ -0,0 +1,6 @@
package com.sw.platecabinet.model.request
data class FeatureBody(
val url:String,
val featureChar:String
)
@@ -0,0 +1,13 @@
package com.sw.platecabinet.model.request
/**
* 通用 ID 请求体
*
* 用于按用户 id(餐盘号)查询或上报类的接口,例如:
* - P-09a 取餐盘(/neglect/pickup/plate-pickup
*
* @property id 用户 id(餐盘号 = userId
*/
data class IdDTO(
val id: Long
)
@@ -1,37 +0,0 @@
package com.sw.platecabinet.model.request
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
/**
* 登录参数
*/
@Parcelize
data class LoginParam(
/**
* app版本号
*/
var appVersion: String = "",
/**
* 设备ID
*/
@SerializedName("equipmentCode")
var equipmentCode: String = "",
/**
* 会员信息
*/
@SerializedName("memberId")
val memberId: Int? = null,
/**
* 密码
*/
@SerializedName("password")
val password: String? = null,
/**
* 手机号
*/
@SerializedName("phone")
val phone: String? = null
) : Parcelable
@@ -10,25 +10,27 @@ import kotlinx.parcelize.Parcelize
*/ */
@Parcelize @Parcelize
data class SearchParam( data class SearchParam(
/** // /**
* app版本号 // * app版本号
*/ // */
var appVersion: String = "", // var appVersion: String = "",
/** // /**
* 设备ID // * 设备ID
*/ // */
@SerializedName("equipmentCode") // @SerializedName("equipmentCode")
var equipmentCode: String = "", // var equipmentCode: String = "",
@SerializedName("consumptionTime") // @SerializedName("consumptionTime")
val consumptionTime: List<String?>? = listOf(), // val consumptionTime: List<String?>? = listOf(),
@SerializedName("createTime") // @SerializedName("createTime")
val createTime: List<String?>? = listOf(), // val createTime: List<String?>? = listOf(),
@SerializedName("memberFrom") // @SerializedName("memberFrom")
val memberFrom: Int? = 0, // val memberFrom: Int? = 0,
@SerializedName("pageNum") // @SerializedName("pageNum")
val pageNum: Int? = 0, var pageNum: Int? = 1,
@SerializedName("pageSize") // @SerializedName("pageSize")
val pageSize: Int? = 0, var pageSize: Int? = 10,
@SerializedName("param") // @SerializedName("param")
val `param`: String? = "" // val `param`: String? = "",
var name:String?=null,
var phone:String?=null
) : Parcelable ) : Parcelable
@@ -1,10 +1,13 @@
package com.sw.platecabinet.model.response package com.sw.platecabinet.model.response
data class ApiResponse<T>( data class ApiResponse<T>(
val code: Int, val code: String,
val success: Boolean? = false, // val success: Boolean? = false,
val msg: String? = "", val msg: String? = "",
val data: T? = null val data: T? = null,
val total: Int = 0,
// val result: T? = null,
) { ) {
fun isSuccess(): Boolean = code == 200 // fun isSuccess(): Boolean = code == 200
fun isSuccess(): Boolean = code == "00000"
} }
@@ -0,0 +1,72 @@
package com.sw.platecabinet.model.response
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
data class EquipmentInfo(
@SerializedName("appPackageLocalUrl")
val appPackageLocalUrl: String? = "",
@SerializedName("appPackageUrl")
val appPackageUrl: String? = "",
@SerializedName("arcsoftActiveKey")
val arcsoftActiveKey: String? = "",
@SerializedName("arcsoftAppId")
val arcsoftAppId: String? = "",
@SerializedName("arcsoftSdkKey")
val arcsoftSdkKey: String? = "",
@SerializedName("arrayCross")
val arrayCross: Int? = 0,
@SerializedName("arrayMode")
val arrayMode: String? = "",
@SerializedName("arrayVertical")
val arrayVertical: Int? = 0,
@SerializedName("canteenId")
val canteenId: String? = "",
@SerializedName("canteenName")
val canteenName: String? = "",
@SerializedName("clientServerIp")
val clientServerIp: String? = "",
@SerializedName("createBy")
val createBy: String? = "",
@SerializedName("createTime")
val createTime: String? = "",
@SerializedName("customerName")
val customerName: String? = "",
@SerializedName("equipmentCode")
val equipmentCode: String? = "",
@SerializedName("equipmentName")
val equipmentName: String? = "",
@SerializedName("equipmentName_dictText")
val equipmentNameDictText: String? = "",
@SerializedName("id")
val id: String? = "",
@SerializedName("isSync")
val isSync: Int? = 0,
@SerializedName("mqName")
val mqName: String? = "",
@SerializedName("mqPassword")
val mqPassword: String? = "",
@SerializedName("orgCode")
val orgCode: String? = "",
@SerializedName("owningTrack")
val owningTrack: Int? = 0,
@SerializedName("owningTrackOrder")
val owningTrackOrder: Int? = 0,
@SerializedName("screenHtml")
val screenHtml: String? = "",
@SerializedName("serviceRequestAddress")
val serviceRequestAddress: String? = "",
@SerializedName("status")
val status: Int? = 0,
@SerializedName("sysOrgCode")
val sysOrgCode: String? = "",
@SerializedName("updateBy")
val updateBy: String? = "",
@SerializedName("updateTime")
val updateTime: String? = "",
@SerializedName("zhstServerIp")
val zhstServerIp: String? = ""
) : Parcelable
@@ -1,8 +1,8 @@
package com.sw.platecabinet.model.response package com.sw.platecabinet.model.response
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import android.text.TextUtils
//import com.google.gson.annotations.SerializedName
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -12,65 +12,79 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class EquipmentUserInfo( data class EquipmentUserInfo(
/** /**
* 设备盒子编号 * 主键(绑定记录id,后端序列化为字符串)
*/ */
@SerializedName("equipmentBoxCode") val id: String? = "",
val equipmentBoxCode: String? = "",
/**
* 设备编号
*/
@SerializedName("equipmentCode")
val equipmentCode: String? = "",
/** /**
* *
* 设备Id * 设备Id
*/ */
@SerializedName("equipmentId")
val equipmentId: String? = "", val equipmentId: String? = "",
/** /**
* 设备名称 * 设备编号
*/ */
@SerializedName("equipmentName") var equipmentCode: String? = "",
val equipmentName: String? = "",
/** /**
* 主键 * 设备盒子编号
*/ */
@SerializedName("id") var equipmentBoxCode: String? = "",
val id: Int? = 0,
/** /**
* 会员Id * 会员Id
*/ */
@SerializedName("memberId") val faceId: String? = "",
val memberId: Int? = 0,
@SerializedName("name")
val name: String? = "",
@SerializedName("phone")
val phone: String? = "",
/** /**
* 餐盘编号 * 餐盘编号
*/ */
@SerializedName("plateNumber")
var plateNumber: String? = "", var plateNumber: String? = "",
/**
* 设备名称
*/
val equipmentName: String? = "",
val orderNo: String? = "",
/** /**
* *
* 更新时间 * 更新时间
*/ */
@SerializedName("updateTime") val updateTime: String? = "",
val updateTime: String? = ""
val name: String? = "",
val phone: String? = "",
val faceUrl: String? = "",
val mealTime: String? = "",
val mealTimeInterval: String? = "",
val openTime: String? = "",
val openTimeInterval: String? = "",
val eatCount: Int? = 0,
var cardBalance: Double? = 0.toDouble(),
/**
* 用于cardBalance后续拦截判断
*/
var showBalanceNotEnoughDialog: Boolean = false,
var isIntercept: Boolean = false
) : Parcelable { ) : Parcelable {
/** /**
* 是否已绑定 * 是否已绑定
*/ */
fun isBound(): Boolean { fun isBound(): Boolean {
return memberId != null return faceId != null
} }
/** /**
* 是否是其他设备 * 是否是其他设备
*/ */
fun isOtherEquipment(): Boolean { fun isOtherEquipment(): Boolean {
return GlobalData.globalEquipmentCode != equipmentCode return equipmentCode != null && equipmentCode != "" && GlobalData.globalEquipmentCode != equipmentCode
}
fun hasEquipmentBox(): Boolean {
return !TextUtils.isEmpty(equipmentBoxCode)
}
fun isPlaceholder(): Boolean {
return equipmentBoxCode == null || equipmentBoxCode == ""
} }
} }
@@ -49,33 +49,37 @@ data class SearchResult(
) : Parcelable { ) : Parcelable {
@Parcelize @Parcelize
data class Member( data class Member(
@SerializedName("cardBalance") // @SerializedName("cardBalance")
val cardBalance: String? = "", // val cardBalance: String? = "",
@SerializedName("cardCode") // @SerializedName("cardCode")
val cardCode: String? = "", // val cardCode: String? = "",
@SerializedName("consumptionCount") // @SerializedName("consumptionCount")
val consumptionCount: Int? = 0, // val consumptionCount: Int? = 0,
@SerializedName("consumptionTotal") // @SerializedName("consumptionTotal")
val consumptionTotal: String? = "", // val consumptionTotal: String? = "",
@SerializedName("createTime") // @SerializedName("createTime")
val createTime: String? = "", // val createTime: String? = "",
@SerializedName("firstTopUpTime") // @SerializedName("firstTopUpTime")
val firstTopUpTime: String? = "", // val firstTopUpTime: String? = "",
@SerializedName("id") // @SerializedName("id")
val id: Int? = 0, // val id: Int? = 0,
@SerializedName("integralBalance") // @SerializedName("integralBalance")
val integralBalance: Int? = 0, // val integralBalance: Int? = 0,
@SerializedName("lastConsumptionTime") // @SerializedName("lastConsumptionTime")
val lastConsumptionTime: String? = "", // val lastConsumptionTime: String? = "",
@SerializedName("memberFrom") // @SerializedName("memberFrom")
val memberFrom: Int? = 0, // val memberFrom: Int? = 0,
@SerializedName("name") // @SerializedName("name")
// val name: String? = "",
// @SerializedName("phone")
// val phone: String? = "",
// @SerializedName("rewardBalance")
// val rewardBalance: String? = "",
// @SerializedName("topUpBalance")
// val topUpBalance: String? = ""
val id: String? = "",
val faceId: String? = "",
val name: String? = "", val name: String? = "",
@SerializedName("phone")
val phone: String? = "", val phone: String? = "",
@SerializedName("rewardBalance")
val rewardBalance: String? = "",
@SerializedName("topUpBalance")
val topUpBalance: String? = ""
) : Parcelable ) : Parcelable
} }
@@ -2,7 +2,6 @@ package com.sw.platecabinet.model.response
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
/** /**
@@ -10,14 +9,20 @@ import kotlinx.parcelize.Parcelize
*/ */
@Parcelize @Parcelize
data class UserFaceModel( data class UserFaceModel(
@SerializedName("faceFeature") val userId: String? = "",
val faceFeature: String? = "", val faceFeatureStr: String? = "",
@SerializedName("faceFeatureString") val faceUpdateTimestamp: Long? = null,
val faceFeatureString: String? = "", /**
@SerializedName("faceType") * 人脸删除标识
val faceType: String? = "", */
@SerializedName("userFaceId") val faceDeleted: Boolean? = false,
val userFaceId: String? = "", /**
@SerializedName("userId") * 会员编号
val userId: String? = "" */
val cardNo: String? = null,
/**
* 是否会员
*/
val member: Boolean? = null
) : Parcelable ) : Parcelable
@@ -0,0 +1,44 @@
package com.sw.platecabinet.model.response
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import kotlinx.parcelize.Parcelize
/**
* 用户人脸信息
*/
@Parcelize
data class UserFaceModelV2(
val userFaceId: String? = null,
val userId: String? = null,
val faceFeature: String? = null,
val faceFeatureStr: String? = null,
val faceFeatureString: String? = null,
val faceUpdateTimestamp: Long? = null,
val cardNo: String? = null,
val member: Boolean? = false,
val faceDeleted: Boolean? = false,
val personType: String? = null
) : Parcelable {
fun resolveFeatureStr(): String? {
return faceFeature?.takeIf { it.isNotEmpty() }
?: faceFeatureStr?.takeIf { it.isNotEmpty() }
?: faceFeatureString?.takeIf { it.isNotEmpty() }
}
fun toFaceEntity(): FaceEntity {
val featureBase64 = resolveFeatureStr()
return FaceEntity(
userId,
android.util.Base64.decode(featureBase64, android.util.Base64.DEFAULT),
personType,
cardNo,
userId,
userFaceId,
member ?: false,
faceUpdateTimestamp ?: 0L
)
}
}
@@ -0,0 +1,7 @@
package com.sw.platecabinet.mqtt
/** 人脸库已由 MQTT 实时更新(DB 已落库),通知界面层刷新识别引擎内存 */
class FaceChangedEvent
/** MQTT 连接/重连成功,请求执行一次 HTTP 增量补拉兜底 */
class FaceSyncTriggerEvent
@@ -0,0 +1,295 @@
package com.sw.platecabinet.mqtt
import com.google.gson.JsonParser
import com.sw.plate.utils.Base64
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.response.UserFaceModelV2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
/**
* 人脸 MQTT 实时同步订阅器(进程级单例,替代 Hilt 注入)
*
* 订阅服务端人脸变更广播主题(随环境区分):用户在小程序/管理端采集或更换人脸后,
* 服务端实时推送特征码变更,设备端立即更新本地人脸库,无需等待 5 分钟定时增量轮询。
*
* 与增量接口的联动:MQTT 提供秒级实时推送;设备侧另有 5 分钟定时轮询 +
* 连接/重连成功时补拉一次 HTTP 增量,兜底推送失败与设备离线
* (关机/断网/broker 会话过期)漏收的变更。增量水位只由 HTTP 响应推进,
* 本模块不改动水位。
*
* 幂等与乱序按 userFaceId + 时间戳守卫处理(见 [applyRealtimeUpdates])。
*/
object FaceMqttSubscriber {
private const val TAG = "FaceMqttSubscriber"
/** MQTT 账号(dev 域名与测试 IP 共用) */
private const val MQTT_USER = "platform"
private const val MQTT_PASSWORD = "ZrKhZhng6t2tlpid"
/** MQTT 账号(UAT 独立) */
private const val MQTT_USER_UAT = "platform-uat"
private const val MQTT_PASSWORD_UAT = "p2AXu3lsUllbXJEY_A1!"
/**
* 根据业务服务器 BASE_URL 解析对应的 MQTT 环境配置(host、port、协议、订阅主题)。
* 与当前项目的三个预设环境对齐:本地 / 测试 / 生产(UAT)。
* 未匹配的环境返回 null 表示不接入实时同步。
*/
private fun resolveMqttEnv(baseUrl: String): MqttEnv? = when {
baseUrl.startsWith("https://platform-api.uat.shuziweidao.com") ->
MqttEnv("mqtt.uat.shuziweidao.com", 443, "wss", "yx/device/face/update-local",
MQTT_USER_UAT, MQTT_PASSWORD_UAT)
baseUrl.startsWith("https://dev.yixiong-tech.com") ->
MqttEnv("dev.yixiong-tech.com", 8089, "wss", "yx/device/face/update-test",
MQTT_USER, MQTT_PASSWORD)
baseUrl.startsWith("http://192.168.10.101") ->
MqttEnv("192.168.10.101", 1884, "tcp", "yx/device/face/update-dev",
MQTT_USER, MQTT_PASSWORD)
else -> null
}
/** MQTT 环境配置:host、port、协议、订阅主题、账号 */
private data class MqttEnv(
val host: String,
val port: Int,
val scheme: String,
val topic: String,
val userName: String,
val password: String
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val faceApi = FaceApi()
private var started = false
private var collectJob: Job? = null
private var stateJob: Job? = null
/** 当前环境的人脸变更订阅主题(随 start() 按 baseUrl 环境解析) */
private var faceUpdateTopic: String = ""
/**
* 启动订阅(幂等,重复调用不会建立多条连接)
*
* clientId 使用 `platecabinet-{设备UDID}`,与 X-DEVICE-CODE 同源,保证一台设备一条连接。
* 应在应用启动、设备初始化完成后调用。
*/
fun start() {
if (started) return
started = true
val baseUrl = GlobalData.appBaseUrl
val env = resolveMqttEnv(baseUrl) ?: run {
started = false
Timber.w("$TAG 当前环境(baseUrl=$baseUrl)无 MQTT 配置,跳过实时同步,人脸变更仅靠定时增量轮询兜底")
return
}
faceUpdateTopic = env.topic
val udid = GlobalData.deviceId.ifBlank { "unknown" }
Timber.i(
"$TAG 启动人脸 MQTT 订阅, broker=${env.scheme}://${env.host}:${env.port}, " +
"topic=${env.topic}, clientId=platecabinet-$udid"
)
MqttManager.configure(
MqttConfig(
host = env.host,
port = env.port,
scheme = env.scheme,
clientId = "platecabinet-$udid",
userName = env.userName,
password = env.password,
cleanSession = false
)
)
MqttManager.connect()
// 消费广播消息:解析 → 落库 → 通知界面刷新引擎内存
collectJob = scope.launch {
MqttManager.messages.collect { message ->
if (message is MqttMessage.Received) {
if (message.topic == faceUpdateTopic) {
handleFaceUpdate(message.payload)
} else {
// 联调排查:后台发送的 topic 与预期不一致时在此可见
Timber.w("$TAG 收到非人脸主题消息,忽略: topic=${message.topic} size=${message.payload.size}")
}
}
}
}
// 连接状态联动:连接成功 → 恢复订阅 + 触发一次 HTTP 增量补拉
stateJob = scope.launch {
MqttManager.state.collect { state ->
if (state == MqttState.Connected) {
MqttManager.subscribe(faceUpdateTopic, 1)
// 设备离线期间(关机/断网/会话过期)的变更 MQTT 无法补收,
// 通知界面层用水位时间戳补拉一次 HTTP 增量兜底
EventBus.getDefault().post(FaceSyncTriggerEvent())
}
}
}
}
fun stop() {
if (!started) return
started = false
collectJob?.cancel()
stateJob?.cancel()
collectJob = null
stateJob = null
MqttManager.disconnect()
}
/** 解析广播 JSON 数组并落库(faceUpdateTimestamp 兼容 long/String 两种格式) */
private suspend fun handleFaceUpdate(payload: ByteArray) {
// 联调排查:记录原始报文预览(特征码很长,只打前 120 字符)
val raw = String(payload, Charsets.UTF_8)
Timber.i("$TAG 收到人脸广播: size=${payload.size} 预览=${raw.take(120)}")
val items = try {
parsePayload(raw)
} catch (e: Exception) {
Timber.e(e, "$TAG 广播消息解析失败: ${raw.take(300)}")
return
}
// 联调排查:逐条记录关键字段(最多 5 条,特征码只打长度)
items.take(5).forEach {
Timber.i(
"$TAG 解析条目: userFaceId=${it.userFaceId} userId=${it.userId} " +
"deleted=${it.faceDeleted} ts=${it.faceUpdateTimestamp} " +
"featureLen=${it.resolveFeatureStr()?.length ?: 0}"
)
}
Timber.i("$TAG 收到人脸广播 ${items.size} 条,开始落库")
applyRealtimeUpdates(items)
Timber.i("$TAG 人脸广播处理完成 ${items.size}")
}
/** 解析 JSON 数组为 [UserFaceModelV2](时间戳兼容 long 数字与字符串两种格式) */
private fun parsePayload(raw: String): List<UserFaceModelV2> {
val array = JsonParser.parseString(raw).asJsonArray
return array.mapNotNull { element ->
val obj = element.asJsonObject
val tsElement = obj.get("faceUpdateTimestamp")
val timestamp = when {
tsElement == null || tsElement.isJsonNull -> null
tsElement.isJsonPrimitive -> tsElement.asString.toLongOrNull()
else -> null
}
UserFaceModelV2(
userFaceId = obj.get("userFaceId")?.takeIf { !it.isJsonNull }?.asString,
userId = obj.get("userId")?.takeIf { !it.isJsonNull }?.asString,
faceFeature = obj.get("faceFeature")?.takeIf { !it.isJsonNull }?.asString,
faceFeatureStr = null,
faceFeatureString = null,
faceUpdateTimestamp = timestamp,
cardNo = obj.get("cardNo")?.takeIf { !it.isJsonNull }?.asString,
member = obj.get("member")?.takeIf { !it.isJsonNull }?.asBoolean,
faceDeleted = obj.get("faceDeleted")?.takeIf { !it.isJsonNull }?.asBoolean ?: false,
personType = obj.get("personType")?.takeIf { !it.isJsonNull }?.asString
)
}
}
/**
* 应用 MQTT 实时推送的人脸变更(立即落库,不等下一轮轮询)
*
* 处理语义与增量接口一致,另加乱序守卫:
* 按 userFaceId 查本地记录,消息时间戳不大于本地时间戳的条目直接丢弃,
* 防止 QoS1 重复投递或乱序到达时旧数据覆盖新数据。
*
* 与 HTTP 增量轮询通过 [FaceSyncLock] 串行化写库,避免同一 userFaceId 重复入库。
*
* 注意:本方法不推进增量水位。水位只由 HTTP 增量接口的响应推进,
* 后台推送失败只记日志不重发,若 MQ 消息把水位推到漏发变更之后,
* 轮询/补拉将永远拉不到那条变更。
*/
private suspend fun applyRealtimeUpdates(items: List<UserFaceModelV2>) {
if (items.isEmpty()) return
var changed = false
FaceSyncLock.mutex.withLock {
// 批内去重:同一 userFaceId 仅保留时间戳最大的一条(含其删除标志)
val batch = items.groupBy { it.userFaceId }
.flatMap { (key, list) ->
if (key.isNullOrEmpty()) list
else listOfNotNull(list.maxByOrNull { it.faceUpdateTimestamp ?: 0L })
}
// 乱序守卫:本地已有同 userFaceId 且时间戳不旧的记录则跳过该条
val fresh = batch.filter { msg ->
val key = msg.userFaceId
if (key.isNullOrEmpty()) return@filter true
val local = faceApi.queryByUserFaceId(key)
val msgTs = msg.faceUpdateTimestamp ?: 0L
when {
local == null -> true
msgTs <= 0L -> true
msgTs > local.faceUpdateTimestamp -> true
else -> {
Timber.d("$TAG 实时消息乱序/重复,丢弃 userFaceId=$key ts=$msgTs")
false
}
}
}
if (fresh.isEmpty()) return@withLock
for (msg in fresh) {
if (msg.faceDeleted == true) {
val userFaceId = msg.userFaceId
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
faceApi.deleteByUserFaceId(userFaceId)
changed = true
}
} else {
val entity = buildEntity(msg) ?: continue
val userFaceId = msg.userFaceId
if (!userFaceId.isNullOrEmpty()) {
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
if (faceApi.queryByUserFaceId(userFaceId) != null) continue
faceApi.insert(entity)
} else {
// userFaceId 为空时回退到特征判重(与 HTTP 增量逻辑对齐)
val featureStr = msg.resolveFeatureStr()
val existList = faceApi.queryAllByUserName(msg.userId)
val alreadyExists = existList.any { e ->
featureStr != null && Base64.encode(e.featureData) == featureStr
}
if (!alreadyExists) faceApi.insert(entity)
}
changed = true
}
}
}
if (changed) {
Timber.i("$TAG MQTT 实时更新完成 ${items.size} 条(实际变更 $changed 条),通知界面刷新引擎")
EventBus.getDefault().post(FaceChangedEvent())
}
}
/** 将 MQTT 消息转换为 [FaceEntity],特征 Base64 为空或解码失败返回 null */
private fun buildEntity(model: UserFaceModelV2): FaceEntity? {
val featureBase64 = model.resolveFeatureStr()
if (featureBase64.isNullOrBlank()) {
Timber.w("$TAG 人脸特征为空,跳过入库: userFaceId=${model.userFaceId} userId=${model.userId}")
return null
}
return try {
model.toFaceEntity()
} catch (e: Exception) {
Timber.e(e, "$TAG 人脸特征 Base64 解码失败: userFaceId=${model.userFaceId}")
null
}
}
}
@@ -0,0 +1,14 @@
package com.sw.platecabinet.mqtt
import kotlinx.coroutines.sync.Mutex
/**
* 本地人脸库写操作的进程级互斥锁。
*
* MQTT 实时更新([FaceMqttSubscriber])与 HTTP 增量轮询(NetViewModelV2.getFaceIncrementList
* 会并发写本地人脸库。二者对同一 userFaceId 的「判重 → 插入」不是原子操作,
* 并发时会产生重复记录;共享此锁将两路写入串行化。
*/
object FaceSyncLock {
val mutex = Mutex()
}
@@ -0,0 +1,30 @@
package com.sw.platecabinet.mqtt
/**
* MQTT 连接配置
*
* @param host Broker 主机地址
* @param port Broker 端口,默认 1883
* @param scheme 连接协议:tcp(裸 MQTT)、wsMQTT over WebSocket)、wssWebSocket + TLS)。
* EMQX 惯例 1883=tcp / 8083=ws / 8084=wss,端口与协议不匹配时 Broker 会在
* CONNACK 前直接断开(32109/EOFException
* @param clientId 客户端唯一标识;同一 Broker 下多台设备禁止重复,否则旧连接会被踢下线
* @param userName 鉴权用户名,null 表示匿名连接
* @param password 鉴权密码,null 表示无密码
* @param cleanSession false 时保留会话(离线期间 QoS1 消息由 Broker 暂存,重连后补收)
* @param keepAliveInterval 心跳间隔(秒)
* @param connectionTimeout 连接超时(秒)
*/
data class MqttConfig(
val host: String,
val port: Int = 1883,
val scheme: String = "tcp",
val clientId: String? = null,
val userName: String? = null,
val password: String? = null,
val cleanSession: Boolean = true,
val keepAliveInterval: Int = 20,
val connectionTimeout: Int = 30
) {
val brokerUrl: String get() = "$scheme://$host:$port"
}
@@ -0,0 +1,274 @@
package com.sw.platecabinet.mqtt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttClient
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import timber.log.Timber
/** MQTT 连接状态 */
enum class MqttState { Disconnected, Connecting, Connected }
/**
* MQTT 连接管理器(进程级单例,替代 Hilt 注入)
*
* 负责 Broker 连接、自动重连、订阅管理与消息分发:
* - 启用 Paho 自动重连,重连成功后自动恢复历史订阅([MqttCallbackExtended.connectComplete]
* - 业务消息通过 [messages] SharedFlow 分发,业务层按 topic 过滤消费
* - cleanSession=false 时 Broker 会暂存离线期间的 QoS1 消息,重连后补收
*/
object MqttManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: MqttClient? = null
private var config: MqttConfig? = null
/** 已订阅的 topic 及 QoS,自动重连成功后按此恢复订阅 */
private val subscriptions = linkedMapOf<String, Int>()
private val _state = MutableStateFlow(MqttState.Disconnected)
val state: StateFlow<MqttState> = _state.asStateFlow()
private val _messages = MutableSharedFlow<MqttMessage>(extraBufferCapacity = 20)
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
/** 最近一次连接成功时间(毫秒时间戳,null 表示从未连上),供运维面板展示 */
private val _lastConnectedAt = MutableStateFlow<Long?>(null)
val lastConnectedAt: StateFlow<Long?> = _lastConnectedAt.asStateFlow()
/** 最近一次连接丢失时间(毫秒时间戳) */
private val _lastLostAt = MutableStateFlow<Long?>(null)
val lastLostAt: StateFlow<Long?> = _lastLostAt.asStateFlow()
/** 最近一次连接/订阅/发布错误信息 */
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
/** 最近一次收到业务消息时间(毫秒时间戳),用于判断"Connected 但链路假活" */
private val _lastMessageArrivedAt = MutableStateFlow<Long?>(null)
val lastMessageArrivedAt: StateFlow<Long?> = _lastMessageArrivedAt.asStateFlow()
/** 累计连接成功 / 断开次数(用于判断闪断) */
private val _connectCount = MutableStateFlow(0)
val connectCount: StateFlow<Int> = _connectCount.asStateFlow()
private val _disconnectCount = MutableStateFlow(0)
val disconnectCount: StateFlow<Int> = _disconnectCount.asStateFlow()
/** 当前 Broker URL(未配置返回 null */
val brokerUrl: String? get() = config?.brokerUrl
/** 当前 clientId(未配置返回 null */
val clientId: String? get() = config?.clientId
/** 已订阅主题与 QoS 的线程安全快照 */
fun getSubscriptions(): Map<String, Int> = synchronized(subscriptions) { subscriptions.toMap() }
/** 首连失败后的退避重试协程,连接成功或主动断开时取消 */
private var retryJob: Job? = null
fun configure(cfg: MqttConfig) {
config = cfg
Timber.d("MQTT 配置完成: ${cfg.brokerUrl}")
}
fun connect() {
val cfg = config ?: run {
Timber.e("MQTT 未配置,请先调用 configure()")
return
}
// 已连接或正在连接时重复调用直接忽略,避免重建客户端导致重复连接
if (_state.value != MqttState.Disconnected) {
Timber.d("MQTT 当前状态=${_state.value},忽略重复 connect()")
return
}
retryJob?.cancel()
retryJob = scope.launch { doConnect(cfg) }
}
/**
* 建立连接,失败则指数退避重试
*
* Paho 的 automaticReconnect 仅在首连成功后生效;设备开机时 Broker 可能
* 尚未就绪导致首连失败,故此处自建退避循环兜底(1s 起、翻倍、60s 封顶)。
*/
private suspend fun doConnect(cfg: MqttConfig) {
val id = cfg.clientId ?: "platecabinet_${System.currentTimeMillis()}"
var backoffMs = 1_000L
while (true) {
try {
_state.value = MqttState.Connecting
client = MqttClient(cfg.brokerUrl, id, MemoryPersistence()).apply {
setCallback(createCallback())
val options = MqttConnectOptions().apply {
isCleanSession = cfg.cleanSession
keepAliveInterval = cfg.keepAliveInterval
connectionTimeout = cfg.connectionTimeout
isAutomaticReconnect = true
maxReconnectDelay = 60_000
// 鉴权信息(用户名非空时才注入,避免空串覆盖匿名连接)
cfg.userName?.let { userName = it }
cfg.password?.let { password = it.toCharArray() }
}
connect(options)
}
_state.value = MqttState.Connected
_lastConnectedAt.value = System.currentTimeMillis()
Timber.i("MQTT 连接成功: ${cfg.brokerUrl}, clientId=$id, cleanSession=${cfg.cleanSession}")
return
} catch (e: Exception) {
// 连接失败时 MqttClient 可能残留半初始化状态,先关闭清理
try { client?.close() } catch (_: Exception) {}
client = null
_state.value = MqttState.Disconnected
_lastError.value = "连接失败: ${e.message}"
_messages.emit(MqttMessage.Error("连接失败: ${e.message}", e))
Timber.e(e, "MQTT 连接失败,${backoffMs / 1000} 秒后重试")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(60_000L)
}
}
}
fun disconnect() {
// 取消首连重试循环,避免主动断开后仍反复重连
retryJob?.cancel()
retryJob = null
scope.launch {
try { client?.disconnect() } catch (_: Exception) {}
client?.close()
client = null
_state.value = MqttState.Disconnected
_messages.emit(MqttMessage.Disconnected())
Timber.d("MQTT 已断开")
}
}
/** 订阅主题(记录 QoS,自动重连成功后由 [createCallback] 恢复) */
fun subscribe(topic: String, qos: Int = 0) {
synchronized(subscriptions) { subscriptions[topic] = qos }
scope.launch {
// 未连接时仅记录订阅关系,待连接成功后由 connectComplete 恢复,
// 不发 Subscribed 事件以免误报"订阅成功"
val current = client ?: run {
Timber.d("MQTT 未连接,订阅 $topic 已记录,连接成功后自动恢复")
return@launch
}
try {
// subscribe 返回 Broker 实际授予的 QoS(0x80 表示被拒绝),用于联调排查
current.subscribe(topic, qos)
_messages.emit(MqttMessage.Subscribed(topic))
Timber.i("MQTT 订阅成功: $topic qos=$qos")
} catch (e: Exception) {
_messages.emit(MqttMessage.Error("订阅失败: ${e.message}", e))
_lastError.value = "订阅失败: ${e.message}"
}
}
}
fun publish(topic: String, payload: ByteArray, qos: Int = 0, retain: Boolean = false) {
scope.launch {
try {
client?.publish(topic, payload, qos, retain)
_messages.emit(MqttMessage.Published(topic))
} catch (e: Exception) {
_messages.emit(MqttMessage.Error("发布失败: ${e.message}", e))
_lastError.value = "发布失败: ${e.message}"
}
}
}
/**
* 手动重连(运维面板用):单个协程内先断开清理、状态归位,再复用 [doConnect] 的退避重试。
* 区别于 [connect]——后者在非 Disconnected 状态会直接忽略,无法用于"已连接但需强制重连"。
*/
fun reconnect() {
retryJob?.cancel()
retryJob = scope.launch {
val cfg = config ?: run {
_lastError.value = "MQTT 未配置,无法重连"
Timber.e("MQTT 未配置,请先调用 configure()")
return@launch
}
try { client?.disconnect() } catch (_: Exception) {}
try { client?.close() } catch (_: Exception) {}
client = null
_state.value = MqttState.Disconnected
_messages.emit(MqttMessage.Disconnected())
doConnect(cfg)
}
}
fun destroy() {
disconnect()
scope.cancel()
}
/** 使用 Extended 回调:connectComplete 在首次连接与自动重连成功时都会回调,用于恢复订阅 */
private fun createCallback() = object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
scope.launch {
_state.value = MqttState.Connected
_lastConnectedAt.value = System.currentTimeMillis()
_connectCount.value += 1
Timber.i("MQTT connectComplete: reconnect=$reconnect serverURI=$serverURI")
// 重连后恢复历史订阅(cleanSession=false 时 Broker 已保留订阅,重复订阅幂等)
val restore = synchronized(subscriptions) { subscriptions.toMap() }
if (restore.isNotEmpty()) {
try {
client?.subscribe(restore.keys.toTypedArray(), restore.values.toIntArray())
Timber.i("MQTT ${if (reconnect) "重连" else "连接"}完成,已恢复订阅 ${restore.size} 个: ${restore.keys}")
} catch (e: Exception) {
Timber.e(e, "MQTT 恢复订阅失败")
}
} else {
Timber.w("MQTT 连接完成但无历史订阅(尚未调用过 subscribe)")
}
_messages.emit(MqttMessage.Connected)
}
}
override fun connectionLost(cause: Throwable?) {
scope.launch {
_state.value = MqttState.Disconnected
_lastLostAt.value = System.currentTimeMillis()
_lastError.value = cause?.message ?: "未知原因(可能是 Broker 踢线/网络中断)"
_disconnectCount.value += 1
_messages.emit(MqttMessage.Disconnected(cause))
// cause 可能为 null(如 Broker 主动踢 clientId 重复时仅表现为静默断开),
// 打印消息体帮助定位掉线原因
Timber.w(cause, "MQTT 连接丢失: ${cause?.message ?: "无异常信息(可能是 Broker 踢线/网络中断)"}")
}
}
override fun messageArrived(topic: String?, message: org.eclipse.paho.client.mqttv3.MqttMessage?) {
if (topic != null && message != null) {
_lastMessageArrivedAt.value = System.currentTimeMillis()
Timber.i(
"MQTT messageArrived: topic=$topic size=${message.payload.size} " +
"qos=${message.qos} dup=${message.isDuplicate} retained=${message.isRetained}"
)
scope.launch {
_messages.emit(MqttMessage.Received(topic, message.payload))
}
} else {
Timber.w("MQTT messageArrived 收到空消息: topic=$topic message=$message")
}
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {}
}
}
@@ -0,0 +1,11 @@
package com.sw.platecabinet.mqtt
/** MQTT 事件消息密封类 */
sealed class MqttMessage {
data object Connected : MqttMessage()
data class Disconnected(val cause: Throwable? = null) : MqttMessage()
data class Received(val topic: String, val payload: ByteArray) : MqttMessage()
data class Subscribed(val topic: String) : MqttMessage()
data class Published(val topic: String) : MqttMessage()
data class Error(val message: String, val throwable: Throwable? = null) : MqttMessage()
}
@@ -1,7 +1,9 @@
package com.sw.platecabinet.network package com.sw.platecabinet.network
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.MyApp import com.sw.platecabinet.MyApp
import com.sw.platecabinet.network.api.ApiService import com.sw.platecabinet.network.api.ApiService
import com.sw.platecabinet.network.api.ApiServiceV2
import com.sw.platecabinet.network.interceptor.RequestInterceptor import com.sw.platecabinet.network.interceptor.RequestInterceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -11,35 +13,42 @@ import timber.log.Timber
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
private const val BASE_URL = "https://vip.shuziweidao.com"
// private const val BASE_URL = "http://192.168.1.8:9092" private const val TIME_OUT = 60L // 超时时间(秒)
private const val TIME_OUT = 30L // 超时时间(秒)
private val okHttpClient = OkHttpClient.Builder() private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(TIME_OUT, TimeUnit.SECONDS) .connectTimeout(TIME_OUT, TimeUnit.SECONDS)
.readTimeout(TIME_OUT, TimeUnit.SECONDS) .readTimeout(TIME_OUT, TimeUnit.SECONDS)
.writeTimeout(TIME_OUT, TimeUnit.SECONDS) .writeTimeout(TIME_OUT, TimeUnit.SECONDS)
.addNetworkInterceptor(HttpLoggingInterceptor(logger = { .addNetworkInterceptor(HttpLoggingInterceptor { Timber.d("okhttp ==>${it}") }.apply {
Timber.d("okhttp logger ==>${it}") level = if (MyApp.DEBUG) HttpLoggingInterceptor.Level.BODY
}).apply { else HttpLoggingInterceptor.Level.NONE
level = if (MyApp.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}) })
.addInterceptor(RequestInterceptor()) .addInterceptor(RequestInterceptor())
.build() .build()
private val retrofit = Retrofit.Builder() private val retrofit by lazy {
.baseUrl(BASE_URL) Retrofit.Builder()
.baseUrl(resolveBaseUrl())
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
// .addCallAdapterFactory(CoroutineCallAdapterFactory()) // 协程适配器
.build() .build()
}
val apiService: ApiService by lazy { val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java) retrofit.create(ApiService::class.java)
} }
val apiServiceV2: ApiServiceV2 by lazy {
retrofit.create(ApiServiceV2::class.java)
}
/**
* 规范化 Retrofit 要求的 baseUrl:非空且以 / 结尾。
* 所有接口走 @Url 全路径,此值不参与实际拼接,只需合法。
*/
private fun resolveBaseUrl(): String {
val url = GlobalData.appBaseUrl.ifBlank { "http://localhost/" }
return if (url.endsWith("/")) url else "$url/"
}
} }
@@ -1,66 +1,185 @@
package com.sw.platecabinet.network.api package com.sw.platecabinet.network.api
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam import com.sw.platecabinet.model.request.BindParam
import com.sw.platecabinet.model.request.EquipmentParam import com.sw.platecabinet.model.request.EquipmentParam
import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.request.SearchParam import com.sw.platecabinet.model.request.SearchParam
import com.sw.platecabinet.model.response.ApiResponse import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentInfo
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.SearchResult import com.sw.platecabinet.model.response.SearchResult
import com.sw.platecabinet.model.response.UserFaceModel import com.sw.platecabinet.model.response.UserFaceModel
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url
interface ApiService { interface ApiService {
// /**
/** // * device获取token
* 生成token // */
*/ // @GET("sys/getEquipmentToken")
@GET("/shuwei-zhct/scales/generateToken") // suspend fun getDeviceToken(
suspend fun generateToken( // @Query("qrcodeId") qrcodeId: String,
@Query("deviceId") deviceId: String, // @Query("appVersion") appVersion: String = GlobalData.appVersion
@Query("appVersion") appVersion: String, // ): ApiResponse<String>
@Query("equipmentCode") equipmentCode: String //
): ApiResponse<String> // /**
// *获取配置信息
// */
// @GET("equipment/stEquipment/queryByEquipmentCode")
// suspend fun getDeviceInfo(
// @Query("equipmentCode") equipmentCode: String,
// @Query("appVersion") appVersion: String = GlobalData.appVersion,
// @Header("X-Access-Token") token: String
// ): ApiResponse<EquipmentInfo>
//
// /**
// * 生成token
// */
// @GET//("/shuwei-zhct/scales/generateToken")
// suspend fun generateToken(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/scales/generateToken",
// @Query("deviceId") deviceId: String,
// @Query("appVersion") appVersion: String,
// @Query("equipmentCode") equipmentCode: String
// ): ApiResponse<String>
/** /**
* 获取人脸数据 * 获取人脸数据
*/ */
@GET("/shuwei-zhct/scales/getUserFaceCache") // @GET//("/shuwei-zhct/scales/getUserFaceCache")
// suspend fun getUserFaceCache(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/scales/getUserFaceCache",
// @Query("appVersion") appVersion: String,
// @Query("equipmentCode") equipmentCode: String
// ): ApiResponse<List<UserFaceModel>>
@POST
suspend fun getUserFaceCache( suspend fun getUserFaceCache(
@Query("appVersion") appVersion: String, @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/common/app/faceFeature/list",
@Query("equipmentCode") equipmentCode: String @Body param: Map<String, Int>
): ApiResponse<List<UserFaceModel>> ): ApiResponse<List<UserFaceModel>>
// /**
// * 餐盘用户信息获取
// */
// @POST//("/shuwei-zhct/swEquipmentRelUser/equipmentBoxLogin")
// suspend fun equipmentBoxLogin(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/swEquipmentRelUser/equipmentBoxLogin",
// @Body param: LoginParam
// ): ApiResponse<EquipmentUserInfo>
/** /**
* 餐盘用户信息获取 * 通过用户ID获取信息(人脸识别匹配到 userId 后调用,替代旧 getPlateBoxUserInfo 登录接口)
*/ */
@POST("/shuwei-zhct/swEquipmentRelUser/equipmentBoxLogin") @GET
suspend fun equipmentBoxLogin(@Body param: LoginParam): ApiResponse<EquipmentUserInfo> suspend fun getMemberRefPlateByUserId(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getMemberRefPlateByUserId",
@Query("userId") userId: String
): ApiResponse<EquipmentUserInfo>
/** /**
* 餐盘用户信息列表查询 * 餐盘用户信息列表查询
*/ */
@POST("/shuwei-zhct/swEquipmentRelUser/list") // @POST//("/shuwei-zhct/swEquipmentRelUser/list")
suspend fun getEquipmentList(@Body param: EquipmentParam): ApiResponse<List<EquipmentUserInfo>> // suspend fun getEquipmentList(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/swEquipmentRelUser/list",
// @Body param: EquipmentParam
// ): ApiResponse<List<EquipmentUserInfo>>
@GET
suspend fun getEquipmentList(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getYxMemberRefPlateByEquipmentCode",
): ApiResponse<List<EquipmentUserInfo>>
// /**
// * 餐盘用户信息绑定解绑
// */
// @POST//("/shuwei-zhct/swEquipmentRelUser/addOrEdit")
// suspend fun bindEquipment(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/swEquipmentRelUser/addOrEdit",
// @Body param: BindParam
// ): ApiResponse<EquipmentUserInfo?>
/** /**
* 餐盘用户信息绑定解 * 餐盘绑
*/ */
@POST("/shuwei-zhct/swEquipmentRelUser/addOrEdit") @POST
suspend fun bindEquipment(@Body param: BindParam): ApiResponse<EquipmentUserInfo?> suspend fun plateBind(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/plateBinding",
@Body param: BindParam
): ApiResponse<EquipmentUserInfo?>
/**
* 餐盘解绑(JSON 请求体)
*/
@POST
suspend fun plateUnbind(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/plateUnbind",
@Body param: Map<String, String>
): ApiResponse<Any?>
/** /**
* 用户信息模糊搜索 * 用户信息模糊搜索
*/ */
@POST("/shuwei-user/swclientUserInfoShop/selectList") @POST//("/shuwei-user/swclientUserInfoShop/selectList")
suspend fun searchUser(@Body param: SearchParam): ApiResponse<SearchResult> suspend fun searchUser(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-user/swclientUserInfoShop/selectList",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/app/getUserInfoByNameOrPhone",
@Body param: SearchParam
): ApiResponse<List<SearchResult.Member>?>
// /**
// * 通过餐盘号获取信息
// */
// @POST//("/shuwei-zhct/swEquipmentRelUser/findByPlateNumber")
// suspend fun findByPlateNumber(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/swEquipmentRelUser/findByPlateNumber",
// @Body param: BindParam
// ): ApiResponse<EquipmentUserInfo>
/** /**
* 通过餐盘号获取信息 * 通过餐盘号获取信息
*/ */
@POST("/shuwei-zhct/swEquipmentRelUser/findByPlateNumber") @GET
suspend fun findByPlateNumber(@Body param: BindParam): ApiResponse<EquipmentUserInfo> suspend fun findByPlateNumber(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getMemberRefPlateByPlateNumber",
@Query("plateNumber") plateNumber: String
): ApiResponse<EquipmentUserInfo>
/**
* 获取人脸增量数据
*
* @param timeDate 时间戳(毫秒级)
*/
// @GET//("/shuwei-zhct/scales/getHeartbeatInterface")
// suspend fun getHeartbeatInterface(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-zhct/scales/getHeartbeatInterface",
// @Query("timeDate") timeDate: Long
// ): ApiResponse<List<UserFaceModel>>
/**
* 获取人脸增量数据
*/
@POST
suspend fun getFaceIncrementList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/common/app/faceFeature/increment/list",
@Body param:Map<String, Long>
): ApiResponse<List<UserFaceModel>?>
/**
* 获取设备配置数据
*/
@GET
suspend fun getDeviceConfig(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/common/app/getYxEquipmentByEquipmentCode"
): ApiResponse<DeviceConfig?>
} }
@@ -0,0 +1,78 @@
package com.sw.platecabinet.network.api
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.request.FeatureBody
import com.sw.platecabinet.model.request.IdDTO
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Url
interface ApiServiceV2 {
/**
* 获取人脸缓存数据
*/
@POST
suspend fun getUserFaceCache(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/face/page",
@Body param: Map<String, Int>
): ApiResponse<List<UserFaceModelV2>>
/**
* 获取人脸增量数据
*/
@POST
suspend fun getFaceIncrementList(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/face/increment",
@Body param: Map<String, Long>
): ApiResponse<List<UserFaceModelV2>?>
/**
* 获取设备配置数据
*/
@GET
suspend fun getDeviceConfig(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/pickup/device/config"
): ApiResponse<DeviceConfigV2?>
/**
* 添加人脸数据
*/
@POST
suspend fun addUserFace(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/user/add-by-face",
@Body faceData: FeatureBody
): ApiResponse<UserFaceModelV2?>
/**
* 上传图片
*/
@Multipart
@POST
suspend fun uploadImage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/upload",
@Part file: MultipartBody.Part,
): ApiResponse<String?>
/**
* 取餐盘(P-09a):记录用户开始就餐时刻
* 调用一次即 upsert 到主单 plate_pickup_time,是 P-09 提交就餐记录的强前置。
* @param param IdDTOid 为用户 id(餐盘号 = userId
* @return ApiResponse<String?>data 为主单 recordNo
*/
@POST
suspend fun platePickup(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/pickup/plate-pickup",
@Body param: IdDTO
): ApiResponse<String?>
}
@@ -2,6 +2,7 @@ package com.sw.platecabinet.network.interceptor
import com.sw.inbound.utils.SPUtil import com.sw.inbound.utils.SPUtil
import com.sw.plate.App import com.sw.plate.App
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey import com.sw.platecabinet.GlobalKey
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@@ -16,7 +17,10 @@ class RequestInterceptor : Interceptor {
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Accept", "application/json") .header("Accept", "application/json")
// .header("Authorization", "Bearer ${getToken()}") // .header("Authorization", "Bearer ${getToken()}")
.header("Authorization", getToken()) // .header("Authorization", getToken())
.header("X-Access-Token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiJiZTE1NDgzMS0zNDY2LTNiYTItYTJlYS01NzY1MmM5MTlmZWQiLCJ0eXBlIjoiNCIsInVzZXJJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDEifQ.sN40cOC-O5WQFrF4IDUs8fFlkNdUKLbJt_rHyTsgYYM")
.header("X-DEVICE-CODE", GlobalData.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
val newRequest = requestBuilder.build() val newRequest = requestBuilder.build()
@@ -0,0 +1,92 @@
package com.sw.platecabinet.network.task
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.sw.plate.utils.arcface.FaceApi
import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepository
class HeartBeatTask(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
companion object {
private const val TAG = "HeartBeatTask"
}
override suspend fun doWork(): Result {
return try {
// 执行后台任务逻辑
performSync()
TaskManager.startTask()
Result.success()
} catch (e: Exception) {
e.printStackTrace()
Result.retry()
}
}
private val repository: RemoteRepository by lazy {
RemoteRepository(ApiClient.apiService)
}
private val faceApi: FaceApi = FaceApi()
private suspend fun performSync() {
// withContext(Dispatchers.IO) {
// val faceUpdateTimeStamp = PrefUtils.getLong(App.getContext(), "faceUpdateTimeStamp", 0)
// if (faceUpdateTimeStamp == 0L) {
// val timestamp = System.currentTimeMillis()
// PrefUtils.setLong(App.getContext(), "faceUpdateTimeStamp", timestamp)
// }
// val resp = repository.getHeartbeatInterface(
// PrefUtils.getLong(
// App.getContext(),
// "faceUpdateTimeStamp",
// 0
// )
// )
// Timber.tag(TAG).d("performSync: ${GsonUtils.toJson(resp)}")
// val list = resp.data
// if (list.isNullOrEmpty()) {
// return@withContext
// }
// val faceServer = FaceServer.getInstance()
// val faceEngine: FaceEngine? = faceServer.faceEngine
//
// list.forEachIndexed { index, it ->
// try {
// if (index == list.size - 1) {
// PrefUtils.setLong(
// App.getContext(),
// "faceUpdateTimeStamp",
// it.updateTimeStamp
// )
// }
// if (it.faceType == "1") {
// if (it.userId.isNullOrBlank().not()) {
// //userId非空,根据userId删除人脸数据
// faceApi.deleteByUserName(it.userId)
// faceEngine?.removeFaceFeature(it.userId.toInt())
// }
// } else if (it.faceType == "0") {
// //新增人脸数据
// val featureData = Base64.decode(it.faceFeature)
// val insertEntity = FaceEntity(it.userId, null, featureData)
// val faceId = faceApi.insert(insertEntity)
// insertEntity.faceId = faceId
//// faceServer.registerFaceFeatureInfoFromDb(insertEntity, faceEngine)
//
// EventBus.getDefault().post(insertEntity)
//// callback(insertEntity)
// }
// } catch (e: Exception) {
// e.printStackTrace()
// }
// }
// }
}
}
@@ -0,0 +1,40 @@
package com.sw.platecabinet.network.task
import androidx.lifecycle.LifecycleOwner
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.sw.plate.App
import java.util.concurrent.TimeUnit
object TaskManager {
private const val TASK_NAME = "SmartPlateCabinetTask"
// private var owner: LifecycleOwner?=null
// fun setOwner(owner: LifecycleOwner) {
// this.owner = owner
// }
fun startTask() {
val nextRequest = OneTimeWorkRequestBuilder<HeartBeatTask>()
.setInitialDelay(1, TimeUnit.MINUTES)
// .setInputData(inputData)
.build()
WorkManager.getInstance(App.getContext())
// .apply{
// getWorkInfoByIdLiveData(nextRequest.id)
// .observe(owner) { workInfo ->
// if (workInfo?.state == WorkInfo.State.SUCCEEDED) {
// val result = workInfo.outputData.getString("RESULT_KEY")
// // 使用返回的结果
// }
// }
// }
.enqueueUniqueWork(TASK_NAME, ExistingWorkPolicy.REPLACE, nextRequest)
}
fun cancelTask() {
WorkManager.getInstance(App.getContext())
.cancelUniqueWork(TASK_NAME)
}
}
@@ -0,0 +1,21 @@
package com.sw.platecabinet.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.sw.platecabinet.activity.DeviceInitActivity
import com.sw.platecabinet.activity.LoginByFaceActivity
import timber.log.Timber
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
Timber.d("设备启动完成,开始执行自启动逻辑")
// val intent = Intent(context, LoginByFaceActivity::class.java)
val intent = Intent(context, DeviceInitActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
}
}
@@ -1,6 +1,8 @@
package com.sw.platecabinet.repository package com.sw.platecabinet.repository
import com.google.gson.JsonParseException import com.google.gson.JsonParseException
import com.sw.inbound.utils.GsonUtils
import com.sw.platecabinet.model.CodeMsg
import com.sw.platecabinet.model.response.ApiResponse import com.sw.platecabinet.model.response.ApiResponse
import retrofit2.HttpException import retrofit2.HttpException
import timber.log.Timber import timber.log.Timber
@@ -18,31 +20,37 @@ abstract class BaseRepository {
when (e) { when (e) {
is HttpException -> { is HttpException -> {
ApiResponse(code = e.code(), msg = e.message()) val jsonData = e.response()?.errorBody()?.string()
val codeMsg = GsonUtils.fromJson(jsonData, CodeMsg::class.java)
if (codeMsg?.msg.isNullOrBlank()) {
ApiResponse(code = "" + e.code(), msg = e.message())
} else {
ApiResponse(code = codeMsg?.code?:"", msg = codeMsg?.msg?:"")
}
} }
is SocketTimeoutException -> { is SocketTimeoutException -> {
ApiResponse(code = -2, msg = "请求超时: ${e.message}") ApiResponse(code = "-2", msg = "请求超时: ${e.message}")
} }
is ConnectException -> { is ConnectException -> {
ApiResponse(code = -3, msg = "连接失败: ${e.message}") ApiResponse(code = "-3", msg = "连接失败: ${e.message}")
} }
is SSLHandshakeException -> { is SSLHandshakeException -> {
ApiResponse(code = -4, msg = "SSL握手失败: ${e.message}") ApiResponse(code = "-4", msg = "SSL握手失败: ${e.message}")
} }
is JsonParseException -> { is JsonParseException -> {
ApiResponse(code = -5, msg = "JSON解析错误: ${e.message}") ApiResponse(code = "-5", msg = "JSON解析错误: ${e.message}")
} }
is IOException -> { is IOException -> {
ApiResponse(code = -6, msg = "网络IO错误: ${e.message}") ApiResponse(code = "-6", msg = "网络IO错误: ${e.message}")
} }
else -> { else -> {
ApiResponse(code = -1, msg = "未知错误: ${e.message ?: "无错误信息"}") ApiResponse(code = "-1", msg = "未知错误: ${e.message ?: "无错误信息"}")
} }
} }
} }
@@ -1,9 +1,7 @@
package com.sw.platecabinet.repository package com.sw.platecabinet.repository
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam import com.sw.platecabinet.model.request.BindParam
import com.sw.platecabinet.model.request.EquipmentParam
import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.request.SearchParam import com.sw.platecabinet.model.request.SearchParam
import com.sw.platecabinet.model.response.ApiResponse import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
@@ -17,63 +15,129 @@ import com.sw.platecabinet.network.api.ApiService
class RemoteRepository constructor( class RemoteRepository constructor(
private val apiService: ApiService private val apiService: ApiService
) : BaseRepository() { ) : BaseRepository() {
/**
* 生成token // /**
*/ // * 生成token
suspend fun generateToken(deviceId: String): ApiResponse<String> { // */
return safeApiCall { // suspend fun getDeviceToken(qrcodeId: String): ApiResponse<String> {
apiService.generateToken( // return safeApiCall {
deviceId, // apiService.getDeviceToken(
GlobalData.appVersion, // qrcodeId
GlobalData.globalEquipmentCode // )
) // }
} // }
} //
// /**
// * 获取设备信息
// */
// suspend fun getDeviceInfo(equipmentCode: String, token: String): ApiResponse<EquipmentInfo> {
// return safeApiCall {
// apiService.getDeviceInfo(
// equipmentCode,
// token = token
// )
// }
// }
//
// /**
// * 生成token
// */
// suspend fun generateToken(deviceId: String): ApiResponse<String> {
// return safeApiCall {
// apiService.generateToken(
// deviceId = deviceId,
// appVersion = GlobalData.appVersion,
// equipmentCode = GlobalData.globalEquipmentCode
// )
// }
// }
/** /**
* 获取人脸数据 * 获取人脸数据
*/ */
suspend fun getUserFaceCache(): ApiResponse<List<UserFaceModel>> { suspend fun getUserFaceCache(
pageNum: Int,
pageSize: Int = 100,
): ApiResponse<List<UserFaceModel>> {
return safeApiCall { return safeApiCall {
apiService.getUserFaceCache( apiService.getUserFaceCache(
GlobalData.appVersion, param = mapOf(
GlobalData.globalEquipmentCode "pageNum" to pageNum,
"pageSize" to pageSize
)
) )
} }
} }
/** /**
* 登录 * 通过用户ID获取信息(人脸识别匹配到 userId 后调用)
*/ */
suspend fun equipmentBoxLogin(param: LoginParam): ApiResponse<EquipmentUserInfo> { suspend fun getMemberRefPlateByUserId(userId: String?): ApiResponse<EquipmentUserInfo> {
return safeApiCall { apiService.equipmentBoxLogin(param) } return safeApiCall { apiService.getMemberRefPlateByUserId(userId = userId ?: "") }
} }
/** /**
* 获取设备绑定用户列表 * 获取设备绑定用户列表
*/ */
suspend fun getEquipmentList(param: EquipmentParam): ApiResponse<List<EquipmentUserInfo>> { suspend fun getEquipmentList(): ApiResponse<List<EquipmentUserInfo>> {
return safeApiCall { apiService.getEquipmentList(param) } return safeApiCall { apiService.getEquipmentList() }
} }
/** // /**
* 餐盘用户信息绑定解绑 // * 餐盘用户信息绑定解绑
*/ // */
suspend fun bindEquipment(param: BindParam): ApiResponse<EquipmentUserInfo?> { // suspend fun bindEquipment(param: BindParam): ApiResponse<EquipmentUserInfo?> {
return safeApiCall { apiService.bindEquipment(param) } // return safeApiCall { apiService.bindEquipment(param = param) }
// }
suspend fun plateBind(param: BindParam): ApiResponse<EquipmentUserInfo?> {
return safeApiCall { apiService.plateBind(param = param) }
}
suspend fun plateUnbind(id: String?): ApiResponse<Any?> {
return safeApiCall { apiService.plateUnbind(param = mapOf("id" to (id ?: ""))) }
} }
/** /**
* 用户信息模糊搜索 * 用户信息模糊搜索
*/ */
suspend fun searchUser(param: SearchParam): ApiResponse<SearchResult> { suspend fun searchUser(param: SearchParam): ApiResponse<List<SearchResult.Member>?> {
return safeApiCall { apiService.searchUser(param) } return safeApiCall { apiService.searchUser(param = param) }
} }
/** /**
* 通过餐盘编号获取绑定信息 * 通过餐盘编号获取绑定信息
*/ */
suspend fun findByPlateNumber(param: BindParam): ApiResponse<EquipmentUserInfo> { suspend fun findByPlateNumber(plateNumber: String): ApiResponse<EquipmentUserInfo> {
return safeApiCall { apiService.findByPlateNumber(param) } return safeApiCall { apiService.findByPlateNumber(plateNumber = plateNumber) }
}
/**
* 获取人脸增量数据
*
* @param timeDate 时间戳(毫秒级)
*/
// suspend fun getHeartbeatInterface(timeDate: Long): ApiResponse<List<UserFaceModel>> {
// return safeApiCall { apiService.getHeartbeatInterface(timeDate = timeDate) }
// }
suspend fun getFaceIncrementList(
pageNum: Long,
pageSize: Long = 100L,
timestamp: Long
): ApiResponse<List<UserFaceModel>?> {
return safeApiCall {
apiService.getFaceIncrementList(
param = mapOf(
"pageNum" to pageNum,
"pageSize" to pageSize,
"timestamp" to timestamp
)
)
}
}
suspend fun getDeviceConfig(): ApiResponse<DeviceConfig?> {
return safeApiCall { apiService.getDeviceConfig() }
} }
} }
@@ -0,0 +1,82 @@
package com.sw.platecabinet.repository
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.request.FeatureBody
import com.sw.platecabinet.model.request.IdDTO
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.network.api.ApiServiceV2
import com.sw.platecabinet.utils.FileUtils
import java.io.File
/**
* 远程数据处理
*/
class RemoteRepositoryV2 constructor(
private val apiService: ApiServiceV2
) : BaseRepository() {
/**
* 获取人脸数据
*/
suspend fun getUserFaceCache(
pageNum: Int,
pageSize: Int = 500,
): ApiResponse<List<UserFaceModelV2>> {
return safeApiCall {
apiService.getUserFaceCache(
param = mapOf(
"pageNum" to pageNum, "pageSize" to pageSize
)
)
}
}
suspend fun getFaceIncrementList(
pageNum: Long, pageSize: Long = 100L, timestamp: Long
): ApiResponse<List<UserFaceModelV2>?> {
return safeApiCall {
apiService.getFaceIncrementList(
param = mapOf(
"pageNum" to pageNum, "pageSize" to pageSize, "timestamp" to timestamp
)
)
}
}
suspend fun getDeviceConfig(): ApiResponse<DeviceConfigV2?> {
return safeApiCall { apiService.getDeviceConfig() }
}
suspend fun addUserFace(faceData: String, imageUrl: String): ApiResponse<UserFaceModelV2?> {
return safeApiCall {
apiService.addUserFace(
faceData = FeatureBody(
url = imageUrl, featureChar = faceData
)
)
}
}
suspend fun uploadImage(file: File): ApiResponse<String?> {
return safeApiCall {
val part = FileUtils.genRequestPart(file)
if (part == null) {
ApiResponse(code = "-1", msg = "解析图片失败")
} else {
apiService.uploadImage(file = part)
}
}
}
/**
* 取餐盘(P-09a):上报用户开始就餐时刻
* @param userId 用户 id(餐盘号 = userId
* @return ApiResponse<String?>data 为主单 recordNo
*/
suspend fun platePickup(userId: Long): ApiResponse<String?> {
return safeApiCall { apiService.platePickup(param = IdDTO(id = userId)) }
}
}
@@ -0,0 +1,237 @@
package com.sw.platecabinet.utils
import android.app.ActivityManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import com.sw.platecabinet.activity.InitActivity
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
/**
* 崩溃处理
*/
class CrashHandler private constructor(private val context: Context) :
Thread.UncaughtExceptionHandler {
companion object {
private const val TAG = "CrashHandler"
private const val CRASH_REPORTS_DIR = "crash_reports"
private const val LOG_LINES = 500 // 收集最近500行日志
@Volatile
private var instance: CrashHandler? = null
fun init(context: Context) {
if (instance == null) {
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
}
}
}
}
fun getCrashReportFiles(context: Context): Array<File> {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
return if (crashDir.exists() && crashDir.isDirectory) {
crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
} else {
emptyArray()
}
}
fun clearCrashReports(context: Context) {
getCrashReportFiles(context).forEach { it.delete() }
}
}
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread, ex: Throwable) {
handleException(thread, ex)
// 如果系统提供了默认的异常处理器,则交给系统去结束程序
// 否则自己结束程序
defaultHandler?.uncaughtException(thread, ex) ?: run {
Process.killProcess(Process.myPid())
exitProcess(1)
}
}
/**
* 自动重启app
*/
private fun restartApp() {
// 延迟1秒后重启应用
Handler(Looper.getMainLooper()).postDelayed({
val intent = Intent(context, InitActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
Process.killProcess(Process.myPid())
exitProcess(1)
}, 1000)
}
private fun handleException(thread: Thread, ex: Throwable) {
// 收集设备信息和异常信息
val crashInfo = collectCrashInfo(thread, ex)
// 保存日志文件
saveCrashInfoToFile(crashInfo)
// 这里可以添加其他处理逻辑,比如上传到服务器等
}
private fun collectCrashInfo(thread: Thread, ex: Throwable): String {
return buildString {
// 收集设备信息
collectDeviceInfo(this)
// 收集应用日志
append("\n\n").append(collectLogs())
// 收集线程和异常信息
append("\n\n========== Thread & Exception Info ==========\n")
append("Thread: ${thread.name}\n")
append("Stack Trace:\n")
val sw = StringWriter()
val pw = PrintWriter(sw)
ex.printStackTrace(pw)
var cause: Throwable? = ex.cause
while (cause != null) {
cause.printStackTrace(pw)
cause = cause.cause
}
pw.close()
append(sw.toString())
}
}
private fun collectDeviceInfo(sb: StringBuilder) {
sb.append("========== Device Info ==========\n")
try {
// 应用信息
val pm = context.packageManager
val pi = pm.getPackageInfo(context.packageName, 0)
sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n")
// Android 版本信息
sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n")
// 设备信息
sb.append("Vendor: ${Build.MANUFACTURER}\n")
sb.append("Model: ${Build.MODEL}\n")
sb.append("CPU ABI: ${Build.SUPPORTED_ABIS[0]}\n")
// 其他信息
sb.append("Locale: ${Locale.getDefault()}\n")
sb.append(
"Current Time: ${
SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault()
).format(Date())
}\n"
)
// 内存信息
val memoryInfo = ActivityManager.MemoryInfo()
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(memoryInfo)
sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n")
sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n")
sb.append("Low Memory: ${memoryInfo.lowMemory}\n")
} catch (e: Exception) {
Timber.e(e, "Error while collecting device info")
sb.append("Error while collecting device info: ${e.message}\n")
}
}
private fun collectLogs(): String {
return buildString {
append("========== Application Logs ==========\n")
try {
val process = Runtime.getRuntime().exec("logcat -d -v threadtime")
val reader = process.inputStream.bufferedReader()
val logLines = reader.readLines()
val start = kotlin.comparisons.maxOf(0, logLines.size - LOG_LINES)
logLines.subList(start, logLines.size).forEach {
append(it).append("\n")
}
} catch (e: IOException) {
Timber.e(e, "Error collecting logs")
append("Error collecting logs: ${e.message}\n")
}
}
}
private fun saveCrashInfoToFile(crashInfo: String) {
try {
val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
val fileName = "crash_$time.log"
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() && !crashDir.mkdirs()) {
Timber.tag(TAG).e("Failed to create crash report directory")
return
}
val crashFile = File(crashDir, fileName)
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
Timber.tag(TAG).d("Crash info saved to: ${crashFile.absolutePath}")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error saving crash info to file")
}
}
/**
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
}
}
@@ -2,6 +2,7 @@ package com.sw.inbound.utils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -67,21 +68,22 @@ object DateTimeUtils {
val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
format.parse(timeString) format.parse(timeString)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() // e.printStackTrace()
Timber.e(e.message)
null null
} }
} }
/** /**
* 判断给定时间是否距离当前时间超过36小时 * 判断给定时间是否距离当前时间超过72小时
* @param timeInMillis 时间戳(毫秒) * @param timeInMillis 时间戳(毫秒)
* @return true 表示超过36小时,false 表示未超过 * @return true 表示超过72小时,false 表示未超过
*/ */
fun isMoreThan36HoursFromNow(timeInMillis: Long): Boolean { fun isMoreThanHoursFromNow(timeInMillis: Long): Boolean {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val timeDifference = currentTime - timeInMillis val timeDifference = currentTime - timeInMillis
val hoursDifference = timeDifference / (1000 * 60 * 60) // 毫秒转小时 val hoursDifference = timeDifference / (1000 * 60 * 60) // 毫秒转小时
return hoursDifference >= 36 return hoursDifference >= 72
} }
/** /**
@@ -0,0 +1,25 @@
package com.sw.platecabinet.utils
class Debouncer(private val delayMillis: Long) {
private var lastActionTime = 0L
/**
* 执行防抖操作
* @param action 要执行的操作
* @return Boolean 是否执行了操作 (true=已执行, false=被防抖)
*/
fun debounce(action: () -> Unit): Boolean {
val currentTime = System.currentTimeMillis()
if (currentTime - lastActionTime >= delayMillis) {
lastActionTime = currentTime
action()
return true
}
return false
}
// 重置防抖计时
fun reset() {
lastActionTime = 0L
}
}
@@ -0,0 +1,61 @@
package com.sw.platecabinet.utils
import android.Manifest
import android.app.ActivityManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
/**
* 设备健康只读信息:运行时长、内存、内部存储。
*/
object DeviceInfoProvider {
/** 进程运行时长(工控机 App 常驻,近似设备运行时长),格式「x小时x分」 */
fun uptime(): String {
val ms = SystemClock.elapsedRealtime()
val days = ms / (24 * 3600 * 1000L)
val hours = (ms % (24 * 3600 * 1000L)) / (3600 * 1000L)
val minutes = (ms % (3600 * 1000L)) / (60 * 1000L)
return if (days > 0) "${days}${hours}小时${minutes}" else "${hours}小时${minutes}"
}
/** 内存摘要:可用/总(MB) */
fun memorySummary(context: Context): String {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return "未知"
val mi = ActivityManager.MemoryInfo()
am.getMemoryInfo(mi)
val avail = mi.availMem / (1024 * 1024)
val total = mi.totalMem / (1024 * 1024)
val low = if (mi.lowMemory) "(低内存告警)" else ""
return "可用 ${avail}MB / 总 ${total}MB$low"
}
/** 内部存储剩余(日志与人脸库都落在 filesDir,满盘是隐形故障源),格式「可用/总 MB」 */
fun internalStorage(context: Context): String {
return try {
val stat = StatFs(context.filesDir.absolutePath)
val total = stat.totalBytes / (1024 * 1024)
val avail = stat.availableBytes / (1024 * 1024)
"可用 ${avail}MB / 总 ${total}MB"
} catch (e: Exception) {
"未知"
}
}
/** 摄像头状态:系统识别到的摄像头数量 + 相机权限是否授予 */
fun cameraSummary(context: Context): String {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
val count = try {
val cm = context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
cm?.cameraIdList?.size ?: 0
} catch (e: Exception) {
0
}
return "摄像头: $count 个 | 权限: ${if (granted) "已授予" else "未授予"}"
}
}
@@ -0,0 +1,135 @@
package com.sw.platecabinet.utils
import android.content.Context
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.comn.SerialApi
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.mqtt.MqttManager
import timber.log.Timber
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* 诊断包导出:把设备信息、运行日志、崩溃日志打包成 zip。
* 优先写 U 盘根目录;U 盘不可用或写入失败时兜底到应用 filesDir/diagnostic/。
* 工控机现场无微信等分享应用,故不走 FileProvider 分享。
*/
object DiagnosticExporter {
/** 导出结果 */
data class ExportResult(val success: Boolean, val message: String, val file: File? = null)
private val faceApi = FaceApi()
/**
* 导出诊断包(在 IO 线程调用):
* 1. 优先写 U 盘根目录;
* 2. U 盘不可用或写入失败时,兜底到应用 filesDir/diagnostic/,提示中给出绝对路径(便于 adb pull)。
*/
fun export(context: Context): ExportResult {
// 1. 优先尝试 U 盘
UsbStorageHelper.findUsbDir(context)?.let { usbDir ->
try {
val file = buildZip(context, usbDir)
return ExportResult(true, "已导出到 U 盘: ${file.name}", file)
} catch (_: Exception) {
// U 盘写入失败,落入应用目录兜底
}
}
// 2. 兜底:应用目录(仅保留最新一份,避免累积占用内部存储)
val appDir = File(context.filesDir, "diagnostic").apply { mkdirs() }
appDir.listFiles { f -> f.isFile && f.name.endsWith(".zip") }?.forEach { it.delete() }
return try {
val file = buildZip(context, appDir)
ExportResult(true, "U 盘不可用,已导出到应用目录:\n${file.absolutePath}", file)
} catch (e: Exception) {
ExportResult(false, "导出失败: ${e.message}")
}
}
/** 生成诊断 zip 并写入目标目录 */
private fun buildZip(context: Context, outDir: File): File {
val time = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val zipFile = File(outDir, "diagnostic_$time.zip")
Timber.d("Building zip file: ${zipFile.absolutePath}")
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
// 1. 设备快照 info.txt
zos.putNextEntry(ZipEntry("info.txt"))
zos.write(buildInfo(context).toByteArray(Charsets.UTF_8))
zos.closeEntry()
// 2. 运行日志
LogFileManager.getLogFiles(context).forEach { file ->
addFile(zos, file, "logs/${file.name}")
}
// 3. 崩溃日志
CrashHandler.getCrashReportFiles(context).forEach { file ->
addFile(zos, file, "crash/${file.name}")
}
}
return zipFile
}
/** 设备信息 + 网络 + MQTT + 人脸 + 设备健康 纯文本快照 */
private fun buildInfo(context: Context): String {
val mqttState = MqttManager.state.value.name
val subs = MqttManager.getSubscriptions().entries
.joinToString("; ") { "${it.key}(qos=${it.value})" }
.ifBlank { "" }
val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
val lastCrash = CrashHandler.getCrashReportFiles(context)
.maxOfOrNull { it.lastModified() }
?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(it)) }
?: ""
val usbDir = UsbStorageHelper.findUsbDir(context)?.absolutePath ?: "未检测"
return buildString {
appendLine("========== 诊断快照 ==========")
appendLine("生成时间: $time")
appendLine("设备ID: ${GlobalData.deviceId}")
appendLine("设备编号: ${GlobalData.globalEquipmentCode}")
appendLine("应用版本: ${GlobalData.appVersion}")
appendLine()
appendLine("--- 设备健康 ---")
appendLine("运行时长: ${DeviceInfoProvider.uptime()}")
appendLine("内存: ${DeviceInfoProvider.memorySummary(context)}")
appendLine("存储: ${DeviceInfoProvider.internalStorage(context)}")
appendLine(DeviceInfoProvider.cameraSummary(context))
appendLine("串口: ${SerialApi.getPath()} @ ${SerialApi.getBaudRate()} | ${if (SerialApi.isOpened()) "已打开" else "未打开"}")
appendLine("U盘: $usbDir")
appendLine("最近崩溃: $lastCrash")
appendLine()
appendLine("--- 网络 ---")
appendLine("连通性: ${NetStatusProvider.connectivitySummary(context)}")
appendLine("本机IP: ${NetStatusProvider.localIpv4()}")
appendLine("环境: ${NetStatusProvider.envName(GlobalData.appBaseUrl)}")
appendLine("BaseUrl: ${GlobalData.appBaseUrl}")
appendLine()
appendLine("--- MQTT ---")
appendLine("状态: $mqttState")
appendLine("Broker: ${MqttManager.brokerUrl ?: "未配置"}")
appendLine("clientId: ${MqttManager.clientId ?: "未配置"}")
appendLine("订阅: $subs")
appendLine("连接次数: ${MqttManager.connectCount.value} / 断开次数: ${MqttManager.disconnectCount.value}")
appendLine("最近错误: ${MqttManager.lastError.value ?: "—"}")
appendLine()
appendLine("--- 人脸库 ---")
appendLine("总数: ${faceApi.queryFaceCount()}")
appendLine("会员: ${faceApi.queryFaceCountByMember(true)} / 非会员: ${faceApi.queryFaceCountByMember(false)}")
appendLine("同步水位: ${SpTool.getLastFaceTimestamp()}")
appendLine("库内最大更新时间: ${faceApi.queryMaxFaceUpdateTimestamp() ?: "—"}")
}
}
private fun addFile(zos: ZipOutputStream, file: File, entryName: String) {
zos.putNextEntry(ZipEntry(entryName))
file.inputStream().use { input -> input.copyTo(zos) }
zos.closeEntry()
}
}
@@ -0,0 +1,64 @@
package com.sw.platecabinet.utils
import android.app.Activity
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Process
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
/**
* 环境切换:落盘 baseUrl + 清空本地人脸库 + 重置增量水位 + 自动重启。
* 供运维面板复用(登录页连点切换的那套逻辑与此等价)。
*/
object EnvironmentSwitcher {
/**
* 切换业务环境。
* @param activity 用于写 SharedPreferences / 取 applicationContext / 切主线程
* @param env 目标环境
* @param onRestarting 重启前回调(主线程,用于 Toast 提示)
*/
fun switch(activity: Activity, env: Environment, onRestarting: () -> Unit) {
// 用 commit 同步写盘,确保自动重启前状态已持久化(apply 是异步的,重启会丢)
activity.getSharedPreferences("default_sp", Context.MODE_PRIVATE).edit()
.putString(GlobalKey.KEY_BASE_URL, env.url)
.putLong(SpTool.LAST_FACE_TIMESTAMP, 0L)
.putBoolean(SpTool.IS_FIRST_GET_FACE, true)
.commit()
GlobalData.appBaseUrl = env.url
// Room 同步访问不能在主线程,后台清库后回主线程重启
Thread {
try {
val faceDao = FaceDatabase.getInstance(activity.applicationContext).faceDao()
faceDao.deleteAll()
faceDao.resetId()
} catch (_: Exception) {
// 清库失败不阻断切换,重启后首次全量同步会兜底
}
activity.runOnUiThread {
onRestarting()
restart(activity)
}
}.start()
}
/** 用 AlarmManager 拉起启动页后杀进程,实现应用自重启 */
private fun restart(context: Context) {
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
} ?: return
val pending = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 500, pending)
Process.killProcess(Process.myPid())
}
}
@@ -0,0 +1,83 @@
package com.sw.platecabinet.utils
import android.content.Context
import android.util.Log
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* 运行时日志落盘:写入 filesDir/logs/log-YYYYMMDD.txt,按天滚动、单文件超 5MB 切分、保留 7 天。
* 与 Timber.DebugTree 并存,专供运维页查看与导出。
*/
class FileLoggingTree(private val context: Context) : Timber.Tree() {
companion object {
private const val LOG_DIR = "logs"
private const val MAX_FILE_SIZE = 5L * 1024 * 1024
private const val MAX_AGE_DAYS = 7L
fun logDir(context: Context): File = File(context.filesDir, LOG_DIR)
}
// 单线程串行写盘,避免多线程并发写同一文件导致行交错;写盘失败不影响业务。
private val executor = Executors.newSingleThreadExecutor()
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// Timber 在 prepareLog 阶段已把 Throwable 的堆栈拼进 message,此处只需落 message,避免重复。
executor.execute {
try {
val dir = logDir(context)
if (!dir.exists() && !dir.mkdirs()) return@execute
cleanupOldLogs(dir)
val file = resolveLogFile(dir)
FileOutputStream(file, true).use { fos ->
fos.write(buildLine(priority, tag, message).toByteArray(Charsets.UTF_8))
}
} catch (_: Exception) {
// 日志写失败静默,不阻断业务
}
}
}
private fun buildLine(priority: Int, tag: String?, message: String): String {
val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val level = when (priority) {
Log.VERBOSE -> "V"
Log.DEBUG -> "D"
Log.INFO -> "I"
Log.WARN -> "W"
Log.ERROR -> "E"
Log.ASSERT -> "A"
else -> "?"
}
val safeTag = tag ?: "Timber"
return "$time $level/$safeTag: $message\n"
}
/** 当前日志文件:不存在或未超限直接使用,超限则追加序号切分 */
private fun resolveLogFile(dir: File): File {
val day = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
val base = File(dir, "log-$day.txt")
if (!base.exists() || base.length() < MAX_FILE_SIZE) return base
var index = 1
while (true) {
val candidate = File(dir, "log-$day-$index.txt")
if (!candidate.exists() || candidate.length() < MAX_FILE_SIZE) return candidate
index++
}
}
/** 清理超过保留天数的旧日志 */
private fun cleanupOldLogs(dir: File) {
val now = System.currentTimeMillis()
val maxAgeMillis = MAX_AGE_DAYS * 24 * 60 * 60 * 1000L
dir.listFiles()?.forEach { file ->
if (file.isFile && file.lastModified() < now - maxAgeMillis) file.delete()
}
}
}
@@ -0,0 +1,163 @@
package com.sw.platecabinet.utils
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import timber.log.Timber
import java.io.File
import kotlin.io.copyTo
import kotlin.io.outputStream
import kotlin.io.use
import kotlin.text.equals
object FileUtils {
/**
* 从Uri获取File
* example: file:///data/user/0/com.sw.inbound/cache/IMG_17515262353556856678814444882273.jpg
*/
private fun getFileFromUri(context: Context, uri: Uri): File? {
Timber.d("getFileFromUri uri = ${uri.scheme}")
return when (uri.scheme) {
"file" -> File(uri.path ?: return null)
"content" -> {
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
val cacheDir = context.cacheDir
val file = File.createTempFile(
"upload_${System.currentTimeMillis()}",
".jpg",
cacheDir
)
file.outputStream().use { output ->
inputStream.copyTo(output)
}
file
} catch (e: Exception) {
Timber.e(e)
null
}
}
else -> null
}
}
/**
* 通过uri生成http请求体
*/
fun genRequestPart(context: Context, imageUri: Uri): MultipartBody.Part? {
Timber.d("genRequestPart imageUri = $imageUri")
// 1. 从Uri获取文件
val file = getFileFromUri(context, imageUri)
return genRequestPart(file)
}
fun genRequestPart(file: File?): MultipartBody.Part? {
if (file == null) {
Timber.e("getFileFromUri file is null")
return null
}
// 2. 创建请求体
val requestFile = file
.asRequestBody("application/octet-stream".toMediaTypeOrNull())
val imagePart = MultipartBody.Part.createFormData(
"file",
file.name,
requestFile
)
return imagePart
}
/**
* 通过Uri删除文件
* @param context 上下文
* @param uri 文件Uri
* @return Boolean 是否删除成功
*/
fun deleteFileWithUri(context: Context, uri: Uri): Boolean {
Timber.d("deleteFileWithUri uri = ${uri.scheme}")
return when {
// 1. 处理 content:// 类型的Uri (MediaStore)
uri.scheme.equals("content", ignoreCase = true) -> {
deleteContentUriFile(context, uri)
}
// 2. 处理 file:// 类型的Uri
uri.scheme.equals("file", ignoreCase = true) -> {
deleteFileUriFile(uri)
}
// 3. 其他情况尝试直接解析路径
else -> {
deleteFileFromPath(uri.path ?: return false)
}
}
}
// 删除Content Uri文件
private fun deleteContentUriFile(context: Context, uri: Uri): Boolean {
Timber.d("deleteContentUriFile uri = ${uri.scheme}")
return try {
context.contentResolver.delete(uri, null, null) > 0
} catch (e: SecurityException) {
// Android 10+需要特殊处理
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
deleteMediaStoreFile(context, uri)
} else {
false
}
} catch (e: Exception) {
Timber.e(e)
false
}
}
// Android 10+删除MediaStore文件
@RequiresApi(Build.VERSION_CODES.Q)
private fun deleteMediaStoreFile(context: Context, uri: Uri): Boolean {
Timber.d("deleteMediaStoreFile uri = ${uri.scheme}")
val contentResolver = context.contentResolver
val projection = arrayOf(MediaStore.MediaColumns._ID)
return try {
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id =
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
val contentUri = ContentUris.withAppendedId(uri, id)
contentResolver.delete(contentUri, null, null) > 0
} else {
false
}
} ?: false
} catch (e: Exception) {
Timber.e(e)
false
}
}
// 删除File Uri文件
private fun deleteFileUriFile(uri: Uri): Boolean {
Timber.d("deleteFileUriFile uri = $uri")
return try {
File(uri.path ?: return false).delete()
} catch (e: Exception) {
Timber.e(e)
false
}
}
// 直接通过路径删除文件
private fun deleteFileFromPath(path: String): Boolean {
Timber.d("deleteFileFromPath path = $path")
return try {
File(path).delete()
} catch (e: Exception) {
Timber.e(e)
false
}
}
}
@@ -0,0 +1,66 @@
package com.sw.platecabinet.utils
import kotlinx.coroutines.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class IntervalExecutor {
/**
* 启动定时任务
* @param delayMillis 延迟时间(毫秒)
* @param action 要执行的方法
* @return Job 可用于取消任务
*/
fun startIntervalTask(delayMillis: Long, action: suspend () -> Unit): Job {
return CoroutineScope(Dispatchers.Default).launch {
while (isActive) {
action()
delay(delayMillis)
}
}
}
/**
* 启动定时任务(带初始延迟)
* @param initialDelay 初始延迟时间(毫秒)
* @param delayMillis 后续执行间隔(毫秒)
* @param action 要执行的方法
* @return Job 可用于取消任务
*/
fun startIntervalTaskWithInitialDelay(
initialDelay: Long,
delayMillis: Long,
action: suspend () -> Unit
): Job {
return CoroutineScope(Dispatchers.Default).launch {
delay(initialDelay)
while (isActive) {
action()
delay(delayMillis)
}
}
}
}
// 使用示例
fun main() = runBlocking {
val executor = IntervalExecutor()
// 示例1:每隔10秒执行一次
val job1 = executor.startIntervalTask(10000) {
println("定时任务执行: ${System.currentTimeMillis()}")
// 这里可以执行你的业务逻辑
}
// 示例2:先延迟5秒,然后每隔3秒执行一次
val job2 = executor.startIntervalTaskWithInitialDelay(5000, 3000) {
println("带初始延迟的定时任务: ${System.currentTimeMillis()}")
}
// 运行30秒后取消任务
delay(30000)
job1.cancel()
job2.cancel()
println("所有定时任务已取消")
}
@@ -0,0 +1,29 @@
package com.sw.platecabinet.utils
import android.app.Activity
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
/**
* 键盘工具类
*/
object KeyboardUtils {
/**
* 显示键盘
*/
fun showKeyboard(view: View) {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
}
/**
* 隐藏键盘
*/
fun hideKeyboard(activity: Activity) {
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val view = activity.currentFocus ?: View(activity)
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
}
@@ -0,0 +1,88 @@
package com.sw.platecabinet.utils
import com.sw.platecabinet.model.response.EquipmentUserInfo
import timber.log.Timber
import kotlin.math.ceil
/**
* 列表排序工具类
*/
object ListArrangementUtil {
/**
* page item水平排序
*/
fun horizontalSortPageItem(
list: List<EquipmentUserInfo>,
columns: Int,
defaultValue: EquipmentUserInfo = EquipmentUserInfo()
): List<EquipmentUserInfo> {
Timber.d("horizontalSortPageItem")
require(columns > 0) { "Columns must be positive" }
val filledList = list.padToMultiple(columns, defaultValue)
val chunks = filledList.chunked(columns)
return (0 until columns)
.windowed(2, 2, partialWindows = true)
.flatMap { group ->
when (group.size) {
2 -> chunks.flatMap { listOf(it[group[0]], it[group[1]]) }
1 -> chunks.flatMap {
listOf(it[group[0]], EquipmentUserInfo(equipmentBoxCode = null))
}.dropLast(1)
else -> error("Unexpected group size")
}
}
}
/**
* item 垂直排序
*/
fun verticalSortItem(list: List<EquipmentUserInfo>): List<EquipmentUserInfo> {
Timber.d("verticalSortItem")
val users = list.padToEvenSize()
val half = users.size / 2
return users.take(half)
.zip(users.drop(half)) { a, b -> listOf(a, b) }
.flatten()
}
/**
* page item垂直排序
*/
fun convertToColumnFirst(
original: List<EquipmentUserInfo>,
cols: Int
): List<EquipmentUserInfo> {
Timber.d("convertToColumnFirst")
require(cols > 0) { "Columns must be positive" }
val rows = ceil(original.size.toDouble() / cols).toInt()
return List(original.size) { pos ->
original.getOrNull(pos % rows * cols + pos / rows) ?: EquipmentUserInfo()
}
}
// 提取的扩展函数
private fun List<EquipmentUserInfo>.padToMultiple(
multiple: Int,
defaultValue: EquipmentUserInfo
): List<EquipmentUserInfo> {
return if (size % multiple != 0) {
this + List(multiple - (size % multiple)) { defaultValue }
} else {
this
}
}
private fun List<EquipmentUserInfo>.padToEvenSize(): List<EquipmentUserInfo> {
return if (size % 2 != 0) {
Timber.d("列表长度必须是偶数")
this + EquipmentUserInfo()
} else {
this
}
}
}
@@ -0,0 +1,36 @@
package com.sw.platecabinet.utils
import android.content.Context
import java.io.File
/**
* 日志文件管理:列出 / 读尾部 / 清理(供运维页与 OpsViewModel 使用)。
*/
object LogFileManager {
/** 列出运行时日志文件(按修改时间倒序) */
fun getLogFiles(context: Context): List<File> {
val dir = FileLoggingTree.logDir(context)
if (!dir.exists() || !dir.isDirectory) return emptyList()
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return emptyList()
return files.sortedByDescending { it.lastModified() }
}
/** 读取文件末尾 maxLines 行(只保留末尾,避免大文件一次性载入内存) */
fun readTail(file: File, maxLines: Int = 200): String {
if (!file.exists()) return "文件不存在"
val tail = ArrayDeque<String>()
file.bufferedReader(Charsets.UTF_8).useLines { seq ->
seq.forEach { line ->
if (tail.size >= maxLines) tail.removeFirst()
tail.addLast(line)
}
}
return tail.joinToString("\n")
}
/** 清理所有运行时日志文件 */
fun clearLogs(context: Context) {
getLogFiles(context).forEach { it.delete() }
}
}
@@ -0,0 +1,50 @@
package com.sw.platecabinet.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.sw.platecabinet.ENVIRONMENTS
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
/**
* 网络状态只读提供器:连通性、本机 IP、业务环境名。
*/
object NetStatusProvider {
/** 连通性摘要(离线/WiFi/蜂窝/以太网/未知) */
fun connectivitySummary(context: Context): String {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return "未知"
val network = cm.activeNetwork ?: return "离线"
val caps = cm.getNetworkCapabilities(network) ?: return "离线"
return when {
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "离线(无外网)"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "以太网"
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "蜂窝"
else -> "已连接"
}
}
/** 本机 IPv4 地址(取首个非回环地址,无则返回"未知" */
fun localIpv4(): String {
return try {
val addresses = mutableListOf<InetAddress>()
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return "未知"
for (nif in interfaces) {
val addrs = nif.inetAddresses ?: continue
for (addr in addrs) addresses.add(addr)
}
addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress ?: "未知"
} catch (e: Exception) {
"未知"
}
}
/** 由 baseUrl 反查预设环境名,未匹配返回"自定义" */
fun envName(baseUrl: String): String {
return ENVIRONMENTS.firstOrNull { it.url == baseUrl }?.name ?: "自定义"
}
}
@@ -0,0 +1,103 @@
package com.sw.platecabinet.utils
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlin.apply
import kotlin.ranges.until
import kotlin.text.isEmpty
/**
* 二维码生成工具类
*/
object QRCodeUtil {
/**
* 生成二维码(默认大小)
* @param content 二维码内容
* @return 生成的二维码Bitmap
*/
@JvmOverloads
fun generateQRCode(content: String, size: Int = 500): Bitmap? {
return generateQRCode(content, size, Color.BLACK, Color.WHITE)
}
/**
* 生成二维码(自定义颜色)
* @param content 二维码内容
* @param size 二维码边长(像素)
* @param colorCode 二维码颜色
* @param backgroundColor 背景颜色
* @return 生成的二维码Bitmap
*/
fun generateQRCode(
content: String,
size: Int,
colorCode: Int,
backgroundColor: Int
): Bitmap? {
if (content.isEmpty()) {
return null
}
return try {
val hints = mutableMapOf<EncodeHintType, Any>().apply {
put(EncodeHintType.CHARACTER_SET, "UTF-8")
put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H) // 纠错级别
put(EncodeHintType.MARGIN, 1) // 边距
}
val bitMatrix = QRCodeWriter().encode(
content,
BarcodeFormat.QR_CODE,
size,
size,
hints
)
val pixels = IntArray(size * size).apply {
for (y in 0 until size) {
for (x in 0 until size) {
this[y * size + x] = if (bitMatrix.get(x, y)) colorCode else backgroundColor
}
}
}
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, size, 0, 0, size, size)
}
} catch (e: WriterException) {
e.printStackTrace()
null
}
}
/**
* 生成带Logo的二维码
* @param content 二维码内容
* @param size 二维码边长(像素)
* @param logo Logo Bitmap
* @return 带Logo的二维码Bitmap
*/
fun generateQRCodeWithLogo(content: String, size: Int, logo: Bitmap?): Bitmap? {
val qrCode = generateQRCode(content, size) ?: return null
logo ?: return qrCode
val logoSize = size / 5 // Logo大小约为二维码的1/5
val scaledLogo = Bitmap.createScaledBitmap(logo, logoSize, logoSize, false)
val offset = (size - logoSize) / 2
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
val canvas = Canvas(this)
canvas.drawBitmap(qrCode, 0f, 0f, null)
canvas.drawBitmap(scaledLogo, offset.toFloat(), offset.toFloat(), null)
}
}
}
@@ -0,0 +1,53 @@
package com.sw.platecabinet.utils;
import com.sw.inbound.utils.SPUtil;
import com.sw.platecabinet.GlobalKey;
import com.sw.platecabinet.MyApp;
public class SpTool {
public static final String LAST_FACE_TIMESTAMP = "faceTimestamp";
public static final String IS_FIRST_GET_FACE = "isFirstGetFace";
public static long getLastFaceTimestamp() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(LAST_FACE_TIMESTAMP, 0L);
}
public static void setLastFaceTimestamp(long timestamp) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(LAST_FACE_TIMESTAMP, timestamp);
}
/**
* 是否首次拉取人脸缓存(Kotlin 侧以 SpTool.firstGetFace 属性语法访问)
*/
public static boolean getFirstGetFace() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(IS_FIRST_GET_FACE, true);
}
public static void setFirstGetFace(boolean value) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(IS_FIRST_GET_FACE, value);
}
/**
* 业务服务器 BaseUrlKotlin 侧以 SpTool.baseUrl 属性语法访问)
*/
public static String getBaseUrl() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(GlobalKey.KEY_BASE_URL, "");
}
public static void setBaseUrl(String value) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(GlobalKey.KEY_BASE_URL, value);
}
/**
* 运维面板密码(默认 1234,运维可在面板内修改)
*/
public static String getOpsPin() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get("opsPin", "1234");
}
public static void setOpsPin(String value) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put("opsPin", value);
}
}
@@ -1,124 +0,0 @@
package com.sw.platecabinet.utils
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlin.coroutines.CoroutineContext
/**
* 多功能线程工具类
* 结合协程、Handler和线程池实现线程切换
*/
object ThreadUtils : CoroutineScope {
// 主线程Handler
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
// 后台线程池(IO密集型任务)
private val ioThreadPool: ExecutorService by lazy {
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2)
}
// CPU密集型线程池
private val cpuThreadPool: ExecutorService by lazy {
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
}
// 协程Job管理
private val job = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
// ========== Handler相关方法 ==========
/**
* 在主线程执行任务
* @param delayMillis 延迟时间(毫秒)
*/
fun runOnUiThread(delayMillis: Long = 0, block: () -> Unit) {
if (delayMillis > 0) {
mainHandler.postDelayed(block, delayMillis)
} else {
if (isOnMainThread()) {
block()
} else {
mainHandler.post(block)
}
}
}
/**
* 移除主线程任务
*/
fun removeUiThreadTask(block: () -> Unit) {
mainHandler.removeCallbacks(block)
}
// ========== 线程池相关方法 ==========
/**
* 在IO线程执行任务
*/
fun runOnIoThread(block: () -> Unit) {
ioThreadPool.execute(block)
}
/**
* 在CPU计算线程执行任务
*/
fun runOnCpuThread(block: () -> Unit) {
cpuThreadPool.execute(block)
}
// ========== 协程相关方法 ==========
/**
* 启动协程(默认在主线程)
*/
fun launch(block: suspend CoroutineScope.() -> Unit): Job {
return launch(coroutineContext, block = block)
}
/**
* 在IO线程启动协程
*/
fun launchOnIo(block: suspend CoroutineScope.() -> Unit): Job {
return launch(Dispatchers.IO, block = block)
}
/**
* 切换到主线程(协程环境)
*/
suspend fun <T> switchToMain(block: suspend CoroutineScope.() -> T): T {
return withContext(Dispatchers.Main, block)
}
/**
* 切换到IO线程(协程环境)
*/
suspend fun <T> switchToIo(block: suspend CoroutineScope.() -> T): T {
return withContext(Dispatchers.IO, block)
}
/**
* 是否在主线程
*/
fun isOnMainThread(): Boolean {
return Looper.myLooper() == Looper.getMainLooper()
}
/**
* 释放资源
*/
fun release() {
job.cancel()
ioThreadPool.shutdown()
cpuThreadPool.shutdown()
}
}
@@ -0,0 +1,116 @@
package com.sw.platecabinet.utils
import android.content.Context
import android.os.Build
import android.os.storage.StorageManager
import java.io.File
/**
* U 盘定位:工控机现场无微信等分享应用,诊断包需直接写入 U 盘。
*
* 关键教训:仅凭「vfat/exfat/ntfs」判断会误判——部分瑞芯微固件把 /oempriv 等
* OEM 私有分区也格式化成 vfat。因此必须「路径 + 文件系统」双重校验:
* 只有挂载点落在 /storage/ 下、或路径明显含 usb/udisk/sdcard 关键字,才算 U 盘;
* 根目录的 /oem /odm /vendor /system 等系统分区一律排除。
*/
object UsbStorageHelper {
fun findUsbDir(context: Context): File? {
// 1. /storage 下卷标目录(XXXX-XXXX),Android 标准可移动存储位置
storageVolumeDir()?.let { return it }
// 2. /proc/mounts 里"明显是外接存储"的可移动介质挂载点
procMountsDir()?.let { return it }
// 3. 硬编码常见挂载点兜底(路径本身含 usb/udisk/sdcard,无需额外校验路径)
commonMountPoints().forEach { if (isWritableDir(it) && isRemovableFs(it)) return it }
// 4. StorageManager removable 卷(路径 + fsType 双重校验)
api30Volume(context)?.let {
if (isWritableDir(it) && isUsbMountPoint(it.absolutePath) && isRemovableFs(it)) return it
}
return null
}
private fun isWritableDir(dir: File): Boolean = dir.exists() && dir.isDirectory && dir.canWrite()
/** 挂载点是否"明显是外接存储":排除根目录系统分区 */
private fun isUsbMountPoint(path: String): Boolean {
val lower = path.lowercase()
if (lower.startsWith("/oem") || lower.startsWith("/odm") ||
lower.startsWith("/vendor") || lower.startsWith("/system") ||
lower.startsWith("/product") || lower.startsWith("/system_ext") ||
lower == "/data" || lower == "/cache" || lower == "/metadata" ||
lower == "/persist" || lower == "/mnt/vendor"
) return false
return lower.startsWith("/storage/") ||
lower.startsWith("/mnt/media_rw/") ||
lower.contains("usb") || lower.contains("udisk") ||
lower.contains("sdcard") || lower.contains("external_sd")
}
/** 判断目录在 /proc/mounts 中的文件系统是否为可移动介质类型 */
private fun isRemovableFs(dir: File): Boolean {
val path = dir.absolutePath
return try {
File("/proc/mounts").readLines().any { line ->
val parts = line.split(" ")
parts.size >= 3 &&
parts[1].replace("\\040", " ") == path &&
isRemovableFsType(parts[2])
}
} catch (e: Exception) {
false
}
}
private fun isRemovableFsType(fsType: String): Boolean =
fsType.contains("vfat") || fsType.contains("exfat") ||
fsType.contains("ntfs") || fsType.contains("fuseblk")
/** API 30+ 通过 StorageManager 拿可移动卷目录(不可靠,需路径 + fsType 双重佐证) */
private fun api30Volume(context: Context): File? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
return try {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager ?: return null
sm.storageVolumes.firstOrNull { it.isRemovable }?.directory
} catch (e: Exception) {
null
}
}
/** /storage 下卷标目录(U 盘卷形如 /storage/ABCD-1234 */
private fun storageVolumeDir(): File? {
return try {
File("/storage").listFiles()
?.filter { it.isDirectory && it.name != "emulated" && it.name != "self" && it.canWrite() }
?.firstOrNull { isRemovableFs(it) }
} catch (e: Exception) {
null
}
}
/** 常见挂载点(路径本身即含 usb/udisk/sdcard 关键字) */
private fun commonMountPoints(): List<File> = listOf(
File("/mnt/usb_storage"),
File("/mnt/usb"),
File("/mnt/udisk"),
File("/storage/usb"),
File("/mnt/external_sd"),
File("/mnt/sdcard2")
)
/** 读 /proc/mounts 找"明显是外接存储"的可移动介质挂载点 */
private fun procMountsDir(): File? {
return try {
File("/proc/mounts").readLines()
.mapNotNull { line ->
val parts = line.split(" ")
if (parts.size < 3) return@mapNotNull null
val fsType = parts[2]
val mountPoint = parts[1].replace("\\040", " ")
if (isRemovableFsType(fsType) && isUsbMountPoint(mountPoint)) File(mountPoint) else null
}
.firstOrNull { isWritableDir(it) }
} catch (e: Exception) {
null
}
}
}
@@ -10,13 +10,16 @@ import android.view.View;
import android.view.Window; import android.view.Window;
import android.view.WindowManager; import android.view.WindowManager;
public class CustomDialog extends Dialog { /**
* 加载进度弹窗
*/
public class CustomLoadingDialog extends Dialog {
/** /**
* 宽高由布局文件中指定但是最底层的宽度无效可以多嵌套一层解决 * 宽高由布局文件中指定但是最底层的宽度无效可以多嵌套一层解决
*/ */
public CustomDialog(Context context, View layout, int style) { public CustomLoadingDialog(Context context, View layout, int style) {
super(context, style); super(context, style);
@@ -35,7 +38,7 @@ public class CustomDialog extends Dialog {
/** /**
* 宽高由该方法的参数设置 * 宽高由该方法的参数设置
*/ */
public CustomDialog(Context context, int width, int height, View layout, public CustomLoadingDialog(Context context, int width, int height, View layout,
int style) { int style) {
super(context, style); super(context, style);
// 设置内容 // 设置内容
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.model.response.ApiResponse import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentInfo
import com.sw.platecabinet.network.ApiClient import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepository import com.sw.platecabinet.repository.RemoteRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -62,4 +63,13 @@ abstract class BaseViewModel() : ViewModel() {
fun handleError(exception: Exception) { fun handleError(exception: Exception) {
Timber.d("handleError ${exception.message}") Timber.d("handleError ${exception.message}")
} }
fun parseEquipmentInfo(equipmentInfo: EquipmentInfo) {
// GlobalData.appBaseUrl = equipmentInfo.appPackageUrl!!
// GlobalData.appBaseUrl="https://vip.shuziweidao.com"
// GlobalData.sdkKey = equipmentInfo.arcsoftSdkKey!!
// GlobalData.appId = equipmentInfo.arcsoftAppId!!
// GlobalData.activeKey = equipmentInfo.arcsoftActiveKey!!
// GlobalData.restId = equipmentInfo.canteenId!!
}
} }
@@ -0,0 +1,68 @@
//package com.sw.platecabinet.viewmodel
//
//import com.sw.inbound.utils.GsonUtils
//import com.sw.inbound.utils.SPUtil
//import com.sw.platecabinet.GlobalData
//import com.sw.platecabinet.GlobalKey
//import com.sw.platecabinet.model.response.EquipmentInfo
//import kotlinx.coroutines.flow.MutableStateFlow
//import kotlinx.coroutines.flow.StateFlow
//import timber.log.Timber
//import kotlin.jvm.java
//
///**
// * 初始化的viewmodel
// */
//class DeviceViewModel : BaseViewModel() {
// private val _deviceInfoResult = MutableStateFlow<Boolean?>(null)
// val deviceInfoResult: StateFlow<Boolean?> = _deviceInfoResult
//
// /**
// * 获取token
// */
// fun getDeviceToken(deviceId: String = GlobalData.deviceId) {
// launchWithLoading {
// val response = repository.getDeviceToken(deviceId)
// if (parseResponse(response)) {
// val response1 = repository.getDeviceInfo(deviceId, response.result!!)
//
// if (parseResponse(response1)) {
// val equipmentInfo = response1.result
// if (equipmentInfo == null) return@launchWithLoading
// try {
// parseEquipmentInfo(equipmentInfo)
// SPUtil.getInstance()
// .put(GlobalKey.KEY_EQUIPMENT_INFO, GsonUtils.toJson(equipmentInfo))
// _deviceInfoResult.value = true
// } catch (e: Exception) {
// Timber.e(e)
// }
// }
// }
// }
// }
//
// /**
// * 检查缓存数据
// */
// fun checkEquipmentInfo(): Boolean {
// val equipmentInfoStr = SPUtil.getInstance().get(GlobalKey.KEY_EQUIPMENT_INFO, "")
// if (equipmentInfoStr == null) {
// Timber.e("获取缓存设备信息失败")
// return false
// }
// val equipmentInfo =
// GsonUtils.fromJson<EquipmentInfo>(equipmentInfoStr, EquipmentInfo::class.java)
// if (equipmentInfo == null) {
// Timber.e("解析缓存设备信息失败")
// return false
// }
// try {
// parseEquipmentInfo(equipmentInfo)
// return true
// } catch (e: Exception) {
// Timber.e(e)
// return false
// }
// }
//}
@@ -0,0 +1,328 @@
package com.sw.platecabinet.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arcsoft.face.ErrorInfo
import com.sw.inbound.utils.SPUtil
import com.sw.plate.App
import com.sw.plate.utils.Base64
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.mqtt.FaceSyncLock
import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepositoryV2
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
/**
* 用户viewmodel
*/
class NetViewModelV2 : ViewModel() {
companion object {
const val PAGE_SIZE = 500
}
private val faceApi: FaceApi = FaceApi()
private val repository = RemoteRepositoryV2(ApiClient.apiServiceV2)
//fun getUserFace(
// pageNo: Int,
// pageSize: Int,
// callback: (Boolean, List<UserFaceModelV2>) -> Unit
//) {
// viewModelScope.launch {
// val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize)
// if (response.isSuccess()) callback(true, response.data ?: emptyList())
// else callback(false, emptyList())
// }
//}
/**
* 获取人脸数据
*/
fun getUserFaceCache(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
callback: (Boolean, String?) -> Unit = { _, _ -> }
) {
var currentPageNo = pageNo
viewModelScope.launch {
val response = repository.getUserFaceCache(pageNo, pageSize)
if (response.isSuccess().not()) {
callback(false, response.msg)
return@launch
}
// 获取成功一次后缓存状态
SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true)
val list = response.data ?: emptyList()
if (pageNo == 1 && list.isEmpty()) {
callback(false, "暂无数据")
return@launch
}
val faceEntityList = list.mapNotNull { face ->
val featureBase64 = face.resolveFeatureStr()
if (featureBase64.isNullOrBlank()) null
else {
val featureData = base64ToByteArray(featureBase64)
if (featureData == null) null
else face.toFaceEntity()
}
}
withContext(Dispatchers.Default) {
if (currentPageNo == 1) {
//第一页数据删除本地数据库,同时重置时间戳
faceApi.clearFaceData()
faceTimestamp = 0L
}
faceApi.updateFaceData(pageNo, faceEntityList)
if (list.size >= pageSize) {
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
currentPageNo++
getUserFaceCache(
pageNo = currentPageNo, callback = callback
)
return@withContext
}
if (list.isNotEmpty()) {
// 最后一页:取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
}
SpTool.setLastFaceTimestamp(faceTimestamp)
callback(true, "查询完成")
}
}
}
private var faceTimestamp = 0L
/**
* 激活人脸识别引擎
*/
fun activeEngine() {
Timber.d("activeEngine")
faceApi.activeEngine(
App.getContext(),
GlobalData.appId,
GlobalData.sdkKey,
GlobalData.activeKey,
object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode=="+GlobalData.activeKey)
// ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) {
when (activeCode) {
ErrorInfo.MOK -> {
ToastUtils.showToast("激活引擎成功")
}
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
ToastUtils.showToast("引擎已激活,无需再次激活")
}
else -> {
ToastUtils.showToast("激活引擎失败($activeCode)")
}
}
}
}
override fun onFail(e: Exception?) {
viewModelScope.launch(Dispatchers.Main) {
ToastUtils.showToast("激活引擎异常,${e?.message}")
}
}
})
}
fun getDeviceConfig(onSuccess: (DeviceConfigV2?) -> Unit, onFailure: (String?) -> Unit) {
// Timber.tag(TAG).d("getDeviceConfig")
viewModelScope.launch {
val response = repository.getDeviceConfig()
if (response.isSuccess()) {
onSuccess(response.data)
} else {
onFailure(response.msg)
}
}
}
/**
* 增量获取人脸数据(支持分页递归,取所有页中的最大时间戳)
*/
fun getFaceIncrementList(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
timestamp: Long,
refreshFaceList: () -> Unit
) {
var currentPageNo = pageNo
viewModelScope.launch {
val response = repository.getFaceIncrementList(
pageNum = currentPageNo.toLong(),
pageSize = pageSize.toLong(),
timestamp = timestamp
)
if (response.isSuccess().not()) {
return@launch
}
withContext(Dispatchers.IO) {
val list = response.data ?: emptyList()
// 与 MQTT 实时更新串行化写库,避免同一 userFaceId 重复入库
FaceSyncLock.mutex.withLock { updateFaceData(list) }
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
if (list.size >= pageSize) {
// 还有下一页,继续递归拉取
currentPageNo++
getFaceIncrementList(
pageNo = currentPageNo,
pageSize = pageSize,
timestamp = timestamp,
refreshFaceList = refreshFaceList
)
return@withContext
}
// 所有页拉取完毕,保存最终时间戳(只升不降)
val storedTimestamp = SpTool.getLastFaceTimestamp()
if (faceTimestamp > storedTimestamp) {
SpTool.setLastFaceTimestamp(faceTimestamp)
}
try {
refreshFaceList()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
private fun updateFaceData(list: List<UserFaceModelV2>) {
try {
list.forEach { model ->
if (model.faceDeleted == true) {
//删除数据
// 仅删除 userFaceId 对应的单条人脸记录,避免误删该用户名下所有特征
val userFaceId = model.userFaceId
if (!userFaceId.isNullOrEmpty()) {
faceApi.deleteByUserFaceId(userFaceId)
}
} else {
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
val userFaceId = model.userFaceId
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
// 本地已存在该 userFaceId,跳过插入
return@forEach
}
// userFaceId 为空时,回退到特征比较去重(修正字段来源,保证与入库一致)
val featureStr = model.resolveFeatureStr()
val existList = faceApi.queryAllByUserName(model.userId)
val alreadyExists = existList.any { entity ->
featureStr != null && Base64.encode(entity.featureData) == featureStr
}
if (!alreadyExists) {
faceApi.insert(model.toFaceEntity())
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 添加人脸数据
*/
fun addUserFace(faceData: String, imageFile: File, block: (Boolean, UserFaceModelV2?) -> Unit) {
viewModelScope.launch {
try {
val imageResp = repository.uploadImage(imageFile)
val imageUrl = imageResp.data ?: ""
if (imageUrl.isBlank()) {
block(false, null)
return@launch
}
val faceResp = repository.addUserFace(faceData, imageUrl)
if (faceResp.isSuccess().not()) {
block(false, null)
return@launch
}
block(true, faceResp.data)
} catch (e: Exception) {
// 网络或解析异常时同样回调失败,避免 block 永不触发导致上层无反馈
e.printStackTrace()
block(false, null)
}
}
}
/**
* 取餐盘上报(P-09a):记录用户开始就餐时刻,作为后续就餐记录(P-09)的强前置。
* 失败仅回调通知,不阻断吐盘等业务主流程。
*
* @param userId 用户 id(餐盘号 = userId
* @param callback 上报结果回调:Boolean 是否成功,String? 成功为 recordNo、失败为错误信息
*/
fun platePickup(
userId: Long,
callback: (Boolean, String?) -> Unit = { _, _ -> }
) {
viewModelScope.launch {
try {
val response = repository.platePickup(userId)
if (response.isSuccess()) {
callback(true, response.data)
} else {
callback(false, response.msg)
}
} catch (e: Exception) {
e.printStackTrace()
callback(false, e.message)
}
}
}
private fun base64ToByteArray(base64: String?): ByteArray? {
base64 ?: return null
return try {
Base64.decode(base64)
} catch (e: Exception) {
null
}
}
/**
* 从人脸数据列表中取出最大的 faceUpdateTimestamp(跳过 null 值)
*/
private fun maxFaceTimestamp(list: List<UserFaceModelV2>): Long {
return list.mapNotNull { it.faceUpdateTimestamp }.maxOrNull() ?: 0L
}
}
@@ -0,0 +1,151 @@
package com.sw.platecabinet.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.mqtt.MqttManager
import com.sw.platecabinet.mqtt.MqttState
import com.sw.platecabinet.utils.LogFileManager
import com.sw.platecabinet.utils.NetStatusProvider
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* 运维面板 ViewModel:聚合 MQTT / 网络 / 人脸 / 日志 状态与动作。
*/
class OpsViewModel : ViewModel() {
private val faceApi = FaceApi()
// ---- MQTT(直接桥接 MqttManager 单例的 StateFlow ----
val mqttState: StateFlow<MqttState> = MqttManager.state
val mqttLastConnectedAt: StateFlow<Long?> = MqttManager.lastConnectedAt
val mqttLastLostAt: StateFlow<Long?> = MqttManager.lastLostAt
val mqttLastError: StateFlow<String?> = MqttManager.lastError
val mqttLastMessageAt: StateFlow<Long?> = MqttManager.lastMessageArrivedAt
val mqttConnectCount: StateFlow<Int> = MqttManager.connectCount
val mqttDisconnectCount: StateFlow<Int> = MqttManager.disconnectCount
// ---- 人脸统计 ----
private val _faceCount = MutableStateFlow(0)
val faceCount: StateFlow<Int> = _faceCount.asStateFlow()
private val _memberCount = MutableStateFlow(0)
val memberCount: StateFlow<Int> = _memberCount.asStateFlow()
private val _nonMemberCount = MutableStateFlow(0)
val nonMemberCount: StateFlow<Int> = _nonMemberCount.asStateFlow()
private val _maxUpdateTs = MutableStateFlow<Long?>(null)
val maxUpdateTs: StateFlow<Long?> = _maxUpdateTs.asStateFlow()
private val _recentFaces = MutableStateFlow<List<FaceEntity>>(emptyList())
val recentFaces: StateFlow<List<FaceEntity>> = _recentFaces.asStateFlow()
// ---- 人脸搜索 ----
private val _searchResult = MutableStateFlow<List<FaceEntity>>(emptyList())
val searchResult: StateFlow<List<FaceEntity>> = _searchResult.asStateFlow()
// ---- 服务端连通自检 ----
private val _serverCheck = MutableStateFlow("未检测")
val serverCheck: StateFlow<String> = _serverCheck.asStateFlow()
// ---- 日志文件 ----
private val _logFiles = MutableStateFlow<List<File>>(emptyList())
val logFiles: StateFlow<List<File>> = _logFiles.asStateFlow()
// ---- 只读快照 ----
val appBaseUrl: String get() = GlobalData.appBaseUrl
val envName: String get() = NetStatusProvider.envName(GlobalData.appBaseUrl)
val lastFaceTimestamp: Long get() = SpTool.getLastFaceTimestamp()
val deviceId: String get() = GlobalData.deviceId
val equipmentCode: String get() = GlobalData.globalEquipmentCode
val appVersion: String get() = GlobalData.appVersion
val mqttBrokerUrl: String? get() = MqttManager.brokerUrl
val mqttClientId: String? get() = MqttManager.clientId
/** 刷新本地人脸统计与最近更新列表(FaceDao 为同步调用,放 IO 线程) */
fun refreshFaceStats() {
viewModelScope.launch(Dispatchers.IO) {
try {
_faceCount.value = faceApi.queryFaceCount()
_memberCount.value = faceApi.queryFaceCountByMember(true)
_nonMemberCount.value = faceApi.queryFaceCountByMember(false)
_maxUpdateTs.value = faceApi.queryMaxFaceUpdateTimestamp()
_recentFaces.value = faceApi.queryRecentUpdatedFaces(50)
} catch (e: Exception) {
// 数据库读取异常仅打印,不影响面板其余区块
_recentFaces.value = emptyList()
}
}
}
/** 按 userId 精确搜索人脸(库内无姓名字段,只能按 userId 搜索) */
fun searchFace(userId: String) {
viewModelScope.launch(Dispatchers.IO) {
_searchResult.value = faceApi.queryByUserId(userId.trim(), 50)
}
}
/**
* 服务端连通自检:GET 真实设备配置接口(而非 baseUrl 根路径,避免 404 也判可达)。
* 任何 HTTP 响应都视为"服务进程存活"(能区分"网络断/服务没起" vs "服务在跑")。
*/
fun checkServer() {
viewModelScope.launch(Dispatchers.IO) {
val baseUrl = GlobalData.appBaseUrl.ifBlank {
_serverCheck.value = "未配置业务地址"
return@launch
}
_serverCheck.value = "检测中…"
val url = "${baseUrl.trimEnd('/')}/nutrition/neglect/pickup/device/config"
val client = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(3, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(url).get().build()
val start = System.currentTimeMillis()
try {
client.newCall(request).execute().use { resp ->
val cost = System.currentTimeMillis() - start
_serverCheck.value = "可达 HTTP ${resp.code}${cost}ms"
}
} catch (e: Exception) {
val cost = System.currentTimeMillis() - start
_serverCheck.value = "不可达(${cost}ms):${e.message}"
}
}
}
/** 刷新日志文件列表 */
fun refreshLogFiles(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
_logFiles.value = LogFileManager.getLogFiles(context)
}
}
/** 手动重连 MQTT */
fun reconnectMqtt() {
MqttManager.reconnect()
}
/** 时间戳格式化(毫秒 → yyyy-MM-dd HH:mm:ss,空/0 显示 —) */
fun formatTs(ts: Long?): String {
if (ts == null || ts <= 0L) return ""
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(ts))
}
}
@@ -1,7 +1,7 @@
package com.sw.platecabinet.viewmodel package com.sw.platecabinet.viewmodel
import android.text.TextUtils
import com.sw.inbound.utils.DateTimeUtils import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalData.globalEquipmentCode import com.sw.platecabinet.GlobalData.globalEquipmentCode
import com.sw.platecabinet.model.ErrorInfo import com.sw.platecabinet.model.ErrorInfo
@@ -10,19 +10,27 @@ import com.sw.platecabinet.model.request.EquipmentParam
import com.sw.platecabinet.model.request.SearchParam import com.sw.platecabinet.model.request.SearchParam
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.SearchResult import com.sw.platecabinet.model.response.SearchResult
import com.sw.platecabinet.model.response.UserFaceModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* 管理员操作viewmodel
*/
class SettingViewModel : BaseViewModel() { class SettingViewModel : BaseViewModel() {
// 设备绑定列表 // 设备绑定列表
private val _equipmentList = MutableStateFlow<List<EquipmentUserInfo>>(emptyList()) // private val _equipmentList = MutableStateFlow<List<EquipmentUserInfo>>(emptyList())
val equipmentList: StateFlow<List<EquipmentUserInfo>> = _equipmentList // val equipmentList: StateFlow<List<EquipmentUserInfo>> = _equipmentList
private val _searchMemberList = MutableStateFlow<List<SearchResult.Member>>(emptyList()) private val _searchMemberList = MutableStateFlow<List<SearchResult.Member>>(emptyList())
val searchMemberList: StateFlow<List<SearchResult.Member>> = _searchMemberList val searchMemberList: StateFlow<List<SearchResult.Member>> = _searchMemberList
private val _currentUserInfo = MutableStateFlow<EquipmentUserInfo?>(null) private val _searchUserInfo = MutableStateFlow<EquipmentUserInfo?>(null)
val currentUserInfo: StateFlow<EquipmentUserInfo?> = _currentUserInfo val searchUserInfo: StateFlow<EquipmentUserInfo?> = _searchUserInfo
var currentPage = 1 var currentPage = 1
var isLoading = false var isLoading = false
@@ -31,20 +39,34 @@ class SettingViewModel : BaseViewModel() {
/** /**
* 绑定/解绑状态 <绑定/解绑, 错误信息,> * 绑定/解绑状态 <绑定/解绑, 错误信息,>
*/ */
private val _bindStateChange = MutableStateFlow<Pair<Boolean?, ErrorInfo>>(null to ErrorInfo()) // private val _bindStateChange = MutableStateFlow<Pair<Boolean?, ErrorInfo>>(null to ErrorInfo())
val bindStateChange: StateFlow<Pair<Boolean?, ErrorInfo>> = _bindStateChange // val bindStateChange: StateFlow<Pair<Boolean?, ErrorInfo>> = _bindStateChange
/** /**
* 获取设备绑定用户信息列表 * 获取设备绑定用户信息列表
*/ */
fun getEquipmentList(equipmentCode: String = globalEquipmentCode) { // fun getEquipmentList(equipmentCode: String = globalEquipmentCode) {
// launchWithLoading {
// val param =
// EquipmentParam(appVersion = GlobalData.appVersion, equipmentCode = equipmentCode)
// val response = repository.getEquipmentList(param)
// if (parseResponse(response)) {
// _equipmentList.value = response.data ?: emptyList()
// }
// }
//// val list = mutableListOf<EquipmentUserInfo>()
//// for (i in 1 .. 30){
//// list.add(EquipmentUserInfo(id = i, equipmentBoxCode = i.toString()))
//// }
//// _equipmentList.value = list
// }
fun getEquipmentList(block: (List<EquipmentUserInfo>) -> Unit) {
launchWithLoading { launchWithLoading {
val param = val response = repository.getEquipmentList()
EquipmentParam(appVersion = GlobalData.appVersion, equipmentCode = equipmentCode)
val response = repository.getEquipmentList(param)
if (parseResponse(response)) { if (parseResponse(response)) {
_equipmentList.value = response.data ?: emptyList() val list = response.data ?: emptyList()
block(list)
} }
} }
} }
@@ -53,77 +75,115 @@ class SettingViewModel : BaseViewModel() {
* 搜索会员信息列表 * 搜索会员信息列表
*/ */
fun getSearchMemberList(param:String, pageNum: Int = 1, pageSize: Int = 50) { fun getSearchMemberList(param:String, pageNum: Int = 1, pageSize: Int = 50) {
if (TextUtils.isEmpty(param)) return
currentPage = pageNum currentPage = pageNum
var name =""
var phone =""
val num = param.toIntOrNull()
if (num != null) {
phone = param
} else {
name = param
}
launch { launch {
val param = SearchParam( val searchParam = SearchParam(
appVersion = GlobalData.appVersion, // appVersion = GlobalData.appVersion,
param = param, name = name,
phone = phone,
pageNum = pageNum, pageNum = pageNum,
pageSize = pageSize pageSize = pageSize
) )
isLoading = true isLoading = true
val response = repository.searchUser(param) val response = repository.searchUser(searchParam)
isLoading = false isLoading = false
if (parseResponse(response)) { if (parseResponse(response)) {
response.data?.let { response.data?.let { list ->
canLoadMore = it.hasNextPage != false
val newList = it.list ?: emptyList()
if (pageNum == 1) { if (pageNum == 1) {
_searchMemberList.value = newList _searchMemberList.value = list
} else { } else {
_searchMemberList.value = _searchMemberList.value.plus(newList) _searchMemberList.value = _searchMemberList.value.plus(list)
} }
currentPage++ if (list.size >= pageSize) currentPage++
} }
} }
} }
} }
fun bindPlate( // fun bindPlate(
equipmentBoxCode: String, // equipmentBoxCode: String,
equipmentCode: String = globalEquipmentCode, // equipmentCode: String = globalEquipmentCode,
memberId: Int, // memberId: Int,
plateNumber: String // plateNumber: String
) { // ) {
// _bindStateChange.value = null to ErrorInfo()
// launchWithLoading {
// val bindParam = BindParam(
// appVersion = GlobalData.appVersion,
// equipmentBoxCode = equipmentBoxCode,
// equipmentCode = equipmentCode,
// memberId = memberId,
// plateNumber = plateNumber
// )
// val response = repository.bindEquipment(bindParam)
// if (parseResponse(response)) {
// var userInfo = response.data
// if (userInfo == null) {
// userInfo = EquipmentUserInfo()
// userInfo.equipmentBoxCode = equipmentBoxCode
// userInfo.plateNumber = plateNumber
// userInfo.equipmentCode = equipmentCode
// }
// _bindStateChange.value = true to ErrorInfo(equipmentUserInfo = userInfo)
// } else {
// _bindStateChange.value = true to ErrorInfo(response.code, response.msg.toString())
// }
// }
// }
// fun unbindPlate(
// equipmentBoxCode: String,
// equipmentCode: String = globalEquipmentCode
// ) {
// launchWithLoading {
// val bindParam = BindParam(
// appVersion = GlobalData.appVersion,
// equipmentBoxCode = equipmentBoxCode,
// equipmentCode = equipmentCode
// )
// val response = repository.bindEquipment(bindParam)
// if (parseResponse(response)) {
// _bindStateChange.value = false to ErrorInfo()
// } else {
// _bindStateChange.value = false to ErrorInfo(response.code, response.msg.toString())
// }
// }
// }
fun unbindPlate(id: String?, block:(Pair<Boolean?, ErrorInfo>)-> Unit) {
block(null to ErrorInfo())
launchWithLoading { launchWithLoading {
val bindParam = BindParam( val response = repository.plateUnbind(id)
appVersion = GlobalData.appVersion,
equipmentBoxCode = equipmentBoxCode,
equipmentCode = equipmentCode,
memberId = memberId,
plateNumber = plateNumber
)
val response = repository.bindEquipment(bindParam)
if (parseResponse(response)) { if (parseResponse(response)) {
val userInfo = response.data block(false to ErrorInfo())
userInfo?.let {
if (it.isOtherEquipment()) {
ToastUtils.showToast("餐盘已在${it.equipmentName}绑定")
return@launchWithLoading
}
}
_bindStateChange.value = true to ErrorInfo()
} else { } else {
_bindStateChange.value = true to ErrorInfo(response.code, response.msg.toString()) block(false to ErrorInfo(response.code, response.msg.toString()))
} }
} }
} }
fun unbindPlate( fun bindPlate(bindParam: BindParam, block:(Pair<Boolean?, ErrorInfo>)-> Unit) {
equipmentBoxCode: String,
equipmentCode: String = globalEquipmentCode
) {
launchWithLoading { launchWithLoading {
val bindParam = BindParam( val response = repository.plateBind(bindParam)
appVersion = GlobalData.appVersion,
equipmentBoxCode = equipmentBoxCode,
equipmentCode = equipmentCode
)
val response = repository.bindEquipment(bindParam)
if (parseResponse(response)) { if (parseResponse(response)) {
_bindStateChange.value = false to ErrorInfo() var userInfo = response.data
if (userInfo == null) {
userInfo = EquipmentUserInfo()
userInfo.equipmentBoxCode = bindParam.equipmentBoxCode
userInfo.plateNumber = bindParam.plateNumber
userInfo.equipmentCode = bindParam.equipmentCode
}
block(true to ErrorInfo(equipmentUserInfo = userInfo))
} else { } else {
_bindStateChange.value = false to ErrorInfo(response.code, response.msg.toString()) block(true to ErrorInfo(response.code, response.msg.toString()))
} }
} }
} }
@@ -132,25 +192,14 @@ class SettingViewModel : BaseViewModel() {
plateNumber: String, plateNumber: String,
equipmentCode: String = globalEquipmentCode equipmentCode: String = globalEquipmentCode
) { ) {
_searchUserInfo.value = null
launchWithLoading { launchWithLoading {
val bindParam = BindParam( // val bindParam = BindParam(plateNumber = plateNumber)
appVersion = GlobalData.appVersion, val response = repository.findByPlateNumber(plateNumber)
plateNumber = plateNumber, if (response.code == "00000") {
equipmentCode = equipmentCode _searchUserInfo.value = response.data
)
val response = repository.findByPlateNumber(bindParam)
if (response.code == 200) {
val userInfo = response.data
userInfo?.let {
if (it.isOtherEquipment()) {
ToastUtils.showToast("餐盘已在${it.equipmentName}绑定")
return@launchWithLoading
}
}
_currentUserInfo.value = response.data
} else { } else {
_currentUserInfo.value = EquipmentUserInfo( _searchUserInfo.value = EquipmentUserInfo(
plateNumber = plateNumber, plateNumber = plateNumber,
equipmentCode = equipmentCode, equipmentCode = equipmentCode,
updateTime = DateTimeUtils.getDateTimeString() updateTime = DateTimeUtils.getDateTimeString()
@@ -158,4 +207,31 @@ class SettingViewModel : BaseViewModel() {
} }
} }
} }
val PAGE_SIZE = 100
/**
* 获取人脸数据
*/
fun getFaceIncrementList(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
timestamp: Long,
block: (List<UserFaceModel>) -> Unit
) {
launch {
val response = repository.getFaceIncrementList(
pageNum = pageNo.toLong(),
pageSize = pageSize.toLong(),
timestamp = timestamp
)
if (parseResponse(response)) {
withContext(Dispatchers.Default) {
val list: List<UserFaceModel> = response.data ?: emptyList()
block(list)
}
}
}
}
} }
@@ -1,5 +1,6 @@
package com.sw.platecabinet.viewmodel package com.sw.platecabinet.viewmodel
import androidx.lifecycle.viewModelScope
import com.arcsoft.face.ErrorInfo import com.arcsoft.face.ErrorInfo
import com.sw.inbound.utils.SPUtil import com.sw.inbound.utils.SPUtil
import com.sw.plate.App import com.sw.plate.App
@@ -10,17 +11,26 @@ import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalData.globalEquipmentCode import com.sw.platecabinet.GlobalData.globalEquipmentCode
import com.sw.platecabinet.GlobalKey import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.request.LoginParam import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.UserFaceModel import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.ThreadUtils import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
/**
* 用户viewmodel
*/
class UserViewModel : BaseViewModel() { class UserViewModel : BaseViewModel() {
companion object {
const val PAGE_SIZE = 100
}
// 通过人脸获取到的用户信息 // 通过人脸获取到的用户信息
private val _currentUserInfo = MutableStateFlow<EquipmentUserInfo?>(null) private val _currentUserInfo = MutableStateFlow<EquipmentUserInfo?>(null)
val currentUserInfo: StateFlow<EquipmentUserInfo?> = _currentUserInfo val currentUserInfo: StateFlow<EquipmentUserInfo?> = _currentUserInfo
@@ -30,114 +40,102 @@ class UserViewModel : BaseViewModel() {
/** /**
* 获取token * 获取token
*/ */
fun generateToken(deviceId: String = "SWSN:88:12:AC:4E:D9:CC") { // fun generateToken(deviceId: String = "SWSN:88:12:AC:4E:D9:CC") {
launchWithLoading { // launchWithLoading {
val response = repository.generateToken(deviceId) // val response = repository.generateToken(deviceId)
if (parseResponse(response)) { // if (parseResponse(response)) {
// 缓存token // // 缓存token
SPUtil.getInstance().put(GlobalKey.KEY_TOKEN, response.data) // SPUtil.getInstance().put(GlobalKey.KEY_TOKEN, response.data)
// 首次运行获取人脸数据 // // 首次运行获取人脸数据
if (SPUtil.getInstance().get(GlobalKey.KEY_FIRST_RUN, false) != true) { // if (SPUtil.getInstance().get(GlobalKey.KEY_FIRST_RUN, false) != true) {
getUserFaceCache() // getUserFaceCache()
} // }
} // }
} // }
} // }
/** /**
* 获取人脸数据 * 获取人脸数据
*/ */
fun getUserFaceCache(index: Int = 0) { fun getUserFaceCache(pageNo: Int = 1, pageSize: Int = PAGE_SIZE) {
var currentPageNo = pageNo
launchWithLoading { launchWithLoading {
val response = repository.getUserFaceCache() val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize)
if (parseResponse(response)) { if (parseResponse(response)) {
// 获取成功一次后缓存状态 // 获取成功一次后缓存状态
SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true) SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true)
val list: List<UserFaceModel> = response.data ?: emptyList() val list: List<UserFaceModel> = response.data ?: emptyList()
val faceEntity = list.map { if (pageNo==1&&list.isEmpty()) {
FaceEntity(it.userId, null, Base64.decode(it.faceFeatureString)) return@launchWithLoading
}
val faceEntityList = list.map {
FaceEntity(it.userId, null, Base64.decode(it.faceFeatureStr))
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
faceApi.updateFaceData(index, faceEntity) faceApi.updateFaceData(pageNo, faceEntityList)
activeEngine() if (list.size >= pageSize) {
faceTimestamp = list.last().faceUpdateTimestamp?:0
currentPageNo++
getUserFaceCache(currentPageNo)
return@withContext
}
if (list.isNotEmpty()) {
faceTimestamp = list.last().faceUpdateTimestamp?:0
}
SpTool.setLastFaceTimestamp(faceTimestamp)
//activeEngine()
} }
} }
} }
} }
/** private var faceTimestamp = 0L
* 激活人脸识别引擎
*/
fun activeEngine() {
Timber.d("activeEngine")
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
var activeKey = "085F-118G-Q391-53YL"
faceApi.activeEngine(
App.getContext(),
appId,
sdkKey,
activeKey,
object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode")
ThreadUtils.launch {
when (activeCode) {
ErrorInfo.MOK -> {
ToastUtils.showToast("激活引擎成功")
}
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
// ToastUtils.showToast("引擎已激活,无需再次激活")
}
else -> {
ToastUtils.showToast("激活引擎失败($activeCode)")
}
}
}
}
override fun onFail(e: Exception?) {
ThreadUtils.launch {
ToastUtils.showToast("激活引擎异常,${e?.message}")
}
}
})
}
/**
* 校验码登录
*/
fun loginWithPwd(equipmentCode: String = globalEquipmentCode, phone: String, password: String) {
launchWithLoading {
val loginParam = LoginParam(
appVersion = GlobalData.appVersion,
equipmentCode = equipmentCode,
phone = phone,
password = password
)
val response = repository.equipmentBoxLogin(loginParam)
if (parseResponse(response)) {
_currentUserInfo.value = response.data
}
}
}
/** /**
* 通过用户id获取用户信息 * 通过用户id获取用户信息
*
* 无论成功或失败(如「未找到用户绑定信息」)都会回调 action,
* 以便调用方统一收尾(例如关闭 loading 弹窗)。
* 成功时回调绑定的用户信息,失败时回调 null。
*
* @param silent true 表示失败时不弹错误 toast。绑定页点会员检查是否已绑定
* 时,「未找到绑定信息」属于正常态,应静默处理。
*/ */
fun getUserInfoById(equipmentCode: String = globalEquipmentCode, memberId: Int) { fun getUserInfoById(memberId: String?, silent: Boolean = false, action:(EquipmentUserInfo?)->Unit={}) {
_currentUserInfo.value = null
launchWithLoading { launchWithLoading {
val loginParam = LoginParam( val response = repository.getMemberRefPlateByUserId(userId = memberId)
appVersion = GlobalData.appVersion, if (response.isSuccess()) {
equipmentCode = equipmentCode,
memberId = memberId
)
val response = repository.equipmentBoxLogin(loginParam)
if (parseResponse(response)) {
_currentUserInfo.value = response.data _currentUserInfo.value = response.data
action(response.data)
} else {
if (!silent) {
Timber.d("msg = ${response.msg}, code = ${response.code}")
ToastUtils.showToast("${response.msg}(${response.code})")
}
action(null)
} }
} }
} }
/**
* 重置用户信息
*/
fun resetUserInfo() {
Timber.d("resetUserInfo")
_currentUserInfo.value = null
}
fun getDeviceConfig(block: (DeviceConfig?) -> Unit) {
// Timber.tag(TAG).d("getDeviceConfig")
launch {
val response = repository.getDeviceConfig()
if (parseResponse(response)) {
block(response.data)
} else {
block(null)
}
}
}
} }
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#000000" /> <!-- 整体黑色背景 -->
<stroke
android:width="2dp"
android:color="#333333" /> <!-- 边框颜色和宽度 -->
<corners android:radius="12dp" /> <!-- 圆角半径 -->
</shape>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#30000000">
<item>
<shape android:shape="rectangle">
<solid android:color="#ff08C6DC" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="600dp"
android:height="1024dp">
<bitmap android:src="@drawable/bg"/>
</item>
<item
android:width="337dp"
android:height="322dp"
android:gravity="top|center_horizontal"
android:top="112dp">
<bitmap android:src="@drawable/img_init" android:scaleType="fitCenter"/>
</item>
</layer-list>
@@ -4,5 +4,5 @@
<corners android:radius="12dp" /> <corners android:radius="12dp" />
<stroke <stroke
android:width="2dp" android:width="2dp"
android:color="#887872" /> android:color="#FFCC99" />
</shape> </shape>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="6dp" /> <corners android:radius="6dp" />
<solid android:color="#F0C8B4" /> <solid android:color="#FFCC99" />
</shape> </shape>
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

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