@trustchex/react-native-sdk 1.475.1 → 1.481.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +76 -24
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
  3. package/ios/Camera/TrustchexCameraView.swift +20 -15
  4. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
  5. package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
  6. package/lib/module/Screens/Static/ResultScreen.js +145 -113
  7. package/lib/module/Shared/Components/DebugOverlay.js +5 -2
  8. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  9. package/lib/module/Shared/Components/IdentityDocumentCamera.js +185 -30
  10. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
  11. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  12. package/lib/module/Shared/EIDReader/eidReader.js +159 -38
  13. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  14. package/lib/module/Shared/Libs/SignalingClient.js +6 -5
  15. package/lib/module/Shared/Libs/crypto.utils.js +25 -3
  16. package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
  17. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  18. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  19. package/lib/module/Shared/Libs/http-client.js +19 -6
  20. package/lib/module/Shared/Libs/mrz.utils.js +193 -33
  21. package/lib/module/Shared/Libs/mrzFrameAggregator.js +74 -3
  22. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  23. package/lib/module/Shared/Libs/promise.utils.js +4 -3
  24. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  25. package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
  26. package/lib/module/Shared/Services/AnalyticsService.js +8 -2
  27. package/lib/module/Shared/Services/DataUploadService.js +114 -127
  28. package/lib/module/Shared/Services/VideoSessionService.js +4 -3
  29. package/lib/module/Translation/Resources/en.js +6 -0
  30. package/lib/module/Translation/Resources/tr.js +6 -0
  31. package/lib/module/Trustchex.js +20 -4
  32. package/lib/module/version.js +1 -1
  33. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  34. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  35. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  38. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  39. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  41. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  42. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
  43. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  44. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  45. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  46. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  48. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
  49. package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
  50. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
  51. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  52. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  53. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  54. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  55. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  56. package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
  57. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  58. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  59. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +55 -0
  60. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  61. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  62. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  63. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  64. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  65. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  66. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
  67. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
  68. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  69. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  70. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
  71. package/lib/typescript/src/Translation/Resources/en.d.ts +6 -0
  72. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  73. package/lib/typescript/src/Translation/Resources/tr.d.ts +6 -0
  74. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  75. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  76. package/lib/typescript/src/version.d.ts +1 -1
  77. package/package.json +17 -3
  78. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
  79. package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
  80. package/src/Screens/Static/ResultScreen.tsx +172 -162
  81. package/src/Shared/Components/DebugOverlay.tsx +5 -2
  82. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  83. package/src/Shared/Components/IdentityDocumentCamera.tsx +200 -29
  84. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  85. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
  86. package/src/Shared/Components/NavigationManager.tsx +7 -7
  87. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  88. package/src/Shared/EIDReader/eidReader.ts +181 -52
  89. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  90. package/src/Shared/Libs/SignalingClient.ts +10 -5
  91. package/src/Shared/Libs/crypto.utils.ts +41 -4
  92. package/src/Shared/Libs/deeplink.utils.ts +39 -7
  93. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  94. package/src/Shared/Libs/diagnostics.ts +251 -0
  95. package/src/Shared/Libs/http-client.ts +24 -8
  96. package/src/Shared/Libs/mrz.utils.ts +200 -34
  97. package/src/Shared/Libs/mrzFrameAggregator.ts +112 -2
  98. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  99. package/src/Shared/Libs/promise.utils.ts +11 -5
  100. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  101. package/src/Shared/Libs/shuffle.utils.ts +15 -0
  102. package/src/Shared/Services/AnalyticsService.ts +17 -1
  103. package/src/Shared/Services/DataUploadService.ts +155 -166
  104. package/src/Shared/Services/VideoSessionService.ts +15 -3
  105. package/src/Translation/Resources/en.ts +7 -0
  106. package/src/Translation/Resources/tr.ts +7 -0
  107. package/src/Trustchex.tsx +26 -3
  108. package/src/version.ts +1 -1
@@ -40,6 +40,7 @@ import com.google.mlkit.vision.text.TextRecognition
40
40
  import com.google.mlkit.vision.text.latin.TextRecognizerOptions
41
41
  import java.io.ByteArrayOutputStream
42
42
  import kotlin.math.max
43
+ import kotlin.math.min
43
44
  import java.io.File
44
45
  import java.util.concurrent.ExecutorService
45
46
  import java.util.concurrent.Executors
@@ -116,6 +117,7 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
116
117
  // Prevent concurrent ML Kit processing (drop frames while busy)
117
118
  private val isProcessing = AtomicBoolean(false)
118
119
 
