package com.ectrip.checkincar.ui.settings; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.text.InputType; import android.text.TextWatcher; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.ScrollView; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import java.io.OutputStream; import java.io.InputStream; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; 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.license.LicenseManager; import com.ectrip.checkincar.plate.BaiduCloudToken; import com.google.android.material.slider.Slider; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; public class SettingsActivity extends AppCompatActivity { private EditText etStaffId; private EditText etBaseUrl; private Spinner spUrlType; private EditText etDeviceCode; private View rowDeviceCode; private View dividerDeviceCode; private EditText etMockPlate; private EditText etSerial; private TextView tvFingerprint; private TextView tvLicenseStatus; private View sectionMock; private View sectionCloud; private View sectionLicense; private EditText etCloudAk; private EditText etCloudSk; private TextView btnSkEye; private TextView tvCloudStatus; private TextView btnCloudAuth; private boolean skVisible = false; private View rowThreshold; private Slider sliderThreshold; private TextView tvThreshold; private TextView btnActivate; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); findViewById(R.id.btnBack).setOnClickListener(v -> finish()); bindDeviceInfo(); bindVerifyConfig(); bindRecognitionConfig(); bindCloudConfig(); bindMockConfig(); bindLicense(); // 保存按钮:先弹确认对话框,确认后强制保存所有配置并备份 findViewById(R.id.btnSave).setOnClickListener(v -> { new androidx.appcompat.app.AlertDialog.Builder(this) .setTitle("确认保存") .setMessage("是否保存当前所有配置并备份到外部存储?") .setPositiveButton("保存", (d, w) -> doSaveConfig()) .setNegativeButton("取消", null) .show(); }); // 导出配置到用户选择的文件(永久保存,重装后可导入恢复) findViewById(R.id.btnExport).setOnClickListener(v -> { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/json"); intent.putExtra(Intent.EXTRA_TITLE, "checkincar_config.json"); startActivityForResult(intent, 1001); }); // 从用户选择的文件导入配置 findViewById(R.id.btnImport).setOnClickListener(v -> { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/json"); intent.putExtra(Intent.EXTRA_TITLE, "checkincar_config.json"); startActivityForResult(intent, 1002); }); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode != RESULT_OK || data == null || data.getData() == null) { return; } Uri uri = data.getData(); if (requestCode == 1001) { // 导出 try { OutputStream os = getContentResolver().openOutputStream(uri); os.write(AppPrefs.exportToJson().getBytes("UTF-8")); os.close(); Toast.makeText(this, "配置已导出", Toast.LENGTH_SHORT).show(); } catch (Exception e) { Toast.makeText(this, "导出失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } else if (requestCode == 1002) { // 导入 try { InputStream is = getContentResolver().openInputStream(uri); byte[] buf = new byte[is.available()]; is.read(buf); is.close(); int count = AppPrefs.importFromJson(new String(buf, "UTF-8")); if (count > 0) { Toast.makeText(this, "已恢复 " + count + " 项配置,页面即将刷新", Toast.LENGTH_LONG).show(); recreate(); // 刷新整个设置页以显示导入的值 } else { Toast.makeText(this, "导入失败:文件格式错误", Toast.LENGTH_LONG).show(); } } catch (Exception e) { Toast.makeText(this, "导入失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } } // ---------------- 设备信息 ---------------- private void bindDeviceInfo() { ((TextView) findViewById(R.id.tvIp)).setText(DeviceInfo.getIpAddress()); ((TextView) findViewById(R.id.tvMac)).setText(DeviceInfo.getMacAddress()); ((TextView) findViewById(R.id.tvSn)).setText(DeviceInfo.getSerial()); ((TextView) findViewById(R.id.tvModel)).setText(DeviceInfo.getModel()); tvFingerprint = findViewById(R.id.tvFingerprint); tvLicenseStatus = findViewById(R.id.tvLicenseStatus); setCopy(R.id.rowIp, R.id.tvIp, "IP地址"); setCopy(R.id.rowMac, R.id.tvMac, "MAC地址"); setCopy(R.id.rowSn, R.id.tvSn, "硬件SN"); setCopy(R.id.rowFingerprint, R.id.tvFingerprint, "授权指纹"); } @Override protected void onResume() { super.onResume(); refreshLicenseInfo(); } private void refreshLicenseInfo() { App.bg().execute(() -> { String fp = LicenseManager.get().getDeviceId(this); String status = LicenseManager.get().getStatusText(this); String expire = LicenseManager.get().getExpireText(this); String fullStatus = expire.isEmpty() ? status : status + "(至" + expire + ")"; runOnUiThread(() -> { tvFingerprint.setText(fp.isEmpty() ? "获取失败" : fp); tvLicenseStatus.setText(fullStatus); }); }); } private void setCopy(int rowId, int valueId, String label) { View row = findViewById(rowId); TextView value = findViewById(valueId); row.setOnClickListener(v -> { String text = value.getText().toString(); ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); cm.setPrimaryClip(ClipData.newPlainText(label, text)); Toast.makeText(this, label + "已复制", Toast.LENGTH_SHORT).show(); }); } // ---------------- 核销配置 ---------------- private void bindVerifyConfig() { etStaffId = findViewById(R.id.etStaffId); etBaseUrl = findViewById(R.id.etBaseUrl); spUrlType = findViewById(R.id.spUrlType); etStaffId.setText(AppPrefs.getStaffId()); etBaseUrl.setText(AppPrefs.getBaseUrl()); // 边输边存,避免失焦监听不可靠(触摸按钮不会移走 EditText 焦点) etStaffId.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { AppPrefs.setStaffId(s.toString()); } }); etBaseUrl.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { String url = s.toString().trim(); if (AppPrefs.isValidUrl(url)) { AppPrefs.setBaseUrl(url); etBaseUrl.setTextColor(Color.BLACK); } } }); etBaseUrl.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { return; } String url = etBaseUrl.getText().toString().trim(); if (!AppPrefs.isValidUrl(url)) { Toast.makeText(this, "核销地址需以 http:// 或 https:// 开头", Toast.LENGTH_LONG).show(); etBaseUrl.setText(AppPrefs.getBaseUrl()); } }); // 地址类型:生产正式 / 开发测试 / 其它(手工输入) // 用户切换不同地址类型时弹确认框:保留修改 / 还原旧值 final String[] urlTypeLabels = {"生产正式地址", "开发测试地址", "其它"}; final int[] urlTypeCurrent = {AppPrefs.getUrlType()}; spUrlType.setSelection(urlTypeCurrent[0]); applyUrlType(urlTypeCurrent[0], false); spUrlType.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(android.widget.AdapterView parent, android.view.View view, int position, long id) { if (position == urlTypeCurrent[0]) { return; } int oldType = urlTypeCurrent[0]; // 先实时保存(不点保存直接返回也生效) AppPrefs.setUrlType(position); applyUrlType(position, true); confirmChange("地址类型", urlTypeLabels[oldType], urlTypeLabels[position], () -> urlTypeCurrent[0] = position, () -> { urlTypeCurrent[0] = oldType; AppPrefs.setUrlType(oldType); spUrlType.setSelection(oldType); applyUrlType(oldType, false); }); } @Override public void onNothingSelected(android.widget.AdapterView parent) { } }); // 核销方式:模拟 / 后台真实核销(mqttScan,会真实开闸并消费预约订单) etDeviceCode = findViewById(R.id.etDeviceCode); rowDeviceCode = findViewById(R.id.rowDeviceCode); dividerDeviceCode = findViewById(R.id.dividerDeviceCode); etDeviceCode.setText(AppPrefs.getDeviceCode()); etDeviceCode.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { AppPrefs.setDeviceCode(s.toString()); } }); setupSegmentConfirm(findViewById(R.id.segVerifyMode), AppPrefs.getVerifyMode(), tag -> { AppPrefs.setVerifyMode(tag); applyVerifyModeVisibility(tag); if (tag == AppPrefs.VERIFY_SERVER) { Toast.makeText(this, "后台核销为正式链路,有效预约车将真实核销并开闸", Toast.LENGTH_LONG).show(); } }, "核销方式", new String[]{"模拟核销", "后台核销"}); applyVerifyModeVisibility(AppPrefs.getVerifyMode()); // 自动核销:识别成功后自动核销(缺省)/ 手动点「确认核销」 setupSegmentConfirm(findViewById(R.id.segAutoVerify), AppPrefs.getAutoVerify(), AppPrefs::setAutoVerify, "自动核销", new String[]{"自动", "手动"}); } private void applyVerifyModeVisibility(int mode) { int vis = mode == AppPrefs.VERIFY_SERVER ? View.VISIBLE : View.GONE; rowDeviceCode.setVisibility(vis); dividerDeviceCode.setVisibility(vis); } /** * 根据地址类型设置核销地址的值及可编辑状态。 * - 生产正式 / 开发测试:填入固定地址,不可编辑 * - 其它:保留当前地址(或清空),可手工输入 */ private void applyUrlType(int type, boolean fromUser) { if (type == AppPrefs.URL_TYPE_PROD) { etBaseUrl.setText(AppPrefs.PROD_BASE_URL); etBaseUrl.setTextColor(Color.BLACK); etBaseUrl.setFocusable(false); etBaseUrl.setFocusableInTouchMode(false); } else if (type == AppPrefs.URL_TYPE_TEST) { etBaseUrl.setText(AppPrefs.TEST_BASE_URL); etBaseUrl.setTextColor(Color.BLACK); etBaseUrl.setFocusable(false); etBaseUrl.setFocusableInTouchMode(false); } else { // 其它:可手工输入;若从固定类型切过来且地址仍是固定值,则清空让用户输入 String cur = etBaseUrl.getText().toString().trim(); if (fromUser && (AppPrefs.PROD_BASE_URL.equals(cur) || AppPrefs.TEST_BASE_URL.equals(cur))) { etBaseUrl.setText(""); } etBaseUrl.setFocusable(true); etBaseUrl.setFocusableInTouchMode(true); } } /** TextWatcher 空实现简化类 */ private abstract static class SimpleWatcher implements TextWatcher { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void afterTextChanged(Editable s) { } } // ---------------- 识别配置 ---------------- private void bindRecognitionConfig() { sectionMock = findViewById(R.id.sectionMock); sectionCloud = findViewById(R.id.sectionCloud); sectionLicense = findViewById(R.id.sectionLicense); rowThreshold = findViewById(R.id.rowThreshold); tvThreshold = findViewById(R.id.tvThreshold); sliderThreshold = findViewById(R.id.sliderThreshold); ViewGroup segEngine = findViewById(R.id.segEngine); setupSegmentConfirm(segEngine, AppPrefs.getEngine(), tag -> { AppPrefs.setEngine(tag); applyEngineVisibility(tag); if (tag == AppPrefs.ENGINE_OFFLINE) { App.bg().execute(() -> LicenseManager.get().ensureReady(this)); refreshLicenseInfo(); } else if (tag == AppPrefs.ENGINE_ONLINE) { refreshCloudStatus(); } }, "识别引擎", new String[]{"模拟识别", "在线识别", "离线识别"}); applyEngineVisibility(AppPrefs.getEngine()); float t = AppPrefs.getThreshold(); sliderThreshold.setValue(t); tvThreshold.setText(String.format("%.2f", t)); sliderThreshold.addOnChangeListener((slider, value, fromUser) -> { tvThreshold.setText(String.format("%.2f", value)); AppPrefs.setThreshold(value); }); } private void applyEngineVisibility(int engine) { sectionMock.setVisibility(engine == AppPrefs.ENGINE_MOCK ? View.VISIBLE : View.GONE); sectionCloud.setVisibility(engine == AppPrefs.ENGINE_ONLINE ? View.VISIBLE : View.GONE); sectionLicense.setVisibility(engine == AppPrefs.ENGINE_OFFLINE ? View.VISIBLE : View.GONE); rowThreshold.setVisibility(engine == AppPrefs.ENGINE_OFFLINE ? View.VISIBLE : View.GONE); sliderThreshold.setVisibility(engine == AppPrefs.ENGINE_OFFLINE ? View.VISIBLE : View.GONE); } // ---------------- 在线配置 ---------------- private void bindCloudConfig() { etCloudAk = findViewById(R.id.etCloudAk); etCloudSk = findViewById(R.id.etCloudSk); btnSkEye = findViewById(R.id.btnSkEye); tvCloudStatus = findViewById(R.id.tvCloudStatus); btnCloudAuth = findViewById(R.id.btnCloudAuth); etCloudAk.setText(AppPrefs.getCloudAk()); etCloudSk.setText(AppPrefs.getCloudSk()); etCloudAk.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { AppPrefs.setCloudAk(s.toString()); AppPrefs.clearCloudToken(); refreshCloudStatus(); } }); etCloudSk.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { AppPrefs.setCloudSk(s.toString()); AppPrefs.clearCloudToken(); refreshCloudStatus(); } }); btnSkEye.setOnClickListener(v -> { skVisible = !skVisible; etCloudSk.setInputType(skVisible ? (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) : (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD)); etCloudSk.setSelection(etCloudSk.getText().length()); btnSkEye.setText(skVisible ? "隐藏" : "显示"); }); btnCloudAuth.setOnClickListener(v -> { String ak = etCloudAk.getText().toString().trim(); String sk = etCloudSk.getText().toString().trim(); if (ak.isEmpty() || sk.isEmpty()) { Toast.makeText(this, "请先填写 API Key 和 Secret Key", Toast.LENGTH_SHORT).show(); return; } AppPrefs.setCloudAk(ak); AppPrefs.setCloudSk(sk); btnCloudAuth.setEnabled(false); btnCloudAuth.setText("鉴权中…"); App.bg().execute(() -> { String msg; try { BaiduCloudToken.refreshToken(); msg = "鉴权成功,Token 已获取"; } catch (Exception e) { msg = "鉴权失败:" + e.getMessage(); } String finalMsg = msg; runOnUiThread(() -> { btnCloudAuth.setEnabled(true); btnCloudAuth.setText("测试鉴权并获取 Token"); Toast.makeText(this, finalMsg, Toast.LENGTH_LONG).show(); refreshCloudStatus(); }); }); }); refreshCloudStatus(); } private void refreshCloudStatus() { if (tvCloudStatus == null) { return; } if (AppPrefs.isCloudTokenValid()) { String expire = new SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) .format(new Date(AppPrefs.getCloudTokenExpireAt())); tvCloudStatus.setText("有效(至" + expire + ")"); tvCloudStatus.setTextColor(ContextCompat.getColor(this, R.color.success)); } else { tvCloudStatus.setText("未获取 Token"); tvCloudStatus.setTextColor(ContextCompat.getColor(this, R.color.text_hint)); } } // ---------------- 模拟配置 ---------------- private void bindMockConfig() { etMockPlate = findViewById(R.id.etMockPlate); etMockPlate.setText(AppPrefs.getMockPlate()); etMockPlate.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { AppPrefs.setMockPlate(s.toString()); } }); setupSegment(findViewById(R.id.segMockDelay), AppPrefs.getMockDelay(), AppPrefs::setMockDelay); setupSegmentConfirm(findViewById(R.id.segMockResult), AppPrefs.getMockResult(), AppPrefs::setMockResult, "模拟结果", new String[]{"成功", "失败", "随机"}); } // ---------------- 授权激活 ---------------- private void bindLicense() { etSerial = findViewById(R.id.etSerial); etSerial.setText(AppPrefs.getLicenseSerial()); etSerial.addTextChangedListener(new SimpleWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { AppPrefs.setLicenseSerial(s.toString()); } }); btnActivate = findViewById(R.id.btnActivate); btnActivate.setOnClickListener(v -> { String serial = etSerial.getText().toString().trim(); if (serial.isEmpty()) { Toast.makeText(this, "请输入序列号", Toast.LENGTH_SHORT).show(); return; } btnActivate.setEnabled(false); btnActivate.setText("激活中…"); App.bg().execute(() -> { int ret = LicenseManager.get().activateOnline(this, serial); runOnUiThread(() -> { btnActivate.setEnabled(true); btnActivate.setText("联网激活"); if (ret == 0) { Toast.makeText(this, "激活成功,模型已加载", Toast.LENGTH_SHORT).show(); } else { // 弹出完整错误详情对话框,方便用户复制给百度技术 showLicenseErrorDialog(LicenseManager.get().getLastError()); } refreshLicenseInfo(); }); }); }); } /** * 显示完整激活错误详情对话框,带复制按钮。 * 方便用户把错误信息发给百度技术支持排查。 */ private void showLicenseErrorDialog(String error) { if (error == null || error.isEmpty()) { error = "未知错误"; } final String content = error; ScrollView sv = new ScrollView(this); TextView tv = new TextView(this); tv.setText(content); tv.setTextSize(13); tv.setTextColor(Color.BLACK); int pad = (int) (getResources().getDisplayMetrics().density * 16); tv.setPadding(pad, pad, pad, pad); tv.setTextIsSelectable(true); sv.addView(tv); new androidx.appcompat.app.AlertDialog.Builder(this) .setTitle("激活失败详情") .setView(sv) .setPositiveButton("复制", (d, w) -> { ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); cm.setPrimaryClip(ClipData.newPlainText("激活错误", content)); Toast.makeText(this, "错误详情已复制到剪贴板", Toast.LENGTH_SHORT).show(); }) .setNegativeButton("关闭", null) .show(); } @Override protected void onPause() { super.onPause(); // 失焦未触发时的兜底保存 if (etStaffId != null) { AppPrefs.setStaffId(etStaffId.getText().toString()); } if (etMockPlate != null) { AppPrefs.setMockPlate(etMockPlate.getText().toString()); } // 离开页面时自动备份到外部存储 AppPrefs.backupToExternal(this); } // ---------------- 分段控件 ---------------- private interface SegCallback { void onSelect(int tag); } private void setupSegment(ViewGroup group, int selected, SegCallback cb) { for (int i = 0; i < group.getChildCount(); i++) { View child = group.getChildAt(i); int tag = Integer.parseInt((String) child.getTag()); child.setOnClickListener(v -> { updateSegment(group, tag); cb.onSelect(tag); }); } updateSegment(group, selected); } private void updateSegment(ViewGroup group, int selected) { int white = ContextCompat.getColor(this, R.color.white); int sub = ContextCompat.getColor(this, R.color.text_sub); for (int i = 0; i < group.getChildCount(); i++) { TextView child = (TextView) group.getChildAt(i); int tag = Integer.parseInt((String) child.getTag()); boolean on = tag == selected; child.setBackgroundResource(on ? R.drawable.bg_seg_selected : R.drawable.bg_seg_normal); child.setTextColor(on ? white : sub); } } /** * 带确认对话框的分段控件绑定:用户切换选项时先实时应用变更(实时保存机制), * 然后弹确认框;选「保留」则更新当前位置,选「还原」则回滚到旧值(重新调用 cb)。 * 不点保存直接返回时,最新选择已生效(实时保存),下次进入仍保留。 * * @param group 分段控件容器 * @param selected 初始选中项的 tag * @param cb 选项回调(用于应用变更和还原,必须幂等) * @param label 选项名称(如"核销方式") * @param labels 各选项文案数组(下标对应 tag) */ private void setupSegmentConfirm(ViewGroup group, int selected, SegCallback cb, String label, String[] labels) { final int[] current = {selected}; for (int i = 0; i < group.getChildCount(); i++) { View child = group.getChildAt(i); int tag = Integer.parseInt((String) child.getTag()); child.setOnClickListener(v -> { if (tag == current[0]) { return; } int oldTag = current[0]; // 先实时保存(不点保存直接返回也生效) updateSegment(group, tag); cb.onSelect(tag); confirmChange(label, labels[oldTag], labels[tag], () -> current[0] = tag, () -> { current[0] = oldTag; updateSegment(group, oldTag); cb.onSelect(oldTag); // 还原 AppPrefs 的值 }); }); } updateSegment(group, selected); } /** * 选项切换确认对话框:用户切换了不同选项时弹框询问「保留修改」或「还原旧值」。 */ private void confirmChange(String label, String oldVal, String newVal, Runnable onKeep, Runnable onRevert) { new androidx.appcompat.app.AlertDialog.Builder(this) .setTitle("确认修改") .setMessage("已将「" + label + "」从「" + oldVal + "」改为「" + newVal + "」\n是否保留此修改?") .setPositiveButton("保留", (d, w) -> { if (onKeep != null) { onKeep.run(); } }) .setNegativeButton("还原", (d, w) -> { if (onRevert != null) { onRevert.run(); } }) .setCancelable(false) .show(); } /** 「保存配置」按钮的实际保存逻辑:兜底同步所有 EditText 值 + 备份到外部存储 */ private void doSaveConfig() { try { if (etStaffId != null) { AppPrefs.setStaffId(etStaffId.getText().toString()); } if (etBaseUrl != null) { String url = etBaseUrl.getText().toString().trim(); if (AppPrefs.isValidUrl(url)) { AppPrefs.setBaseUrl(url); } } if (etDeviceCode != null) { AppPrefs.setDeviceCode(etDeviceCode.getText().toString()); } if (etMockPlate != null) { AppPrefs.setMockPlate(etMockPlate.getText().toString()); } if (etCloudAk != null) { AppPrefs.setCloudAk(etCloudAk.getText().toString()); } if (etCloudSk != null) { AppPrefs.setCloudSk(etCloudSk.getText().toString()); } if (etSerial != null) { AppPrefs.setLicenseSerial(etSerial.getText().toString()); } Toast.makeText(this, "配置保存成功", Toast.LENGTH_SHORT).show(); // 同时备份到外部存储(防重装丢失) AppPrefs.backupToExternal(this); } catch (Exception e) { Toast.makeText(this, "保存失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } }