package com.ectrip.checkincar.ui.verify; import android.Manifest; import android.annotation.SuppressLint; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.ImageFormat; import android.graphics.Matrix; import android.media.AudioManager; import android.media.ToneGenerator; import android.os.Build; import android.os.Bundle; import android.os.SystemClock; import android.os.VibrationEffect; import android.os.Vibrator; import android.view.KeyEvent; import android.view.View; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.camera.core.Camera; import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageAnalysis; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.ImageProxy; import androidx.camera.core.Preview; import androidx.camera.lifecycle.ProcessCameraProvider; import androidx.camera.view.PreviewView; import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; import com.ectrip.checkincar.App; import com.ectrip.checkincar.R; import com.ectrip.checkincar.core.device.DeviceInfo; import com.ectrip.checkincar.core.prefs.AppPrefs; import com.ectrip.checkincar.history.HistoryStore; import com.ectrip.checkincar.history.VerifyRecord; import com.ectrip.checkincar.license.LicenseManager; import com.ectrip.checkincar.model.PlateInfo; import com.ectrip.checkincar.model.QueryResult; import com.ectrip.checkincar.model.VerifyRequest; import com.ectrip.checkincar.model.VerifyResult; import com.ectrip.checkincar.net.QueryApi; import com.ectrip.checkincar.net.VerifyApi; import com.ectrip.checkincar.net.VerifyApiFactory; import com.ectrip.checkincar.plate.OnlinePlateRecognizer; import com.ectrip.checkincar.plate.PlateRecognizer; import com.ectrip.checkincar.plate.PlateRecognizerFactory; import com.google.common.util.concurrent.ListenableFuture; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import android.graphics.Rect; import android.graphics.YuvImage; public class VerifyActivity extends AppCompatActivity { // 状态机 private static final int S1_SHOOT = 1; private static final int S2_RECOGNIZING = 2; private static final int S3_CONFIRM = 3; private static final int S4_VERIFYING = 4; private static final int S5_FINAL = 5; private volatile int state = S1_SHOOT; private PreviewView previewView; private View layerShoot; private View layerRecognizing; private View layerResult; private View layerVerifying; private View layerFinal; private TextView tvHint; private TextView tvRecognizingHint; private ImageView imgPlate; private TextView tvPlateNumber; private TextView tvPlateColor; private TextView tvConfidence; private ImageView btnFlash; private TextView tvFinalTitle; private TextView tvFinalMsg; private ImageView imgFinal; private View finalFailButtons; private TextView btnFinalContinue; private LinearLayout finalInfo; private ImageCapture imageCapture; private ImageAnalysis imageAnalysis; private ProcessCameraProvider cameraProvider; private Camera camera; private boolean torchOn = false; private final ExecutorService workExecutor = Executors.newSingleThreadExecutor(); private PlateRecognizer recognizer; private long lastAnalyzeTime = 0L; private long lastCloudErrorToast = 0L; private PlateInfo currentPlate; private Bitmap currentBitmap; private Runnable autoBackRunnable; private Runnable autoVerifyRunnable; private AlertDialog confirmDialog; private volatile boolean queryPending = false; private final ActivityResultLauncher permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { if (granted) { startCamera(); prepareRecognizer(); } else { Toast.makeText(this, "需要摄像头权限才能核销", Toast.LENGTH_LONG).show(); finish(); } }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_verify); bindViews(); bindListeners(); if (AppPrefs.getStaffId().trim().isEmpty()) { Toast.makeText(this, "请先在设置中填写核销员工ID", Toast.LENGTH_LONG).show(); finish(); return; } if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { startCamera(); prepareRecognizer(); } else { permissionLauncher.launch(Manifest.permission.CAMERA); } } private void bindViews() { previewView = findViewById(R.id.previewView); layerShoot = findViewById(R.id.layerShoot); layerRecognizing = findViewById(R.id.layerRecognizing); layerResult = findViewById(R.id.layerResult); layerVerifying = findViewById(R.id.layerVerifying); layerFinal = findViewById(R.id.layerFinal); tvHint = findViewById(R.id.tvHint); tvRecognizingHint = findViewById(R.id.tvRecognizingHint); imgPlate = findViewById(R.id.imgPlate); tvPlateNumber = findViewById(R.id.tvPlateNumber); tvPlateColor = findViewById(R.id.tvPlateColor); tvConfidence = findViewById(R.id.tvConfidence); btnFlash = findViewById(R.id.btnFlash); tvFinalTitle = findViewById(R.id.tvFinalTitle); tvFinalMsg = findViewById(R.id.tvFinalMsg); imgFinal = findViewById(R.id.imgFinal); finalFailButtons = findViewById(R.id.finalFailButtons); btnFinalContinue = findViewById(R.id.btnFinalContinue); finalInfo = findViewById(R.id.finalInfo); } private void bindListeners() { findViewById(R.id.btnBack).setOnClickListener(v -> finish()); findViewById(R.id.btnShutter).setOnClickListener(v -> takePhoto()); findViewById(R.id.btnManual).setOnClickListener(v -> showManualInputDialog(null)); findViewById(R.id.btnEditPlate).setOnClickListener(v -> showManualInputDialog(currentPlate == null ? null : currentPlate.number)); findViewById(R.id.btnRetake).setOnClickListener(v -> backToScanning()); findViewById(R.id.btnBackHome).setOnClickListener(v -> finish()); findViewById(R.id.btnConfirm).setOnClickListener(v -> { if (currentPlate != null && !queryPending) { queryAndShowConfirm(currentPlate); } }); btnFlash.setOnClickListener(v -> toggleTorch()); btnFinalContinue.setOnClickListener(v -> backToScanning()); findViewById(R.id.btnFinalHome).setOnClickListener(v -> finish()); findViewById(R.id.btnFailRetake).setOnClickListener(v -> backToScanning()); findViewById(R.id.btnFailEdit).setOnClickListener(v -> showManualInputDialog(currentPlate == null ? null : currentPlate.number)); } // ---------------- 相机 ---------------- private void startCamera() { ListenableFuture future = ProcessCameraProvider.getInstance(this); future.addListener(() -> { try { cameraProvider = future.get(); Preview preview = new Preview.Builder().build(); preview.setSurfaceProvider(previewView.getSurfaceProvider()); imageCapture = new ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build(); imageAnalysis = new ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build(); imageAnalysis.setAnalyzer(workExecutor, this::analyzeFrame); cameraProvider.unbindAll(); camera = cameraProvider.bindToLifecycle((LifecycleOwner) this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, imageAnalysis); } catch (Exception e) { Toast.makeText(this, "相机启动失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); } }, ContextCompat.getMainExecutor(this)); } private void toggleTorch() { if (camera == null) { return; } torchOn = !torchOn; camera.getCameraControl().enableTorch(torchOn); btnFlash.setImageResource(torchOn ? R.drawable.ic_flash_on : R.drawable.ic_flash_off); } // ---------------- 识别器 ---------------- private void prepareRecognizer() { int engine = AppPrefs.getEngine(); runOnUiThread(() -> { tvHint.setText("引擎准备中…"); tvRecognizingHint.setText(engine == AppPrefs.ENGINE_ONLINE ? "云端识别中…" : "识别中…"); }); if (workExecutor.isShutdown() || workExecutor.isTerminated()) { return; } workExecutor.execute(() -> { recognizer = PlateRecognizerFactory.create(this); runOnUiThread(() -> { if (recognizer.isReady()) { tvHint.setText(readyHint(engine)); } else { tvHint.setText("引擎未就绪:" + notReadyReason(engine) + ",可手动输入"); } }); }); } private static String readyHint(int engine) { switch (engine) { case AppPrefs.ENGINE_ONLINE: return "请将车牌放入框内(在线识别)"; case AppPrefs.ENGINE_OFFLINE: return "请将车牌放入框内"; default: return "请将车牌放入框内(模拟识别)"; } } private static String notReadyReason(int engine) { switch (engine) { case AppPrefs.ENGINE_ONLINE: return "未配置 API Key/Secret"; case AppPrefs.ENGINE_OFFLINE: return LicenseManager.get().getLastError(); default: return ""; } } /** 自动分析帧节流:云端按次计费放宽到 1s,本地 400ms */ private long frameIntervalMs() { return AppPrefs.getEngine() == AppPrefs.ENGINE_ONLINE ? 1000L : 400L; } @SuppressLint("UnsafeOptInUsageError") private void analyzeFrame(@NonNull ImageProxy proxy) { if (state != S1_SHOOT) { proxy.close(); return; } PlateRecognizer r = recognizer; if (r == null || !r.isReady()) { proxy.close(); return; } long now = SystemClock.elapsedRealtime(); if (now - lastAnalyzeTime < frameIntervalMs()) { proxy.close(); return; } lastAnalyzeTime = now; Bitmap bmp = null; try { bmp = imageProxyToBitmap(proxy); } finally { proxy.close(); } if (bmp == null) { return; } // 在线引擎自动帧预筛:暗/糊画面不上传,省云端调用次数(手动拍照不经过此处) if (r instanceof OnlinePlateRecognizer && !((OnlinePlateRecognizer) r).frameWorthSending(bmp)) { bmp.recycle(); return; } PlateInfo info; try { info = r.recognize(bmp); } catch (Throwable t) { info = null; } if (info != null) { info.bitmap = bmp; PlateInfo hit = info; if (imageAnalysis != null) { imageAnalysis.clearAnalyzer(); } runOnUiThread(() -> onRecognized(hit)); } else { bmp.recycle(); // 在线引擎:网络/鉴权类错误 Toast 节流提示(画面无车牌属正常,不提示) if (r instanceof OnlinePlateRecognizer) { String err = ((OnlinePlateRecognizer) r).getLastError(); long t = SystemClock.elapsedRealtime(); if (err != null && t - lastCloudErrorToast > 3000) { lastCloudErrorToast = t; runOnUiThread(() -> Toast.makeText(this, err, Toast.LENGTH_SHORT).show()); } } } } private void takePhoto() { if (state != S1_SHOOT) { return; } PlateRecognizer r = recognizer; if (r == null) { Toast.makeText(this, "引擎准备中,请稍候", Toast.LENGTH_SHORT).show(); return; } if (!r.isReady()) { Toast.makeText(this, "引擎未就绪:" + notReadyReason(AppPrefs.getEngine()), Toast.LENGTH_LONG).show(); showManualInputDialog(null); return; } showState(S2_RECOGNIZING); if (imageAnalysis != null) { imageAnalysis.clearAnalyzer(); } imageCapture.takePicture(workExecutor, new ImageCapture.OnImageCapturedCallback() { @Override public void onCaptureSuccess(@NonNull ImageProxy proxy) { Bitmap bmp = null; PlateInfo info = null; try { bmp = imageProxyToBitmap(proxy); if (bmp != null) { info = r.recognize(bmp); } } catch (Throwable t) { info = null; } finally { proxy.close(); } Bitmap finalBmp = bmp; PlateInfo finalInfo = info; runOnUiThread(() -> { if (finalInfo != null) { finalInfo.bitmap = finalBmp; onRecognized(finalInfo); } else { String cloudErr = r instanceof OnlinePlateRecognizer ? ((OnlinePlateRecognizer) r).getLastError() : null; Toast.makeText(VerifyActivity.this, cloudErr != null ? cloudErr : "未识别到车牌,请重拍或手动输入", Toast.LENGTH_LONG).show(); if (finalBmp != null) { finalBmp.recycle(); } showManualInputDialog(null); } }); } @Override public void onError(@NonNull ImageCaptureException e) { runOnUiThread(() -> { Toast.makeText(VerifyActivity.this, "拍照失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); backToScanning(); }); } }); } private void onRecognized(PlateInfo info) { currentPlate = info; currentBitmap = info.bitmap; populateResultSheet(); showState(S3_CONFIRM); vibrate(new long[]{0, 30}, -1); // 自动核销:不再自动提交,改为弹确认框后再核销 // 原 AUTO_VERIFY_ON 选项保留但实际由确认弹窗统一处理 queryAndShowConfirm(info); } private void cancelAutoVerify() { if (autoVerifyRunnable != null) { tvHint.removeCallbacks(autoVerifyRunnable); autoVerifyRunnable = null; } } private void populateResultSheet() { PlateInfo info = currentPlate; if (info.bitmap != null) { imgPlate.setImageBitmap(info.bitmap); } else { imgPlate.setImageDrawable(null); } tvPlateNumber.setText(info.number); tvPlateColor.setText(PlateInfo.colorName(info.color)); tvPlateColor.getBackground().setTint(colorValue(info.color)); if (info.manual) { tvConfidence.setText("手动输入"); } else if (info.confidence < 0) { tvConfidence.setText("云识别"); } else { tvConfidence.setText(String.format("置信度 %.0f%%", info.confidence * 100)); } } private int colorValue(String color) { switch (color == null ? "" : color) { case "blue": return ContextCompat.getColor(this, R.color.plate_blue); case "yellow": return ContextCompat.getColor(this, R.color.plate_yellow); case "green": return ContextCompat.getColor(this, R.color.plate_green); case "white": return ContextCompat.getColor(this, R.color.plate_black); case "black": return ContextCompat.getColor(this, R.color.plate_black); default: return ContextCompat.getColor(this, R.color.text_hint); } } // ---------------- 手动输入 ---------------- private void showManualInputDialog(String preset) { final EditText input = new EditText(this); input.setSingleLine(true); input.setTextSize(18); input.setFilters(new android.text.InputFilter[]{new android.text.InputFilter.AllCaps(), new android.text.InputFilter.LengthFilter(8)}); if (preset != null) { input.setText(preset); input.setSelection(preset.length()); } input.setHint("如 京A12345"); LinearLayout container = new LinearLayout(this); container.setOrientation(LinearLayout.VERTICAL); int pad = (int) (20 * getResources().getDisplayMetrics().density); container.setPadding(pad, pad / 2, pad, 0); container.addView(input); new AlertDialog.Builder(this) .setTitle(preset == null ? "手动输入车牌" : "修改车牌") .setView(container) .setPositiveButton("确定", (d, w) -> { String plate = input.getText().toString().trim().toUpperCase(); if (plate.isEmpty()) { if (state == S2_RECOGNIZING) { backToScanning(); } return; } String oldColor = currentPlate != null ? currentPlate.color : "unknown"; PlateInfo info = new PlateInfo(plate, oldColor, 1f, true); currentPlate = info; onRecognized(info); }) .setNegativeButton("取消", (d, w) -> { if (state == S2_RECOGNIZING) { backToScanning(); } }) .setCancelable(false) .show(); } // ---------------- 预约查询 + 核销确认弹窗 ---------------- private void queryAndShowConfirm(PlateInfo plate) { if (queryPending) { return; } queryPending = true; tvHint.setText("查询预约信息…"); if (workExecutor.isShutdown() || workExecutor.isTerminated()) { queryPending = false; return; } workExecutor.execute(() -> { QueryResult result; try { QueryApi api = VerifyApiFactory.createQuery(); result = api.query(plate.number); } catch (Throwable t) { result = QueryResult.error("查询异常: " + t.getMessage()); } QueryResult finalResult = result; runOnUiThread(() -> { queryPending = false; if (state != S3_CONFIRM || currentPlate != plate) { return; // 用户已返回扫描态或换了车牌 } tvHint.setText("识别成功,请核对预约信息"); if (finalResult.ok && finalResult.success) { // 查询成功 + 有有效预约 → 弹确认弹窗 showConfirmDialog(finalResult); } else if (finalResult.ok) { // 查询成功但无预约(success=false)→ 提示,仍允许核销 showNoReservationDialog(finalResult.info); } else { // 网络/解析异常 → 降级,仍允许核销 showNoReservationDialog("未查到预约信息(" + finalResult.info + ")"); } }); }); } /** 有有效预约时的确认弹窗 */ private void showConfirmDialog(QueryResult qr) { View view = getLayoutInflater().inflate(R.layout.dialog_verify_confirm, null); TextView tvPlate = view.findViewById(R.id.dialogPlateNumber); TextView tvStatus = view.findViewById(R.id.dialogStatus); TextView tvDriverName = view.findViewById(R.id.dialogDriverName); TextView tvDriverPhone = view.findViewById(R.id.dialogDriverPhone); TextView tvOrderCode = view.findViewById(R.id.dialogOrderCode); TextView tvUseStartDate = view.findViewById(R.id.dialogUseStartDate); TextView tvParkingAddress = view.findViewById(R.id.dialogParkingAddress); // 车牌号:优先显示后端返回值(Base64解码后的正确值) tvPlate.setText(qr.licensePlate.isEmpty() ? currentPlate.number : qr.licensePlate); // 状态标签 if (qr.isCheckedIn()) { tvStatus.setText("已入场"); tvStatus.setTextColor(ContextCompat.getColor(this, R.color.fail)); tvStatus.setBackgroundColor(ContextCompat.getColor(this, R.color.fail_bg)); } else { tvStatus.setText(qr.statusText()); tvStatus.setTextColor(ContextCompat.getColor(this, R.color.success)); tvStatus.setBackgroundColor(ContextCompat.getColor(this, R.color.success_bg)); } tvDriverName.setText(qr.driverName.isEmpty() ? "-" : qr.driverName); tvDriverPhone.setText(qr.maskedPhone()); tvOrderCode.setText(qr.orderCode.isEmpty() ? "-" : qr.orderCode); tvUseStartDate.setText(qr.useStartDate.isEmpty() ? "-" : qr.useStartDate); tvParkingAddress.setText(qr.parkingAddress.isEmpty() ? "-" : qr.parkingAddress); confirmDialog = new AlertDialog.Builder(this) .setView(view) .setCancelable(true) .create(); view.findViewById(R.id.btnConfirmCancel).setOnClickListener(v -> { confirmDialog.dismiss(); confirmDialog = null; backToScanning(); }); view.findViewById(R.id.btnConfirmOk).setOnClickListener(v -> { confirmDialog.dismiss(); confirmDialog = null; if (qr.isCheckedIn()) { // 已入场再次核销,二次确认 new AlertDialog.Builder(this) .setTitle("再次核销确认") .setMessage("该车牌已入场核销,确认再次核销?") .setPositiveButton("确认核销", (d, w) -> submitVerify(currentPlate)) .setNegativeButton("取消", null) .show(); } else { submitVerify(currentPlate); } }); confirmDialog.setOnCancelListener(d -> { confirmDialog = null; queryPending = false; }); confirmDialog.show(); } /** 无有效预约时的降级提示 */ private void showNoReservationDialog(String info) { new AlertDialog.Builder(this) .setTitle("未查到预约信息") .setMessage(info + "\n\n是否继续核销?") .setPositiveButton("继续核销", (d, w) -> submitVerify(currentPlate)) .setNegativeButton("取消", (d, w) -> backToScanning()) .setCancelable(false) .show(); } // ---------------- 核销提交 ---------------- private void submitVerify(PlateInfo plate) { showState(S4_VERIFYING); workExecutor.execute(() -> { VerifyRequest req = new VerifyRequest(); req.plateNo = plate.number; req.plateColor = plate.color; req.staffId = AppPrefs.getStaffId(); req.deviceSn = DeviceInfo.getSerial(); req.mac = DeviceInfo.getMacAddress(); req.ip = DeviceInfo.getIpAddress(); req.timestamp = System.currentTimeMillis(); VerifyResult result; try { VerifyApi api = VerifyApiFactory.create(); result = api.verify(req); } catch (Throwable t) { result = VerifyResult.fail("网络异常: " + t.getMessage()); } VerifyRecord record = new VerifyRecord(); record.time = req.timestamp; record.plate = plate.number; record.color = plate.color; record.staffId = req.staffId; record.success = result.success; record.message = result.message; record.visitorName = result.visitorName; record.enterDate = result.enterDate; record.visitTime = result.visitTime; HistoryStore.add(record); VerifyResult finalResult = result; runOnUiThread(() -> showFinal(finalResult)); }); } private void showFinal(VerifyResult result) { showState(S5_FINAL); finalInfo.removeAllViews(); if (result.success) { imgFinal.setBackgroundResource(R.drawable.bg_circle_success); imgFinal.setImageResource(R.drawable.ic_check); tvFinalTitle.setTextColor(ContextCompat.getColor(this, R.color.success)); finalFailButtons.setVisibility(View.GONE); btnFinalContinue.setVisibility(View.VISIBLE); tvFinalTitle.setText("核销成功"); tvFinalMsg.setText("车牌 " + currentPlate.number + " " + (result.fromServer ? result.message : "已核销")); if (result.fromServer) { // 后台真实核销:显示后台返回的 data 字段(新版返回完整 data,旧版仅 success+info 时字段为空) String plate = result.licensePlate.isEmpty() ? (currentPlate != null ? currentPlate.number : "-") : result.licensePlate; addInfoRow("车牌号", plate); addInfoRowIfNotEmpty("订单编号", result.orderCode); addInfoRowIfNotEmpty("驾驶员", result.driverName); addInfoRowIfNotEmpty("联系电话", result.driverPhone); addInfoRowIfNotEmpty("预约日期", result.useStartDate); addInfoRowIfNotEmpty("停车地点", result.parkingAddress); addInfoRowIfNotEmpty("入场时间", result.enterTime); addInfoRow("核销结果", result.message); } else { // 模拟核销:保持完整演示信息 addInfoRow("预约号", result.reservationId); addInfoRow("预约时间", result.visitTime); addInfoRow("预约人", result.visitorName); addInfoRow("证件号", result.visitorIdCard); addInfoRow("预约类型", result.visitorType); addInfoRow("进入日期", result.enterDate); addInfoRow("核销流水", result.verifySerialNo); } vibrate(new long[]{0, 350}, -1); playBeep(true); autoBackRunnable = this::backToScanning; tvFinalTitle.postDelayed(autoBackRunnable, 8000); } else { imgFinal.setBackgroundResource(R.drawable.bg_circle_fail); imgFinal.setImageResource(R.drawable.ic_fail); tvFinalTitle.setText("核销失败"); tvFinalTitle.setTextColor(ContextCompat.getColor(this, R.color.fail)); addInfoRow("车牌", currentPlate != null ? currentPlate.number : "-"); tvFinalMsg.setText(result.message); finalFailButtons.setVisibility(View.VISIBLE); btnFinalContinue.setVisibility(View.GONE); vibrate(new long[]{0, 100, 150, 100}, -1); playBeep(false); } } /** 值为空时整行隐藏(后台未返回的字段不显示) */ private void addInfoRowIfNotEmpty(String label, String value) { if (value == null || value.trim().isEmpty()) { return; } addInfoRow(label, value); } private void addInfoRow(String label, String value) { LinearLayout row = new LinearLayout(this); row.setOrientation(LinearLayout.HORIZONTAL); LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); rp.topMargin = dp(6); row.setLayoutParams(rp); TextView l = new TextView(this); l.setText(label); l.setTextSize(14); l.setTextColor(ContextCompat.getColor(this, R.color.text_hint)); l.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 0.35f)); TextView v = new TextView(this); v.setText(value == null || value.isEmpty() ? "-" : value); v.setTextSize(14); v.setTextColor(Color.BLACK); v.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 0.65f)); row.addView(l); row.addView(v); finalInfo.addView(row); } private int dp(int v) { return (int) (v * getResources().getDisplayMetrics().density + 0.5f); } private void backToScanning() { cancelAutoVerify(); if (confirmDialog != null && confirmDialog.isShowing()) { confirmDialog.dismiss(); confirmDialog = null; } queryPending = false; if (autoBackRunnable != null) { tvFinalTitle.removeCallbacks(autoBackRunnable); autoBackRunnable = null; } if (currentBitmap != null) { imgPlate.setImageDrawable(null); currentBitmap.recycle(); currentBitmap = null; } currentPlate = null; lastAnalyzeTime = 0L; showState(S1_SHOOT); prepareRecognizer(); if (imageAnalysis != null) { imageAnalysis.setAnalyzer(workExecutor, this::analyzeFrame); } } // ---------------- 状态切换 ---------------- private void showState(int s) { state = s; layerShoot.setVisibility(s == S1_SHOOT ? View.VISIBLE : View.GONE); layerRecognizing.setVisibility(s == S2_RECOGNIZING ? View.VISIBLE : View.GONE); layerResult.setVisibility(s == S3_CONFIRM ? View.VISIBLE : View.GONE); layerVerifying.setVisibility(s == S4_VERIFYING ? View.VISIBLE : View.GONE); layerFinal.setVisibility(s == S5_FINAL ? View.VISIBLE : View.GONE); } // ---------------- 音量下键拍照 ---------------- @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && state == S1_SHOOT) { takePhoto(); return true; } return super.onKeyDown(keyCode, event); } // ---------------- 反馈 ---------------- @SuppressWarnings("deprecation") private void vibrate(long[] pattern, int repeat) { try { Vibrator vib = (Vibrator) getSystemService(VIBRATOR_SERVICE); if (vib == null || !vib.hasVibrator()) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { long[] timings = pattern; vib.vibrate(VibrationEffect.createWaveform(timings, repeat)); } else { vib.vibrate(pattern, repeat); } } catch (Throwable ignored) { } } private void playBeep(boolean success) { try { ToneGenerator tone = new ToneGenerator(AudioManager.STREAM_MUSIC, 60); if (success) { tone.startTone(ToneGenerator.TONE_CDMA_PIP, 250); } else { tone.startTone(ToneGenerator.TONE_CDMA_LOW_L, 300); } } catch (Throwable ignored) { } } // ---------------- 图像转换 ---------------- /** ImageProxy(YUV_420_888 或 JPEG)→ 转正、限宽 1280 的 Bitmap */ private static Bitmap imageProxyToBitmap(ImageProxy image) { try { Bitmap raw; int format = image.getFormat(); if (format == ImageFormat.JPEG || format == 256) { ByteBuffer buf = image.getPlanes()[0].getBuffer(); byte[] bytes = new byte[buf.remaining()]; buf.get(bytes); raw = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } else { byte[] nv21 = yuv420ToNv21(image); int w = image.getWidth(); int h = image.getHeight(); YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, w, h, null); ByteArrayOutputStream out = new ByteArrayOutputStream(); yuv.compressToJpeg(new Rect(0, 0, w, h), 80, out); byte[] jpeg = out.toByteArray(); raw = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); } if (raw == null) { return null; } int rotation = image.getImageInfo().getRotationDegrees(); Bitmap rotated = raw; if (rotation != 0) { Matrix matrix = new Matrix(); matrix.postRotate(rotation); rotated = Bitmap.createBitmap(raw, 0, 0, raw.getWidth(), raw.getHeight(), matrix, true); if (rotated != raw) { raw.recycle(); } } if (rotated.getWidth() > 1280) { float ratio = 1280f / rotated.getWidth(); Bitmap scaled = Bitmap.createScaledBitmap(rotated, 1280, Math.round(rotated.getHeight() * ratio), true); rotated.recycle(); rotated = scaled; } return rotated; } catch (Throwable t) { return null; } } private static byte[] yuv420ToNv21(ImageProxy image) { int width = image.getWidth(); int height = image.getHeight(); byte[] nv21 = new byte[width * height * 3 / 2]; ImageProxy.PlaneProxy[] planes = image.getPlanes(); ByteBuffer yBuffer = planes[0].getBuffer(); ByteBuffer uBuffer = planes[1].getBuffer(); ByteBuffer vBuffer = planes[2].getBuffer(); int yRowStride = planes[0].getRowStride(); int uRowStride = planes[1].getRowStride(); int uPixelStride = planes[1].getPixelStride(); int vRowStride = planes[2].getRowStride(); int vPixelStride = planes[2].getPixelStride(); // Y 平面 int pos = 0; if (yRowStride == width) { yBuffer.position(0); yBuffer.get(nv21, 0, Math.min(width * height, yBuffer.remaining())); pos = width * height; } else { for (int row = 0; row < height; row++) { yBuffer.position(row * yRowStride); for (int col = 0; col < width; col++) { nv21[pos++] = yBuffer.get(); } } } // VU 交错(NV21: V 在前 U 在后) int chromaHeight = (height + 1) / 2; int chromaWidth = (width + 1) / 2; for (int row = 0; row < chromaHeight; row++) { for (int col = 0; col < chromaWidth; col++) { nv21[pos++] = vBuffer.get(row * vRowStride + col * vPixelStride); nv21[pos++] = uBuffer.get(row * uRowStride + col * uPixelStride); } } return nv21; } // ---------------- 生命周期 ---------------- @Override protected void onDestroy() { super.onDestroy(); cancelAutoVerify(); if (confirmDialog != null && confirmDialog.isShowing()) { confirmDialog.dismiss(); confirmDialog = null; } queryPending = false; workExecutor.shutdown(); try { workExecutor.awaitTermination(300, TimeUnit.MILLISECONDS); } catch (InterruptedException ignored) { } if (currentBitmap != null) { currentBitmap.recycle(); currentBitmap = null; } } }