120
+ @Volatile
119
121
  private var lastFrameTime = 0L
120
122
  private val minFrameInterval get() = 1000L / targetFps
121
123
 
@@ -164,27 +166,6 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
164
166
  return selectWidestFrontCamera()
165
167
  }
166
168
 
167
- // For back camera, try to select Ultra Wide camera for document scanning
168
- val cameraProvider = cameraProvider ?: return CameraSelector.DEFAULT_BACK_CAMERA
169
-
170
- try {
171
- // Check for Ultra Wide camera
172
- val ultraWideSelector = CameraSelector.Builder()
173
- .requireLensFacing(CameraSelector.LENS_FACING_BACK)
174
- .build()
175
-
176
- val cameras = cameraProvider.availableCameraInfos.filter {
177
- ultraWideSelector.filter(listOf(it)).isNotEmpty()
178
- }
179
-
180
- // Heuristic: If we have 3+ back cameras, index 2 is often Ultra Wide
181
- if (cameras.size >= 3) {
182
- // Heuristic: If we have 3+ back cameras, index 2 is often Ultra Wide
183
- }
184
- } catch (e: Exception) {
185
- // Failed to enumerate cameras
186
- }
187
-
188
169
  return CameraSelector.DEFAULT_BACK_CAMERA
189
170
  }
190
171
 
@@ -544,8 +525,35 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
544
525
  val result = textTask.result
545
526
  frameData.putString("resultText", result.text)
546
527
 
528
+ // Per-symbol confidence aggregation (ML Kit BUNDLED library
529
+ // only — Text.Symbol.confidence is 0 on the unbundled
530
+ // play-services variant). We walk Block→Line→Element→Symbol
531
+ // and accumulate confidence over MRZ-shaped characters
532
+ // ([A-Z0-9<]) only, so surrounding non-MRZ text doesn't skew
533
+ // the weight. The mean and min are surfaced to JS as a
534
+ // per-frame quality signal that down-weights low-confidence
535
+ // frames in the multi-frame vote.
536
+ var confSum = 0f
537
+ var confCount = 0
538
+ var confMin = 1f
539
+
547
540
  val blocksArray = Arguments.createArray()
548
541
  for (block in result.textBlocks) {
542
+ for (line in block.lines) {
543
+ for (element in line.elements) {
544
+ for (symbol in element.symbols) {
545
+ val ch = symbol.text.firstOrNull() ?: continue
546
+ val upper = ch.uppercaseChar()
547
+ val isMrzChar = upper in 'A'..'Z' ||
548
+ upper in '0'..'9' || upper == '<'
549
+ if (!isMrzChar) continue
550
+ val c = symbol.confidence ?: continue
551
+ confSum += c
552
+ confCount++
553
+ if (c < confMin) confMin = c
554
+ }
555
+ }
556
+ }
549
557
  val blockMap = Arguments.createMap()
550
558
  blockMap.putString("text", block.text)
551
559
  val bb = block.boundingBox
@@ -572,6 +580,17 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
572
580
  blocksArray.pushMap(blockMap)
573
581
  }
574
582
  frameData.putArray("textBlocks", blocksArray)
583
+
584
+ // Frame-level MRZ-character confidence (mean + min over MRZ
585
+ // symbols). Omitted when ML Kit returned no usable confidence
586
+ // (count == 0) so JS can fall back to band-height weighting.
587
+ if (confCount > 0) {
588
+ val confMap = Arguments.createMap()
589
+ confMap.putDouble("mean", (confSum / confCount).toDouble())
590
+ confMap.putDouble("min", confMin.toDouble())
591
+ confMap.putInt("count", confCount)
592
+ frameData.putMap("textConfidence", confMap)
593
+ }
575
594
  } else {
576
595
  frameData.putString("resultText", "")
577
596
  frameData.putArray("textBlocks", Arguments.createArray())
@@ -758,6 +777,24 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
758
777
  }
759
778
  }
760
779
  }
780
+ } else if (uvPixelStride == 1) {
781
+ val vRowStride = vPlane.rowStride
782
+
783
+ for (row in 0 until uvHeight) {
784
+ val uRowOffset = row * uvRowStride
785
+ val vRowOffset = row * vRowStride
786
+ for (col in 0 until uvWidth) {
787
+ val uIndex = uRowOffset + col
788
+ val vIndex = vRowOffset + col
789
+ if (vIndex < vBuffer.capacity() && uIndex < uBuffer.capacity()) {
790
+ nv21[nv21Offset++] = vBuffer.get(vIndex)
791
+ nv21[nv21Offset++] = uBuffer.get(uIndex)
792
+ } else {
793
+ nv21[nv21Offset++] = 128.toByte()
794
+ nv21[nv21Offset++] = 128.toByte()
795
+ }
796
+ }
797
+ }
761
798
  }
