@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 = (): boolean => 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: string, isDateRecord: boolean = false): string => {
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
  */
@@ -341,23 +369,55 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
341
369
  l1.slice(5);
342
370
  let l2: string;
343
371
  if (l2m) {
344
- // Use the matched date record, padded to 30.
345
- l2 = pad(l2m, 30);
372
+ // Use the matched date record. It is the TD1 line whose trailing char is
373
+ // the composite check digit, so pad layout-aware: a short line ending in
374
+ // a digit gets its dropped filler run restored BEFORE that digit (a
375
+ // plain right-pad would strand the composite mid-line). See padTD1Line.
376
+ l2 = padTD1Line(l2m, true);
346
377
  } else {
347
378
  const after = flat.indexOf(l1Source) + l1Source.length;
348
- l2 = pad(flat.slice(after, after + 30), 30);
379
+ l2 = padTD1Line(flat.slice(after, after + 30), true);
349
380
  }
350
- // Line 3 (name): the longest letter+filler OCR line that is NOT the doc or
351
- // date line exclude obvious front-of-card title words by requiring fillers.
352
- const nameRuns = ocrLines.filter(
353
- (l) =>
354
- l !== l1Source &&
355
- l !== l2Line &&
356
- /^[A-Z<]+$/.test(l) &&
357
- /</.test(l) &&
358
- l.length >= 5
359
- );
360
- const l3 = pad(nameRuns.sort((a, b) => b.length - a.length)[0] ?? '', 30);
381
+ // Line 3 (name): the longest mostly-letters+filler OCR line that is NOT the
382
+ // doc or date line. OCR mangles the name line too a leading "O" reads as
383
+ // "0", and the trailing "<" fillers read as K/C — so we DON'T require a
384
+ // pure [A-Z<] line (that wrongly drops "0ZCAN<<ERDAL…" entirely). Accept a
385
+ // line that is predominantly letters/fillers, then normalise it.
386
+ const isNameLike = (l: string): boolean => {
387
+ if (l === l1Source || l === l2Line || l.length < 5) return false;
388
+ if (!/</.test(l)) return false; // must have at least one filler
389
+ // Must look like "SURNAME<<NAMES…": starts with a letter (or its digit
390
+ // look-alike) and is dominated by letters/fillers, not a numeric record.
391
+ if (!/^[A-Z0-9]/.test(l)) return false;
392
+ const letters = (l.match(/[A-Z]/g) || []).length;
393
+ const digits = (l.match(/[0-9]/g) || []).length;
394
+ // A date/doc record is digit-heavy; a name is letter-heavy.
395
+ return letters >= 3 && letters >= digits;
396
+ };
397
+ // Normalise a name line: digit look-alikes back to letters (O/I/S/B/Z/G),
398
+ // and the filler-letter run misreads (K/C/E/G adjacent to fillers) to "<".
399
+ const normaliseName = (l: string): string => {
400
+ const digitToLetter: Record<string, string> = {
401
+ '0': 'O',
402
+ '1': 'I',
403
+ '5': 'S',
404
+ '8': 'B',
405
+ '2': 'Z',
406
+ '6': 'G',
407
+ };
408
+ return (
409
+ l
410
+ // digit look-alikes inside the name → letters
411
+ .replace(/[015862]/g, (d) => digitToLetter[d] ?? d)
412
+ // a run of filler-letters (K/C/E/G) flanked by fillers or at the tail
413
+ // is the trailing/internal filler region
414
+ .replace(/(?<=<)[KCEG]+(?=<|$)/g, (m) => '<'.repeat(m.length))
415
+ .replace(/[KCEG]+$/g, (m) => '<'.repeat(m.length))
416
+ );
417
+ };
418
+ const nameRuns = ocrLines.filter(isNameLike);
419
+ const bestName = nameRuns.sort((a, b) => b.length - a.length)[0] ?? '';
420
+ const l3 = pad(normaliseName(bestName), 30);
361
421
  candidates.push([l1, l2, l3]);
362
422
  }
363
423
  }
