@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.
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +76 -24
- package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
- package/ios/Camera/TrustchexCameraView.swift +20 -15
- package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
- package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
- package/lib/module/Screens/Static/ResultScreen.js +145 -113
- package/lib/module/Shared/Components/DebugOverlay.js +5 -2
- package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +185 -30
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
- package/lib/module/Shared/Components/NavigationManager.js +7 -9
- package/lib/module/Shared/EIDReader/eidReader.js +159 -38
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
- package/lib/module/Shared/Libs/SignalingClient.js +6 -5
- package/lib/module/Shared/Libs/crypto.utils.js +25 -3
- package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
- package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
- package/lib/module/Shared/Libs/diagnostics.js +171 -0
- package/lib/module/Shared/Libs/http-client.js +19 -6
- package/lib/module/Shared/Libs/mrz.utils.js +193 -33
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +74 -3
- package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
- package/lib/module/Shared/Libs/promise.utils.js +4 -3
- package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
- package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
- package/lib/module/Shared/Services/AnalyticsService.js +8 -2
- package/lib/module/Shared/Services/DataUploadService.js +114 -127
- package/lib/module/Shared/Services/VideoSessionService.js +4 -3
- package/lib/module/Translation/Resources/en.js +6 -0
- package/lib/module/Translation/Resources/tr.js +6 -0
- package/lib/module/Trustchex.js +20 -4
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
- package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
- package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
- package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
- package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
- package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
- package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +55 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
- package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
- package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
- package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/en.d.ts +6 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +6 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/Trustchex.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +17 -3
- package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
- package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
- package/src/Screens/Static/ResultScreen.tsx +172 -162
- package/src/Shared/Components/DebugOverlay.tsx +5 -2
- package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +200 -29
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
- package/src/Shared/Components/NavigationManager.tsx +7 -7
- package/src/Shared/Components/TrustchexCamera.tsx +6 -0
- package/src/Shared/EIDReader/eidReader.ts +181 -52
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
- package/src/Shared/Libs/SignalingClient.ts +10 -5
- package/src/Shared/Libs/crypto.utils.ts +41 -4
- package/src/Shared/Libs/deeplink.utils.ts +39 -7
- package/src/Shared/Libs/diagnosticReport.ts +206 -0
- package/src/Shared/Libs/diagnostics.ts +251 -0
- package/src/Shared/Libs/http-client.ts +24 -8
- package/src/Shared/Libs/mrz.utils.ts +200 -34
- package/src/Shared/Libs/mrzFrameAggregator.ts +112 -2
- package/src/Shared/Libs/native-device-info.utils.ts +116 -0
- package/src/Shared/Libs/promise.utils.ts +11 -5
- package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
- package/src/Shared/Libs/shuffle.utils.ts +15 -0
- package/src/Shared/Services/AnalyticsService.ts +17 -1
- package/src/Shared/Services/DataUploadService.ts +155 -166
- package/src/Shared/Services/VideoSessionService.ts +15 -3
- package/src/Translation/Resources/en.ts +7 -0
- package/src/Translation/Resources/tr.ts +7 -0
- package/src/Trustchex.tsx +26 -3
- 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.
|
|
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
|
|
1094
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
1097
|
-
|
|
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)))
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
247
|
-
filepath:
|
|
248
|
-
filetype
|
|
239
|
+
filename: fileName,
|
|
240
|
+
filepath: filePath,
|
|
241
|
+
filetype
|
|
249
242
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
258
|
-
filepath:
|
|
259
|
-
filetype: '
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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__*/
|
|
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
|
-
|
|
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
|
|
530
|
+
children: "\u2713 MRZ STABLE \u2014 GO"
|
|
528
531
|
}), /*#__PURE__*/_jsx(TextView, {
|
|
529
532
|
style: {
|
|
530
533
|
color: '#FFFFFF',
|