@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
@@ -116,6 +116,7 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
116
116
  // Prevent concurrent ML Kit processing (drop frames while busy)
117
117
  private val isProcessing = AtomicBoolean(false)
118
118
 
119
+ @Volatile
119
120
  private var lastFrameTime = 0L
120
121
  private val minFrameInterval get() = 1000L / targetFps
121
122
 
@@ -164,27 +165,6 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
164
165
  return selectWidestFrontCamera()
165
166
  }
166
167
 
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
168
  return CameraSelector.DEFAULT_BACK_CAMERA
189
169
  }
190
170
 
@@ -758,6 +738,24 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
758
738
  }
759
739
  }
760
740
  }
741
+ } else if (uvPixelStride == 1) {
742
+ val vRowStride = vPlane.rowStride
743
+
744
+ for (row in 0 until uvHeight) {
745
+ val uRowOffset = row * uvRowStride
746
+ val vRowOffset = row * vRowStride
747
+ for (col in 0 until uvWidth) {
748
+ val uIndex = uRowOffset + col
749
+ val vIndex = vRowOffset + col
750
+ if (vIndex < vBuffer.capacity() && uIndex < uBuffer.capacity()) {
751
+ nv21[nv21Offset++] = vBuffer.get(vIndex)
752
+ nv21[nv21Offset++] = uBuffer.get(uIndex)
753
+ } else {
754
+ nv21[nv21Offset++] = 128.toByte()
755
+ nv21[nv21Offset++] = 128.toByte()
756
+ }
757
+ }
758
+ }
761
759
  }
762
760
 
763
761
  // Convert YUV to JPEG with optimized quality (75% for faster encoding)
@@ -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
 
@@ -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';
@@ -185,130 +185,122 @@ const ResultScreen = () => {
185
185
  console.log('[ResultScreen] Media already uploaded during video call, skipping');
186
186
  return;
187
187
  }
