Blame view

app/src/main/java/com/ectrip/checkincar/ui/settings/SettingsActivity.java 28.2 KB
0fd093a2   李进   初始提交:手持车牌核销应用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
  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();
          }
      }
  }