@trustchex/react-native-sdk 1.478.7 → 1.481.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +57 -3
  2. package/ios/Camera/TrustchexCameraView.swift +20 -15
  3. package/lib/module/Screens/Static/ResultScreen.js +42 -6
  4. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  5. package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
  6. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  7. package/lib/module/Shared/EIDReader/eidReader.js +149 -29
  8. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  9. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  10. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  11. package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
  12. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  13. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  14. package/lib/module/Translation/Resources/en.js +5 -0
  15. package/lib/module/Translation/Resources/tr.js +5 -0
  16. package/lib/module/Trustchex.js +20 -4
  17. package/lib/module/version.js +1 -1
  18. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  19. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  20. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  21. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  23. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  26. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  29. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  30. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  31. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  32. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +33 -0
  33. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  35. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  37. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  38. package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
  39. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  40. package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -0
  41. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  42. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  43. package/lib/typescript/src/version.d.ts +1 -1
  44. package/package.json +5 -1
  45. package/src/Screens/Static/ResultScreen.tsx +46 -1
  46. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  47. package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
  48. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  49. package/src/Shared/Components/NavigationManager.tsx +7 -7
  50. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  51. package/src/Shared/EIDReader/eidReader.ts +166 -46
  52. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  53. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  54. package/src/Shared/Libs/diagnostics.ts +251 -0
  55. package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
  56. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  57. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  58. package/src/Translation/Resources/en.ts +6 -0
  59. package/src/Translation/Resources/tr.ts +6 -0
  60. package/src/Trustchex.tsx +26 -3
  61. package/src/version.ts +1 -1
@@ -9,6 +9,33 @@ import { DG2File } from "./lds/icao/dg2File.js";
9
9
  import { Buffer } from 'buffer';
10
10
  import { InputStream } from "./java/inputStream.js";
11
11
  import { debugLog } from "../Libs/debug.utils.js";
12
+ import { diagnostics } from "../Libs/diagnostics.js";
13
+
14
+ /**
15
+ * Extract an APDU status word (hex, e.g. "6982") from an error message if the
16
+ * protocol layer included one. Returns undefined when none is present. Kept
17
+ * deliberately loose — the protocol senders embed "SW=...." / "(6982)" forms.
18
+ */
19
+ const extractStatusWord = msg => {
20
+ const m = msg.match(/\b(?:SW[:= ]?)?([0-9a-fA-F]{4})\b/);
21
+ return m ? m[1].toUpperCase() : undefined;
22
+ };
23
+
24
+ /** Categorise an NFC error message into a coarse, non-PII bucket. */
25
+ const categorizeNfcError = msg => {
26
+ const m = msg.toLowerCase();
27
+ if (m.includes('cancel')) return 'user_cancelled';
28
+ if (m.includes('lost') || m.includes('steady')) return 'tag_lost';
29
+ if (m.includes('timeout')) return 'timeout';
30
+ if (m.includes('not supported') || m.includes('unsupported')) {
31
+ return 'unsupported';
32
+ }
33
+ if (m.includes('not enabled') || m.includes('disabled')) return 'not_enabled';
34
+ if (m.includes('auth') || m.includes('mutual') || m.includes('security')) {
35
+ return 'auth_failed';
36
+ }
37
+ return 'reading_error';
38
+ };
12
39
 
13
40
  // --- Minimal PNG encoder (pure JS, no native deps) ---
14
41
  // Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
@@ -275,8 +302,40 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
275
302
  let progress = 0;
276
303
  const nfcManagerCardService = new NFCManagerCardService();
277
304
  const passportService = new EIDService(nfcManagerCardService, EIDService.NORMAL_MAX_TRANSCEIVE_LENGTH, EIDService.DEFAULT_MAX_BLOCKSIZE, false, true);
