feat:消费机更新
feat:人员调用钱包部门不调动的bug
This commit is contained in:
1 parent
842402e364
commit
60d9380a65
144 files changed
+27832
-1666
No files matched your search
@@ -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>
|
||||
|
||||
+42
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+420
-24
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+543
-84
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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;
|
||||
}
|
||||
+251
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+413
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1116
File diff suppressed because it is too large.
Load diff
+183
-18
@@ -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;
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
/**
|
||||
* 提示文字变更的回调
|
||||
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+87
-12
@@ -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" +
|
||||
|
||||
+142
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
-52
@@ -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);
|
||||
|
||||
+242
-27
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
@@ -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>
|
||||
Reference in new issue
Block a user