@trustchex/react-native-sdk 1.478.7 → 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 +57 -3
- package/ios/Camera/TrustchexCameraView.swift +20 -15
- package/lib/module/Screens/Static/ResultScreen.js +42 -6
- package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
- package/lib/module/Shared/Components/NavigationManager.js +7 -9
- package/lib/module/Shared/EIDReader/eidReader.js +149 -29
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
- package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
- package/lib/module/Shared/Libs/diagnostics.js +171 -0
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
- package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
- package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
- package/lib/module/Translation/Resources/en.js +5 -0
- package/lib/module/Translation/Resources/tr.js +5 -0
- package/lib/module/Trustchex.js +20 -4
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.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/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/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/mrzFrameAggregator.d.ts +33 -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/sendDiagnosticReport.d.ts +30 -0
- package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -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 +5 -1
- package/src/Screens/Static/ResultScreen.tsx +46 -1
- package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
- package/src/Shared/Components/NavigationManager.tsx +7 -7
- package/src/Shared/Components/TrustchexCamera.tsx +6 -0
- package/src/Shared/EIDReader/eidReader.ts +166 -46
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
- package/src/Shared/Libs/diagnosticReport.ts +206 -0
- package/src/Shared/Libs/diagnostics.ts +251 -0
- package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
- package/src/Shared/Libs/native-device-info.utils.ts +116 -0
- package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
- package/src/Translation/Resources/en.ts +6 -0
- package/src/Translation/Resources/tr.ts +6 -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
|
|
@@ -524,8 +525,35 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
|
|
|
524
525
|
val result = textTask.result
|
|
525
526
|
frameData.putString("resultText", result.text)
|
|
526
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
|
+
|
|
527
540
|
val blocksArray = Arguments.createArray()
|
|
528
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
|
+
}
|
|
529
557
|
val blockMap = Arguments.createMap()
|
|
530
558
|
blockMap.putString("text", block.text)
|
|
531
559
|
val bb = block.boundingBox
|
|
@@ -552,6 +580,17 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
|
|
|
552
580
|
blocksArray.pushMap(blockMap)
|
|
553
581
|
}
|
|
554
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
|
+
}
|
|
555
594
|
} else {
|
|
556
595
|
frameData.putString("resultText", "")
|
|
557
596
|
frameData.putArray("textBlocks", Arguments.createArray())
|
|
@@ -1085,11 +1124,26 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
|
|
|
1085
1124
|
full.recycle()
|
|
1086
1125
|
|
|
1087
1126
|
// Upscale so a ~30–44-char MRZ line clears the per-character pixel
|
|
1088
|
-
// 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).
|
|
1089
1133
|
val maxLongEdge = 2200
|
|
1090
1134
|
val longEdge = max(cropW, cropH)
|
|
1091
|
-
val
|
|
1092
|
-
|
|
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)
|
|
1093
1147
|
val outW = (cropW * scale).toInt()
|
|
1094
1148
|
val outH = (cropH * scale).toInt()
|
|
1095
1149
|
val scaled = if (scale != 1f) {
|
|
@@ -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
|
}
|
|
@@ -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";
|
|
@@ -711,18 +737,28 @@ const ResultScreen = () => {
|
|
|
711
737
|
}, `${consentVideo.videoPath}-${index}`))]
|
|
712
738
|
})]
|
|
713
739
|
})
|
|
714
|
-
}), /*#__PURE__*/
|
|
715
|
-
style: {
|
|
716
|
-
paddingBottom: insets.bottom
|
|
717
|
-
},
|
|
718
|
-
children: /*#__PURE__*/_jsx(StyledButton, {
|
|
740
|
+
}), /*#__PURE__*/_jsxs(View, {
|
|
741
|
+
style: [styles.footer, {
|
|
742
|
+
paddingBottom: insets.bottom + 20
|
|
743
|
+
}],
|
|
744
|
+
children: [/*#__PURE__*/_jsx(StyledButton, {
|
|
719
745
|
mode: "contained",
|
|
720
746
|
onPress: () => {
|
|
721
747
|
appContext.onCompleted?.();
|
|
722
748
|
navigationManagerRef.current?.reset();
|
|
723
749
|
},
|
|
724
750
|
children: t('resultScreen.demoStartOver')
|
|
725
|
-
})
|
|
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
|
+
})]
|
|
726
762
|
})]
|
|
727
763
|
}) : /*#__PURE__*/_jsxs(_Fragment, {
|
|
728
764
|
children: [/*#__PURE__*/_jsx(LottieView, {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Alert } from 'react-native';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import StyledButton from "./StyledButton.js";
|
|
7
|
+
import { sendDiagnosticReport, isDiagnosticSharingAvailable } from "../Libs/sendDiagnosticReport.js";
|
|
8
|
+
import { SDK_VERSION } from "../../version.js";
|
|
9
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
10
|
+
/**
|
|
11
|
+
* "Report a scanning issue" button for the result screen.
|
|
12
|
+
*
|
|
13
|
+
* Shows a brief notice describing what will be shared (the draft itself is the
|
|
14
|
+
* user's review/consent step), then opens the OS share sheet — the user picks
|
|
15
|
+
* email / WhatsApp / Drive / etc. and sends the diagnostic bundle to support.
|
|
16
|
+
* Self-contained so the result screen only needs to render it with the scan
|
|
17
|
+
* data + captured images.
|
|
18
|
+
*/
|
|
19
|
+
const DiagnosticReportButton = ({
|
|
20
|
+
scan,
|
|
21
|
+
sessionId,
|
|
22
|
+
images
|
|
23
|
+
}) => {
|
|
24
|
+
const {
|
|
25
|
+
t
|
|
26
|
+
} = useTranslation();
|
|
27
|
+
const [sending, setSending] = useState(false);
|
|
28
|
+
|
|
29
|
+
// `react-native-share` is optional — hide the button entirely when the host
|
|
30
|
+
// app hasn't installed it, so apps that don't want diagnostics see no UI.
|
|
31
|
+
if (!isDiagnosticSharingAvailable()) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const onPress = () => {
|
|
35
|
+
if (sending) return;
|
|
36
|
+
// Brief notice — the share draft is the actual review/consent step.
|
|
37
|
+
Alert.alert(t('diagnosticReport.noticeTitle'), t('diagnosticReport.noticeBody'), [{
|
|
38
|
+
text: t('diagnosticReport.cancel'),
|
|
39
|
+
style: 'cancel'
|
|
40
|
+
}, {
|
|
41
|
+
text: t('diagnosticReport.continue'),
|
|
42
|
+
onPress: async () => {
|
|
43
|
+
setSending(true);
|
|
44
|
+
try {
|
|
45
|
+
await sendDiagnosticReport({
|
|
46
|
+
scan,
|
|
47
|
+
sdkVersion: SDK_VERSION,
|
|
48
|
+
sessionId,
|
|
49
|
+
images
|
|
50
|
+
});
|
|
51
|
+
} finally {
|
|
52
|
+
setSending(false);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}]);
|
|
56
|
+
};
|
|
57
|
+
return /*#__PURE__*/_jsx(StyledButton, {
|
|
58
|
+
mode: "outlined",
|
|
59
|
+
onPress: onPress,
|
|
60
|
+
disabled: sending,
|
|
61
|
+
children: t('diagnosticReport.button')
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
export default DiagnosticReportButton;
|
|
@@ -13,6 +13,7 @@ import { useKeepAwake } from "../Libs/native-keep-awake.utils.js";
|
|
|
13
13
|
import { useIsFocused } from '@react-navigation/native';
|
|
14
14
|
import { useTranslation } from 'react-i18next';
|
|
15
15
|
import { debugLog, logError, isDebugEnabled } from "../Libs/debug.utils.js";
|
|
16
|
+
import { diagnostics } from "../Libs/diagnostics.js";
|
|
16
17
|
import LottieView from 'lottie-react-native';
|
|
17
18
|
import StyledButton from "./StyledButton.js";
|
|
18
19
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
@@ -52,8 +53,21 @@ const IdentityDocumentCamera = ({
|
|
|
52
53
|
}, []);
|
|
53
54
|
const isCameraInitialized = useRef(false);
|
|
54
55
|
const [currentFaceImage, setCurrentFaceImage] = useState(undefined);
|
|
56
|
+
// Synchronous mirror of the locked face image (see currentHologramImageRef).
|
|
57
|
+
// Used as a fallback when completing SCAN_HOLOGRAM: the torch is on during
|
|
58
|
+
// hologram capture so the live frame's face is often rejected, leaving the
|
|
59
|
+
// state-derived faceImageToUse null within the stale closure — without this
|
|
60
|
+
// the completion gate's `&& !!faceImageToUse` would never pass.
|
|
61
|
+
const currentFaceImageRef = useRef(undefined);
|
|
55
62
|
const [_currentHologramMaskImage, setCurrentHologramMaskImage] = useState(undefined);
|
|
56
63
|
const [currentHologramImage, setCurrentHologramImage] = useState(undefined);
|
|
64
|
+
// Mirror of currentHologramImage for synchronous reads inside the per-frame
|
|
65
|
+
// callback. The setState above is async and currentHologramImage is a
|
|
66
|
+
// useCallback dependency, so frames processed before React re-renders the
|
|
67
|
+
// closure would otherwise still see the old (undefined) value — causing the
|
|
68
|
+
// SCAN_HOLOGRAM step to re-collect faces forever and never complete after a
|
|
69
|
+
// successful detection. Always keep this ref in lockstep with the state.
|
|
70
|
+
const currentHologramImageRef = useRef(undefined);
|
|
57
71
|
const [currentSecondaryFaceImage, setCurrentSecondaryFaceImage] = useState(undefined);
|
|
58
72
|
const [isBrightnessLow, setIsBrightnessLow] = useState(false);
|
|
59
73
|
const [isFrameBlurry, setIsFrameBlurry] = useState(false);
|
|
@@ -110,10 +124,28 @@ const IdentityDocumentCamera = ({
|
|
|
110
124
|
const isHologramDetectionInProgress = useRef(false); // Prevent concurrent hologram detection calls
|
|
111
125
|
const isCompletionCallbackInvoked = useRef(false); // Prevent multiple callback invocations when COMPLETED
|
|
112
126
|
const isStepTransitionInProgress = useRef(false); // Prevent duplicate step transitions from rapid frames
|
|
127
|
+
// The step a transition is currently in progress FROM. The latch above is
|
|
128
|
+
// reset asynchronously (via a nextStep effect), so in fast flows (e.g. demo
|
|
129
|
+
// mode auto-advancing) a later transition from a DIFFERENT step could be
|
|
130
|
+
// wrongly blocked while the latch is still set for the previous step. Gating
|
|
131
|
+
// on the originating step lets a genuine next-step transition through while
|
|
132
|
+
// still de-duping rapid repeat frames of the SAME transition.
|
|
133
|
+
const stepTransitionFrom = useRef(null);
|
|
113
134
|
const isCompletionAnnouncementSpoken = useRef(false); // Prevent duplicate "scan completed" voice guidance
|
|
114
135
|
|
|
115
136
|
const faceDetectionErrorCount = useRef(0);
|
|
116
137
|
const brightnessHistory = useRef([]);
|
|
138
|
+
// Running camera-quality stats for the support diagnostic report.
|
|
139
|
+
const diagStats = useRef({
|
|
140
|
+
frames: 0,
|
|
141
|
+
brightnessSum: 0,
|
|
142
|
+
minBrightness: 255,
|
|
143
|
+
maxBrightness: 0,
|
|
144
|
+
frameW: 0,
|
|
145
|
+
frameH: 0,
|
|
146
|
+
hadLowBrightness: false,
|
|
147
|
+
hadBlurryFrames: false
|
|
148
|
+
});
|
|
117
149
|
const [faceDetectionEnabled, setFaceDetectionEnabled] = useState(true);
|
|
118
150
|
const faceImages = useRef([]);
|
|
119
151
|
const hologramImageCountRef = useRef(0);
|
|
@@ -151,6 +183,7 @@ const IdentityDocumentCamera = ({
|
|
|
151
183
|
setIsActive(true);
|
|
152
184
|
isCompletionCallbackInvoked.current = false; // Reset callback flag when starting new scan
|
|
153
185
|
isStepTransitionInProgress.current = false;
|
|
186
|
+
stepTransitionFrom.current = null;
|
|
154
187
|
isCompletionAnnouncementSpoken.current = false;
|
|
155
188
|
} else {
|
|
156
189
|
setIsActive(false);
|
|
@@ -166,12 +199,27 @@ const IdentityDocumentCamera = ({
|
|
|
166
199
|
validMRZConsecutiveCount.current = 0;
|
|
167
200
|
mrzAggregator.current.reset();
|
|
168
201
|
setMrzReliable(false);
|
|
202
|
+
// Start a fresh diagnostics session for the support report.
|
|
203
|
+
diagnostics.reset(Date.now());
|
|
204
|
+
diagStats.current = {
|
|
205
|
+
frames: 0,
|
|
206
|
+
brightnessSum: 0,
|
|
207
|
+
minBrightness: 255,
|
|
208
|
+
maxBrightness: 0,
|
|
209
|
+
frameW: 0,
|
|
210
|
+
frameH: 0,
|
|
211
|
+
hadLowBrightness: false,
|
|
212
|
+
hadBlurryFrames: false
|
|
213
|
+
};
|
|
169
214
|
cachedBarcode.current = null;
|
|
170
215
|
isCompletionCallbackInvoked.current = false;
|
|
171
216
|
isStepTransitionInProgress.current = false;
|
|
217
|
+
stepTransitionFrom.current = null;
|
|
172
218
|
isCompletionAnnouncementSpoken.current = false;
|
|
173
219
|
// Clear all captured image states from previous scan
|
|
220
|
+
currentFaceImageRef.current = undefined;
|
|
174
221
|
setCurrentFaceImage(undefined);
|
|
222
|
+
currentHologramImageRef.current = undefined;
|
|
175
223
|
setCurrentHologramImage(undefined);
|
|
176
224
|
setCurrentSecondaryFaceImage(undefined);
|
|
177
225
|
resetLastMessage();
|
|
@@ -184,9 +232,12 @@ const IdentityDocumentCamera = ({
|
|
|
184
232
|
setLatestHologramFaceImage(undefined);
|
|
185
233
|
isCompletionCallbackInvoked.current = false; // Reset callback flag on unmount
|
|
186
234
|
isStepTransitionInProgress.current = false;
|
|
235
|
+
stepTransitionFrom.current = null;
|
|
187
236
|
isCompletionAnnouncementSpoken.current = false;
|
|
188
237
|
// Clear all captured image states on unmount
|
|
238
|
+
currentFaceImageRef.current = undefined;
|
|
189
239
|
setCurrentFaceImage(undefined);
|
|
240
|
+
currentHologramImageRef.current = undefined;
|
|
190
241
|
setCurrentHologramImage(undefined);
|
|
191
242
|
setCurrentSecondaryFaceImage(undefined);
|
|
192
243
|
resetLastMessage();
|
|
@@ -232,6 +283,7 @@ const IdentityDocumentCamera = ({
|
|
|
232
283
|
isCompletionCallbackInvoked.current = false;
|
|
233
284
|
}
|
|
234
285
|
isStepTransitionInProgress.current = false;
|
|
286
|
+
stepTransitionFrom.current = null;
|
|
235
287
|
}, [nextStep]);
|
|
236
288
|
|
|
237
289
|
// Update status bar based on guide visibility
|
|
@@ -365,6 +417,7 @@ const IdentityDocumentCamera = ({
|
|
|
365
417
|
setHologramImageCount(0);
|
|
366
418
|
setLatestHologramFaceImage(undefined);
|
|
367
419
|
// Clear previous hologram state to prevent premature completion
|
|
420
|
+
currentHologramImageRef.current = undefined;
|
|
368
421
|
setCurrentHologramImage(undefined);
|
|
369
422
|
setCurrentHologramMaskImage(undefined);
|
|
370
423
|
}
|
|
@@ -408,10 +461,14 @@ const IdentityDocumentCamera = ({
|
|
|
408
461
|
}
|
|
409
462
|
}, [setIsTorchOn]);
|
|
410
463
|
const transitionStepWithCallback = useCallback((nextStepType, fromStep, scannedData) => {
|
|
411
|
-
|
|
464
|
+
// De-dupe only rapid repeat frames of the SAME originating step. A
|
|
465
|
+
// transition from a different step is a genuine advance (the async latch
|
|
466
|
+
// reset may not have run yet in fast flows like demo mode), so allow it.
|
|
467
|
+
if (isStepTransitionInProgress.current && stepTransitionFrom.current === fromStep) {
|
|
412
468
|
return;
|
|
413
469
|
}
|
|
414
470
|
isStepTransitionInProgress.current = true;
|
|
471
|
+
stepTransitionFrom.current = fromStep;
|
|
415
472
|
|
|
416
473
|
// Torch only needed during SCAN_HOLOGRAM - turn off for all other transitions
|
|
417
474
|
if (nextStepType !== 'SCAN_HOLOGRAM') {
|
|
@@ -567,9 +624,30 @@ const IdentityDocumentCamera = ({
|
|
|
567
624
|
// of letting one noisy frame reset progress.
|
|
568
625
|
const mrzBandHeight = scannedText?.blocks?.length ? Math.max(0, ...scannedText.blocks.map(b => b.blockFrame?.height ?? 0)) : 0;
|
|
569
626
|
const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
|
|
627
|
+
// ML Kit per-symbol confidence over MRZ characters (Android bundled library
|
|
628
|
+
// only; undefined on iOS). When present it down-weights frames the
|
|
629
|
+
// recognizer was unsure about, so a confident frame outvotes a shaky one of
|
|
630
|
+
// the same band height on look-alike ties (A↔4, S↔5, B↔8).
|
|
631
|
+
const frameConfidence = scannedText?.textConfidence?.mean;
|
|
570
632
|
const consensus = mrzAggregator.current.addFrame({
|
|
571
633
|
text: textForValidation,
|
|
572
|
-
weight: frameWeight
|
|
634
|
+
weight: frameWeight,
|
|
635
|
+
confidence: frameConfidence
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Record MRZ consensus metrics for the support diagnostic report (counts
|
|
639
|
+
// and pass/fail only — never the MRZ values themselves). `reachedStable`
|
|
640
|
+
// is sticky in the collector, so a later flicker can't unset it.
|
|
641
|
+
// `stabilityFrames`/`minContestedMargin` and the MRZ band pixel height are
|
|
642
|
+
// device-variance signals (convergence speed, look-alike resolution
|
|
643
|
+
// confidence, and whether the band cleared the ~16px/char OCR floor).
|
|
644
|
+
diagnostics.setMrz({
|
|
645
|
+
framesProcessed: consensus.frames,
|
|
646
|
+
reachedStable: consensus.stable,
|
|
647
|
+
format: consensus.validation?.format ?? undefined,
|
|
648
|
+
stabilityFrames: consensus.stableStreak,
|
|
649
|
+
minContestedMargin: Number.isFinite(consensus.minContestedMargin) ? consensus.minContestedMargin : 0,
|
|
650
|
+
maxMrzBandHeightPx: mrzBandHeight
|
|
573
651
|
});
|
|
574
652
|
|
|
575
653
|
// Use the fused consensus when it is valid; otherwise fall back to a
|
|
@@ -593,6 +671,9 @@ const IdentityDocumentCamera = ({
|
|
|
593
671
|
// be wrong (ID_FRONT locked before passport MRZ became readable)
|
|
594
672
|
const documentType = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? detectDocumentType(primaryFaces, text, parsedMRZData?.fields, frameWidth, mrzText) : parsedMRZData?.fields?.documentCode === 'P' ? 'PASSPORT' : detectedDocumentType;
|
|
595
673
|
|
|
674
|
+
// Record the detected document type for the diagnostic report.
|
|
675
|
+
if (documentType) diagnostics.setDocument(documentType);
|
|
676
|
+
|
|
596
677
|
// Crop faces once document type is confirmed or we're past the initial step
|
|
597
678
|
const shouldCropFaces = documentType === 'ID_FRONT' || documentType === 'PASSPORT' || nextStep !== 'SCAN_ID_FRONT_OR_PASSPORT';
|
|
598
679
|
const croppedFaces = shouldCropFaces ? await getFaceImages(primaryFaces, image ?? '', frameWidth, frameHeight) : [];
|
|
@@ -646,6 +727,7 @@ const IdentityDocumentCamera = ({
|
|
|
646
727
|
// Continue scanning without locking this face
|
|
647
728
|
} else {
|
|
648
729
|
faceImageToUse = croppedFaces[0];
|
|
730
|
+
currentFaceImageRef.current = croppedFaces[0];
|
|
649
731
|
setCurrentFaceImage(croppedFaces[0]);
|
|
650
732
|
}
|
|
651
733
|
}
|
|
@@ -860,8 +942,8 @@ const IdentityDocumentCamera = ({
|
|
|
860
942
|
// Skip face position validation for hologram — flash toggling causes position jitter
|
|
861
943
|
if (primaryFaceOnly) {
|
|
862
944
|
hologramFramesWithoutFace.current = 0;
|
|
863
|
-
if (
|
|
864
|
-
scannedData.hologramImage =
|
|
945
|
+
if (currentHologramImageRef.current) {
|
|
946
|
+
scannedData.hologramImage = currentHologramImageRef.current;
|
|
865
947
|
} else if (faceImages.current.length < HOLOGRAM_IMAGE_COUNT) {
|
|
866
948
|
// Space out captures for better variation
|
|
867
949
|
const timeSinceLastCapture = Date.now() - lastHologramCaptureTime.current;
|
|
@@ -921,6 +1003,7 @@ const IdentityDocumentCamera = ({
|
|
|
921
1003
|
if (hologram) {
|
|
922
1004
|
setCurrentHologramMaskImage(hologramMask);
|
|
923
1005
|
scannedData.hologramImage = hologram;
|
|
1006
|
+
currentHologramImageRef.current = hologram;
|
|
924
1007
|
setCurrentHologramImage(hologram);
|
|
925
1008
|
if (isDebugEnabled()) {
|
|
926
1009
|
debugLog('IdentityDocumentCamera', '[Hologram] ✓ Saved hologram image');
|
|
@@ -946,8 +1029,8 @@ const IdentityDocumentCamera = ({
|
|
|
946
1029
|
} else {
|
|
947
1030
|
hologramFramesWithoutFace.current++;
|
|
948
1031
|
}
|
|
949
|
-
} else if (
|
|
950
|
-
scannedData.hologramImage =
|
|
1032
|
+
} else if (currentHologramImageRef.current) {
|
|
1033
|
+
scannedData.hologramImage = currentHologramImageRef.current;
|
|
951
1034
|
}
|
|
952
1035
|
|
|
953
1036
|
// Secondary face capture (continuous during initial scan and hologram detection)
|
|
@@ -1081,10 +1164,20 @@ const IdentityDocumentCamera = ({
|
|
|
1081
1164
|
return;
|
|
1082
1165
|
}
|
|
1083
1166
|
|
|
1084
|
-
// Complete when hologram captured OR retries exhausted (not mid-collection)
|
|
1085
|
-
|
|
1167
|
+
// Complete when hologram captured OR retries exhausted (not mid-collection).
|
|
1168
|
+
// Fall back to the previously locked face (ref) when the live frame's face
|
|
1169
|
+
// was rejected — the torch is on during hologram capture, so faceImageToUse
|
|
1170
|
+
// is frequently null even though a valid face was locked during the front
|
|
1171
|
+
// scan. Without this the gate's face requirement could never be satisfied.
|
|
1172
|
+
const faceForCompletion = faceImageToUse ?? currentFaceImageRef.current;
|
|
1173
|
+
const stepComplete = (!!scannedData.hologramImage || maxRetriesReached && !isCollecting) && !!faceForCompletion; // Require face before completing
|
|
1086
1174
|
|
|
1087
1175
|
if (stepComplete) {
|
|
1176
|
+
// Make sure the locked face rides along even if this frame's face was
|
|
1177
|
+
// rejected.
|
|
1178
|
+
if (!scannedData.faceImage && faceForCompletion) {
|
|
1179
|
+
scannedData.faceImage = faceForCompletion;
|
|
1180
|
+
}
|
|
1088
1181
|
// Ensure preserved MRZ data is included (current frame may not have
|
|
1089
1182
|
// readable MRZ due to flash/tilting during hologram capture)
|
|
1090
1183
|
if (!scannedData.mrzText && lastValidMRZText.current) {
|
|
@@ -1196,6 +1289,15 @@ const IdentityDocumentCamera = ({
|
|
|
1196
1289
|
brightnessHistory.current.shift();
|
|
1197
1290
|
}
|
|
1198
1291
|
const avgBrightness = brightnessHistory.current.reduce((a, b) => a + b, 0) / brightnessHistory.current.length;
|
|
1292
|
+
|
|
1293
|
+
// Accumulate camera diagnostics (frame count, brightness range, dims) for
|
|
1294
|
+
// the support diagnostic report. Cheap, side-effect-only.
|
|
1295
|
+
diagStats.current.frames += 1;
|
|
1296
|
+
diagStats.current.brightnessSum += frameBrightness;
|
|
1297
|
+
diagStats.current.minBrightness = Math.min(diagStats.current.minBrightness, frameBrightness);
|
|
1298
|
+
diagStats.current.maxBrightness = Math.max(diagStats.current.maxBrightness, frameBrightness);
|
|
1299
|
+
diagStats.current.frameW = frame.width ?? diagStats.current.frameW;
|
|
1300
|
+
diagStats.current.frameH = frame.height ?? diagStats.current.frameH;
|
|
1199
1301
|
const isOverallBright = avgBrightness >= MIN_BRIGHTNESS_THRESHOLD;
|
|
1200
1302
|
|
|
1201
1303
|
// Check brightness in center region (area of interest)
|
|
@@ -1206,6 +1308,7 @@ const IdentityDocumentCamera = ({
|
|
|
1206
1308
|
isRegionBright = isOverallBright;
|
|
1207
1309
|
}
|
|
1208
1310
|
setIsBrightnessLow(!isRegionBright);
|
|
1311
|
+
if (!isRegionBright) diagStats.current.hadLowBrightness = true;
|
|
1209
1312
|
|
|
1210
1313
|
// Check blur only in center region (area of interest) to avoid false positives
|
|
1211
1314
|
// from iOS depth-of-field background blur
|
|
@@ -1226,10 +1329,30 @@ const IdentityDocumentCamera = ({
|
|
|
1226
1329
|
);
|
|
1227
1330
|
isNotBlurry = !isBlurry;
|
|
1228
1331
|
setIsFrameBlurry(isBlurry);
|
|
1332
|
+
if (isBlurry) diagStats.current.hadBlurryFrames = true;
|
|
1229
1333
|
} catch (error) {
|
|
1230
1334
|
setIsFrameBlurry(false);
|
|
1231
1335
|
}
|
|
1232
1336
|
|
|
1337
|
+
// Flush accumulated camera stats into the diagnostics collector.
|
|
1338
|
+
const ds = diagStats.current;
|
|
1339
|
+
diagnostics.setCamera({
|
|
1340
|
+
frames: ds.frames,
|
|
1341
|
+
frameW: ds.frameW,
|
|
1342
|
+
frameH: ds.frameH,
|
|
1343
|
+
avgBrightness: ds.frames > 0 ? Math.round(ds.brightnessSum / ds.frames) : 0,
|
|
1344
|
+
minBrightness: ds.minBrightness === 255 && ds.frames === 0 ? 0 : ds.minBrightness,
|
|
1345
|
+
maxBrightness: ds.maxBrightness,
|
|
1346
|
+
hadLowBrightness: ds.hadLowBrightness,
|
|
1347
|
+
hadBlurryFrames: ds.hadBlurryFrames,
|
|
1348
|
+
// The actual gates the measured values are judged against — lets support
|
|
1349
|
+
// read "measured 45 vs floor 45" instead of just a pass/fail flag.
|
|
1350
|
+
thresholds: {
|
|
1351
|
+
minBrightness: MIN_BRIGHTNESS_THRESHOLD,
|
|
1352
|
+
blur: 50
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1233
1356
|
// Only proceed if image quality is acceptable
|
|
1234
1357
|
const hasAcceptableQuality = isRegionBright && isNotBlurry;
|
|
1235
1358
|
|
|
@@ -1274,6 +1397,7 @@ const IdentityDocumentCamera = ({
|
|
|
1274
1397
|
resultText,
|
|
1275
1398
|
mrzOnlyText: undefined,
|
|
1276
1399
|
// Will be set below if MRZ blocks detected
|
|
1400
|
+
textConfidence: frame.textConfidence,
|
|
1277
1401
|
blocks: textBlocks.map(block => ({
|
|
1278
1402
|
blockText: block.text || '',
|
|
1279
1403
|
blockFrame: block.blockFrame ?? {
|
|
@@ -5,7 +5,6 @@ import AppContext from "../Contexts/AppContext.js";
|
|
|
5
5
|
import { CommonActions, useNavigation, usePreventRemove } from '@react-navigation/native';
|
|
6
6
|
import { View, StyleSheet, Alert } from 'react-native';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
|
-
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
9
8
|
import i18n from "../../Translation/index.js";
|
|
10
9
|
import StyledButton from "./StyledButton.js";
|
|
11
10
|
import { analyticsService } from "../Services/AnalyticsService.js";
|
|
@@ -26,7 +25,6 @@ const NavigationManager = /*#__PURE__*/forwardRef(({
|
|
|
26
25
|
const {
|
|
27
26
|
t
|
|
28
27
|
} = useTranslation();
|
|
29
|
-
const insets = useSafeAreaInsets();
|
|
30
28
|
const routes = {
|
|
31
29
|
VERIFICATION_SESSION_CHECK: 'VerificationSessionCheckScreen',
|
|
32
30
|
DYNAMIC_ROUTES: {
|
|
@@ -185,9 +183,7 @@ const NavigationManager = /*#__PURE__*/forwardRef(({
|
|
|
185
183
|
allowSkipStep: appContext.currentWorkflowStep?.required
|
|
186
184
|
}));
|
|
187
185
|
return appContext.currentWorkflowStep && (!appContext.currentWorkflowStep?.required || canSkipStep) && /*#__PURE__*/_jsx(View, {
|
|
188
|
-
style:
|
|
189
|
-
paddingBottom: insets.bottom
|
|
190
|
-
}],
|
|
186
|
+
style: styles.container,
|
|
191
187
|
children: /*#__PURE__*/_jsx(StyledButton, {
|
|
192
188
|
mode: "text",
|
|
193
189
|
onPress: goToNextRouteWithAlert,
|
|
@@ -196,11 +192,13 @@ const NavigationManager = /*#__PURE__*/forwardRef(({
|
|
|
196
192
|
});
|
|
197
193
|
});
|
|
198
194
|
const styles = StyleSheet.create({
|
|
195
|
+
// Hug the text button: centered, minimal padding, no full-width opaque bar so
|
|
196
|
+
// it occupies the least area and never overlays sibling content. The screen's
|
|
197
|
+
// footer wrapper already applies the bottom safe-area inset.
|
|
199
198
|
container: {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
backgroundColor: 'white'
|
|
199
|
+
alignSelf: 'center',
|
|
200
|
+
paddingHorizontal: 16,
|
|
201
|
+
paddingVertical: 8
|
|
204
202
|
}
|
|
205
203
|
});
|
|
206
204
|
export default NavigationManager;
|