@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 { Buffer } from 'buffer';
9
9
  import { MRZInfo } from './lds/icao/mrzInfo';
10
10
  import { InputStream } from './java/inputStream';
11
11
  import { debugLog } from '../Libs/debug.utils';
12
+ import { diagnostics } from '../Libs/diagnostics';
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: string): string | undefined => {
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: string): string => {
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.
@@ -307,8 +334,40 @@ const eidReader = async (
307
334
  true
308
335
  );
309
336
 
337
+ // Structured NFC diagnostics: record each step's outcome + timing + APDU SW
338
+ // into the session collector so the result screen can surface device-specific
339
+ // read failures. Best-effort and side-effect-only — never alters the flow.
340
+ diagnostics.nfcAttemptStarted();
341
+ const stepTimer = () => {
342
+ const t0 = Date.now();
343
+ return (step: string, ok: boolean, error?: unknown) => {
344
+ const msg = error instanceof Error ? error.message : error ? String(error) : undefined;
345
+ // Store only the APDU status word and a COARSE category — never the raw
346
+ // error text, which could in theory embed field/PII values. The SW + step
347
+ // name + category carry all the actionable signal.
348
+ diagnostics.nfcStep({
349
+ step,
350
+ ok,
351
+ ms: Date.now() - t0,
352
+ sw: msg ? extractStatusWord(msg) : undefined,
353
+ error: msg ? categorizeNfcError(msg) : undefined,
354
+ });
355
+ };
356
+ };
357
+
310
358
  try {
311
- await passportService.open();
359
+ {
360
+ const done = stepTimer();
361
+ try {
362
+ await passportService.open();
363
+ // Reaching open() without throwing means the chip powered on / was seen.
364
+ diagnostics.nfcChipDetected();
365
+ done('open', true);
366
+ } catch (e) {
367
+ done('open', false, e);
368
+ throw e;
369
+ }
370
+ }
312
371
 
313
372
  progress = 1;
314
373
  if (progressCallback) {
@@ -320,15 +379,20 @@ const eidReader = async (
320
379
  // regardless of which application the chip activates on power-on.
321
380
  let hasPACESucceeded = false;
322
381
 
323
- try {
324
- await passportService.sendSelectMF();
325
- debugLog('EID', '[EID] SELECT MF OK');
326
- } catch (mfErr) {
327
- const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
328
- debugLog(
329
- 'EID',
330
- `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`
331
- );
382
+ {
383
+ const done = stepTimer();
384
+ try {
385
+ await passportService.sendSelectMF();
386
+ done('selectMF', true);
387
+ debugLog('EID', '[EID] SELECT MF OK');
388
+ } catch (mfErr) {
389
+ done('selectMF', false, mfErr);
390
+ const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
391
+ debugLog(
392
+ 'EID',
393
+ `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`
394
+ );
395
+ }
332
396
  }
333
397
 
334
398
  progress = 2;
@@ -339,28 +403,33 @@ const eidReader = async (
339
403
  // Read EF.CardAccess — kept in a separate try/catch so we can distinguish
340
404
  // "file read failure" from "PACE protocol failure" in the logs.
341
405
  let cardAccessBuf: Buffer | null = null;
342
- try {
343
- const cardAccessStream = passportService.getInputStream(
344
- EIDService.EF_CARD_ACCESS
345
- );
346
- await cardAccessStream.init();
347
- const cardAccessLen = cardAccessStream.getLength();
348
- const buf = Buffer.alloc(cardAccessLen);
349
- for (let i = 0; i < cardAccessLen; i++) {
350
- buf[i] = await cardAccessStream.read();
406
+ {
407
+ const done = stepTimer();
408
+ try {
409
+ const cardAccessStream = passportService.getInputStream(
410
+ EIDService.EF_CARD_ACCESS
411
+ );
412
+ await cardAccessStream.init();
413
+ const cardAccessLen = cardAccessStream.getLength();
414
+ const buf = Buffer.alloc(cardAccessLen);
415
+ for (let i = 0; i < cardAccessLen; i++) {
416
+ buf[i] = await cardAccessStream.read();
417
+ }
418
+ cardAccessBuf = buf;
419
+ done('readCardAccess', true);
420
+ debugLog(
421
+ 'EID',
422
+ `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`
423
+ );
424
+ } catch (readErr) {
425
+ done('readCardAccess', false, readErr);
426
+ const readMsg =
427
+ readErr instanceof Error ? readErr.message : String(readErr);
428
+ debugLog(
429
+ 'EID',
430
+ `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`
431
+ );
351
432
  }
352
- cardAccessBuf = buf;
353
- debugLog(
354
- 'EID',
355
- `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`
356
- );
357
- } catch (readErr) {
358
- const readMsg =
359
- readErr instanceof Error ? readErr.message : String(readErr);
360
- debugLog(
361
- 'EID',
362
- `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`
363
- );
364
433
  }
365
434
 
366
435
  progress = 3;
@@ -369,6 +438,11 @@ const eidReader = async (
369
438
  }
370
439
 
371
440
  // If EF.CardAccess was read successfully, attempt PACE.
441
+ if (cardAccessBuf === null) {
442
+ // EF.CardAccess unreadable (the readCardAccess step already recorded the
443
+ // failure/SW) → PACE can't be attempted; BAC will be used.
444
+ diagnostics.nfcPaceFellBackToBac('no_card_access');
445
+ }
372
446
  if (cardAccessBuf !== null) {
373
447
  try {
374
448
  const paceInfo = parsePACEInfoFromCardAccess(cardAccessBuf);
@@ -378,6 +452,7 @@ const eidReader = async (
378
452
  'EID',
379
453
  `[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`
380
454
  );
455
+ diagnostics.nfcSetAuth('PACE', paceInfo.oid);
381
456
  // PACE is available - derive key from MRZ and perform PACE
382
457
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
383
458
  const paceKey = PACEKeySpec.createMRZKey(bacKey.getKeySeedForPACE());
@@ -387,15 +462,23 @@ const eidReader = async (
387
462
  progressCallback(progress);
388
463
  }
389
464
 
390
- await passportService.doPACE(
391
- paceKey,
392
- paceInfo.oid,
393
- paceInfo.parameterId,
394
- progressCallback
395
- );
396
- hasPACESucceeded = true;
397
- debugLog('EID', '[EID] PACE succeeded');
465
+ const done = stepTimer();
466
+ try {
467
+ await passportService.doPACE(
468
+ paceKey,
469
+ paceInfo.oid,
470
+ paceInfo.parameterId,
471
+ progressCallback
472
+ );
473
+ hasPACESucceeded = true;
474
+ done('paceAuth', true);
475
+ debugLog('EID', '[EID] PACE succeeded');
476
+ } catch (paceErr) {
477
+ done('paceAuth', false, paceErr);
478
+ throw paceErr;
479
+ }
398
480
  } else {
481
+ diagnostics.nfcPaceFellBackToBac('no_pace_info');
399
482
  debugLog(
400
483
  'EID',
401
484
  '[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC'
@@ -403,6 +486,7 @@ const eidReader = async (
403
486
  }
404
487
  } catch (paceError) {
405
488
  // PACE protocol exchange failed — fall back to BAC
489
+ diagnostics.nfcPaceFellBackToBac('pace_failed');
406
490
  const msg =
407
491
  paceError instanceof Error ? paceError.message : String(paceError);
408
492
  debugLog(
@@ -421,7 +505,16 @@ const eidReader = async (
421
505
  }
422
506
 
423
507
  // Select Applet for MRTD
424
- await passportService.sendSelectApplet(hasPACESucceeded);
508
+ {
509
+ const done = stepTimer();
510
+ try {
511
+ await passportService.sendSelectApplet(hasPACESucceeded);
512
+ done('selectApplet', true);
513
+ } catch (e) {
514
+ done('selectApplet', false, e);
515
+ throw e;
516
+ }
517
+ }
425
518
 
426
519
  progress = 10;
427
520
  if (progressCallback) {
@@ -436,8 +529,16 @@ const eidReader = async (
436
529
  await efComStream.read();
437
530
  } catch (error) {
438
531
  // EF_COM not available -> try to do BAC
532
+ diagnostics.nfcSetAuth('BAC');
439
533
  const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
440
- await passportService.doBAC(bacKey);
534
+ const done = stepTimer();
535
+ try {
536
+ await passportService.doBAC(bacKey);
537
+ done('bacAuth', true);
538
+ } catch (e) {
539
+ done('bacAuth', false, e);
540
+ throw e;
541
+ }
441
542
  }
442
543
  }
443
544
 
@@ -447,10 +548,18 @@ const eidReader = async (
447
548
  }
448
549
 
449
550
  // Read DG1 (MRZ Info)
450
- const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
451
- await dg1InputStream.init();
452
- const dg1File = new DG1File(undefined, dg1InputStream);
453
- const mrzInfo = await dg1File.getMRZInfo();
551
+ const dg1Done = stepTimer();
552
+ let mrzInfo: MRZInfo;
553
+ try {
554
+ const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
555
+ await dg1InputStream.init();
556
+ const dg1File = new DG1File(undefined, dg1InputStream);
557
+ mrzInfo = await dg1File.getMRZInfo();
558
+ dg1Done('readDG1', true);
559
+ } catch (e) {
560
+ dg1Done('readDG1', false, e);
561
+ throw e;
562
+ }
454
563
 
455
564
  progress = 30;
456
565
  if (progressCallback) {
@@ -458,6 +567,7 @@ const eidReader = async (
458
567
  }
459
568
 
460
569
  // Read DG2 (Face Image)
570
+ const dg2Done = stepTimer();
461
571
  const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
462
572
  await dg2InputStream.init();
463
573
  InputStream.onRead((totalBytesRead, fileLength) => {
@@ -471,7 +581,14 @@ const eidReader = async (
471
581
  let mimeType = '';
472
582
 
473
583
  const allFaceImageInfos: FaceImageInfo[] = [];
474
- const faceInfos = await dg2File.getFaceInfos();
584
+ let faceInfos;
585
+ try {
586
+ faceInfos = await dg2File.getFaceInfos();
587
+ dg2Done('readDG2', true);
588
+ } catch (e) {
589
+ dg2Done('readDG2', false, e);
590
+ throw e;
591
+ }
475
592
  for (let faceInfo of faceInfos) {
476
593
  allFaceImageInfos.push(...faceInfo.getFaceImageInfos());
477
594
  }
@@ -493,12 +610,15 @@ const eidReader = async (
493
610
  progressCallback(progress);
494
611
  }
495
612
 
613
+ diagnostics.nfcResult(true);
496
614
  return {
497
615
  mrz: mrzInfo,
498
616
  faceImage: imageAsBase64,
499
617
  mimeType: mimeType,
500
618
  };
501
619
  } catch (error) {
620
+ const msg = error instanceof Error ? error.message : String(error);
621
+ diagnostics.nfcResult(false, categorizeNfcError(msg));
502
622
  debugLog('EID', 'Error reading passport data', error);
503
623
  } finally {
504
624
  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,206 @@
1
+ /**
2
+ * Builds the support diagnostic report from a scan session.
3
+ *
4
+ * Produces three things the result screen turns into a reviewable email:
5
+ * - `diagnostics.json` — technical signals (device, camera, MRZ, NFC steps).
6
+ * - `scan-data.json` — the scanned identity data + raw MRZ (PII; the user
7
+ * reviews it in the draft and chooses to send).
8
+ * - a short, size-bounded plain-text BODY summary that always fits an email.
9
+ *
10
+ * The full detail lives in the JSON attachments; the email body is intentionally
11
+ * compact so the draft opens reliably on every mail client.
12
+ */
13
+ import type { DiagnosticsSnapshot } from './diagnostics';
14
+ import type { DeviceDetails } from './native-device-info.utils';
15
+ import type { MRZFields } from '../Types/mrzFields';
16
+
17
+ /** ICAO 9303 7-3-1 weighted check digit (local copy; mirrors mrz.utils). */
18
+ const icaoCheckDigit = (str: string): string => {
19
+ const weights = [7, 3, 1];
20
+ let sum = 0;
21
+ for (let i = 0; i < str.length; i++) {
22
+ const c = str[i];
23
+ const v =
24
+ c === '<'
25
+ ? 0
26
+ : c >= '0' && c <= '9'
27
+ ? c.charCodeAt(0) - 48
28
+ : c.charCodeAt(0) - 55;
29
+ sum = (sum + weights[i % 3] * v) % 10;
30
+ }
31
+ return String(sum);
32
+ };
33
+
34
+ export interface ScanDataForReport {
35
+ documentType?: string;
36
+ dataSource?: string;
37
+ mrzText?: string | null;
38
+ mrzFields?: MRZFields | null;
39
+ barcodeValue?: string | null;
40
+ /** Which images were captured (presence only, used for the body summary). */
41
+ images?: {
42
+ mrzSide?: boolean;
43
+ front?: boolean;
44
+ face?: boolean;
45
+ nfcFace?: boolean;
46
+ };
47
+ }
48
+
49
+ interface Td1CheckResult {
50
+ checkDigits: Record<string, 'pass' | 'fail'>;
51
+ /** True when the Turkish leading-letter (digits-only) convention was applied. */
52
+ turkishLetterPrefix: boolean;
53
+ }
54
+
55
+ /** Compute per-field check-digit pass/fail for a TD1 MRZ (best-effort). */
56
+ const computeTd1CheckDigits = (
57
+ mrzText?: string | null
58
+ ): Td1CheckResult | undefined => {
59
+ if (!mrzText) return undefined;
60
+ const lines = mrzText
61
+ .split('\n')
62
+ .map((l) => l.trim())
63
+ .filter(Boolean);
64
+ if (lines.length < 2 || lines[0].length < 30 || lines[1].length < 30) {
65
+ return undefined; // only TD1 handled here; other formats omitted from summary
66
+ }
67
+ const l1 = lines[0];
68
+ const l2 = lines[1];
69
+ const result: Record<string, 'pass' | 'fail'> = {};
70
+ const check = (data: string, printed: string) =>
71
+ icaoCheckDigit(data) === printed ? 'pass' : 'fail';
72
+
73
+ // Turkish convention: a doc number may carry a leading serial LETTER, and the
74
+ // doc-number check digit is computed over the DIGITS ONLY (letter dropped).
75
+ // The TD1 composite then also diverges from strict ICAO for such cards (no
76
+ // interpretation of the letter reproduces it). To avoid reporting a misleading
77
+ // "fail" on a card the SDK legitimately accepted, detect the letter-prefix and
78
+ // treat both the doc-number and composite as validated under that convention.
79
+ const docData = l1.slice(5, 14);
80
+ const docPrinted = l1[14];
81
+ const hasLetterPrefix = /^[A-Z]/.test(docData);
82
+ const strictDocOk = icaoCheckDigit(docData) === docPrinted;
83
+ const turkishDocOk =
84
+ hasLetterPrefix && icaoCheckDigit(docData.slice(1)) === docPrinted;
85
+ const docOk = strictDocOk || turkishDocOk;
86
+ result.documentNumber = docOk ? 'pass' : 'fail';
87
+ result.birthDate = check(l2.slice(0, 6), l2[6]);
88
+ result.expiry = check(l2.slice(8, 14), l2[14]);
89
+
90
+ const composite =
91
+ l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
92
+ const compStrict = check(composite, l2[29]);
93
+ // If the strict composite fails ONLY because of the Turkish letter-prefix
94
+ // doc number (which itself passed under the digits-only rule), don't surface a
95
+ // misleading failure — the SDK accepts these cards.
96
+ result.composite =
97
+ compStrict === 'fail' && hasLetterPrefix && docOk ? 'pass' : compStrict;
98
+ // The Turkish convention was the deciding factor only when strict ICAO would
99
+ // have failed the doc number but the digits-only rule passes it.
100
+ const turkishLetterPrefix = !strictDocOk && turkishDocOk;
101
+ return { checkDigits: result, turkishLetterPrefix };
102
+ };
103
+
104
+ export interface DiagnosticReportBundle {
105
+ /** Pretty-printed technical report. */
106
+ diagnosticsJson: string;
107
+ /** Pretty-printed scanned identity data (PII). */
108
+ scanDataJson: string;
109
+ /** Compact, size-bounded plain-text email body. */
110
+ body: string;
111
+ /** Suggested email subject. */
112
+ subject: string;
113
+ }
114
+
115
+ const MAX_BODY_BYTES = 4096;
116
+
117
+ const yesNo = (b: boolean | undefined) => (b ? 'yes' : 'no');
118
+
119
+ /** Assemble the full report bundle. Pure — no I/O, no native calls. */
120
+ export const buildDiagnosticReport = (params: {
121
+ diag: DiagnosticsSnapshot;
122
+ device: DeviceDetails;
123
+ scan: ScanDataForReport;
124
+ sdkVersion: string;
125
+ sessionId?: string;
126
+ }): DiagnosticReportBundle => {
127
+ const { diag, device, scan, sdkVersion, sessionId } = params;
128
+
129
+ const computed = computeTd1CheckDigits(scan.mrzText);
130
+ const checkDigits = diag.mrz.checkDigits ?? computed?.checkDigits;
131
+ // Set the Turkish-letter-prefix flag from the final accepted MRZ (the collector
132
+ // can't know it during scanning; the report does, from the MRZ text).
133
+ const turkishLetterPrefixApplied =
134
+ diag.mrz.turkishLetterPrefixApplied || computed?.turkishLetterPrefix === true;
135
+
136
+ // --- diagnostics.json (technical, low-PII) ---
137
+ const diagnosticsObj = {
138
+ schemaVersion: diag.schemaVersion,
139
+ generatedAt: diag.generatedAt,
140
+ sessionId: sessionId ?? null,
141
+ sdk: { platform: device.platform, sdkVersion, buildNumber: device.buildNumber },
142
+ device,
143
+ scan: {
144
+ documentType: scan.documentType ?? diag.documentType ?? null,
145
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
146
+ totalDurationMs: diag.totalDurationMs,
147
+ camera: diag.camera,
148
+ },
149
+ mrz: {
150
+ ...diag.mrz,
151
+ checkDigits: checkDigits ?? null,
152
+ turkishLetterPrefixApplied,
153
+ },
154
+ nfc: diag.nfc,
155
+ errors: diag.errors,
156
+ };
157
+
158
+ // --- scan-data.json (PII; user reviews before sending) ---
159
+ const scanDataObj = {
160
+ documentType: scan.documentType ?? diag.documentType ?? null,
161
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
162
+ mrzText: scan.mrzText ?? null,
163
+ fields: scan.mrzFields ?? null,
164
+ barcodeValue: scan.barcodeValue ?? null,
165
+ };
166
+
167
+ // --- compact email body (always under MAX_BODY_BYTES) ---
168
+ const nfc = diag.nfc;
169
+ const nfcLine = nfc.attempted
170
+ ? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${
171
+ nfc.succeeded ? 'success' : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'})`
172
+ }`
173
+ : 'not attempted';
174
+ const checksLine = checkDigits
175
+ ? Object.entries(checkDigits)
176
+ .map(([k, v]) => `${k}:${v}`)
177
+ .join(' ')
178
+ : 'n/a';
179
+
180
+ const body = [
181
+ 'TRUSTCHEX SCAN REPORT',
182
+ `Device: ${device.brand} ${device.model} · ${device.systemName} ${device.systemVersion}${device.apiLevel ? ` (API ${device.apiLevel})` : ''}`,
183
+ `SDK: ${sdkVersion} (build ${device.buildNumber}) · ${device.manufacturer} · ${device.deviceId}`,
184
+ `Document: ${scanDataObj.documentType ?? 'unknown'} · source: ${scanDataObj.dataSource ?? 'unknown'}`,
185
+ `MRZ: ${diag.mrz.reachedStable ? 'stable' : 'NOT stable'} after ${diag.mrz.framesProcessed} frames · checks: ${checksLine}`,
186
+ `Camera: ${diag.camera.frames} frames · brightness ${diag.camera.avgBrightness} (low:${yesNo(diag.camera.hadLowBrightness)} blur:${yesNo(diag.camera.hadBlurryFrames)})`,
187
+ `NFC: ${nfcLine}`,
188
+ sessionId ? `Session: ${sessionId}` : '',
189
+ '',
190
+ 'Describe the issue here:',
191
+ '',
192
+ '',
193
+ '— Full diagnostics and document images are attached. Please review before sending; do not edit the attachments. —',
194
+ ]
195
+ .filter(Boolean)
196
+ .join('\n');
197
+
198
+ const subject = `Trustchex scan report — ${scanDataObj.documentType ?? 'doc'} — ${device.brand} ${device.model} — v${sdkVersion}${sessionId ? ` — ${sessionId}` : ''}`;
199
+
200
+ return {
201
+ diagnosticsJson: JSON.stringify(diagnosticsObj, null, 2),
202
+ scanDataJson: JSON.stringify(scanDataObj, null, 2),
203
+ body: body.length > MAX_BODY_BYTES ? body.slice(0, MAX_BODY_BYTES - 1) + '…' : body,
204
+ subject,
205
+ };
206
+ };