@trustchex/react-native-sdk 1.472.0 → 1.475.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/build.gradle +3 -3
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
- package/ios/Camera/TrustchexCameraView.swift +92 -19
- package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
- package/lib/module/Screens/Static/ResultScreen.js +5 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/lib/module/Shared/Libs/mrz.utils.js +639 -16
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
- package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +15 -10
- package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
- package/src/Screens/Static/ResultScreen.tsx +5 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/src/Shared/Libs/mrz.utils.ts +704 -11
- package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
- package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trustchex/react-native-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.475.1",
|
|
4
4
|
"description": "Trustchex mobile app react native SDK for android or ios devices",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
|
@@ -60,7 +60,17 @@
|
|
|
60
60
|
"@react-navigation/core": "7.12.4",
|
|
61
61
|
"@react-navigation/native": "7.1.18",
|
|
62
62
|
"@react-navigation/native-stack": "7.3.27",
|
|
63
|
-
"@react-navigation/elements": "2.6.5"
|
|
63
|
+
"@react-navigation/elements": "2.6.5",
|
|
64
|
+
"lodash": "4.17.23",
|
|
65
|
+
"brace-expansion": "1.1.13",
|
|
66
|
+
"minimatch": "3.1.4",
|
|
67
|
+
"qs": "6.15.2",
|
|
68
|
+
"ip-address": "10.1.1",
|
|
69
|
+
"uuid": "11.1.1",
|
|
70
|
+
"yaml": "2.8.3",
|
|
71
|
+
"fast-uri": "3.1.2",
|
|
72
|
+
"flatted": "3.4.2",
|
|
73
|
+
"react-native-screens": "4.18.0"
|
|
64
74
|
},
|
|
65
75
|
"scripts": {
|
|
66
76
|
"example": "yarn workspace @trustchex/react-native-sdk-example",
|
|
@@ -116,7 +126,6 @@
|
|
|
116
126
|
"react-native-svg": "^15.12.0",
|
|
117
127
|
"react-native-tts": "^4.1.1",
|
|
118
128
|
"react-native-video": "^6.16.1",
|
|
119
|
-
"react-native-vision-camera": "^4.7.1",
|
|
120
129
|
"react-native-webrtc": "^124.0.0",
|
|
121
130
|
"react-native-webview": "^13.15.0",
|
|
122
131
|
"release-it": "^17.10.0",
|
|
@@ -129,6 +138,7 @@
|
|
|
129
138
|
"buffer": "^6.0.3",
|
|
130
139
|
"crypto-js": "^4.2.0",
|
|
131
140
|
"elliptic": "^6.6.1",
|
|
141
|
+
"hash.js": "^1.1.7",
|
|
132
142
|
"i18next": "^25.3.2",
|
|
133
143
|
"jpeg2000": "^1.1.1",
|
|
134
144
|
"mrz": "^5.0.1",
|
|
@@ -153,10 +163,8 @@
|
|
|
153
163
|
"react-native-svg": ">=15.12.0",
|
|
154
164
|
"react-native-tts": ">=4.1.1",
|
|
155
165
|
"react-native-video": ">=6.16.1",
|
|
156
|
-
"react-native-vision-camera": ">=4.7.1",
|
|
157
166
|
"react-native-webrtc": ">=124.0.0",
|
|
158
|
-
"react-native-webview": ">=13.15.0"
|
|
159
|
-
"react-native-worklets-core": ">=1.6.2"
|
|
167
|
+
"react-native-webview": ">=13.15.0"
|
|
160
168
|
},
|
|
161
169
|
"peerDependenciesMeta": {
|
|
162
170
|
"@react-navigation/native": {
|
|
@@ -210,9 +218,6 @@
|
|
|
210
218
|
"react-native-video": {
|
|
211
219
|
"optional": false
|
|
212
220
|
},
|
|
213
|
-
"react-native-vision-camera": {
|
|
214
|
-
"optional": false
|
|
215
|
-
},
|
|
216
221
|
"react-native-webrtc": {
|
|
217
222
|
"optional": false
|
|
218
223
|
},
|
|
@@ -231,7 +236,7 @@
|
|
|
231
236
|
"<rootDir>/lib/"
|
|
232
237
|
],
|
|
233
238
|
"transformIgnorePatterns": [
|
|
234
|
-
"node_modules/(?!(uuid|react-native|@react-native|react-native-device-info|react-native-get-random-values|react-native-fs)/)"
|
|
239
|
+
"node_modules/(?!(uuid|mrz|react-native|@react-native|react-native-device-info|react-native-get-random-values|react-native-fs)/)"
|
|
235
240
|
]
|
|
236
241
|
},
|
|
237
242
|
"commitlint": {
|
|
@@ -7,116 +7,108 @@ import {
|
|
|
7
7
|
StatusBar,
|
|
8
8
|
TouchableOpacity,
|
|
9
9
|
type NativeSyntheticEvent,
|
|
10
|
-
type LayoutChangeEvent,
|
|
11
10
|
} from 'react-native';
|
|
12
11
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
13
12
|
import {
|
|
14
13
|
TrustchexCamera,
|
|
15
14
|
type TrustchexCameraHandle,
|
|
16
15
|
type Frame,
|
|
17
|
-
type NativeTextBlock,
|
|
18
16
|
} from '../../Shared/Components/TrustchexCamera';
|
|
19
|
-
import
|
|
17
|
+
import { MRZFrameAggregator } from '../../Shared/Libs/mrzFrameAggregator';
|
|
20
18
|
import { useKeepAwake } from '../../Shared/Libs/native-keep-awake.utils';
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// All per-frame display data lives in ONE state object so a processed frame
|
|
21
|
+
// triggers a SINGLE re-render instead of 7 separate setState calls.
|
|
22
|
+
interface MrzView {
|
|
23
|
+
raw: string;
|
|
24
|
+
voted: string;
|
|
25
|
+
valid: boolean;
|
|
26
|
+
frames: number;
|
|
27
|
+
stable: boolean;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
const EMPTY_VIEW: MrzView = {
|
|
31
|
+
raw: '',
|
|
32
|
+
voted: 'Voting…',
|
|
33
|
+
valid: false,
|
|
34
|
+
frames: 0,
|
|
35
|
+
stable: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Process the live OCR stream at this cadence. The camera delivers frames faster
|
|
39
|
+
// than the MRZ validation/voting can run; gating here keeps the JS thread free to
|
|
40
|
+
// service touches (so the buttons stay responsive) while still converging fast.
|
|
41
|
+
const MRZ_PROCESS_INTERVAL_MS = 400;
|
|
42
|
+
|
|
26
43
|
const MRZTestScreen = () => {
|
|
27
44
|
useKeepAwake();
|
|
28
45
|
const cameraRef = useRef<TrustchexCameraHandle>(null);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const [
|
|
32
|
-
const [rawHistory, setRawHistory] = useState<string[]>([]);
|
|
46
|
+
|
|
47
|
+
// Single consolidated view-state; one setState per processed frame.
|
|
48
|
+
const [view, setView] = useState<MrzView>(EMPTY_VIEW);
|
|
33
49
|
const [isPaused, setIsPaused] = useState<boolean>(false);
|
|
34
|
-
const [overlayBlocks, setOverlayBlocks] = useState<MrzOverlayBlock[]>([]);
|
|
35
|
-
const [cameraLayout, setCameraLayout] = useState({ width: 1, height: 1 });
|
|
36
|
-
const [frameSize, setFrameSize] = useState({ width: 1080, height: 1920 });
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
// Refs for values the frame handler reads but that must NOT re-create it:
|
|
52
|
+
// keeping handleFrame identity-stable avoids re-subscribing the camera and
|
|
53
|
+
// keeps it cheap.
|
|
54
|
+
const pausedRef = useRef(false);
|
|
55
|
+
const lastProcessedAt = useRef(0);
|
|
56
|
+
const aggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
|
|
42
57
|
|
|
43
58
|
const handleFrame = useCallback(
|
|
44
59
|
(event: NativeSyntheticEvent<{ frame: Frame }>) => {
|
|
45
|
-
if (
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const frame = event.nativeEvent.frame;
|
|
50
|
-
|
|
51
|
-
if (frame.width && frame.height) {
|
|
52
|
-
setFrameSize({ width: frame.width, height: frame.height });
|
|
53
|
-
}
|
|
60
|
+
if (pausedRef.current) return;
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const blockText = block.text || '';
|
|
60
|
-
return {
|
|
61
|
-
...block,
|
|
62
|
-
isMrz:
|
|
63
|
-
blockText.includes('<') &&
|
|
64
|
-
blockText.length >= 12 &&
|
|
65
|
-
(block.blockFrame ? block.blockFrame.y > bottomThreshold : false),
|
|
66
|
-
};
|
|
67
|
-
});
|
|
68
|
-
setOverlayBlocks(blocks);
|
|
69
|
-
} else {
|
|
70
|
-
setOverlayBlocks([]);
|
|
71
|
-
}
|
|
62
|
+
// Throttle the ENTIRE handler. Drop frames that arrive inside the window
|
|
63
|
+
// before doing ANY work (no parsing, no setState) so touch events land.
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
if (now - lastProcessedAt.current < MRZ_PROCESS_INTERVAL_MS) return;
|
|
72
66
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
const next = [frame.resultText, ...prev];
|
|
79
|
-
return next.slice(0, 20);
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (frame.resultText && frame.resultText.includes('<')) {
|
|
84
|
-
// Extract MRZ-only text from detected blocks
|
|
85
|
-
let mrzOnlyText = '';
|
|
86
|
-
if (frame.textBlocks && frame.textBlocks.length > 0) {
|
|
87
|
-
// Filter blocks that look like MRZ (contain < and are in bottom half of frame)
|
|
88
|
-
const frameHeight = frame.height;
|
|
89
|
-
const bottomThreshold = frameHeight * 0.5;
|
|
90
|
-
|
|
91
|
-
const mrzBlocks = frame.textBlocks.filter((block) => {
|
|
92
|
-
const blockText = block.text || '';
|
|
93
|
-
const hasFillers = blockText.includes('<');
|
|
94
|
-
const isInBottomArea = block.blockFrame
|
|
95
|
-
? block.blockFrame.y > bottomThreshold
|
|
96
|
-
: false;
|
|
97
|
-
const isLongEnough = blockText.length >= 12;
|
|
98
|
-
return hasFillers && isInBottomArea && isLongEnough;
|
|
99
|
-
});
|
|
67
|
+
const frame = event.nativeEvent.frame;
|
|
68
|
+
const resultText = frame.resultText;
|
|
69
|
+
if (!resultText || !resultText.includes('<')) return;
|
|
70
|
+
lastProcessedAt.current = now;
|
|
100
71
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
72
|
+
// Collect every MRZ-looking block (contains a filler) regardless of
|
|
73
|
+
// position/length — the band is often fragmented and may not sit in the
|
|
74
|
+
// bottom half. Reconstruction downstream isolates it from front-of-card text.
|
|
75
|
+
const mrzBlocks = (frame.textBlocks ?? []).filter((b) =>
|
|
76
|
+
(b.text || '').includes('<')
|
|
77
|
+
);
|
|
78
|
+
const mrzOnlyText = mrzBlocks.length
|
|
79
|
+
? mrzBlocks.map((b) => b.text).join('\n')
|
|
80
|
+
: resultText;
|
|
105
81
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
82
|
+
const bandHeight = mrzBlocks.length
|
|
83
|
+
? Math.max(0, ...mrzBlocks.map((b) => b.blockFrame?.height ?? 0))
|
|
84
|
+
: 0;
|
|
85
|
+
const consensus = aggregator.current.addFrame({
|
|
86
|
+
text: `${mrzOnlyText}\n${resultText}`,
|
|
87
|
+
weight: bandHeight > 0 ? bandHeight : 1,
|
|
88
|
+
});
|
|
110
89
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
90
|
+
// One setState → one re-render.
|
|
91
|
+
setView({
|
|
92
|
+
raw: mrzOnlyText,
|
|
93
|
+
voted: consensus.mrz ?? `Voting… (${consensus.frames} frames)`,
|
|
94
|
+
valid: !!consensus.validation?.valid,
|
|
95
|
+
frames: consensus.frames,
|
|
96
|
+
stable: consensus.stable,
|
|
97
|
+
});
|
|
116
98
|
},
|
|
117
|
-
[
|
|
99
|
+
[]
|
|
118
100
|
);
|
|
119
101
|
|
|
102
|
+
const togglePause = useCallback(() => {
|
|
103
|
+
pausedRef.current = !pausedRef.current;
|
|
104
|
+
setIsPaused(pausedRef.current);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const resetVoting = useCallback(() => {
|
|
108
|
+
aggregator.current.reset();
|
|
109
|
+
setView(EMPTY_VIEW);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
120
112
|
return (
|
|
121
113
|
<View style={styles.container}>
|
|
122
114
|
<StatusBar
|
|
@@ -124,7 +116,7 @@ const MRZTestScreen = () => {
|
|
|
124
116
|
backgroundColor="transparent"
|
|
125
117
|
translucent
|
|
126
118
|
/>
|
|
127
|
-
<View style={styles.cameraWrapper}
|
|
119
|
+
<View style={styles.cameraWrapper}>
|
|
128
120
|
<TrustchexCamera
|
|
129
121
|
ref={cameraRef}
|
|
130
122
|
style={StyleSheet.absoluteFill}
|
|
@@ -134,78 +126,50 @@ const MRZTestScreen = () => {
|
|
|
134
126
|
enableTextRecognition={true}
|
|
135
127
|
enableBarcodeScanning={false}
|
|
136
128
|
includeBase64={false}
|
|
137
|
-
targetFps={
|
|
129
|
+
targetFps={5}
|
|
138
130
|
onFrameAvailable={handleFrame}
|
|
139
131
|
/>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<View
|
|
147
|
-
key={i}
|
|
148
|
-
style={[
|
|
149
|
-
styles.boundingBox,
|
|
150
|
-
block.isMrz ? styles.boundingBoxMrz : styles.boundingBoxText,
|
|
151
|
-
{
|
|
152
|
-
left: bf.x * scaleX,
|
|
153
|
-
top: bf.y * scaleY,
|
|
154
|
-
width: bf.width * scaleX,
|
|
155
|
-
height: bf.height * scaleY,
|
|
156
|
-
},
|
|
157
|
-
]}
|
|
158
|
-
>
|
|
159
|
-
{block.isMrz && (
|
|
160
|
-
<View style={styles.blockLabel}>
|
|
161
|
-
<Text style={styles.blockLabelText}>MRZ</Text>
|
|
162
|
-
</View>
|
|
163
|
-
)}
|
|
164
|
-
</View>
|
|
165
|
-
);
|
|
166
|
-
})}
|
|
132
|
+
|
|
133
|
+
{/* Scan-frame guide: matches the native OCR crop region. Only the data
|
|
134
|
+
INSIDE this box is OCR'd — align the card's MRZ band here. */}
|
|
135
|
+
<View pointerEvents="none" style={styles.scanGuide}>
|
|
136
|
+
<Text style={styles.scanGuideLabel}>Align MRZ inside this frame</Text>
|
|
137
|
+
</View>
|
|
167
138
|
</View>
|
|
168
139
|
|
|
169
140
|
<SafeAreaView style={styles.mrzPanel} edges={['bottom']}>
|
|
170
141
|
<ScrollView style={styles.scrollView}>
|
|
171
142
|
<View style={styles.panelContent}>
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
143
|
+
<View style={styles.buttonRow}>
|
|
144
|
+
<TouchableOpacity
|
|
145
|
+
style={styles.pauseButton}
|
|
146
|
+
onPress={togglePause}
|
|
147
|
+
>
|
|
148
|
+
<Text style={styles.pauseButtonText}>
|
|
149
|
+
{isPaused ? 'Resume Processing' : 'Pause Processing'}
|
|
150
|
+
</Text>
|
|
151
|
+
</TouchableOpacity>
|
|
152
|
+
<TouchableOpacity
|
|
153
|
+
style={styles.pauseButton}
|
|
154
|
+
onPress={resetVoting}
|
|
155
|
+
>
|
|
156
|
+
<Text style={styles.pauseButtonText}>Reset Voting</Text>
|
|
157
|
+
</TouchableOpacity>
|
|
158
|
+
</View>
|
|
180
159
|
<Text style={styles.title}>MRZ Text Read</Text>
|
|
181
|
-
|
|
182
|
-
<Text style={styles.
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.map(
|
|
187
|
-
(line, i) =>
|
|
188
|
-
`Line ${i + 1}: ${line} (${line.length} chars)`
|
|
189
|
-
)
|
|
190
|
-
.join('\n')
|
|
191
|
-
: 'No MRZ raw text'}
|
|
160
|
+
|
|
161
|
+
<Text style={styles.sectionTitle}>
|
|
162
|
+
★ Multi-frame Voted MRZ — {view.frames} frames
|
|
163
|
+
{view.valid ? ' · VALID' : ''}
|
|
164
|
+
{view.stable ? ' · STABLE' : ''}
|
|
192
165
|
</Text>
|
|
193
|
-
<Text style={styles.
|
|
194
|
-
|
|
195
|
-
{mrzText
|
|
196
|
-
.split('\n')
|
|
197
|
-
.map(
|
|
198
|
-
(line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`
|
|
199
|
-
)
|
|
200
|
-
.join('\n')}
|
|
166
|
+
<Text style={[styles.mrzText, view.valid ? styles.mrzValid : null]}>
|
|
167
|
+
{formatLines(view.voted)}
|
|
201
168
|
</Text>
|
|
202
|
-
|
|
169
|
+
|
|
170
|
+
<Text style={styles.sectionTitle}>MRZ Raw Text (this frame)</Text>
|
|
203
171
|
<Text style={styles.mrzText}>
|
|
204
|
-
{
|
|
205
|
-
? rawHistory
|
|
206
|
-
.map((text, i) => `#${i + 1}: ${text.replace(/\n/g, ' ')}`)
|
|
207
|
-
.join('\n')
|
|
208
|
-
: 'No history'}
|
|
172
|
+
{view.raw ? formatLines(view.raw) : 'No MRZ raw text'}
|
|
209
173
|
</Text>
|
|
210
174
|
</View>
|
|
211
175
|
</ScrollView>
|
|
@@ -214,6 +178,13 @@ const MRZTestScreen = () => {
|
|
|
214
178
|
);
|
|
215
179
|
};
|
|
216
180
|
|
|
181
|
+
/** Render MRZ text as "Line N: <chars> (N chars)" rows. */
|
|
182
|
+
const formatLines = (text: string): string =>
|
|
183
|
+
text
|
|
184
|
+
.split('\n')
|
|
185
|
+
.map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`)
|
|
186
|
+
.join('\n');
|
|
187
|
+
|
|
217
188
|
const styles = StyleSheet.create({
|
|
218
189
|
container: {
|
|
219
190
|
flex: 1,
|
|
@@ -223,31 +194,26 @@ const styles = StyleSheet.create({
|
|
|
223
194
|
flex: 2,
|
|
224
195
|
position: 'relative',
|
|
225
196
|
},
|
|
226
|
-
|
|
197
|
+
scanGuide: {
|
|
227
198
|
position: 'absolute',
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
boundingBoxMrz: {
|
|
234
|
-
borderColor: '#FFA500',
|
|
199
|
+
// Mirrors the native OCR crop: top 25% / bottom 12% / 4% side margins.
|
|
200
|
+
top: '25%',
|
|
201
|
+
bottom: '12%',
|
|
202
|
+
left: '4%',
|
|
203
|
+
right: '4%',
|
|
235
204
|
borderWidth: 2,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
top: -14,
|
|
241
|
-
left: 0,
|
|
242
|
-
backgroundColor: '#FFA500',
|
|
243
|
-
paddingHorizontal: 4,
|
|
244
|
-
paddingVertical: 1,
|
|
245
|
-
borderRadius: 2,
|
|
205
|
+
borderColor: '#00E5FF',
|
|
206
|
+
borderRadius: 6,
|
|
207
|
+
justifyContent: 'flex-start',
|
|
208
|
+
alignItems: 'center',
|
|
246
209
|
},
|
|
247
|
-
|
|
248
|
-
color: '#
|
|
249
|
-
fontSize:
|
|
210
|
+
scanGuideLabel: {
|
|
211
|
+
color: '#00E5FF',
|
|
212
|
+
fontSize: 10,
|
|
250
213
|
fontWeight: 'bold',
|
|
214
|
+
marginTop: -16,
|
|
215
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
216
|
+
paddingHorizontal: 4,
|
|
251
217
|
},
|
|
252
218
|
mrzPanel: {
|
|
253
219
|
flex: 1,
|
|
@@ -261,6 +227,11 @@ const styles = StyleSheet.create({
|
|
|
261
227
|
panelContent: {
|
|
262
228
|
padding: 10,
|
|
263
229
|
},
|
|
230
|
+
buttonRow: {
|
|
231
|
+
flexDirection: 'row',
|
|
232
|
+
justifyContent: 'center',
|
|
233
|
+
gap: 8,
|
|
234
|
+
},
|
|
264
235
|
pauseButton: {
|
|
265
236
|
alignSelf: 'center',
|
|
266
237
|
borderWidth: 1,
|
|
@@ -100,10 +100,15 @@ const ResultScreen = () => {
|
|
|
100
100
|
|
|
101
101
|
const formatGender = useCallback(
|
|
102
102
|
(code?: string | null) => {
|
|
103
|
+
// The eID/NFC path stores sex as "M"/"F" (ICAO getStrCode), while the
|
|
104
|
+
// camera MRZ path (the `mrz` npm package) stores "male"/"female". Accept
|
|
105
|
+
// both so the ID scan shows the gender instead of "unspecified".
|
|
103
106
|
switch (code?.toUpperCase()) {
|
|
104
107
|
case 'M':
|
|
108
|
+
case 'MALE':
|
|
105
109
|
return t('eidScannerScreen.genderMale');
|
|
106
110
|
case 'F':
|
|
111
|
+
case 'FEMALE':
|
|
107
112
|
return t('eidScannerScreen.genderFemale');
|
|
108
113
|
default:
|
|
109
114
|
return t('eidScannerScreen.genderUnspecified');
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { NativeModules } from 'react-native';
|
|
24
24
|
import PermissionManager from '../Libs/permissions.utils';
|
|
25
25
|
import mrzUtils from '../Libs/mrz.utils';
|
|
26
|
+
import { MRZFrameAggregator } from '../Libs/mrzFrameAggregator';
|
|
26
27
|
import { useKeepAwake } from '../Libs/native-keep-awake.utils';
|
|
27
28
|
import { useIsFocused } from '@react-navigation/native';
|
|
28
29
|
import { useTranslation } from 'react-i18next';
|
|
@@ -144,6 +145,10 @@ const IdentityDocumentCamera = ({
|
|
|
144
145
|
const lastValidMRZText = useRef<string | null>(null);
|
|
145
146
|
const lastValidMRZFields = useRef<any>(null);
|
|
146
147
|
const validMRZConsecutiveCount = useRef(0);
|
|
148
|
+
// Multi-frame per-character voting: fuses OCR across frames so a single-frame
|
|
149
|
+
// misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
|
|
150
|
+
// resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
|
|
151
|
+
const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
|
|
147
152
|
|
|
148
153
|
// Document type stability tracking - require consistent detections from good quality frames
|
|
149
154
|
const lastDetectedDocType = useRef<
|
|
@@ -277,6 +282,7 @@ const IdentityDocumentCamera = ({
|
|
|
277
282
|
lastValidMRZText.current = null;
|
|
278
283
|
lastValidMRZFields.current = null;
|
|
279
284
|
validMRZConsecutiveCount.current = 0;
|
|
285
|
+
mrzAggregator.current.reset();
|
|
280
286
|
cachedBarcode.current = null;
|
|
281
287
|
isCompletionCallbackInvoked.current = false;
|
|
282
288
|
isStepTransitionInProgress.current = false;
|
|
@@ -601,6 +607,8 @@ const IdentityDocumentCamera = ({
|
|
|
601
607
|
if (nextStepType === 'SCAN_ID_BACK') {
|
|
602
608
|
lastValidMRZText.current = null;
|
|
603
609
|
lastValidMRZFields.current = null;
|
|
610
|
+
// New MRZ expected on the back — start voting fresh.
|
|
611
|
+
mrzAggregator.current.reset();
|
|
604
612
|
}
|
|
605
613
|
validMRZConsecutiveCount.current = 0;
|
|
606
614
|
cachedBarcode.current = null; // Clear cached barcode on step change
|
|
@@ -848,14 +856,43 @@ const IdentityDocumentCamera = ({
|
|
|
848
856
|
// Prefer MRZ-only text if available (from detected MRZ blocks),
|
|
849
857
|
// otherwise fall back to all text (for backward compatibility)
|
|
850
858
|
const textForValidation = scannedText?.mrzOnlyText || text;
|
|
859
|
+
|
|
860
|
+
// Multi-frame per-character voting: feed this frame's OCR into the
|
|
861
|
+
// aggregator (weighted by the MRZ band's pixel height as a sharpness/
|
|
862
|
+
// closeness proxy — a taller band means more pixels per character, which
|
|
863
|
+
// ML Kit reads more reliably) and prefer the fused consensus. This outvotes
|
|
864
|
+
// single-frame OCR errors (B↔8, S↔5, <↔K, dropped/inserted digit) instead
|
|
865
|
+
// of letting one noisy frame reset progress.
|
|
866
|
+
const mrzBandHeight = scannedText?.blocks?.length
|
|
867
|
+
? Math.max(
|
|
868
|
+
0,
|
|
869
|
+
...scannedText.blocks.map((b) => b.blockFrame?.height ?? 0)
|
|
870
|
+
)
|
|
871
|
+
: 0;
|
|
872
|
+
const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
|
|
873
|
+
const consensus = mrzAggregator.current.addFrame({
|
|
874
|
+
text: textForValidation,
|
|
875
|
+
weight: frameWeight,
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// Use the fused consensus when it is valid; otherwise fall back to a
|
|
879
|
+
// single-frame validation so document-type detection still works on the
|
|
880
|
+
// early frames. Only run the (expensive) single-frame pass when the
|
|
881
|
+
// consensus has NOT yet validated — once it has, the per-frame validation
|
|
882
|
+
// is redundant work that would needlessly load the JS thread.
|
|
851
883
|
const mrzValidationResult =
|
|
852
|
-
|
|
884
|
+
consensus.validation?.valid && consensus.validation.fields
|
|
885
|
+
? consensus.validation
|
|
886
|
+
: mrzUtils.validateMRZWithCorrections(textForValidation);
|
|
853
887
|
const parsedMRZData = {
|
|
854
888
|
valid: mrzValidationResult.valid,
|
|
855
889
|
fields: mrzValidationResult.fields || null,
|
|
856
890
|
};
|
|
857
|
-
//
|
|
858
|
-
|
|
891
|
+
// Raw MRZ lines: prefer the fused/corrected consensus when valid, else the
|
|
892
|
+
// lightweight per-frame clean. correctedMrz is the noise-free recovered MRZ.
|
|
893
|
+
const mrzText = parsedMRZData.valid
|
|
894
|
+
? (mrzValidationResult.correctedMrz ?? mrzUtils.fixMRZ(text))
|
|
895
|
+
: null;
|
|
859
896
|
|
|
860
897
|
// CRITICAL: Only detect document type during initial scan step
|
|
861
898
|
// For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
|
|
@@ -1006,9 +1043,12 @@ const IdentityDocumentCamera = ({
|
|
|
1006
1043
|
|
|
1007
1044
|
// Check if we have enough consistent valid reads
|
|
1008
1045
|
const mrzStableAndValid =
|
|
1009
|
-
|
|
1010
|
-
parsedMRZData?.valid === true
|
|
1011
|
-
|
|
1046
|
+
// Either: the multi-frame voted consensus has converged (valid + stable),
|
|
1047
|
+
(consensus.stable && parsedMRZData?.valid === true) ||
|
|
1048
|
+
// or the legacy N-identical-reads path is satisfied.
|
|
1049
|
+
(validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
|
|
1050
|
+
parsedMRZData?.valid === true &&
|
|
1051
|
+
areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields));
|
|
1012
1052
|
|
|
1013
1053
|
// ============================================================================
|
|
1014
1054
|
// SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
|