762
799
 
763
800
  // Convert YUV to JPEG with optimized quality (75% for faster encoding)
@@ -1087,11 +1124,26 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
1087
1124
  full.recycle()
1088
1125
 
1089
1126
  // Upscale so a ~30–44-char MRZ line clears the per-character pixel
1090
- // minimum. Cap the long edge to keep OCR latency bounded.
1127
+ // minimum — but NO FURTHER. ML Kit gains no accuracy above ~24px per
1128
+ // character (Google's guidance), and upscaling past that only adds
1129
+ // interpolation artifacts and latency. So target ~24px/char and take
1130
+ // the SMALLER of (a) the scale needed to reach that target and (b) the
1131
+ // long-edge/latency cap. When the band is already sharp enough, scale
1132
+ // stays at 1 (skip upscaling entirely).
1091
1133
  val maxLongEdge = 2200
1092
1134
  val longEdge = max(cropW, cropH)
1093
- val rawScale = if (longEdge > 0) (maxLongEdge.toFloat() / longEdge) else 1f
1094
- val scale = rawScale.coerceIn(1f, 2.5f)
1135
+ val capScale = if (longEdge > 0) {
1136
+ (maxLongEdge.toFloat() / longEdge).coerceIn(1f, 2.5f)
1137
+ } else 1f
1138
+ // Estimate current glyph height: the MRZ band is up to ~3 text lines
1139
+ // (TD1) within the crop; ~1.4 accounts for line spacing vs glyph height.
1140
+ val mrzBandLines = 3f
1141
+ val targetGlyphPx = 24f
1142
+ val estGlyphPx = (cropH / mrzBandLines) / 1.4f
1143
+ val targetScale = if (estGlyphPx > 0f) {
1144
+ (targetGlyphPx / estGlyphPx).coerceAtLeast(1f)
1145
+ } else capScale
1146
+ val scale = min(targetScale, capScale)
1095
1147
  val outW = (cropW * scale).toInt()
1096
1148
  val outH = (cropH * scale).toInt()
1097
1149
  val scaled = if (scale != 1f) {
@@ -233,6 +233,7 @@ class MLKitModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
233
233
  private fun base64ToInputImage(base64: String): InputImage {
234
234
  val imageBytes = Base64.decode(base64, Base64.NO_WRAP)
235
235
  val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
236
+ ?: throw IllegalArgumentException("Failed to decode image data")
236
237
  return InputImage.fromBitmap(bitmap, 0)
237
238
  }
238
239
 
@@ -972,19 +972,14 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
972
972
  blockY = Int(bb.origin.y / roi.scale) + roi.offsetY
973
973
  blockWidth = Int(bb.width / roi.scale)
974
974
  blockHeight = Int(bb.height / roi.scale)
975
- } else if isBufferLandscape {
976
- // Rotate from landscape (1920x1080) to portrait (1080x1920)
977
- // When rotating 90° clockwise (.right):
978
- // new_x = old_y
979
- // new_y = landscape_width - old_x - width
980
- // new_width = old_height
981
- // new_height = old_width
982
- blockX = Int(bb.origin.y)
983
- blockY = pixelWidth - Int(bb.origin.x) - Int(bb.width)
984
- blockWidth = Int(bb.height)
985
- blockHeight = Int(bb.width)
986
975
  } else {
987
- // Already portrait, use directly
976
+ // Fallback when the ROI couldn't be built: OCR ran on
977
+ // `visionImage`, which is created from `orientedImage` — already
978
+ // rotated to portrait (.right applied above when the buffer was
979
+ // landscape) with VisionImage.orientation = .up. ML Kit therefore
980
+ // returns coordinates in PORTRAIT space already, so they are used
981
+ // directly. (A prior landscape→portrait rotation here was a
982
+ // double-rotation bug, since the image was no longer landscape.)
988
983
  blockX = Int(bb.origin.x)
989
984
  blockY = Int(bb.origin.y)
990
985
  blockWidth = Int(bb.width)
@@ -1091,10 +1086,20 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
1091
1086
  // Move the crop origin to (0,0) so downstream extents are simple.
1092
1087
  img = img.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
1093
1088
 
1094
- // Upscale (≤2.5×, long edge capped at 2200) for more pixels per character.
1089
+ // Upscale for more pixels per character, but only up to ~24px/char — ML Kit
1090
+ // gains no accuracy above that (Google's guidance), and scaling further only
1091
+ // adds interpolation artifacts and latency. Take the SMALLER of the scale
1092
+ // needed to reach the target glyph height and the long-edge/latency cap; a
1093
+ // band that's already sharp enough stays at scale 1 (no upscaling).
1095
1094
  let longEdge = max(cropW, cropH)
1096
- let rawScale = longEdge > 0 ? (2200.0 / longEdge) : 1.0
1097
- let scale = min(max(rawScale, 1.0), 2.5)
1095
+ let capScale = longEdge > 0 ? min(max(2200.0 / longEdge, 1.0), 2.5) : 1.0
1096
+ // MRZ band is up to ~3 text lines (TD1) within the crop; ~1.4 accounts for
1097
+ // line spacing vs glyph height.
1098
+ let mrzBandLines: CGFloat = 3.0
1099
+ let targetGlyphPx: CGFloat = 24.0
1100
+ let estGlyphPx = (cropH / mrzBandLines) / 1.4
1101
+ let targetScale = estGlyphPx > 0 ? max(targetGlyphPx / estGlyphPx, 1.0) : capScale
1102
+ let scale = min(targetScale, capScale)
1098
1103
  if scale != 1.0 {
1099
1104
  img = img.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
1100
1105
  }
@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next';
13
13
  import StyledButton from "../../Shared/Components/StyledButton.js";
14
14
  import LottieView from 'lottie-react-native';
15
15
  import { speak, resetLastMessage } from "../../Shared/Libs/tts.utils.js";
16
+ import { secureShuffle } from "../../Shared/Libs/shuffle.utils.js";
16
17
  import { useStatusBarWhiteBackground } from "../../Shared/Libs/status-bar.utils.js";
17
18
  import { trackFunnelStep, useScreenTracking, trackVerificationStart, trackVerificationComplete } from "../../Shared/Libs/analytics.utils.js";
18
19
  import { useKeepAwake } from "../../Shared/Libs/native-keep-awake.utils.js";
@@ -137,9 +138,9 @@ const LivenessDetectionScreen = () => {
137
138
  }
138
139
  }, [hasGuideShown]);
