Author SHA1 Message Date
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
65 changed files with 2127 additions and 314 deletions
+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="azul-17" /> <option name="gradleJvm" value="jbr-21" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
+2 -1
View File
@@ -1,6 +1,7 @@
<?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="zulu-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) {
// 开柜失败
}
});
```
+2 -2
View File
@@ -14,11 +14,11 @@ 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
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
}
+8 -8
View File
@@ -17,7 +17,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <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"
@@ -31,7 +31,7 @@
tools:targetApi="31"> tools:targetApi="31">
<!-- 注册BootReceiver,监听开机完成广播 --> <!-- 注册BootReceiver,监听开机完成广播 -->
<receiver <receiver
android:name=".receiver.BootReceiver" android:name="com.sw.platecabinet.receiver.BootReceiver"
android:enabled="true" android:enabled="true"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -39,24 +39,24 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<activity <activity
android:name=".activity.UnBindDialogActivity" android:name="com.sw.platecabinet.activity.UnBindDialogActivity"
android:exported="false" android:exported="false"
android:theme="@style/DialogActivity" /> android:theme="@style/DialogActivity" />
<activity <activity
android:name=".activity.InitActivity" android:name="com.sw.platecabinet.activity.InitActivity"
android:exported="false" android:exported="false"
android:launchMode="singleTask"> android:launchMode="singleTask">
</activity> </activity>
<activity <activity
android:name=".activity.LoginByPwdActivity" android:name="com.sw.platecabinet.activity.LoginByPwdActivity"
android:exported="false" android:exported="false"
android:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
android:name=".activity.LoginByFaceActivity" android:name="com.sw.platecabinet.activity.LoginByFaceActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity <activity
android:name=".activity.DeviceInitActivity" android:name="com.sw.platecabinet.activity.DeviceInitActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
@@ -66,7 +66,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".activity.MainActivity" android:name="com.sw.platecabinet.activity.MainActivity"
android:exported="false"> android:exported="false">
</activity> </activity>
</application> </application>
@@ -21,6 +21,7 @@ object GlobalData {
* 竖排数量 * 竖排数量
*/ */
var arrayVertical: Int = 11 var arrayVertical: Int = 11
/** /**
* 排列方式 0 垂直 1 水平 * 排列方式 0 垂直 1 水平
*/ */
@@ -28,12 +29,19 @@ object GlobalData {
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj" var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k" var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
var activeKey ="085F-118G-Q3J6-35UX" //"085F-118G-Q391-53YL" var activeKey = "85Q1-1216-X3DH-QYTJ"//"085F-118G-Q3J6-35UX" //"085F-118G-Q391-53YL"
/** /**
* 具体业务baseurl * 具体业务baseurl
*/ */
var appBaseUrl: String = "https://dev.yixiong-tech.com:8081" 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 * 设备id
@@ -61,4 +69,24 @@ object GlobalKey {
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 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)
)
@@ -4,6 +4,7 @@ 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.CrashHandler
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber import timber.log.Timber
class MyApp : App() { class MyApp : App() {
@@ -33,6 +34,10 @@ class MyApp : App() {
GlobalData.appVersion = AppUtil.getAppVersionCode(this).toString() 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" // GlobalData.appBaseUrl = "http://192.168.1.201:14801"
} }
} }
@@ -17,30 +17,29 @@ 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.Base64
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.facedb.FaceDatabase
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.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.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.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.IntervalExecutor import com.sw.platecabinet.utils.IntervalExecutor
import com.sw.platecabinet.utils.PermissionHelper import com.sw.platecabinet.utils.PermissionHelper
import com.sw.platecabinet.utils.SpTool import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.view.CustomLoadingDialog 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 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.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
/** /**
@@ -61,6 +60,9 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
// 用户对应的viewModel // 用户对应的viewModel
protected val viewModel by viewModels<UserViewModel>() protected val viewModel by viewModels<UserViewModel>()
// V2 网络请求 viewModel(人脸缓存/增量同步/设备配置)
val netViewModelV2 by viewModels<NetViewModelV2>()
private var startTime: Long = 0 private var startTime: Long = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -84,8 +86,6 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
registerKeyEvent() registerKeyEvent()
val durationTime = System.currentTimeMillis() - startTime val durationTime = System.currentTimeMillis() - startTime
Timber.d("启动时间:$durationTime") Timber.d("启动时间:$durationTime")
startFaceTask()
} }
/** /**
@@ -394,9 +394,11 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
// private val initialDelay = 5 * 60 * 1000L // private val initialDelay = 5 * 60 * 1000L
// private val dealyMillis = 10 * 60 * 1000L // private val dealyMillis = 10 * 60 * 1000L
private val initialDelay = 1 * 60 * 1000L private val initialDelay = 5 * 60 * 1000L
private val dealyMillis = 1 * 60 * 1000L private val dealyMillis = 5 * 60 * 1000L
private var taskPageNo = 1
val recognizeViewModel by viewModels<RecognizeViewModel>()
fun startFaceTask() { fun startFaceTask() {
faceTaskJob = faceTaskJob =
intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) { intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) {
@@ -404,70 +406,33 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
} }
} }
private var faceTimestamp = 0L /**
* 人脸增量同步(V2,同步逻辑在 NetViewModelV2 内部处理)
private fun getFaceIncrementList() { */
private fun getFaceIncrementList(pageNo: Int = 1) {
val timestamp = SpTool.getLastFaceTimestamp() val timestamp = SpTool.getLastFaceTimestamp()
if (timestamp == 0L) { if (timestamp == 0L) {
return return
} }
settingViewModel.getFaceIncrementList( netViewModelV2.getFaceIncrementList(
pageNo = taskPageNo, pageNo = pageNo,
timestamp = timestamp timestamp = timestamp
) { list -> ) {
runOnUiThread { recognizeViewModel.refreshFaceList()
if (taskPageNo == 1 && list.isEmpty()) {
//未查询到增量数据
return@runOnUiThread
}
updateFaceData(list)
if (list.size >= settingViewModel.PAGE_SIZE) {
faceTimestamp = list.last().faceUpdateTimestamp?:0
taskPageNo++
getFaceIncrementList()
return@runOnUiThread
}
if (list.isNotEmpty()) {
faceTimestamp = list.last().faceUpdateTimestamp?:0
}
SpTool.setLastFaceTimestamp(faceTimestamp)
}
} }
} }
private val recognizeViewModel by viewModels<RecognizeViewModel>() /**
private fun updateFaceData(list: List<UserFaceModel>) { * 清空本地人脸库
Thread { */
val faceList = mutableListOf<FaceEntity>() fun clearAllFace(block: () -> Unit) {
try { lifecycleScope.launch(Dispatchers.IO) {
list.forEach { model -> val faceDao = FaceDatabase.getInstance(this@BaseActivity).faceDao()
if (model.faceDeleted == true) { faceDao.deleteAll()
//删除数据 faceDao.resetId()
FaceDatabase.getInstance(this).faceDao().deleteFaceById(model.userId) recognizeViewModel.refreshFaceList()
} else { withContext(Dispatchers.Main) { block() }
//保存数据 }
val faceEntity = FaceEntity(
model.userId,
null,
Base64.decode(model.faceFeatureStr)
).also {
it.userType = "1"
}
faceList.add(faceEntity)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
try {
if (faceList.isNotEmpty()) {
FaceDatabase.getInstance(this).faceDao().insert(faceList)
}
recognizeViewModel.refreshFaceList()
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
} }
} }
@@ -1,22 +1,34 @@
package com.sw.platecabinet.activity package com.sw.platecabinet.activity
import android.content.Intent import android.content.Intent
import android.util.Log import android.os.Handler
import androidx.activity.viewModels import android.os.Looper
import com.sw.plate.utils.AppUtil import android.view.MotionEvent
import android.view.WindowManager
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.databinding.ActivityDeviceInitBinding import com.sw.platecabinet.dialog.EnvironmentSelectDialog
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ActivityDeviceInitBinding
import com.sw.platecabinet.ext.gone import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.ext.invisible import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.viewmodel.SettingViewModel import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
/** /**
* 设备初始化界面 * 设备初始化界面
*/ */
class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() { class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
// 连点切换环境的计数与复位
private var tapCount = 0
private val tapHandler = Handler(Looper.getMainLooper())
private val resetTapRunnable = Runnable { tapCount = 0 }
override fun inflateViewBinding(): ActivityDeviceInitBinding { override fun inflateViewBinding(): ActivityDeviceInitBinding {
return ActivityDeviceInitBinding.inflate(layoutInflater) return ActivityDeviceInitBinding.inflate(layoutInflater)
} }
@@ -26,33 +38,73 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
} }
override fun initialize() { override fun initialize() {
// val isSuccess = deviceViewModel.checkEquipmentInfo() // 等待框设为不拦截触摸,确保连点手势可达
// if (isSuccess) { showWaitingDialog("加载中……")?.window?.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
// goLoginActivity() // 首次启动先全量拉取人脸缓存,后续走增量同步
// return if (SpTool.getFirstGetFace()) {
// } netViewModelV2.getUserFaceCache { status, msg ->
// binding.ivQrCode.setImageBitmap( SpTool.setFirstGetFace(false)
// QRCodeUtil.generateQRCode( getDeviceConfig()
// content = GlobalData.deviceId, }
// size = 200 } else {
// ) getDeviceConfig()
// ) }
// binding.btnInit.setOnClickListener { }
// deviceViewModel.getDeviceToken()
// } /**
// binding.btnInit.gone() * 连续点击 3 次(500ms 内)触发环境切换弹窗
// binding.ivQrCode.invisible() */
// binding.root.postDelayed({ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
// goLoginActivity() if (ev.action == MotionEvent.ACTION_DOWN) {
// }, 1000) tapCount++
showWaitingDialog("加载中……") tapHandler.removeCallbacks(resetTapRunnable)
viewModel.getDeviceConfig { deviceConfig -> tapHandler.postDelayed(resetTapRunnable, 500)
if (tapCount >= 3) {
tapCount = 0
tapHandler.removeCallbacks(resetTapRunnable)
showEnvironmentDialog()
return true
}
}
return super.dispatchTouchEvent(ev)
}
/**
* 弹出环境选择弹窗
*/
private fun showEnvironmentDialog() {
EnvironmentSelectDialog(this) { env ->
switchEnvironment(env)
}.show()
}
/**
* 切换环境:持久化 + 清空本地人脸库 + 重置增量同步状态
*/
private fun switchEnvironment(env: Environment) {
SpTool.setBaseUrl(env.url)
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@DeviceInitActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
SpTool.setLastFaceTimestamp(0L)
SpTool.setFirstGetFace(true)
withContext(Dispatchers.Main) {
ToastUtils.showToast("已切换到${env.name},重启后生效")
}
}
}
/**
* 获取设备配置(V2),并初始化虹软 SDK 激活参数
*/
private fun getDeviceConfig() {
netViewModelV2.getDeviceConfig(onSuccess = { deviceConfig ->
runOnUiThread { runOnUiThread {
if (deviceConfig == null) { if (deviceConfig == null) {
hideWaitingDialog() hideWaitingDialog()
ToastUtils.showToast("获取设备配置数据失败") ToastUtils.showToast("获取设备配置失败,请连点屏幕3次切换环境")
hideWaitingDialog() // goLoginActivity()
goLoginActivity()
return@runOnUiThread return@runOnUiThread
} }
GlobalData.appId = deviceConfig.arcsoftAppId ?: "" GlobalData.appId = deviceConfig.arcsoftAppId ?: ""
@@ -63,8 +115,14 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
goLoginActivity() goLoginActivity()
}, 500) }, 500)
} }
} }, onFailure = { errMsg ->
// goLoginActivity() runOnUiThread {
hideWaitingDialog()
Timber.e("getDeviceConfig onFailure: $errMsg")
ToastUtils.showToast("服务连接失败,请连点屏幕3次切换环境")
// goLoginActivity()
}
})
} }
override fun registerDataChange() { override fun registerDataChange() {
@@ -3,8 +3,8 @@ package com.sw.platecabinet.activity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.arcface.FaceApi import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.faceserver.FaceServer import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.platecabinet.databinding.ActivityInitBinding import com.sw.platecabinet.member.databinding.ActivityInitBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -24,14 +24,16 @@ 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.faceserver.FaceServer
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity 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.model.response.EquipmentUserInfo
import com.sw.platecabinet.network.task.TaskManager import com.sw.platecabinet.network.task.TaskManager
import com.sw.platecabinet.utils.PermissionHelper import com.sw.platecabinet.utils.PermissionHelper
@@ -45,7 +47,6 @@ import timber.log.Timber
*/ */
class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(), class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
ViewTreeObserver.OnGlobalLayoutListener { ViewTreeObserver.OnGlobalLayoutListener {
private val recognizeViewModel by viewModels<RecognizeViewModel>()
private var countDownTimer: CountDownTimer? = null private var countDownTimer: CountDownTimer? = null
private val CAMERA_PERMISSION_REQUEST_CODE = 100 private val CAMERA_PERMISSION_REQUEST_CODE = 100
@@ -73,16 +74,17 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
override fun initialize() { override fun initialize() {
instance = this instance = this
viewModel.activeEngine() // V2 引擎激活(appId/sdkKey/activeKey 来自 V2 设备配置)
netViewModelV2.activeEngine()
initCountTime() initCountTime()
initArcViewModel() initArcViewModel()
initArcView() initArcView()
openRectInfoDraw = true openRectInfoDraw = true
recognizeViewModel.setDrawRectInfoTextValue(true) recognizeViewModel.setDrawRectInfoTextValue(true)
//viewModel.generateToken()
// viewModel.activeEngine() //开启人脸增量数据定时任务(V2
viewModel.getUserFaceCache() startFaceTask()
binding.llToPwd.setOnClickListener { binding.llToPwd.setOnClickListener {
val intent = Intent(this, LoginByPwdActivity::class.java) val intent = Intent(this, LoginByPwdActivity::class.java)
@@ -171,9 +173,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) "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 ->
@@ -187,6 +192,47 @@ 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") Timber.d("onDestroy")
instance = null instance = null
@@ -277,7 +323,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
val layoutParams = adjustPreviewViewSize( val layoutParams = adjustPreviewViewSize(
binding.dualCameraTexturePreviewRgb, binding.dualCameraTexturePreviewRgb,
binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView, binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView,
previewSizeRgb, displayOrientation, 0.6f 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}")
@@ -392,14 +438,24 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
} }
private fun resumeCamera() { private fun resumeCamera() {
isRecognition = true val helper = rgbCameraHelper
if (rgbCameraHelper != null && rgbCameraHelper!!.isStopped) { if (helper != null && helper.isStopped) {
rgbCameraHelper!!.start() isRecognition = true
helper.start()
} else {
// 相机未停止时,发送黑帧清除 ViewModel 内部识别缓存,防止恢复后使用旧数据
recognizeViewModel.onPreviewFrame(emptyFrame, true)
binding.dualCameraFaceRectView.clearFaceInfo()
binding.root.postDelayed({
isRecognition = true
}, 500)
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// 清空上次识别结果和 FaceHelper 内部状态,防止短时间内再次识别无法触发
recognizeViewModel.resetFaceState()
resumeCamera() resumeCamera()
viewModel.resetUserInfo() viewModel.resetUserInfo()
countDownTimer?.let { countDownTimer?.let {
@@ -416,10 +472,26 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
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) { override fun handleLoginSuccess(equipmentUserInfo: EquipmentUserInfo, isAdmin: Boolean) {
super.handleLoginSuccess(equipmentUserInfo, isAdmin) super.handleLoginSuccess(equipmentUserInfo, isAdmin)
// goInitActivity() // goInitActivity()
@@ -2,8 +2,8 @@ package com.sw.platecabinet.activity
import android.content.Intent import android.content.Intent
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.databinding.ActivityLoginByPwdBinding import com.sw.platecabinet.member.databinding.ActivityLoginByPwdBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.dialog.BalanceNotEnoughDialog import com.sw.platecabinet.dialog.BalanceNotEnoughDialog
import com.sw.platecabinet.model.request.LoginParam import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
@@ -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
@@ -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() {
@@ -6,8 +6,8 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.sw.platecabinet.GlobalKey import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.databinding.ActivityUnbindDialogBinding import com.sw.platecabinet.member.databinding.ActivityUnbindDialogBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.ext.dp import com.sw.platecabinet.ext.dp
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
@@ -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
@@ -5,7 +5,7 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.view.Window import android.view.Window
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.databinding.DialogBalanceNotEnoughBinding import com.sw.platecabinet.member.databinding.DialogBalanceNotEnoughBinding
import com.sw.platecabinet.ext.dp import com.sw.platecabinet.ext.dp
open class BalanceNotEnoughDialog( open class BalanceNotEnoughDialog(
@@ -0,0 +1,81 @@
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.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 = env.name
gravity = Gravity.CENTER_VERTICAL
textSize = 18f
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 }
}
/**
* 确认按钮:回调所选环境并关闭弹窗
*/
private fun addListener() {
binding.tvConfirm.setOnClickListener {
selected?.let(onConfirm)
dismiss()
}
}
}
@@ -5,7 +5,7 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.view.Window import android.view.Window
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.databinding.DialogUserBindRemindBinding import com.sw.platecabinet.member.databinding.DialogUserBindRemindBinding
import com.sw.platecabinet.ext.dp import com.sw.platecabinet.ext.dp
open class UserBindRemindDialog( open class UserBindRemindDialog(
@@ -11,7 +11,7 @@ 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.CustomLoadingDialog 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
@@ -14,14 +14,14 @@ import androidx.recyclerview.widget.RecyclerView
import com.sw.plate.utils.ToastUtils 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.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.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.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
@@ -2,7 +2,7 @@ package com.sw.platecabinet.fragment
import android.os.CountDownTimer import android.os.CountDownTimer
import com.sw.platecabinet.Constants import com.sw.platecabinet.Constants
import com.sw.platecabinet.databinding.FragmentPlateCabinetFullBinding import com.sw.platecabinet.member.databinding.FragmentPlateCabinetFullBinding
import timber.log.Timber import timber.log.Timber
/** /**
@@ -4,9 +4,9 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import com.sw.platecabinet.Constants import com.sw.platecabinet.Constants
import com.sw.platecabinet.R import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.InitActivity import com.sw.platecabinet.activity.InitActivity
import com.sw.platecabinet.databinding.FragmentPlateOpenBinding import com.sw.platecabinet.member.databinding.FragmentPlateOpenBinding
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
/** /**
@@ -11,15 +11,15 @@ 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
@@ -9,7 +9,7 @@ import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.GlobalKey import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.activity.UnBindDialogActivity import com.sw.platecabinet.activity.UnBindDialogActivity
import com.sw.platecabinet.databinding.FragmentUnbindPlateBinding 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.ErrorInfo
@@ -21,6 +21,28 @@ data class DeviceConfig(
var arcsoftActiveKey: 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( data class CodeMsg(
val code: String? = "", val code: String? = "",
val msg: String? = "" val msg: String? = ""
@@ -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
)
@@ -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,21 +9,20 @@ import kotlinx.parcelize.Parcelize
*/ */
@Parcelize @Parcelize
data class UserFaceModel( data class UserFaceModel(
// @SerializedName("faceFeature")
// val faceFeature: String? = "",
// @SerializedName("faceFeatureString")
// val faceFeatureString: String? = "",
// @SerializedName("faceType")
// val faceType: String? = "",
// @SerializedName("userFaceId")
// val userFaceId: String? = "",
// @SerializedName("userId")
// val userId: String? = "",
// @SerializedName("updateTimeStamp")
// val updateTimeStamp: Long = 0,
val userId: String? = "", val userId: String? = "",
val faceFeatureStr: String? = "", val faceFeatureStr: String? = "",
val faceUpdateTimestamp:Long?=null, val faceUpdateTimestamp: Long? = null,
val faceDeleted: Boolean?=false /**
* 人脸删除标识
*/
val faceDeleted: Boolean? = false,
/**
* 会员编号
*/
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
)
}
}
@@ -3,6 +3,7 @@ package com.sw.platecabinet.network
import com.sw.platecabinet.GlobalData 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
@@ -12,36 +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 DEVICE_BASE_URL = "http://device.shuziweidao.com:8889/"
private var DEVICE_BASE_URL = GlobalData.appBaseUrl
// private const val BASE_URL = "http://192.168.1.8:9092"
private const val TIME_OUT = 30L // 超时时间(秒)
val okHttpClient = OkHttpClient.Builder() private const val TIME_OUT = 60L // 超时时间(秒)
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(DEVICE_BASE_URL) Retrofit.Builder()
.client(okHttpClient) .baseUrl(resolveBaseUrl())
.addConverterFactory(GsonConverterFactory.create()) .client(okHttpClient)
// .addCallAdapterFactory(CoroutineCallAdapterFactory()) // 协程适配器 .addConverterFactory(GsonConverterFactory.create())
.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/"
}
}
@@ -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?>
}
@@ -1,23 +1,11 @@
package com.sw.platecabinet.network.task package com.sw.platecabinet.network.task
import android.content.Context import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.arcsoft.face.FaceEngine
import com.sw.inbound.utils.GsonUtils
import com.sw.plate.App
import com.sw.plate.utils.Base64
import com.sw.plate.utils.PrefUtils
import com.sw.plate.utils.arcface.FaceApi import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.arcface.faceserver.FaceServer
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.Dispatchers
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
class HeartBeatTask(appContext: Context, workerParams: WorkerParameters) : class HeartBeatTask(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) { CoroutineWorker(appContext, workerParams) {
@@ -1,13 +1,10 @@
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.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.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
@@ -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,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
}
}
}
@@ -1,11 +1,13 @@
package com.sw.platecabinet.utils; package com.sw.platecabinet.utils;
import com.sw.inbound.utils.SPUtil; import com.sw.inbound.utils.SPUtil;
import com.sw.platecabinet.GlobalKey;
import com.sw.platecabinet.MyApp; import com.sw.platecabinet.MyApp;
public class SpTool { public class SpTool {
public static final String LAST_FACE_TIMESTAMP = "faceTimestamp"; public static final String LAST_FACE_TIMESTAMP = "faceTimestamp";
public static final String IS_FIRST_GET_FACE = "isFirstGetFace";
public static long getLastFaceTimestamp() { public static long getLastFaceTimestamp() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(LAST_FACE_TIMESTAMP, 0L); return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(LAST_FACE_TIMESTAMP, 0L);
@@ -15,6 +17,26 @@ public class SpTool {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(LAST_FACE_TIMESTAMP, 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);
}
} }
@@ -3,7 +3,6 @@ package com.sw.platecabinet.viewmodel
import androidx.lifecycle.ViewModel 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.GlobalData
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.EquipmentInfo
import com.sw.platecabinet.network.ApiClient import com.sw.platecabinet.network.ApiClient
@@ -0,0 +1,325 @@
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.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.withContext
import timber.log.Timber
import java.io.File
/**
* 用户viewmodel
*/
class NetViewModelV2 : ViewModel() {
companion object {
const val PAGE_SIZE = 100
}
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")
// 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()
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
}
}
@@ -59,9 +59,9 @@
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginTop="129dp" android:layout_marginTop="129dp"
android:text="请正视屏幕" android:text="请正视屏幕"
android:textStyle="bold"
android:textColor="#FFCC99" android:textColor="#FFCC99"
android:textSize="36sp" /> android:textSize="36sp"
android:textStyle="bold" />
<LinearLayout <LinearLayout
android:id="@+id/ll_to_pwd" android:id="@+id/ll_to_pwd"
@@ -72,7 +72,8 @@
android:background="@drawable/btn_outline" android:background="@drawable/btn_outline"
android:gravity="center" android:gravity="center"
android:paddingHorizontal="45dp" android:paddingHorizontal="45dp"
android:paddingVertical="18dp"> android:paddingVertical="18dp"
android:visibility="gone">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -86,12 +87,12 @@
android:id="@+id/tv_to_init" android:id="@+id/tv_to_init"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
tools:text="30"
android:padding="12dp"
android:gravity="center"
android:layout_marginTop="185dp" android:layout_marginTop="185dp"
android:gravity="center"
android:padding="12dp"
android:textColor="#FFCC99" android:textColor="#FFCC99"
android:textSize="36sp" /> android:textSize="36sp"
tools:text="30" />
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
+1 -1
View File
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">餐盘柜</string> <string name="app_name">餐盘柜会员版</string>
<!-- TODO: Remove or change this placeholder text --> <!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string> <string name="hello_blank_fragment">Hello blank fragment</string>
@@ -0,0 +1,119 @@
# 业务 BaseUrl 切换 — 设计文档
- 日期:2026-09-01
- 状态:已确认
- 关联模块:网络层 / 设备初始化 / 全局配置
## 1. 背景与目标
SmartTakePlate(发盘机)当前业务服务地址由 `GlobalData.appBaseUrl` 决定,但该字段:
- 默认值为空串,全局无任何活跃代码赋值(仅剩注释)。
- 已具备一套"半成品"基础设施(`SpTool.getBaseUrl/setBaseUrl``GlobalKey.KEY_BASE_URL`
`LOCAL/TEST/PROD_BASE_URL` 常量),但从未接线。
目标:让现场运维人员能在**设备端**切换业务环境(本地 / 测试 / 生产),切换结果持久化,
重启后生效。
## 2. 需求
- **入口**:设备端隐藏入口,置于**登录前的初始化界面**(`DeviceInitActivity`)。
原因:若入口放在业务主界面,一旦配置的服务地址访问不到,业务界面因依赖网络请求成功才能
进入,将无法切回正确地址,形成死锁。
- **生效方式**:持久化到 SharedPreferences,重启 app 后生效(最简单可靠)。
- **选项来源**:仅预设环境列表(本地 / 测试 / 生产),不支持手输。
## 3. 现状分析
| 已存在 | 状态 |
|---|---|
| `GlobalData.appBaseUrl``var String = ""` | 默认空串,无活跃赋值 |
| `LOCAL_BASE_URL` / `TEST_BASE_URL` / `PROD_BASE_URL` | 已定义,不带尾斜杠 |
| `GlobalKey.KEY_BASE_URL = "baseUrlKey"` | 已定义 |
| `SpTool.getBaseUrl() / setBaseUrl()` | 已实现,无调用方 |
| `ApiClient.retrofit``by lazy``baseUrl(GlobalData.appBaseUrl)` | 用空串构建,切环境不重建 |
| `ApiService` / `ApiServiceV2` 全部方法 | 用 `@Url url = "${GlobalData.appBaseUrl}/..."` 默认参数,调用时求值 |
关键结论:
1. 所有请求地址由 `GlobalData.appBaseUrl` 在**每次调用时**经默认参数拼接决定,故切环境
只需改这一个变量(配合重启重建 `Retrofit`)。
2. `GlobalData.appBaseUrl=""` 会让 `Retrofit.Builder().baseUrl("")` 抛异常 —— 现有隐患。
## 4. 设计
### 4.1 组件划分与改动清单
| 文件 | 改动 |
|---|---|
| `GlobalData.kt` | 补充环境列表模型(名称 + URL),复用已有 `LOCAL/TEST/PROD_BASE_URL` |
| `MyApp.kt` | `initGlobalData()` 接线:读 `SpTool.getBaseUrl()`,兜底 `TEST_BASE_URL` |
| `DeviceInitActivity.kt` | 加隐藏连点手势 + 弹环境选择弹窗 |
| `ApiClient.kt` | 修复 Retrofit baseUrl 校验(补尾斜杠 / 占位),消除空串崩溃 |
| 新增 `EnvironmentSelectDialog`(或内联 Dialog) | 环境单选弹窗,高亮当前项 |
| `BaseActivity.kt`(复用) | 复用已有 `clearAllFace()` 清人脸库 |
### 4.2 关键设计决策
**决策 A — URL 规范化(消除现有隐患)**
- 约定 `GlobalData.appBaseUrl` **不带尾斜杠**,供 `@Url` 拼接得到形如
`https://dev.yixiong-tech.com:8081/terminal/...` 的完整地址。
- `Retrofit` 的 baseUrl 单独规范化:空则占位 `http://localhost/`,非空且无尾斜杠则补 `/`
因所有请求走 `@Url` 全路径,Retrofit baseUrl 不参与实际拼接,只需合法即可。
**决策 B — 切换时清理环境残留**
选中环境后执行:
1. `SpTool.setBaseUrl(url)` 持久化;
2. 清空本地人脸库(`FaceDatabase` / `clearAllFace()`);
3. 重置人脸时间戳 `lastFaceTimestamp = 0`
4. 重置 `firstGetFace = true`
原因:不同环境的人脸数据与时间戳不同,不清理会导致重启后增量同步对不上。
另:ArcSoft 激活参数(`appId` / `sdkKey` / `activeKey`)无需单独处理——`DeviceInitActivity`
每次启动都会调用 `getDeviceConfig()` 从新环境重新获取并覆盖,切环境后自动适配。
**决策 C — 隐藏手势**
`DeviceInitActivity` 连续点击屏幕(如 5 次)触发,不新增可见 UI 元素。该界面登录前
可达、不依赖网络,满足"配错地址也能切回"的要求。
## 5. 数据流
```mermaid
sequenceDiagram
participant User as 运维人员
participant App as MyApp/DeviceInit
participant Sp as SpTool(SharedPrefs)
participant Net as Retrofit/网络
App->>Sp: 启动时读 KEY_BASE_URL
alt 有值
Sp-->>App: 返回已存 URL
else 无值(首次)
Sp-->>App: 空 → 用 TEST_BASE_URL
end
App->>App: GlobalData.appBaseUrl = 结果
User->>App: 初始化界面连点5次
App->>App: 弹环境选择Dialog(高亮当前)
User->>App: 选择「生产」并确认
App->>Sp: setBaseUrl(PROD)
App->>App: 清人脸库 + 重置时间戳 + firstGetFace=true
App->>User: Toast「重启后生效」
User->>App: 重启 app
App->>Net: appBaseUrl 已指向生产 → 全量拉新环境人脸
```
## 6. 错误处理
- **baseUrl 空 / 非法**:兜底 `TEST_BASE_URL`,并保证 Retrofit baseUrl 合法(占位/补斜杠)。
- **清人脸库失败**:非致命,仅记日志,不阻塞切换(重启后首次拉取仍会兜底全量)。
- **写 SharedPreferences 失败**:几乎不抛;Toast 提示用户重试。
## 7. 验证方式
- 构建:`./gradlew assembleDebug` 通过。
- 手动:初始化界面连点 5 次 → 弹窗 → 切「本地」→ 重启 → 抓包/日志确认请求打到新地址;
再切回「测试」验证可恢复。
@@ -0,0 +1,211 @@
# 智能餐盘柜(开柜门版)业务接口对齐文档
> 版本:v1.02026-08-18
> 适用设备:会员版智能餐盘柜(开柜门,applicationId `com.sw.platecabinet.member`
> 用途:供后端对照本设备端实际调用的接口,对齐业务功能与字段
## 1. 通用约定
### 1.1 服务器环境
设备端 BaseUrl 运行时确定,优先级:设备配置接口下发的 `appPackageUrl` > 本地缓存(SP)> 内置默认。
| 环境 | 地址 | 说明 |
| --- | --- | --- |
| 本地 | `http://192.168.10.101:24801` | LOCAL_BASE_URL |
| 测试 | `https://dev.yixiong-tech.com:8081` | TEST_BASE_URL |
| 生产 | `https://platform-api.uat.shuziweidao.com` | PROD_BASE_URL |
### 1.2 请求头(所有请求统一携带)
| Header | 值 | 说明 |
| --- | --- | --- |
| Content-Type | application/json | JSON 请求体 |
| Accept | application/json | — |
| X-Access-Token | 固定 JWT 字符串(当前硬编码) | 设备免登录态 |
| X-DEVICE-CODE | 设备 SN`GlobalData.deviceId` | 设备标识 |
| authorization | 固定 key `57ee87183f2a4fa59683ec9ef41c8f5d` | 网关鉴权 |
### 1.3 统一响应格式
```json
{
"code": "00000", // 成功固定为 "00000",其余视为失败
"msg": "",
"data": { ... } // 业务数据,可为 null / 数组
}
```
## 2. 接口总览
本设备端接口分两套体系(两套设备共用同一后端时请都保留):
| 体系 | 用途 | 状态 |
| --- | --- | --- |
| V2/nutrition/neglect/** | 人脸体系:设备配置、人脸全量/增量同步、人脸采集、取餐上报 | **当前启用** |
| V1/terminal/neglect/** | 开柜门业务:登录、餐盘绑定/解绑、会员搜索、扫描枪查询 | **当前启用** |
| V1 旧人脸接口(faceFeature 系列) | 旧人脸同步 | **已停用**(被 V2 替代,可下线) |
## 3. V2 人脸体系接口(当前启用)
### 3.1 获取设备配置
- **GET** `/nutrition/neglect/pickup/device/config`
- 无参数
- 调用时机:设备启动(DeviceInitActivity),人脸缓存同步之前
响应 `data`DeviceConfigV2):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| appPackageUrl | String | 业务服务器 BASE URL(下发后全局生效) |
| canteenName | String | 食堂名称 |
| canteenId | String | 食堂 ID |
| arcsoftAppId | String | 虹软 SDK AppID(设备激活用) |
| arcsoftSdkKey | String | 虹软 SDK Key |
| arcsoftActiveKey | String | 虹软 SDK 激活码 |
| clientServerIp | String | MQTT 客户端服务器 IP |
| zhstServerIp | String | 智慧食堂服务器 IPMQTT 端口) |
### 3.2 获取人脸缓存(全量,分页)
- **POST** `/nutrition/neglect/common/face/page`
- 请求体:`{ "pageNum": 1, "pageSize": 100 }`pageSize 固定 100,客户端自动递归翻页直到不足一页)
- 调用时机:设备首次启动(本地标记 isFirstGetFace)时全量拉取,拉取成功后清空本地人脸库重建
响应 `data``UserFaceModelV2` 数组:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| userFaceId | String | 人脸记录唯一 ID(增量删除/判重的关键) |
| userId | String | 用户 ID |
| faceFeature / faceFeatureStr / faceFeatureString | String | 人脸特征 Base64(三字段取第一个非空) |
| faceUpdateTimestamp | Long | 人脸更新时间戳(毫秒) |
| cardNo | String | 会员编号 |
| member | Boolean | 是否会员 |
| faceDeleted | Boolean | 删除标识(增量接口用) |
| personType | String | 人员类型 |
### 3.3 获取人脸增量数据
- **POST** `/nutrition/neglect/common/face/increment`
- 请求体:`{ "pageNum": 1, "pageSize": 100, "timestamp": 1723900000000 }`(timestamp 为上次同步的最大 faceUpdateTimestamp
- 调用时机:登录页每 5 分钟定时任务(人脸库非空时)
- 客户端处理:`faceDeleted=true``userFaceId` 删单条;否则按 `userFaceId` 判重后插入;同步完成后保存最大时间戳(只升不降)
- 响应:同 3.2 的 `UserFaceModelV2` 数组
### 3.4 上传人脸照片(预留,本版本未启用)
- **POST** `/nutrition/neglect/upload`multipart/form-data,字段名 `file`
- 响应 `data`:图片 URLString
### 3.5 新增人脸数据(预留,本版本未启用)
- **POST** `/nutrition/neglect/user/add-by-face`
- 请求体:`{ "url": "<图片URL>", "featureChar": "<特征Base64>" }`
- 响应 `data``UserFaceModelV2`(含服务端生成的 userFaceId / userId
### 3.6 取餐盘时刻上报(预留,本版本未启用)
- **POST** `/nutrition/neglect/pickup/plate-pickup`
- 请求体:`{ "id": <用户id> }`
- 响应 `data`:主单 recordNoString
- 说明:吐盘机 5.0 在识别成功出盘后调用;开柜门版暂未启用,后端可先行保留
## 4. V1 开柜门业务接口(当前启用)
> 基础路径 `/terminal/neglect`,本设备核心业务流。
### 4.1 登录(获取餐盘用户信息)
- **POST** `/terminal/neglect/sideboard/app/getPlateBoxUserInfo`
- 请求体(LoginParam):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| equipmentId | Int | 设备 ID |
| faceId | String | 会员人脸 ID(人脸识别登录时传) |
| phone | String | 手机号(密码登录时传) |
| password | String | 密码(密码登录时传) |
- 调用时机:人脸识别成功(相似度 ≥ 0.8,3 秒防抖)/ 手机号密码登录
响应 `data`EquipmentUserInfo):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | Long | 绑定记录主键(解绑时使用) |
| equipmentId | String | 设备 ID |
| equipmentCode | String | 设备编号 |
| equipmentBoxCode | String | 柜门/格子编号(**非空即执行开柜门**,转 Int 后下发串口) |
| faceId | String | 会员人脸 ID(非 null 表示已绑定餐盘) |
| plateNumber | String | 餐盘编号 |
| equipmentName | String | 设备名称 |
| orderNo | String | 订单号 |
| updateTime | String | 更新时间 |
| name / phone / faceUrl | String | 会员基础信息 |
| mealTime / mealTimeInterval | String | 就餐时段 |
| openTime / openTimeInterval | String | 开柜时段 |
| eatCount | Int | 就餐次数 |
| cardBalance | Double | 卡余额(≤0 弹余额不足提示并拦截) |
### 4.2 查询设备下的绑定列表
- **GET** `/terminal/neglect/sideboard/app/getYxMemberRefPlateByEquipmentCode`(设备编号走 `X-DEVICE-CODE` 头)
- 调用时机:管理员登录且未绑定格子时,取可绑定的空格子
- 响应 `data`EquipmentUserInfo 数组
### 4.3 餐盘绑定
- **POST** `/terminal/neglect/sideboard/app/plateBinding`
- 请求体(BindParam):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| equipmentId | String | 设备 ID |
| equipmentCode | String | 设备编号 |
| equipmentBoxCode | String | 格子编号 |
| memberId | String | 会员 ID |
| faceId | String | 会员人脸 ID |
| plateNumber | String | 餐盘编号 |
- 响应 `data`EquipmentUserInfo
### 4.4 餐盘解绑
- **POST** `/terminal/neglect/sideboard/app/plateUnbind`form-urlencoded
- 参数:`id`Long,绑定记录主键)
- 响应 `data`:无
### 4.5 会员模糊搜索
- **POST** `/terminal/neglect/common/app/getUserInfoByNameOrPhone`
- 请求体:`{ "pageNum": 0, "pageSize": 0, "name": "张", "phone": "138..." }`
- 响应 `data`Member 数组):`id``faceId``name``phone`
- 调用时机:管理界面按姓名/手机号搜索会员
### 4.6 按餐盘号查询(扫描枪)
- **GET** `/terminal/neglect/sideboard/app/getMemberRefPlateByPlateNumber?plateNumber=xxx`
- 响应 `data`EquipmentUserInfo
- 调用时机:扫描枪扫餐盘码
## 5. 已停用的 V1 旧接口(可下线)
以下接口在本次人脸体系切换(V2)后**设备端已无调用**,后端对齐时可安排下线:
| 接口 | 路径 |
| --- | --- |
| 旧人脸缓存(V1 | POST `/terminal/neglect/common/app/faceFeature/list` |
| 旧人脸增量(V1 | POST `/terminal/neglect/common/app/faceFeature/increment/list` |
| 旧设备配置(V1 | GET `/terminal/neglect/common/app/getYxEquipmentByEquipmentCode` |
## 6. 设备端业务主流程(供对照)
1. 启动 → 拉取设备配置(V2 3.1,注入虹软激活参数、BaseUrl)
2. 首次启动全量拉取人脸(V2 3.2,分页递归重建本地库)
3. 进入人脸识别登录页,每 5 分钟增量同步(V2 3.3)
4. 人脸识别成功(阈值 0.8)→ 登录接口(V1 4.1)查会员与绑定信息
5. 已绑格子(equipmentBoxCode 非空)→ 串口开柜门 → 跳转结果页
6. 余额不足(cardBalance ≤ 0)→ 弹窗拦截
7. 管理员路径:搜索会员(4.5)/ 扫描枪(4.6)→ 绑定(4.3)/ 解绑(4.4)格子
+2 -2
View File
@@ -13,7 +13,7 @@ android {
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
ndk { ndk {
abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/)) abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a"))
} }
} }
@@ -62,6 +62,6 @@ dependencies {
implementation("com.google.code.gson:gson:2.8.6") implementation("com.google.code.gson:gson:2.8.6")
val glideVersion = "4.12.0" val glideVersion = "4.12.0"
implementation("com.github.bumptech.glide:glide:$glideVersion") api("com.github.bumptech.glide:glide:$glideVersion")
annotationProcessor("com.github.bumptech.glide:compiler:$glideVersion") annotationProcessor("com.github.bumptech.glide:compiler:$glideVersion")
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,6 +2,8 @@ package com.sw.plate.utils;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity; import android.view.Gravity;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@@ -30,18 +32,20 @@ public class ToastUtils {
* @param text the text * @param text the text
*/ */
public static void showToast(String text) { public static void showToast(String text) {
Context context = App.getContext(); new Handler(Looper.getMainLooper()).post(()->{
if (toast == null) { Context context = App.getContext();
View view = LayoutInflater.from(context).inflate(R.layout.toast_bg, null); if (toast == null) {
textCenterView = view.findViewById(R.id.toast_tv); View view = LayoutInflater.from(context).inflate(R.layout.toast_bg, null);
toast = new Toast(context); textCenterView = view.findViewById(R.id.toast_tv);
toast.setGravity(Gravity.CENTER, 0, 20); toast = new Toast(context);
toast.setDuration(Toast.LENGTH_SHORT); toast.setGravity(Gravity.CENTER, 0, 20);
toast.setView(view); toast.setDuration(Toast.LENGTH_SHORT);
} toast.setView(view);
}
textCenterView.setText(text); textCenterView.setText(text);
toast.show(); toast.show();
});
} }
/** /**
@@ -87,10 +87,10 @@ public class ConfigUtil {
/** /**
* 默认相机分辨率 * 默认相机分辨率
*/ */
private static final String DEFAULT_PREVIEW_SIZE = "1280x720"; // private static final String DEFAULT_PREVIEW_SIZE = "1280x720";
private static final String DEFAULT_PREVIEW_SIZE = "1024x768";
// private static final String DEFAULT_PREVIEW_SIZE = "400x640"; // private static final String DEFAULT_PREVIEW_SIZE = "400x640";
/** /**
* 获取String类型的preference * 获取String类型的preference
* *
@@ -32,14 +32,20 @@ public class FaceApi {
*/ */
public void updateFaceData(int index, List<FaceEntity> list) { public void updateFaceData(int index, List<FaceEntity> list) {
Log.d(TAG, "updateFaceData: index = " + index + ", listSize = " + list.size()); Log.d(TAG, "updateFaceData: index = " + index + ", listSize = " + list.size());
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); FaceDao faceDao = getFaceDao();
if (index == 1) { //if (index == 1) {
faceDao.deleteAll(); // faceDao.deleteAll();
faceDao.resetId(); // faceDao.resetId();
} //}
faceDao.insert(list); faceDao.insert(list);
} }
public void clearFaceData() {
FaceDao faceDao = getFaceDao();
faceDao.deleteAll();
faceDao.resetId();
}
/** /**
* 激活arcsoft 人脸 * 激活arcsoft 人脸
* *
@@ -82,24 +88,51 @@ public class FaceApi {
} }
public void deleteByUserName(String userName) { public void deleteByUserName(String userName) {
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); getFaceDao().deleteFaceById(userName);
faceDao.deleteFaceById(userName); }
/**
* 按 userFaceId 删除单条人脸记录
*/
public void deleteByUserFaceId(String userFaceId) {
getFaceDao().deleteFaceByUserFaceId(userFaceId);
}
/**
* 按 userFaceId 查询单条人脸记录
*/
public FaceEntity queryByUserFaceId(String userFaceId) {
if (userFaceId == null || userFaceId.isEmpty()) {
return null;
}
return getFaceDao().queryByUserFaceId(userFaceId);
} }
public Long insert(FaceEntity entity) { public Long insert(FaceEntity entity) {
if (entity == null) { if (entity == null) {
return 0L; return 0L;
} }
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); return getFaceDao().insert(entity);
return faceDao.insert(entity);
} }
public FaceEntity queryByUserName(String userName) { public FaceEntity queryByUserName(String userName) {
if (TextUtils.isEmpty(userName)) { if (TextUtils.isEmpty(userName)) {
return null; return null;
} }
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao(); return getFaceDao().queryByUserName(userName);
return faceDao.queryByUserName(userName); }
public int queryFaceCount() {
return getFaceDao().getFaceCount();
}
public List<FaceEntity> queryAllByUserName(String userName) {
return getFaceDao().queryAllByUserName(userName);
}
public FaceDao getFaceDao() {
return FaceDatabase.getInstance(App.getContext()).faceDao();
} }
} }
@@ -46,6 +46,10 @@ public class FaceRectView extends View {
} }
} }
public int getRectColor() {
return paint.getColor();
}
public void clearFaceInfo() { public void clearFaceInfo() {
drawInfoList.clear(); drawInfoList.clear();
postInvalidate(); postInvalidate();
@@ -5,6 +5,7 @@ import android.hardware.Camera;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import android.widget.Toast;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -18,6 +19,7 @@ import com.arcsoft.face.ImageQualitySimilar;
import com.arcsoft.face.LivenessInfo; import com.arcsoft.face.LivenessInfo;
import com.arcsoft.face.MaskInfo; import com.arcsoft.face.MaskInfo;
import com.arcsoft.face.enums.ExtractType; import com.arcsoft.face.enums.ExtractType;
import com.sw.plate.App;
import com.sw.plate.utils.L; import com.sw.plate.utils.L;
import com.sw.plate.utils.arcface.FaceRectTransformer; import com.sw.plate.utils.arcface.FaceRectTransformer;
import com.sw.plate.utils.arcface.face.constants.LivenessType; import com.sw.plate.utils.arcface.face.constants.LivenessType;
@@ -37,7 +39,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -102,6 +103,10 @@ public class FaceHelper implements FaceListener {
* 活体检测引擎 * 活体检测引擎
*/ */
private FaceEngine flEngine; private FaceEngine flEngine;
/**
* 口罩检测引擎(5.0 独立引擎)
*/
private FaceEngine maskEngine;
private Camera.Size previewSize; private Camera.Size previewSize;
@@ -146,11 +151,6 @@ public class FaceHelper implements FaceListener {
*/ */
private boolean onlyDetectLiveness; private boolean onlyDetectLiveness;
/**
* 是否需要更新faceData
*/
private boolean needUpdateFaceData;
/** /**
* 识别的配置项 * 识别的配置项
*/ */
@@ -180,9 +180,9 @@ public class FaceHelper implements FaceListener {
} }
private FaceHelper(Builder builder) { private FaceHelper(Builder builder) {
needUpdateFaceData = builder.needUpdateFaceData;
onlyDetectLiveness = builder.onlyDetectLiveness; onlyDetectLiveness = builder.onlyDetectLiveness;
ftEngine = builder.ftEngine; ftEngine = builder.ftEngine;
maskEngine = builder.maskEngine;
trackedFaceCount = builder.trackedFaceCount; trackedFaceCount = builder.trackedFaceCount;
previewSize = builder.previewSize; previewSize = builder.previewSize;
frEngine = builder.frEngine; frEngine = builder.frEngine;
@@ -341,12 +341,20 @@ public class FaceHelper implements FaceListener {
refreshTrackId(faceInfoList); refreshTrackId(faceInfoList);
if (faceInfoList.isEmpty()) { if (faceInfoList.isEmpty()) {
return facePreviewInfoList; return facePreviewInfoList;
} else {
FaceInfo currentFaceInfo = faceInfoList.get(0);
// L.e("currentFaceInfo width=" + currentFaceInfo.getRect().width() +
// "---height" + currentFaceInfo.getRect().height());
if (currentFaceInfo.getRect().width() < 200) {//距离远(人脸小),不识别
clearLeftFace(facePreviewInfoList);
return facePreviewInfoList;
}
} }
if (!onlyDetectLiveness) { if (!onlyDetectLiveness && maskEngine != null) {
code = ftEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList, code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList,
FaceEngine.ASF_MASK_DETECT); FaceEngine.ASF_MASK_DETECT);
if (code == ErrorInfo.MOK) { if (code == ErrorInfo.MOK) {
code = ftEngine.getMask(maskInfoList); code = maskEngine.getMask(maskInfoList);
if (code != ErrorInfo.MOK) { if (code != ErrorInfo.MOK) {
onFail(new Exception("process getMask failed,code is " + code)); onFail(new Exception("process getMask failed,code is " + code));
return facePreviewInfoList; return facePreviewInfoList;
@@ -652,21 +660,52 @@ public class FaceHelper implements FaceListener {
}); });
} }
private int failCount = 0;
private long startTime = 0;
private void searchFace(final FaceFeature faceFeature, final Integer trackId) { private void searchFace(final FaceFeature faceFeature, final Integer trackId) {
CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature, frEngine); CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature, frEngine);
if (compareResult == null || compareResult.getFaceEntity() == null) { if (compareResult == null || compareResult.getFaceEntity() == null) {
if (startTime == 0) {
failCount = 0;
startTime = System.currentTimeMillis();
}
if (System.currentTimeMillis() - startTime > 10*1000) {
failCount = 0;
startTime = System.currentTimeMillis();
}
failCount++;
float similar;
if (compareResult != null) {
similar = compareResult.getSimilar();
} else {
similar = 0f;
}
// new Handler(Looper.getMainLooper()).post(()-> {
// try {
// Toast.makeText(App.getContext(),"识别失败"+failCount+"次,similar="+similar, Toast.LENGTH_SHORT).show();
// } catch (Exception e) {
// e.printStackTrace();
// }
// });
Log.d(TAG, "collectFace,searchFace查询失败"+failCount+"次,similar="+similar);
if (failCount >= 2) {
recognizeCallback.onRecognized(null, LivenessInfo.UNKNOWN, false);
return;
}
retryRecognizeDelayed(trackId); retryRecognizeDelayed(trackId);
return; return;
} }
compareResult.setTrackId(trackId); compareResult.setTrackId(trackId);
boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold(); boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold();
compareResult.setSimilarPass(pass);
Log.d(TAG, "collectFace,searchFace: pass="+pass+",similar="+compareResult.getSimilar()+",threshold="+recognizeConfiguration.getSimilarThreshold());
recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass); recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass);
if (pass) { if (pass) {
setName(trackId, "识别通过"); setName(trackId, "识别通过");
noticeCurrentStatus("识别通过"); noticeCurrentStatus("识别通过");
changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED); changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED);
} else { } else {
noticeCurrentStatus("未通过:NOT_REGISTERED"); noticeCurrentStatus("未通过:NOT_REGISTERED"+compareResult.getSimilar());
retryRecognizeDelayed(trackId); retryRecognizeDelayed(trackId);
} }
} }
@@ -839,18 +878,7 @@ public class FaceHelper implements FaceListener {
int fdCode = flEngine.detectFaces(nv21Data, width, height, format, faceInfoList); int fdCode = flEngine.detectFaces(nv21Data, width, height, format, faceInfoList);
boolean isFaceExists = isFaceExists(faceInfoList, faceInfo); boolean isFaceExists = isFaceExists(faceInfoList, faceInfo);
if (fdCode == ErrorInfo.MOK && isFaceExists) { if (fdCode == ErrorInfo.MOK && isFaceExists) {
if (needUpdateFaceData) { flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
/*
* 若IR人脸框有偏移,则需要对IR的人脸数据进行updateFaceData处理,再将处理后的FaceInfo信息传输给活体检测接口
*/
flCode = flEngine.updateFaceData(nv21Data, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21,
new ArrayList<>(Collections.singletonList(faceInfo)));
if (flCode == ErrorInfo.MOK) {
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
}
} else {
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
}
} else { } else {
onFail(new Exception("ir detectFaces failed fdCode:" + fdCode + ",isFaceExists:" + isFaceExists)); onFail(new Exception("ir detectFaces failed fdCode:" + fdCode + ",isFaceExists:" + isFaceExists));
} }
@@ -1057,9 +1085,9 @@ public class FaceHelper implements FaceListener {
private FaceEngine ftEngine; private FaceEngine ftEngine;
private FaceEngine frEngine; private FaceEngine frEngine;
private FaceEngine flEngine; private FaceEngine flEngine;
private FaceEngine maskEngine;
private Camera.Size previewSize; private Camera.Size previewSize;
private boolean onlyDetectLiveness; private boolean onlyDetectLiveness;
private boolean needUpdateFaceData;
private RecognizeConfiguration recognizeConfiguration; private RecognizeConfiguration recognizeConfiguration;
private RecognizeCallback recognizeCallback; private RecognizeCallback recognizeCallback;
private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer; private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer;
@@ -1100,6 +1128,11 @@ public class FaceHelper implements FaceListener {
return this; return this;
} }
public Builder maskEngine(FaceEngine val) {
maskEngine = val;
return this;
}
public Builder previewSize(Camera.Size val) { public Builder previewSize(Camera.Size val) {
previewSize = val; previewSize = val;
return this; return this;
@@ -1125,11 +1158,6 @@ public class FaceHelper implements FaceListener {
return this; return this;
} }
public Builder needUpdateFaceData(boolean val) {
needUpdateFaceData = val;
return this;
}
public FaceHelper build() { public FaceHelper build() {
return new FaceHelper(this); return new FaceHelper(this);
} }
@@ -10,6 +10,16 @@ public class CompareResult {
private int compareCode; private int compareCode;
private long cost; private long cost;
private boolean similarPass;
public void setSimilarPass(boolean similarPass) {
this.similarPass = similarPass;
}
public boolean isSimilarPass() {
return similarPass;
}
public CompareResult(FaceEntity faceEntity, float similar) { public CompareResult(FaceEntity faceEntity, float similar) {
this.faceEntity = faceEntity; this.faceEntity = faceEntity;
this.similar = similar; this.similar = similar;
@@ -9,7 +9,7 @@ import androidx.room.RoomDatabase;
import com.sw.plate.utils.arcface.facedb.dao.FaceDao; import com.sw.plate.utils.arcface.facedb.dao.FaceDao;
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity; import com.sw.plate.utils.arcface.facedb.entity.FaceEntity;
@Database(entities = {FaceEntity.class}, version = 1, exportSchema = false) @Database(entities = {FaceEntity.class}, version = 2, exportSchema = false)
public abstract class FaceDatabase extends RoomDatabase { public abstract class FaceDatabase extends RoomDatabase {
public abstract FaceDao faceDao(); public abstract FaceDao faceDao();
@@ -22,7 +22,10 @@ public abstract class FaceDatabase extends RoomDatabase {
faceDatabase = Room.databaseBuilder(context, FaceDatabase.class, faceDatabase = Room.databaseBuilder(context, FaceDatabase.class,
context.getDatabasePath("faceDB.db").getPath() context.getDatabasePath("faceDB.db").getPath()
// context.getExternalFilesDir("database") + File.separator + "faceDB.db" // context.getExternalFilesDir("database") + File.separator + "faceDB.db"
).build(); )
// 5.0 特征模型不兼容,升级时直接删除旧表重建
.fallbackToDestructiveMigration()
.build();
} }
} }
} }
@@ -55,6 +55,18 @@ public interface FaceDao {
@Query("DELETE from face WHERE user_name = :userName") @Query("DELETE from face WHERE user_name = :userName")
int deleteFaceById(String userName); int deleteFaceById(String userName);
/**
* 按 userFaceId 删除单条人脸记录
*/
@Query("DELETE from face WHERE user_face_id = :userFaceId")
int deleteFaceByUserFaceId(String userFaceId);
/**
* 按 userFaceId 查询单条人脸记录
*/
@Query("SELECT * FROM face WHERE user_face_id = :userFaceId LIMIT 1")
FaceEntity queryByUserFaceId(String userFaceId);
/** /**
* 删除所有已注册的人脸 * 删除所有已注册的人脸
* *
@@ -91,4 +103,26 @@ public interface FaceDao {
@Query("SELECT * FROM face WHERE user_name = :userName limit 1") @Query("SELECT * FROM face WHERE user_name = :userName limit 1")
FaceEntity queryByUserName(String userName); FaceEntity queryByUserName(String userName);
/**
* 查询指定用户的所有人脸记录(同一用户可能存有多条特征数据)
*
* @param userName 用户ID
* @return 该用户所有人脸记录列表
*/
@Query("SELECT * FROM face WHERE user_name = :userName")
List<FaceEntity> queryAllByUserName(String userName);
/**
* @return 删除临时用户人脸
*/
@Query("DELETE from face WHERE user_type = :userType")
int deleteUserFaceData(int userType);
/**
* 查询用户人脸数
* @param userType 1-会员,2-临时用户
*/
@Query("SELECT COUNT(1) FROM face WHERE user_type = :userType")
int getFaceCountByUserType(int userType);
} }
@@ -45,14 +45,46 @@ public class FaceEntity implements Parcelable {
@ColumnInfo(name = "register_time") @ColumnInfo(name = "register_time")
private long registerTime; private long registerTime;
/** /**
* 用户类型:1-普通会员、2-临时用户、3-内部员工、或者其它待定类型 * 用户类型:1-普通会员、2-临时用户、或者其它待定类型
*/ */
@ColumnInfo(name = "user_type") @ColumnInfo(name = "user_type")
private String userType; private String userType;
/**
* 会员编号
*/
@ColumnInfo(name = "card_no")
private String cardNo;
@Ignore @Ignore
private int trackId;//人脸追踪ID private int trackId;//人脸追踪ID
@ColumnInfo(name = "user_id")
private String userId;
@ColumnInfo(name = "user_face_id")
private String userFaceId;
@ColumnInfo(name = "member")
private boolean member;
@ColumnInfo(name = "face_update_timestamp")
private long faceUpdateTimestamp;
public FaceEntity() {
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(String userName, byte[] featureData, String userType, String cardNo, String userId, String userFaceId, boolean member, long faceUpdateTimestamp) {
this.userName = userName;
this.featureData = featureData;
this.userType = userType;
this.cardNo = cardNo;
this.userId = userId;
this.userFaceId = userFaceId;
this.member = member;
this.faceUpdateTimestamp = faceUpdateTimestamp;
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(String userName, String imagePath, byte[] featureData) { public FaceEntity(String userName, String imagePath, byte[] featureData) {
this.userName = userName; this.userName = userName;
this.imagePath = imagePath; this.imagePath = imagePath;
@@ -60,21 +92,35 @@ public class FaceEntity implements Parcelable {
registerTime = System.currentTimeMillis(); registerTime = System.currentTimeMillis();
} }
@Ignore
public FaceEntity(FaceEntity faceEntity) { public FaceEntity(FaceEntity faceEntity) {
this.faceId = faceEntity.faceId; this.faceId = faceEntity.faceId;
this.userName = faceEntity.userName; this.userName = faceEntity.userName;
this.imagePath = faceEntity.imagePath; this.imagePath = faceEntity.imagePath;
this.featureData = faceEntity.featureData; this.featureData = faceEntity.featureData;
this.registerTime = faceEntity.registerTime; this.registerTime = faceEntity.registerTime;
this.userType = faceEntity.getUserType();
this.cardNo = faceEntity.getCardNo();
this.userId = faceEntity.getUserId();
this.userFaceId = faceEntity.getUserFaceId();
this.member = faceEntity.isMember();
this.faceUpdateTimestamp = faceEntity.getFaceUpdateTimestamp();
} }
@Ignore
protected FaceEntity(Parcel in) { protected FaceEntity(Parcel in) {
faceId = in.readLong(); faceId = in.readLong();
registerTime = in.readLong(); registerTime = in.readLong();
userName = in.readString(); userName = in.readString();
imagePath = in.readString(); imagePath = in.readString();
featureData = in.createByteArray(); featureData = in.createByteArray();
userType = in.readString();
cardNo = in.readString();
userId = in.readString();
userFaceId = in.readString();
member = in.readByte() != 0;
faceUpdateTimestamp = in.readLong();
} }
public static final Creator<FaceEntity> CREATOR = new Creator<FaceEntity>() { public static final Creator<FaceEntity> CREATOR = new Creator<FaceEntity>() {
@@ -145,6 +191,46 @@ public class FaceEntity implements Parcelable {
this.userType = userType; this.userType = userType;
} }
public String getCardNo() {
return cardNo;
}
public void setCardNo(String cardNo) {
this.cardNo = cardNo;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserFaceId() {
return userFaceId;
}
public void setUserFaceId(String userFaceId) {
this.userFaceId = userFaceId;
}
public boolean isMember() {
return member;
}
public void setMember(boolean member) {
this.member = member;
}
public long getFaceUpdateTimestamp() {
return faceUpdateTimestamp;
}
public void setFaceUpdateTimestamp(long faceUpdateTimestamp) {
this.faceUpdateTimestamp = faceUpdateTimestamp;
}
@Override @Override
public int describeContents() { public int describeContents() {
return 0; return 0;
@@ -158,10 +244,13 @@ public class FaceEntity implements Parcelable {
dest.writeString(imagePath); dest.writeString(imagePath);
dest.writeByteArray(featureData); dest.writeByteArray(featureData);
dest.writeString(userType); dest.writeString(userType);
dest.writeString(cardNo);
dest.writeString(userId);
dest.writeString(userFaceId);
dest.writeByte((byte) (member ? 1 : 0));
dest.writeLong(faceUpdateTimestamp);
} }
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) { if (this == o) {
@@ -176,12 +265,17 @@ public class FaceEntity implements Parcelable {
TextUtils.equals(this.userName, that.userName) && TextUtils.equals(this.userName, that.userName) &&
TextUtils.equals(this.imagePath, that.imagePath) && TextUtils.equals(this.imagePath, that.imagePath) &&
Arrays.equals(featureData, that.featureData) && Arrays.equals(featureData, that.featureData) &&
TextUtils.equals(this.userType, that.userType); TextUtils.equals(this.userType, that.userType) &&
TextUtils.equals(this.cardNo, that.cardNo) &&
TextUtils.equals(this.userId, that.userId) &&
TextUtils.equals(this.userFaceId, that.userFaceId) &&
this.member == that.member &&
this.faceUpdateTimestamp == that.faceUpdateTimestamp;
} }
@Override @Override
public int hashCode() { public int hashCode() {
int result = Objects.hash(faceId, registerTime, userName, imagePath, userType); int result = Objects.hash(faceId, registerTime, userName, imagePath, userType, cardNo, userId, userFaceId, member, faceUpdateTimestamp);
result = 31 * result + Arrays.hashCode(featureData); result = 31 * result + Arrays.hashCode(featureData);
return result; return result;
} }
@@ -12,7 +12,6 @@ import androidx.lifecycle.ViewModel;
import com.arcsoft.face.AgeInfo; import com.arcsoft.face.AgeInfo;
import com.arcsoft.face.ErrorInfo; import com.arcsoft.face.ErrorInfo;
import com.arcsoft.face.FaceAttributeParam;
import com.arcsoft.face.FaceEngine; import com.arcsoft.face.FaceEngine;
import com.arcsoft.face.FaceInfo; import com.arcsoft.face.FaceInfo;
import com.arcsoft.face.GenderInfo; import com.arcsoft.face.GenderInfo;
@@ -132,13 +131,14 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
private MutableLiveData<Integer> ftInitCode = new MutableLiveData<>(); private MutableLiveData<Integer> ftInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> frInitCode = new MutableLiveData<>(); private MutableLiveData<Integer> frInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> flInitCode = new MutableLiveData<>(); private MutableLiveData<Integer> flInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> maskInitCode = new MutableLiveData<>();
/** /**
* 人脸操作辅助类,推帧即可,内部会进行特征提取、识别 * 人脸操作辅助类,推帧即可,内部会进行特征提取、识别
*/ */
private FaceHelper faceHelper; private FaceHelper faceHelper;
/** /**
* VIDEO模式人脸检测引擎,用于预览帧人脸追踪及图像质量检测 * VIDEO模式人脸检测引擎,用于预览帧人脸追踪
*/ */
private FaceEngine ftEngine; private FaceEngine ftEngine;
/** /**
@@ -149,6 +149,10 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
* IMAGE模式活体检测引擎,用于预览帧人脸活体检测 * IMAGE模式活体检测引擎,用于预览帧人脸活体检测
*/ */
private FaceEngine flEngine; private FaceEngine flEngine;
/**
* IMAGE模式口罩检测引擎(5.0 独立引擎)
*/
private FaceEngine maskEngine;
private PreviewConfig previewConfig; private PreviewConfig previewConfig;
@@ -158,12 +162,8 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
private MutableLiveData<String> drawRectInfoText = new MutableLiveData<>(); private MutableLiveData<String> drawRectInfoText = new MutableLiveData<>();
private MutableLiveData<String> recognizeUserId = new MutableLiveData<>(); private MutableLiveData<CompareResult> recognizeUserId = new MutableLiveData<>();
/**
* 检测ir活体前,是否需要更新faceData
*/
private boolean needUpdateFaceData;
/** /**
* 当前活体检测的检测类型 * 当前活体检测的检测类型
*/ */
@@ -280,6 +280,8 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
// 填入在设置界面设置好的配置信息 // 填入在设置界面设置好的配置信息
boolean enableLive = !ConfigUtil.getLivenessDetectType(context).equals(context.getString(R.string.value_liveness_type_disable)); boolean enableLive = !ConfigUtil.getLivenessDetectType(context).equals(context.getString(R.string.value_liveness_type_disable));
enableLive = false;
boolean enableFaceQualityDetect = ConfigUtil.isEnableImageQualityDetect(context); boolean enableFaceQualityDetect = ConfigUtil.isEnableImageQualityDetect(context);
boolean enableFaceMoveLimit = ConfigUtil.isEnableFaceMoveLimit(context); boolean enableFaceMoveLimit = ConfigUtil.isEnableFaceMoveLimit(context);
boolean enableFaceSizeLimit = ConfigUtil.isEnableFaceSizeLimit(context); boolean enableFaceSizeLimit = ConfigUtil.isEnableFaceSizeLimit(context);
@@ -295,22 +297,22 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
.similarThreshold(ConfigUtil.getRecognizeThreshold(context)) .similarThreshold(ConfigUtil.getRecognizeThreshold(context))
.imageQualityNoMaskRecognizeThreshold(ConfigUtil.getImageQualityNoMaskRecognizeThreshold(context)) .imageQualityNoMaskRecognizeThreshold(ConfigUtil.getImageQualityNoMaskRecognizeThreshold(context))
.imageQualityMaskRecognizeThreshold(ConfigUtil.getImageQualityMaskRecognizeThreshold(context)) .imageQualityMaskRecognizeThreshold(ConfigUtil.getImageQualityMaskRecognizeThreshold(context))
.livenessParam(new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context), .livenessParam(new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context)))
ConfigUtil.getLivenessFqThreshold(context)))
.build(); .build();
int cameraOffsetX = ConfigUtil.getDualCameraHorizontalOffset(context);
int cameraOffsetY = ConfigUtil.getDualCameraVerticalOffset(context);
needUpdateFaceData = (livenessType == LivenessType.IR && (cameraOffsetX != 0 || cameraOffsetY != 0));
// 人脸追踪引擎(VIDEO模式,仅检测)
ftEngine = new FaceEngine(); ftEngine = new FaceEngine();
int ftEngineMask = FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT; int ftEngineMask = FaceEngine.ASF_FACE_DETECT;
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, ConfigUtil.getFtOrient(context), ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, ConfigUtil.getFtOrient(context),
ConfigUtil.getRecognizeMaxDetectFaceNum(context), ftEngineMask)); ConfigUtil.getRecognizeMaxDetectFaceNum(context), ftEngineMask));
FaceAttributeParam attributeParam = new FaceAttributeParam(
ConfigUtil.getRecognizeEyeOpenThreshold(context), ConfigUtil.getRecognizeMouthCloseThreshold(context),
ConfigUtil.getRecognizeWearGlassesThreshold(context));
ftEngine.setFaceAttributeParam(attributeParam);
// 口罩检测引擎(5.0 独立引擎)
maskEngine = new FaceEngine();
maskInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
ConfigUtil.getRecognizeMaxDetectFaceNum(context), FaceEngine.ASF_MASK_DETECT));
// 特征提取引擎
frEngine = new FaceEngine(); frEngine = new FaceEngine();
int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION; int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION;
if (enableFaceQualityDetect) { if (enableFaceQualityDetect) {
@@ -320,16 +322,13 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
10, frEngineMask)); 10, frEngineMask));
FaceServer.getInstance().initFaceList(context, frEngine, faceCount -> loadFaceList = true, true); FaceServer.getInstance().initFaceList(context, frEngine, faceCount -> loadFaceList = true, true);
//启用活体检测时,才初始化活体引擎 // 启用活体检测时,才初始化活体引擎
if (enableLive) { if (enableLive) {
flEngine = new FaceEngine(); flEngine = new FaceEngine();
int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT)); int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT));
if (needUpdateFaceData) {
flEngineMask |= FaceEngine.ASF_UPDATE_FACEDATA;
}
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask)); DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask));
LivenessParam livenessParam = new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context), ConfigUtil.getLivenessFqThreshold(context)); LivenessParam livenessParam = new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context));
flEngine.setLivenessParam(livenessParam); flEngine.setLivenessParam(livenessParam);
} }
@@ -354,19 +353,25 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
if (ftEngine != null) { if (ftEngine != null) {
synchronized (ftEngine) { synchronized (ftEngine) {
int ftUnInitCode = ftEngine.unInit(); int ftUnInitCode = ftEngine.unInit();
Log.i(TAG, "unInitEngine: " + ftUnInitCode); Log.i(TAG, "unInitEngine ft: " + ftUnInitCode);
}
}
if (maskEngine != null) {
synchronized (maskEngine) {
int maskUnInitCode = maskEngine.unInit();
Log.i(TAG, "unInitEngine mask: " + maskUnInitCode);
} }
} }
if (frEngine != null) { if (frEngine != null) {
synchronized (frEngine) { synchronized (frEngine) {
int frUnInitCode = frEngine.unInit(); int frUnInitCode = frEngine.unInit();
Log.i(TAG, "unInitEngine: " + frUnInitCode); Log.i(TAG, "unInitEngine fr: " + frUnInitCode);
} }
} }
if (flEngine != null) { if (flEngine != null) {
synchronized (flEngine) { synchronized (flEngine) {
int flUnInitCode = flEngine.unInit(); int flUnInitCode = flEngine.unInit();
Log.i(TAG, "unInitEngine: " + flUnInitCode); Log.i(TAG, "unInitEngine fl: " + flUnInitCode);
} }
} }
} }
@@ -395,6 +400,20 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
} }
} }
/**
* 重置人脸识别状态:清空上次识别结果及 FaceHelper 内部的 recognizeInfoMap
* 使下一帧进入时能重新触发识别流程。
* 适用场景:短时间内再次识别、点击重试按钮等需要重新开始识别的时机。
*/
public void resetFaceState() {
// 清空粘性 LiveData,防止旧结果被重新投递给 observer
recognizeUserId.postValue(null);
// 清空 FaceHelper 内部所有人脸状态,让 trackId 对应的状态回到 TO_RETRY
if (faceHelper != null) {
faceHelper.clearFacePreviewInfoList();
}
}
/** /**
* 释放操作 * 释放操作
*/ */
@@ -453,7 +472,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
.ftEngine(ftEngine) .ftEngine(ftEngine)
.frEngine(frEngine) .frEngine(frEngine)
.flEngine(flEngine) .flEngine(flEngine)
.needUpdateFaceData(needUpdateFaceData) .maskEngine(maskEngine)
.frQueueSize(maxDetectFaceNum) .frQueueSize(maxDetectFaceNum)
.flQueueSize(maxDetectFaceNum) .flQueueSize(maxDetectFaceNum)
.previewSize(previewSize) .previewSize(previewSize)
@@ -469,13 +488,20 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
} }
} }
private String getUserId(CompareResult result) {
if (result != null && result.getFaceEntity() != null) {
return result.getFaceEntity().getUserName();
}
return null;
}
@Override @Override
public void onRecognized(CompareResult compareResult, Integer live, boolean similarPass) { public void onRecognized(CompareResult compareResult, Integer live, boolean similarPass) {
Disposable disposable = Observable.just(true).observeOn(AndroidSchedulers.mainThread()).subscribe(aBoolean -> { // TODO: 2026/1/21 测试使用 Observable.just(similarPass)代替Observable.just(true)---
Observable.just(similarPass).observeOn(AndroidSchedulers.mainThread()).subscribe(aBoolean -> {
Log.d(TAG, "collectFace,onRecognized: similarPass=" + similarPass + ",live=" + live + ",userId=" + getUserId(compareResult));
if (similarPass) { if (similarPass) {
if (recognizeUserId != null) { recognizeUserId.postValue(compareResult);
recognizeUserId.postValue(compareResult.getFaceEntity().getUserName());
}
boolean isAdded = false; boolean isAdded = false;
List<CompareResult> compareResults = compareResultList.getValue(); List<CompareResult> compareResults = compareResultList.getValue();
if (compareResults != null && !compareResults.isEmpty()) { if (compareResults != null && !compareResults.isEmpty()) {
@@ -497,6 +523,8 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
getFaceItemEventMutableLiveData().postValue(new FaceItemEvent(compareResults.size() - 1, EventType.INSERTED)); getFaceItemEventMutableLiveData().postValue(new FaceItemEvent(compareResults.size() - 1, EventType.INSERTED));
} }
} }
} else {
recognizeUserId.postValue(compareResult);
} }
}); });
} }
@@ -540,7 +568,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
return recognizeNotice; return recognizeNotice;
} }
public MutableLiveData<String> getRecognizeUserId() { public MutableLiveData<CompareResult> getRecognizeUserId() {
return recognizeUserId; return recognizeUserId;
} }
@@ -561,7 +589,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
} }
} }
private void updateRegisterStatus(int status) { public void updateRegisterStatus(int status) {
registerStatus = status; registerStatus = status;
} }
+3 -3
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
app:cardCornerRadius="5dp" app:cardCornerRadius="5dp"
app:cardPreventCornerOverlap="true"> app:cardPreventCornerOverlap="true">
@@ -10,7 +10,7 @@
<TextView <TextView
android:id="@+id/toast_tv" android:id="@+id/toast_tv"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:maxLines="1" android:maxLines="1"