@trustchex/react-native-sdk 1.475.0 → 1.478.7

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 (75) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +19 -21
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
  3. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
  4. package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
  5. package/lib/module/Screens/Static/ResultScreen.js +103 -107
  6. package/lib/module/Shared/Components/DebugOverlay.js +5 -2
  7. package/lib/module/Shared/Components/IdentityDocumentCamera.js +53 -22
  8. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
  9. package/lib/module/Shared/EIDReader/eidReader.js +15 -14
  10. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +34 -0
  11. package/lib/module/Shared/Libs/SignalingClient.js +6 -5
  12. package/lib/module/Shared/Libs/crypto.utils.js +25 -3
  13. package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
  14. package/lib/module/Shared/Libs/http-client.js +19 -6
  15. package/lib/module/Shared/Libs/mrz.utils.js +266 -37
  16. package/lib/module/Shared/Libs/mrzFrameAggregator.js +45 -1
  17. package/lib/module/Shared/Libs/promise.utils.js +4 -3
  18. package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
  19. package/lib/module/Shared/Services/AnalyticsService.js +8 -2
  20. package/lib/module/Shared/Services/DataUploadService.js +114 -127
  21. package/lib/module/Shared/Services/VideoSessionService.js +4 -3
  22. package/lib/module/Translation/Resources/en.js +1 -0
  23. package/lib/module/Translation/Resources/tr.js +1 -0
  24. package/lib/module/version.js +1 -1
  25. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  26. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  27. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  30. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
  31. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  32. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  33. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
  35. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
  36. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
  38. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  39. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +22 -0
  41. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  42. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  43. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
  44. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
  45. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  46. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
  48. package/lib/typescript/src/Translation/Resources/en.d.ts +1 -0
  49. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  50. package/lib/typescript/src/Translation/Resources/tr.d.ts +1 -0
  51. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  52. package/lib/typescript/src/version.d.ts +1 -1
  53. package/package.json +14 -3
  54. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
  55. package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
  56. package/src/Screens/Static/ResultScreen.tsx +126 -161
  57. package/src/Shared/Components/DebugOverlay.tsx +5 -2
  58. package/src/Shared/Components/IdentityDocumentCamera.tsx +55 -22
  59. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
  60. package/src/Shared/EIDReader/eidReader.ts +23 -14
  61. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +34 -0
  62. package/src/Shared/Libs/SignalingClient.ts +10 -5
  63. package/src/Shared/Libs/crypto.utils.ts +41 -4
  64. package/src/Shared/Libs/deeplink.utils.ts +39 -7
  65. package/src/Shared/Libs/http-client.ts +24 -8
  66. package/src/Shared/Libs/mrz.utils.ts +276 -45
  67. package/src/Shared/Libs/mrzFrameAggregator.ts +53 -1
  68. package/src/Shared/Libs/promise.utils.ts +11 -5
  69. package/src/Shared/Libs/shuffle.utils.ts +15 -0
  70. package/src/Shared/Services/AnalyticsService.ts +17 -1
  71. package/src/Shared/Services/DataUploadService.ts +155 -166
  72. package/src/Shared/Services/VideoSessionService.ts +15 -3
  73. package/src/Translation/Resources/en.ts +1 -0
  74. package/src/Translation/Resources/tr.ts +1 -0
  75. package/src/version.ts +1 -1
@@ -14,6 +14,34 @@ const RECOVERY_BUDGET_MS = 80;
14
14
  let recoveryDeadline = 0;
15
15
  const recoveryExpired = () => Date.now() > recoveryDeadline;
16
16
 
17
+ /**
18
+ * Pads a TD1 line to the fixed 30-char width.
19
+ *
20
+ * The naive `s.padEnd(30, '<')` is WRONG for the date record (line 2): its last
21
+ * character is the composite check digit, preceded by an 11-char optional-data
22
+ * filler run. OCR routinely UNDER-COUNTS that low-contrast `<<<` run, so the OCR
23
+ * string arrives short AND ending in the composite digit (e.g. a 28-char
24
+ * `…TUR<<<<<<<<<6`). `padEnd` then appends fillers AFTER the digit
25
+ * (`…6<<`), stranding the composite mid-line: the parser absorbs it into the
26
+ * optional field and the composite check fails, after which a recompute writes a
27
+ * WRONG digit over the misaligned layout — a plausible-but-malformed MRZ.
28
+ *
29
+ * The fix is layout-aware: when a short line ends in a digit, that digit is the
30
+ * trailing check digit, so re-grow the filler region BEFORE it. We only do this
31
+ * for the date record (`isDateRecord`); the doc record (line 1) ends in optional
32
+ * data, not a check digit, so plain right-padding is correct there.
33
+ */
34
+ const padTD1Line = (s, isDateRecord = false) => {
35
+ if (s.length >= 30) return s.slice(0, 30);
36
+ const last = s[s.length - 1];
37
+ if (isDateRecord && last >= '0' && last <= '9') {
38
+ // Trailing digit is the composite check digit — insert the dropped fillers
39
+ // BEFORE it so it lands at position 29.
40
+ return s.slice(0, -1).padEnd(29, '<') + last;
41
+ }
42
+ return s.padEnd(30, '<');
43
+ };
44
+
17
45
  /**
18
46
  * MRZ Format Types according to ICAO 9303
19
47
  */
