@trustchex/react-native-sdk 1.475.1 → 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 +193 -33
  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 +13 -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 +200 -34
  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,11 +319,14 @@ 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
331
  // Line 3 (name): the longest mostly-letters+filler OCR line that is NOT the
301
332
  // doc or date line. OCR mangles the name line too — a leading "O" reads as
@@ -547,6 +578,112 @@ const icaoCheckDigit = str => {
547
578
  return String(sum);
548
579
  };
549
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
+
550
687
  /**
551
688
  * When a TD1 MRZ fails ONLY on its composite check digit (every per-field check
552
689
  * digit is valid), the composite is deterministically derivable from the other
@@ -557,6 +694,17 @@ const fixTD1Composite = lines => {
557
694
  if (lines.length < 3 || lines[0].length !== 30 || lines[1].length !== 30) {
558
695
  return null;
559
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
+ }
560
708
  const r = parse(lines);
561
709
  const bad = (r.details ?? []).filter(d => !d.valid);
562
710
  if (bad.length !== 1 || bad[0].field !== 'compositeCheckDigit') return null;
@@ -580,18 +728,24 @@ const fixTD1Composite = lines => {
580
728
  * - line 2 (date record): delete each char in the birth..expiry-check window
581
729
  * [0..15) (then right-pad to 30) — undoes a spurious inserted digit;
582
730
  * - line 1 (doc record): delete each char in the doc-number..check window
583
- * [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.
584
735
  * Each candidate is re-padded to 30, the composite is recomputed, and only a
585
736
  * fully check-digit-valid parse is accepted, so a wrong edit can never pass.
586
737
  */
587
738
  const recoverTD1ByIndel = lines => {
588
739
  if (lines.length < 3) return null;
589
- 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);
590
744
 
591
745
  // Which line/window to try edits in, based on which check digit is failing.
592
746
  let base;
593
747
  try {
594
- base = parse(lines.map(pad30));
748
+ base = parse(lines.map((l, li) => pad30(l, li)));
595
749
  } catch {
596
750
  return null;
597
751
  }
@@ -612,15 +766,26 @@ const recoverTD1ByIndel = lines => {
612
766
  // Build all single-deletion candidates up front.
613
767
  const candidates = [];
614
768
  for (const [li, from, to] of regions) {
615
- const original = pad30(lines[li] ?? '');
769
+ const original = pad30(lines[li] ?? '', li);
616
770
  for (let i = from; i < to && i < original.length; i++) {
617
- const edited = pad30(original.slice(0, i) + original.slice(i + 1));
618
- 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));
619
773
  candidate[li] = edited;
620
774
  candidates.push(candidate);
621
775
  }
622
776
  }
623
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
+
624
789
  // Cheap pass first: a plain parse or a composite recompute resolves the common
625
790
  // case (a dropped/duplicated digit) in microseconds — try ALL deletions this
626
791
  // way before paying for any substitution search.
@@ -809,26 +974,7 @@ const correctedMrzFromParse = result => {
809
974
  const width = widths[result.format ?? ''];
810
975
  if (!width) return undefined;
811
976
  const lineCount = result.format === 'TD1' ? 3 : 2;
812
- const buffers = Array.from({
813
- length: lineCount
814
- }, () => new Array(width).fill('<'));
815
- for (const detail of result.details ?? []) {
816
- for (const range of detail.ranges ?? []) {
817
- // `raw` (the actual characters at this span) exists at runtime but isn't in
818
- // the `mrz` Range type.
819
- const {
820
- line,
821
- start,
822
- raw = ''
823
- } = range;
824
- const buf = buffers[line];
825
- if (!buf) continue;
826
- for (let i = 0; i < raw.length && start + i < width; i++) {
827
- buf[start + i] = raw[i];
828
- }
829
- }
830
- }
831
- return buffers.map(b => b.join('')).join('\n');
977
+ return linesFromParseRanges(result, width, lineCount).join('\n');
832
978
  };
833
979
 
834
980
  /**
@@ -862,6 +1008,10 @@ const validateMRZ = (mrzText, autocorrect = true) => {
862
1008
  // line + fragments). That must NOT short-circuit the recovery/reconstruction
863
1009
  // path below — treat a throw here as "no valid parse yet" and keep going.
864
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;
865
1015
  try {
866
1016
  result = parse(fixedText, {
867
1017
  autocorrect
@@ -891,6 +1041,16 @@ const validateMRZ = (mrzText, autocorrect = true) => {
891
1041
  }
892
1042
  }
893
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
+
894
1054
  // Primary recovery: exhaustive, CHECK-DIGIT-DRIVEN correction scoped to the
895
1055
  // fields whose check digit actually fails. It tries every OCR-ambiguous
896
1056
  // permutation within those fields — ordered by how likely the OCR error is
@@ -899,7 +1059,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
899
1059
  // digits all pass). This both (a) fixes a misread document number before the
900
1060
  // caller advances to the NFC/next screen, and (b) prefers the genuinely
901
1061
  // correct correction over an arbitrary check-digit-satisfying one.
902
- if (!result || !result.valid) {
1062
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
903
1063
  const fixedLines = fixedText.split('\n').filter(l => l.trim());
904
1064
  if (fixedLines.length >= 2) {
905
1065
  const recovered = recoverByFailingFields(fixedLines);
@@ -912,7 +1072,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
912
1072
  // Last-resort fallback: the older whole-string ambiguous sweep, only for
913
1073
  // inputs the field-scoped recovery can't address (e.g. an unrecognised
914
1074
  // format with no check-digit span map). Still requires a fully-valid parse.
915
- if (!result || !result.valid) {
1075
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
916
1076
  const variants = generateAmbiguousVariants(fixedText);
917
1077
  for (const variant of variants) {
918
1078
  if (recoveryExpired()) break; // bail before blocking the JS thread
@@ -936,7 +1096,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
936
1096
  // or broke a field with spaces). Reconstruct fixed-width line-sets for every
937
1097
  // supported format (TD1/TD2/TD3) and validate each WITH CHECK DIGITS, keeping
938
1098
  // only one that passes — a wrong reconstruction can never leak through.
939
- if (!result || !result.valid) {
1099
+ if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
940
1100
  for (const reLines of reconstructMRZCandidates(mrzText)) {
941
1101
  // A reconstructed candidate for the wrong format (e.g. a 2-line TD2 guess
942
1102
  // built from a TD1 frame) makes the `mrz` parser THROW on line count —
@@ -957,7 +1117,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
957
1117
  }
958
1118
  }
959
1119
  }
960
- if (!result || !result.valid) {
1120
+ if (!result || !result.valid && !turkishLetterPrefixAccepted) {
961
1121
  return {
962
1122
  valid: false,
963
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