305
+
306
+ // Structured NFC diagnostics: record each step's outcome + timing + APDU SW
307
+ // into the session collector so the result screen can surface device-specific
308
+ // read failures. Best-effort and side-effect-only — never alters the flow.
309
+ diagnostics.nfcAttemptStarted();
310
+ const stepTimer = () => {
311
+ const t0 = Date.now();
312
+ return (step, ok, error) => {
313
+ const msg = error instanceof Error ? error.message : error ? String(error) : undefined;
314
+ // Store only the APDU status word and a COARSE category — never the raw
315
+ // error text, which could in theory embed field/PII values. The SW + step
316
+ // name + category carry all the actionable signal.
317
+ diagnostics.nfcStep({
318
+ step,
319
+ ok,
320
+ ms: Date.now() - t0,
321
+ sw: msg ? extractStatusWord(msg) : undefined,
322
+ error: msg ? categorizeNfcError(msg) : undefined
323
+ });
324
+ };
325
+ };
278
326
  try {
279
- await passportService.open();
327
+ {
328
+ const done = stepTimer();
329
+ try {
330
+ await passportService.open();
331
+ // Reaching open() without throwing means the chip powered on / was seen.
332
+ diagnostics.nfcChipDetected();
333
+ done('open', true);
334
+ } catch (e) {
335
+ done('open', false, e);
336
+ throw e;
337
+ }
338
+ }
280
339
  progress = 1;
281
340
  if (progressCallback) {
282
341
  progressCallback(progress);
@@ -286,12 +345,17 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
286
345
  // Explicitly SELECT MF first so the SFI=0x1C context is correct,
287
346
  // regardless of which application the chip activates on power-on.
288
347
  let hasPACESucceeded = false;
289
- try {
290
- await passportService.sendSelectMF();
291
- debugLog('EID', '[EID] SELECT MF OK');
292
- } catch (mfErr) {
293
- const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
294
- debugLog('EID', `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`);
348
+ {
349
+ const done = stepTimer();
350
+ try {
351
+ await passportService.sendSelectMF();
352
+ done('selectMF', true);
353
+ debugLog('EID', '[EID] SELECT MF OK');
354
+ } catch (mfErr) {
355
+ done('selectMF', false, mfErr);
356
+ const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
357
+ debugLog('EID', `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`);
358
+ }
295
359
  }
296
360
  progress = 2;
297
361
  if (progressCallback) {
@@ -301,19 +365,24 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
301
365
  // Read EF.CardAccess — kept in a separate try/catch so we can distinguish
302
366
  // "file read failure" from "PACE protocol failure" in the logs.
303
367
  let cardAccessBuf = null;
304
- try {
305
- const cardAccessStream = passportService.getInputStream(EIDService.EF_CARD_ACCESS);
306
- await cardAccessStream.init();
307
- const cardAccessLen = cardAccessStream.getLength();
308
- const buf = Buffer.alloc(cardAccessLen);
309
- for (let i = 0; i < cardAccessLen; i++) {
310
- buf[i] = await cardAccessStream.read();
368
+ {
369
+ const done = stepTimer();
370
+ try {
371
+ const cardAccessStream = passportService.getInputStream(EIDService.EF_CARD_ACCESS);
372
+ await cardAccessStream.init();
373
+ const cardAccessLen = cardAccessStream.getLength();
374
+ const buf = Buffer.alloc(cardAccessLen);
375
+ for (let i = 0; i < cardAccessLen; i++) {
376
+ buf[i] = await cardAccessStream.read();
377
+ }
378
+ cardAccessBuf = buf;
379
+ done('readCardAccess', true);
380
+ debugLog('EID', `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`);
381
+ } catch (readErr) {
382
+ done('readCardAccess', false, readErr);
383
+ const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
384
+ debugLog('EID', `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`);
311
385
  }
312
- cardAccessBuf = buf;
313
- debugLog('EID', `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`);
314
- } catch (readErr) {
315
- const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
316
- debugLog('EID', `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`);
317
386
  }
318
387
  progress = 3;
319
388
  if (progressCallback) {
@@ -321,11 +390,17 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
321
390
  }
322
391
 
323
392
  // If EF.CardAccess was read successfully, attempt PACE.
393
+ if (cardAccessBuf === null) {
394
+ // EF.CardAccess unreadable (the readCardAccess step already recorded the
395
+ // failure/SW) → PACE can't be attempted; BAC will be used.
396
+ diagnostics.nfcPaceFellBackToBac('no_card_access');
397
+ }
324
398
  if (cardAccessBuf !== null) {
325
399
  try {
326
400
  const paceInfo = parsePACEInfoFromCardAccess(cardAccessBuf);
327
401
  if (paceInfo) {
328
402
  debugLog('EID', `[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`);
403
+ diagnostics.nfcSetAuth('PACE', paceInfo.oid);
329
404
  // PACE is available - derive key from MRZ and perform PACE
330
405
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
331
406
  const paceKey = PACEKeySpec.createMRZKey(bacKey.getKeySeedForPACE());
@@ -333,14 +408,23 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
333
408
  if (progressCallback) {
334
409
  progressCallback(progress);
335
410
  }
336
- await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
337
- hasPACESucceeded = true;
338
- debugLog('EID', '[EID] PACE succeeded');
411
+ const done = stepTimer();
412
+ try {
413
+ await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
414
+ hasPACESucceeded = true;
415
+ done('paceAuth', true);
416
+ debugLog('EID', '[EID] PACE succeeded');
417
+ } catch (paceErr) {
418
+ done('paceAuth', false, paceErr);
419
+ throw paceErr;
420
+ }
339
421
  } else {
422
+ diagnostics.nfcPaceFellBackToBac('no_pace_info');
340
423
  debugLog('EID', '[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC');
341
424
  }
342
425
  } catch (paceError) {
343
426
  // PACE protocol exchange failed — fall back to BAC
427
+ diagnostics.nfcPaceFellBackToBac('pace_failed');
344
428
  const msg = paceError instanceof Error ? paceError.message : String(paceError);
345
429
  debugLog('EID', `[EID] PACE protocol failed (${msg}), falling back to BAC`);
346
430
  if (paceError instanceof Error && paceError.stack) {
@@ -354,7 +438,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
354
438
  }
355
439
 
356
440
  // Select Applet for MRTD
357
- await passportService.sendSelectApplet(hasPACESucceeded);
441
+ {
442
+ const done = stepTimer();
443
+ try {
444
+ await passportService.sendSelectApplet(hasPACESucceeded);
445
+ done('selectApplet', true);
446
+ } catch (e) {
447
+ done('selectApplet', false, e);
448
+ throw e;
449
+ }
450
+ }
358
451
  progress = 10;
359
452
  if (progressCallback) {
360
453
  progressCallback(progress);
@@ -367,8 +460,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
367
460
  await efComStream.read();
368
461
  } catch (error) {
369
462
  // EF_COM not available -> try to do BAC
463
+ diagnostics.nfcSetAuth('BAC');
370
464
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
371
- await passportService.doBAC(bacKey);
465
+ const done = stepTimer();
466
+ try {
467
+ await passportService.doBAC(bacKey);
468
+ done('bacAuth', true);
469
+ } catch (e) {
470
+ done('bacAuth', false, e);
471
+ throw e;
472
+ }
372
473
  }
373
474
  }
374
475
  progress = 20;
@@ -377,16 +478,25 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
377
478
  }
378
479
 
379
480
  // Read DG1 (MRZ Info)
380
- const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
381
- await dg1InputStream.init();
382
- const dg1File = new DG1File(undefined, dg1InputStream);
383
- const mrzInfo = await dg1File.getMRZInfo();
481
+ const dg1Done = stepTimer();
482
+ let mrzInfo;
483
+ try {
484
+ const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
485
+ await dg1InputStream.init();
486
+ const dg1File = new DG1File(undefined, dg1InputStream);
487
+ mrzInfo = await dg1File.getMRZInfo();
488
+ dg1Done('readDG1', true);
489
+ } catch (e) {
490
+ dg1Done('readDG1', false, e);
491
+ throw e;
492
+ }
384
493
  progress = 30;
385
494
  if (progressCallback) {
386
495
  progressCallback(progress);
387
496
  }
388
497
 
389
498
  // Read DG2 (Face Image)
499
+ const dg2Done = stepTimer();
390
500
  const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
391
501
  await dg2InputStream.init();
392
502
  InputStream.onRead((totalBytesRead, fileLength) => {
@@ -399,7 +509,14 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
399
509
  let imageAsBase64 = '';
400
510
  let mimeType = '';
401
511
  const allFaceImageInfos = [];
402
- const faceInfos = await dg2File.getFaceInfos();
512
+ let faceInfos;
513
+ try {
514
+ faceInfos = await dg2File.getFaceInfos();
515
+ dg2Done('readDG2', true);
516
+ } catch (e) {
517
+ dg2Done('readDG2', false, e);
518
+ throw e;
519
+ }
403
520
  for (let faceInfo of faceInfos) {
404
521
  allFaceImageInfos.push(...faceInfo.getFaceImageInfos());
405
522
  }
@@ -418,12 +535,15 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
418
535
  if (progressCallback) {
419
536
  progressCallback(progress);
420
537
  }
538
+ diagnostics.nfcResult(true);
421
539
  return {
422
540
  mrz: mrzInfo,
423
541
  faceImage: imageAsBase64,
424
542
  mimeType: mimeType
425
543
  };
426
544
  } catch (error) {
545
+ const msg = error instanceof Error ? error.message : String(error);
546
+ diagnostics.nfcResult(false, categorizeNfcError(msg));
427
547
  debugLog('EID', 'Error reading passport data', error);
428
548
  } finally {
429
549
  await passportService.close();
@@ -118,6 +118,13 @@ Covered by the "real letters in names are not eaten" tests in `mrz.utils.test.ts
118
118
  30–44-char MRZ line below that floor, so the native pipeline **crops to the
119
119
  on-screen scan frame and upscales + grayscale/contrast-enhances** before OCR.
120
120
  The MRZ band must be reasonably filled within the on-screen guide.
121
+ - Two distinct regions exist in the native code (don't confuse them):
122
+ the **MRZ-OCR crop** (`buildScanFrameRoiImage`: top 25% / bottom 12% / sides
123
+ 4%, upscaled ≤2.5× to ≤2200px, grayscale, contrast 1.6×) and the
124
+ **brightness-sampling region** (the on-screen guide: 36% / 36% / 5%). Both are
125
+ the same on iOS and Android. The diagnostic report records the measured MRZ
126
+ band pixel height so you can tell when a device leaves the band below the
127
+ ~16 px/char floor.
121
128
  - **Glare / lamination.** Heavy glare on a glossy laminated card still degrades
122
129
  OCR; the enhancement helps but does not eliminate it. Steadying the card and
123
130
  reducing direct glare improves convergence.
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Builds the support diagnostic report from a scan session.
5
+ *
6
+ * Produces three things the result screen turns into a reviewable email:
7
+ * - `diagnostics.json` — technical signals (device, camera, MRZ, NFC steps).
8
+ * - `scan-data.json` — the scanned identity data + raw MRZ (PII; the user
9
+ * reviews it in the draft and chooses to send).
10
+ * - a short, size-bounded plain-text BODY summary that always fits an email.
11
+ *
12
+ * The full detail lives in the JSON attachments; the email body is intentionally
13
+ * compact so the draft opens reliably on every mail client.
14
+ */
15
+
16
+ /** ICAO 9303 7-3-1 weighted check digit (local copy; mirrors mrz.utils). */
17
+ const icaoCheckDigit = str => {
18
+ const weights = [7, 3, 1];
19
+ let sum = 0;
20
+ for (let i = 0; i < str.length; i++) {
21
+ const c = str[i];
22
+ const v = c === '<' ? 0 : c >= '0' && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
23
+ sum = (sum + weights[i % 3] * v) % 10;
24
+ }
25
+ return String(sum);
26
+ };
27
+ /** Compute per-field check-digit pass/fail for a TD1 MRZ (best-effort). */
28
+ const computeTd1CheckDigits = mrzText => {
29
+ if (!mrzText) return undefined;
30
+ const lines = mrzText.split('\n').map(l => l.trim()).filter(Boolean);
31
+ if (lines.length < 2 || lines[0].length < 30 || lines[1].length < 30) {
32
+ return undefined; // only TD1 handled here; other formats omitted from summary
33
+ }
34
+ const l1 = lines[0];
35
+ const l2 = lines[1];
36
+ const result = {};
37
+ const check = (data, printed) => icaoCheckDigit(data) === printed ? 'pass' : 'fail';
38
+
39
+ // Turkish convention: a doc number may carry a leading serial LETTER, and the
40
+ // doc-number check digit is computed over the DIGITS ONLY (letter dropped).
41
+ // The TD1 composite then also diverges from strict ICAO for such cards (no
42
+ // interpretation of the letter reproduces it). To avoid reporting a misleading
43
+ // "fail" on a card the SDK legitimately accepted, detect the letter-prefix and
44
+ // treat both the doc-number and composite as validated under that convention.
45
+ const docData = l1.slice(5, 14);
46
+ const docPrinted = l1[14];
47
+ const hasLetterPrefix = /^[A-Z]/.test(docData);
48
+ const strictDocOk = icaoCheckDigit(docData) === docPrinted;
49
+ const turkishDocOk = hasLetterPrefix && icaoCheckDigit(docData.slice(1)) === docPrinted;
50
+ const docOk = strictDocOk || turkishDocOk;
51
+ result.documentNumber = docOk ? 'pass' : 'fail';
52
+ result.birthDate = check(l2.slice(0, 6), l2[6]);
53
+ result.expiry = check(l2.slice(8, 14), l2[14]);
54
+ const composite = l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
55
+ const compStrict = check(composite, l2[29]);
56
+ // If the strict composite fails ONLY because of the Turkish letter-prefix
57
+ // doc number (which itself passed under the digits-only rule), don't surface a
58
+ // misleading failure — the SDK accepts these cards.
59
+ result.composite = compStrict === 'fail' && hasLetterPrefix && docOk ? 'pass' : compStrict;
60
+ // The Turkish convention was the deciding factor only when strict ICAO would
61
+ // have failed the doc number but the digits-only rule passes it.
62
+ const turkishLetterPrefix = !strictDocOk && turkishDocOk;
63
+ return {
64
+ checkDigits: result,
65
+ turkishLetterPrefix
66
+ };
67
+ };
68
+ const MAX_BODY_BYTES = 4096;
69
+ const yesNo = b => b ? 'yes' : 'no';
70
+
71
+ /** Assemble the full report bundle. Pure — no I/O, no native calls. */
72
+ export const buildDiagnosticReport = params => {
73
+ const {
74
+ diag,
75
+ device,
76
+ scan,
77
+ sdkVersion,
78
+ sessionId
79
+ } = params;
80
+ const computed = computeTd1CheckDigits(scan.mrzText);
81
+ const checkDigits = diag.mrz.checkDigits ?? computed?.checkDigits;
82
+ // Set the Turkish-letter-prefix flag from the final accepted MRZ (the collector
83
+ // can't know it during scanning; the report does, from the MRZ text).
84
+ const turkishLetterPrefixApplied = diag.mrz.turkishLetterPrefixApplied || computed?.turkishLetterPrefix === true;
85
+
86
+ // --- diagnostics.json (technical, low-PII) ---
87
+ const diagnosticsObj = {
88
+ schemaVersion: diag.schemaVersion,
89
+ generatedAt: diag.generatedAt,
90
+ sessionId: sessionId ?? null,
91
+ sdk: {
92
+ platform: device.platform,
93
+ sdkVersion,
94
+ buildNumber: device.buildNumber
95
+ },
96
+ device,
97
+ scan: {
98
+ documentType: scan.documentType ?? diag.documentType ?? null,
99
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
100
+ totalDurationMs: diag.totalDurationMs,
101
+ camera: diag.camera
102
+ },
103
+ mrz: {
104
+ ...diag.mrz,
105
+ checkDigits: checkDigits ?? null,
106
+ turkishLetterPrefixApplied
107
+ },
108
+ nfc: diag.nfc,
109
+ errors: diag.errors
110
+ };
111
+
112
+ // --- scan-data.json (PII; user reviews before sending) ---
113
+ const scanDataObj = {
114
+ documentType: scan.documentType ?? diag.documentType ?? null,
115
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
116
+ mrzText: scan.mrzText ?? null,
117
+ fields: scan.mrzFields ?? null,
118
+ barcodeValue: scan.barcodeValue ?? null
119
+ };
120
+
121
+ // --- compact email body (always under MAX_BODY_BYTES) ---
122
+ const nfc = diag.nfc;
123
+ const nfcLine = nfc.attempted ? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${nfc.succeeded ? 'success' : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'})`}` : 'not attempted';
124
+ const checksLine = checkDigits ? Object.entries(checkDigits).map(([k, v]) => `${k}:${v}`).join(' ') : 'n/a';
125
+ const body = ['TRUSTCHEX SCAN REPORT', `Device: ${device.brand} ${device.model} · ${device.systemName} ${device.systemVersion}${device.apiLevel ? ` (API ${device.apiLevel})` : ''}`, `SDK: ${sdkVersion} (build ${device.buildNumber}) · ${device.manufacturer} · ${device.deviceId}`, `Document: ${scanDataObj.documentType ?? 'unknown'} · source: ${scanDataObj.dataSource ?? 'unknown'}`, `MRZ: ${diag.mrz.reachedStable ? 'stable' : 'NOT stable'} after ${diag.mrz.framesProcessed} frames · checks: ${checksLine}`, `Camera: ${diag.camera.frames} frames · brightness ${diag.camera.avgBrightness} (low:${yesNo(diag.camera.hadLowBrightness)} blur:${yesNo(diag.camera.hadBlurryFrames)})`, `NFC: ${nfcLine}`, sessionId ? `Session: ${sessionId}` : '', '', 'Describe the issue here:', '', '', '— Full diagnostics and document images are attached. Please review before sending; do not edit the attachments. —'].filter(Boolean).join('\n');
126
+ const subject = `Trustchex scan report — ${scanDataObj.documentType ?? 'doc'} — ${device.brand} ${device.model} — v${sdkVersion}${sessionId ? ` — ${sessionId}` : ''}`;
127
+ return {
128
+ diagnosticsJson: JSON.stringify(diagnosticsObj, null, 2),
129
+ scanDataJson: JSON.stringify(scanDataObj, null, 2),
130
+ body: body.length > MAX_BODY_BYTES ? body.slice(0, MAX_BODY_BYTES - 1) + '…' : body,
131
+ subject
132
+ };
133
+ };
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Diagnostics collector for the "Report a scanning issue" feature.
5
+ *
6
+ * Accumulates a PII-light technical snapshot of a single scan session — camera
7
+ * quality, MRZ consensus, and (the highest-value, most device-specific part) a
8
+ * structured per-step NFC/eID read log. The result screen serialises this into
9
+ * the diagnostic email the user reviews and sends to support.
10
+ *
11
+ * Design notes:
12
+ * - This is a MODULE-LEVEL SINGLETON so the deep camera/EID call stack can record
13
+ * into it without threading a prop/context through every layer. Call `reset()`
14
+ * at the start of a scan flow.
15
+ * - It stores only TECHNICAL signals (counts, timings, brightness, APDU status
16
+ * words, pass/fail). It deliberately holds NO identity data, images, MRZ text,
17
+ * keys, or nonces — the scanned identity/images shown to the user come from the
18
+ * result screen's own state, not from here, and are attached separately with
19
+ * the user's explicit review in the email draft.
20
+ */
21
+
22
+ /** A single step of the NFC/eID read, with its outcome and timing. */
23
+
24
+ const emptyCamera = () => ({
25
+ frames: 0,
26
+ frameW: 0,
27
+ frameH: 0,
28
+ avgBrightness: 0,
29
+ minBrightness: 0,
30
+ maxBrightness: 0,
31
+ hadLowBrightness: false,
32
+ hadBlurryFrames: false
33
+ });
34
+ const emptyMrz = () => ({
35
+ framesProcessed: 0,
36
+ reachedStable: false,
37
+ stabilityFrames: 0,
38
+ minContestedMargin: 0,
39
+ turkishLetterPrefixApplied: false
40
+ });
41
+ const emptyNfc = () => ({
42
+ attempted: false,
43
+ chipDetected: false,
44
+ authProtocol: 'none',
45
+ paceFellBackToBac: false,
46
+ steps: [],
47
+ retryCount: 0,
48
+ succeeded: false
49
+ });
50
+
51
+ /**
52
+ * Accumulates one scan session's diagnostics. A single shared instance is
53
+ * exported below; tests can construct their own.
54
+ */
55
+ export class DiagnosticsCollector {
56
+ startedAtMs = 0;
57
+ camera = emptyCamera();
58
+ mrz = emptyMrz();
59
+ nfc = emptyNfc();
60
+ errors = [];
61
+
62
+ /** Begin a fresh session — call when a scan flow starts. */
63
+ reset(nowMs) {
64
+ this.startedAtMs = nowMs;
65
+ this.documentType = undefined;
66
+ this.dataSource = undefined;
67
+ this.camera = emptyCamera();
68
+ this.mrz = emptyMrz();
69
+ this.nfc = emptyNfc();
70
+ this.errors = [];
71
+ }
72
+ setDocument(documentType, dataSource) {
73
+ if (documentType) this.documentType = documentType;
74
+ if (dataSource) this.dataSource = dataSource;
75
+ }
76
+
77
+ /** Merge a camera snapshot (called periodically as frames arrive). */
78
+ setCamera(c) {
79
+ this.camera = {
80
+ ...this.camera,
81
+ ...c
82
+ };
83
+ }
84
+
85
+ /** Merge MRZ consensus metrics. `reachedStable` is STICKY (once true, stays
86
+ * true) so a later frame's flicker can't unset that the scan did stabilise;
87
+ * `maxMrzBandHeightPx` keeps the largest seen. */
88
+ setMrz(m) {
89
+ const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
90
+ const maxMrzBandHeightPx = Math.max(this.mrz.maxMrzBandHeightPx ?? 0, m.maxMrzBandHeightPx ?? 0) || undefined;
91
+ this.mrz = {
92
+ ...this.mrz,
93
+ ...m,
94
+ reachedStable,
95
+ maxMrzBandHeightPx
96
+ };
97
+ }
98
+
99
+ // ---- NFC: the structured per-step log ----
100
+
101
+ nfcAttemptStarted() {
102
+ // A fresh attempt: bump retry count if a previous attempt left steps.
103
+ if (this.nfc.attempted) this.nfc.retryCount += 1;
104
+ this.nfc.attempted = true;
105
+ }
106
+ nfcChipDetected() {
107
+ this.nfc.chipDetected = true;
108
+ }
109
+ nfcSetAuth(protocol, oid) {
110
+ this.nfc.authProtocol = protocol;
111
+ if (oid) this.nfc.paceOid = oid;
112
+ }
113
+ nfcPaceFellBackToBac(reason) {
114
+ this.nfc.paceFellBackToBac = true;
115
+ if (reason && !this.nfc.paceFallbackReason) {
116
+ this.nfc.paceFallbackReason = reason;
117
+ }
118
+ }
119
+
120
+ /** Record one completed step. Keep messages short and free of PII. */
121
+ nfcStep(step) {
122
+ this.nfc.steps.push(step);
123
+ if (!step.ok && !this.nfc.failureStep) {
124
+ this.nfc.failureStep = step.step;
125
+ }
126
+ }
127
+ nfcResult(succeeded, errorCategory) {
128
+ this.nfc.succeeded = succeeded;
129
+ if (succeeded) {
130
+ // The read ultimately succeeded — transient per-attempt failures (e.g. a
131
+ // recovered DG2 retry, or the expected EF.CardAccess 6982 on a BAC-only
132
+ // card) must NOT be reported as the outcome. Clear the failure markers.
133
+ this.nfc.failureStep = undefined;
134
+ this.nfc.errorCategory = undefined;
135
+ } else if (errorCategory) {
136
+ this.nfc.errorCategory = errorCategory;
137
+ }
138
+ }
139
+ addError(e) {
140
+ this.errors.push(e);
141
+ }
142
+
143
+ /** Build the serialisable snapshot. */
144
+ snapshot(nowMs, isoNow) {
145
+ return {
146
+ schemaVersion: 1,
147
+ generatedAt: isoNow,
148
+ documentType: this.documentType,
149
+ dataSource: this.dataSource,
150
+ totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
151
+ camera: {
152
+ ...this.camera
153
+ },
154
+ mrz: {
155
+ ...this.mrz
156
+ },
157
+ nfc: {
158
+ ...this.nfc,
159
+ steps: this.nfc.steps.map(s => ({
160
+ ...s
161
+ }))
162
+ },
163
+ errors: this.errors.map(e => ({
164
+ ...e
165
+ }))
166
+ };
167
+ }
168
+ }
169
+
170
+ /** Shared session-scoped collector used across the scan flow. */
171
+ export const diagnostics = new DiagnosticsCollector();
@@ -40,6 +40,12 @@ const FORMAT_GEOMETRY = {
40
40
  }
41
41
  };
42
42
  const MRZ_CHAR = /^[A-Z0-9<]$/;
43
+ /**
44
+ * Floor applied to a frame's confidence multiplier. A frame ML Kit was very
45
+ * unsure about still contributes (it may carry the only reading of a cell), but
46
+ * at a fraction of a confident frame's weight.
47
+ */
48
+ const MIN_CONFIDENCE_WEIGHT = 0.25;
43
49
  /**
44
50
  * Reconstructs fixed-width MRZ candidate line-sets for a frame's raw OCR text.
45
51
  * Reuses the same reconstruction the single-frame validator uses, so the
@@ -181,7 +187,12 @@ export class MRZFrameAggregator {
181
187
  * consensus is returned.
182
188
  */
183
189
  addFrame(input) {
184
- const weight = input.weight && input.weight > 0 ? input.weight : 1;
190
+ const baseWeight = input.weight && input.weight > 0 ? input.weight : 1;
191
+ // Fold OCR confidence into the weight (clamped to [floor, 1]). Absent
192
+ // confidence leaves the weight unchanged, so callers without per-symbol
193
+ // confidence (e.g. iOS ML Kit) keep the prior sharpness-only behaviour.
194
+ const confidenceFactor = typeof input.confidence === 'number' && Number.isFinite(input.confidence) ? Math.min(1, Math.max(MIN_CONFIDENCE_WEIGHT, input.confidence)) : 1;
195
+ const weight = baseWeight * confidenceFactor;
185
196
  const lines = linesForFrame(input.text);
186
197
  if (lines) {
187
198
  // Determine the format this frame's lines fit (by line count + width).
@@ -244,18 +255,30 @@ export class MRZFrameAggregator {
244
255
  * is what turns "looks valid this frame" into "the camera agrees across frames".
245
256
  */
246
257
  consensusIsConfident(format) {
258
+ return this.minContestedMargin(format) >= this.minMargin;
259
+ }
260
+
261
+ /**
262
+ * Smallest winning vote margin across all CONTESTED cells (where >1 distinct
263
+ * char was voted). Returns Infinity when no cell is contested. Used both to
264
+ * gate confidence and to surface a diagnostic of how narrowly a look-alike was
265
+ * resolved at acceptance.
266
+ */
267
+ minContestedMargin(format) {
247
268
  const geom = FORMAT_GEOMETRY[format];
248
269
  const tally = this.tallies[format];
270
+ let min = Infinity;
249
271
  for (let li = 0; li < geom.lines; li++) {
250
272
  for (let ci = 0; ci < geom.width; ci++) {
251
273
  const cell = tally[li][ci];
252
274
  const weights = Object.values(cell);
253
275
  if (weights.length < 2) continue; // uncontested cell — no ambiguity
254
276
  weights.sort((a, b) => b - a);
255
- if (weights[0] - weights[1] < this.minMargin) return false;
277
+ const margin = weights[0] - weights[1];
278
+ if (margin < min) min = margin;
256
279
  }
257
280
  }
258
- return true;
281
+ return min;
259
282
  }
260
283
 
261
284
  /** Read out the winning character per cell for a format. */
@@ -293,7 +316,9 @@ export class MRZFrameAggregator {
293
316
  mrz: null,
294
317
  validation: null,
295
318
  frames: this.frameCount,
296
- stable: false
319
+ stable: false,
320
+ stableStreak: 0,
321
+ minContestedMargin: Infinity
297
322
  };
298
323
  }
299
324
  let bestMrz = null;
@@ -338,7 +363,9 @@ export class MRZFrameAggregator {
338
363
  mrz: bestMrz,
339
364
  validation: bestValidation,
340
365
  frames: this.frameCount,
341
- stable: bestValid && confident && this.stableStreak >= this.stabilityTarget
366
+ stable: bestValid && confident && this.stableStreak >= this.stabilityTarget,
367
+ stableStreak: this.stableStreak,
368
+ minContestedMargin: bestFormat != null ? this.minContestedMargin(bestFormat) : Infinity
342
369
  };
343
370
  }
344
371
  }