139
140
  useEffect(() => {
140
- const il = Object.keys(instructions).filter(instruction => !['START', 'FINISH'].includes(instruction) && (
141
+ const il = secureShuffle(Object.keys(instructions).filter(instruction => !['START', 'FINISH'].includes(instruction) && (
141
142
  // Look straight and blink is always included
142
- instruction === 'LOOK_STRAIGHT_AND_BLINK' || !appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes || appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes.length === 0 || appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes.includes(instruction))).sort(() => Math.random() - 0.5);
143
+ instruction === 'LOOK_STRAIGHT_AND_BLINK' || !appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes || appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes.length === 0 || appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes.includes(instruction))));
143
144
  il.unshift('START');
144
145
  il.push('FINISH');
145
146
  setInitialState({
@@ -5,7 +5,8 @@ import { SafeAreaView, StyleSheet } from 'react-native';
5
5
  import AppContext from "../../Shared/Contexts/AppContext.js";
6
6
  import QrCodeScannerCamera from "../../Shared/Components/QrCodeScannerCamera.js";
7
7
  import { useNavigation } from '@react-navigation/native';
8
- import { handleDeepLink } from "../../Shared/Libs/deeplink.utils.js";
8
+ import { handleDeepLink, getUrlHost } from "../../Shared/Libs/deeplink.utils.js";
9
+ import { logWarn } from "../../Shared/Libs/debug.utils.js";
9
10
  import { useKeepAwake } from "../../Shared/Libs/native-keep-awake.utils.js";
10
11
  import { useStatusBarWhiteBackground } from "../../Shared/Libs/status-bar.utils.js";
11
12
  import { jsx as _jsx } from "react/jsx-runtime";
@@ -17,9 +18,18 @@ const QrCodeScanningScreen = () => {
17
18
  // Configure status bar for white background
18
19
  useStatusBarWhiteBackground();
19
20
  const onQrCodeScanned = data => {
20
- const [bUrl, sId] = handleDeepLink({
21
+ let [bUrl, sId] = handleDeepLink({
21
22
  url: data
22
23
  });
24
+
25
+ // A scanned QR may not repoint an already-configured backend to another
26
+ // host — uploads carry identity documents and biometrics. Keep the
27
+ // configured baseUrl and only take the session id from the QR.
28
+ const configuredBaseUrl = appContext.baseUrl;
29
+ if (bUrl && configuredBaseUrl && getUrlHost(bUrl) !== getUrlHost(configuredBaseUrl)) {
30
+ logWarn('QrCodeScanningScreen', 'Ignoring QR-provided baseUrl with mismatched host');
31
+ bUrl = configuredBaseUrl;
32
+ }
23
33
  if (bUrl && sId) {
24
34
  if (appContext.setBaseUrl) {
25
35
  appContext.setBaseUrl(bUrl);
@@ -4,7 +4,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
4
4
  import { Alert, Image, Platform, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
5
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import AppContext from "../../Shared/Contexts/AppContext.js";
7
- import httpClient from "../../Shared/Libs/http-client.js";
7
+ import httpClient, { getSessionToken } from "../../Shared/Libs/http-client.js";
8
8
  import RNFS from 'react-native-fs';
9
9
  import NativeProgressBar from "../../Shared/Components/NativeProgressBar.js";
10
10
  import LottieView from 'lottie-react-native';
@@ -18,7 +18,33 @@ import { Video as VideoCompressor } from 'react-native-compressor';
18
18
  import { encryptWithAes, getSessionKey } from "../../Shared/Libs/crypto.utils.js";
19
19
  import Video from 'react-native-video';
20
20
  import StyledButton from "../../Shared/Components/StyledButton.js";
21
+ import DiagnosticReportButton from "../../Shared/Components/DiagnosticReportButton.js";
21
22
  import NativeDeviceInfo from "../../Shared/Libs/native-device-info.utils.js";
23
+
24
+ /**
25
+ * Collect the captured document/face images (base64) as attachments for a
26
+ * diagnostic report. On an ID card the BACK image is the MRZ side; on a passport
27
+ * the front carries the MRZ. The NFC chip face arrives as `faceImage` with its
28
+ * own mime type, which may be png.
29
+ */
30
+ const collectDiagnosticImages = doc => {
31
+ if (!doc) return [];
32
+ const out = [];
33
+ const push = (name, b64, mime = 'jpg') => {
34
+ if (b64 && b64 !== '') out.push({
35
+ name,
36
+ base64: b64,
37
+ mime
38
+ });
39
+ };
40
+ push('mrz-side', doc.backImage);
41
+ push('front', doc.frontImage);
42
+ const faceMime = doc.faceImageMimeType === 'image/png' ? 'png' : 'jpg';
43
+ push('face', doc.faceImage, faceMime);
44
+ push('secondary-face', doc.secondaryFaceImage);
45
+ push('hologram', doc.hologramImage);
46
+ return out;
47
+ };
22
48
  import { trackError, trackFunnelStep, useScreenTracking } from "../../Shared/Libs/analytics.utils.js";
23
49
  import { analyticsService } from "../../Shared/Services/AnalyticsService.js";
24
50
  import { AnalyticsEventName, AnalyticsEventCategory } from "../../Shared/Types/analytics.types.js";
@@ -185,130 +211,122 @@ const ResultScreen = () => {
185
211
  console.log('[ResultScreen] Media already uploaded during video call, skipping');
186
212
  return;
187
213
  }
214
+ const uploadSessionToken = getSessionToken();
188
215
  const uploadFileOptions = {
189
216
  toUrl: `${apiUrl}/identifications/${identificationId}/media`,
190
217
  method: 'POST',
191
218
  headers: {
192
- Accept: 'application/json'
219
+ Accept: 'application/json',
220
+ ...(uploadSessionToken ? {
221
+ 'X-Session-Token': uploadSessionToken
222
+ } : {})
193
223
  },
194
224
  files: [],
195
225
  progress: res => {
196
226
  setProgress(60 + res.totalBytesSent / res.totalBytesExpectedToSend * 30);
197
227
  }
198
228
  };
199
- const frontImage = scannedIdentityDocument?.frontImage;
200
- if (frontImage && frontImage !== '') {
201
- await RNFS.writeFile(decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_FRONT_IMAGE.jpg`), frontImage, 'base64');
202
- uploadFileOptions.files.push({
203
- name: 'files',
204
- filename: 'DOCUMENT_FRONT_IMAGE.jpg',
205
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_FRONT_IMAGE.jpg`),
206
- filetype: 'image/jpeg'
207
- });
208
- }
209
- const backImage = scannedIdentityDocument?.backImage;
210
- if (backImage && backImage !== '') {
211
- await RNFS.writeFile(decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_BACK_IMAGE.jpg`), backImage, 'base64');
212
- uploadFileOptions.files.push({
213
- name: 'files',
214
- filename: 'DOCUMENT_BACK_IMAGE.jpg',
215
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_BACK_IMAGE.jpg`),
216
- filetype: 'image/jpeg'
217
- });
218
- }
219
- const faceImage = scannedIdentityDocument?.faceImage;
220
- if (faceImage && faceImage !== '') {
221
- scannedIdentityDocument.faceImageMimeType ||= 'image/jpeg';
222
- const extension = scannedIdentityDocument.faceImageMimeType === 'image/jp2' ? 'jp2' : scannedIdentityDocument.faceImageMimeType === 'image/png' ? 'png' : 'jpg';
223
- await RNFS.writeFile(decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/FACE_IMAGE.${extension}`), faceImage, 'base64');
224
- uploadFileOptions.files.push({
225
- name: 'files',
226
- filename: `FACE_IMAGE.${extension}`,
227
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/FACE_IMAGE.${extension}`),
228
- filetype: scannedIdentityDocument.faceImageMimeType
229
- });
230
- }
231
- const secondaryFaceImage = scannedIdentityDocument?.secondaryFaceImage;
232
- if (secondaryFaceImage && secondaryFaceImage !== '') {
233
- await RNFS.writeFile(decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_SECONDARY_FACE_IMAGE.jpg`), secondaryFaceImage, 'base64');
234
- uploadFileOptions.files.push({
235
- name: 'files',
236
- filename: 'DOCUMENT_SECONDARY_FACE_IMAGE.jpg',
237
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_SECONDARY_FACE_IMAGE.jpg`),
238
- filetype: 'image/jpeg'
239
- });
240
- }
241
- const hologramImage = scannedIdentityDocument?.hologramImage;
242
- if (hologramImage && hologramImage !== '') {
243
- await RNFS.writeFile(decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_HOLOGRAM_IMAGE.jpg`), hologramImage, 'base64');
229
+
230
+ // Plaintext identity images/video staged on disk for the multipart
231
+ // upload; everything tracked here is deleted in the finally below.
232
+ const tempPaths = [];
233
+ const writeTempImage = async (fileName, base64Data, filetype = 'image/jpeg') => {
234
+ const filePath = decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/${fileName}`);
235
+ await RNFS.writeFile(filePath, base64Data, 'base64');
236
+ tempPaths.push(filePath);
244
237
  uploadFileOptions.files.push({
245
238
  name: 'files',
246
- filename: 'DOCUMENT_HOLOGRAM_IMAGE.jpg',
247
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_HOLOGRAM_IMAGE.jpg`),
248
- filetype: 'image/jpeg'
239
+ filename: fileName,
240
+ filepath: filePath,
241
+ filetype
249
242
  });
250
- }
251
- if (livenessDetection) {
252
- for (const instruction of livenessDetection.instructions) {
253
- if (instruction.photo !== '') {
254
- await RNFS.writeFile(decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/LIVENESS_${instruction.instruction}_IMAGE.jpg`), instruction.photo, 'base64');
243
+ return filePath;
244
+ };
245
+ try {
246
+ const frontImage = scannedIdentityDocument?.frontImage;
247
+ if (frontImage && frontImage !== '') {
248
+ await writeTempImage('DOCUMENT_FRONT_IMAGE.jpg', frontImage);
249
+ }
250
+ const backImage = scannedIdentityDocument?.backImage;
251
+ if (backImage && backImage !== '') {
252
+ await writeTempImage('DOCUMENT_BACK_IMAGE.jpg', backImage);
253
+ }
254
+ const faceImage = scannedIdentityDocument?.faceImage;
255
+ if (faceImage && faceImage !== '') {
256
+ scannedIdentityDocument.faceImageMimeType ||= 'image/jpeg';
257
+ const extension = scannedIdentityDocument.faceImageMimeType === 'image/jp2' ? 'jp2' : scannedIdentityDocument.faceImageMimeType === 'image/png' ? 'png' : 'jpg';
258
+ await writeTempImage(`FACE_IMAGE.${extension}`, faceImage, scannedIdentityDocument.faceImageMimeType);
259
+ }
260
+ const secondaryFaceImage = scannedIdentityDocument?.secondaryFaceImage;
261
+ if (secondaryFaceImage && secondaryFaceImage !== '') {
262
+ await writeTempImage('DOCUMENT_SECONDARY_FACE_IMAGE.jpg', secondaryFaceImage);
263
+ }
264
+ const hologramImage = scannedIdentityDocument?.hologramImage;
265
+ if (hologramImage && hologramImage !== '') {
266
+ await writeTempImage('DOCUMENT_HOLOGRAM_IMAGE.jpg', hologramImage);
267
+ }
268
+ if (livenessDetection) {
269
+ for (const instruction of livenessDetection.instructions) {
270
+ if (instruction.photo !== '') {
271
+ const filePath = await writeTempImage(`LIVENESS_${instruction.instruction}_IMAGE.jpg`, instruction.photo);
272
+ if (instruction.instruction === 'LOOK_STRAIGHT_AND_BLINK') {
273
+ uploadFileOptions.files.push({
274
+ name: 'files',
275
+ filename: 'SELFIE_IMAGE.jpg',
276
+ filepath: filePath,
277
+ filetype: 'image/jpeg'
278
+ });
279
+ }
280
+ }
281
+ }
282
+ const livenessVideoPath = livenessDetection.videoPath;
283
+ if (livenessVideoPath) {
284
+ let videoFilePath;
285
+ if (Platform.OS === 'ios') {
286
+ const tmpTimestamp = new Date().getTime();
287
+ const tempDir = `${RNFS.TemporaryDirectoryPath}/${tmpTimestamp}`;
288
+ await RNFS.mkdir(tempDir);
289
+ tempPaths.push(tempDir);
290
+ videoFilePath = `${tempDir}/LIVENESS_VIDEO.mp4`;
291
+ } else {
292
+ videoFilePath = `${RNFS.TemporaryDirectoryPath}/LIVENESS_VIDEO.mp4`;
293
+ tempPaths.push(videoFilePath);
294
+ }
295
+ await RNFS.copyFile(livenessVideoPath, videoFilePath);
296
+
297
+ // Compress video with maximum compression settings
298
+ const compressedVideoPath = await VideoCompressor.compress(videoFilePath, {
299
+ compressionMethod: 'manual',
300
+ bitrate: 500000,
301
+ // 500 kbps
302
+ maxSize: 1280,
303
+ // HD 720p
304
+ minimumFileSizeForCompress: 0 // Always compress
305
+ }, compressionProgress => {
306
+ // Map compression progress (0-1) to main progress (40-60%)
307
+ setProgress(40 + compressionProgress * 20);
308
+ });
309
+
310
+ // Convert file:// URI to regular path for RNFS upload
311
+ const normalizedPath = compressedVideoPath.replace('file://', '');
312
+ tempPaths.push(normalizedPath);
255
313
  uploadFileOptions.files.push({
256
314
  name: 'files',
257
- filename: `LIVENESS_${instruction.instruction}_IMAGE.jpg`,
258
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/LIVENESS_${instruction.instruction}_IMAGE.jpg`),
259
- filetype: 'image/jpeg'
315
+ filename: 'LIVENESS_VIDEO.mp4',
316
+ filepath: normalizedPath,
317
+ filetype: 'video/mp4'
260
318
  });
261
- if (instruction.instruction === 'LOOK_STRAIGHT_AND_BLINK') {
262
- uploadFileOptions.files.push({
263
- name: 'files',
264
- filename: 'SELFIE_IMAGE.jpg',
265
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/LIVENESS_${instruction.instruction}_IMAGE.jpg`),
266
- filetype: 'image/jpeg'
267
- });
268
- }
269
319
  }
270
320
  }
271
- const livenessVideoPath = livenessDetection.videoPath;
272
- if (livenessVideoPath) {
273
- let videoFilePath;
274
- if (Platform.OS === 'ios') {
275
- const tmpTimestamp = new Date().getTime();
276
- await RNFS.mkdir(`${RNFS.TemporaryDirectoryPath}/${tmpTimestamp}`);
277
- videoFilePath = `${RNFS.TemporaryDirectoryPath}/${tmpTimestamp}/LIVENESS_VIDEO.mp4`;
278
- } else {
279
- videoFilePath = `${RNFS.TemporaryDirectoryPath}/LIVENESS_VIDEO.mp4`;
280
- }
281
- await RNFS.copyFile(livenessVideoPath, videoFilePath);
282
-
283
- // Compress video with maximum compression settings
284
- const compressedVideoPath = await VideoCompressor.compress(videoFilePath, {
285
- compressionMethod: 'manual',
286
- bitrate: 500000,
287
- // 500 kbps
288
- maxSize: 1280,
289
- // HD 720p
290
- minimumFileSizeForCompress: 0 // Always compress
291
- }, compressionProgress => {
292
- // Map compression progress (0-1) to main progress (40-60%)
293
- setProgress(40 + compressionProgress * 20);
294
- });
295
-
296
- // Convert file:// URI to regular path for RNFS upload
297
- const normalizedPath = compressedVideoPath.replace('file://', '');
298
- uploadFileOptions.files.push({
299
- name: 'files',
300
- filename: 'LIVENESS_VIDEO.mp4',
301
- filepath: normalizedPath,
302
- filetype: 'video/mp4'
303
- });
321
+ if (uploadFileOptions.files.length === 0) {
322
+ return;
304
323
  }
305
- }
306
- if (uploadFileOptions.files.length === 0) {
307
- return;
308
- }
309
- const response = await RNFS.uploadFiles(uploadFileOptions).promise;
310
- if (![200, 201, 204].includes(response.statusCode)) {
311
- throw new Error('Media upload failed');
324
+ const response = await RNFS.uploadFiles(uploadFileOptions).promise;
325
+ if (![200, 201, 204].includes(response.statusCode)) {
326
+ throw new Error('Media upload failed');
327
+ }
328
+ } finally {
329
+ await Promise.all(tempPaths.map(path => RNFS.unlink(path).catch(() => {})));
312
330
  }
313
331
  }, [apiUrl]);