188
+ const uploadSessionToken = getSessionToken();
188
189
  const uploadFileOptions = {
189
190
  toUrl: `${apiUrl}/identifications/${identificationId}/media`,
190
191
  method: 'POST',
191
192
  headers: {
192
- Accept: 'application/json'
193
+ Accept: 'application/json',
194
+ ...(uploadSessionToken ? {
195
+ 'X-Session-Token': uploadSessionToken
196
+ } : {})
193
197
  },
194
198
  files: [],
195
199
  progress: res => {
196
200
  setProgress(60 + res.totalBytesSent / res.totalBytesExpectedToSend * 30);
197
201
  }
198
202
  };
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');
203
+
204
+ // Plaintext identity images/video staged on disk for the multipart
205
+ // upload; everything tracked here is deleted in the finally below.
206
+ const tempPaths = [];
207
+ const writeTempImage = async (fileName, base64Data, filetype = 'image/jpeg') => {
208
+ const filePath = decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/${fileName}`);
209
+ await RNFS.writeFile(filePath, base64Data, 'base64');
210
+ tempPaths.push(filePath);
244
211
  uploadFileOptions.files.push({
245
212
  name: 'files',
246
- filename: 'DOCUMENT_HOLOGRAM_IMAGE.jpg',
247
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/DOCUMENT_HOLOGRAM_IMAGE.jpg`),
248
- filetype: 'image/jpeg'
213
+ filename: fileName,
214
+ filepath: filePath,
215
+ filetype
249
216
  });
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');
217
+ return filePath;
218
+ };
219
+ try {
220
+ const frontImage = scannedIdentityDocument?.frontImage;
221
+ if (frontImage && frontImage !== '') {
222
+ await writeTempImage('DOCUMENT_FRONT_IMAGE.jpg', frontImage);
223
+ }
224
+ const backImage = scannedIdentityDocument?.backImage;
225
+ if (backImage && backImage !== '') {
226
+ await writeTempImage('DOCUMENT_BACK_IMAGE.jpg', backImage);
227
+ }
228
+ const faceImage = scannedIdentityDocument?.faceImage;
229
+ if (faceImage && faceImage !== '') {
230
+ scannedIdentityDocument.faceImageMimeType ||= 'image/jpeg';
231
+ const extension = scannedIdentityDocument.faceImageMimeType === 'image/jp2' ? 'jp2' : scannedIdentityDocument.faceImageMimeType === 'image/png' ? 'png' : 'jpg';
232
+ await writeTempImage(`FACE_IMAGE.${extension}`, faceImage, scannedIdentityDocument.faceImageMimeType);
233
+ }
234
+ const secondaryFaceImage = scannedIdentityDocument?.secondaryFaceImage;
235
+ if (secondaryFaceImage && secondaryFaceImage !== '') {
236
+ await writeTempImage('DOCUMENT_SECONDARY_FACE_IMAGE.jpg', secondaryFaceImage);
237
+ }
238
+ const hologramImage = scannedIdentityDocument?.hologramImage;
239
+ if (hologramImage && hologramImage !== '') {
240
+ await writeTempImage('DOCUMENT_HOLOGRAM_IMAGE.jpg', hologramImage);
241
+ }
242
+ if (livenessDetection) {
243
+ for (const instruction of livenessDetection.instructions) {
244
+ if (instruction.photo !== '') {
245
+ const filePath = await writeTempImage(`LIVENESS_${instruction.instruction}_IMAGE.jpg`, instruction.photo);
246
+ if (instruction.instruction === 'LOOK_STRAIGHT_AND_BLINK') {
247
+ uploadFileOptions.files.push({
248
+ name: 'files',
249
+ filename: 'SELFIE_IMAGE.jpg',
250
+ filepath: filePath,
251
+ filetype: 'image/jpeg'
252
+ });
253
+ }
254
+ }
255
+ }
256
+ const livenessVideoPath = livenessDetection.videoPath;
257
+ if (livenessVideoPath) {
258
+ let videoFilePath;
259
+ if (Platform.OS === 'ios') {
260
+ const tmpTimestamp = new Date().getTime();
261
+ const tempDir = `${RNFS.TemporaryDirectoryPath}/${tmpTimestamp}`;
262
+ await RNFS.mkdir(tempDir);
263
+ tempPaths.push(tempDir);
264
+ videoFilePath = `${tempDir}/LIVENESS_VIDEO.mp4`;
265
+ } else {
266
+ videoFilePath = `${RNFS.TemporaryDirectoryPath}/LIVENESS_VIDEO.mp4`;
267
+ tempPaths.push(videoFilePath);
268
+ }
269
+ await RNFS.copyFile(livenessVideoPath, videoFilePath);
270
+
271
+ // Compress video with maximum compression settings
272
+ const compressedVideoPath = await VideoCompressor.compress(videoFilePath, {
273
+ compressionMethod: 'manual',
274
+ bitrate: 500000,
275
+ // 500 kbps
276
+ maxSize: 1280,
277
+ // HD 720p
278
+ minimumFileSizeForCompress: 0 // Always compress
279
+ }, compressionProgress => {
280
+ // Map compression progress (0-1) to main progress (40-60%)
281
+ setProgress(40 + compressionProgress * 20);
282
+ });
283
+
284
+ // Convert file:// URI to regular path for RNFS upload
285
+ const normalizedPath = compressedVideoPath.replace('file://', '');
286
+ tempPaths.push(normalizedPath);
255
287
  uploadFileOptions.files.push({
256
288
  name: 'files',
257
- filename: `LIVENESS_${instruction.instruction}_IMAGE.jpg`,
258
- filepath: decodeURIComponent(`${RNFS.TemporaryDirectoryPath}/LIVENESS_${instruction.instruction}_IMAGE.jpg`),
259
- filetype: 'image/jpeg'
289
+ filename: 'LIVENESS_VIDEO.mp4',
290
+ filepath: normalizedPath,
291
+ filetype: 'video/mp4'
260
292
  });
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
293
  }
270
294
  }
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
- });
295
+ if (uploadFileOptions.files.length === 0) {
296
+ return;
304
297
  }
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');
298
+ const response = await RNFS.uploadFiles(uploadFileOptions).promise;
299
+ if (![200, 201, 204].includes(response.statusCode)) {
300
+ throw new Error('Media upload failed');
301
+ }
302
+ } finally {
303
+ await Promise.all(tempPaths.map(path => RNFS.unlink(path).catch(() => {})));
312
304
  }
313
305
  }, [apiUrl]);