@@ -599,6 +659,118 @@ const icaoCheckDigit = (str: string): string => {
599
659
  return String(sum);
600
660
  };
601
661
 
662
+ /**
663
+ * Rebuilds the fixed-width MRZ lines from a parse result's per-field `ranges`,
664
+ * which carry the actual characters at known line/column spans. Works for both
665
+ * valid and invalid parses (the `mrz` library populates ranges either way), so
666
+ * callers can read the PRINTED characters — e.g. a check digit the parser
667
+ * rejected — straight from the reconstructed line. Lines default to all-filler.
668
+ */
669
+ const linesFromParseRanges = (
670
+ result: ReturnType<typeof parse>,
671
+ width: number,
672
+ lineCount: number
673
+ ): string[] => {
674
+ const buffers: string[][] = Array.from({ length: lineCount }, () =>
675
+ new Array(width).fill('<')
676
+ );
677
+ for (const detail of result.details ?? []) {
678
+ for (const range of detail.ranges ?? []) {
679
+ // `raw` (the actual characters at this span) exists at runtime but isn't in
680
+ // the `mrz` Range type.
681
+ const { line, start, raw = '' } = range as typeof range & { raw?: string };
682
+ const buf = buffers[line];
683
+ if (!buf) continue;
684
+ for (let i = 0; i < raw.length && start + i < width; i++) {
685
+ buf[start + i] = raw[i];
686
+ }
687
+ }
688
+ }
689
+ return buffers.map((b) => b.join(''));
690
+ };
691
+
692
+ /**
693
+ * Accepts a TD1 parse that the `mrz` library marks invalid SOLELY because of the
694
+ * Turkish e-ID document-number check-digit convention. (Full writeup: §2a in
695
+ * MRZ_KNOWN_ISSUES.md.)
696
+ *
697
+ * Turkish identity cards put a SERIAL LETTER at the start of the 9-char document
698
+ * number (e.g. "A53032352", "A49M38410"). Empirically — verified against real
699
+ * cards — Türkiye computes the document-number check digit over the number with
700
+ * that LEADING letter EXCLUDED (interior letters are kept), which diverges from
701
+ * strict ICAO 9303 (where A=10 is included). So a genuine card like "A53032352"
702
+ * (printed check digit 5) FAILS the strict check (which wants 7) even though the
703
+ * card is valid — `icaoCheckDigit("53032352") === "5"`.
704
+ *
705
+ * Without this, the downstream ambiguous-char recovery "fixes" the failing check
706
+ * by rewriting the real leading letter to a digit (A→4), corrupting the document
707
+ * number. This function instead recognises the legitimate Turkish reading and
708
+ * accepts the parse AS-IS, preserving the letter.
709
+ *
710
+ * It is deliberately narrow: it only fires for a TUR-issued TD1 whose document
711
+ * number starts with a letter, when the document-number check digit is failing
712
+ * (its signature) and the only OTHER failure permitted is the composite (the
713
+ * date checks must pass — an impossible date is real OCR damage, not this
714
+ * convention), and the leading-letter-dropped recomputation matches the printed
715
+ * document-number check digit.
716
+ *
717
+ * The leading serial letter MUST be preserved: the NFC eID chip derives its
718
+ * BAC/PACE access key from the document number exactly as stored on the chip
719
+ * (which includes the letter, e.g. "A53032352"). Rewriting it to a digit ("4…")
720
+ * — as the ambiguous-char recovery would, to satisfy strict ICAO — makes the
721
+ * chip read FAIL. So a real card must keep its letter even though the chip's
722
+ * MRZ carries a non-standard composite check digit that strict ICAO rejects.
723
+ *
724
+ * The TD1 COMPOSITE is therefore NOT required to match: on Turkish eIDs the
725
+ * composite is computed over the document-number region with the serial letter
726
+ * weighted in a way that diverges from strict ICAO (it cannot be reproduced by
727
+ * either including A=10 or dropping the letter), so it is not a reliable anchor
728
+ * here. The document-number check digit under the drop-leading-letter rule
729
+ * uniquely pins the numeric portion, and the letter is verbatim from OCR — the
730
+ * authoritative confirmation is the subsequent NFC read, which only succeeds
731
+ * with the correct (letter-preserving) document number.
732
+ */
733
+ const acceptsTurkishLetterPrefix = (
734
+ result: ReturnType<typeof parse> | null
735
+ ): boolean => {
736
+ if (!result || result.valid || result.format !== 'TD1') return false;
737
+ const fields = result.fields ?? {};
738
+ if (fields.issuingState !== 'TUR') return false;
739
+ const docNumber = fields.documentNumber ?? '';
740
+ // Must start with a serial LETTER (the Turkish convention this handles).
741
+ if (!/^[A-Z]/.test(docNumber)) return false;
742
+
743
+ const failing = new Set(
744
+ (result.details ?? []).filter((d) => !d.valid).map((d) => d.field)
745
+ );
746
+ // Only the document-number and/or composite checks may be off — any other
747
+ // failing check digit (dates) means real OCR damage, not this convention.
748
+ const allowed = new Set(['documentNumberCheckDigit', 'compositeCheckDigit']);
749
+ if (failing.size === 0) return false;
750
+ for (const f of failing) if (!allowed.has(f)) return false;
751
+ // The document-number check digit MUST be among the failures — that is the
752
+ // signature of the letter-prefix convention. If only the composite is off, this
753
+ // is a different problem (handled by fixTD1Composite / indel recovery).
754
+ if (!failing.has('documentNumberCheckDigit')) return false;
755
+
756
+ // Rebuild the fixed-width lines from the parse ranges. We read the PRINTED
757
+ // check digit straight from the line (the parsed `documentNumberCheckDigit`
758
+ // field is null when the check fails, so it can't be relied on here).
759
+ const lines = linesFromParseRanges(result, 30, 3);
760
+ const l1 = lines[0] ?? '';
761
+ if (l1.length < 30) return false;
762
+
763
+ // The printed document-number check digit (TD1 line 1, position 14) must equal
764
+ // the check digit computed over the document number WITHOUT its leading letter.
765
+ // This uniquely identifies the numeric portion; the leading letter is kept
766
+ // verbatim (NFC requires it). The composite is deliberately NOT checked here.
767
+ const printedDocCheck = l1[14];
768
+ const digitsAfterLetter = docNumber.slice(1);
769
+ if (icaoCheckDigit(digitsAfterLetter) !== printedDocCheck) return false;
770
+
771
+ return true;
772
+ };
773
+
602
774
  /**
603
775
  * When a TD1 MRZ fails ONLY on its composite check digit (every per-field check
604
776
  * digit is valid), the composite is deterministically derivable from the other
@@ -609,6 +781,17 @@ const fixTD1Composite = (lines: string[]): ReturnType<typeof parse> | null => {
609
781
  if (lines.length < 3 || lines[0].length !== 30 || lines[1].length !== 30) {
610
782
  return null;
611
783
  }
784
+ // Guard against a MISPLACED composite: when OCR drops filler(s) from the line-2
785
+ // optional run, a layout-naive right-pad lands the composite digit mid-line and
786
+ // pads the tail with "<". The result is length-30 but structurally wrong, and
787
+ // recomputing over it writes a bogus digit (a plausible-but-malformed MRZ). If
788
+ // the composite slot [29] is "<" yet a digit sits in the optional window
789
+ // [18..29), the line is misaligned — refuse the recompute and let the indel
790
+ // (filler-insertion) recovery realign it first.
791
+ const l2line = lines[1];
792
+ if (l2line[29] === '<' && /[0-9]/.test(l2line.slice(18, 29))) {
793
+ return null;
794
+ }
612
795
  const r = parse(lines);
613
796
  const bad = (r.details ?? []).filter((d) => !d.valid);
614
797
  if (bad.length !== 1 || bad[0].field !== 'compositeCheckDigit') return null;
@@ -633,7 +816,10 @@ const fixTD1Composite = (lines: string[]): ReturnType<typeof parse> | null => {
633
816
  * - line 2 (date record): delete each char in the birth..expiry-check window
634
817
  * [0..15) (then right-pad to 30) — undoes a spurious inserted digit;
635
818
  * - line 1 (doc record): delete each char in the doc-number..check window
636
- * [5..15) — undoes an inserted char in the serial.
819
+ * [5..15) — undoes an inserted char in the serial;
820
+ * - line 2 (date record): when OCR DROPPED filler(s) from the optional run, the
821
+ * line arrives short — insert a "<" across the optional window [18..29) to
822
+ * restore width 30 with the composite at position 29.
637
823
  * Each candidate is re-padded to 30, the composite is recomputed, and only a
638
824
  * fully check-digit-valid parse is accepted, so a wrong edit can never pass.
639
825
  */