@@ -291,16 +319,52 @@ const reconstructMRZCandidates = rawText => {
291
319
  l1 = l1.slice(0, 2) + l1.slice(2, 5).replace(/K(?=<)|(?<=<)K|(?<=[A-Z])K$/g, '<') + l1.slice(5);
292
320
  let l2;
293
321
  if (l2m) {
294
- // Use the matched date record, padded to 30.
295
- l2 = pad(l2m, 30);
322
+ // Use the matched date record. It is the TD1 line whose trailing char is
323
+ // the composite check digit, so pad layout-aware: a short line ending in
324
+ // a digit gets its dropped filler run restored BEFORE that digit (a
325
+ // plain right-pad would strand the composite mid-line). See padTD1Line.
326
+ l2 = padTD1Line(l2m, true);
296
327
  } else {
297
328
  const after = flat.indexOf(l1Source) + l1Source.length;
298
- l2 = pad(flat.slice(after, after + 30), 30);
329
+ l2 = padTD1Line(flat.slice(after, after + 30), true);
299
330
  }
300
- // Line 3 (name): the longest letter+filler OCR line that is NOT the doc or
301
- // date line exclude obvious front-of-card title words by requiring fillers.
302
- const nameRuns = ocrLines.filter(l => l !== l1Source && l !== l2Line && /^[A-Z<]+$/.test(l) && /</.test(l) && l.length >= 5);
303
- const l3 = pad(nameRuns.sort((a, b) => b.length - a.length)[0] ?? '', 30);
331
+ // Line 3 (name): the longest mostly-letters+filler OCR line that is NOT the
332
+ // doc or date line. OCR mangles the name line too a leading "O" reads as
333
+ // "0", and the trailing "<" fillers read as K/C so we DON'T require a
334
+ // pure [A-Z<] line (that wrongly drops "0ZCAN<<ERDAL…" entirely). Accept a
335
+ // line that is predominantly letters/fillers, then normalise it.
336
+ const isNameLike = l => {
337
+ if (l === l1Source || l === l2Line || l.length < 5) return false;
338
+ if (!/</.test(l)) return false; // must have at least one filler
339
+ // Must look like "SURNAME<<NAMES…": starts with a letter (or its digit
340
+ // look-alike) and is dominated by letters/fillers, not a numeric record.
341
+ if (!/^[A-Z0-9]/.test(l)) return false;
342
+ const letters = (l.match(/[A-Z]/g) || []).length;
343
+ const digits = (l.match(/[0-9]/g) || []).length;
344
+ // A date/doc record is digit-heavy; a name is letter-heavy.
345
+ return letters >= 3 && letters >= digits;
346
+ };
347
+ // Normalise a name line: digit look-alikes back to letters (O/I/S/B/Z/G),
348
+ // and the filler-letter run misreads (K/C/E/G adjacent to fillers) to "<".
349
+ const normaliseName = l => {
350
+ const digitToLetter = {
351
+ '0': 'O',
352
+ '1': 'I',
353
+ '5': 'S',
354
+ '8': 'B',
355
+ '2': 'Z',
356
+ '6': 'G'
357
+ };
358
+ return l
359
+ // digit look-alikes inside the name → letters
360
+ .replace(/[015862]/g, d => digitToLetter[d] ?? d)
361
+ // a run of filler-letters (K/C/E/G) flanked by fillers or at the tail
362
+ // is the trailing/internal filler region
363
+ .replace(/(?<=<)[KCEG]+(?=<|$)/g, m => '<'.repeat(m.length)).replace(/[KCEG]+$/g, m => '<'.repeat(m.length));
364
+ };
365
+ const nameRuns = ocrLines.filter(isNameLike);
366
+ const bestName = nameRuns.sort((a, b) => b.length - a.length)[0] ?? '';
367
+ const l3 = pad(normaliseName(bestName), 30);
304
368
  candidates.push([l1, l2, l3]);
305
369
  }