314
306
  const uploadVerbalConsentVideos = useCallback(async (identificationId, verbalConsentVideos) => {
@@ -335,11 +327,15 @@ const ResultScreen = () => {
335
327
  minimumFileSizeForCompress: 0 // Always compress
336
328
  });
337
329
  const compressedNormalizedPath = compressedVideoPath.replace('file://', '');
330
+ const consentSessionToken = getSessionToken();
338
331
  const response = await RNFS.uploadFiles({
339
332
  toUrl: `${apiUrl}/identifications/${identificationId}/media`,
340
333
  method: 'POST',
341
334
  headers: {
342
- Accept: 'application/json'
335
+ Accept: 'application/json',
336
+ ...(consentSessionToken ? {
337
+ 'X-Session-Token': consentSessionToken
338
+ } : {})
343
339
  },
344
340
  fields: {
345
341
  consentTitle: consentVideo.title,
@@ -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',
@@ -22,7 +22,7 @@ import { useTheme } from "../Contexts/ThemeContext.js";
22
22
  import DebugOverlay, { TestModePanel } from "./DebugOverlay.js";
23
23
  import { getStatusMessage, getFrameToScreenTransform, transformBoundsToScreen, getScanAreaBounds, angleBetweenPoints, detectDocumentType, determineDocumentTypeToSet, areMRZFieldsEqual, hasRequiredMRZFields, validateFacePosition } from "./IdentityDocumentCamera.utils.js";
24
24
  import { handlePassportFlow, handleIDFrontFlow, handleIDBackFlow, getNextStepAfterHologram } from "./IdentityDocumentCamera.flows.js";
25
- import { HOLOGRAM_IMAGE_COUNT, HOLOGRAM_DETECTION_THRESHOLD, HOLOGRAM_DETECTION_RETRY_COUNT, HOLOGRAM_CAPTURE_INTERVAL, HOLOGRAM_MAX_FRAMES_WITHOUT_FACE, MIN_BRIGHTNESS_THRESHOLD, MAX_BRIGHTNESS_THRESHOLD, FACE_EDGE_MARGIN_PERCENT, MAX_CONSECUTIVE_QUALITY_FAILURES, REQUIRED_CONSISTENT_MRZ_READS, REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS, SIGNATURE_TEXT_REGEX, MRZ_BLOCK_PATTERN, PASSPORT_MRZ_PATTERN, MIN_CARD_FACE_SIZE_PERCENT } from "./IdentityDocumentCamera.constants.js";
25
+ import { HOLOGRAM_IMAGE_COUNT, HOLOGRAM_DETECTION_THRESHOLD, HOLOGRAM_DETECTION_RETRY_COUNT, HOLOGRAM_CAPTURE_INTERVAL, HOLOGRAM_MAX_FRAMES_WITHOUT_FACE, MIN_BRIGHTNESS_THRESHOLD, MAX_BRIGHTNESS_THRESHOLD, FACE_EDGE_MARGIN_PERCENT, MAX_CONSECUTIVE_QUALITY_FAILURES, REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS, SIGNATURE_TEXT_REGEX, MRZ_BLOCK_PATTERN, PASSPORT_MRZ_PATTERN, MIN_CARD_FACE_SIZE_PERCENT } from "./IdentityDocumentCamera.constants.js";
26
26
 
27
27
  // Re-export types for backward compatibility
28
28
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
@@ -59,6 +59,11 @@ const IdentityDocumentCamera = ({
59
59
  const [isFrameBlurry, setIsFrameBlurry] = useState(false);
60
60
  const [hasGuideShown, setHasGuideShown] = useState(false);
61
61
  const [status, setStatus] = useState('SEARCHING');
62
+ // True once the multi-frame voted MRZ consensus is STABLE + valid (the same
63
+ // reading matched across `stabilityTarget` consecutive frames, with a clear
64
+ // vote margin on contested cells). Drives the green "GO" signal — we never show
65
+ // green on an early/transient valid read, only on a reliably-detected MRZ.
66
+ const [mrzReliable, setMrzReliable] = useState(false);
62
67
  const [nextStep, setNextStep] = useState('SCAN_ID_FRONT_OR_PASSPORT');
63
68
  const [_completedStep, setCompletedStep] = useState(null);
64
69
  const [detectedDocumentType, setDetectedDocumentType] = useState('UNKNOWN');
@@ -74,8 +79,11 @@ const IdentityDocumentCamera = ({
74
79
  // Multi-frame per-character voting: fuses OCR across frames so a single-frame
75
80
  // misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
76
81
  // resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
82
+ // stabilityTarget: 3 — the voted consensus must read the SAME valid MRZ across
83
+ // 3 consecutive contributing frames before we accept, so a transient look-alike
84
+ // misread (e.g. OCR-B "4" vs "A") can never lock in on one or two early frames.
77
85
  const mrzAggregator = useRef(new MRZFrameAggregator({
78
- stabilityTarget: 2
86
+ stabilityTarget: 3
79
87
  }));
80
88
 
81
89
  // Document type stability tracking - require consistent detections from good quality frames
@@ -157,6 +165,7 @@ const IdentityDocumentCamera = ({
157
165
  lastValidMRZFields.current = null;
158
166
  validMRZConsecutiveCount.current = 0;
159
167
  mrzAggregator.current.reset();
168
+ setMrzReliable(false);
160
169
  cachedBarcode.current = null;
161
170
  isCompletionCallbackInvoked.current = false;
162
171
  isStepTransitionInProgress.current = false;
@@ -386,6 +395,7 @@ const IdentityDocumentCamera = ({
386
395
  lastValidMRZFields.current = null;
387
396
  // New MRZ expected on the back — start voting fresh.
388
397
  mrzAggregator.current.reset();
398
+ setMrzReliable(false);
389
399
  }
390
400
  validMRZConsecutiveCount.current = 0;
391
401
  cachedBarcode.current = null; // Clear cached barcode on step change
@@ -650,15 +660,6 @@ const IdentityDocumentCamera = ({
650
660
  // During SCAN_HOLOGRAM, allow processing even if text is not readable
651
661
  }
652
662
 
653
- // Capture test mode data
654
- if (testMode && text && text.includes('<')) {
655
- const mrzOnlyText = scannedText?.mrzOnlyText || text;
656
- setTestModeData({
657
- mrzText: mrzOnlyText,
658
- timestamp: Date.now()
659
- });
660
- }
661
-
662
663
  // MRZ stability: require consistent valid reads across frames
663
664
  // Skip during SCAN_HOLOGRAM - document type already locked
664
665
  const mrzHasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
@@ -674,12 +675,37 @@ const IdentityDocumentCamera = ({
674
675
  }
675
676
  // else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
676
677
 
677
- // Check if we have enough consistent valid reads
678
- const mrzStableAndValid =
679
- // Either: the multi-frame voted consensus has converged (valid + stable),
680
- consensus.stable && parsedMRZData?.valid === true ||
681
- // or the legacy N-identical-reads path is satisfied.
682
- validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS && parsedMRZData?.valid === true && areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
678
+ // Accept ONLY when the multi-frame voted consensus is stable.
679
+ //
680
+ // Early frames can OCR an ambiguous glyph wrongly yet still produce a
681
+ // check-digit-VALID reading — e.g. a Turkish document number "A53032352"
682
+ // misread as "453032352" (the OCR-B 4 looks like A, and 453032352 also
683
+ // passes its check digit). The old code also accepted on a legacy path of
684
+ // "N identical valid single-frame reads", which those transient frames
685
+ // satisfy — locking in the WRONG number before the voted consensus settles
686
+ // on the correct one. We now require `consensus.stable`: the per-character
687
+ // vote must be unchanged across `stabilityTarget` consecutive contributing
688
+ // frames, which is exactly the "only accept once stable" guarantee.
689
+ // `addFrame` runs for every frame above, so the consensus is always current.
690
+ const mrzStableAndValid = consensus.stable && parsedMRZData?.valid === true;
691
+
692
+ // Surface the reliable-detection signal to the render so the UI can show a
693
+ // green "GO" the moment (and only once) the consensus is stable + valid.
694
+ // Never flips green on an early/transient valid read.
695
+ setMrzReliable(prev => prev === mrzStableAndValid ? prev : mrzStableAndValid);
696
+
697
+ // Test-mode panel: show the MRZ ONLY once it is stable + valid (the same
698
+ // consensus matched across `stabilityTarget` consecutive frames). Showing
699
+ // the raw per-frame OCR here would flash transient, possibly mis-converted
700
+ // readings (e.g. a momentary "4" for "A") before the vote settles — the
701
+ // opposite of what the screen should convey. We display the corrected,
702
+ // check-digit-valid consensus and never an unstable intermediate.
703
+ if (testMode && mrzStableAndValid && mrzText) {
704
+ setTestModeData(prev => prev?.mrzText === mrzText ? prev : {
705
+ mrzText,
706
+ timestamp: Date.now()
707
+ });
708
+ }
683
709
 
684
710
  // ============================================================================
685
711
  // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
@@ -1635,8 +1661,10 @@ const IdentityDocumentCamera = ({
1635
1661
  }), /*#__PURE__*/_jsx(AnimatedText, {
1636
1662
  style: [styles.topZoneText,
1637
1663
  // Priority order for coloring (later styles override earlier ones)
1638
- // 1. Success (green) - scan completed
1639
- nextStep === 'COMPLETED' && styles.topZoneTextSuccess,
1664
+ // 1. Success/GO (green) - scan completed OR MRZ reliably detected
1665
+ // (stable consensus). This is the "GO" signal; never shown on a
1666
+ // transient/early valid read.
1667
+ (nextStep === 'COMPLETED' || mrzReliable) && styles.topZoneTextSuccess,
1640
1668
  // 2. Error (red) - wrong side - with flash opacity
1641
1669
  status === 'INCORRECT' && styles.topZoneTextError, status === 'INCORRECT' && {
1642
1670
  opacity: errorFlashAnim
@@ -1647,7 +1675,7 @@ const IdentityDocumentCamera = ({
1647
1675
  status === 'SCANNING' && allElementsDetected && elementsOutsideScanArea.length === 0 && !isBrightnessLow && !isFrameBlurry && styles.topZoneTextScanning
1648
1676
  // 5. Default (white) - aligning (not all detected OR elements outside scan area)
1649
1677
  ],
1650
- children: getStatusMessage(nextStep, status, detectedDocumentType, isBrightnessLow, isFrameBlurry, allElementsDetected, elementsOutsideScanArea, t)
1678
+ children: getStatusMessage(nextStep, status, detectedDocumentType, isBrightnessLow, isFrameBlurry, allElementsDetected, elementsOutsideScanArea, t, mrzReliable)
1651
1679
  })]
1652
1680
  }), /*#__PURE__*/_jsx(View, {
1653
1681
  style: styles.leftZone
@@ -1657,8 +1685,11 @@ const IdentityDocumentCamera = ({
1657
1685
  style: styles.bottomZone
1658
1686
  }), /*#__PURE__*/_jsx(View, {
1659
1687
  style: [styles.scanArea, {
1660
- borderColor: nextStep === 'COMPLETED' ? '#4CAF50' : status === 'INCORRECT' ? '#f44336' : status === 'SCANNING' ? '#2196F3' : isBrightnessLow || isFrameBlurry ? '#FFC107' : 'white',
1661
- borderWidth: status === 'SCANNING' ? 3 : 2
1688
+ borderColor:
1689
+ // Green once the scan is done OR the MRZ is reliably detected
1690
+ // (stable consensus) — the "GO" signal. Never green before that.
1691
+ nextStep === 'COMPLETED' || mrzReliable ? '#4CAF50' : status === 'INCORRECT' ? '#f44336' : status === 'SCANNING' ? '#2196F3' : isBrightnessLow || isFrameBlurry ? '#FFC107' : 'white',
1692
+ borderWidth: mrzReliable || status === 'SCANNING' ? 3 : 2
1662
1693
  }],
1663
1694
  children: nextStep === 'COMPLETED' ? /*#__PURE__*/_jsx(LottieView, {
1664
1695
  source: require('../../Shared/Animations/success.json'),
@@ -78,10 +78,17 @@ export function transformBoundsToScreen(bounds, scale, offsetX, offsetY) {
78
78
  * Unified status message logic used by both voice guidance and render text.
79
79
  * Returns the appropriate i18n key arguments for the current scan state.
80
80
  */
81
- export function getStatusMessage(nextStep, status, detectedDocumentType, isBrightnessLow, isFrameBlurry, allElementsDetected, elementsOutsideScanArea, t) {
81
+ export function getStatusMessage(nextStep, status, detectedDocumentType, isBrightnessLow, isFrameBlurry, allElementsDetected, elementsOutsideScanArea, t, mrzReliable = false) {
82
82
  if (nextStep === 'COMPLETED') {
83
83
  return t('identityDocumentCamera.scanCompleted');
84
84
  }
85
+
86
+ // MRZ reliably detected (stable consensus) — the green "GO" message. Ranks
87
+ // above the in-progress states so the user gets a clear positive signal the
88
+ // moment the reading has settled, but never on an early/transient valid read.
89
+ if (mrzReliable) {
90
+ return t('identityDocumentCamera.mrzDetected');
91
+ }
85
92
  if (status === 'INCORRECT') {
86
93
  if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
87
94
  return t('identityDocumentCamera.alignIDFront');