@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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics collector for the "Report a scanning issue" feature.
|
|
3
|
+
*
|
|
4
|
+
* Accumulates a PII-light technical snapshot of a single scan session — camera
|
|
5
|
+
* quality, MRZ consensus, and (the highest-value, most device-specific part) a
|
|
6
|
+
* structured per-step NFC/eID read log. The result screen serialises this into
|
|
7
|
+
* the diagnostic email the user reviews and sends to support.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - This is a MODULE-LEVEL SINGLETON so the deep camera/EID call stack can record
|
|
11
|
+
* into it without threading a prop/context through every layer. Call `reset()`
|
|
12
|
+
* at the start of a scan flow.
|
|
13
|
+
* - It stores only TECHNICAL signals (counts, timings, brightness, APDU status
|
|
14
|
+
* words, pass/fail). It deliberately holds NO identity data, images, MRZ text,
|
|
15
|
+
* keys, or nonces — the scanned identity/images shown to the user come from the
|
|
16
|
+
* result screen's own state, not from here, and are attached separately with
|
|
17
|
+
* the user's explicit review in the email draft.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A single step of the NFC/eID read, with its outcome and timing. */
|
|
21
|
+
export interface NfcStep {
|
|
22
|
+
/** Step name: 'open' | 'selectMF' | 'readCardAccess' | 'paceAuth' | 'bacAuth'
|
|
23
|
+
* | 'selectApplet' | 'readDG1' | 'readDG2' | … */
|
|
24
|
+
step: string;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
ms: number;
|
|
27
|
+
/** APDU status word (hex, e.g. "6982") when the step failed at the APDU level. */
|
|
28
|
+
sw?: string;
|
|
29
|
+
/** Coarse, non-PII error CATEGORY when the step failed (e.g. "auth_failed",
|
|
30
|
+
* "tag_lost", "reading_error") — never the raw error text. */
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NfcDiagnostics {
|
|
35
|
+
attempted: boolean;
|
|
36
|
+
chipDetected: boolean;
|
|
37
|
+
/** 'PACE' | 'BAC' | 'none' */
|
|
38
|
+
authProtocol: 'PACE' | 'BAC' | 'none';
|
|
39
|
+
paceFellBackToBac: boolean;
|
|
40
|
+
/** PACE object identifier string when PACE was attempted. */
|
|
41
|
+
paceOid?: string;
|
|
42
|
+
/** Why PACE wasn't used / fell back: 'no_card_access' | 'no_pace_info' |
|
|
43
|
+
* 'pace_failed'. Distinguishes device/chip-specific PACE issues. */
|
|
44
|
+
paceFallbackReason?: string;
|
|
45
|
+
steps: NfcStep[];
|
|
46
|
+
/** Name of the first failing step, if any. */
|
|
47
|
+
failureStep?: string;
|
|
48
|
+
/** Coarse category: 'auth_failed' | 'tag_lost' | 'timeout' | 'unsupported'
|
|
49
|
+
* | 'not_enabled' | 'user_cancelled' | 'reading_error' | null */
|
|
50
|
+
errorCategory?: string;
|
|
51
|
+
retryCount: number;
|
|
52
|
+
succeeded: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CameraDiagnostics {
|
|
56
|
+
frames: number;
|
|
57
|
+
frameW: number;
|
|
58
|
+
frameH: number;
|
|
59
|
+
avgBrightness: number;
|
|
60
|
+
minBrightness: number;
|
|
61
|
+
maxBrightness: number;
|
|
62
|
+
hadLowBrightness: boolean;
|
|
63
|
+
hadBlurryFrames: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The thresholds the measured values were judged against, so support can read
|
|
66
|
+
* "117 measured vs 65 floor" rather than just a pass/fail flag. Recorded once.
|
|
67
|
+
*/
|
|
68
|
+
thresholds?: {
|
|
69
|
+
brightnessLow?: number;
|
|
70
|
+
brightnessHigh?: number;
|
|
71
|
+
minBrightness?: number;
|
|
72
|
+
blur?: number;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MrzDiagnostics {
|
|
77
|
+
framesProcessed: number;
|
|
78
|
+
reachedStable: boolean;
|
|
79
|
+
stabilityFrames: number;
|
|
80
|
+
/** Smallest winning vote margin across contested cells at acceptance. */
|
|
81
|
+
minContestedMargin: number;
|
|
82
|
+
/**
|
|
83
|
+
* Largest MRZ-band pixel height seen (max OCR'd text-block height). Compared
|
|
84
|
+
* to the ~16px/character ML Kit floor, this explains "scans on device A,
|
|
85
|
+
* fails on device B" — a smaller band falls below the OCR resolution floor.
|
|
86
|
+
*/
|
|
87
|
+
maxMrzBandHeightPx?: number;
|
|
88
|
+
format?: string;
|
|
89
|
+
/** Per-field check-digit outcomes: 'pass' | 'fail' (no values, no PII). */
|
|
90
|
+
checkDigits?: Record<string, 'pass' | 'fail'>;
|
|
91
|
+
/** True when the Turkish leading-serial-letter check-digit convention was used
|
|
92
|
+
* to accept the document number (set at report-build time from the final MRZ). */
|
|
93
|
+
turkishLetterPrefixApplied: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DiagnosticsSnapshot {
|
|
97
|
+
schemaVersion: number;
|
|
98
|
+
generatedAt: string;
|
|
99
|
+
documentType?: string;
|
|
100
|
+
dataSource?: string;
|
|
101
|
+
totalDurationMs: number;
|
|
102
|
+
camera: CameraDiagnostics;
|
|
103
|
+
mrz: MrzDiagnostics;
|
|
104
|
+
nfc: NfcDiagnostics;
|
|
105
|
+
/** Sanitised, non-PII error entries collected during the session. */
|
|
106
|
+
errors: Array<{ code?: string; category?: string; screen?: string }>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const emptyCamera = (): CameraDiagnostics => ({
|
|
110
|
+
frames: 0,
|
|
111
|
+
frameW: 0,
|
|
112
|
+
frameH: 0,
|
|
113
|
+
avgBrightness: 0,
|
|
114
|
+
minBrightness: 0,
|
|
115
|
+
maxBrightness: 0,
|
|
116
|
+
hadLowBrightness: false,
|
|
117
|
+
hadBlurryFrames: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const emptyMrz = (): MrzDiagnostics => ({
|
|
121
|
+
framesProcessed: 0,
|
|
122
|
+
reachedStable: false,
|
|
123
|
+
stabilityFrames: 0,
|
|
124
|
+
minContestedMargin: 0,
|
|
125
|
+
turkishLetterPrefixApplied: false,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const emptyNfc = (): NfcDiagnostics => ({
|
|
129
|
+
attempted: false,
|
|
130
|
+
chipDetected: false,
|
|
131
|
+
authProtocol: 'none',
|
|
132
|
+
paceFellBackToBac: false,
|
|
133
|
+
steps: [],
|
|
134
|
+
retryCount: 0,
|
|
135
|
+
succeeded: false,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Accumulates one scan session's diagnostics. A single shared instance is
|
|
140
|
+
* exported below; tests can construct their own.
|
|
141
|
+
*/
|
|
142
|
+
export class DiagnosticsCollector {
|
|
143
|
+
private startedAtMs = 0;
|
|
144
|
+
private documentType?: string;
|
|
145
|
+
private dataSource?: string;
|
|
146
|
+
private camera = emptyCamera();
|
|
147
|
+
private mrz = emptyMrz();
|
|
148
|
+
private nfc = emptyNfc();
|
|
149
|
+
private errors: DiagnosticsSnapshot['errors'] = [];
|
|
150
|
+
|
|
151
|
+
/** Begin a fresh session — call when a scan flow starts. */
|
|
152
|
+
reset(nowMs: number): void {
|
|
153
|
+
this.startedAtMs = nowMs;
|
|
154
|
+
this.documentType = undefined;
|
|
155
|
+
this.dataSource = undefined;
|
|
156
|
+
this.camera = emptyCamera();
|
|
157
|
+
this.mrz = emptyMrz();
|
|
158
|
+
this.nfc = emptyNfc();
|
|
159
|
+
this.errors = [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setDocument(documentType?: string, dataSource?: string): void {
|
|
163
|
+
if (documentType) this.documentType = documentType;
|
|
164
|
+
if (dataSource) this.dataSource = dataSource;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Merge a camera snapshot (called periodically as frames arrive). */
|
|
168
|
+
setCamera(c: Partial<CameraDiagnostics>): void {
|
|
169
|
+
this.camera = { ...this.camera, ...c };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Merge MRZ consensus metrics. `reachedStable` is STICKY (once true, stays
|
|
173
|
+
* true) so a later frame's flicker can't unset that the scan did stabilise;
|
|
174
|
+
* `maxMrzBandHeightPx` keeps the largest seen. */
|
|
175
|
+
setMrz(m: Partial<MrzDiagnostics>): void {
|
|
176
|
+
const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
|
|
177
|
+
const maxMrzBandHeightPx =
|
|
178
|
+
Math.max(
|
|
179
|
+
this.mrz.maxMrzBandHeightPx ?? 0,
|
|
180
|
+
m.maxMrzBandHeightPx ?? 0
|
|
181
|
+
) || undefined;
|
|
182
|
+
this.mrz = { ...this.mrz, ...m, reachedStable, maxMrzBandHeightPx };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- NFC: the structured per-step log ----
|
|
186
|
+
|
|
187
|
+
nfcAttemptStarted(): void {
|
|
188
|
+
// A fresh attempt: bump retry count if a previous attempt left steps.
|
|
189
|
+
if (this.nfc.attempted) this.nfc.retryCount += 1;
|
|
190
|
+
this.nfc.attempted = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
nfcChipDetected(): void {
|
|
194
|
+
this.nfc.chipDetected = true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
nfcSetAuth(protocol: 'PACE' | 'BAC' | 'none', oid?: string): void {
|
|
198
|
+
this.nfc.authProtocol = protocol;
|
|
199
|
+
if (oid) this.nfc.paceOid = oid;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
nfcPaceFellBackToBac(reason?: string): void {
|
|
203
|
+
this.nfc.paceFellBackToBac = true;
|
|
204
|
+
if (reason && !this.nfc.paceFallbackReason) {
|
|
205
|
+
this.nfc.paceFallbackReason = reason;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Record one completed step. Keep messages short and free of PII. */
|
|
210
|
+
nfcStep(step: NfcStep): void {
|
|
211
|
+
this.nfc.steps.push(step);
|
|
212
|
+
if (!step.ok && !this.nfc.failureStep) {
|
|
213
|
+
this.nfc.failureStep = step.step;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
nfcResult(succeeded: boolean, errorCategory?: string): void {
|
|
218
|
+
this.nfc.succeeded = succeeded;
|
|
219
|
+
if (succeeded) {
|
|
220
|
+
// The read ultimately succeeded — transient per-attempt failures (e.g. a
|
|
221
|
+
// recovered DG2 retry, or the expected EF.CardAccess 6982 on a BAC-only
|
|
222
|
+
// card) must NOT be reported as the outcome. Clear the failure markers.
|
|
223
|
+
this.nfc.failureStep = undefined;
|
|
224
|
+
this.nfc.errorCategory = undefined;
|
|
225
|
+
} else if (errorCategory) {
|
|
226
|
+
this.nfc.errorCategory = errorCategory;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
addError(e: { code?: string; category?: string; screen?: string }): void {
|
|
231
|
+
this.errors.push(e);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Build the serialisable snapshot. */
|
|
235
|
+
snapshot(nowMs: number, isoNow: string): DiagnosticsSnapshot {
|
|
236
|
+
return {
|
|
237
|
+
schemaVersion: 1,
|
|
238
|
+
generatedAt: isoNow,
|
|
239
|
+
documentType: this.documentType,
|
|
240
|
+
dataSource: this.dataSource,
|
|
241
|
+
totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
|
|
242
|
+
camera: { ...this.camera },
|
|
243
|
+
mrz: { ...this.mrz },
|
|
244
|
+
nfc: { ...this.nfc, steps: this.nfc.steps.map((s) => ({ ...s })) },
|
|
245
|
+
errors: this.errors.map((e) => ({ ...e })),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Shared session-scoped collector used across the scan flow. */
|
|
251
|
+
export const diagnostics = new DiagnosticsCollector();
|
|
@@ -43,8 +43,29 @@ export interface MRZFrameInput {
|
|
|
43
43
|
* focus-weighted voting beats unweighted.
|
|
44
44
|
*/
|
|
45
45
|
weight?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Optional OCR confidence for this frame, in [0, 1] — e.g. ML Kit's mean
|
|
48
|
+
* per-symbol confidence over the MRZ characters (Android bundled library only;
|
|
49
|
+
* iOS ML Kit exposes no symbol confidence). When present it MULTIPLIES `weight`,
|
|
50
|
+
* so a frame the recognizer was unsure about contributes proportionally fewer
|
|
51
|
+
* votes than a confident one of the same sharpness. This is the per-frame form
|
|
52
|
+
* of confidence-weighted voting: it can't misalign cells (unlike threading
|
|
53
|
+
* per-column confidence through the column-shifting line reconstruction), yet
|
|
54
|
+
* still lets confident frames dominate look-alike ties (A↔4, S↔5, B↔8).
|
|
55
|
+
*
|
|
56
|
+
* A confidence floor is applied so a single near-zero frame is down-weighted
|
|
57
|
+
* but never fully silenced (it may still hold the only vote for some cell).
|
|
58
|
+
*/
|
|
59
|
+
confidence?: number;
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Floor applied to a frame's confidence multiplier. A frame ML Kit was very
|
|
64
|
+
* unsure about still contributes (it may carry the only reading of a cell), but
|
|
65
|
+
* at a fraction of a confident frame's weight.
|
|
66
|
+
*/
|
|
67
|
+
const MIN_CONFIDENCE_WEIGHT = 0.25;
|
|
68
|
+
|
|
48
69
|
export interface MRZConsensus {
|
|
49
70
|
/** Best consensus MRZ (newline-joined fixed-width lines), or null if none yet. */
|
|
50
71
|
mrz: string | null;
|
|
@@ -57,6 +78,18 @@ export interface MRZConsensus {
|
|
|
57
78
|
* last `stabilityTarget` contributing frames. Callers should advance only then.
|
|
58
79
|
*/
|
|
59
80
|
stable: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* How many consecutive contributing frames the current (valid) consensus has
|
|
83
|
+
* stayed unchanged. Diagnostic: convergence speed across devices.
|
|
84
|
+
*/
|
|
85
|
+
stableStreak: number;
|
|
86
|
+
/**
|
|
87
|
+
* Smallest winning vote margin across all CONTESTED cells of the best format
|
|
88
|
+
* (cells where >1 distinct char was voted). Higher = more confident reading;
|
|
89
|
+
* a low value at acceptance means a look-alike was barely resolved. Infinity
|
|
90
|
+
* when no cell is contested (a perfectly clean read). Diagnostic only.
|
|
91
|
+
*/
|
|
92
|
+
minContestedMargin: number;
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
export interface MRZFrameAggregatorOptions {
|
|
@@ -244,7 +277,15 @@ export class MRZFrameAggregator {
|
|
|
244
277
|
* consensus is returned.
|
|
245
278
|
*/
|
|
246
279
|
addFrame(input: MRZFrameInput): MRZConsensus {
|
|
247
|
-
const
|
|
280
|
+
const baseWeight = input.weight && input.weight > 0 ? input.weight : 1;
|
|
281
|
+
// Fold OCR confidence into the weight (clamped to [floor, 1]). Absent
|
|
282
|
+
// confidence leaves the weight unchanged, so callers without per-symbol
|
|
283
|
+
// confidence (e.g. iOS ML Kit) keep the prior sharpness-only behaviour.
|
|
284
|
+
const confidenceFactor =
|
|
285
|
+
typeof input.confidence === 'number' && Number.isFinite(input.confidence)
|
|
286
|
+
? Math.min(1, Math.max(MIN_CONFIDENCE_WEIGHT, input.confidence))
|
|
287
|
+
: 1;
|
|
288
|
+
const weight = baseWeight * confidenceFactor;
|
|
248
289
|
const lines = linesForFrame(input.text);
|
|
249
290
|
|
|
250
291
|
if (lines) {
|
|
@@ -311,18 +352,30 @@ export class MRZFrameAggregator {
|
|
|
311
352
|
* is what turns "looks valid this frame" into "the camera agrees across frames".
|
|
312
353
|
*/
|
|
313
354
|
private consensusIsConfident(format: string): boolean {
|
|
355
|
+
return this.minContestedMargin(format) >= this.minMargin;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Smallest winning vote margin across all CONTESTED cells (where >1 distinct
|
|
360
|
+
* char was voted). Returns Infinity when no cell is contested. Used both to
|
|
361
|
+
* gate confidence and to surface a diagnostic of how narrowly a look-alike was
|
|
362
|
+
* resolved at acceptance.
|
|
363
|
+
*/
|
|
364
|
+
private minContestedMargin(format: string): number {
|
|
314
365
|
const geom = FORMAT_GEOMETRY[format];
|
|
315
366
|
const tally = this.tallies[format];
|
|
367
|
+
let min = Infinity;
|
|
316
368
|
for (let li = 0; li < geom.lines; li++) {
|
|
317
369
|
for (let ci = 0; ci < geom.width; ci++) {
|
|
318
370
|
const cell = tally[li][ci];
|
|
319
371
|
const weights = Object.values(cell);
|
|
320
372
|
if (weights.length < 2) continue; // uncontested cell — no ambiguity
|
|
321
373
|
weights.sort((a, b) => b - a);
|
|
322
|
-
|
|
374
|
+
const margin = weights[0] - weights[1];
|
|
375
|
+
if (margin < min) min = margin;
|
|
323
376
|
}
|
|
324
377
|
}
|
|
325
|
-
return
|
|
378
|
+
return min;
|
|
326
379
|
}
|
|
327
380
|
|
|
328
381
|
/** Read out the winning character per cell for a format. */
|
|
@@ -365,6 +418,8 @@ export class MRZFrameAggregator {
|
|
|
365
418
|
validation: null,
|
|
366
419
|
frames: this.frameCount,
|
|
367
420
|
stable: false,
|
|
421
|
+
stableStreak: 0,
|
|
422
|
+
minContestedMargin: Infinity,
|
|
368
423
|
};
|
|
369
424
|
}
|
|
370
425
|
|
|
@@ -415,6 +470,9 @@ export class MRZFrameAggregator {
|
|
|
415
470
|
frames: this.frameCount,
|
|
416
471
|
stable:
|
|
417
472
|
bestValid && confident && this.stableStreak >= this.stabilityTarget,
|
|
473
|
+
stableStreak: this.stableStreak,
|
|
474
|
+
minContestedMargin:
|
|
475
|
+
bestFormat != null ? this.minContestedMargin(bestFormat) : Infinity,
|
|
418
476
|
};
|
|
419
477
|
}
|
|
420
478
|
}
|
|
@@ -1,6 +1,122 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
2
|
import DeviceInfo from 'react-native-device-info';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Full, non-PII device details for the support diagnostic report. Device-
|
|
6
|
+
* specific scanning/NFC issues correlate with hardware, OS level, memory and
|
|
7
|
+
* NFC capability, so we capture a broad-but-safe set. Excludes anything that
|
|
8
|
+
* identifies a person (uniqueId is a per-install vendor id, not PII, and is kept
|
|
9
|
+
* only for correlating repeat reports from the same device).
|
|
10
|
+
*/
|
|
11
|
+
export interface DeviceDetails {
|
|
12
|
+
platform: string; // 'ios' | 'android'
|
|
13
|
+
systemName: string; // 'iOS' | 'Android'
|
|
14
|
+
systemVersion: string;
|
|
15
|
+
apiLevel?: number; // Android API level
|
|
16
|
+
brand: string;
|
|
17
|
+
manufacturer: string;
|
|
18
|
+
model: string;
|
|
19
|
+
deviceId: string; // hardware model code, e.g. "iPhone16,1" / "tegu"
|
|
20
|
+
deviceType: string; // Handset / Tablet / …
|
|
21
|
+
isTablet: boolean;
|
|
22
|
+
isEmulator: boolean;
|
|
23
|
+
hasNotch: boolean;
|
|
24
|
+
totalMemoryMB?: number;
|
|
25
|
+
usedMemoryMB?: number;
|
|
26
|
+
totalDiskMB?: number;
|
|
27
|
+
freeDiskMB?: number;
|
|
28
|
+
batteryLevel?: number; // 0..1
|
|
29
|
+
appVersion: string;
|
|
30
|
+
buildNumber: string;
|
|
31
|
+
bundleId: string;
|
|
32
|
+
fontScale?: number;
|
|
33
|
+
installerPackage?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve an async getter to a number, tolerating failure. */
|
|
37
|
+
const num = async (p: Promise<number>): Promise<number | undefined> => {
|
|
38
|
+
try {
|
|
39
|
+
const v = await p;
|
|
40
|
+
return typeof v === 'number' && isFinite(v) ? v : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Resolve an async getter, falling back on failure. */
|
|
47
|
+
const safe = async <T>(p: Promise<T>, fallback: T): Promise<T> => {
|
|
48
|
+
try {
|
|
49
|
+
return await p;
|
|
50
|
+
} catch {
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Read a synchronous getter, tolerating failure. */
|
|
56
|
+
const sync = <T>(fn: () => T, fallback: T): T => {
|
|
57
|
+
try {
|
|
58
|
+
return fn();
|
|
59
|
+
} catch {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Gather the full non-PII device profile for a diagnostic report. */
|
|
65
|
+
export const gatherDeviceDetails = async (): Promise<DeviceDetails> => {
|
|
66
|
+
const bytesToMB = (b?: number) =>
|
|
67
|
+
b === undefined ? undefined : Math.round(b / (1024 * 1024));
|
|
68
|
+
const isAndroid = Platform.OS === 'android';
|
|
69
|
+
|
|
70
|
+
const [
|
|
71
|
+
manufacturer,
|
|
72
|
+
isEmulator,
|
|
73
|
+
apiLevel,
|
|
74
|
+
installerPackage,
|
|
75
|
+
totalMemory,
|
|
76
|
+
usedMemory,
|
|
77
|
+
totalDisk,
|
|
78
|
+
freeDisk,
|
|
79
|
+
batteryLevel,
|
|
80
|
+
fontScale,
|
|
81
|
+
] = await Promise.all([
|
|
82
|
+
safe(DeviceInfo.getManufacturer(), 'unknown'),
|
|
83
|
+
safe(DeviceInfo.isEmulator(), false),
|
|
84
|
+
isAndroid ? num(DeviceInfo.getApiLevel()) : Promise.resolve(undefined),
|
|
85
|
+
safe(DeviceInfo.getInstallerPackageName(), 'unknown'),
|
|
86
|
+
num(DeviceInfo.getTotalMemory()),
|
|
87
|
+
num(DeviceInfo.getUsedMemory()),
|
|
88
|
+
num(DeviceInfo.getTotalDiskCapacity()),
|
|
89
|
+
num(DeviceInfo.getFreeDiskStorage()),
|
|
90
|
+
num(DeviceInfo.getBatteryLevel()),
|
|
91
|
+
num(DeviceInfo.getFontScale()),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
platform: Platform.OS,
|
|
96
|
+
systemName: isAndroid ? 'Android' : 'iOS',
|
|
97
|
+
systemVersion: sync(() => DeviceInfo.getSystemVersion(), 'unknown'),
|
|
98
|
+
apiLevel,
|
|
99
|
+
brand: sync(() => DeviceInfo.getBrand(), 'unknown'),
|
|
100
|
+
manufacturer,
|
|
101
|
+
model: sync(() => DeviceInfo.getModel(), 'unknown'),
|
|
102
|
+
deviceId: sync(() => DeviceInfo.getDeviceId(), 'unknown'),
|
|
103
|
+
deviceType: sync(() => DeviceInfo.getDeviceType(), 'unknown'),
|
|
104
|
+
isTablet: sync(() => DeviceInfo.isTablet(), false),
|
|
105
|
+
isEmulator,
|
|
106
|
+
hasNotch: sync(() => DeviceInfo.hasNotch(), false),
|
|
107
|
+
totalMemoryMB: bytesToMB(totalMemory),
|
|
108
|
+
usedMemoryMB: bytesToMB(usedMemory),
|
|
109
|
+
totalDiskMB: bytesToMB(totalDisk),
|
|
110
|
+
freeDiskMB: bytesToMB(freeDisk),
|
|
111
|
+
batteryLevel,
|
|
112
|
+
appVersion: sync(() => DeviceInfo.getVersion(), 'unknown'),
|
|
113
|
+
buildNumber: sync(() => DeviceInfo.getBuildNumber(), 'unknown'),
|
|
114
|
+
bundleId: sync(() => DeviceInfo.getBundleId(), 'unknown'),
|
|
115
|
+
fontScale,
|
|
116
|
+
installerPackage,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
4
120
|
export const NativeDeviceInfo = {
|
|
5
121
|
generateHumanReadableIdentifier: async (): Promise<string> => {
|
|
6
122
|
try {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembles and shares the support diagnostic report.
|
|
3
|
+
*
|
|
4
|
+
* `react-native-share` is an OPTIONAL dependency: the host app opts into the
|
|
5
|
+
* diagnostic feature by installing it. When it's present we open the OS share
|
|
6
|
+
* sheet with the full report (the user picks email / WhatsApp / Drive / etc. and
|
|
7
|
+
* reviews before sending); when it's absent the feature is silently unavailable
|
|
8
|
+
* — `isDiagnosticSharingAvailable()` returns false and the result-screen button
|
|
9
|
+
* renders nothing, so apps that don't want it incur no dependency.
|
|
10
|
+
*
|
|
11
|
+
* Attached files (staged to a temp dir via react-native-fs, already an SDK dep):
|
|
12
|
+
* - `diagnostics.json` — technical signals (device, camera, MRZ, NFC steps),
|
|
13
|
+
* - `scan-data.json` — the scanned identity data (PII; user reviews it),
|
|
14
|
+
* - the captured document/face images.
|
|
15
|
+
*
|
|
16
|
+
* The readable summary rides as the share `message` so it's visible in the
|
|
17
|
+
* draft regardless of the chosen app.
|
|
18
|
+
*/
|
|
19
|
+
import RNFS from 'react-native-fs';
|
|
20
|
+
import { diagnostics } from './diagnostics';
|
|
21
|
+
import { gatherDeviceDetails } from './native-device-info.utils';
|
|
22
|
+
import {
|
|
23
|
+
buildDiagnosticReport,
|
|
24
|
+
type ScanDataForReport,
|
|
25
|
+
} from './diagnosticReport';
|
|
26
|
+
import { logError } from './debug.utils';
|
|
27
|
+
|
|
28
|
+
// `react-native-share` is OPTIONAL. Load it softly so the SDK bundles and runs
|
|
29
|
+
// without it; only host apps that installed it get the diagnostic feature. A
|
|
30
|
+
// static `import` would make Metro fail to resolve the module when it's absent,
|
|
31
|
+
// so we use a guarded require and keep the reference typed loosely.
|
|
32
|
+
type ShareModule = {
|
|
33
|
+
open: (options: Record<string, unknown>) => Promise<unknown>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let shareModule: ShareModule | null | undefined;
|
|
37
|
+
|
|
38
|
+
const loadShare = (): ShareModule | null => {
|
|
39
|
+
if (shareModule !== undefined) return shareModule;
|
|
40
|
+
try {
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
42
|
+
const mod = require('react-native-share');
|
|
43
|
+
shareModule = (mod?.default ?? mod) as ShareModule;
|
|
44
|
+
} catch {
|
|
45
|
+
shareModule = null; // not installed — feature unavailable
|
|
46
|
+
}
|
|
47
|
+
return shareModule;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether diagnostic sharing is available (i.e. `react-native-share` is
|
|
52
|
+
* installed in the host app). The result-screen button hides itself when false.
|
|
53
|
+
*/
|
|
54
|
+
export const isDiagnosticSharingAvailable = (): boolean => loadShare() != null;
|
|
55
|
+
|
|
56
|
+
export interface DiagnosticImage {
|
|
57
|
+
/** Stable file name without extension, e.g. "mrz-side". */
|
|
58
|
+
name: string;
|
|
59
|
+
base64: string;
|
|
60
|
+
mime: 'jpg' | 'png';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SendDiagnosticParams {
|
|
64
|
+
scan: ScanDataForReport;
|
|
65
|
+
sdkVersion: string;
|
|
66
|
+
sessionId?: string;
|
|
67
|
+
/** base64 images to attach (MRZ side / front / face / NFC chip face). */
|
|
68
|
+
images?: DiagnosticImage[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
const writeTemp = async (
|
|
73
|
+
dir: string,
|
|
74
|
+
name: string,
|
|
75
|
+
data: string,
|
|
76
|
+
encoding: 'utf8' | 'base64'
|
|
77
|
+
): Promise<string> => {
|
|
78
|
+
const path = `${dir}/${name}`;
|
|
79
|
+
await RNFS.writeFile(path, data, encoding);
|
|
80
|
+
return path.startsWith('file://') ? path : `file://${path}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface SendResult {
|
|
84
|
+
shared: boolean;
|
|
85
|
+
/** True when the user dismissed the share sheet (not an error). */
|
|
86
|
+
dismissed?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the report, stage attachments, and present the share sheet. Returns
|
|
91
|
+
* { shared } — false on error, with `dismissed` set when the user cancelled.
|
|
92
|
+
*/
|
|
93
|
+
export const sendDiagnosticReport = async (
|
|
94
|
+
params: SendDiagnosticParams
|
|
95
|
+
): Promise<SendResult> => {
|
|
96
|
+
const share = loadShare();
|
|
97
|
+
if (!share) {
|
|
98
|
+
// Optional dependency not installed — nothing to do.
|
|
99
|
+
return { shared: false };
|
|
100
|
+
}
|
|
101
|
+
const { scan, sdkVersion, sessionId, images = [] } = params;
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const iso = new Date(now).toISOString();
|
|
104
|
+
|
|
105
|
+
const device = await gatherDeviceDetails();
|
|
106
|
+
const report = buildDiagnosticReport({
|
|
107
|
+
diag: diagnostics.snapshot(now, iso),
|
|
108
|
+
device,
|
|
109
|
+
scan,
|
|
110
|
+
sdkVersion,
|
|
111
|
+
sessionId,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const dir = `${RNFS.TemporaryDirectoryPath}/trustchex-diagnostics-${now}`;
|
|
115
|
+
try {
|
|
116
|
+
await RNFS.mkdir(dir);
|
|
117
|
+
const urls: string[] = [];
|
|
118
|
+
urls.push(
|
|
119
|
+
await writeTemp(dir, 'diagnostics.json', report.diagnosticsJson, 'utf8')
|
|
120
|
+
);
|
|
121
|
+
urls.push(
|
|
122
|
+
await writeTemp(dir, 'scan-data.json', report.scanDataJson, 'utf8')
|
|
123
|
+
);
|
|
124
|
+
for (const img of images) {
|
|
125
|
+
if (!img.base64) continue;
|
|
126
|
+
urls.push(
|
|
127
|
+
await writeTemp(dir, `${img.name}.${img.mime}`, img.base64, 'base64')
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await share.open({
|
|
133
|
+
title: report.subject,
|
|
134
|
+
subject: report.subject, // used by email targets
|
|
135
|
+
message: report.body,
|
|
136
|
+
urls,
|
|
137
|
+
// Per-file MIME types so targets render attachments correctly.
|
|
138
|
+
filenames: urls.map((u) => u.split('/').pop() ?? 'attachment'),
|
|
139
|
+
failOnCancel: false,
|
|
140
|
+
});
|
|
141
|
+
return { shared: true };
|
|
142
|
+
} catch (shareErr) {
|
|
143
|
+
// react-native-share throws on user cancel unless failOnCancel:false; treat
|
|
144
|
+
// a cancel as a non-error dismissal.
|
|
145
|
+
const msg =
|
|
146
|
+
shareErr instanceof Error ? shareErr.message : String(shareErr);
|
|
147
|
+
if (/cancel|dismiss|user did not share/i.test(msg)) {
|
|
148
|
+
return { shared: false, dismissed: true };
|
|
149
|
+
}
|
|
150
|
+
throw shareErr;
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
logError(
|
|
154
|
+
'Diagnostics',
|
|
155
|
+
'Failed to share diagnostic report',
|
|
156
|
+
e instanceof Error ? e.message : String(e)
|
|
157
|
+
);
|
|
158
|
+
return { shared: false };
|
|
159
|
+
} finally {
|
|
160
|
+
// Best-effort cleanup — temp files are short-lived.
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
RNFS.unlink(dir).catch(() => {});
|
|
163
|
+
}, 60_000);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
@@ -53,6 +53,12 @@ export default {
|
|
|
53
53
|
'resultScreen.demoVerbalConsentTitle': 'Title',
|
|
54
54
|
'resultScreen.demoVerbalConsentText': 'Text',
|
|
55
55
|
'resultScreen.demoStartOver': 'Start Over',
|
|
56
|
+
'diagnosticReport.button': 'Report a scanning issue',
|
|
57
|
+
'diagnosticReport.noticeTitle': 'Share diagnostic report',
|
|
58
|
+
'diagnosticReport.noticeBody':
|
|
59
|
+
'This opens a draft with your scan data, document images and technical details so support can help resolve a scanning issue. Review it before sending.',
|
|
60
|
+
'diagnosticReport.continue': 'Continue',
|
|
61
|
+
'diagnosticReport.cancel': 'Cancel',
|
|
56
62
|
'livenessDetectionScreen.guideHeader': 'Face Verification',
|
|
57
63
|
'livenessDetectionScreen.guideText':
|
|
58
64
|
'Please ensure the following for best results:',
|
|
@@ -53,6 +53,12 @@ export default {
|
|
|
53
53
|
'resultScreen.demoVerbalConsentTitle': 'Başlık',
|
|
54
54
|
'resultScreen.demoVerbalConsentText': 'Metin',
|
|
55
55
|
'resultScreen.demoStartOver': 'Baştan Başla',
|
|
56
|
+
'diagnosticReport.button': 'Tarama sorununu bildir',
|
|
57
|
+
'diagnosticReport.noticeTitle': 'Tanılama raporu paylaş',
|
|
58
|
+
'diagnosticReport.noticeBody':
|
|
59
|
+
'Bu işlem; tarama verilerinizi, belge görüntülerinizi ve teknik ayrıntıları içeren bir taslak açar; böylece destek ekibi tarama sorununu çözebilir. Göndermeden önce gözden geçirin.',
|
|
60
|
+
'diagnosticReport.continue': 'Devam et',
|
|
61
|
+
'diagnosticReport.cancel': 'İptal',
|
|
56
62
|
'livenessDetectionScreen.guideHeader': 'Yüz Doğrulaması',
|
|
57
63
|
'livenessDetectionScreen.guideText':
|
|
58
64
|
'En iyi sonuç için lütfen şunları sağlayın:',
|