@@ -641,13 +827,16 @@ const recoverTD1ByIndel = (
641
827
  lines: string[]
642
828
  ): ReturnType<typeof parse> | null => {
643
829
  if (lines.length < 3) return null;
644
- const pad30 = (s: string) =>
645
- s.length >= 30 ? s.slice(0, 30) : s.padEnd(30, '<');
830
+ // Layout-aware: line 2 (index 1) is the date record whose trailing char is the
831
+ // composite check digit, so a short line must have its dropped fillers restored
832
+ // BEFORE that digit, not appended after it (padEnd would strand the composite).
833
+ const pad30 = (s: string, li?: number) =>
834
+ s.length >= 30 ? s.slice(0, 30) : padTD1Line(s, li === 1);
646
835
 
647
836
  // Which line/window to try edits in, based on which check digit is failing.
648
837
  let base: ReturnType<typeof parse>;
649
838
  try {
650
- base = parse(lines.map(pad30));
839
+ base = parse(lines.map((l, li) => pad30(l, li)));
651
840
  } catch {
652
841
  return null;
653
842
  }
@@ -677,15 +866,26 @@ const recoverTD1ByIndel = (
677
866
  // Build all single-deletion candidates up front.
678
867
  const candidates: string[][] = [];
679
868
  for (const [li, from, to] of regions) {
680
- const original = pad30(lines[li] ?? '');
869
+ const original = pad30(lines[li] ?? '', li);
681
870
  for (let i = from; i < to && i < original.length; i++) {
682
- const edited = pad30(original.slice(0, i) + original.slice(i + 1));
683
- const candidate = lines.map(pad30);
871
+ const edited = pad30(original.slice(0, i) + original.slice(i + 1), li);
872
+ const candidate = lines.map((l, idx) => pad30(l, idx));
684
873
  candidate[li] = edited;
685
874
  candidates.push(candidate);
686
875
  }
687
876
  }
688
877
 
878
+ // Dropped-filler insertion: when OCR under-counts the line-2 optional filler
879
+ // run, the date record arrives SHORT. The layout-aware pad30 already restores
880
+ // it (fillers before the trailing composite digit), so include the raw padded
881
+ // date record as an explicit candidate — this repairs the common Turkish-ID
882
+ // case (`…TUR<<<<<<<<<6` read with 9 fillers instead of 11) by INSERTION, not a
883
+ // bogus composite recompute over a misaligned line.
884
+ if ((lines[1]?.length ?? 30) < 30) {
885
+ const padded = lines.map((l, idx) => pad30(l, idx));
886
+ candidates.push(padded);
887
+ }
888
+
689
889
  // Cheap pass first: a plain parse or a composite recompute resolves the common
690
890
  // case (a dropped/duplicated digit) in microseconds — try ALL deletions this
691
891
  // way before paying for any substitution search.
@@ -882,26 +1082,22 @@ const correctedMrzFromParse = (
882
1082
  const width = widths[result.format ?? ''];
883
1083
  if (!width) return undefined;
884
1084
  const lineCount = result.format === 'TD1' ? 3 : 2;
885
- const buffers: string[][] = Array.from({ length: lineCount }, () =>
886
- new Array(width).fill('<')
887
- );
888
- for (const detail of result.details ?? []) {
889
- for (const range of detail.ranges ?? []) {
890
- // `raw` (the actual characters at this span) exists at runtime but isn't in
891
- // the `mrz` Range type.
892
- const {
893
- line,
894
- start,
895
- raw = '',
896
- } = range as typeof range & { raw?: string };
897
- const buf = buffers[line];
898
- if (!buf) continue;
899
- for (let i = 0; i < raw.length && start + i < width; i++) {
900
- buf[start + i] = raw[i];
901
- }
902
- }
903
- }
904
- return buffers.map((b) => b.join('')).join('\n');
1085
+ return linesFromParseRanges(result, width, lineCount).join('\n');
1086
+ };
1087
+
1088
+ /**
1089
+ * Heuristic: does a parsed result's name look like it still carries OCR noise
1090
+ * (a digit where a name letter belongs, or a run of filler-confusable letters
1091
+ * that should have been "<")? Names are letters only, so any digit is noise; a
1092
+ * 3+ run of K/C/E/G is very likely misread fillers leaking into the given names.
1093
+ */
1094
+ const nameLooksNoisy = (r: ReturnType<typeof parse>): boolean => {
1095
+ const name = `${r.fields?.lastName ?? ''} ${r.fields?.firstName ?? ''}`;
1096
+ // A DIGIT in a name is unambiguous OCR noise (MRZ names are letters only).
1097
+ // A 5+ run of filler-confusable letters is almost certainly misread padding —
1098
+ // real given names like "ECE"/"GECE" are short, so keep the threshold high to
1099
+ // avoid false positives on legitimate names.
1100
+ return /[0-9]/.test(name) || /[KCEG]{5,}/.test(name);
905
1101
  };
906
1102
 
907
1103
  /**
@@ -923,12 +1119,47 @@ const validateMRZ = (
923
1119
  // line + fragments). That must NOT short-circuit the recovery/reconstruction
924
1120
  // path below — treat a throw here as "no valid parse yet" and keep going.
925
1121
  let result: ReturnType<typeof parse> | null = null;
1122
+ // Set when a TUR card is accepted under the Turkish serial-letter convention
1123
+ // (strict ICAO marks it invalid, but the leading-letter-dropped check passes).
1124
+ // Treated as valid downstream so the real letter is preserved, not corrected.
1125
+ let turkishLetterPrefixAccepted = false;
926
1126
  try {
927
1127
  result = parse(fixedText, { autocorrect });
928
1128
  } catch {
929
1129
  result = null;
930
1130
  }
931
1131
 
1132
+ // The direct parse can validate (the name field has no check digit) while the
1133
+ // name line still carries OCR noise — a leading "O" read as "0", or trailing
1134
+ // "<" fillers read as K/C/E/G that leak into the given names. The
1135
+ // reconstruction path normalises the name line; when the direct result's name
1136
+ // looks noisy, prefer a reconstructed candidate that validates with a cleaner
1137
+ // name. This only ever swaps in another fully check-digit-valid reading.
1138
+ if (result?.valid && nameLooksNoisy(result)) {
1139
+ for (const reLines of reconstructMRZCandidates(mrzText)) {
1140
+ let cand: ReturnType<typeof parse>;
1141
+ try {
1142
+ cand = parse(reLines);
1143
+ } catch {
1144
+ continue;
1145
+ }
1146
+ if (cand.valid && !nameLooksNoisy(cand)) {
1147
+ result = cand;
1148
+ break;
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // Turkish e-ID serial-letter convention: a genuine TUR card whose document
1154
+ // number starts with a serial LETTER (e.g. "A53032352") fails the strict
1155
+ // ICAO check (Türkiye omits the leading letter from the check-digit math). If
1156
+ // the card validates under that convention, accept it AS-IS — the leading
1157
+ // letter is REAL and must be preserved, NOT rewritten to a digit by the
1158
+ // ambiguous-char recovery below. See acceptsTurkishLetterPrefix.
1159
+ if (result && !result.valid && acceptsTurkishLetterPrefix(result)) {
1160
+ turkishLetterPrefixAccepted = true;
1161
+ }
1162
+
932
1163
  // Primary recovery: exhaustive, CHECK-DIGIT-DRIVEN correction scoped to the
933
1164
  // fields whose check digit actually fails. It tries every OCR-ambiguous
934
1165
  // permutation within those fields — ordered by how likely the OCR error is
@@ -937,7 +1168,7 @@ const validateMRZ = (
937
1168
  // digits all pass). This both (a) fixes a misread document number before the
938
1169
  // caller advances to the NFC/next screen, and (b) prefers the genuinely
939
1170
  // correct correction over an arbitrary check-digit-satisfying one.
940
- if (!result || !result.valid) {
1171
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
941
1172
  const fixedLines = fixedText.split('\n').filter((l) => l.trim());
942
1173
  if (fixedLines.length >= 2) {
943
1174
  const recovered = recoverByFailingFields(fixedLines);
@@ -950,7 +1181,7 @@ const validateMRZ = (
950
1181
  // Last-resort fallback: the older whole-string ambiguous sweep, only for
951
1182
  // inputs the field-scoped recovery can't address (e.g. an unrecognised
952
1183
  // format with no check-digit span map). Still requires a fully-valid parse.
953
- if (!result || !result.valid) {
1184
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
954
1185
  const variants = generateAmbiguousVariants(fixedText);
955
1186
  for (const variant of variants) {
956
1187
  if (recoveryExpired()) break; // bail before blocking the JS thread
@@ -972,7 +1203,7 @@ const validateMRZ = (
972
1203
  // or broke a field with spaces). Reconstruct fixed-width line-sets for every
973
1204
  // supported format (TD1/TD2/TD3) and validate each WITH CHECK DIGITS, keeping
974
1205
  // only one that passes — a wrong reconstruction can never leak through.
975
- if (!result || !result.valid) {
1206
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
976
1207
  for (const reLines of reconstructMRZCandidates(mrzText)) {
977
1208
  // A reconstructed candidate for the wrong format (e.g. a 2-line TD2 guess
978
1209
  // built from a TD1 frame) makes the `mrz` parser THROW on line count —
@@ -994,7 +1225,7 @@ const validateMRZ = (
994
1225
  }
995
1226
  }
996
1227
 
997
- if (!result || !result.valid) {
1228
+ if (!result || (!result.valid && !turkishLetterPrefixAccepted)) {
998
1229
  return {
999
1230
  valid: false,
1000
1231
  format: 'UNKNOWN',
@@ -67,6 +67,12 @@ export interface MRZFrameAggregatorOptions {
67
67
  stabilityTarget?: number;
68
68
  /** Drop the running tallies after this many frames to bound memory. Default 60. */
69
69
  maxFrames?: number;
70
+ /**
71
+ * Minimum winning vote margin a contested cell needs before the consensus can
72
+ * be considered stable. Higher = more frames required to resolve a look-alike
73
+ * ambiguity (A↔4, S↔5) before accepting. Default 2.
74
+ */
75
+ minMargin?: number;
70
76
  }
71
77
 
72
78
  interface CellVotes {
@@ -157,6 +163,10 @@ const linesForFrame = (text: string): string[] | null => {
157
163
  export class MRZFrameAggregator {
158
164
  private readonly stabilityTarget: number;
159
165
  private readonly maxFrames: number;
166
+ // Minimum winning vote margin a CONTESTED cell needs before the consensus is
167
+ // considered confident (and thus eligible to be "stable"). Guards against an
168
+ // early look-alike misread (A↔4, S↔5, B↔8) locking in on a 1-vote lead.
169
+ private readonly minMargin: number;
160
170
  // format -> per-line, per-column vote tallies
161
171
  private tallies: Record<string, CellVotes[][]> = {};
162
172
  private frameCount = 0;
@@ -168,6 +178,7 @@ export class MRZFrameAggregator {
168
178
  constructor(options: MRZFrameAggregatorOptions = {}) {
169
179
  this.stabilityTarget = Math.max(1, options.stabilityTarget ?? 2);
170
180
  this.maxFrames = Math.max(1, options.maxFrames ?? 60);
181
+ this.minMargin = Math.max(0, options.minMargin ?? 2);
171
182
  }
172
183
 
173
184
  reset(): void {
@@ -285,6 +296,35 @@ export class MRZFrameAggregator {
285
296
  this.frameCount = Math.ceil(this.frameCount / 2);
286
297
  }
287
298
 
299
+ /**
300
+ * Is every CONTESTED cell of this format decided by a clear margin?
301
+ *
302
+ * A cell is contested when more than one distinct character has received votes
303
+ * (e.g. position 5 of a Turkish doc number gets both "A" and the look-alike
304
+ * "4"). Per-character voting alone reports the consensus "stable" the instant an
305
+ * early misread takes a 1-vote lead and stays unchanged — so a transient
306
+ * "A"→"4" confusion locks in before the correct glyph accumulates enough votes.
307
+ *
308
+ * Requiring the winner to lead the runner-up by `minMargin` weight on every
309
+ * contested cell defers stability until the vote has genuinely settled: a 2-vs-1
310
+ * early lead is NOT confident; a 4-vs-1 (or clean uncontested) reading is. This
311
+ * is what turns "looks valid this frame" into "the camera agrees across frames".
312
+ */
313
+ private consensusIsConfident(format: string): boolean {
314
+ const geom = FORMAT_GEOMETRY[format];
315
+ const tally = this.tallies[format];
316
+ for (let li = 0; li < geom.lines; li++) {
317
+ for (let ci = 0; ci < geom.width; ci++) {
318
+ const cell = tally[li][ci];
319
+ const weights = Object.values(cell);
320
+ if (weights.length < 2) continue; // uncontested cell — no ambiguity
321
+ weights.sort((a, b) => b - a);
322
+ if (weights[0] - weights[1] < this.minMargin) return false;
323
+ }
324
+ }
325
+ return true;
326
+ }
327
+
288
328
  /** Read out the winning character per cell for a format. */
289
329
  private consensusLines(format: string): string[] {
290
330
  const geom = FORMAT_GEOMETRY[format];
@@ -331,6 +371,7 @@ export class MRZFrameAggregator {
331
371
  let bestMrz: string | null = null;
332
372
  let bestValidation: MRZValidationResult | null = null;
333
373
  let bestValid = false;
374
+ let bestFormat: string | null = null;
334
375
 
335
376
  for (const format of formats) {
336
377
  const lines = this.consensusLines(format);
@@ -342,9 +383,11 @@ export class MRZFrameAggregator {
342
383
  bestValid = true;
343
384
  bestMrz = validation.correctedMrz ?? mrz;
344
385
  bestValidation = validation;
386
+ bestFormat = format;
345
387
  } else if (!bestValid && bestMrz === null) {
346
388
  bestMrz = mrz;
347
389
  bestValidation = validation;
390
+ bestFormat = format;
348
391
  }
349
392
  }
350
393
 
@@ -358,11 +401,20 @@ export class MRZFrameAggregator {
358
401
  }
359
402
  this.lastConsensusMrz = stableKey;
360
403
 
404
+ // "Stable" requires BOTH an unchanged streak AND a confident vote: every
405
+ // contested cell must have resolved by a clear margin. The streak alone lets
406
+ // an early look-alike misread (A↔4) lock in the instant it takes a 1-vote
407
+ // lead and stops changing — the confidence gate defers acceptance until the
408
+ // correct glyph has genuinely won, not merely arrived first.
409
+ const confident =
410
+ bestValid && bestFormat != null && this.consensusIsConfident(bestFormat);
411
+
361
412
  return {
362
413
  mrz: bestMrz,
363
414
  validation: bestValidation,
364
415
  frames: this.frameCount,
365
- stable: bestValid && this.stableStreak >= this.stabilityTarget,
416
+ stable:
417
+ bestValid && confident && this.stableStreak >= this.stabilityTarget,
366
418
  };
367
419
  }
368
420
  }
@@ -1,3 +1,4 @@
1
+ import { debugLog } from './debug.utils';
1
2
  const runWithRetry = async <T>(
2
3
  fn: () => Promise<T>,
3
4
  maxRetries: number = 3,
@@ -9,26 +10,31 @@ const runWithRetry = async <T>(
9
10
  while (retries < maxRetries) {
10
11
  try {
11
12
  if (retries > 0) {
12
- console.log(`[Retry] Attempt ${retries + 1}/${maxRetries}...`);
13
+ debugLog('Retry', `[Retry] Attempt ${retries + 1}/${maxRetries}...`);
13
14
  }
14
15
  result = await fn();
15
16
  if (retries > 0) {
16
- console.log(`[Retry] ✓ Success on attempt ${retries + 1}`);
17
+ debugLog('Retry', `[Retry] ✓ Success on attempt ${retries + 1}`);
17
18
  }
18
19
  return result;
19
20
  } catch (error) {
20
21
  lastError = error;
21
22
  retries++;
22
- console.error(`[Retry] ✗ Attempt ${retries}/${maxRetries} failed:`, error instanceof Error ? error.message : error);
23
+ console.error(
24
+ `[Retry] ✗ Attempt ${retries}/${maxRetries} failed:`,
25
+ error instanceof Error ? error.message : error
26
+ );
23
27
  if (retries < maxRetries) {
24
28
  const waitTime = delay * retries;
25
- console.log(`[Retry] Waiting ${waitTime}ms before retry...`);
29
+ debugLog('Retry', `[Retry] Waiting ${waitTime}ms before retry...`);
26
30
  await new Promise((resolve) => setTimeout(resolve, waitTime));
27
31
  }
28
32
  }
29
33
  }
30
34
  console.error('[Retry] ✗ All retries exhausted. Last error:', lastError);
31
- throw new Error(`Max retries (${maxRetries}) exceeded. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
35
+ throw new Error(
36
+ `Max retries (${maxRetries}) exceeded. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`
37
+ );
32
38
  };
33
39
 
34
40
  export { runWithRetry };
@@ -0,0 +1,15 @@
1
+ import 'react-native-get-random-values';
2
+
3
+ // Unbiased Fisher–Yates shuffle backed by a CSPRNG. Used for the liveness
4
+ // instruction order, which acts as an anti-replay challenge and must not be
5
+ // predictable.
6
+ export const secureShuffle = <T>(items: T[]): T[] => {
7
+ const result = [...items];
8
+ const random = new Uint32Array(1);
9
+ for (let i = result.length - 1; i > 0; i--) {
10
+ crypto.getRandomValues(random);
11
+ const j = random[0] % (i + 1);
12
+ [result[i], result[j]] = [result[j], result[i]];
13
+ }
14
+ return result;
15
+ };
@@ -318,7 +318,13 @@ class AnalyticsService implements IAnalyticsService {
318
318
  throw new Error('Rate limit exceeded');
319
319
  }
320
320
 
321
- // For other 4xx errors (Bad Request, Unauthorized, etc.), do NOT retry.
321
+ // Auth failures are transient from the SDK's perspective (e.g. a token
322
+ // rollout); throw so the batch is retried instead of silently dropped.
323
+ if (response.status === 401 || response.status === 403) {
324
+ throw new Error(`Authentication error: ${response.status}`);
325
+ }
326
+
327
+ // For other 4xx errors (Bad Request, etc.), do NOT retry.
322
328
  // We return successfully so the bad events are removed from the queue.
323
329
  if (response.status >= 400 && response.status < 500) {
324
330
  if (__DEV__)
@@ -450,6 +456,16 @@ class AnalyticsService implements IAnalyticsService {
450
456
  'password',
451
457
  'apikey',
452
458
  'accesstoken',
459
+ 'documentnumber',
460
+ 'personalnumber',
461
+ 'nationalid',
462
+ 'tckimlik',
463
+ 'mrz',
464
+ 'mrztext',
465
+ 'birthdate',
466
+ 'dateofbirth',
467
+ 'sessionkey',
468
+ 'sessiontoken',
453
469
  ];
454
470
 
455
471
  for (const [key, value] of Object.entries(metadata)) {