306
370
  }
@@ -514,6 +578,112 @@ const icaoCheckDigit = str => {
514
578
  return String(sum);
515
579
  };
516
580
 
581
+ /**
582
+ * Rebuilds the fixed-width MRZ lines from a parse result's per-field `ranges`,
583
+ * which carry the actual characters at known line/column spans. Works for both
584
+ * valid and invalid parses (the `mrz` library populates ranges either way), so
585
+ * callers can read the PRINTED characters — e.g. a check digit the parser
586
+ * rejected — straight from the reconstructed line. Lines default to all-filler.
587
+ */
588
+ const linesFromParseRanges = (result, width, lineCount) => {
589
+ const buffers = Array.from({
590
+ length: lineCount
591
+ }, () => new Array(width).fill('<'));
592
+ for (const detail of result.details ?? []) {
593
+ for (const range of detail.ranges ?? []) {
594
+ // `raw` (the actual characters at this span) exists at runtime but isn't in
595
+ // the `mrz` Range type.
596
+ const {
597
+ line,
598
+ start,
599
+ raw = ''
600
+ } = range;
601
+ const buf = buffers[line];
602
+ if (!buf) continue;
603
+ for (let i = 0; i < raw.length && start + i < width; i++) {
604
+ buf[start + i] = raw[i];
605
+ }
606
+ }
607
+ }
608
+ return buffers.map(b => b.join(''));
609
+ };
610
+
611
+ /**
612
+ * Accepts a TD1 parse that the `mrz` library marks invalid SOLELY because of the
613
+ * Turkish e-ID document-number check-digit convention. (Full writeup: §2a in
614
+ * MRZ_KNOWN_ISSUES.md.)
615
+ *
616
+ * Turkish identity cards put a SERIAL LETTER at the start of the 9-char document
617
+ * number (e.g. "A53032352", "A49M38410"). Empirically — verified against real
618
+ * cards — Türkiye computes the document-number check digit over the number with
619
+ * that LEADING letter EXCLUDED (interior letters are kept), which diverges from
620
+ * strict ICAO 9303 (where A=10 is included). So a genuine card like "A53032352"
621
+ * (printed check digit 5) FAILS the strict check (which wants 7) even though the
622
+ * card is valid — `icaoCheckDigit("53032352") === "5"`.
623
+ *
624
+ * Without this, the downstream ambiguous-char recovery "fixes" the failing check
625
+ * by rewriting the real leading letter to a digit (A→4), corrupting the document
626
+ * number. This function instead recognises the legitimate Turkish reading and
627
+ * accepts the parse AS-IS, preserving the letter.
628
+ *
629
+ * It is deliberately narrow: it only fires for a TUR-issued TD1 whose document
630
+ * number starts with a letter, when the document-number check digit is failing
631
+ * (its signature) and the only OTHER failure permitted is the composite (the
632
+ * date checks must pass — an impossible date is real OCR damage, not this
633
+ * convention), and the leading-letter-dropped recomputation matches the printed
634
+ * document-number check digit.
635
+ *
636
+ * The leading serial letter MUST be preserved: the NFC eID chip derives its
637
+ * BAC/PACE access key from the document number exactly as stored on the chip
638
+ * (which includes the letter, e.g. "A53032352"). Rewriting it to a digit ("4…")
639
+ * — as the ambiguous-char recovery would, to satisfy strict ICAO — makes the
640
+ * chip read FAIL. So a real card must keep its letter even though the chip's
641
+ * MRZ carries a non-standard composite check digit that strict ICAO rejects.
642
+ *
643
+ * The TD1 COMPOSITE is therefore NOT required to match: on Turkish eIDs the
644
+ * composite is computed over the document-number region with the serial letter
645
+ * weighted in a way that diverges from strict ICAO (it cannot be reproduced by
646
+ * either including A=10 or dropping the letter), so it is not a reliable anchor
647
+ * here. The document-number check digit under the drop-leading-letter rule
648
+ * uniquely pins the numeric portion, and the letter is verbatim from OCR — the
649
+ * authoritative confirmation is the subsequent NFC read, which only succeeds
650
+ * with the correct (letter-preserving) document number.
651
+ */
652
+ const acceptsTurkishLetterPrefix = result => {
653
+ if (!result || result.valid || result.format !== 'TD1') return false;
654
+ const fields = result.fields ?? {};
655
+ if (fields.issuingState !== 'TUR') return false;
656
+ const docNumber = fields.documentNumber ?? '';
657
+ // Must start with a serial LETTER (the Turkish convention this handles).
658
+ if (!/^[A-Z]/.test(docNumber)) return false;
659
+ const failing = new Set((result.details ?? []).filter(d => !d.valid).map(d => d.field));
660
+ // Only the document-number and/or composite checks may be off — any other
661
+ // failing check digit (dates) means real OCR damage, not this convention.
662
+ const allowed = new Set(['documentNumberCheckDigit', 'compositeCheckDigit']);
663
+ if (failing.size === 0) return false;
664
+ for (const f of failing) if (!allowed.has(f)) return false;
665
+ // The document-number check digit MUST be among the failures — that is the
666
+ // signature of the letter-prefix convention. If only the composite is off, this
667
+ // is a different problem (handled by fixTD1Composite / indel recovery).
668
+ if (!failing.has('documentNumberCheckDigit')) return false;
669
+
670
+ // Rebuild the fixed-width lines from the parse ranges. We read the PRINTED
671
+ // check digit straight from the line (the parsed `documentNumberCheckDigit`
672
+ // field is null when the check fails, so it can't be relied on here).
673
+ const lines = linesFromParseRanges(result, 30, 3);
674
+ const l1 = lines[0] ?? '';
675
+ if (l1.length < 30) return false;
676
+
677
+ // The printed document-number check digit (TD1 line 1, position 14) must equal
678
+ // the check digit computed over the document number WITHOUT its leading letter.
679
+ // This uniquely identifies the numeric portion; the leading letter is kept
680
+ // verbatim (NFC requires it). The composite is deliberately NOT checked here.
681
+ const printedDocCheck = l1[14];
682
+ const digitsAfterLetter = docNumber.slice(1);
683
+ if (icaoCheckDigit(digitsAfterLetter) !== printedDocCheck) return false;
684
+ return true;
685
+ };
686
+
517
687
  /**
518
688
  * When a TD1 MRZ fails ONLY on its composite check digit (every per-field check
519
689
  * digit is valid), the composite is deterministically derivable from the other
@@ -524,6 +694,17 @@ const fixTD1Composite = lines => {
524
694
  if (lines.length < 3 || lines[0].length !== 30 || lines[1].length !== 30) {
525
695
  return null;
526
696
  }
697
+ // Guard against a MISPLACED composite: when OCR drops filler(s) from the line-2
698
+ // optional run, a layout-naive right-pad lands the composite digit mid-line and
699
+ // pads the tail with "<". The result is length-30 but structurally wrong, and
700
+ // recomputing over it writes a bogus digit (a plausible-but-malformed MRZ). If
701
+ // the composite slot [29] is "<" yet a digit sits in the optional window
702
+ // [18..29), the line is misaligned — refuse the recompute and let the indel
703
+ // (filler-insertion) recovery realign it first.
704
+ const l2line = lines[1];
705
+ if (l2line[29] === '<' && /[0-9]/.test(l2line.slice(18, 29))) {
706
+ return null;
707
+ }
527
708
  const r = parse(lines);
528
709
  const bad = (r.details ?? []).filter(d => !d.valid);
529
710
  if (bad.length !== 1 || bad[0].field !== 'compositeCheckDigit') return null;
@@ -547,18 +728,24 @@ const fixTD1Composite = lines => {
547
728
  * - line 2 (date record): delete each char in the birth..expiry-check window
548
729
  * [0..15) (then right-pad to 30) — undoes a spurious inserted digit;
549
730
  * - line 1 (doc record): delete each char in the doc-number..check window
550
- * [5..15) — undoes an inserted char in the serial.
731
+ * [5..15) — undoes an inserted char in the serial;
732
+ * - line 2 (date record): when OCR DROPPED filler(s) from the optional run, the
733
+ * line arrives short — insert a "<" across the optional window [18..29) to
734
+ * restore width 30 with the composite at position 29.
551
735
  * Each candidate is re-padded to 30, the composite is recomputed, and only a
552
736
  * fully check-digit-valid parse is accepted, so a wrong edit can never pass.
553
737
  */