314
332
  const uploadVerbalConsentVideos = useCallback(async (identificationId, verbalConsentVideos) => {
@@ -335,11 +353,15 @@ const ResultScreen = () => {
335
353
  minimumFileSizeForCompress: 0 // Always compress
336
354
  });
337
355
  const compressedNormalizedPath = compressedVideoPath.replace('file://', '');
356
+ const consentSessionToken = getSessionToken();
338
357
  const response = await RNFS.uploadFiles({
339
358
  toUrl: `${apiUrl}/identifications/${identificationId}/media`,
340
359
  method: 'POST',
341
360
  headers: {
342
- Accept: 'application/json'
361
+ Accept: 'application/json',
362
+ ...(consentSessionToken ? {
363
+ 'X-Session-Token': consentSessionToken
364
+ } : {})
343
365
  },
344
366
  fields: {
345
367
  consentTitle: consentVideo.title,
@@ -715,18 +737,28 @@ const ResultScreen = () => {
715
737
  }, `${consentVideo.videoPath}-${index}`))]
716
738
  })]
717
739
  })
718
- }), /*#__PURE__*/_jsx(View, {
719
- style: {
720
- paddingBottom: insets.bottom
721
- },
722
- children: /*#__PURE__*/_jsx(StyledButton, {
740
+ }), /*#__PURE__*/_jsxs(View, {
741
+ style: [styles.footer, {
742
+ paddingBottom: insets.bottom + 20
743
+ }],
744
+ children: [/*#__PURE__*/_jsx(StyledButton, {
723
745
  mode: "contained",
724
746
  onPress: () => {
725
747
  appContext.onCompleted?.();
726
748
  navigationManagerRef.current?.reset();
727
749
  },
728
750
  children: t('resultScreen.demoStartOver')
729
- })
751
+ }), /*#__PURE__*/_jsx(DiagnosticReportButton, {
752
+ sessionId: appContext.identificationInfo?.sessionId,
753
+ scan: {
754
+ documentType: appContext.identificationInfo?.scannedDocument?.documentType,
755
+ dataSource: appContext.identificationInfo?.scannedDocument?.dataSource,
756
+ mrzText: appContext.identificationInfo?.scannedDocument?.mrzText,
757
+ mrzFields: appContext.identificationInfo?.scannedDocument?.mrzFields,
758
+ barcodeValue: appContext.identificationInfo?.scannedDocument?.barcodeValue
759
+ },
760
+ images: collectDiagnosticImages(appContext.identificationInfo?.scannedDocument)
761
+ })]
730
762
  })]
731
763
  }) : /*#__PURE__*/_jsxs(_Fragment, {
732
764
  children: [/*#__PURE__*/_jsx(LottieView, {
@@ -518,13 +518,16 @@ export const TestModePanel = /*#__PURE__*/React.memo(function TestModePanel({
518
518
  },
519
519
  children: [/*#__PURE__*/_jsx(TextView, {
520
520
  style: {
521
- color: '#FFA500',
521
+ // Green "GO" header: the panel only ever receives a STABLE + valid
522
+ // consensus MRZ (the caller withholds it until then), so showing it
523
+ // here IS the reliable-detection signal.
524
+ color: '#4CAF50',
522
525
  fontSize: 12,
523
526
  fontWeight: 'bold',
524
527
  marginBottom: 8,
525
528
  textAlign: 'center'
526
529
  },
527
- children: "MRZ Text Read"
530
+ children: "\u2713 MRZ STABLE \u2014 GO"
528
531
  }), /*#__PURE__*/_jsx(TextView, {
529
532
  style: {
530
533
  color: '#FFFFFF',