feat:消费机更新

feat:人员调用钱包部门不调动的bug
This commit is contained in:
dzc committed 2026-09-09 11:56:19 +08:00
1 parent 842402e364
commit 60d9380a65
144 files changed
+27832 -1666

No files matched your search

+28 -1
View File
@@ -2,6 +2,8 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />`
<uses-permission android:name="android.permission.USB_PERMISSION" />
@@ -37,7 +39,8 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme">
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
@@ -57,6 +60,30 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<receiver
android:name=".KioskDeviceAdminReceiver"
android:description="@string/kiosk_device_admin_description"
android:exported="true"
android:label="@string/kiosk_device_admin_label"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/kiosk_device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".KioskBootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
<queries>
@@ -15,7 +15,8 @@ public class DatabaseHelper extends SQLiteOpenHelper {
private static final String DATABASE_PATH = "pj_cashier/database";
private static final String DATABASE_NAME = "cash_register.db";
private static final int DATABASE_VERSION = 9;
/** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */
private static final int DATABASE_VERSION = 13;
public DatabaseHelper(Context context) {
super(context, getCustomDatabasePath(context), null, DATABASE_VERSION);
@@ -40,6 +41,45 @@ public class DatabaseHelper extends SQLiteOpenHelper {
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onCreate(db);
if (oldVersion < 12) {
migrateFaceQuality(db);
}
}
/**
* 从版本11无损补齐本地人脸质量字段;重复执行安全,避免原生打开数据库时覆盖 Floor 迁移。
*/
private void migrateFaceQuality(SQLiteDatabase db) {
if (!hasColumn(db, "face", "qualityScore")) {
db.execSQL("ALTER TABLE face ADD COLUMN qualityScore REAL");
}
if (!hasColumn(db, "face", "qualityStatus")) {
db.execSQL("ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'");
}
if (!hasColumn(db, "face", "qualityCheckedAt")) {
db.execSQL("ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT");
}
if (!hasColumn(db, "face", "qualityErrorCode")) {
db.execSQL("ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER");
}
db.execSQL("UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')");
// Floor 的可空时间转换器使用空字符串承载业务空值,原生兜底升级必须保持同一存储语义。
db.execSQL("UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL");
db.execSQL("CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)");
}
/**
* 检查表中是否已经存在目标字段。
*/
private boolean hasColumn(SQLiteDatabase db, String table, String column) {
try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) {
int nameIndex = cursor.getColumnIndex("name");
while (cursor.moveToNext()) {
if (nameIndex >= 0 && column.equals(cursor.getString(nameIndex))) {
return true;
}
}
}
return false;
}
}
@@ -5,29 +5,45 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import java.util.List;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.flutter.Log;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager;
import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore;
import io.flutter.plugins.facediscern.ui.FaceDiscern;
import io.flutter.view.TextureRegistry;
public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, FaceRecognitionSessionManager.Listener {
private static final String TAG = "人脸识别插件";
private static final String CHANNEL_TAG = "local/face_discern";
private static final String EVENT_TAG = "local/face_discern_event";
private static final String SESSION_EVENT_TAG = "local/face_discern_session_event";
private static final String REGISTRATION_EVENT_TAG = "local/face_discern_registration_event";
/** 在库照片质量检测进度事件通道。 */
private static final String QUALITY_AUDIT_EVENT_TAG = "local/face_discern_quality_audit_event";
private static final ExecutorService FACE_EXECUTOR = Executors.newSingleThreadExecutor();
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
public static MethodChannel staticChannel;
@@ -35,12 +51,24 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
private MethodChannel channel;
private EventChannel eventChannel;
private EventChannel sessionEventChannel;
private EventChannel registrationEventChannel;
/** 当前Flutter Engine对应的质量检测进度通道。 */
private EventChannel qualityAuditEventChannel;
private volatile EventChannel.EventSink faceEventSink;
private volatile EventChannel.EventSink sessionEventSink;
private volatile EventChannel.EventSink registrationEventSink;
/** 当前质量检测进度订阅;页面离开时置空但不终止原生任务。 */
private volatile EventChannel.EventSink qualityAuditEventSink;
private MethodChannel.Result pendingResult;
private TextureRegistry textureRegistry;
private FaceRecognitionSessionManager sessionManager;
private FlutterFaceReceiver flutterFaceReceiver;
private boolean isReceiverRegistered = false;
private boolean receiverRegistered;
private String engineEndpoint = "unknown";
private final Map<Long, Long> previewOutputs = new HashMap<>();
private BroadcastReceiver actionsReceiver = new BroadcastReceiver() {
@Override
@@ -54,27 +82,94 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
context = binding.getApplicationContext();
textureRegistry = binding.getTextureRegistry();
sessionManager = FaceRecognitionSessionManager.getInstance(context);
sessionManager.addListener(this);
channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_TAG);
staticChannel = channel;
if (staticChannel == null) {
staticChannel = channel;
engineEndpoint = "main";
} else {
engineEndpoint = "sub";
}
channel.setMethodCallHandler(this);
eventChannel = new EventChannel(binding.getBinaryMessenger(), EVENT_TAG);
eventChannel.setStreamHandler(this);
sessionEventChannel = new EventChannel(binding.getBinaryMessenger(), SESSION_EVENT_TAG);
sessionEventChannel.setStreamHandler(new EventChannel.StreamHandler() {
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
sessionEventSink = events;
events.success(sessionManager.getState());
}
@Override
public void onCancel(Object arguments) {
sessionEventSink = null;
}
});
registrationEventChannel = new EventChannel(binding.getBinaryMessenger(), REGISTRATION_EVENT_TAG);
registrationEventChannel.setStreamHandler(new EventChannel.StreamHandler() {
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
registrationEventSink = events;
events.success(FlutterTool.getLatestRegistrationProgress());
}
@Override
public void onCancel(Object arguments) {
registrationEventSink = null;
}
});
qualityAuditEventChannel = new EventChannel(binding.getBinaryMessenger(), QUALITY_AUDIT_EVENT_TAG);
qualityAuditEventChannel.setStreamHandler(new EventChannel.StreamHandler() {
/** 订阅后立即返回最新进度,支持页面重建。 */
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
qualityAuditEventSink = events;
events.success(FlutterTool.getLatestQualityAuditProgress());
}
/** 取消页面订阅,不取消仍在后台执行的批量检测。 */
@Override
public void onCancel(Object arguments) {
qualityAuditEventSink = null;
}
});
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
boolean mainEndpoint = staticChannel == channel;
channel.setMethodCallHandler(null);
if (mainEndpoint) staticChannel = null;
eventChannel.setStreamHandler(null);
if (actionsReceiver != null && isReceiverRegistered) {
context.unregisterReceiver(actionsReceiver);
}
sessionEventChannel.setStreamHandler(null);
registrationEventChannel.setStreamHandler(null);
if (qualityAuditEventChannel != null) qualityAuditEventChannel.setStreamHandler(null);
faceEventSink = null;
sessionEventSink = null;
registrationEventSink = null;
qualityAuditEventSink = null;
unregisterLegacyReceiver();
if (sessionManager != null) {
for (Long outputId : previewOutputs.values().toArray(new Long[0])) {
sessionManager.releasePreviewTexture(outputId);
}
}
previewOutputs.clear();
if (sessionManager != null) sessionManager.removeListener(this);
if (mainEndpoint && sessionManager != null) {
sessionManager.suspendRecognition("主屏 Flutter Engine 已销毁,人脸识别已结束");
}
textureRegistry = null;
}
// flutter 方法的回调
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String method = call.method;
Log.i(TAG, "执行方法:" + method + ";参数:" + new Gson().toJson(call.arguments) + ";");
Log.i(TAG, "执行人脸插件方法:" + method);
switch (method) {
case "jumpFaceDiscern":
try {
@@ -88,15 +183,125 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
Log.i(TAG, "jumpFaceDiscern error:" + e.getMessage(), e);
}
break;
case "createPreviewTexture":
try {
Map<String, Object> texture = sessionManager.createPreviewTexture(textureRegistry);
Number createdTextureId = (Number) texture.get("textureId");
Number previewOutputId = (Number) texture.get("previewOutputId");
if (createdTextureId == null || previewOutputId == null) {
throw new IllegalStateException("原生未返回有效的人脸预览输出编号");
}
previewOutputs.put(createdTextureId.longValue(), previewOutputId.longValue());
Log.i(TAG, "创建人脸预览 endpoint=" + engineEndpoint + ";textureId=" + createdTextureId + ";outputId=" + previewOutputId);
result.success(texture);
} catch (Exception e) {
result.error("PREVIEW_TEXTURE_FAILED", "创建人脸预览纹理失败", e.getMessage());
}
break;
case "releasePreviewTexture":
Number textureId = call.argument("textureId");
if (textureId != null) {
Long outputId = previewOutputs.remove(textureId.longValue());
if (outputId != null) {
Log.i(TAG, "释放人脸预览 endpoint=" + engineEndpoint + ";textureId=" + textureId + ";outputId=" + outputId);
sessionManager.releasePreviewTexture(outputId);
}
}
result.success(true);
break;
case "startRecognition":
try {
if (FlutterTool.isRegistrationInProgress()) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库更新中,请稍后重试", null);
break;
}
result.success(sessionManager.startRecognition(stringArgument(call, "sessionId"), stringArgument(call, "scene"), this));
} catch (FaceRecognitionSessionManager.SessionBusyException e) {
result.error("SESSION_BUSY", e.getMessage(), e.getActiveSessionId());
} catch (SecurityException e) {
result.error("CAMERA_PERMISSION_DENIED", e.getMessage(), null);
} catch (Exception e) {
result.error("RECOGNITION_START_FAILED", "启动人脸识别失败", e.getMessage());
}
break;
case "retryRecognition":
try {
if (FlutterTool.isRegistrationInProgress()) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库更新中,请稍后重试", null);
break;
}
result.success(sessionManager.retryRecognition(stringArgument(call, "sessionId")));
} catch (Exception e) {
result.error("RECOGNITION_RETRY_FAILED", e.getMessage(), null);
}
break;
case "cancelRecognition":
result.success(sessionManager.cancelRecognition(stringArgument(call, "sessionId"), stringArgument(call, "message")));
break;
case "finishRecognition":
result.success(sessionManager.finishRecognition(stringArgument(call, "sessionId")));
break;
case "suspendRecognition":
result.success(sessionManager.suspendRecognition(stringArgument(call, "reason")));
break;
case "getRecognitionState":
result.success(sessionManager.getState());
break;
case "hasCamera":
result.success(sessionManager.hasCamera());
break;
case "cleanupOrderEvidenceFiles":
List<?> rawReferencedPaths = call.argument("referencedPaths");
Number olderThan = call.argument("olderThan");
Set<String> referencedPaths = new HashSet<>();
if (rawReferencedPaths != null) {
for (Object path : rawReferencedPaths) {
if (path instanceof String && !((String) path).trim().isEmpty()) {
referencedPaths.add((String) path);
}
}
}
result.success(sessionManager.cleanupOrderEvidenceFiles(
referencedPaths, olderThan == null ? 0L : olderThan.longValue()));
break;
case "getRecognizeConfiguration":
result.success(RecognizeConfigurationStore.toMap(RecognizeConfigurationStore.load(context)));
break;
case "setRecognizeConfiguration":
try {
RecognizeConfiguration configuration = RecognizeConfigurationStore.save(context, call.arguments instanceof Map ? (Map<?, ?>) call.arguments : null);
sessionManager.onRecognitionConfigurationChanged();
result.success(RecognizeConfigurationStore.toMap(configuration));
} catch (IllegalArgumentException e) {
result.error("INVALID_RECOGNIZE_CONFIGURATION", e.getMessage(), null);
} catch (Exception e) {
result.error("SAVE_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null);
}
break;
case "resetRecognizeConfiguration":
try {
RecognizeConfiguration configuration = RecognizeConfigurationStore.reset(context);
sessionManager.onRecognitionConfigurationChanged();
result.success(RecognizeConfigurationStore.toMap(configuration));
} catch (Exception e) {
result.error("RESET_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null);
}
break;
case "active":
String appId = Objects.requireNonNull(call.argument("appId")).toString();
String activeKey = Objects.requireNonNull(call.argument("activeKey")).toString();
String sdkKey = Objects.requireNonNull(call.argument("sdkKey")).toString();
String appId = stringArgument(call, "appId");
String activeKey = stringArgument(call, "activeKey");
String sdkKey = stringArgument(call, "sdkKey");
Boolean active = FlutterTool.active(context, appId, activeKey, sdkKey);
result.success(active);
break;
case "getConfig":
result.success(FlutterTool.getConfig());
result.success(FlutterTool.getConfig(context));
break;
case "getFaceStatus":
result.success(FlutterTool.getFaceStatus(context, stringArgument(call, "appId"), stringArgument(call, "activeKey"), stringArgument(call, "sdkKey")));
break;
case "activateFaceSdk":
result.success(FlutterTool.activateFaceSdk(context, stringArgument(call, "appId"), stringArgument(call, "activeKey"), stringArgument(call, "sdkKey")));
break;
case "registerAllStorageFace":
String filePath = Objects.requireNonNull(call.argument("filePath")).toString();
@@ -105,9 +310,19 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
result.success(res);
break;
case "registerFaceByDatabase":
String userId = call.argument("userId");
List<Integer> errorSampleIdList = FlutterTool.registerFaceByDatabase(context, userId);
result.success(errorSampleIdList);
startRegistrationTask(call.argument("userId"), result, true);
break;
case "rebuildFaceLibrary":
startRegistrationTask(call.argument("userId"), result, false);
break;
case "extractFaceFeature":
startFaceFeatureExtraction(stringArgument(call, "filePath"), result);
break;
case "auditFaceQuality":
startFaceQualityAudit(call.argument("items"), result);
break;
case "applyFaceDelta":
startFaceDelta(call, result);
break;
case "getFace":
String sampleId = Objects.requireNonNull(call.argument("sampleId")).toString();
@@ -123,31 +338,212 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
Boolean isRemove = FlutterTool.removeFace(context, Integer.parseInt(removeSampleId));
result.success(isRemove);
break;
default:
result.notImplemented();
break;
}
}
private void startRegistrationTask(String userId, MethodChannel.Result result, boolean legacyResult) {
if (!FlutterTool.tryStartRegistration()) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在注册,请稍候", null);
return;
}
FACE_EXECUTOR.execute(() -> {
try {
Map<String, Object> registrationResult = FlutterTool.rebuildFaceLibrary(context, userId, this::emitRegistrationProgress);
MAIN_HANDLER.post(() -> {
if (legacyResult) {
result.success(registrationResult.get("failedSampleIds"));
} else {
result.success(registrationResult);
}
});
} catch (Exception e) {
Log.e(TAG, "人脸库注册失败:" + e.getMessage(), e);
MAIN_HANDLER.post(() -> result.error("REGISTRATION_FAILED", "人脸库注册失败,请稍后重试", null));
} finally {
FlutterTool.finishRegistration();
}
});
}
/**
* 在统一人脸注册占用标记和后台执行器中提取临时图片特征。
*
* @param filePath 临时图片绝对路径
* @param result Flutter MethodChannel 异步结果
*/
private void startFaceFeatureExtraction(String filePath, MethodChannel.Result result) {
if (!FlutterTool.tryStartRegistration()) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
return;
}
FACE_EXECUTOR.execute(() -> {
try {
Map<String, Object> extraction = FlutterTool.extractFaceFeature(context, filePath);
MAIN_HANDLER.post(() -> result.success(extraction));
} catch (Exception exception) {
Log.e(TAG, "提取人脸特征失败:" + exception.getMessage(), exception);
MAIN_HANDLER.post(() -> result.error("EXTRACT_FACE_FEATURE_FAILED", "人脸特征提取失败", null));
} finally {
FlutterTool.finishRegistration();
}
});
}
/**
* 在统一人脸操作互斥锁内批量检测存量图片,并通过独立事件通道报告进度。
*
* @param rawItems Flutter提交的样本编号与本地路径列表
* @param result 批量检测完成结果
*/
private void startFaceQualityAudit(Object rawItems, MethodChannel.Result result) {
if (!(rawItems instanceof List)) {
result.error("INVALID_QUALITY_AUDIT_ITEMS", "质量检测参数无效", null);
return;
}
if (!FlutterTool.tryStartRegistration()) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
return;
}
FACE_EXECUTOR.execute(() -> {
try {
Map<String, Object> audit = FlutterTool.auditFaceQuality(
context, (List<?>) rawItems, this::emitQualityAuditProgress);
MAIN_HANDLER.post(() -> result.success(audit));
} catch (Exception exception) {
Log.e(TAG, "在库人脸质量检测失败:" + exception.getMessage(), exception);
MAIN_HANDLER.post(() -> result.error("FACE_QUALITY_AUDIT_FAILED", "在库人脸质量检测失败", null));
} finally {
FlutterTool.finishRegistration();
}
});
}
/**
* 在统一人脸注册占用标记和后台执行器中应用单样本增删。
*
* @param call 包含 operation、sampleId、用户元数据和可选特征字节的调用
* @param result Flutter MethodChannel 异步结果
*/
private void startFaceDelta(MethodCall call, MethodChannel.Result result) {
if (!FlutterTool.tryStartRegistration()) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
return;
}
FACE_EXECUTOR.execute(() -> {
try {
String operation = stringArgument(call, "operation");
Number sampleIdValue = call.argument("sampleId");
if (sampleIdValue == null) throw new IllegalArgumentException("sampleId不能为空");
byte[] featureData = byteArrayArgument(call.argument("featureData"));
Map<String, Object> delta = FlutterTool.applyFaceDelta(
context,
operation,
sampleIdValue.intValue(),
stringArgument(call, "userId"),
stringArgument(call, "username"),
featureData
);
MAIN_HANDLER.post(() -> result.success(delta));
} catch (Exception exception) {
Log.e(TAG, "应用人脸增量失败:" + exception.getMessage(), exception);
MAIN_HANDLER.post(() -> result.error("APPLY_FACE_DELTA_FAILED", "人脸库增量更新失败", null));
} finally {
FlutterTool.finishRegistration();
}
});
}
/**
* 将 Flutter StandardMessageCodec 的数字列表转换为虹软特征字节。
*
* @param value 可为空的 byte[] 或数字列表
* @return 特征字节;参数为空时返回空数组
*/
private byte[] byteArrayArgument(Object value) {
if (value instanceof byte[]) return (byte[]) value;
if (!(value instanceof List)) return new byte[0];
List<?> values = (List<?>) value;
byte[] result = new byte[values.size()];
for (int index = 0; index < values.size(); index++) {
Object item = values.get(index);
if (!(item instanceof Number)) throw new IllegalArgumentException("featureData包含非数字值");
result[index] = ((Number) item).byteValue();
}
return result;
}
private void emitRegistrationProgress(Map<String, Object> progress) {
MAIN_HANDLER.post(() -> {
EventChannel.EventSink sink = registrationEventSink;
if (sink != null) sink.success(progress);
});
}
/** 将质量检测进度安全切回主线程发送给Flutter。 */
private void emitQualityAuditProgress(Map<String, Object> progress) {
MAIN_HANDLER.post(() -> {
EventChannel.EventSink sink = qualityAuditEventSink;
if (sink != null) sink.success(progress);
});
}
private String stringArgument(MethodCall call, String key) {
Object value = call.argument(key);
return value == null ? "" : value.toString();
}
// flutter回调监听
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
Log.e(TAG, "flutter回调监听进入");
faceEventSink = events;
unregisterLegacyReceiver();
try {
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(FaceBrodConstant.FACE_GLOBAL_INTENT);
IntentFilter intentFilter = new IntentFilter(FaceBrodConstant.FACE_GLOBAL_INTENT);
flutterFaceReceiver = new FlutterFaceReceiver(events);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
context.registerReceiver(flutterFaceReceiver, intentFilter, Context.RECEIVER_EXPORTED);
} else {
context.registerReceiver(flutterFaceReceiver, intentFilter);
}
isReceiverRegistered = true;
} catch (Exception e) {
Log.e(TAG, "flutter回调监听错误error:" + e.getMessage(), e);
receiverRegistered = true;
} catch (Exception error) {
Log.e(TAG, "注册旧人脸 Activity 回调失败:" + error.getMessage(), error);
}
}
@Override
public void onCancel(Object arguments) {
context.unregisterReceiver(flutterFaceReceiver);
faceEventSink = null;
unregisterLegacyReceiver();
}
private void unregisterLegacyReceiver() {
if (flutterFaceReceiver != null && receiverRegistered) {
try {
context.unregisterReceiver(flutterFaceReceiver);
} catch (Exception ignored) {
}
}
flutterFaceReceiver = null;
receiverRegistered = false;
}
@Override
public void onSessionEvent(Map<String, Object> event) {
MAIN_HANDLER.post(() -> {
EventChannel.EventSink sink = sessionEventSink;
if (sink != null) sink.success(event);
});
}
@Override
public void onRecognitionResult(String json) {
MAIN_HANDLER.post(() -> {
EventChannel.EventSink sink = faceEventSink;
if (sink != null) sink.success(json);
});
}
}
@@ -0,0 +1,44 @@
package io.flutter.plugins.facediscern.flutter;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
/**
* flutter_subscreen_plugin 会复用主屏 Registry 中的插件对象。
* 该桥接器忽略主屏重复附着,仅在发现不同的副屏 Engine 时创建独立插件端点,
* 避免两个 Engine 覆盖彼此的 MethodChannel、EventSink 和 TextureRegistry。
*/
public final class FlutterFaceSubScreenPlugin implements FlutterPlugin {
private FlutterEngine mainEngine;
private FlutterEngine subEngine;
private FlutterFaceDiscernPlugin subDelegate;
private FlutterPluginBinding subBinding;
@Override
public synchronized void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
FlutterEngine engine = binding.getFlutterEngine();
if (mainEngine == null) {
mainEngine = engine;
return;
}
if (engine == mainEngine || engine == subEngine) return;
subEngine = engine;
subBinding = binding;
subDelegate = new FlutterFaceDiscernPlugin();
subDelegate.onAttachedToEngine(binding);
}
@Override
public synchronized void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
FlutterEngine engine = binding.getFlutterEngine();
if (engine == subEngine && subDelegate != null && subBinding != null) {
subDelegate.onDetachedFromEngine(subBinding);
subDelegate = null;
subBinding = null;
subEngine = null;
}
if (engine == mainEngine) mainEngine = null;
}
}
@@ -1,6 +1,7 @@
package io.flutter.plugins.facediscern.flutter;
import static com.arcsoft.face.FaceEngine.activeOnline;
import static com.arcsoft.face.FaceEngine.getActiveFileInfo;
import static io.flutter.plugins.facediscern.database.DataBaseUtils.getUserFace;
import static io.flutter.plugins.facediscern.database.DataBaseUtils.updateFaceModel;
@@ -11,6 +12,7 @@ import android.graphics.BitmapFactory;
import io.flutter.Log;
import com.arcsoft.face.ActiveFileInfo;
import com.arcsoft.face.ErrorInfo;
import com.arcsoft.face.FaceFeatureInfo;
import com.arcsoft.imageutil.ArcSoftImageFormat;
@@ -22,10 +24,13 @@ import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import io.flutter.plugins.facediscern.flutter.config.Constants;
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
@@ -38,49 +43,127 @@ public class FlutterTool {
static private final String FACE_JSON_FILE_NAME = "face.json";
private static final int REGISTER_BATCH_SIZE = 100;
private static final AtomicBoolean REGISTRATION_IN_PROGRESS = new AtomicBoolean(false);
private static volatile Map<String, Object> latestRegistrationProgress = idleRegistrationProgress();
private static volatile Map<String, Object> latestRegistrationResult;
private static volatile Map<String, Object> latestQualityAuditProgress = idleQualityAuditProgress();
public interface RegistrationProgressListener {
void onProgress(Map<String, Object> progress);
}
/** 接收原生在库质量检测进度并转发给Flutter事件通道。 */
public interface QualityAuditProgressListener {
/** 发布一份不包含图片路径和人员信息的进度快照。 */
void onProgress(Map<String, Object> progress);
}
static void updateConfig(String appId, String activeKey, String sdkKey) {
Constants.APP_ID = appId == null ? "" : appId.trim();
Constants.ACTIVE_KEY = activeKey == null ? "" : activeKey.trim();
Constants.SDK_KEY = sdkKey == null ? "" : sdkKey.trim();
}
// 在线激活
static public Boolean active(Context context, String appId, String activeKey, String sdkKey) {
if (Objects.isNull(appId)) {
Log.e(TAG, "请传入appId");
return false;
Map<String, Object> result = activateFaceSdk(context, appId, activeKey, sdkKey);
return Boolean.TRUE.equals(result.get("success"));
}
static public Map<String, Object> activateFaceSdk(Context context, String appId, String activeKey, String sdkKey) {
updateConfig(appId, activeKey, sdkKey);
Map<String, Object> result = new HashMap<>();
if (!hasCompleteConfig()) {
result.put("success", false);
result.put("code", ErrorInfo.MERR_INVALID_PARAM);
result.put("message", "人脸插件参数不完整,请先同步设备配置");
result.put("requiresPermission", false);
result.put("canOpenSettings", false);
return result;
}
if (Objects.isNull(activeKey)) {
Log.e(TAG, "请传入activeKey");
return false;
}
if (Objects.isNull(sdkKey)) {
Log.e(TAG, "请传入sdkKey");
return false;
}
Constants.APP_ID = appId;
Constants.ACTIVE_KEY = activeKey;
Constants.SDK_KEY = sdkKey;
int code = activeOnline(context, Constants.ACTIVE_KEY, Constants.APP_ID, Constants.SDK_KEY);
switch (code) {
case ErrorInfo.MOK:
Log.i(TAG, "激活成功!");
break;
case ErrorInfo.MERR_ASF_ALREADY_ACTIVATED:
Log.i(TAG, "已激活!");
break;
default:
Log.e(TAG, "激活失败!code:" + code);
}
boolean flag = Objects.equals(code, ErrorInfo.MOK) || Objects.equals(code, ErrorInfo.MERR_ASF_ALREADY_ACTIVATED);
Constants.IS_ACTIVE = flag;
return flag;
result.put("success", flag);
result.put("code", code);
result.put("message", flag ? "人脸插件激活成功" : activationErrorMessage(code));
boolean requiresPermission = code == ErrorInfo.MERR_ASF_READ_PHONE_STATE_DENIED;
result.put("requiresPermission", requiresPermission);
result.put("canOpenSettings", requiresPermission);
Log.i(TAG, "人脸插件激活结果code:" + code + ";success:" + flag);
return result;
}
// 获取配置
static Map<String, Object> getConfig() {
static Map<String, Object> getConfig(Context context) {
HashMap<String, Object> config = new HashMap<>();
config.put("appId", Constants.APP_ID);
config.put("sdkKey", Constants.SDK_KEY);
config.put("activeKey", Constants.ACTIVE_KEY);
config.put("isActive", Constants.IS_ACTIVE);
config.put("isActive", isActivated(context));
return config;
}
static Map<String, Object> getFaceStatus(Context context, String appId, String activeKey, String sdkKey) {
updateConfig(appId, activeKey, sdkKey);
Map<String, Object> status = new HashMap<>();
ActiveFileInfo activeFileInfo = new ActiveFileInfo();
int activeCode = getActiveFileInfo(context, activeFileInfo);
boolean activated = activeCode == ErrorInfo.MOK;
Constants.IS_ACTIVE = activated;
FaceServer faceServer = FaceServer.getInstance();
if (activated) faceServer.init(context);
List<FaceEntity> databaseFaces = getUserFace(context, null);
int databaseCount = databaseFaces.size();
int memoryCount = activated && faceServer.isEngineReady() ? Math.max(faceServer.getTotalFaceCount(), 0) : 0;
boolean registrationInProgress = REGISTRATION_IN_PROGRESS.get();
Set<Integer> databaseSampleIds = new HashSet<>();
for (FaceEntity face : databaseFaces) {
if (face.getSampleId() != null) databaseSampleIds.add(face.getSampleId());
}
Set<Integer> memorySampleIds = activated && faceServer.isEngineReady()
? faceServer.getRegisteredFaceIds()
: new HashSet<>();
status.put("activated", activated);
status.put("activationCode", activeCode);
status.put("parametersComplete", hasCompleteConfig());
status.put("activationStartTime", activated ? activeFileInfo.getStartTime() : "");
status.put("activationEndTime", activated ? activeFileInfo.getEndTime() : "");
status.put("engineReady", faceServer.isEngineReady());
status.put("engineCode", faceServer.getEngineInitCode());
status.put("databaseCount", databaseCount);
status.put("memoryCount", memoryCount);
status.put("registrationInProgress", registrationInProgress);
status.put("libraryConsistent", !registrationInProgress && databaseSampleIds.equals(memorySampleIds));
status.put("registrationProgress", new HashMap<>(latestRegistrationProgress));
status.put("lastRegistrationResult", latestRegistrationResult == null ? null : new HashMap<>(latestRegistrationResult));
return status;
}
static boolean isRegistrationInProgress() {
return REGISTRATION_IN_PROGRESS.get();
}
static boolean tryStartRegistration() {
return REGISTRATION_IN_PROGRESS.compareAndSet(false, true);
}
static void finishRegistration() {
REGISTRATION_IN_PROGRESS.set(false);
}
static Map<String, Object> getLatestRegistrationProgress() {
return new HashMap<>(latestRegistrationProgress);
}
/** 返回最新在库图片质量检测进度,供新订阅者立即恢复页面状态。 */
static Map<String, Object> getLatestQualityAuditProgress() {
return new HashMap<>(latestQualityAuditProgress);
}
static Integer parseId(String id) {
try {
@@ -168,29 +251,38 @@ public class FlutterTool {
static public Boolean registerFace(Context context, byte[] bytes, FaceEntity entity) {
FaceServer faceServer = FaceServer.getInstance();
faceServer.init(context);
// 类型转换
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (bitmap == null) return false;
Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true);
byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode);
if (alignedBitmap == null) {
bitmap.recycle();
return false;
}
byte[] faceBytes = faceServer.registerBgr24(
context,
bgr24Data,
alignedBitmap.getWidth(),
alignedBitmap.getHeight(),
String.valueOf(System.currentTimeMillis())
);
if (faceBytes == null) {
Log.e(TAG, "注册人脸,registerBgr24错误");
return false;
try {
byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode);
return false;
}
byte[] faceBytes = faceServer.registerBgr24(
context,
bgr24Data,
alignedBitmap.getWidth(),
alignedBitmap.getHeight(),
String.valueOf(System.currentTimeMillis())
);
if (faceBytes == null) {
Log.e(TAG, "注册人脸,registerBgr24错误");
return false;
}
entity.setFaceModel(faceBytes);
int code = faceServer.registerFaceFeatureInfoFromDb(entity);
return code == ErrorInfo.MOK;
} finally {
if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle();
if (!bitmap.isRecycled()) bitmap.recycle();
}
entity.setFaceModel(faceBytes);
int code = faceServer.registerFaceFeatureInfoFromDb(entity);
return code == ErrorInfo.MOK;
}
@@ -222,50 +314,417 @@ public class FlutterTool {
return i == 0;
}
/**
* 从临时图片提取虹软注册特征,不修改 SQLite 或虹软内存人脸库。
*
* @param context Android 应用上下文
* @param filePath 同步服务写入的临时图片绝对路径
* @return 包含成功标记、安全摘要和特征字节的 MethodChannel 结果
*/
static public Map<String, Object> extractFaceFeature(Context context, String filePath) {
Map<String, Object> result = new HashMap<>();
try {
FaceServer.QualityFeatureResult nativeResult = extractFaceResultFromFile(context, filePath);
byte[] featureData = nativeResult.featureData;
boolean success = featureData != null && featureData.length > 0;
result.put("success", success);
result.put("message", success ? "人脸特征提取成功" : "图片中未提取到可注册人脸");
result.put("featureData", success ? featureData : new byte[0]);
result.put("qualityStatus", nativeResult.qualityStatus);
result.put("qualityScore", nativeResult.qualityScore);
result.put("qualityErrorCode", nativeResult.qualityErrorCode);
return result;
} catch (Exception exception) {
Log.e(TAG, "提取人脸特征异常error:" + exception.getMessage(), exception);
result.put("success", false);
result.put("message", "人脸特征提取失败");
result.put("featureData", new byte[0]);
result.put("qualityStatus", "failed");
result.put("qualityErrorCode", -99);
return result;
}
}
// 批量注册全部人脸
static public List<Integer> registerFaceByDatabase(Context context, String userId) {
List<FaceEntity> faceList = getUserFace(context, userId);
List<Integer> errorSampleIdList = new ArrayList<>();
if (faceList.isEmpty()) return errorSampleIdList;
long startTime = System.currentTimeMillis();
// 批量查询人脸模型
for (FaceEntity v : faceList) {
if (v.getFaceModel() != null && v.getFaceModel().length > 0) continue;
byte[] bytes = null;
try {
bytes = FileUtil.fileToData(new File(v.getFaceFilePath()));
} catch (Exception e) {
errorSampleIdList.add(v.getSampleId());
Log.e(TAG, "registerFaceByDatabase,方法错误,file读取错误error:" + e.getMessage() + "json:" + new Gson().toJson(v), e);
continue;
}
if (bytes == null || bytes.length == 0) {
errorSampleIdList.add(v.getSampleId());
Log.e(TAG, "registerFaceByDatabase,方法错误" + v.getUserName() + "bytes is null," + "json:" + new Gson().toJson(v));
continue;
}
Boolean isRegister = registerFace(context, bytes, v);
if (isRegister) {
updateFaceModel(context, v);
/**
* 批量检测Flutter提交的本地人脸图片质量,逐图释放Bitmap并返回一一对应的结果。
*/
static public Map<String, Object> auditFaceQuality(Context context, List<?> items, QualityAuditProgressListener listener) {
List<Map<String, Object>> results = new ArrayList<>();
int total = items == null ? 0 : items.size();
int success = 0;
int failed = 0;
publishQualityAuditProgress(listener, 0, total, success, failed, null, true, "准备检测在库人脸");
if (items != null) {
for (Object rawItem : items) {
Map<String, Object> itemResult = new HashMap<>();
String sampleId = "";
try {
if (!(rawItem instanceof Map)) throw new IllegalArgumentException("检测项格式错误");
Map<?, ?> item = (Map<?, ?>) rawItem;
sampleId = item.get("sampleId") == null ? "" : item.get("sampleId").toString();
String filePath = item.get("filePath") == null ? "" : item.get("filePath").toString();
FaceServer.QualityFeatureResult quality = detectFaceQualityFromFile(context, filePath);
itemResult.put("sampleId", sampleId);
itemResult.put("status", quality.qualityStatus);
itemResult.put("score", quality.qualityScore);
itemResult.put("errorCode", quality.qualityErrorCode);
boolean detected = "detected".equals(quality.qualityStatus) && quality.qualityScore != null;
itemResult.put("message", detected ? "图片质量检测成功" : "图片质量检测失败");
if (detected) success++; else failed++;
} catch (Exception exception) {
Log.w(TAG, "在库图片质量检测失败sampleId:" + maskSampleIdText(sampleId) + ";error:" + exception.getClass().getSimpleName());
itemResult.put("sampleId", sampleId);
itemResult.put("status", "failed");
itemResult.put("errorCode", -99);
itemResult.put("message", "图片质量检测失败");
failed++;
}
results.add(itemResult);
publishQualityAuditProgress(listener, results.size(), total, success, failed, sampleId, true, "正在检测在库人脸");
}
}
publishQualityAuditProgress(listener, total, total, success, failed, null, false, "在库人脸质量检测完成");
Map<String, Object> auditResult = new HashMap<>();
auditResult.put("results", results);
auditResult.put("message", "在库人脸质量检测完成");
return auditResult;
}
/**
* 在虹软内存库中应用单样本增删;替换失败由 FaceServer 恢复旧特征。
*
* @param context Android 应用上下文
* @param operation upsert 或 delete
* @param sampleId 虹软 32 位搜索标识
* @param userId 用户主键,仅 upsert 使用
* @param username 用户姓名,仅作为内存识别元数据
* @param featureData 已通过提取校验的特征字节,仅 upsert 使用
* @return 包含成功标记、SDK 返回码和安全摘要的结果
*/
static public Map<String, Object> applyFaceDelta(Context context, String operation, int sampleId,
String userId, String username, byte[] featureData) {
Map<String, Object> result = new HashMap<>();
FaceServer faceServer = FaceServer.getInstance();
faceServer.init(context);
if (errorSampleIdList.isEmpty()) {
try {
faceServer.registerFaceFeatureInfoListFromDb(faceList);
Log.d(TAG, "registerFaceByDatabase,注册全部人脸耗时:" + (System.currentTimeMillis() - startTime) + "毫秒");
} catch (Exception e) {
faceServer.removeFace(-1);
Log.e(TAG, "registerFaceByDatabase,人脸引擎批量注册失败使用单个注册");
}
int code;
if ("delete".equals(operation)) {
code = faceServer.removeFace(sampleId);
} else if ("upsert".equals(operation) && featureData != null && featureData.length > 0) {
FaceEntity entity = new FaceEntity();
entity.setFaceId(sampleId);
entity.setSampleId(sampleId);
entity.setUserId(userId);
entity.setUserName(username);
entity.setFaceModel(featureData);
code = faceServer.replaceFaceFeature(entity);
} else {
for (FaceEntity v : faceList) {
faceServer.registerFaceFeatureInfoFromDb(v);
}
Log.d(TAG, "registerFaceByDatabase,注册单个人脸耗时:" + (System.currentTimeMillis() - startTime) + "毫秒");
code = ErrorInfo.MERR_INVALID_PARAM;
}
return errorSampleIdList;
boolean success = code == ErrorInfo.MOK;
result.put("success", success);
result.put("code", code);
result.put("message", success ? "人脸库增量更新成功" : "人脸库增量更新失败");
return result;
}
// 旧通道保留同步包装;新页面通过 rebuildFaceLibrary 获取完整结果和进度。
static public List<Integer> registerFaceByDatabase(Context context, String userId) {
Map<String, Object> result = rebuildFaceLibrary(context, userId, null);
Object failed = result.get("failedSampleIds");
if (failed instanceof List) {
//noinspection unchecked
return (List<Integer>) failed;
}
return new ArrayList<>();
}
static public Map<String, Object> rebuildFaceLibrary(Context context, String userId, RegistrationProgressListener listener) {
long startTime = System.currentTimeMillis();
List<FaceEntity> faceList = getUserFace(context, userId);
Set<Integer> failedSampleIds = new LinkedHashSet<>();
List<FaceEntity> validFaces = new ArrayList<>();
int total = faceList.size();
int processed = 0;
publishProgress(listener, "preparing", "准备人脸特征", processed, total, 0, 0, null, true);
for (FaceEntity face : faceList) {
boolean valid = hasFaceModel(face);
if (!valid) {
valid = extractAndPersistFaceModel(context, face);
}
processed++;
if (valid) {
validFaces.add(face);
} else if (face.getSampleId() != null) {
failedSampleIds.add(face.getSampleId());
}
publishProgress(listener, "preparing", "生成缺失的人脸特征", processed, total, validFaces.size(), failedSampleIds.size(), face.getSampleId(), true);
}
FaceServer faceServer = FaceServer.getInstance();
faceServer.init(context);
if (!faceServer.isEngineReady()) {
for (FaceEntity face : validFaces) {
if (face.getSampleId() != null) failedSampleIds.add(face.getSampleId());
}
return finishRegistrationResult(listener, total, 0, failedSampleIds, "人脸引擎初始化失败,请重新激活后再试", startTime);
}
boolean fullRebuild = userId == null || userId.trim().isEmpty();
if (fullRebuild) {
faceServer.removeFace(-1);
} else {
faceServer.removeFacesByUserId(userId);
}
int preparationFailureCount = failedSampleIds.size();
processed = 0;
int success = 0;
for (int start = 0; start < validFaces.size(); start += REGISTER_BATCH_SIZE) {
int end = Math.min(start + REGISTER_BATCH_SIZE, validFaces.size());
List<FaceEntity> batch = new ArrayList<>(validFaces.subList(start, end));
int code;
try {
code = faceServer.registerFaceFeatureInfoBatchFromDb(batch);
} catch (Exception exception) {
code = -99;
Log.e(TAG, "批量注册抛出异常,改为逐条处理error:" + exception.getMessage(), exception);
}
if (code == ErrorInfo.MOK) {
success += batch.size();
processed += batch.size();
Integer currentSampleId = batch.get(batch.size() - 1).getSampleId();
publishProgress(listener, "registering", "装载人脸到内存库", preparationFailureCount + processed, total, success, failedSampleIds.size(), currentSampleId, true);
continue;
}
Log.w(TAG, "批量注册失败code:" + code + ";改为逐条定位失败样本");
for (FaceEntity face : batch) {
int singleCode;
try {
singleCode = faceServer.registerFaceFeatureInfoFromDb(face);
} catch (Exception exception) {
singleCode = -99;
Log.e(TAG, "单条注册异常sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + exception.getMessage(), exception);
}
if (singleCode == ErrorInfo.MOK) {
success++;
} else if (face.getSampleId() != null) {
failedSampleIds.add(face.getSampleId());
}
processed++;
publishProgress(listener, "registering", "逐条定位异常样本", preparationFailureCount + processed, total, success, failedSampleIds.size(), face.getSampleId(), true);
}
}
String message;
if (total == 0) {
message = "本地数据库暂无人脸样本";
} else if (failedSampleIds.isEmpty()) {
message = "人脸库注册完成";
} else {
message = "人脸库注册完成,部分样本失败,请前往人脸管理处理";
}
return finishRegistrationResult(listener, total, success, failedSampleIds, message, startTime);
}
private static boolean extractAndPersistFaceModel(Context context, FaceEntity face) {
if (face == null || face.getFaceFilePath() == null) return false;
try {
byte[] faceBytes = extractFaceModelFromFile(context, face.getFaceFilePath());
if (faceBytes == null || faceBytes.length == 0) return false;
face.setFaceModel(faceBytes);
return updateFaceModel(context, face);
} catch (Exception e) {
Log.e(TAG, "生成人脸特征失败sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + e.getMessage());
return false;
}
}
/**
* 解码图片并在统一 FaceServer 引擎锁内提取注册特征,不写入业务数据库。
*
* @param context Android 应用上下文
* @param filePath 待校验图片绝对路径
* @return 虹软特征字节,图片无效或未检测到合格人脸时返回空
*/
private static byte[] extractFaceModelFromFile(Context context, String filePath) {
return extractFaceResultFromFile(context, filePath).featureData;
}
/** 将本地文件一次转换为BGR24并同时返回特征与质量结果。 */
private static FaceServer.QualityFeatureResult extractFaceResultFromFile(Context context, String filePath) {
FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult();
failed.qualityStatus = "failed";
failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
if (filePath == null || filePath.isEmpty()) return failed;
byte[] bytes = FileUtil.fileToData(new File(filePath));
if (bytes == null || bytes.length == 0) return failed;
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (bitmap == null) return failed;
Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true);
if (alignedBitmap == null) {
bitmap.recycle();
return failed;
}
try {
byte[] bgr24Data = ArcSoftImageUtil.createImageData(
alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
failed.qualityErrorCode = transformCode;
return failed;
}
FaceServer faceServer = FaceServer.getInstance();
faceServer.init(context);
return faceServer.registerBgr24WithQuality(context, bgr24Data, alignedBitmap.getWidth(),
alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis()));
} finally {
if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle();
if (!bitmap.isRecycled()) bitmap.recycle();
}
}
/** 将本地文件转换为BGR24后仅执行质量检测。 */
private static FaceServer.QualityFeatureResult detectFaceQualityFromFile(Context context, String filePath) {
FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult();
failed.qualityStatus = "failed";
failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
if (filePath == null || filePath.isEmpty()) return failed;
byte[] bytes = FileUtil.fileToData(new File(filePath));
if (bytes == null || bytes.length == 0) return failed;
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (bitmap == null) return failed;
Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true);
if (alignedBitmap == null) {
bitmap.recycle();
return failed;
}
try {
byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
failed.qualityErrorCode = transformCode;
return failed;
}
FaceServer faceServer = FaceServer.getInstance();
faceServer.init(context);
return faceServer.detectBgr24Quality(bgr24Data, alignedBitmap.getWidth(), alignedBitmap.getHeight());
} finally {
if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle();
if (!bitmap.isRecycled()) bitmap.recycle();
}
}
/** 发布并缓存质量检测进度,供页面中途进入时恢复。 */
private static void publishQualityAuditProgress(QualityAuditProgressListener listener, int processed, int total,
int success, int failed, String currentSampleId,
boolean inProgress, String message) {
Map<String, Object> progress = new HashMap<>();
progress.put("processed", processed);
progress.put("total", total);
progress.put("successCount", success);
progress.put("failedCount", failed);
progress.put("currentSampleId", currentSampleId);
progress.put("inProgress", inProgress);
progress.put("message", message);
latestQualityAuditProgress = progress;
if (listener != null) listener.onProgress(new HashMap<>(progress));
}
/** 创建尚未执行质量检测时的空进度快照。 */
private static Map<String, Object> idleQualityAuditProgress() {
Map<String, Object> progress = new HashMap<>();
progress.put("processed", 0);
progress.put("total", 0);
progress.put("successCount", 0);
progress.put("failedCount", 0);
progress.put("currentSampleId", null);
progress.put("inProgress", false);
progress.put("message", "尚未执行在库人脸质量检测");
return progress;
}
/** 对质量检测日志中的样本编号做最小脱敏。 */
private static String maskSampleIdText(String sampleId) {
if (sampleId == null || sampleId.isEmpty()) return "-";
if (sampleId.length() <= 4) return "***" + sampleId;
return sampleId.substring(0, 1) + "***" + sampleId.substring(sampleId.length() - 3);
}
private static boolean hasFaceModel(FaceEntity face) {
return face != null && face.getFaceModel() != null && face.getFaceModel().length > 0;
}
private static boolean hasCompleteConfig() {
return !Constants.APP_ID.isEmpty() && !Constants.SDK_KEY.isEmpty() && !Constants.ACTIVE_KEY.isEmpty();
}
private static boolean isActivated(Context context) {
ActiveFileInfo activeFileInfo = new ActiveFileInfo();
boolean activated = getActiveFileInfo(context, activeFileInfo) == ErrorInfo.MOK;
Constants.IS_ACTIVE = activated;
return activated;
}
private static Map<String, Object> idleRegistrationProgress() {
Map<String, Object> progress = new HashMap<>();
progress.put("phase", "idle");
progress.put("message", "尚未执行人脸库注册");
progress.put("processed", 0);
progress.put("total", 0);
progress.put("successCount", 0);
progress.put("failedCount", 0);
progress.put("currentSampleId", null);
progress.put("inProgress", false);
return progress;
}
private static void publishProgress(RegistrationProgressListener listener, String phase, String message, int processed, int total, int success, int failed, Integer currentSampleId, boolean inProgress) {
Map<String, Object> progress = new HashMap<>();
progress.put("phase", phase);
progress.put("message", message);
progress.put("processed", processed);
progress.put("total", total);
progress.put("successCount", success);
progress.put("failedCount", failed);
progress.put("currentSampleId", currentSampleId);
progress.put("inProgress", inProgress);
latestRegistrationProgress = progress;
if (listener != null) listener.onProgress(new HashMap<>(progress));
}
private static Map<String, Object> finishRegistrationResult(RegistrationProgressListener listener, int total, int success, Set<Integer> failedSampleIds, String message, long startTime) {
List<Integer> failed = new ArrayList<>(failedSampleIds);
Map<String, Object> result = new HashMap<>();
result.put("success", failed.isEmpty() && success == total);
result.put("message", message);
result.put("total", total);
result.put("successCount", success);
result.put("failedCount", failed.size());
result.put("failedSampleIds", failed);
result.put("durationMs", System.currentTimeMillis() - startTime);
latestRegistrationResult = result;
publishProgress(listener, failed.isEmpty() ? "completed" : "completedWithErrors", message, total, total, success, failed.size(), null, false);
Log.i(TAG, "人脸库注册结束total:" + total + ";success:" + success + ";failed:" + failed.size());
return result;
}
private static String maskSampleId(Integer sampleId) {
if (sampleId == null) return "-";
String value = String.valueOf(sampleId);
if (value.length() <= 4) return "***" + value;
return value.substring(0, 1) + "***" + value.substring(value.length() - 3);
}
private static String activationErrorMessage(int code) {
if (code == ErrorInfo.MERR_ASF_READ_PHONE_STATE_DENIED) return "缺少电话状态权限,请授权后重试";
if (code == ErrorInfo.MERR_ASF_INTERNET_DENIED || code == ErrorInfo.MERR_ASF_NETWORK_COULDNT_RESOLVE_HOST || code == ErrorInfo.MERR_ASF_NETWORK_COULDNT_CONNECT_SERVER || code == ErrorInfo.MERR_ASF_NETWORK_CONNECT_TIMEOUT || code == ErrorInfo.MERR_ASF_NETWORK_UNKNOWN_ERROR) return "无法连接虹软激活服务,请检查网络后重试";
if (code == ErrorInfo.MERR_ASF_ACTIVEKEY_PARM_MISMATCH || code == ErrorInfo.MERR_ASF_ACTIVEKEY_APPID_PARM_MISMATCH || code == ErrorInfo.MERR_ASF_ACTIVEKEY_SDK_FILE_MISMATCH || code == ErrorInfo.MERR_ASF_APPID_APPKEY_SDK_MISMATCH || code == ErrorInfo.MERR_FSDK_INVALID_ID_PAIR || code == ErrorInfo.MERR_FSDK_MISMATCH_ID_AND_SDK) return "激活参数不匹配,请重新同步设备配置";
if (code == ErrorInfo.MERR_ASF_ACTIVEKEY_EXPIRED || code == ErrorInfo.MERR_EXPIRED || code == ErrorInfo.MERR_ASF_LICENSE_FILE_EXPIRED) return "人脸插件授权已过期,请联系管理员更新授权";
if (code == ErrorInfo.MERR_ASF_LOCAL_TIME_NOT_CALIBRATED || code == ErrorInfo.MERR_ASF_CURRENT_DEVICE_TIME_INCORRECT) return "设备时间异常,请校准系统时间后重试";
if (code == ErrorInfo.MERR_ASF_DEVICE_MISMATCH) return "授权与当前设备不匹配,请联系管理员重新绑定";
return "人脸插件激活失败,错误码:" + code;
}
}
@@ -2,8 +2,12 @@ package io.flutter.plugins.facediscern.flutter.config;
public class Constants {
public static String APP_ID = "FWKhBhtgnUnVrUJXZD3SWqnGYV99AiXP4SS8aNSR6bk3";
public static String SDK_KEY = "5SAziGjCHYPY4GdjGY8dTiBABBmuLbiTp7oWsKTcWzMb";
public static String ACTIVE_KEY = "U571-11UF-F13U-F43A";
public static String APP_ID = "";
public static String SDK_KEY = "";
public static String ACTIVE_KEY = "";
/**
* 仅保留给旧版 getConfig 通道兼容,真实授权状态必须通过 ActiveFileInfo 查询。
*/
public static boolean IS_ACTIVE = false;
}
@@ -0,0 +1,251 @@
package io.flutter.plugins.facediscern.session;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.YuvImage;
import android.graphics.ImageFormat;
import com.arcsoft.face.FaceInfo;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
/**
* 将人脸比对成功帧裁剪为终端私有 JPEG 证据。
*
* <p>裁剪使用虹软实际参与比对的人脸框并外扩约 20%,随后补偿相机方向和镜像,最长边
* 限制为 640 像素,质量逐级降低到约 100KB。文件写入使用临时文件改名,避免任务队列
* 看到半个 JPEG。</p>
*/
public final class FaceEvidenceImageWriter {
/** 本地现场图最长边上限,单位为像素。 */
private static final int MAX_EDGE = 640;
/** 本地现场图目标大小,单位为字节。 */
private static final int TARGET_BYTES = 100 * 1024;
/** 人脸框外扩比例。 */
private static final float PADDING_RATIO = 0.20f;
/** 创建人脸证据写入器。 */
private FaceEvidenceImageWriter() {
}
/**
* 写入一张成功匹配目标的人脸现场图。
*
* @param contextFilesDir 应用私有 files 目录
* @param snapshot 实际提取特征所用的帧和目标框
* @param rotation 相机预览方向,取 0/90/180/270
* @param mirror 是否需要水平镜像
* @param trackId 虹软跟踪编号,仅用于生成不含业务信息的文件名
* @return 图片写入结果;失败时包含安全错误摘要且路径为空
*/
public static Result write(File contextFilesDir, RecognitionFrameSnapshot snapshot, int rotation,
boolean mirror, int trackId) {
try {
if (snapshot == null || snapshot.getNv21() == null || snapshot.getFaceInfo() == null) {
return Result.failure(snapshot == null ? 0L : snapshot.getCapturedAt(), "成功帧快照为空");
}
byte[] nv21 = snapshot.getNv21();
FaceInfo faceInfo = snapshot.getFaceInfo();
Rect crop = expandAndClamp(faceInfo.getRect(), snapshot.getWidth(), snapshot.getHeight());
if (crop.width() < 2 || crop.height() < 2) return Result.failure(snapshot.getCapturedAt(), "人脸框无效");
ByteArrayOutputStream rawJpeg = new ByteArrayOutputStream();
YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, snapshot.getWidth(), snapshot.getHeight(), null);
if (!yuv.compressToJpeg(crop, 92, rawJpeg)) return Result.failure(snapshot.getCapturedAt(), "NV21 转图片失败");
Bitmap bitmap = BitmapFactory.decodeByteArray(rawJpeg.toByteArray(), 0, rawJpeg.size());
if (bitmap == null) return Result.failure(snapshot.getCapturedAt(), "图片解码失败");
Bitmap oriented = orient(bitmap, rotation, mirror);
Bitmap scaled = scale(oriented, MAX_EDGE);
if (oriented != bitmap) bitmap.recycle();
if (scaled != oriented) oriented.recycle();
byte[] encoded = compress(scaled);
scaled.recycle();
if (encoded == null) return Result.failure(snapshot.getCapturedAt(), "JPEG 压缩失败");
File directory = new File(contextFilesDir, "order_evidence");
if (!directory.exists() && !directory.mkdirs()) return Result.failure(snapshot.getCapturedAt(), "证据目录不可用");
File target = new File(directory, UUID.randomUUID().toString() + "_" + trackId + ".jpg");
File temporary = new File(directory, "." + target.getName() + ".tmp");
try (FileOutputStream output = new FileOutputStream(temporary)) {
output.write(encoded);
output.getFD().sync();
}
if (!temporary.renameTo(target)) {
// Android 部分文件系统不支持跨目录原子改名,当前目录内失败时直接返回并清理。
temporary.delete();
return Result.failure(snapshot.getCapturedAt(), "图片文件写入失败");
}
return Result.success(target.getAbsolutePath(), sha256(encoded), encoded.length,
scaledWidth(encoded), scaledHeight(encoded), snapshot.getCapturedAt());
} catch (Exception error) {
return Result.failure(snapshot == null ? 0L : snapshot.getCapturedAt(), "现场图片生成失败");
}
}
/**
* 清理原生写图后未能写入 Floor 队列的超时文件。
*
* @param contextFilesDir 应用私有 files 目录
* @param referencedPaths Floor 队列仍引用的绝对路径,这些文件不受 24 小时无主清理影响
* @param olderThanMillis 仅清理最后修改时间早于该 Unix 毫秒时间点的文件
* @return 实际成功删除的文件数量
*/
public static int cleanupOrphans(File contextFilesDir, Set<String> referencedPaths, long olderThanMillis) {
File directory = new File(contextFilesDir, "order_evidence");
File[] files = directory.listFiles();
if (files == null || files.length == 0) return 0;
Set<String> protectedPaths = canonicalPaths(referencedPaths);
int deleted = 0;
for (File file : files) {
if (!file.isFile() || file.lastModified() <= 0L || file.lastModified() > olderThanMillis) continue;
String name = file.getName().toLowerCase(Locale.ROOT);
boolean temporary = name.startsWith(".") && name.endsWith(".tmp");
boolean jpeg = name.endsWith(".jpg") || name.endsWith(".jpeg");
if (!temporary && !jpeg) continue;
if (jpeg && protectedPaths.contains(canonicalPath(file))) continue;
if (file.delete()) deleted++;
}
return deleted;
}
/** 将 Floor 传入的路径统一为规范路径,避免路径表示差异误删仍在补传的文件。 */
private static Set<String> canonicalPaths(Set<String> paths) {
Set<String> result = new HashSet<>();
if (paths == null) return result;
for (String path : paths) {
if (path != null && !path.trim().isEmpty()) result.add(canonicalPath(new File(path)));
}
return result;
}
/** 返回文件规范路径;系统拒绝解析时退回绝对路径用于保守比对。 */
private static String canonicalPath(File file) {
try {
return file.getCanonicalPath();
} catch (Exception ignored) {
return file.getAbsolutePath();
}
}
/** 外扩人脸框并限制在 NV21 有效区域内,坐标调整为偶数以适配 UV 平面。 */
private static Rect expandAndClamp(Rect source, int width, int height) {
int paddingX = Math.round(source.width() * PADDING_RATIO);
int paddingY = Math.round(source.height() * PADDING_RATIO);
int left = Math.max(0, source.left - paddingX);
int top = Math.max(0, source.top - paddingY);
int right = Math.min(width, source.right + paddingX);
int bottom = Math.min(height, source.bottom + paddingY);
left &= ~1;
top &= ~1;
right = Math.min(width, right & ~1);
bottom = Math.min(height, bottom & ~1);
return new Rect(left, top, right, bottom);
}
/** 应用方向和前置镜像补偿。 */
private static Bitmap orient(Bitmap source, int rotation, boolean mirror) {
int normalizedRotation = ((rotation % 360) + 360) % 360;
if (normalizedRotation == 0 && !mirror) return source;
Matrix matrix = new Matrix();
if (normalizedRotation != 0) matrix.postRotate(normalizedRotation);
if (mirror) matrix.postScale(-1f, 1f);
return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true);
}
/** 按最长边等比缩小,避免上传大图。 */
private static Bitmap scale(Bitmap source, int maxEdge) {
int edge = Math.max(source.getWidth(), source.getHeight());
if (edge <= maxEdge) return source;
float ratio = maxEdge / (float) edge;
return Bitmap.createScaledBitmap(source, Math.round(source.getWidth() * ratio),
Math.round(source.getHeight() * ratio), true);
}
/** 逐级压缩到约 100KB;缩放后仍过大时保留最低质量结果供服务端校验。 */
private static byte[] compress(Bitmap bitmap) {
for (int quality : new int[]{88, 78, 68, 58, 48, 40}) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output);
if (output.size() <= TARGET_BYTES || quality == 40) return output.toByteArray();
}
return null;
}
/** 读取压缩图片宽度;失败时返回 0,避免再次持有 Bitmap。 */
private static int scaledWidth(byte[] jpeg) {
Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length);
if (bitmap == null) return 0;
int width = bitmap.getWidth();
bitmap.recycle();
return width;
}
/** 读取压缩图片高度;失败时返回 0,避免再次持有 Bitmap。 */
private static int scaledHeight(byte[] jpeg) {
Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length);
if (bitmap == null) return 0;
int height = bitmap.getHeight();
bitmap.recycle();
return height;
}
/** 计算现场 JPEG 的 SHA-256。 */
private static String sha256(byte[] content) throws NoSuchAlgorithmException {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(content);
StringBuilder result = new StringBuilder(digest.length * 2);
for (byte value : digest) result.append(String.format(Locale.ROOT, "%02x", value));
return result.toString();
}
/** 单张现场图写入结果。 */
public static final class Result {
/** 私有文件路径,失败时为空。 */
public final String filePath;
/** 文件 SHA-256,失败时为空。 */
public final String hash;
/** 文件大小,单位字节。 */
public final int size;
/** 图片宽度,单位像素。 */
public final int width;
/** 图片高度,单位像素。 */
public final int height;
/** 原始帧采集时间,单位 Unix 毫秒。 */
public final long capturedAt;
/** 安全失败摘要,成功时为空。 */
public final String error;
/** 创建成功或失败结果;成功时文件字段完整,失败时仅保留采集时间和安全摘要。 */
private Result(String filePath, String hash, int size, int width, int height, long capturedAt, String error) {
this.filePath = filePath;
this.hash = hash;
this.size = size;
this.width = width;
this.height = height;
this.capturedAt = capturedAt;
this.error = error;
}
/** 创建成功结果。 */
public static Result success(String filePath, String hash, int size, int width, int height, long capturedAt) {
return new Result(filePath, hash, size, width, height, capturedAt, null);
}
/** 创建失败结果。 */
public static Result failure(long capturedAt, String error) {
return new Result(null, null, 0, 0, 0, capturedAt, error);
}
}
}
@@ -0,0 +1,413 @@
package io.flutter.plugins.facediscern.session;
import android.graphics.SurfaceTexture;
import android.opengl.EGL14;
import android.opengl.EGLConfig;
import android.opengl.EGLContext;
import android.opengl.EGLDisplay;
import android.opengl.EGLExt;
import android.opengl.EGLSurface;
import android.opengl.GLES11Ext;
import android.opengl.GLES20;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.util.Log;
import android.view.Surface;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import io.flutter.view.TextureRegistry;
/**
* Camera1 只绑定一个 OES 输入纹理,渲染线程再把同一帧复制到多个 Flutter Texture。
* 这样主、副屏不会各自打开摄像头,也不需要把 NV21 转成 Bitmap 造成大块内存复制。
*/
final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListener {
private static final String TAG = "FacePreviewRenderer";
private static final AtomicLong NEXT_OUTPUT_ID = new AtomicLong(1L);
private static final float[] VERTICES = {
// SurfaceTexture#getTransformMatrix 已包含生产者到 GL 纹理坐标的 Y 轴补偿,
// 这里必须使用标准 UV;再次手工翻转会让主副屏画面上下颠倒。
-1f, -1f, 0f, 0f,
1f, -1f, 1f, 0f,
-1f, 1f, 0f, 1f,
1f, 1f, 1f, 1f,
};
private static final String VERTEX_SHADER =
"attribute vec2 aPosition;\n" +
"attribute vec2 aTextureCoord;\n" +
"uniform mat4 uTextureMatrix;\n" +
"varying vec2 vTextureCoord;\n" +
"void main() {\n" +
" gl_Position = vec4(aPosition, 0.0, 1.0);\n" +
" vTextureCoord = (uTextureMatrix * vec4(aTextureCoord, 0.0, 1.0)).xy;\n" +
"}\n";
private static final String FRAGMENT_SHADER =
"#extension GL_OES_EGL_image_external : require\n" +
"precision mediump float;\n" +
"varying vec2 vTextureCoord;\n" +
"uniform samplerExternalOES uTexture;\n" +
"void main() {\n" +
" gl_FragColor = texture2D(uTexture, vTextureCoord);\n" +
"}\n";
private final HandlerThread renderThread = new HandlerThread("face-preview-render");
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private Handler renderHandler;
private final Object lock = new Object();
private final Map<Long, OutputTarget> outputs = new HashMap<>();
/**
* 当前主、副 Flutter Engine 实际持有的预览输出数量。
* Camera 回调线程只读取该原子值,不跨线程直接访问渲染线程拥有的 outputs。
*/
private final AtomicInteger activeOutputCount = new AtomicInteger();
private final float[] textureMatrix = new float[16];
private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
private EGLSurface pbufferSurface = EGL14.EGL_NO_SURFACE;
private EGLConfig eglConfig;
private SurfaceTexture cameraTexture;
private int oesTextureId;
private int program;
private int positionHandle;
private int textureCoordHandle;
private int textureMatrixHandle;
private FloatBuffer vertexBuffer;
private int previewWidth = 1280;
private int previewHeight = 720;
private boolean initialized;
private boolean released;
private boolean firstFrameLogged;
private volatile boolean frozen;
FacePreviewRenderer() {
renderThread.start();
renderHandler = new Handler(renderThread.getLooper());
}
SurfaceTexture getCameraTexture() throws Exception {
runBlocking(() -> {
ensureInitialized();
cameraTexture.setDefaultBufferSize(previewWidth, previewHeight);
});
return cameraTexture;
}
void setPreviewSize(int width, int height) {
if (width <= 0 || height <= 0) return;
previewWidth = width;
previewHeight = height;
Handler handler = renderHandler;
if (handler != null) {
handler.post(() -> {
if (cameraTexture != null) cameraTexture.setDefaultBufferSize(width, height);
});
}
}
long addOutput(TextureRegistry.SurfaceTextureEntry entry) throws Exception {
// Texture ID 只在各自 Flutter Engine 内唯一,主屏和副屏的首个 ID 通常都会是 0。
// 共享渲染器必须另分配进程级输出编号,否则副屏会被误判为主屏的重复 Texture。
// 编号必须跨 Renderer 重建保持唯一,避免旧页面的迟到 release 误删新会话输出。
long outputId = NEXT_OUTPUT_ID.getAndIncrement();
runBlocking(() -> {
ensureInitialized();
SurfaceTexture surfaceTexture = entry.surfaceTexture();
surfaceTexture.setDefaultBufferSize(previewWidth, previewHeight);
Surface surface = new Surface(surfaceTexture);
int[] surfaceAttributes = {EGL14.EGL_NONE};
EGLSurface eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttributes, 0);
if (eglSurface == null || eglSurface == EGL14.EGL_NO_SURFACE) {
surface.release();
throw new IllegalStateException("无法创建 Flutter 人脸预览 EGLSurface");
}
outputs.put(outputId, new OutputTarget(entry, surface, eglSurface));
activeOutputCount.set(outputs.size());
Log.i(TAG, "预览输出已添加 outputId=" + outputId + ";flutterTextureId=" + entry.id() + ";outputCount=" + outputs.size());
});
return outputId;
}
void removeOutput(long outputId) {
Handler handler = renderHandler;
if (handler == null) return;
handler.post(() -> releaseOutput(outputId));
}
/**
* 判断是否仍有界面需要实时人脸框。
*
* @return 主屏或副屏至少持有一个有效 Texture 输出时返回 true
*/
boolean hasActiveOutputs() {
return activeOutputCount.get() > 0;
}
/**
* 成功后继续保留 Camera 热资源,只暂停向主副屏 Texture 提交新帧。
* 这样界面会停在通过阈值的最后一帧,下一位顾客开始时再恢复,不需要重新开 Camera。
*/
void setFrozen(boolean frozen) {
boolean wasFrozen = this.frozen;
this.frozen = frozen;
Log.i(TAG, frozen ? "人脸预览已冻结" : "人脸预览已恢复");
if (wasFrozen && !frozen) {
Handler handler = renderHandler;
if (handler != null) {
// 冻结期间不调用 updateTexImage 会让 SurfaceTexture 积压一帧,
// Camera 生产端随后可能停在缓冲队列上,不再产生新的帧回调。
// 新会话解冻时主动消费该帧,释放生产端后主、副屏才能继续收到实时画面。
handler.post(() -> {
if (!this.frozen) {
firstFrameLogged = false;
renderFrame();
}
});
}
}
}
@Override
public void onFrameAvailable(SurfaceTexture surfaceTexture) {
if (frozen) return;
Handler handler = renderHandler;
if (handler != null) handler.post(this::renderFrame);
}
void release() {
Handler handler = renderHandler;
if (handler == null) return;
try {
runBlocking(this::releaseInternal);
} catch (Exception error) {
Log.w(TAG, "释放预览渲染器失败", error);
}
renderHandler = null;
renderThread.quitSafely();
}
private void ensureInitialized() {
if (initialized) return;
if (released) throw new IllegalStateException("预览渲染器已释放");
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
int[] version = new int[2];
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
throw new IllegalStateException("EGL 初始化失败");
}
int[] configAttributes = {
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT | EGL14.EGL_PBUFFER_BIT,
EGL14.EGL_NONE,
};
EGLConfig[] configs = new EGLConfig[1];
int[] configCount = new int[1];
EGL14.eglChooseConfig(eglDisplay, configAttributes, 0, configs, 0, 1, configCount, 0);
if (configCount[0] == 0) throw new IllegalStateException("未找到可用 EGLConfig");
eglConfig = configs[0];
int[] contextAttributes = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttributes, 0);
int[] pbufferAttributes = {EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE};
pbufferSurface = EGL14.eglCreatePbufferSurface(eglDisplay, eglConfig, pbufferAttributes, 0);
makeCurrent(pbufferSurface);
int[] textureIds = new int[1];
GLES20.glGenTextures(1, textureIds, 0);
oesTextureId = textureIds[0];
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
cameraTexture = new SurfaceTexture(oesTextureId);
cameraTexture.setDefaultBufferSize(previewWidth, previewHeight);
cameraTexture.setOnFrameAvailableListener(this, renderHandler);
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER);
positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
textureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord");
textureMatrixHandle = GLES20.glGetUniformLocation(program, "uTextureMatrix");
vertexBuffer = ByteBuffer.allocateDirect(VERTICES.length * 4)
.order(ByteOrder.nativeOrder()).asFloatBuffer();
vertexBuffer.put(VERTICES).position(0);
firstFrameLogged = false;
initialized = true;
}
private void renderFrame() {
if (!initialized || released || frozen || cameraTexture == null) return;
makeCurrent(pbufferSurface);
try {
cameraTexture.updateTexImage();
cameraTexture.getTransformMatrix(textureMatrix);
} catch (RuntimeException error) {
Log.w(TAG, "更新相机纹理失败", error);
return;
}
if (!firstFrameLogged) {
firstFrameLogged = true;
Log.i(TAG, "预览首帧已到达 outputCount=" + outputs.size() + ";size=" + previewWidth + "x" + previewHeight);
}
for (OutputTarget output : outputs.values().toArray(new OutputTarget[0])) {
if (!makeCurrent(output.eglSurface)) continue;
GLES20.glViewport(0, 0, previewWidth, previewHeight);
GLES20.glClearColor(0f, 0f, 0f, 1f);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(program);
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId);
vertexBuffer.position(0);
GLES20.glEnableVertexAttribArray(positionHandle);
GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 16, vertexBuffer);
vertexBuffer.position(2);
GLES20.glEnableVertexAttribArray(textureCoordHandle);
GLES20.glVertexAttribPointer(textureCoordHandle, 2, GLES20.GL_FLOAT, false, 16, vertexBuffer);
GLES20.glUniformMatrix4fv(textureMatrixHandle, 1, false, textureMatrix, 0);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
EGLExt.eglPresentationTimeANDROID(eglDisplay, output.eglSurface, cameraTexture.getTimestamp());
if (!EGL14.eglSwapBuffers(eglDisplay, output.eglSurface)) {
Log.w(TAG, "Flutter 人脸预览交换缓冲区失败 textureId=" + output.entry.id());
}
}
makeCurrent(pbufferSurface);
}
private boolean makeCurrent(EGLSurface surface) {
return EGL14.eglMakeCurrent(eglDisplay, surface, surface, eglContext);
}
private int createProgram(String vertexSource, String fragmentSource) {
int vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource);
int fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
int result = GLES20.glCreateProgram();
GLES20.glAttachShader(result, vertex);
GLES20.glAttachShader(result, fragment);
GLES20.glLinkProgram(result);
int[] linkStatus = new int[1];
GLES20.glGetProgramiv(result, GLES20.GL_LINK_STATUS, linkStatus, 0);
GLES20.glDeleteShader(vertex);
GLES20.glDeleteShader(fragment);
if (linkStatus[0] == 0) {
String info = GLES20.glGetProgramInfoLog(result);
GLES20.glDeleteProgram(result);
throw new IllegalStateException("GL 程序链接失败:" + info);
}
return result;
}
private int compileShader(int type, String source) {
int shader = GLES20.glCreateShader(type);
GLES20.glShaderSource(shader, source);
GLES20.glCompileShader(shader);
int[] compileStatus = new int[1];
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if (compileStatus[0] == 0) {
String info = GLES20.glGetShaderInfoLog(shader);
GLES20.glDeleteShader(shader);
throw new IllegalStateException("GL Shader 编译失败:" + info);
}
return shader;
}
private void releaseOutput(long outputId) {
OutputTarget output = outputs.remove(outputId);
if (output == null) return;
activeOutputCount.set(outputs.size());
if (eglDisplay != EGL14.EGL_NO_DISPLAY && output.eglSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, output.eglSurface);
}
output.surface.release();
// EGLSurface 与 Android Surface 属于渲染线程;Flutter Texture 注册只能在主线程解除。
long releasedTextureId = output.entry.id();
mainHandler.post(() -> {
try {
output.entry.release();
Log.i(TAG, "Flutter 预览 Texture 已释放 outputId=" + outputId + ";textureId=" + releasedTextureId);
} catch (RuntimeException error) {
Log.w(TAG, "释放 Flutter 预览 Texture 失败 outputId=" + outputId + ";textureId=" + releasedTextureId, error);
}
});
}
private void releaseInternal() {
if (released) return;
released = true;
for (Long textureId : outputs.keySet().toArray(new Long[0])) releaseOutput(textureId);
activeOutputCount.set(0);
if (cameraTexture != null) {
cameraTexture.setOnFrameAvailableListener(null);
cameraTexture.release();
cameraTexture = null;
}
if (program != 0) GLES20.glDeleteProgram(program);
if (oesTextureId != 0) GLES20.glDeleteTextures(1, new int[]{oesTextureId}, 0);
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
makeCurrent(pbufferSurface);
if (pbufferSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(eglDisplay, pbufferSurface);
if (eglContext != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(eglDisplay, eglContext);
EGL14.eglTerminate(eglDisplay);
}
initialized = false;
}
private void runBlocking(ThrowingRunnable runnable) throws Exception {
Handler handler = renderHandler;
if (handler == null) throw new IllegalStateException("预览渲染线程不可用");
if (Thread.currentThread() == handler.getLooper().getThread()) {
runnable.run();
return;
}
final Object waitLock = new Object();
final Exception[] failure = new Exception[1];
final boolean[] completed = {false};
handler.post(() -> {
try {
runnable.run();
} catch (Exception error) {
failure[0] = error;
} finally {
synchronized (waitLock) {
completed[0] = true;
waitLock.notifyAll();
}
}
});
synchronized (waitLock) {
while (!completed[0]) waitLock.wait();
}
if (failure[0] != null) throw failure[0];
}
private interface ThrowingRunnable {
void run() throws Exception;
}
private static final class OutputTarget {
final TextureRegistry.SurfaceTextureEntry entry;
final Surface surface;
final EGLSurface eglSurface;
OutputTarget(TextureRegistry.SurfaceTextureEntry entry, Surface surface, EGLSurface eglSurface) {
this.entry = entry;
this.surface = surface;
this.eglSurface = eglSurface;
}
}
}
@@ -33,6 +33,7 @@ import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeInfo;
import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -69,6 +70,7 @@ public class FaceHelper implements FaceListener {
* 识别结果的回调
*/
private RecognizeCallback recognizeCallback;
private volatile long sessionGeneration;
/**
@@ -76,6 +78,9 @@ public class FaceHelper implements FaceListener {
*/
private ConcurrentHashMap<Integer, RecognizeInfo> recognizeInfoMap = new ConcurrentHashMap<>();
/** 每个跟踪目标最近一次特征提取所用的帧快照,仅在当前会话内短暂持有。 */
private ConcurrentHashMap<Integer, RecognitionFrameSnapshot> recognitionFrameMap = new ConcurrentHashMap<>();
private CompositeDisposable getFeatureDelayedDisposables = new CompositeDisposable();
private CompositeDisposable delayFaceTaskCompositeDisposable = new CompositeDisposable();
/**
@@ -162,6 +167,12 @@ public class FaceHelper implements FaceListener {
private List<Integer> currentTrackIdList = new ArrayList<>();
private List<FacePreviewInfo> facePreviewInfoList = new ArrayList<>();
/**
* “仅识别最大人脸”模式下当前允许进入异步识别链的跟踪编号。
* 该值随每一帧的面积排序更新,用于拦截上一目标已排队但尚未完成的结果。
*/
private volatile Integer currentRecognitionTargetTrackId;
private Disposable timerDisposable;
private FaceHelper(Builder builder) {
@@ -288,7 +299,13 @@ public class FaceHelper implements FaceListener {
stop();
}
public void stop() {
public synchronized void stop() {
sessionGeneration++;
clearTrackingState();
}
private void clearTrackingState() {
currentRecognitionTargetTrackId = null;
if (faceInfoList != null) {
faceInfoList.clear();
}
@@ -298,17 +315,33 @@ public class FaceHelper implements FaceListener {
if (recognizeInfoMap != null) {
recognizeInfoMap.clear();
}
if (recognitionFrameMap != null) {
recognitionFrameMap.clear();
}
if (facePreviewInfoList != null) {
facePreviewInfoList.clear();
}
}
/**
* 新顾客开始时复用既有识别线程和 FaceEngine,只清除跟踪状态与延迟任务。
* 异步特征任务会校验代次,上一位顾客已在执行的任务即使晚到也不会上报结果。
*/
public synchronized void beginSession(long generation, RecognizeCallback callback) {
sessionGeneration = generation;
recognizeCallback = callback;
if (getFeatureDelayedDisposables != null) getFeatureDelayedDisposables.clear();
if (delayFaceTaskCompositeDisposable != null) delayFaceTaskCompositeDisposable.clear();
if (timerDisposable != null && !timerDisposable.isDisposed()) timerDisposable.dispose();
clearTrackingState();
}
/**
* 处理帧数据
*
* @param rgbNv21 可见光相机预览回传的NV21数据
* @param irNv21 红外相机预览回传的NV21数据
* @param doRecognize 是否进行识别
* @param doRecognize 是否进入口罩、活体、特征提取和比对链;false 时只做人脸检测与跟踪
* @return 实时人脸处理结果,封装添加了一个trackId,trackId的获取依赖于faceId,用于记录人脸序号并保存
*/
public List<FacePreviewInfo> onPreviewFrame(@NonNull byte[] rgbNv21, @Nullable byte[] irNv21, boolean doRecognize) {
@@ -321,14 +354,15 @@ public class FaceHelper implements FaceListener {
onFail(new Exception("detectFaces failed,code is " + code));
return facePreviewInfoList;
}
if (recognizeConfiguration.isKeepMaxFace()) {
keepMaxFace(faceInfoList);
}
refreshTrackId(faceInfoList);
if (faceInfoList.isEmpty()) {
updateRecognitionTarget(null);
clearLeftFace(facePreviewInfoList);
return facePreviewInfoList;
}
if (!onlyDetectLiveness) {
// 结果展示期只需要实时框,跳过口罩与后续识别链可以降低常驻 Camera 的 CPU 占用,
// 也避免口罩引擎的单帧异常把已经成功检测到的人脸误报为空列表。
if (doRecognize && !onlyDetectLiveness) {
code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList,
FaceEngine.ASF_MASK_DETECT);
if (code == ErrorInfo.MOK) {
@@ -406,6 +440,7 @@ public class FaceHelper implements FaceListener {
}
if (!contained) {
RecognizeInfo recognizeInfo = recognizeInfoMap.remove(key);
recognitionFrameMap.remove(key);
if (recognizeInfo != null) {
recognizeCallback.onNoticeChanged("");
// 人脸离开时,通知特征提取线程,避免一直等待活体结果
@@ -417,13 +452,29 @@ public class FaceHelper implements FaceListener {
}
}
/**
* 对当前帧允许参与识别的人脸执行质量、活体和特征提取流程。
* “仅识别最大人脸”只收窄识别列表,不修改原始预览列表,因此 Flutter 仍能绘制其他人脸。
*
* @param rgbNv21 可见光相机当前帧数据
* @param irNv21 红外相机当前帧数据,单摄像头时为空
* @param facePreviewInfoList 当前帧全部人脸预览信息
*/
private void doRecognize(byte[] rgbNv21, byte[] irNv21, List<FacePreviewInfo> facePreviewInfoList) {
if (facePreviewInfoList != null && !facePreviewInfoList.isEmpty() && previewSize != null) {
for (FaceRecognizeFilter faceRecognizeFilter : faceRecognizeFilterList) {
faceRecognizeFilter.filter(facePreviewInfoList);
List<FacePreviewInfo> recognitionFaceList = recognitionFaces(facePreviewInfoList);
if (recognitionFaceList.isEmpty()) {
updateRecognitionTarget(null);
return;
}
for (int i = 0; i < facePreviewInfoList.size(); i++) {
FacePreviewInfo facePreviewInfo = facePreviewInfoList.get(i);
updateRecognitionTarget(recognizeConfiguration.isKeepMaxFace()
? recognitionFaceList.get(0).getTrackId()
: null);
for (FaceRecognizeFilter faceRecognizeFilter : faceRecognizeFilterList) {
faceRecognizeFilter.filter(recognitionFaceList);
}
for (int i = 0; i < recognitionFaceList.size(); i++) {
FacePreviewInfo facePreviewInfo = recognitionFaceList.get(i);
if (!facePreviewInfo.isQualityPass()) {
continue;
}
@@ -466,9 +517,69 @@ public class FaceHelper implements FaceListener {
);
}
}
} else {
updateRecognitionTarget(null);
}
}
/**
* 根据当前策略返回参与识别的人脸列表;最大脸按矩形面积而不是单边长度判断。
*
* @param previewFaces 当前帧全部预览人脸
* @return 多人模式返回原列表,仅识别最大人脸时返回只含最大面积人脸的新列表
*/
private List<FacePreviewInfo> recognitionFaces(List<FacePreviewInfo> previewFaces) {
if (!recognizeConfiguration.isKeepMaxFace() || previewFaces.size() <= 1) {
return previewFaces;
}
FacePreviewInfo largestFace = previewFaces.get(0);
for (FacePreviewInfo face : previewFaces) {
if (faceArea(face.getFaceInfoRgb().getRect()) > faceArea(largestFace.getFaceInfoRgb().getRect())) {
largestFace = face;
}
}
List<FacePreviewInfo> result = new ArrayList<>(1);
result.add(largestFace);
return result;
}
/**
* 更新当前最大脸目标,并将刚退出目标位的人脸恢复为可重试状态。
* 这样目标再次成为最大脸时不会停留在已经没有异步任务的 SEARCHING 状态。
*
* @param trackId 当前最大脸跟踪编号;无目标或多人识别模式时为空
*/
private void updateRecognitionTarget(@Nullable Integer trackId) {
Integer previousTrackId = currentRecognitionTargetTrackId;
if (Objects.equals(previousTrackId, trackId)) {
return;
}
currentRecognitionTargetTrackId = trackId;
if (recognizeConfiguration.isKeepMaxFace() && previousTrackId != null) {
RecognizeInfo previousInfo = recognizeInfoMap.get(previousTrackId);
if (previousInfo != null) {
previousInfo.setRecognizeStatus(RequestFeatureStatus.TO_RETRY);
previousInfo.setLiveness(LivenessInfo.UNKNOWN);
synchronized (previousInfo.getWaitLock()) {
previousInfo.getWaitLock().notifyAll();
}
}
}
}
/**
* 判断异步任务是否仍属于当前会话且仍是允许识别的目标脸。
*
* @param trackId 异步任务对应的人脸跟踪编号
* @param generation 创建异步任务时的业务会话代次
* @return 任务结果仍可进入识别回调时返回 true
*/
private boolean isCurrentRecognitionTarget(@Nullable Integer trackId, long generation) {
return generation == sessionGeneration
&& (!recognizeConfiguration.isKeepMaxFace()
|| Objects.equals(currentRecognitionTargetTrackId, trackId));
}
@Override
public void onFail(Exception e) {
Log.e(TAG, "onFail:" + e.getMessage());
@@ -492,6 +603,9 @@ public class FaceHelper implements FaceListener {
@Override
public void onFaceFeatureInfoGet(@Nullable FaceFeature faceFeature, Integer trackId, Integer errorCode) {
if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
return;
}
//FR成功
RecognizeInfo recognizeInfo = getRecognizeInfo(recognizeInfoMap, trackId);
if (faceFeature != null) {
@@ -610,6 +724,9 @@ public class FaceHelper implements FaceListener {
@Override
public void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId, Integer errorCode) {
if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
return;
}
if (livenessInfo != null) {
int liveness = livenessInfo.getLiveness();
Log.i(TAG, "onFaceLivenessInfoGet liveness:" + liveness);
@@ -649,14 +766,20 @@ public class FaceHelper implements FaceListener {
}
private void searchFace(final FaceFeature faceFeature, final Integer trackId) {
if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
return;
}
CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature);
if (compareResult == null || compareResult.getFaceEntity() == null) {
retryRecognizeDelayed(trackId);
return;
}
compareResult.setTrackId(trackId);
// 相似度属于当前跟踪人脸,先保存再判断阈值,预览框才能展示真实的最近一次比对结果。
setSimilarity(trackId, compareResult.getSimilar());
boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold();
recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass);
recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass,
recognitionFrameMap.get(trackId));
if (pass) {
String msg = "识别通过";
Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";");
@@ -688,6 +811,7 @@ public class FaceHelper implements FaceListener {
private Integer trackId;
private byte[] nv21Data;
private int isMask;
private final long generation;
/**
* 异步特征提取任务的构造函数
@@ -699,20 +823,26 @@ public class FaceHelper implements FaceListener {
* @param format 图像格式
*/
private FaceRecognizeRunnable(byte[] nv21Data, FacePreviewInfo facePreviewInfo, int width, int height, int format) {
this.generation = sessionGeneration;
if (nv21Data == null) {
return;
}
this.nv21Data = nv21Data;
// Camera1 使用回调缓冲区复用帧内存,异步特征提取必须持有自己的快照,
// 否则下一帧会覆盖当前顾客数据,连续识别时可能出现错人或提取失败。
this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length);
this.faceInfo = new FaceInfo(facePreviewInfo.getFaceInfoRgb());
this.width = width;
this.height = height;
this.format = format;
this.trackId = facePreviewInfo.getTrackId();
this.isMask = facePreviewInfo.getMask();
recognitionFrameMap.put(this.trackId, new RecognitionFrameSnapshot(this.nv21Data, this.faceInfo,
this.width, this.height, System.currentTimeMillis()));
}
@Override
public void run() {
if (!isCurrentRecognitionTarget(trackId, generation)) return;
if (nv21Data != null) {
if (frEngine != null) {
if (recognizeConfiguration.isEnableImageQuality()) {
@@ -763,14 +893,20 @@ public class FaceHelper implements FaceListener {
}
Log.i(TAG, "frTime:" + (System.currentTimeMillis() - irStartTime) + "ms");
if (frCode == ErrorInfo.MOK) {
onFaceFeatureInfoGet(faceFeature, trackId, frCode);
synchronized (FaceHelper.this) {
if (!isCurrentRecognitionTarget(trackId, generation)) return;
onFaceFeatureInfoGet(faceFeature, trackId, frCode);
}
} else {
onFaceFail(frCode, "fr failed errorCode is " + frCode);
}
}
private void onFaceFail(int code, String errorMsg) {
onFaceFeatureInfoGet(null, trackId, code);
synchronized (FaceHelper.this) {
if (!isCurrentRecognitionTarget(trackId, generation)) return;
onFaceFeatureInfoGet(null, trackId, code);
}
onFail(new Exception(errorMsg));
}
}
@@ -788,10 +924,13 @@ public class FaceHelper implements FaceListener {
private LivenessType livenessType;
private Object waitLock;
/** 创建活体任务时的会话代次,用于丢弃上一位顾客或上一目标脸的迟到结果。 */
private final long generation;
/**
* 异步活体任务的构造函数
*
* @param nv21Data 可见光或红外图像数据
* @param nv21Data 可见光或红外图像数据;构造时复制,避免 Camera 复用缓冲区覆盖异步任务
* @param faceInfo 可见光人脸检测得到的人脸信息
* @param width 图像宽度
* @param height 图像高度
@@ -800,10 +939,11 @@ public class FaceHelper implements FaceListener {
* @param waitLock 活体检测通过后,调用该对象的notifyAll函数,通知识别线程活体已通过
*/
private FaceLivenessDetectRunnable(byte[] nv21Data, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) {
this.generation = sessionGeneration;
if (nv21Data == null) {
return;
}
this.nv21Data = nv21Data;
this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length);
this.faceInfo = new FaceInfo(faceInfo.getFaceInfoRgb());
this.width = width;
this.height = height;
@@ -815,6 +955,7 @@ public class FaceHelper implements FaceListener {
@Override
public void run() {
if (!isCurrentRecognitionTarget(trackId, generation)) return;
if (nv21Data != null) {
if (flEngine != null) {
processLiveness();
@@ -864,6 +1005,7 @@ public class FaceHelper implements FaceListener {
}
if (flCode == ErrorInfo.MOK && !livenessInfoList.isEmpty()) {
if (!isCurrentRecognitionTarget(trackId, generation)) return;
onFaceLivenessInfoGet(livenessInfoList.get(0), trackId, flCode);
if (livenessInfoList.get(0).getLiveness() == LivenessInfo.ALIVE) {
synchronized (waitLock) {
@@ -876,6 +1018,7 @@ public class FaceHelper implements FaceListener {
}
private void onProcessFail(int code, String msg) {
if (!isCurrentRecognitionTarget(trackId, generation)) return;
onFaceLivenessInfoGet(null, trackId, code);
onFail(new Exception(msg));
}
@@ -1003,6 +1146,15 @@ public class FaceHelper implements FaceListener {
return recognizeInfo == null ? null : recognizeInfo.getName();
}
public void setSimilarity(int trackId, float similarity) {
getRecognizeInfo(recognizeInfoMap, trackId).setSimilarity(similarity);
}
public Float getSimilarity(int trackId) {
RecognizeInfo recognizeInfo = recognizeInfoMap.get(trackId);
return recognizeInfo == null ? null : recognizeInfo.getSimilarity();
}
/**
* 设置可识别区域(相对于View)
@@ -1080,7 +1232,7 @@ public class FaceHelper implements FaceListener {
}
/**
* 保留ftFaceList中最大的人脸
* 保留 ftFaceList 中面积最大的人脸。
*
* @param ftFaceList 人脸追踪时,一帧数据的人脸信息
*/
@@ -1090,7 +1242,7 @@ public class FaceHelper implements FaceListener {
}
FaceInfo maxFaceInfo = ftFaceList.get(0);
for (FaceInfo faceInfo : ftFaceList) {
if (faceInfo.getRect().width() > maxFaceInfo.getRect().width()) {
if (faceArea(faceInfo.getRect()) > faceArea(maxFaceInfo.getRect())) {
maxFaceInfo = faceInfo;
}
}
@@ -1098,6 +1250,19 @@ public class FaceHelper implements FaceListener {
ftFaceList.add(maxFaceInfo);
}
/**
* 计算原始摄像头坐标中的有效矩形面积,异常或反向矩形按 0 处理。
*
* @param rect 虹软返回的人脸矩形
* @return 人脸矩形面积,单位为预览像素平方
*/
private static long faceArea(@Nullable Rect rect) {
if (rect == null) {
return 0L;
}
return (long) Math.max(0, rect.width()) * Math.max(0, rect.height());
}
public static final class Builder {
private FaceEngine ftEngine;
@@ -2,6 +2,7 @@ package io.flutter.plugins.facediscern.tool.face.help.callback;
import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
public interface RecognizeCallback {
/**
@@ -10,8 +11,10 @@ public interface RecognizeCallback {
* @param compareResult 比对结果
* @param liveness 活体值
* @param similarPass 是否通过(依据设置的阈值)
* @param frameSnapshot 实际提取特征的 NV21 和目标人脸框;旧 Activity 链路允许为空
*/
void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass);
void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass,
RecognitionFrameSnapshot frameSnapshot);
/**
* 提示文字变更的回调
@@ -0,0 +1,62 @@
package io.flutter.plugins.facediscern.tool.face.model;
import com.arcsoft.face.FaceInfo;
import java.util.Arrays;
/**
* 人脸特征提取使用的可见光帧快照。
*
* <p>Camera1 会复用回调缓冲区,因此这里必须复制 NV21 和 FaceInfo,保证异步比对成功后
* 仍然能裁剪出同一张实际命中目标的人脸图片。</p>
*/
public final class RecognitionFrameSnapshot {
/** 原始 NV21 帧的独立副本。 */
private final byte[] nv21;
/** 该帧中实际参与特征提取的人脸框。 */
private final FaceInfo faceInfo;
/** NV21 原始宽度,单位为像素。 */
private final int width;
/** NV21 原始高度,单位为像素。 */
private final int height;
/** 相机回调采集时间,单位为 Unix 毫秒。 */
private final long capturedAt;
/** 创建一份不可变的人脸成功帧快照。 */
public RecognitionFrameSnapshot(byte[] nv21, FaceInfo faceInfo, int width, int height, long capturedAt) {
this.nv21 = nv21 == null ? null : Arrays.copyOf(nv21, nv21.length);
this.faceInfo = faceInfo == null ? null : new FaceInfo(faceInfo);
this.width = width;
this.height = height;
this.capturedAt = capturedAt;
}
/** 返回 NV21 快照副本,避免调用方修改共享内容。 */
public byte[] getNv21() {
return nv21 == null ? null : Arrays.copyOf(nv21, nv21.length);
}
/** 返回实际命中人脸框的独立副本。 */
public FaceInfo getFaceInfo() {
return faceInfo == null ? null : new FaceInfo(faceInfo);
}
/** 返回 NV21 原始宽度。 */
public int getWidth() {
return width;
}
/** 返回 NV21 原始高度。 */
public int getHeight() {
return height;
}
/** 返回相机帧采集时间。 */
public long getCapturedAt() {
return capturedAt;
}
}
@@ -6,6 +6,21 @@ import com.arcsoft.face.LivenessParam;
* 识别相关的配置项
*/
public class RecognizeConfiguration {
public static final int DEFAULT_EXTRACT_RETRY_COUNT = 3;
public static final int DEFAULT_LIVENESS_RETRY_COUNT = 3;
public static final int DEFAULT_MAX_DETECT_FACES = 1;
public static final int DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL = 0;
public static final int DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL = 0;
public static final float DEFAULT_SIMILAR_THRESHOLD = 0.8F;
public static final float DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD = 0.49F;
public static final float DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD = 0.29F;
/** 在库照片质量诊断默认阈值,只用于终端运维分类。 */
public static final float DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD = 0.60F;
public static final float DEFAULT_RGB_LIVENESS_THRESHOLD = 0.5F;
public static final boolean DEFAULT_ENABLE_LIVENESS = false;
public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true;
public static final boolean DEFAULT_KEEP_MAX_FACE = true;
/**
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
*/
@@ -30,6 +45,10 @@ public class RecognizeConfiguration {
* 图像质量检测阈值:适用于戴口罩且人脸识别场景
*/
private float imageQualityMaskRecognizeThreshold;
/**
* 在库照片质量诊断阈值,不参与实时识别和特征注册。
*/
private float libraryFaceQualityThreshold;
/**
* 识别失败重试间隔
*/
@@ -86,6 +105,7 @@ public class RecognizeConfiguration {
this.similarThreshold = builder.similarThreshold;
this.imageQualityNoMaskRecognizeThreshold = builder.imageQualityNoMaskRecognizeThreshold;
this.imageQualityMaskRecognizeThreshold = builder.imageQualityMaskRecognizeThreshold;
this.libraryFaceQualityThreshold = builder.libraryFaceQualityThreshold;
this.enableLiveness = builder.enableLiveness;
this.enableImageQuality = builder.enableImageQuality;
this.enableFaceAreaLimit = builder.enableFaceAreaLimit;
@@ -100,23 +120,25 @@ public class RecognizeConfiguration {
//TODO: demo不实现所有配置,若以下项也需要进行自定义配置,可参考其他配置项实现
public static class Builder {
private int extractRetryCount = 3;
private int livenessRetryCount = 3;
private int maxDetectFaces = 3;
private int recognizeFailedRetryInterval = 0;
private int livenessFailedRetryInterval = 0;
private float similarThreshold = 0.8f;
private float imageQualityNoMaskRecognizeThreshold = 0.49f;
private float imageQualityMaskRecognizeThreshold = 0.29f;
private boolean enableLiveness = false;
private int extractRetryCount = DEFAULT_EXTRACT_RETRY_COUNT;
private int livenessRetryCount = DEFAULT_LIVENESS_RETRY_COUNT;
private int maxDetectFaces = DEFAULT_MAX_DETECT_FACES;
private int recognizeFailedRetryInterval = DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL;
private int livenessFailedRetryInterval = DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL;
private float similarThreshold = DEFAULT_SIMILAR_THRESHOLD;
private float imageQualityNoMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD;
private float imageQualityMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD;
/** 在库照片质量诊断阈值,允许范围为0到1。 */
private float libraryFaceQualityThreshold = DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD;
private boolean enableLiveness = DEFAULT_ENABLE_LIVENESS;
private boolean enableFaceAreaLimit = false;
private boolean enableImageQuality = false;
private boolean enableImageQuality = DEFAULT_ENABLE_IMAGE_QUALITY;
private boolean enableFaceSizeLimit = false;
private boolean enableFaceMoveLimit = false;
private int faceSizeLimit = 0;
private int faceMoveLimit = 0;
private boolean keepMaxFace = false;
private LivenessParam livenessParam;
private boolean keepMaxFace = DEFAULT_KEEP_MAX_FACE;
private LivenessParam livenessParam = new LivenessParam(DEFAULT_RGB_LIVENESS_THRESHOLD, DEFAULT_RGB_LIVENESS_THRESHOLD);
public Builder recognizeFailedRetryInterval(int val) {
this.recognizeFailedRetryInterval = val;
@@ -158,6 +180,17 @@ public class RecognizeConfiguration {
return this;
}
/**
* 设置在库照片质量诊断阈值。
*
* @param val 0到1之间的质量分界值
* @return 当前构建器
*/
public Builder libraryFaceQualityThreshold(float val) {
this.libraryFaceQualityThreshold = val;
return this;
}
public Builder enableLiveness(boolean val) {
this.enableLiveness = val;
return this;
@@ -217,10 +250,51 @@ public class RecognizeConfiguration {
return imageQualityMaskRecognizeThreshold;
}
/**
* 读取在库照片质量诊断阈值。
*
* @return 0到1之间的质量分界值
*/
public float getLibraryFaceQualityThreshold() {
return libraryFaceQualityThreshold;
}
public boolean isEnableImageQuality() {
return enableImageQuality;
}
/**
* 创建仅切换实时图像质量能力的运行时配置,其他现场参数保持不变。
*
* <p>该副本不会写回 SharedPreferences,专供虹软质量增值模块初始化失败时
* 降级本次识别会话,避免授权差异阻断原有特征提取和比对。</p>
*
* @param enabled 本次识别会话是否执行实时图像质量检测
* @return 保留其余识别参数的新配置
*/
public RecognizeConfiguration withImageQualityEnabled(boolean enabled) {
return new Builder()
.extractRetryCount(extractRetryCount)
.livenessRetryCount(livenessRetryCount)
.maxDetectFaces(maxDetectFaces)
.recognizeFailedRetryInterval(recognizeFailedRetryInterval)
.livenessFailedRetryInterval(livenessFailedRetryInterval)
.similarThreshold(similarThreshold)
.imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold)
.imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold)
.libraryFaceQualityThreshold(libraryFaceQualityThreshold)
.enableLiveness(enableLiveness)
.enableImageQuality(enabled)
.enableFaceAreaLimit(enableFaceAreaLimit)
.keepMaxFace(keepMaxFace)
.livenessParam(livenessParam)
.enableFaceSizeLimit(enableFaceSizeLimit)
.enableFaceMoveLimit(enableFaceMoveLimit)
.faceSizeLimit(faceSizeLimit)
.faceMoveLimit(faceMoveLimit)
.build();
}
public boolean isEnableFaceAreaLimit() {
return enableFaceAreaLimit;
}
@@ -291,6 +365,7 @@ public class RecognizeConfiguration {
"enableImageQuality: " + enableImageQuality + "\r\n" +
"imageQualityNoMaskRecognizeThreshold: " + imageQualityNoMaskRecognizeThreshold + "\r\n" +
"imageQualityMaskRecognizeThreshold: " + imageQualityMaskRecognizeThreshold + "\r\n" +
"libraryFaceQualityThreshold: " + libraryFaceQualityThreshold + "\r\n" +
"enableLiveness: " + enableLiveness + "\r\n" +
"livenessRetryCount: " + livenessRetryCount + "\r\n" +
@@ -0,0 +1,142 @@
package io.flutter.plugins.facediscern.tool.face.model;
import android.content.Context;
import android.content.SharedPreferences;
import com.arcsoft.face.LivenessParam;
import java.util.HashMap;
import java.util.Map;
/**
* 保存终端本机的人脸识别策略,不写入业务数据库,避免配置升级影响 Floor 与人脸共享库。
*/
public final class RecognizeConfigurationStore {
private static final String PREFERENCES_NAME = "face_recognize_configuration";
private RecognizeConfigurationStore() {
}
public static RecognizeConfiguration load(Context context) {
SharedPreferences preferences = preferences(context);
return new RecognizeConfiguration.Builder()
.enableLiveness(preferences.getBoolean("enableLiveness", RecognizeConfiguration.DEFAULT_ENABLE_LIVENESS))
.similarThreshold(preferences.getFloat("similarThreshold", RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD))
.livenessParam(new LivenessParam(
preferences.getFloat("rgbLivenessThreshold", RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD),
RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD))
.enableImageQuality(preferences.getBoolean("enableImageQuality", RecognizeConfiguration.DEFAULT_ENABLE_IMAGE_QUALITY))
.imageQualityNoMaskRecognizeThreshold(preferences.getFloat("imageQualityNoMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD))
.imageQualityMaskRecognizeThreshold(preferences.getFloat("imageQualityMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD))
.libraryFaceQualityThreshold(preferences.getFloat("libraryFaceQualityThreshold", RecognizeConfiguration.DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD))
.keepMaxFace(preferences.getBoolean("keepMaxFace", RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE))
.maxDetectFaces(preferences.getInt("maxDetectFaces", RecognizeConfiguration.DEFAULT_MAX_DETECT_FACES))
.extractRetryCount(preferences.getInt("extractRetryCount", RecognizeConfiguration.DEFAULT_EXTRACT_RETRY_COUNT))
.livenessRetryCount(preferences.getInt("livenessRetryCount", RecognizeConfiguration.DEFAULT_LIVENESS_RETRY_COUNT))
.recognizeFailedRetryInterval(preferences.getInt("recognizeFailedRetryInterval", RecognizeConfiguration.DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL))
.livenessFailedRetryInterval(preferences.getInt("livenessFailedRetryInterval", RecognizeConfiguration.DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL))
.build();
}
/**
* Flutter 只传允许现场调整的白名单字段,原生再次校验范围,防止异常参数拖垮识别线程。
*/
public static RecognizeConfiguration save(Context context, Map<?, ?> values) {
RecognizeConfiguration current = load(context);
boolean enableLiveness = booleanValue(values, "enableLiveness", current.isEnableLiveness());
float similarThreshold = floatValue(values, "similarThreshold", current.getSimilarThreshold(), 0.5F, 0.99F);
float rgbLivenessThreshold = floatValue(values, "rgbLivenessThreshold", rgbLivenessThreshold(current), 0.3F, 0.9F);
boolean enableImageQuality = booleanValue(values, "enableImageQuality", current.isEnableImageQuality());
float noMaskThreshold = floatValue(values, "imageQualityNoMaskRecognizeThreshold", current.getImageQualityNoMaskRecognizeThreshold(), 0F, 1F);
float maskThreshold = floatValue(values, "imageQualityMaskRecognizeThreshold", current.getImageQualityMaskRecognizeThreshold(), 0F, 1F);
float libraryFaceQualityThreshold = floatValue(values, "libraryFaceQualityThreshold", current.getLibraryFaceQualityThreshold(), 0F, 1F);
boolean keepMaxFace = booleanValue(values, "keepMaxFace", current.isKeepMaxFace());
int maxDetectFaces = intValue(values, "maxDetectFaces", current.getMaxDetectFaces(), 1, 3);
int extractRetryCount = intValue(values, "extractRetryCount", current.getExtractRetryCount(), 0, 10);
int livenessRetryCount = intValue(values, "livenessRetryCount", current.getLivenessRetryCount(), 0, 10);
int recognizeRetryInterval = intValue(values, "recognizeFailedRetryInterval", current.getRecognizeFailedRetryInterval(), 0, 5000);
int livenessRetryInterval = intValue(values, "livenessFailedRetryInterval", current.getLivenessFailedRetryInterval(), 0, 5000);
boolean saved = preferences(context).edit()
.putBoolean("enableLiveness", enableLiveness)
.putFloat("similarThreshold", similarThreshold)
.putFloat("rgbLivenessThreshold", rgbLivenessThreshold)
.putBoolean("enableImageQuality", enableImageQuality)
.putFloat("imageQualityNoMaskRecognizeThreshold", noMaskThreshold)
.putFloat("imageQualityMaskRecognizeThreshold", maskThreshold)
.putFloat("libraryFaceQualityThreshold", libraryFaceQualityThreshold)
.putBoolean("keepMaxFace", keepMaxFace)
.putInt("maxDetectFaces", maxDetectFaces)
.putInt("extractRetryCount", extractRetryCount)
.putInt("livenessRetryCount", livenessRetryCount)
.putInt("recognizeFailedRetryInterval", recognizeRetryInterval)
.putInt("livenessFailedRetryInterval", livenessRetryInterval)
.commit();
if (!saved) throw new IllegalStateException("人脸识别配置保存失败");
return load(context);
}
public static RecognizeConfiguration reset(Context context) {
if (!preferences(context).edit().clear().commit()) {
throw new IllegalStateException("人脸识别配置恢复失败");
}
return load(context);
}
public static Map<String, Object> toMap(RecognizeConfiguration configuration) {
Map<String, Object> result = new HashMap<>();
result.put("enableLiveness", configuration.isEnableLiveness());
result.put("similarThreshold", configuration.getSimilarThreshold());
result.put("rgbLivenessThreshold", rgbLivenessThreshold(configuration));
result.put("enableImageQuality", configuration.isEnableImageQuality());
result.put("imageQualityNoMaskRecognizeThreshold", configuration.getImageQualityNoMaskRecognizeThreshold());
result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold());
result.put("libraryFaceQualityThreshold", configuration.getLibraryFaceQualityThreshold());
result.put("keepMaxFace", configuration.isKeepMaxFace());
result.put("maxDetectFaces", configuration.getMaxDetectFaces());
result.put("extractRetryCount", configuration.getExtractRetryCount());
result.put("livenessRetryCount", configuration.getLivenessRetryCount());
result.put("recognizeFailedRetryInterval", configuration.getRecognizeFailedRetryInterval());
result.put("livenessFailedRetryInterval", configuration.getLivenessFailedRetryInterval());
result.put("livenessType", "RGB");
return result;
}
private static SharedPreferences preferences(Context context) {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE);
}
private static float rgbLivenessThreshold(RecognizeConfiguration configuration) {
LivenessParam param = configuration.getLivenessParam();
return param == null ? RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD : param.getRgbThreshold();
}
private static boolean booleanValue(Map<?, ?> values, String key, boolean fallback) {
Object value = values == null ? null : values.get(key);
if (value == null) return fallback;
if (!(value instanceof Boolean)) throw new IllegalArgumentException(key + " 必须为布尔值");
return (Boolean) value;
}
private static float floatValue(Map<?, ?> values, String key, float fallback, float min, float max) {
Object value = values == null ? null : values.get(key);
if (value == null) return fallback;
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为数字");
float result = ((Number) value).floatValue();
if (Float.isNaN(result) || Float.isInfinite(result) || result < min || result > max) {
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
}
return result;
}
private static int intValue(Map<?, ?> values, String key, int fallback, int min, int max) {
Object value = values == null ? null : values.get(key);
if (value == null) return fallback;
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为整数");
int result = ((Number) value).intValue();
if (result < min || result > max) {
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
}
return result;
}
}
@@ -19,6 +19,8 @@ public class RecognizeInfo {
* 用于存储活体值
*/
private int liveness = LivenessInfo.UNKNOWN;
/** 最近一次人脸库比对相似度;尚未完成比对时为空。 */
private volatile Float similarity;
/**
* 用于存储活体检测出错重试次数
*/
@@ -52,6 +54,14 @@ public class RecognizeInfo {
return liveness;
}
public Float getSimilarity() {
return similarity;
}
public void setSimilarity(Float similarity) {
this.similarity = similarity;
}
public int increaseAndGetLivenessErrorRetryCount() {
return ++livenessErrorRetryCount;
}
@@ -83,4 +93,4 @@ public class RecognizeInfo {
public int getLivenessErrorRetryCount() {
return livenessErrorRetryCount;
}
}
}
@@ -11,6 +11,7 @@ import com.arcsoft.face.FaceEngine;
import com.arcsoft.face.FaceFeature;
import com.arcsoft.face.FaceFeatureInfo;
import com.arcsoft.face.FaceInfo;
import com.arcsoft.face.ImageQualitySimilar;
import com.arcsoft.face.MaskInfo;
import com.arcsoft.face.SearchResult;
import com.arcsoft.face.enums.DetectFaceOrientPriority;
@@ -25,8 +26,13 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
@@ -38,7 +44,10 @@ public class FaceServer {
private static final String TAG = "FaceServer";
private static FaceEngine faceEngine = null;
private static volatile FaceServer faceServer = null;
private List<FaceEntity> faceRegisterInfoList = new ArrayList<>();
private final Map<Integer, FaceEntity> faceRegisterInfoMap = new HashMap<>();
private int engineInitCode = -99;
private int imageQualityInitCode = -99;
private boolean imageQualityReady;
private String imageRootPath;
/**
* 最大注册人脸数
@@ -48,19 +57,57 @@ public class FaceServer {
public synchronized void init(Context context) {
if (faceEngine == null && context != null) {
faceEngine = new FaceEngine();
int engineCode = faceEngine.init(
int requestedCode = faceEngine.init(
context,
DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
1,
FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT
FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT | FaceEngine.ASF_IMAGEQUALITY
);
if (engineCode != ErrorInfo.MOK) {
Log.e(TAG, "人脸引擎初始化失败code" + engineCode);
imageQualityInitCode = requestedCode;
if (requestedCode == ErrorInfo.MOK) {
engineInitCode = requestedCode;
imageQualityReady = true;
} else {
Log.w(TAG, "图像质量模块不可用,回退基础人脸引擎code" + requestedCode);
faceEngine.unInit();
faceEngine = null;
faceEngine = new FaceEngine();
engineInitCode = faceEngine.init(
context,
DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
1,
FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT
);
imageQualityReady = false;
if (engineInitCode != ErrorInfo.MOK) {
Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode);
faceEngine.unInit();
faceEngine = null;
}
}
}
}
public synchronized int getEngineInitCode() {
return engineInitCode;
}
public synchronized boolean isEngineReady() {
return faceEngine != null && engineInitCode == ErrorInfo.MOK;
}
/** 返回当前授权是否包含虹软图像质量模块。 */
public synchronized boolean isImageQualityReady() {
return imageQualityReady;
}
/** 返回图像质量模块初始化返回码,用于终端诊断授权状态。 */
public synchronized int getImageQualityInitCode() {
return imageQualityInitCode;
}
public static FaceServer getInstance() {
if (faceServer == null) {
synchronized (FaceServer.class) {
@@ -76,58 +123,142 @@ public class FaceServer {
* 销毁
*/
public synchronized void release() {
if (faceRegisterInfoList != null) {
faceRegisterInfoList.clear();
faceRegisterInfoList = null;
}
faceRegisterInfoMap.clear();
if (faceEngine != null) {
synchronized (faceEngine) {
faceEngine.unInit();
}
faceEngine = null;
}
engineInitCode = -99;
imageQualityInitCode = -99;
imageQualityReady = false;
faceServer = null;
}
public int registerFaceFeatureInfoListFromDb(List<FaceEntity> faceEntityList) {
/**
* 全量重建使用:先清空旧内存库,再按调用方传入的数据重新装载。
*/
public synchronized int registerFaceFeatureInfoListFromDb(List<FaceEntity> faceEntityList) {
if (faceEngine == null) return -99;
faceEngine.removeFaceFeature(-1);
faceRegisterInfoMap.clear();
return registerFaceFeatureInfoBatchFromDb(faceEntityList);
}
/**
* 只追加当前批次,不清空其他人脸;用于分批全量装载和单用户增量替换。
*/
public synchronized int registerFaceFeatureInfoBatchFromDb(List<FaceEntity> faceEntityList) {
if (faceEngine == null || faceEntityList == null || faceEntityList.isEmpty()) return -99;
List<FaceFeatureInfo> faceFeatureInfoList = new ArrayList<>();
for (FaceEntity faceEntity : faceEntityList) {
FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo((int) faceEntity.getFaceId(), faceEntity.getFaceModel());
faceFeatureInfoList.add(faceFeatureInfo);
}
//首先清除FaceEngine中所有人脸数据,再添加新的人脸数据
faceEngine.removeFaceFeature(-1);
int code = faceEngine.registerFaceFeature(faceFeatureInfoList);
if (code == ErrorInfo.MOK) {
faceRegisterInfoList.addAll(faceEntityList);
for (FaceEntity faceEntity : faceEntityList) {
faceRegisterInfoMap.put(faceEntity.getFaceId(), faceEntity);
}
}
return code;
}
// 注册单个人脸数据
public int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) {
if (faceEntity != null && faceEngine != null) {
FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo(faceEntity.getFaceId(), faceEntity.getFaceModel());
int code = faceEngine.registerFaceFeature(faceFeatureInfo);
if (code == ErrorInfo.MOK) {
faceEntity.setFaceModel(faceFeatureInfo.getFeatureData());
// 查询一下确保入库成功
FaceFeatureInfo afterDetail = getFaceFeature(faceEntity.getFaceId());
if (Objects.nonNull(afterDetail)) {
long count = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceEntity.getFaceId())).count();
if (count == 0) {
faceRegisterInfoList.add(faceEntity);
}
return code;
}
}
/**
* 在同一虹软引擎互斥边界内原子替换单个搜索标识的人脸特征。
*
* <p>替换前复制旧特征和业务元数据;新特征注册失败或 SDK 抛异常时恢复旧特征,
* 避免坏图导致原本可识别的人脸永久丢失。</p>
*
* @param faceEntity 包含搜索标识、用户元数据和新特征的目标人脸
* @return 虹软返回码,插件异常返回内部错误码 {@code -99}
*/
public synchronized int replaceFaceFeature(FaceEntity faceEntity) {
if (faceEntity == null || faceEngine == null || faceEntity.getFaceId() == null || faceEntity.getFaceModel() == null) {
return -99;
}
return -99;
int searchId = faceEntity.getFaceId();
FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId));
try {
FaceFeatureInfo oldFeature = getFaceFeature(searchId);
if (oldEntity == null && oldFeature != null) {
oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData());
}
faceEngine.removeFaceFeature(searchId);
faceRegisterInfoMap.remove(searchId);
FaceFeatureInfo newFeature = new FaceFeatureInfo(searchId, faceEntity.getFaceModel());
int code = faceEngine.registerFaceFeature(Collections.singletonList(newFeature));
if (code == ErrorInfo.MOK && getFaceFeature(searchId) != null) {
faceRegisterInfoMap.put(searchId, faceEntity);
return code;
}
restoreFaceFeature(searchId, oldEntity);
return code == ErrorInfo.MOK ? -99 : code;
} catch (Exception exception) {
Log.e(TAG, "替换人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception);
restoreFaceFeature(searchId, oldEntity);
return -99;
}
}
/**
* 返回当前虹软内存库已登记的搜索标识快照,用于集合一致性判断。
*
* @return 与内部映射解耦的搜索标识集合
*/
public synchronized Set<Integer> getRegisteredFaceIds() {
return new HashSet<>(faceRegisterInfoMap.keySet());
}
/**
* 恢复单样本替换前的人脸特征;没有旧特征时保持目标搜索标识为空。
*
* @param searchId 需要恢复的虹软搜索标识
* @param oldEntity 替换前的特征和元数据,新增场景为空
*/
private void restoreFaceFeature(int searchId, FaceEntity oldEntity) {
try {
faceEngine.removeFaceFeature(searchId);
faceRegisterInfoMap.remove(searchId);
if (oldEntity == null || oldEntity.getFaceModel() == null) return;
FaceFeatureInfo oldFeature = new FaceFeatureInfo(searchId, oldEntity.getFaceModel());
if (faceEngine.registerFaceFeature(Collections.singletonList(oldFeature)) == ErrorInfo.MOK) {
faceRegisterInfoMap.put(searchId, oldEntity);
} else {
Log.e(TAG, "恢复旧人脸特征失败searchId:" + searchId);
}
} catch (Exception exception) {
Log.e(TAG, "恢复旧人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception);
}
}
/**
* 深拷贝内存库中的人脸业务元数据和特征,避免回滚快照被后续修改。
*
* @param source 待复制的人脸记录
* @return 独立副本,源记录为空时返回空
*/
private FaceEntity copyFaceEntity(FaceEntity source) {
if (source == null) return null;
FaceEntity copy = new FaceEntity();
copy.setFaceId(source.getFaceId());
copy.setSampleId(source.getSampleId());
copy.setUserId(source.getUserId());
copy.setUserName(source.getUserName());
copy.setFaceFilePath(source.getFaceFilePath());
copy.setFaceModel(source.getFaceModel() == null ? null : source.getFaceModel().clone());
return copy;
}
// 注册单个人脸数据
public synchronized int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) {
// 历史单条入口也复用带旧特征回滚的替换逻辑,避免 SDK 异常后丢失原可用样本。
return replaceFaceFeature(faceEntity);
}
// 从人脸数据库中搜索人脸
public FaceFeatureInfo getFaceFeature(int searchId) {
public synchronized FaceFeatureInfo getFaceFeature(int searchId) {
FaceFeatureInfo faceFeatureInfo = null;
try {
faceFeatureInfo = faceEngine.getFaceFeature(searchId);
@@ -138,19 +269,49 @@ public class FaceServer {
}
// 获取人脸数量
public int getTotalFaceCount() {
public synchronized int getTotalFaceCount() {
if (faceEngine == null) return -99;
return faceEngine.getFaceCount();
}
// 删除人脸-1删除全部
public int removeFace(int searchId) {
public synchronized int removeFace(int searchId) {
if (faceEngine == null) return -99;
return faceEngine.removeFaceFeature(searchId);
// MQ 和每日快照都可能重复删除同一样本;内存库已不存在时直接按幂等成功处理。
if (searchId != -1 && getFaceFeature(searchId) == null) {
faceRegisterInfoMap.remove(searchId);
return ErrorInfo.MOK;
}
int code = faceEngine.removeFaceFeature(searchId);
if (code == ErrorInfo.MOK) {
if (searchId == -1) {
faceRegisterInfoMap.clear();
} else {
faceRegisterInfoMap.remove(searchId);
}
}
return code;
}
/**
* 增量更新时仅移除该用户已注册的样本,避免同步单人后覆盖整个内存库。
*/
public synchronized void removeFacesByUserId(String userId) {
if (faceEngine == null || userId == null) return;
List<Integer> faceIds = new ArrayList<>();
for (Map.Entry<Integer, FaceEntity> entry : faceRegisterInfoMap.entrySet()) {
if (Objects.equals(userId, entry.getValue().getUserId())) {
faceIds.add(entry.getKey());
}
}
for (Integer faceId : faceIds) {
faceEngine.removeFaceFeature(faceId);
faceRegisterInfoMap.remove(faceId);
}
}
// 在特征库中搜索
public CompareResult searchFaceFeature(FaceFeature faceFeature) {
public synchronized CompareResult searchFaceFeature(FaceFeature faceFeature) {
if (faceFeature == null) return null;
if (faceEngine == null) return null;
SearchResult searchResult = null;
@@ -160,8 +321,8 @@ public class FaceServer {
long searchCost = System.currentTimeMillis() - searchStart;
if (searchResult != null) {
FaceFeatureInfo faceFeatureInfo = searchResult.getFaceFeatureInfo();
FaceEntity entity = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceFeatureInfo.getSearchId())).findFirst().get();
if (Objects.isNull(entity)) {
FaceEntity entity = faceRegisterInfoMap.get(faceFeatureInfo.getSearchId());
if (entity == null) {
entity = new FaceEntity(faceFeatureInfo.getSearchId(), faceFeatureInfo.getFeatureData());
}
Log.i(TAG, "查询人脸总耗时:" + (System.currentTimeMillis() - searchStart) + "ms;username:" + entity.getUserName() + ";faceId:" + entity.getFaceId() + ";");
@@ -174,10 +335,23 @@ public class FaceServer {
}
public byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) {
/** 保持旧调用方返回特征字节的兼容入口。 */
public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) {
return registerBgr24WithQuality(context, bgr24, width, height, name).featureData;
}
/**
* 在统一解码结果上同时执行图像质量检测和虹软特征提取。
*
* <p>质量模块不可用或检测失败时仅记录failed状态,仍继续原有特征提取,兼容免费版授权。</p>
*/
public synchronized QualityFeatureResult registerBgr24WithQuality(Context context, byte[] bgr24, int width, int height, String name) {
QualityFeatureResult qualityResult = new QualityFeatureResult();
if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) {
Log.e(TAG, "registerBgr24: invalid params");
return null;
qualityResult.qualityStatus = "failed";
qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
return qualityResult;
}
//人脸检测
List<FaceInfo> faceInfoList = new ArrayList<>();
@@ -186,6 +360,25 @@ public class FaceServer {
code = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList);
}
if (code == ErrorInfo.MOK && !faceInfoList.isEmpty()) {
if (imageQualityReady) {
ImageQualitySimilar qualitySimilar = new ImageQualitySimilar();
int qualityCode;
synchronized (faceEngine) {
qualityCode = faceEngine.imageQualityDetect(
bgr24, width, height, FaceEngine.CP_PAF_BGR24,
faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar);
}
if (qualityCode == ErrorInfo.MOK) {
qualityResult.qualityStatus = "detected";
qualityResult.qualityScore = (double) qualitySimilar.getScore();
} else {
qualityResult.qualityStatus = "failed";
qualityResult.qualityErrorCode = qualityCode;
}
} else {
qualityResult.qualityStatus = "failed";
qualityResult.qualityErrorCode = imageQualityInitCode;
}
code = faceEngine.process(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList,
FaceEngine.ASF_MASK_DETECT);
if (code == ErrorInfo.MOK) {
@@ -198,16 +391,18 @@ public class FaceServer {
* 注册照要求不戴口罩
*/
Log.e(TAG, "registerBgr24: maskInfo is worn");
return null;
return qualityResult;
}
if (isMask == MaskInfo.UNKNOWN) {
Log.e(TAG, "registerBgr24: maskInfo is unknown");
return null;
return qualityResult;
}
}
} else {
Log.e(TAG, "registerBgr24 mask process fail res=" + code);
return null;
qualityResult.qualityStatus = "failed";
qualityResult.qualityErrorCode = code;
return qualityResult;
}
FaceFeature faceFeature = new FaceFeature();
/*
@@ -224,7 +419,9 @@ public class FaceServer {
Rect cropRect = getBestRect(width, height, faceInfoList.get(0).getRect());
if (cropRect == null) {
Log.e(TAG, "registerBgr24: cropRect is null");
return null;
qualityResult.qualityStatus = "failed";
qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
return qualityResult;
}
cropRect.left &= ~3;
cropRect.top &= ~3;
@@ -233,25 +430,83 @@ public class FaceServer {
String imgPath = getImagePath(userName, context);
// 创建一个头像的Bitmap,存放旋转结果图
Bitmap headBmp = getHeadImage(bgr24, width, height, faceInfoList.get(0).getOrient(), cropRect, ArcSoftImageFormat.BGR24);
try {
FileOutputStream fos = new FileOutputStream(imgPath);
try (FileOutputStream fos = new FileOutputStream(imgPath)) {
headBmp.compress(Bitmap.CompressFormat.JPEG, 100, fos);
fos.close();
} catch (IOException e) {
e.printStackTrace();
return null;
Log.e(TAG, "registerBgr24: save head image failed", e);
return qualityResult;
} finally {
if (!headBmp.isRecycled()) headBmp.recycle();
}
return faceFeature.getFeatureData();
qualityResult.featureData = faceFeature.getFeatureData();
return qualityResult;
} else {
Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code);
return null;
return qualityResult;
}
} else {
Log.e(TAG, "registerBgr24: no face detected, code is " + code);
return null;
qualityResult.qualityStatus = "failed";
qualityResult.qualityErrorCode = code == ErrorInfo.MOK
? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE
: code;
return qualityResult;
}
}
/**
* 仅检测BGR24在库照片的质量,不提取或写入人脸特征。
*
* @param bgr24 BGR24图片数据
* @param width 图片宽度
* @param height 图片高度
* @return detected或failed质量结果
*/
public synchronized QualityFeatureResult detectBgr24Quality(byte[] bgr24, int width, int height) {
QualityFeatureResult result = new QualityFeatureResult();
if (faceEngine == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) {
result.qualityStatus = "failed";
result.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
return result;
}
List<FaceInfo> faceInfoList = new ArrayList<>();
int detectCode = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList);
if (detectCode != ErrorInfo.MOK || faceInfoList.isEmpty()) {
result.qualityStatus = "failed";
result.qualityErrorCode = detectCode == ErrorInfo.MOK ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE : detectCode;
return result;
}
if (!imageQualityReady) {
result.qualityStatus = "failed";
result.qualityErrorCode = imageQualityInitCode;
return result;
}
ImageQualitySimilar qualitySimilar = new ImageQualitySimilar();
int qualityCode = faceEngine.imageQualityDetect(
bgr24, width, height, FaceEngine.CP_PAF_BGR24,
faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar);
if (qualityCode == ErrorInfo.MOK) {
result.qualityStatus = "detected";
result.qualityScore = (double) qualitySimilar.getScore();
} else {
result.qualityStatus = "failed";
result.qualityErrorCode = qualityCode;
}
return result;
}
/** 原生图片特征与质量诊断的组合结果。 */
public static class QualityFeatureResult {
/** 注册特征字节,提取失败时为空。 */
public byte[] featureData;
/** 质量状态,稳定值为detected或failed。 */
public String qualityStatus = "pending";
/** 检测成功时的质量分。 */
public Double qualityScore;
/** 检测失败时的错误码。 */
public Integer qualityErrorCode;
}
private String getImagePath(String name, Context context) {
if (imageRootPath == null) {
imageRootPath = getImageDir(context);
@@ -18,6 +18,7 @@ import com.arcsoft.face.FaceInfo;
import com.arcsoft.face.GenderInfo;
import com.arcsoft.face.LivenessInfo;
import com.arcsoft.face.LivenessParam;
import com.arcsoft.face.ErrorInfo;
import com.arcsoft.face.enums.DetectFaceOrientPriority;
import com.arcsoft.face.enums.DetectMode;
import com.google.gson.Gson;
@@ -35,6 +36,8 @@ import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore;
import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView;
import io.flutter.plugins.facediscern.ui.FaceDiscern;
@@ -47,6 +50,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
private final String TAG = "FaceDiscernViewModel";
/**
* 仅识别最大脸时的预览检测上限。
* 原生配置允许的上限为 3,足以让收银员看清排队场景中的目标选择,同时只让一张脸进入识别线程。
*/
private static final int KEEP_MAX_FACE_PREVIEW_LIMIT = 3;
// 相机预览的分辨率
private Camera.Size previewSize;
@@ -60,10 +69,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
private FaceEngine maskEngine;
// IMAGE模式活体检测引擎,用于预览帧人脸活体检测
private FaceEngine flEngine;
// 各个引擎初始化的错误码
private MutableLiveData<Integer> ftInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> frInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> flInitCode = new MutableLiveData<>();
// 各个引擎初始化的错误码。虹软 init 是同步调用,不能通过 LiveData.postValue
// 异步保存后立刻读取,否则主线程消息尚未分发时会把成功初始化误判为失败。
private int ftInitCode = Integer.MIN_VALUE;
private int maskInitCode = Integer.MIN_VALUE;
private int frInitCode = Integer.MIN_VALUE;
private int flInitCode = Integer.MIN_VALUE;
/// ****************** end 引擎 end ******************
@@ -101,8 +112,31 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
private HashMap<Object, Object> faceDiscern = null;
private RecognitionListener recognitionListener;
private RecognizeCallback faceHelperCallback = this;
private MutableLiveData<RecognizeConfiguration> recognizeConfiguration = new MutableLiveData<>();
/**
* 新的共享会话不再依赖 Activity 广播,由进程级 Manager 直接接收识别结果。
* 旧 Activity 不设置该监听时仍保持原有广播行为,避免破坏历史入口。
*/
public interface RecognitionListener {
/**
* 返回当前会话唯一识别结果及实际成功帧,Manager 会在代次校验后生成现场证据。
*
* @param result 不含原始帧的识别用户与相似度结果
* @param generation 识别会话代次,旧 Activity 兼容链路使用 0
* @param frameSnapshot 实际参与成功比对的帧;旧 Activity 兼容链路允许为空
*/
void onRecognized(HashMap<Object, Object> result, long generation, RecognitionFrameSnapshot frameSnapshot);
}
public void setRecognitionListener(RecognitionListener recognitionListener) {
this.recognitionListener = recognitionListener;
}
public void setRgbFaceRectTransformer(FaceRectTransformer rgbFaceRectTransformer) {
faceHelper.setRgbFaceRectTransformer(rgbFaceRectTransformer);
}
@@ -119,46 +153,133 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
registerStatus = status;
}
// 初始化引擎
/**
* 读取本机识别配置并初始化虹软检测、口罩、特征和可选活体引擎。
* 仅识别最大脸时扩大检测上限只服务预览标框,不会扩大实际参与比对的人脸数量。
*
* @param context 应用上下文,用于初始化虹软引擎和读取本机 SharedPreferences 配置
*/
public void init(Context context) {
setContext(context);
ftInitCode = Integer.MIN_VALUE;
maskInitCode = Integer.MIN_VALUE;
frInitCode = Integer.MIN_VALUE;
flInitCode = Integer.MIN_VALUE;
boolean switchCamera = false;
previewConfig = new PreviewConfig(switchCamera ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK, switchCamera ? Camera.CameraInfo.CAMERA_FACING_BACK : Camera.CameraInfo.CAMERA_FACING_FRONT, 0, 0);
// 填入在设置界面设置好的配置信息
boolean enableLive = false;
boolean enableFaceQualityDetect = true;
boolean enableFaceMoveLimit = false;
boolean enableFaceSizeLimit = false;
int faceSizeLimit = 160;
int faceMoveLimit = 20;
int maxDetectFaces = 1;
boolean keepMaxFace = true;
float similarThreshold = 0.8F;
float imageQualityNoMaskRecognizeThreshold = 0.49F;
float imageQualityMaskRecognizeThreshold = 0.29F;
RecognizeConfiguration configuration = new RecognizeConfiguration.Builder().enableFaceMoveLimit(enableFaceMoveLimit).enableFaceSizeLimit(enableFaceSizeLimit).faceSizeLimit(faceSizeLimit).faceMoveLimit(faceMoveLimit).enableLiveness(enableLive).enableImageQuality(enableFaceQualityDetect).maxDetectFaces(maxDetectFaces).keepMaxFace(keepMaxFace).similarThreshold(similarThreshold).imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold).imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold).livenessParam(new LivenessParam(0.5F, 0.5F)).build();
// 单摄像头终端仅支持 RGB 活体,配置由设置页持久化并在每次冷启动识别引擎时读取。
livenessType = LivenessType.RGB;
RecognizeConfiguration configuration = RecognizeConfigurationStore.load(context);
boolean enableLive = configuration.isEnableLiveness();
boolean enableFaceQualityDetect = configuration.isEnableImageQuality();
int maxDetectFaces = effectiveMaxDetectFaces(configuration);
ftEngine = new FaceEngine();
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_FACE_DETECT));
ftInitCode = ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_FACE_DETECT);
maskEngine = new FaceEngine();
ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_MASK_DETECT));
maskInitCode = maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_MASK_DETECT);
frEngine = new FaceEngine();
int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION;
if (enableFaceQualityDetect) {
frEngineMask |= FaceEngine.ASF_IMAGEQUALITY;
}
frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, 10, frEngineMask));
frInitCode = frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, maxDetectFaces, frEngineMask);
if (enableFaceQualityDetect && frInitCode != ErrorInfo.MOK) {
int qualityInitCode = frInitCode;
Log.w(TAG, "实时图像质量模块不可用,回退基础识别引擎code=" + qualityInitCode);
frEngine.unInit();
frEngine = new FaceEngine();
frInitCode = frEngine.init(
context,
DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_0_ONLY,
maxDetectFaces,
FaceEngine.ASF_FACE_RECOGNITION);
if (frInitCode == ErrorInfo.MOK) {
// 只关闭本次会话的质量判断,保留管理员配置,授权恢复后下次会重新尝试。
configuration = configuration.withImageQualityEnabled(false);
}
}
//启用活体检测时,才初始化活体引擎
if (enableLive) {
flEngine = new FaceEngine();
int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT));
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask));
LivenessParam livenessParam = new LivenessParam(0.5F, 0.5F);
flInitCode = flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, flEngineMask);
LivenessParam livenessParam = configuration.getLivenessParam();
flEngine.setLivenessParam(livenessParam);
}
recognizeConfiguration.setValue(configuration);
flutterFaceCallbackIntent = new Intent(FaceBrodConstant.FACE_GLOBAL_INTENT);
Log.i(TAG, "虹软引擎初始化结果 " + getEngineInitSummary());
}
public boolean isEngineReady() {
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
return ftInitCode == ErrorInfo.MOK
&& maskInitCode == ErrorInfo.MOK
&& frInitCode == ErrorInfo.MOK
&& (configuration == null || !configuration.isEnableLiveness()
|| flInitCode == ErrorInfo.MOK);
}
public String getEngineInitSummary() {
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
boolean livenessEnabled = configuration != null && configuration.isEnableLiveness();
return "ft=" + ftInitCode
+ ";mask=" + maskInitCode
+ ";fr=" + frInitCode
+ ";fl=" + (livenessEnabled ? flInitCode : "disabled")
+ ";maxFaces=" + (configuration == null ? "unknown" : effectiveMaxDetectFaces(configuration))
+ ";imageQuality=" + (configuration == null ? "unknown" : configuration.isEnableImageQuality());
}
public boolean isLivenessEnabled() {
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
return configuration != null && configuration.isEnableLiveness();
}
public float getSimilarityThreshold() {
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
return configuration == null ? RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD : configuration.getSimilarThreshold();
}
/**
* 返回当前引擎是否启用“仅识别最大人脸”,供 Flutter 会话事件保持与原生识别策略一致。
*
* @return 当前配置开启时返回 true;引擎尚未完成配置加载时返回默认值
*/
public boolean isKeepMaxFaceEnabled() {
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
return configuration == null
? RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE
: configuration.isKeepMaxFace();
}
/**
* 解析虹软检测引擎的人脸上限。
* 开启最大脸模式时必须先检测到多张脸才能比较面积,因此固定提升至配置允许的三张上限。
*
* @param configuration 当前本机识别配置
* @return 传给虹软各引擎的单帧最大检测数量
*/
private int effectiveMaxDetectFaces(RecognizeConfiguration configuration) {
if (configuration.isKeepMaxFace()) {
return Math.max(configuration.getMaxDetectFaces(), KEEP_MAX_FACE_PREVIEW_LIMIT);
}
return configuration.getMaxDetectFaces();
}
public Float getFaceSimilarity(int trackId) {
return faceHelper == null ? null : faceHelper.getSimilarity(trackId);
}
public Integer getFaceLiveness(int trackId) {
return faceHelper == null ? null : faceHelper.getLiveness(trackId);
}
public Integer getFaceRecognizeStatus(int trackId) {
return faceHelper == null ? null : faceHelper.getRecognizeStatus(trackId);
}
private void initFaceHelper(Camera.Size lastPreviewSize) {
@@ -171,8 +292,8 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
}
int horizontalOffset = 0;
int verticalOffset = 0;
int maxDetectFaceNum = 1;
faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(this).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> {
int maxDetectFaceNum = recognizeConfiguration.getValue().getMaxDetectFaces();
faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(faceHelperCallback).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> {
FaceInfo irFaceInfo = new FaceInfo(faceInfo);
irFaceInfo.getRect().offset(horizontalOffset, verticalOffset);
return irFaceInfo;
@@ -189,6 +310,17 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
initFaceHelper(lastPreviewSize);
}
/**
* 每个业务会话复用 FaceHelper 与 ArcSoft FaceEngine,只重置跟踪状态并切换代次回调。
* 这样连续排队无需重建线程池,上一位顾客的异步提取晚到时也不会串入下一笔订单。
*/
public void beginSession(long generation) {
faceDiscern = null;
if (faceHelper == null) throw new IllegalStateException("人脸处理器尚未初始化");
faceHelperCallback = new SessionRecognizeCallback(generation);
faceHelper.beginSession(generation, faceHelperCallback);
}
public Point loadPreviewSize() {
String[] size = new String[]{"1280", "720"};
return new Point(Integer.parseInt(size[0]), Integer.parseInt(size[1]));
@@ -251,8 +383,36 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
public void destroy() {
if (faceHelper != null) {
faceHelper.release();
faceHelper = null;
}
// FaceServer.getInstance().release();
if (ftEngine != null) {
synchronized (ftEngine) {
ftEngine.unInit();
}
ftEngine = null;
}
if (maskEngine != null) {
synchronized (maskEngine) {
maskEngine.unInit();
}
maskEngine = null;
}
if (frEngine != null) {
synchronized (frEngine) {
frEngine.unInit();
}
frEngine = null;
}
if (flEngine != null) {
synchronized (flEngine) {
flEngine.unInit();
}
flEngine = null;
}
faceDiscern = null;
recognitionListener = null;
faceHelperCallback = this;
// FaceServer 是跨会话的内存特征库,热会话关闭时不能释放,否则下一位顾客会重新装载全库。
}
public void stop() {
@@ -263,7 +423,8 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
// 识别结果回调
@Override
public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass) {
public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass,
RecognitionFrameSnapshot frameSnapshot) {
try {
if (!similarPass) return;
HashMap<Object, Object> json = new HashMap<>();
@@ -292,6 +453,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
flutterFaceCallbackIntent = new Intent(FaceBrodConstant.FACE_GLOBAL_INTENT);
}
Log.i(TAG, "人脸识别成功json:" + new Gson().toJson(faceDiscern));
if (recognitionListener != null) {
HashMap<Object, Object> result = new HashMap<>(faceDiscern);
faceDiscern = null;
recognitionListener.onRecognized(result, 0L, null);
return;
}
flutterFaceCallbackIntent.putExtra("json", new Gson().toJson(faceDiscern));
context.sendBroadcast(flutterFaceCallbackIntent);
faceDiscern = null;
@@ -307,4 +474,52 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
Log.e(TAG, "提示文字变更的回调,错误error:" + e.getMessage(), e);
}
}
private HashMap<Object, Object> buildRecognitionResult(CompareResult compareResult) {
HashMap<Object, Object> json = new HashMap<>();
json.put("similar", compareResult.getSimilar());
json.put("cost", compareResult.getCost());
if (Objects.nonNull(compareResult.getFaceEntity())) {
FaceEntity face = compareResult.getFaceEntity();
json.put("faceId", face.getFaceId());
json.put("sampleId", face.getSampleId());
json.put("userId", face.getUserId());
json.put("userName", face.getUserName());
}
return json;
}
/** 会话专用回调只保存本代识别结果,避免共享字段被旧异步任务覆盖。 */
private final class SessionRecognizeCallback implements RecognizeCallback {
/** 创建该回调时绑定的会话代次,旧代异步结果不得进入新会话。 */
private final long generation;
/** 当前代已经通过阈值的人脸用户和相似度结果,通知成功后立即清空。 */
private HashMap<Object, Object> result;
/** 创建只服务指定会话代次的回调。 */
private SessionRecognizeCallback(long generation) {
this.generation = generation;
}
@Override
public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass,
RecognitionFrameSnapshot frameSnapshot) {
if (!similarPass) return;
result = buildRecognitionResult(compareResult);
pendingFrameSnapshot = frameSnapshot;
}
@Override
public void onNoticeChanged(String notice) {
if (!Objects.equals(notice, "") || result == null || recognitionListener == null) return;
HashMap<Object, Object> current = new HashMap<>(result);
result = null;
recognitionListener.onRecognized(current, generation, pendingFrameSnapshot);
pendingFrameSnapshot = null;
}
/** 与 result 同一次特征提取对应的成功帧,转交 Manager 后立即释放引用。 */
private RecognitionFrameSnapshot pendingFrameSnapshot;
}
}
@@ -0,0 +1,194 @@
package com.example.pj_supermarket_cloud
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import java.io.File
import java.security.MessageDigest
/**
* 校验并通过 Root 静默覆盖安装当前应用 APK。
*
* 安装前强制校验包名、签名和版本码,Root 仅用于执行 `pm install -r`,不会卸载重装、
* 绕过签名或清理应用数据,从而保留本地数据库与 Device Owner 状态。
*/
object ApkUpdateManager {
/** 待更新状态使用的原生偏好文件,应用替换后仍随数据目录保留。 */
private const val PREFERENCES_NAME = "apk_update_runtime"
/** 是否存在等待新版本启动确认的更新。 */
private const val PENDING_KEY = "pending"
/** 后台目标版本码。 */
private const val EXPECTED_VERSION_CODE_KEY = "expected_version_code"
/** 下载到应用私有目录的 APK 绝对路径。 */
private const val APK_PATH_KEY = "apk_path"
/** 安装命令最长允许 3 分钟,超时后按失败处理。 */
private const val INSTALL_TIMEOUT_MILLIS = 180_000L
/**
* 校验 APK 是否可安全覆盖当前应用。
*
* @param context 当前应用上下文
* @param apkPath 应用私有目录中的 APK 绝对路径
* @param expectedVersionCode 服务端声明的目标版本码
* @return MethodChannel 可直接返回的结构化校验结果
*/
fun validate(context: Context, apkPath: String, expectedVersionCode: Long): Map<String, Any?> {
val file = File(apkPath)
if (!file.isFile || file.length() <= 0L) {
return failure("APK_NOT_FOUND", "安装包不存在或为空")
}
val archive = packageArchiveInfo(context, apkPath)
?: return failure("APK_INVALID", "无法解析安装包,请重新下载")
val current = currentPackageInfo(context)
val archiveVersionCode = versionCode(archive)
val currentVersionCode = versionCode(current)
if (archive.packageName != context.packageName) {
return failure("PACKAGE_MISMATCH", "安装包包名与当前应用不一致")
}
if (archiveVersionCode != expectedVersionCode) {
return failure("VERSION_MISMATCH", "安装包版本码与后台目标版本不一致")
}
if (archiveVersionCode <= currentVersionCode) {
return failure("VERSION_NOT_HIGHER", "目标版本不高于当前版本,已拒绝重复或降级安装")
}
if (signerDigests(archive).intersect(signerDigests(current)).isEmpty()) {
return failure("SIGNATURE_MISMATCH", "安装包签名与当前应用不一致")
}
return mapOf(
"valid" to true,
"code" to "OK",
"message" to "安装包校验通过",
"packageName" to archive.packageName,
"versionCode" to archiveVersionCode,
"versionName" to archive.versionName
)
}
/**
* 重新校验 Root 与 APK 后执行覆盖安装;待更新状态先于命令写入,保证进程被替换时可恢复。
*/
fun install(context: Context, apkPath: String, expectedVersionCode: Long): Map<String, Any?> {
val validation = validate(context, apkPath, expectedVersionCode)
if (validation["valid"] != true) return validation
val root = RootCommandRunner.checkRoot()
if (root["available"] != true) {
return failure("ROOT_UNAVAILABLE", root["message"]?.toString() ?: "Root 不可用")
}
preferences(context).edit()
.putBoolean(PENDING_KEY, true)
.putLong(EXPECTED_VERSION_CODE_KEY, expectedVersionCode)
.putString(APK_PATH_KEY, apkPath)
.commit()
val command = RootCommandRunner.execute(
"pm install -r ${RootCommandRunner.quoteShell(apkPath)}",
INSTALL_TIMEOUT_MILLIS
)
val success = command.exitCode == 0 && command.output.contains("Success", ignoreCase = true)
if (!success) {
clearPending(context, deleteApk = true)
val message = when {
command.timedOut -> "APK 安装超时,请检查系统安装服务"
command.output.isNotBlank() -> command.output
else -> "pm install -r 执行失败(退出码 ${command.exitCode ?: -1})"
}
return failure("INSTALL_FAILED", message)
}
return mapOf(
"valid" to true,
"code" to "INSTALL_STARTED",
"message" to "安装命令已成功执行,等待新版本启动确认",
"versionCode" to expectedVersionCode
)
}
/**
* 应用启动时确认待更新版本并清理私有 APK;版本未提升时保留明确失败结果并终止恢复。
*/
fun consumePending(context: Context): Map<String, Any?> {
val prefs = preferences(context)
if (!prefs.getBoolean(PENDING_KEY, false)) {
return mapOf("pending" to false, "success" to false)
}
val expected = prefs.getLong(EXPECTED_VERSION_CODE_KEY, -1L)
val current = versionCode(currentPackageInfo(context))
val success = expected > 0L && current >= expected
val message = if (success) {
"自动更新完成,当前 Build $current"
} else {
"自动更新未完成,当前 Build $current,目标 Build $expected"
}
clearPending(context, deleteApk = true)
return mapOf(
"pending" to true,
"success" to success,
"message" to message,
"currentVersionCode" to current,
"expectedVersionCode" to expected
)
}
/** 读取当前安装应用的包信息与签名证书。 */
@Suppress("DEPRECATION")
private fun currentPackageInfo(context: Context): PackageInfo =
context.packageManager.getPackageInfo(context.packageName, signatureFlags())
/** 从 APK 文件解析包信息与签名证书;文件损坏或格式不支持时返回空。 */
@Suppress("DEPRECATION")
private fun packageArchiveInfo(context: Context, path: String): PackageInfo? =
context.packageManager.getPackageArchiveInfo(path, signatureFlags())
/** 根据 Android 版本选择签名信息标志,兼容项目最低 Android 7。 */
@Suppress("DEPRECATION")
private fun signatureFlags(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
PackageManager.GET_SIGNATURES
}
/** 统一读取 64 位版本码,Android 9 以下回退旧整型字段。 */
@Suppress("DEPRECATION")
private fun versionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode
} else {
info.versionCode.toLong()
}
/** 计算 APK 当前签名者 SHA-256 摘要集合,用交集兼容多签名包。 */
@Suppress("DEPRECATION")
private fun signerDigests(info: PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners ?: emptyArray()
} else {
info.signatures ?: emptyArray()
}
return signatures.map { signature ->
MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString("") { byte -> "%02x".format(byte) }
}.toSet()
}
/** 构造统一失败结构,避免 Flutter 解析不同异常文本。 */
private fun failure(code: String, message: String): Map<String, Any?> = mapOf(
"valid" to false,
"code" to code,
"message" to message
)
/** 清理待更新偏好,并按调用场景删除应用私有目录中的 APK。 */
private fun clearPending(context: Context, deleteApk: Boolean) {
val prefs = preferences(context)
if (deleteApk) prefs.getString(APK_PATH_KEY, null)?.let { File(it).delete() }
prefs.edit().clear().commit()
}
/** 返回只归当前应用所有的原生待更新偏好。 */
private fun preferences(context: Context) =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
@@ -0,0 +1,23 @@
package com.example.pj_supermarket_cloud
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* 开机时仅在应用已经是 Device Owner 时拉起;应用包替换后始终拉起新版本,并按现有
* Device Owner 与自动进入配置恢复 Kiosk。
*/
class KioskBootReceiver : BroadcastReceiver() {
/** 仅处理开机完成和本应用包替换事件,并按 Device Owner 状态决定是否拉起主界面。 */
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) return
if (action == Intent.ACTION_BOOT_COMPLETED && !KioskModeManager.prepareForBoot(context)) return
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) KioskModeManager.prepareForBoot(context)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(launchIntent)
}
}
@@ -0,0 +1,10 @@
package com.example.pj_supermarket_cloud
import android.app.admin.DeviceAdminReceiver
/**
* 专用收银终端的设备管理接收器。
*
* 该接收器只作为 Device Owner 的系统入口,应用不会在普通安装状态下自行提升权限。
*/
class KioskDeviceAdminReceiver : DeviceAdminReceiver()
@@ -0,0 +1,355 @@
package com.example.pj_supermarket_cloud
import android.app.Activity
import android.app.ActivityManager
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* 管理 Android 专用设备模式。
*
* 只有 Device Owner 才能可靠限制主页、最近任务和状态栏。普通设备管理员或沉浸式全屏
* 都不视为 Kiosk 可用,避免界面把“看起来全屏”误报为“无法退出”。
*/
object KioskModeManager {
/** Kiosk 原生配置使用的应用私有偏好文件。 */
private const val PREFERENCES_NAME = "kiosk_runtime"
/** 是否在进入 App 时自动恢复 Lock Task 的持久化键。 */
private const val AUTO_ENTER_ENABLED_KEY = "auto_enter_enabled"
/** Root 执行 DPM 命令的最长等待时间。 */
private const val DPM_TIMEOUT_MILLIS = 30_000L
/** 厂商系统栏被边缘手势唤出后重新隐藏的等待时间,避免与手势动画同帧争用。 */
private const val SYSTEM_BAR_REHIDE_DELAY_MILLIS = 250L
/** 返回 Flutter 设置页和心跳共用的实时 Kiosk 能力与运行状态。 */
fun status(context: Context): Map<String, Any> {
val policyManager = policyManager(context)
val deviceOwner = policyManager.isDeviceOwnerApp(context.packageName)
val autoEnterEnabled = isAutoEnterEnabled(context)
return mapOf(
"deviceAdminActive" to policyManager.isAdminActive(adminComponent(context)),
"deviceOwner" to deviceOwner,
"available" to deviceOwner,
"active" to isKioskActive(context),
"lockTaskPermitted" to policyManager.isLockTaskPermitted(context.packageName),
"autoEnterEnabled" to autoEnterEnabled,
"bootAutoStartSupported" to deviceOwner
)
}
/**
* Activity 每次回到前台时按配置补齐白名单并进入锁定任务。管理员退出到桌面的过程
* 不会触发新的 onPostResume;用户再次主动打开 App 时才会重新进入 Kiosk。
*/
fun enterIfAvailable(activity: Activity) {
if (!isAutoEnterEnabled(activity) || !ensureLockTaskPolicy(activity)) return
if (!isKioskActive(activity)) {
try {
activity.startLockTask()
} catch (_: IllegalArgumentException) {
return
} catch (_: IllegalStateException) {
return
} catch (_: SecurityException) {
return
}
}
if (isKioskActive(activity)) hideSystemBars(activity)
}
/**
* Device Owner 设备始终支持开机拉起 App;只有开启自动进入时才预先恢复 Kiosk 策略。
*/
fun prepareForBoot(context: Context): Boolean {
if (!policyManager(context).isDeviceOwnerApp(context.packageName)) return false
if (isAutoEnterEnabled(context)) {
return ensureLockTaskPolicy(context)
}
return try {
policyManager(context).setStatusBarDisabled(adminComponent(context), false)
true
} catch (_: Exception) {
true
}
}
/**
* 解除当前锁定并回到系统 HOME。保留 Lock Task 白名单,自动进入配置开启时,用户
* 下次主动打开 App 会通过 onPostResume 重新锁定。
*/
fun exitToHome(activity: Activity): Boolean {
val policyManager = policyManager(activity)
if (!policyManager.isDeviceOwnerApp(activity.packageName)) return false
return try {
if (isKioskActive(activity)) activity.stopLockTask()
policyManager.setStatusBarDisabled(adminComponent(activity), false)
showSystemBars(activity)
val homeIntent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
if (homeIntent.resolveActivity(activity.packageManager) != null) {
activity.startActivity(homeIntent)
} else {
activity.moveTaskToBack(true)
}
true
} catch (_: Exception) {
// 退出失败时恢复终端策略,不能把设备留在既未锁定也未回到桌面的中间状态。
if (isAutoEnterEnabled(activity) && ensureLockTaskPolicy(activity)) {
enterIfAvailable(activity)
}
false
}
}
/**
* 保存 Kiosk 自动进入配置并立即同步当前 Activity 的 Lock Task 状态。
*
* 设置页本身已经受管理员密码保护,因此关闭开关时直接退出当前 Lock Task 但仍停留在
* App 内;开启时立即补齐策略并进入,避免持久化值与当前系统状态需要多次切换才一致。
*/
fun setAutoEnterEnabled(activity: Activity, enabled: Boolean): Map<String, Any> {
if (!policyManager(activity).isDeviceOwnerApp(activity.packageName)) return status(activity)
preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, enabled).commit()
if (enabled) {
enterIfAvailable(activity)
} else {
exitCurrentKiosk(activity)
}
if (isKioskActive(activity) != enabled) {
// 系统状态切换失败时回滚持久化值,避免界面显示关闭但设备仍处于 Lock Task。
preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, !enabled).commit()
}
return status(activity)
}
/**
* 仅在用户主动触发后通过 Root 配置当前应用为 Device Owner。
*
* 本方法不会删除系统账户、移除其他 Owner 或修改设备策略文件;Android 拒绝时按常见
* 限制返回稳定错误编码,调用方可给管理员明确处理建议。
*/
fun provisionDeviceOwnerWithRoot(context: Context): Map<String, Any> {
val policyManager = policyManager(context)
if (policyManager.isDeviceOwnerApp(context.packageName)) {
return provisionResult(true, "ALREADY_OWNER", "当前应用已经是 Device Owner")
}
val root = RootCommandRunner.checkRoot()
if (root["available"] != true) {
return provisionResult(false, "ROOT_UNAVAILABLE", root["message"]?.toString() ?: "Root 不可用")
}
val component = adminComponent(context).flattenToShortString()
val command = RootCommandRunner.execute(
"dpm set-device-owner --user 0 ${RootCommandRunner.quoteShell(component)}",
DPM_TIMEOUT_MILLIS
)
if (policyManager.isDeviceOwnerApp(context.packageName)) {
return provisionResult(true, "OK", "Device Owner 配置成功")
}
val output = command.output.lowercase()
val (code, message) = when {
command.timedOut -> "DPM_TIMEOUT" to "DPM 命令执行超时,请检查厂商系统策略"
"account" in output -> "ACCOUNT_EXISTS" to "设备存在系统账户,Android 不允许配置 Device Owner;请在设备初始化部署阶段处理账户后重试"
"already" in output && ("owner" in output || "profile" in output) ->
"OWNER_EXISTS" to "设备已存在其他 Device Owner 或 Profile Owner,当前应用不会自动移除它"
"system user" in output || "primary user" in output || "user 0" in output && "not" in output ->
"NON_PRIMARY_USER" to "当前不在 Android 主用户(user 0)环境,无法配置 Device Owner"
"unknown command" in output || "not found" in output || "unsupported" in output ->
"COMMAND_UNSUPPORTED" to "当前系统不支持 dpm set-device-owner 命令"
else -> "OEM_RESTRICTED" to (command.output.ifBlank {
"Android 或厂商策略拒绝配置 Device Owner,请检查设备初始化状态"
})
}
return provisionResult(false, code, message)
}
/** 窗口重新获得焦点时恢复 Kiosk 系统栏策略,防止临时系统界面留下可退出入口。 */
fun reapplySystemBars(activity: Activity) {
if (isKioskActive(activity)) hideSystemBars(activity)
}
/** 判断 Android 当前是否处于完全锁定的 Lock Task 模式。 */
fun isKioskActive(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return activityManager.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_LOCKED
}
/** 为当前 Device Owner 配置 Lock Task 白名单、系统栏策略和长按电源菜单能力。 */
private fun ensureLockTaskPolicy(context: Context): Boolean {
val policyManager = policyManager(context)
if (!policyManager.isDeviceOwnerApp(context.packageName)) return false
return try {
policyManager.setLockTaskPackages(adminComponent(context), arrayOf(context.packageName))
policyManager.setStatusBarDisabled(adminComponent(context), true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
policyManager.setLockTaskFeatures(
adminComponent(context),
// 仅放行系统全局操作菜单,使管理员仍能长按电源关机;主页和最近任务继续禁用。
DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS
)
}
policyManager.isLockTaskPermitted(context.packageName)
} catch (_: Exception) {
false
}
}
/**
* 应用 Kiosk 系统栏策略,并监听厂商 SystemUI 与输入法可见性变化。
*
* Android 沉浸模式允许边缘手势短暂唤出系统栏;RK3568 固件不会按标准行为自动收回,
* 因此在动画结束后再次应用策略。输入法显示期间仅恢复导航栏,使系统输入法保留收起
* 键盘入口;输入法关闭后再隐藏全部系统栏,不能为了键盘操作永久暴露系统导航。
*/
private fun hideSystemBars(activity: Activity) {
val decorView = activity.window.decorView
var imeVisible = isInputMethodVisible(activity)
var rehidePosted = false
/**
* 厂商 SystemUI 完成边缘手势动画后再恢复沉浸模式;同一轮 Insets 与旧版可见性
* 回调只排队一次,并在执行前复核 Kiosk 和输入法状态,避免误隐藏键盘导航入口。
*/
fun scheduleSystemBarRehide() {
if (rehidePosted) return
rehidePosted = true
decorView.postDelayed(
{
rehidePosted = false
if (isKioskActive(activity) && !isInputMethodVisible(activity)) {
applyKioskSystemBars(activity, false)
}
},
SYSTEM_BAR_REHIDE_DELAY_MILLIS
)
}
ViewCompat.setOnApplyWindowInsetsListener(decorView) { _, insets ->
val nextImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val systemBarsVisible =
insets.isVisible(WindowInsetsCompat.Type.statusBars()) ||
insets.isVisible(WindowInsetsCompat.Type.navigationBars())
if (imeVisible != nextImeVisible) {
imeVisible = nextImeVisible
decorView.post {
if (isKioskActive(activity)) {
applyKioskSystemBars(activity, nextImeVisible)
}
}
} else if (!nextImeVisible && systemBarsVisible) {
// RK3568 的 Android 11 不一定触发旧版可见性回调,必须直接监听 Insets。
scheduleSystemBarRehide()
}
// 不消费 Insets,Flutter 仍需获得准确的 viewInsets 以完成键盘避让布局。
insets
}
@Suppress("DEPRECATION")
decorView.setOnSystemUiVisibilityChangeListener {
if (!isKioskActive(activity)) return@setOnSystemUiVisibilityChangeListener
// 保留旧回调兼容 RK 厂商固件中的混合 SystemUI 实现。
scheduleSystemBarRehide()
}
applyKioskSystemBars(activity, imeVisible)
ViewCompat.requestApplyInsets(decorView)
}
/**
* 根据输入法状态应用 AndroidX Insets 与 Android 11 厂商固件仍依赖的系统栏标记。
*
* Lock Task 继续负责禁用 Home 与最近任务;输入法可见时恢复的导航栏仅提供系统返回/
* 收起键盘入口,状态栏始终保持隐藏。
*/
@Suppress("DEPRECATION")
private fun applyKioskSystemBars(activity: Activity, imeVisible: Boolean) {
val decorView = activity.window.decorView
decorView.systemUiVisibility = if (imeVisible) {
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
} else {
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
}
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply {
hide(WindowInsetsCompat.Type.statusBars())
if (imeVisible) {
show(WindowInsetsCompat.Type.navigationBars())
} else {
hide(WindowInsetsCompat.Type.navigationBars())
}
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
/** 返回当前输入法是否实际占用窗口,空 Insets 视为键盘未显示。 */
private fun isInputMethodVisible(activity: Activity): Boolean =
ViewCompat.getRootWindowInsets(activity.window.decorView)
?.isVisible(WindowInsetsCompat.Type.ime()) == true
/** 关闭当前 Lock Task 但停留在 App 内,并立即恢复可用的系统栏。 */
private fun exitCurrentKiosk(activity: Activity) {
try {
if (isKioskActive(activity)) activity.stopLockTask()
} catch (_: Exception) {
// 后续实时状态会向 Flutter 暴露关闭失败,不能在这里伪造成功。
}
try {
policyManager(activity).setStatusBarDisabled(adminComponent(activity), false)
} catch (_: Exception) {
// 部分厂商在 Lock Task 切换帧内暂时拒绝策略变更,系统栏恢复仍继续执行。
}
showSystemBars(activity)
}
/** 管理员关闭或退出 Kiosk 时移除自动重隐藏监听,并恢复正常窗口边距与系统栏。 */
@Suppress("DEPRECATION")
private fun showSystemBars(activity: Activity) {
val decorView = activity.window.decorView
ViewCompat.setOnApplyWindowInsetsListener(decorView, null)
decorView.setOnSystemUiVisibilityChangeListener(null)
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
WindowCompat.setDecorFitsSystemWindows(activity.window, true)
WindowCompat.getInsetsController(activity.window, decorView)
.show(WindowInsetsCompat.Type.systemBars())
}
/** 返回 Manifest 已注册的设备管理员组件。 */
private fun adminComponent(context: Context) = ComponentName(context, KioskDeviceAdminReceiver::class.java)
/** 获取系统 DevicePolicyManager,所有 Owner 判断以其实时结果为准。 */
private fun policyManager(context: Context) =
context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
/** 读取进入 App 自动恢复 Kiosk 的原生持久化配置,旧设备默认开启。 */
private fun isAutoEnterEnabled(context: Context) =
preferences(context).getBoolean(AUTO_ENTER_ENABLED_KEY, true)
/** 返回仅归当前应用所有的 Kiosk 偏好存储。 */
private fun preferences(context: Context) =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
/** 构造 MethodChannel 可直接返回的 DPM 配置结果。 */
private fun provisionResult(success: Boolean, code: String, message: String): Map<String, Any> =
mapOf("success" to success, "code" to code, "message" to message)
}
@@ -1,28 +1,348 @@
package com.example.pj_supermarket_cloud
import android.content.Context
import android.app.ActivityManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.TrafficStats
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Debug
import android.os.Process
import android.os.StatFs
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.telephony.TelephonyManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin
import io.flutter.plugins.facediscern.flutter.FlutterFaceSubScreenPlugin
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager
import io.flutter.plugin.common.MethodChannel
import com.rex.flutter_subscreen_plugin.FlutterSubScreenProvider
import com.rex.flutter_subscreen_plugin.FlutterSubscreenPlugin
import java.util.concurrent.Executors
/**
* 终端主屏 Flutter Activity,集中注册人脸、副屏、运行状态、Root/Kiosk 和 APK 更新通道。
*/
class MainActivity : FlutterActivity() {
/** 保存进程级运行时基准,Activity 重建时不重置 App 运行时长。 */
companion object {
/** 进程首次加载 Activity 类时的单调时钟,用于心跳报告当前 App 运行时长。 */
private val processStartedAtMillis = SystemClock.elapsedRealtime()
}
/** Flutter 运行环境、Root/Kiosk 与 APK 操作共用的 MethodChannel 名称。 */
private val runtimeChannel = "biz_xfj/runtime_environment"
/** 主屏向独立副屏 Engine 转发轻量消息的 MethodChannel 名称。 */
private val subScreenChannel = "screen_plugin_sub_channel"
/** Root、DPM 与 APK 安装均在后台串行执行,避免阻塞 Flutter 主线程和 Android UI。 */
private val privilegedExecutor = Executors.newSingleThreadExecutor()
/** 注册项目原生插件和运行时通道;耗时特权命令始终转交后台执行器。 */
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 人脸识别插件
val flutterFaceDiscernPlugin = FlutterFaceDiscernPlugin()
flutterEngine.plugins.add(flutterFaceDiscernPlugin)
flutterEngine.plugins.add(FlutterFaceSubScreenPlugin())
// 副屏是独立 Flutter Engine,需要独立的人脸通道和 TextureRegistry;
// 两个插件实例最终共享同一个进程级 Camera/虹软 Session。
FlutterSubscreenPlugin.registerThirdPlugins(
arrayListOf(FlutterFaceSubScreenPlugin()),
flutterEngine.plugins
)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, runtimeChannel).setMethodCallHandler { call, result ->
when (call.method) {
"getRuntimeEnvironment" -> {
val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val packageInfo = packageManager.getPackageInfo(packageName, 0)
@Suppress("DEPRECATION")
val apkVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
packageInfo.versionCode.toLong()
}
result.success(
mapOf(
"isEmulator" to isEmulator(),
"displayCount" to displayManager.displays.size,
// 系统和安装包版本来自当前进程实际运行环境,不能使用后台目标版本代替。
"androidVersion" to Build.VERSION.RELEASE,
"androidSdkInt" to Build.VERSION.SDK_INT,
"apkVersionName" to (packageInfo.versionName ?: ""),
"apkVersionCode" to apkVersionCode
)
)
}
"getNetworkSnapshot" -> result.success(getNetworkSnapshot())
"getPerformanceSnapshot" -> result.success(getPerformanceSnapshot())
"vibrate" -> {
val duration = call.argument<Number>("durationMs")?.toLong() ?: 60L
result.success(vibrateForBlockedDialog(duration.coerceIn(20L, 200L)))
}
"isSubScreenShowing" -> result.success(
FlutterSubScreenProvider.instance.presentation?.isShowing == true
)
"sendViceMessage" -> {
val method = call.argument<String>("method")
val params = call.argument<Map<String, Any?>>("params") ?: emptyMap()
if (method.isNullOrBlank()) {
result.error("INVALID_ARGUMENT", "缺少副屏消息方法", null)
} else {
val subEngine = FlutterSubScreenProvider.instance.flutterEngine
if (subEngine == null) {
result.success(false)
} else {
try {
// 只做单向通知,不注册回调等待副屏;副屏异常不能占住主屏硬件链路。
MethodChannel(subEngine.dartExecutor.binaryMessenger, subScreenChannel)
.invokeMethod(method, params)
result.success(true)
} catch (error: Exception) {
result.error("VICE_MESSAGE_FAILED", "副屏消息转发失败", error.message)
}
}
}
}
"getKioskStatus" -> result.success(KioskModeManager.status(this))
"checkRootAccess" -> runPrivileged(result) {
RootCommandRunner.checkRoot()
}
"enableKioskWithRoot" -> {
privilegedExecutor.execute {
val provision = try {
KioskModeManager.provisionDeviceOwnerWithRoot(applicationContext)
} catch (error: Exception) {
mapOf(
"success" to false,
"code" to "DPM_FAILED",
"message" to (error.message ?: "Device Owner 配置失败")
)
}
runOnUiThread {
if (provision["success"] == true) {
KioskModeManager.setAutoEnterEnabled(this, true)
}
result.success(provision + ("status" to KioskModeManager.status(this)))
}
}
}
"setKioskAutoEnterEnabled" -> {
val enabled = call.argument<Boolean>("enabled")
if (enabled == null) {
result.error("INVALID_ARGUMENT", "缺少 Kiosk 自动进入配置", null)
} else {
result.success(KioskModeManager.setAutoEnterEnabled(this, enabled))
}
}
"exitKioskToHome" -> result.success(KioskModeManager.exitToHome(this))
"validateApk" -> {
val path = call.argument<String>("path")
val expectedVersionCode = call.argument<Number>("expectedVersionCode")?.toLong()
if (path.isNullOrBlank() || expectedVersionCode == null) {
result.error("INVALID_ARGUMENT", "缺少 APK 路径或目标版本码", null)
} else {
runPrivileged(result) {
ApkUpdateManager.validate(applicationContext, path, expectedVersionCode)
}
}
}
"installApkWithRoot" -> {
val path = call.argument<String>("path")
val expectedVersionCode = call.argument<Number>("expectedVersionCode")?.toLong()
if (path.isNullOrBlank() || expectedVersionCode == null) {
result.error("INVALID_ARGUMENT", "缺少 APK 路径或目标版本码", null)
} else {
runPrivileged(result) {
ApkUpdateManager.install(applicationContext, path, expectedVersionCode)
}
}
}
"consumePendingUpdate" -> runPrivileged(result) {
ApkUpdateManager.consumePending(applicationContext)
}
else -> result.notImplemented()
}
}
GeneratedPluginRegistrant.registerWith(flutterEngine)
//
//
// FlutterSubscreenPlugin.registerThirdPlugins(
// arrayListOf(
//
// ),
// this.flutterEngine!!.plugins
// )
}
}
/** Activity 回到可交互状态时按原生持久化配置恢复 Kiosk。 */
override fun onPostResume() {
super.onPostResume()
KioskModeManager.enterIfAvailable(this)
}
/** 销毁主 Activity 时同步释放副屏 Engine 和特权命令线程,避免持有失效资源。 */
override fun onDestroy() {
val subScreenProvider = FlutterSubScreenProvider.instance
try {
// Android 12 以下副屏使用 Overlay Presentation,任务被划掉时必须在
// Activity 销毁前同步移除窗口并销毁独立 Engine,不能只依赖 Dart 生命周期回调。
try {
subScreenProvider.closeSubDisplay()
} finally {
try {
subScreenProvider.flutterEngine?.destroy()
} finally {
subScreenProvider.onDispose()
}
}
} finally {
privilegedExecutor.shutdownNow()
super.onDestroy()
}
}
/** 系统报告低内存时释放当前人脸识别会话的高占用资源。 */
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= TRIM_MEMORY_RUNNING_LOW) {
// 低内存时优先释放 Camera/EGL/会话级引擎,跨会话 FaceServer 特征库仍由进程保留。
FaceRecognitionSessionManager.getInstance(applicationContext).releaseForLowMemory()
}
}
/** 窗口重新获得焦点时恢复 Kiosk 系统栏隐藏策略。 */
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) KioskModeManager.reapplySystemBars(this)
}
@Suppress("DEPRECATION")
/** Kiosk 运行时拦截系统返回键,普通模式继续使用 FlutterActivity 默认行为。 */
override fun onBackPressed() {
// 当前 FlutterActivity 基线没有 OnBackPressedDispatcher,Kiosk 运行时直接拦截系统返回。
if (KioskModeManager.isKioskActive(this)) return
super.onBackPressed()
}
/**
* 使用 Android Build 特征识别常见虚拟机,避免在虚拟设备上初始化串口、人脸和副屏硬件。
*/
private fun isEmulator(): Boolean {
return Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.lowercase().contains("genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
Build.PRODUCT == "google_sdk" ||
Build.PRODUCT.lowercase().contains("sdk_gphone") ||
Build.PRODUCT.lowercase().contains("vbox86p") ||
Build.HARDWARE.lowercase().contains("goldfish") ||
Build.HARDWARE.lowercase().contains("ranchu")
}
/**
* 返回当前物理网络类型和累计流量。Flutter 侧只计算相邻快照差值,避免测速请求
* 额外占用收银终端带宽,也不依赖外部测速服务是否可用。
*/
private fun getNetworkSnapshot(): Map<String, Any> {
val connectivity = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork)
val type = when {
capabilities == null -> "offline"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> mobileNetworkType()
else -> "network"
}
return mapOf(
"type" to type,
"connected" to (capabilities != null),
"rxBytes" to TrafficStats.getTotalRxBytes().coerceAtLeast(0L),
"txBytes" to TrafficStats.getTotalTxBytes().coerceAtLeast(0L)
)
}
/**
* 返回轻量的进程与设备资源快照。CPU 使用率由 Flutter 比较两次累计进程时间计算,
* 原生层不创建额外线程或定时器,避免运行状态展示反过来增加终端负载。
*/
private fun getPerformanceSnapshot(): Map<String, Any> {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
val storage = StatFs(filesDir.absolutePath)
return mapOf(
"systemUptimeSeconds" to SystemClock.elapsedRealtime() / 1_000L,
"appUptimeSeconds" to (SystemClock.elapsedRealtime() - processStartedAtMillis).coerceAtLeast(0L) / 1_000L,
"processCpuTimeMs" to Process.getElapsedCpuTime(),
"logicalProcessorCount" to Runtime.getRuntime().availableProcessors().coerceAtLeast(1),
"memoryTotalBytes" to memoryInfo.totalMem,
"memoryAvailableBytes" to memoryInfo.availMem,
"appMemoryBytes" to Debug.getPss().toLong() * 1024L,
"storageTotalBytes" to storage.totalBytes,
"storageAvailableBytes" to storage.availableBytes
)
}
/**
* 为禁止关闭的业务弹框提供短震动提示;设备不具备震动器时返回 false 供 Flutter 回退。
*/
@Suppress("DEPRECATION")
private fun vibrateForBlockedDialog(durationMillis: Long): Boolean {
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return false
if (!vibrator.hasVibrator()) return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibrator.vibrate(durationMillis)
}
return true
}
@Suppress("DEPRECATION")
/** 将蜂窝网络制式归一为心跳接口使用的 2g/3g/4g/5g/mobile 编码。 */
private fun mobileNetworkType(): String {
return try {
val telephony = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
when (telephony.dataNetworkType) {
TelephonyManager.NETWORK_TYPE_NR -> "5g"
TelephonyManager.NETWORK_TYPE_LTE -> "4g"
TelephonyManager.NETWORK_TYPE_UMTS,
TelephonyManager.NETWORK_TYPE_HSDPA,
TelephonyManager.NETWORK_TYPE_HSUPA,
TelephonyManager.NETWORK_TYPE_HSPA,
TelephonyManager.NETWORK_TYPE_HSPAP -> "3g"
TelephonyManager.NETWORK_TYPE_GPRS,
TelephonyManager.NETWORK_TYPE_EDGE,
TelephonyManager.NETWORK_TYPE_CDMA,
TelephonyManager.NETWORK_TYPE_1xRTT -> "2g"
else -> "mobile"
}
} catch (_: SecurityException) {
"mobile"
}
}
/**
* 在专用后台线程执行高延迟原生动作,并始终切回主线程完成 MethodChannel 回调。
*/
private fun runPrivileged(result: MethodChannel.Result, action: () -> Any?) {
privilegedExecutor.execute {
try {
val payload = action()
runOnUiThread { result.success(payload) }
} catch (error: Exception) {
runOnUiThread {
result.error("PRIVILEGED_OPERATION_FAILED", error.message, null)
}
}
}
}
}
@@ -0,0 +1,191 @@
package com.example.pj_supermarket_cloud
import android.os.SystemClock
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* 统一执行需要 Root 的短生命周期 shell 命令。
*
* 所有调用方必须显式给出超时;标准错误并入标准输出并限制返回长度,避免子进程管道阻塞
* 或把不可控的系统输出长期保留在 Flutter 层。
*/
object RootCommandRunner {
/** Root 检查固定超时,超过 5 秒按不可用处理。 */
private const val ROOT_CHECK_TIMEOUT_MILLIS = 5_000L
/** 返回给上层的最大诊断字符数。 */
private const val MAX_OUTPUT_LENGTH = 2_000
/** 并发读取子进程输出,防止输出缓冲区写满后命令无法退出。 */
private val outputExecutor = Executors.newCachedThreadPool()
/**
* 当前进程已经确认可用的 su 参数格式;首次调用时为空,由只读 Root 检查或语法回退确定。
*/
@Volatile
private var preferredInvocationMode: SuInvocationMode? = null
/**
* Android 设备现场存在两类常见 su 参数协议,枚举值只描述进程参数格式,不改变业务命令。
*/
private enum class SuInvocationMode {
/** Magisk、KernelSU 和 SuperSU 常见的 `su -c <command>` 格式。 */
OPTION_C,
/** AOSP userdebug 与部分厂商固件要求显式 UID 的 `su 0 sh -c <command>` 格式。 */
UID_ZERO_SHELL
}
/**
* 一次 Root 命令执行结果。
*
* @property exitCode 进程退出码;超时或无法启动时为空
* @property output 合并并截断后的标准输出与错误输出
* @property timedOut 是否因超过调用方限制被终止
*/
data class CommandResult(
val exitCode: Int?,
val output: String,
val timedOut: Boolean
)
/**
* 使用设备支持的 su 参数格式执行命令并轮询退出状态,兼容项目最低 Android 版本。
*
* 部分厂商 su 会把 `-c` 当成 UID 并返回 `invalid uid/gid '-c'`。仅识别到这个明确的
* 参数语法错误时才回退到 `su 0 sh -c`,因此不会把可能已经执行过的安装或 DPM 命令
* 重复执行;两次尝试共享调用方给出的总超时。
*
* @param command 传给 Root shell 的完整命令
* @param timeoutMillis 两种参数格式合计允许的最大执行时间,单位毫秒
* @return 包含退出码、诊断输出与超时状态的结果
*/
fun execute(command: String, timeoutMillis: Long): CommandResult {
val normalizedTimeout = timeoutMillis.coerceAtLeast(1L)
val preferred = preferredInvocationMode
if (preferred != null) {
return executeWithMode(command, normalizedTimeout, preferred)
}
val startedAt = SystemClock.elapsedRealtime()
val optionResult = executeWithMode(command, normalizedTimeout, SuInvocationMode.OPTION_C)
if (!isOptionCSyntaxRejected(optionResult)) {
if (!optionResult.timedOut && optionResult.exitCode != null) {
preferredInvocationMode = SuInvocationMode.OPTION_C
}
return optionResult
}
val elapsed = SystemClock.elapsedRealtime() - startedAt
val remaining = (normalizedTimeout - elapsed).coerceAtLeast(1L)
val uidResult = executeWithMode(command, remaining, SuInvocationMode.UID_ZERO_SHELL)
if (!uidResult.timedOut && uidResult.exitCode != null) {
preferredInvocationMode = SuInvocationMode.UID_ZERO_SHELL
}
return uidResult
}
/**
* 按指定 su 参数协议启动一次子进程;本方法不进行协议回退,避免特权命令重复执行。
*
* @param command 传给 Root shell 的完整命令
* @param timeoutMillis 本次进程允许执行的最长时间,单位毫秒
* @param mode 本次使用的 su 参数协议
* @return 单次进程的退出码、诊断输出和超时状态
*/
private fun executeWithMode(
command: String,
timeoutMillis: Long,
mode: SuInvocationMode
): CommandResult {
var process: Process? = null
return try {
val processCommand = when (mode) {
SuInvocationMode.OPTION_C -> listOf("su", "-c", command)
SuInvocationMode.UID_ZERO_SHELL -> listOf("su", "0", "sh", "-c", command)
}
val runningProcess = ProcessBuilder(processCommand)
.redirectErrorStream(true)
.start()
process = runningProcess
val outputFuture = outputExecutor.submit<String> {
runningProcess.inputStream.bufferedReader().use { it.readText() }
}
val deadline = SystemClock.elapsedRealtime() + timeoutMillis
var exitCode: Int? = null
while (SystemClock.elapsedRealtime() <= deadline) {
exitCode = try {
runningProcess.exitValue()
} catch (_: IllegalThreadStateException) {
null
}
if (exitCode != null) break
Thread.sleep(50)
}
val timedOut = exitCode == null
if (timedOut) runningProcess.destroy()
val output = try {
outputFuture.get(500, TimeUnit.MILLISECONDS)
} catch (_: Exception) {
""
}
CommandResult(exitCode, sanitize(output), timedOut)
} catch (error: Exception) {
process?.destroy()
CommandResult(null, sanitize(error.message ?: "无法执行 su 命令"), false)
}
}
/**
* 判断标准 `su -c` 是否被设备误当成 UID/GID 或未知选项。
*
* 只有命中明确参数错误才允许换协议重试;授权拒绝、命令失败和超时都原样返回,避免
* DPM 或 APK 安装等具有副作用的命令被执行两次。
*/
private fun isOptionCSyntaxRejected(result: CommandResult): Boolean {
if (result.timedOut || result.exitCode == null || result.exitCode == 0) return false
val output = result.output.lowercase()
val mentionsOptionC = "'-c'" in output || "\"-c\"" in output || " -c" in output
return mentionsOptionC && (
"invalid uid/gid" in output ||
"invalid uid" in output ||
"unknown option" in output ||
"unrecognized option" in output ||
"illegal option" in output
)
}
/**
* 真实执行 Root shell 的 `id` 检查;不能只根据 su 文件是否存在进行判断。
*/
fun checkRoot(): Map<String, Any> {
val command = execute("id", ROOT_CHECK_TIMEOUT_MILLIS)
val available = command.exitCode == 0 && Regex("(^|\\s)uid=0([\\s(]|$)")
.containsMatchIn(command.output)
val message = when {
command.timedOut -> "Root 授权超时(5秒),请检查 su 授权策略"
available -> "已通过 su 执行 id 确认 uid=0"
command.exitCode == null -> command.output.ifBlank { "未找到 su 或 Root 命令无法启动" }
command.output.isBlank() -> "su 返回退出码 ${command.exitCode},未确认 uid=0"
else -> command.output
}
return mapOf(
"available" to available,
"exitCode" to (command.exitCode ?: -1),
"timedOut" to command.timedOut,
"message" to message
)
}
/**
* 使用单引号安全包裹 shell 路径,避免空格或特殊字符改变安装命令含义。
*/
fun quoteShell(value: String): String = "'${value.replace("'", "'\\''")}'"
/** 将多行命令输出压缩为界面可读摘要并限制长度。 */
private fun sanitize(output: String): String {
val compact = output.trim().replace(Regex("\\s+"), " ")
return if (compact.length <= MAX_OUTPUT_LENGTH) compact else compact.take(MAX_OUTPUT_LENGTH) + "..."
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="kiosk_device_admin_label">收银终端设备管理</string>
<string name="kiosk_device_admin_description">用于启用开机自启和专用设备锁定模式</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies />
</device-admin>