554
738
  const recoverTD1ByIndel = lines => {
555
739
  if (lines.length < 3) return null;
556
- const pad30 = s => s.length >= 30 ? s.slice(0, 30) : s.padEnd(30, '<');
740
+ // Layout-aware: line 2 (index 1) is the date record whose trailing char is the
741
+ // composite check digit, so a short line must have its dropped fillers restored
742
+ // BEFORE that digit, not appended after it (padEnd would strand the composite).
743
+ const pad30 = (s, li) => s.length >= 30 ? s.slice(0, 30) : padTD1Line(s, li === 1);
557
744
 
558
745
  // Which line/window to try edits in, based on which check digit is failing.
559
746
  let base;
560
747
  try {
561
- base = parse(lines.map(pad30));
748
+ base = parse(lines.map((l, li) => pad30(l, li)));
562
749
  } catch {
563
750
  return null;
564
751
  }
@@ -579,15 +766,26 @@ const recoverTD1ByIndel = lines => {
579
766
  // Build all single-deletion candidates up front.
580
767
  const candidates = [];
581
768
  for (const [li, from, to] of regions) {
582
- const original = pad30(lines[li] ?? '');
769
+ const original = pad30(lines[li] ?? '', li);
583
770
  for (let i = from; i < to && i < original.length; i++) {
584
- const edited = pad30(original.slice(0, i) + original.slice(i + 1));
585
- const candidate = lines.map(pad30);
771
+ const edited = pad30(original.slice(0, i) + original.slice(i + 1), li);
772
+ const candidate = lines.map((l, idx) => pad30(l, idx));
586
773
  candidate[li] = edited;
587
774
  candidates.push(candidate);
588
775
  }
589
776
  }
590
777
 
778
+ // Dropped-filler insertion: when OCR under-counts the line-2 optional filler
779
+ // run, the date record arrives SHORT. The layout-aware pad30 already restores
780
+ // it (fillers before the trailing composite digit), so include the raw padded
781
+ // date record as an explicit candidate — this repairs the common Turkish-ID
782
+ // case (`…TUR<<<<<<<<<6` read with 9 fillers instead of 11) by INSERTION, not a
783
+ // bogus composite recompute over a misaligned line.
784
+ if ((lines[1]?.length ?? 30) < 30) {
785
+ const padded = lines.map((l, idx) => pad30(l, idx));
786
+ candidates.push(padded);
787
+ }
788
+
591
789
  // Cheap pass first: a plain parse or a composite recompute resolves the common
592
790
  // case (a dropped/duplicated digit) in microseconds — try ALL deletions this
593
791
  // way before paying for any substitution search.
@@ -776,26 +974,22 @@ const correctedMrzFromParse = result => {
776
974
  const width = widths[result.format ?? ''];
777
975
  if (!width) return undefined;
778
976
  const lineCount = result.format === 'TD1' ? 3 : 2;
779
- const buffers = Array.from({
780
- length: lineCount
781
- }, () => new Array(width).fill('<'));
782
- for (const detail of result.details ?? []) {
783
- for (const range of detail.ranges ?? []) {
784
- // `raw` (the actual characters at this span) exists at runtime but isn't in
785
- // the `mrz` Range type.
786
- const {
787
- line,
788
- start,
789
- raw = ''
790
- } = range;
791
- const buf = buffers[line];
792
- if (!buf) continue;
793
- for (let i = 0; i < raw.length && start + i < width; i++) {
794
- buf[start + i] = raw[i];
795
- }
796
- }
797
- }
798
- return buffers.map(b => b.join('')).join('\n');
977
+ return linesFromParseRanges(result, width, lineCount).join('\n');
978
+ };
979
+
980
+ /**
981
+ * Heuristic: does a parsed result's name look like it still carries OCR noise
982
+ * (a digit where a name letter belongs, or a run of filler-confusable letters
983
+ * that should have been "<")? Names are letters only, so any digit is noise; a
984
+ * 3+ run of K/C/E/G is very likely misread fillers leaking into the given names.
985
+ */
986
+ const nameLooksNoisy = r => {
987
+ const name = `${r.fields?.lastName ?? ''} ${r.fields?.firstName ?? ''}`;
988
+ // A DIGIT in a name is unambiguous OCR noise (MRZ names are letters only).
989
+ // A 5+ run of filler-confusable letters is almost certainly misread padding —
990
+ // real given names like "ECE"/"GECE" are short, so keep the threshold high to
991
+ // avoid false positives on legitimate names.
992
+ return /[0-9]/.test(name) || /[KCEG]{5,}/.test(name);
799
993
  };
800
994
 
801
995
  /**
@@ -814,6 +1008,10 @@ const validateMRZ = (mrzText, autocorrect = true) => {
814
1008
  // line + fragments). That must NOT short-circuit the recovery/reconstruction
815
1009
  // path below — treat a throw here as "no valid parse yet" and keep going.
816
1010
  let result = null;
1011
+ // Set when a TUR card is accepted under the Turkish serial-letter convention
1012
+ // (strict ICAO marks it invalid, but the leading-letter-dropped check passes).
1013
+ // Treated as valid downstream so the real letter is preserved, not corrected.
1014
+ let turkishLetterPrefixAccepted = false;
817
1015
  try {
818
1016
  result = parse(fixedText, {
819
1017
  autocorrect
@@ -822,6 +1020,37 @@ const validateMRZ = (mrzText, autocorrect = true) => {
822
1020
  result = null;
823
1021
  }
824
1022
 
1023
+ // The direct parse can validate (the name field has no check digit) while the
1024
+ // name line still carries OCR noise — a leading "O" read as "0", or trailing
1025
+ // "<" fillers read as K/C/E/G that leak into the given names. The
1026
+ // reconstruction path normalises the name line; when the direct result's name
1027
+ // looks noisy, prefer a reconstructed candidate that validates with a cleaner
1028
+ // name. This only ever swaps in another fully check-digit-valid reading.
1029
+ if (result?.valid && nameLooksNoisy(result)) {
1030
+ for (const reLines of reconstructMRZCandidates(mrzText)) {
1031
+ let cand;
1032
+ try {
1033
+ cand = parse(reLines);
1034
+ } catch {
1035
+ continue;
1036
+ }
1037
+ if (cand.valid && !nameLooksNoisy(cand)) {
1038
+ result = cand;
1039
+ break;
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ // Turkish e-ID serial-letter convention: a genuine TUR card whose document
1045
+ // number starts with a serial LETTER (e.g. "A53032352") fails the strict
1046
+ // ICAO check (Türkiye omits the leading letter from the check-digit math). If
1047
+ // the card validates under that convention, accept it AS-IS — the leading
1048
+ // letter is REAL and must be preserved, NOT rewritten to a digit by the
1049
+ // ambiguous-char recovery below. See acceptsTurkishLetterPrefix.
1050
+ if (result && !result.valid && acceptsTurkishLetterPrefix(result)) {
1051
+ turkishLetterPrefixAccepted = true;
1052
+ }
1053
+
825
1054
  // Primary recovery: exhaustive, CHECK-DIGIT-DRIVEN correction scoped to the
826
1055
  // fields whose check digit actually fails. It tries every OCR-ambiguous
827
1056
  // permutation within those fields — ordered by how likely the OCR error is
@@ -830,7 +1059,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
830
1059
  // digits all pass). This both (a) fixes a misread document number before the
831
1060
  // caller advances to the NFC/next screen, and (b) prefers the genuinely
832
1061
  // correct correction over an arbitrary check-digit-satisfying one.
833
- if (!result || !result.valid) {
1062
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
834
1063
  const fixedLines = fixedText.split('\n').filter(l => l.trim());
835
1064
  if (fixedLines.length >= 2) {
836
1065
  const recovered = recoverByFailingFields(fixedLines);
@@ -843,7 +1072,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
843
1072
  // Last-resort fallback: the older whole-string ambiguous sweep, only for
844
1073
  // inputs the field-scoped recovery can't address (e.g. an unrecognised
845
1074
  // format with no check-digit span map). Still requires a fully-valid parse.
846
- if (!result || !result.valid) {
1075
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
847
1076
  const variants = generateAmbiguousVariants(fixedText);
848
1077
  for (const variant of variants) {
849
1078
  if (recoveryExpired()) break; // bail before blocking the JS thread
@@ -867,7 +1096,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
867
1096
  // or broke a field with spaces). Reconstruct fixed-width line-sets for every
868
1097
  // supported format (TD1/TD2/TD3) and validate each WITH CHECK DIGITS, keeping
869
1098
  // only one that passes — a wrong reconstruction can never leak through.
870
- if (!result || !result.valid) {
1099
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
871
1100
  for (const reLines of reconstructMRZCandidates(mrzText)) {
872
1101
  // A reconstructed candidate for the wrong format (e.g. a 2-line TD2 guess
873
1102
  // built from a TD1 frame) makes the `mrz` parser THROW on line count —
@@ -888,7 +1117,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
888
1117
  }
889
1118
  }
890
1119
  }
891
- if (!result || !result.valid) {
1120
+ if (!result || !result.valid && !turkishLetterPrefixAccepted) {
892
1121
  return {
893
1122
  valid: false,
894
1123
  format: 'UNKNOWN',
@@ -101,6 +101,10 @@ const linesForFrame = text => {
101
101
  * is valid + stable. Reset between documents with `reset`.
102
102
  */
103
103
  export class MRZFrameAggregator {
104
+ // Minimum winning vote margin a CONTESTED cell needs before the consensus is
105
+ // considered confident (and thus eligible to be "stable"). Guards against an
106
+ // early look-alike misread (A↔4, S↔5, B↔8) locking in on a 1-vote lead.
107
+
104
108
  // format -> per-line, per-column vote tallies
105
109
  tallies = {};
106
110
  frameCount = 0;
@@ -111,6 +115,7 @@ export class MRZFrameAggregator {
111
115
  constructor(options = {}) {
112
116
  this.stabilityTarget = Math.max(1, options.stabilityTarget ?? 2);
113
117
  this.maxFrames = Math.max(1, options.maxFrames ?? 60);
118
+ this.minMargin = Math.max(0, options.minMargin ?? 2);
114
119
  }
115
120
  reset() {
116
121
  this.tallies = {};
@@ -224,6 +229,35 @@ export class MRZFrameAggregator {
224
229
  this.frameCount = Math.ceil(this.frameCount / 2);
225
230
  }
226
231
 
232
+ /**
233
+ * Is every CONTESTED cell of this format decided by a clear margin?
234
+ *
235
+ * A cell is contested when more than one distinct character has received votes
236
+ * (e.g. position 5 of a Turkish doc number gets both "A" and the look-alike
237
+ * "4"). Per-character voting alone reports the consensus "stable" the instant an
238
+ * early misread takes a 1-vote lead and stays unchanged — so a transient
239
+ * "A"→"4" confusion locks in before the correct glyph accumulates enough votes.
240
+ *
241
+ * Requiring the winner to lead the runner-up by `minMargin` weight on every
242
+ * contested cell defers stability until the vote has genuinely settled: a 2-vs-1
243
+ * early lead is NOT confident; a 4-vs-1 (or clean uncontested) reading is. This
244
+ * is what turns "looks valid this frame" into "the camera agrees across frames".
245
+ */
246
+ consensusIsConfident(format) {
247
+ const geom = FORMAT_GEOMETRY[format];
248
+ const tally = this.tallies[format];
249
+ for (let li = 0; li < geom.lines; li++) {
250
+ for (let ci = 0; ci < geom.width; ci++) {
251
+ const cell = tally[li][ci];
252
+ const weights = Object.values(cell);
253
+ if (weights.length < 2) continue; // uncontested cell — no ambiguity
254
+ weights.sort((a, b) => b - a);
255
+ if (weights[0] - weights[1] < this.minMargin) return false;
256
+ }
257
+ }
258
+ return true;
259
+ }
260
+
227
261
  /** Read out the winning character per cell for a format. */
228
262
  consensusLines(format) {
229
263
  const geom = FORMAT_GEOMETRY[format];
@@ -265,6 +299,7 @@ export class MRZFrameAggregator {
265
299
  let bestMrz = null;
266
300
  let bestValidation = null;
267
301
  let bestValid = false;
302
+ let bestFormat = null;
268
303
  for (const format of formats) {
269
304
  const lines = this.consensusLines(format);
270
305
  const mrz = lines.join('\n');
@@ -275,9 +310,11 @@ export class MRZFrameAggregator {
275
310
  bestValid = true;
276
311
  bestMrz = validation.correctedMrz ?? mrz;
277
312
  bestValidation = validation;
313
+ bestFormat = format;
278
314
  } else if (!bestValid && bestMrz === null) {
279
315
  bestMrz = mrz;
280
316
  bestValidation = validation;
317
+ bestFormat = format;
281
318
  }
282
319
  }
283
320
 
@@ -290,11 +327,18 @@ export class MRZFrameAggregator {
290
327
  this.stableStreak = stableKey ? 1 : 0;
291
328
  }
292
329
  this.lastConsensusMrz = stableKey;
330
+
331
+ // "Stable" requires BOTH an unchanged streak AND a confident vote: every
332
+ // contested cell must have resolved by a clear margin. The streak alone lets
333
+ // an early look-alike misread (A↔4) lock in the instant it takes a 1-vote
334
+ // lead and stops changing — the confidence gate defers acceptance until the
335
+ // correct glyph has genuinely won, not merely arrived first.
336
+ const confident = bestValid && bestFormat != null && this.consensusIsConfident(bestFormat);
293
337
  return {
294
338
  mrz: bestMrz,
295
339
  validation: bestValidation,
296
340
  frames: this.frameCount,
297
- stable: bestValid && this.stableStreak >= this.stabilityTarget
341
+ stable: bestValid && confident && this.stableStreak >= this.stabilityTarget
298
342
  };
299
343
  }
300
344
  }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ import { debugLog } from "./debug.utils.js";
3
4
  const runWithRetry = async (fn, maxRetries = 3, delay = 1000) => {
4
5
  let retries = 0;
5
6
  let lastError;
@@ -7,11 +8,11 @@ const runWithRetry = async (fn, maxRetries = 3, delay = 1000) => {
7
8
  while (retries < maxRetries) {
8
9
  try {
9
10
  if (retries > 0) {
10
- console.log(`[Retry] Attempt ${retries + 1}/${maxRetries}...`);
11
+ debugLog('Retry', `[Retry] Attempt ${retries + 1}/${maxRetries}...`);
11
12
  }
12
13
  result = await fn();
13
14
  if (retries > 0) {
14
- console.log(`[Retry] ✓ Success on attempt ${retries + 1}`);
15
+ debugLog('Retry', `[Retry] ✓ Success on attempt ${retries + 1}`);
15
16
  }
16
17
  return result;
17
18
  } catch (error) {
@@ -20,7 +21,7 @@ const runWithRetry = async (fn, maxRetries = 3, delay = 1000) => {
20
21
  console.error(`[Retry] ✗ Attempt ${retries}/${maxRetries} failed:`, error instanceof Error ? error.message : error);
21
22
  if (retries < maxRetries) {
22
23
  const waitTime = delay * retries;
23
- console.log(`[Retry] Waiting ${waitTime}ms before retry...`);
24
+ debugLog('Retry', `[Retry] Waiting ${waitTime}ms before retry...`);
24
25
  await new Promise(resolve => setTimeout(resolve, waitTime));
25
26
  }
26
27
  }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+
3
+ import 'react-native-get-random-values';
4
+
5
+ // Unbiased Fisher–Yates shuffle backed by a CSPRNG. Used for the liveness
6
+ // instruction order, which acts as an anti-replay challenge and must not be
7
+ // predictable.
8
+ export const secureShuffle = items => {
9
+ const result = [...items];
10
+ const random = new Uint32Array(1);
11
+ for (let i = result.length - 1; i > 0; i--) {
12
+ crypto.getRandomValues(random);
13
+ const j = random[0] % (i + 1);
14
+ [result[i], result[j]] = [result[j], result[i]];
15
+ }
16
+ return result;
17
+ };
@@ -263,7 +263,13 @@ class AnalyticsService {
263
263
  throw new Error('Rate limit exceeded');
264
264
  }
265
265
 
266
- // For other 4xx errors (Bad Request, Unauthorized, etc.), do NOT retry.
266
+ // Auth failures are transient from the SDK's perspective (e.g. a token
267
+ // rollout); throw so the batch is retried instead of silently dropped.
268
+ if (response.status === 401 || response.status === 403) {
269
+ throw new Error(`Authentication error: ${response.status}`);
270
+ }
271
+
272
+ // For other 4xx errors (Bad Request, etc.), do NOT retry.
267
273
  // We return successfully so the bad events are removed from the queue.
268
274
  if (response.status >= 400 && response.status < 500) {
269
275
  if (__DEV__) debugWarn('AnalyticsService', `[Analytics] Dropping batch due to ${response.status} error`);
@@ -354,7 +360,7 @@ class AnalyticsService {
354
360
  sanitizeMetadata(metadata) {
355
361
  if (!metadata) return undefined;
356
362
  const sanitized = {};
357
- const forbiddenKeys = ['email', 'emailaddress', 'phone', 'phonenumber', 'firstname', 'lastname', 'fullname', 'address', 'streetaddress', 'ssn', 'socialsecurity', 'passport', 'passportnumber', 'driverlicense', 'creditcard', 'cardnumber', 'cvv', 'password', 'apikey', 'accesstoken'];
363
+ const forbiddenKeys = ['email', 'emailaddress', 'phone', 'phonenumber', 'firstname', 'lastname', 'fullname', 'address', 'streetaddress', 'ssn', 'socialsecurity', 'passport', 'passportnumber', 'driverlicense', 'creditcard', 'cardnumber', 'cvv', 'password', 'apikey', 'accesstoken', 'documentnumber', 'personalnumber', 'nationalid', 'tckimlik', 'mrz', 'mrztext', 'birthdate', 'dateofbirth', 'sessionkey', 'sessiontoken'];
358
364
  for (const [key, value] of Object.entries(metadata)) {
359
365
  const lowerKey = key.toLowerCase();
360
366