@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
|
@@ -9,6 +9,33 @@ import { DG2File } from "./lds/icao/dg2File.js";
|
|
|
9
9
|
import { Buffer } from 'buffer';
|
|
10
10
|
import { InputStream } from "./java/inputStream.js";
|
|
11
11
|
import { debugLog } from "../Libs/debug.utils.js";
|
|
12
|
+
import { diagnostics } from "../Libs/diagnostics.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract an APDU status word (hex, e.g. "6982") from an error message if the
|
|
16
|
+
* protocol layer included one. Returns undefined when none is present. Kept
|
|
17
|
+
* deliberately loose — the protocol senders embed "SW=...." / "(6982)" forms.
|
|
18
|
+
*/
|
|
19
|
+
const extractStatusWord = msg => {
|
|
20
|
+
const m = msg.match(/\b(?:SW[:= ]?)?([0-9a-fA-F]{4})\b/);
|
|
21
|
+
return m ? m[1].toUpperCase() : undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Categorise an NFC error message into a coarse, non-PII bucket. */
|
|
25
|
+
const categorizeNfcError = msg => {
|
|
26
|
+
const m = msg.toLowerCase();
|
|
27
|
+
if (m.includes('cancel')) return 'user_cancelled';
|
|
28
|
+
if (m.includes('lost') || m.includes('steady')) return 'tag_lost';
|
|
29
|
+
if (m.includes('timeout')) return 'timeout';
|
|
30
|
+
if (m.includes('not supported') || m.includes('unsupported')) {
|
|
31
|
+
return 'unsupported';
|
|
32
|
+
}
|
|
33
|
+
if (m.includes('not enabled') || m.includes('disabled')) return 'not_enabled';
|
|
34
|
+
if (m.includes('auth') || m.includes('mutual') || m.includes('security')) {
|
|
35
|
+
return 'auth_failed';
|
|
36
|
+
}
|
|
37
|
+
return 'reading_error';
|
|
38
|
+
};
|
|
12
39
|
|
|
13
40
|
// --- Minimal PNG encoder (pure JS, no native deps) ---
|
|
14
41
|
// Uses deflate stored (uncompressed) blocks for O(n) encoding speed.
|
|
@@ -275,8 +302,40 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
275
302
|
let progress = 0;
|
|
276
303
|
const nfcManagerCardService = new NFCManagerCardService();
|
|
277
304
|
const passportService = new EIDService(nfcManagerCardService, EIDService.NORMAL_MAX_TRANSCEIVE_LENGTH, EIDService.DEFAULT_MAX_BLOCKSIZE, false, true);
|
|
305
|
+
|
|
306
|
+
// Structured NFC diagnostics: record each step's outcome + timing + APDU SW
|
|
307
|
+
// into the session collector so the result screen can surface device-specific
|
|
308
|
+
// read failures. Best-effort and side-effect-only — never alters the flow.
|
|
309
|
+
diagnostics.nfcAttemptStarted();
|
|
310
|
+
const stepTimer = () => {
|
|
311
|
+
const t0 = Date.now();
|
|
312
|
+
return (step, ok, error) => {
|
|
313
|
+
const msg = error instanceof Error ? error.message : error ? String(error) : undefined;
|
|
314
|
+
// Store only the APDU status word and a COARSE category — never the raw
|
|
315
|
+
// error text, which could in theory embed field/PII values. The SW + step
|
|
316
|
+
// name + category carry all the actionable signal.
|
|
317
|
+
diagnostics.nfcStep({
|
|
318
|
+
step,
|
|
319
|
+
ok,
|
|
320
|
+
ms: Date.now() - t0,
|
|
321
|
+
sw: msg ? extractStatusWord(msg) : undefined,
|
|
322
|
+
error: msg ? categorizeNfcError(msg) : undefined
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
};
|
|
278
326
|
try {
|
|
279
|
-
|
|
327
|
+
{
|
|
328
|
+
const done = stepTimer();
|
|
329
|
+
try {
|
|
330
|
+
await passportService.open();
|
|
331
|
+
// Reaching open() without throwing means the chip powered on / was seen.
|
|
332
|
+
diagnostics.nfcChipDetected();
|
|
333
|
+
done('open', true);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
done('open', false, e);
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
280
339
|
progress = 1;
|
|
281
340
|
if (progressCallback) {
|
|
282
341
|
progressCallback(progress);
|
|
@@ -286,12 +345,17 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
286
345
|
// Explicitly SELECT MF first so the SFI=0x1C context is correct,
|
|
287
346
|
// regardless of which application the chip activates on power-on.
|
|
288
347
|
let hasPACESucceeded = false;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
348
|
+
{
|
|
349
|
+
const done = stepTimer();
|
|
350
|
+
try {
|
|
351
|
+
await passportService.sendSelectMF();
|
|
352
|
+
done('selectMF', true);
|
|
353
|
+
debugLog('EID', '[EID] SELECT MF OK');
|
|
354
|
+
} catch (mfErr) {
|
|
355
|
+
done('selectMF', false, mfErr);
|
|
356
|
+
const mfMsg = mfErr instanceof Error ? mfErr.message : String(mfErr);
|
|
357
|
+
debugLog('EID', `[EID] SELECT MF failed (${mfMsg}), continuing with SFI read anyway`);
|
|
358
|
+
}
|
|
295
359
|
}
|
|
296
360
|
progress = 2;
|
|
297
361
|
if (progressCallback) {
|
|
@@ -301,19 +365,24 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
301
365
|
// Read EF.CardAccess — kept in a separate try/catch so we can distinguish
|
|
302
366
|
// "file read failure" from "PACE protocol failure" in the logs.
|
|
303
367
|
let cardAccessBuf = null;
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
buf
|
|
368
|
+
{
|
|
369
|
+
const done = stepTimer();
|
|
370
|
+
try {
|
|
371
|
+
const cardAccessStream = passportService.getInputStream(EIDService.EF_CARD_ACCESS);
|
|
372
|
+
await cardAccessStream.init();
|
|
373
|
+
const cardAccessLen = cardAccessStream.getLength();
|
|
374
|
+
const buf = Buffer.alloc(cardAccessLen);
|
|
375
|
+
for (let i = 0; i < cardAccessLen; i++) {
|
|
376
|
+
buf[i] = await cardAccessStream.read();
|
|
377
|
+
}
|
|
378
|
+
cardAccessBuf = buf;
|
|
379
|
+
done('readCardAccess', true);
|
|
380
|
+
debugLog('EID', `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`);
|
|
381
|
+
} catch (readErr) {
|
|
382
|
+
done('readCardAccess', false, readErr);
|
|
383
|
+
const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
|
384
|
+
debugLog('EID', `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`);
|
|
311
385
|
}
|
|
312
|
-
cardAccessBuf = buf;
|
|
313
|
-
debugLog('EID', `[EID] EF.CardAccess read OK: ${cardAccessLen} bytes = ${cardAccessBuf.toString('hex')}`);
|
|
314
|
-
} catch (readErr) {
|
|
315
|
-
const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
|
316
|
-
debugLog('EID', `[EID] EF.CardAccess read FAILED: ${readMsg} — falling back to BAC`);
|
|
317
386
|
}
|
|
318
387
|
progress = 3;
|
|
319
388
|
if (progressCallback) {
|
|
@@ -321,11 +390,17 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
321
390
|
}
|
|
322
391
|
|
|
323
392
|
// If EF.CardAccess was read successfully, attempt PACE.
|
|
393
|
+
if (cardAccessBuf === null) {
|
|
394
|
+
// EF.CardAccess unreadable (the readCardAccess step already recorded the
|
|
395
|
+
// failure/SW) → PACE can't be attempted; BAC will be used.
|
|
396
|
+
diagnostics.nfcPaceFellBackToBac('no_card_access');
|
|
397
|
+
}
|
|
324
398
|
if (cardAccessBuf !== null) {
|
|
325
399
|
try {
|
|
326
400
|
const paceInfo = parsePACEInfoFromCardAccess(cardAccessBuf);
|
|
327
401
|
if (paceInfo) {
|
|
328
402
|
debugLog('EID', `[EID] PACE available: oid=${paceInfo.oid} parameterId=${paceInfo.parameterId}`);
|
|
403
|
+
diagnostics.nfcSetAuth('PACE', paceInfo.oid);
|
|
329
404
|
// PACE is available - derive key from MRZ and perform PACE
|
|
330
405
|
const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
|
|
331
406
|
const paceKey = PACEKeySpec.createMRZKey(bacKey.getKeySeedForPACE());
|
|
@@ -333,14 +408,23 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
333
408
|
if (progressCallback) {
|
|
334
409
|
progressCallback(progress);
|
|
335
410
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
411
|
+
const done = stepTimer();
|
|
412
|
+
try {
|
|
413
|
+
await passportService.doPACE(paceKey, paceInfo.oid, paceInfo.parameterId, progressCallback);
|
|
414
|
+
hasPACESucceeded = true;
|
|
415
|
+
done('paceAuth', true);
|
|
416
|
+
debugLog('EID', '[EID] PACE succeeded');
|
|
417
|
+
} catch (paceErr) {
|
|
418
|
+
done('paceAuth', false, paceErr);
|
|
419
|
+
throw paceErr;
|
|
420
|
+
}
|
|
339
421
|
} else {
|
|
422
|
+
diagnostics.nfcPaceFellBackToBac('no_pace_info');
|
|
340
423
|
debugLog('EID', '[EID] EF.CardAccess read OK but no PACE SecurityInfo found — document does not support PACE, falling back to BAC');
|
|
341
424
|
}
|
|
342
425
|
} catch (paceError) {
|
|
343
426
|
// PACE protocol exchange failed — fall back to BAC
|
|
427
|
+
diagnostics.nfcPaceFellBackToBac('pace_failed');
|
|
344
428
|
const msg = paceError instanceof Error ? paceError.message : String(paceError);
|
|
345
429
|
debugLog('EID', `[EID] PACE protocol failed (${msg}), falling back to BAC`);
|
|
346
430
|
if (paceError instanceof Error && paceError.stack) {
|
|
@@ -354,7 +438,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
354
438
|
}
|
|
355
439
|
|
|
356
440
|
// Select Applet for MRTD
|
|
357
|
-
|
|
441
|
+
{
|
|
442
|
+
const done = stepTimer();
|
|
443
|
+
try {
|
|
444
|
+
await passportService.sendSelectApplet(hasPACESucceeded);
|
|
445
|
+
done('selectApplet', true);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
done('selectApplet', false, e);
|
|
448
|
+
throw e;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
358
451
|
progress = 10;
|
|
359
452
|
if (progressCallback) {
|
|
360
453
|
progressCallback(progress);
|
|
@@ -367,8 +460,16 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
367
460
|
await efComStream.read();
|
|
368
461
|
} catch (error) {
|
|
369
462
|
// EF_COM not available -> try to do BAC
|
|
463
|
+
diagnostics.nfcSetAuth('BAC');
|
|
370
464
|
const bacKey = new BACKey(documentNumber, dateOfBirth, dateOfExpiry);
|
|
371
|
-
|
|
465
|
+
const done = stepTimer();
|
|
466
|
+
try {
|
|
467
|
+
await passportService.doBAC(bacKey);
|
|
468
|
+
done('bacAuth', true);
|
|
469
|
+
} catch (e) {
|
|
470
|
+
done('bacAuth', false, e);
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
372
473
|
}
|
|
373
474
|
}
|
|
374
475
|
progress = 20;
|
|
@@ -377,16 +478,25 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
377
478
|
}
|
|
378
479
|
|
|
379
480
|
// Read DG1 (MRZ Info)
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
481
|
+
const dg1Done = stepTimer();
|
|
482
|
+
let mrzInfo;
|
|
483
|
+
try {
|
|
484
|
+
const dg1InputStream = passportService.getInputStream(EIDService.EF_DG1);
|
|
485
|
+
await dg1InputStream.init();
|
|
486
|
+
const dg1File = new DG1File(undefined, dg1InputStream);
|
|
487
|
+
mrzInfo = await dg1File.getMRZInfo();
|
|
488
|
+
dg1Done('readDG1', true);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
dg1Done('readDG1', false, e);
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
384
493
|
progress = 30;
|
|
385
494
|
if (progressCallback) {
|
|
386
495
|
progressCallback(progress);
|
|
387
496
|
}
|
|
388
497
|
|
|
389
498
|
// Read DG2 (Face Image)
|
|
499
|
+
const dg2Done = stepTimer();
|
|
390
500
|
const dg2InputStream = passportService.getInputStream(EIDService.EF_DG2);
|
|
391
501
|
await dg2InputStream.init();
|
|
392
502
|
InputStream.onRead((totalBytesRead, fileLength) => {
|
|
@@ -399,7 +509,14 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
399
509
|
let imageAsBase64 = '';
|
|
400
510
|
let mimeType = '';
|
|
401
511
|
const allFaceImageInfos = [];
|
|
402
|
-
|
|
512
|
+
let faceInfos;
|
|
513
|
+
try {
|
|
514
|
+
faceInfos = await dg2File.getFaceInfos();
|
|
515
|
+
dg2Done('readDG2', true);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
dg2Done('readDG2', false, e);
|
|
518
|
+
throw e;
|
|
519
|
+
}
|
|
403
520
|
for (let faceInfo of faceInfos) {
|
|
404
521
|
allFaceImageInfos.push(...faceInfo.getFaceImageInfos());
|
|
405
522
|
}
|
|
@@ -418,12 +535,15 @@ const eidReader = async (documentNumber, dateOfBirth, dateOfExpiry, progressCall
|
|
|
418
535
|
if (progressCallback) {
|
|
419
536
|
progressCallback(progress);
|
|
420
537
|
}
|
|
538
|
+
diagnostics.nfcResult(true);
|
|
421
539
|
return {
|
|
422
540
|
mrz: mrzInfo,
|
|
423
541
|
faceImage: imageAsBase64,
|
|
424
542
|
mimeType: mimeType
|
|
425
543
|
};
|
|
426
544
|
} catch (error) {
|
|
545
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
546
|
+
diagnostics.nfcResult(false, categorizeNfcError(msg));
|
|
427
547
|
debugLog('EID', 'Error reading passport data', error);
|
|
428
548
|
} finally {
|
|
429
549
|
await passportService.close();
|
|
@@ -118,6 +118,13 @@ Covered by the "real letters in names are not eaten" tests in `mrz.utils.test.ts
|
|
|
118
118
|
30–44-char MRZ line below that floor, so the native pipeline **crops to the
|
|
119
119
|
on-screen scan frame and upscales + grayscale/contrast-enhances** before OCR.
|
|
120
120
|
The MRZ band must be reasonably filled within the on-screen guide.
|
|
121
|
+
- Two distinct regions exist in the native code (don't confuse them):
|
|
122
|
+
the **MRZ-OCR crop** (`buildScanFrameRoiImage`: top 25% / bottom 12% / sides
|
|
123
|
+
4%, upscaled ≤2.5× to ≤2200px, grayscale, contrast 1.6×) and the
|
|
124
|
+
**brightness-sampling region** (the on-screen guide: 36% / 36% / 5%). Both are
|
|
125
|
+
the same on iOS and Android. The diagnostic report records the measured MRZ
|
|
126
|
+
band pixel height so you can tell when a device leaves the band below the
|
|
127
|
+
~16 px/char floor.
|
|
121
128
|
- **Glare / lamination.** Heavy glare on a glossy laminated card still degrades
|
|
122
129
|
OCR; the enhancement helps but does not eliminate it. Steadying the card and
|
|
123
130
|
reducing direct glare improves convergence.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the support diagnostic report from a scan session.
|
|
5
|
+
*
|
|
6
|
+
* Produces three things the result screen turns into a reviewable email:
|
|
7
|
+
* - `diagnostics.json` — technical signals (device, camera, MRZ, NFC steps).
|
|
8
|
+
* - `scan-data.json` — the scanned identity data + raw MRZ (PII; the user
|
|
9
|
+
* reviews it in the draft and chooses to send).
|
|
10
|
+
* - a short, size-bounded plain-text BODY summary that always fits an email.
|
|
11
|
+
*
|
|
12
|
+
* The full detail lives in the JSON attachments; the email body is intentionally
|
|
13
|
+
* compact so the draft opens reliably on every mail client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** ICAO 9303 7-3-1 weighted check digit (local copy; mirrors mrz.utils). */
|
|
17
|
+
const icaoCheckDigit = str => {
|
|
18
|
+
const weights = [7, 3, 1];
|
|
19
|
+
let sum = 0;
|
|
20
|
+
for (let i = 0; i < str.length; i++) {
|
|
21
|
+
const c = str[i];
|
|
22
|
+
const v = c === '<' ? 0 : c >= '0' && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
|
|
23
|
+
sum = (sum + weights[i % 3] * v) % 10;
|
|
24
|
+
}
|
|
25
|
+
return String(sum);
|
|
26
|
+
};
|
|
27
|
+
/** Compute per-field check-digit pass/fail for a TD1 MRZ (best-effort). */
|
|
28
|
+
const computeTd1CheckDigits = mrzText => {
|
|
29
|
+
if (!mrzText) return undefined;
|
|
30
|
+
const lines = mrzText.split('\n').map(l => l.trim()).filter(Boolean);
|
|
31
|
+
if (lines.length < 2 || lines[0].length < 30 || lines[1].length < 30) {
|
|
32
|
+
return undefined; // only TD1 handled here; other formats omitted from summary
|
|
33
|
+
}
|
|
34
|
+
const l1 = lines[0];
|
|
35
|
+
const l2 = lines[1];
|
|
36
|
+
const result = {};
|
|
37
|
+
const check = (data, printed) => icaoCheckDigit(data) === printed ? 'pass' : 'fail';
|
|
38
|
+
|
|
39
|
+
// Turkish convention: a doc number may carry a leading serial LETTER, and the
|
|
40
|
+
// doc-number check digit is computed over the DIGITS ONLY (letter dropped).
|
|
41
|
+
// The TD1 composite then also diverges from strict ICAO for such cards (no
|
|
42
|
+
// interpretation of the letter reproduces it). To avoid reporting a misleading
|
|
43
|
+
// "fail" on a card the SDK legitimately accepted, detect the letter-prefix and
|
|
44
|
+
// treat both the doc-number and composite as validated under that convention.
|
|
45
|
+
const docData = l1.slice(5, 14);
|
|
46
|
+
const docPrinted = l1[14];
|
|
47
|
+
const hasLetterPrefix = /^[A-Z]/.test(docData);
|
|
48
|
+
const strictDocOk = icaoCheckDigit(docData) === docPrinted;
|
|
49
|
+
const turkishDocOk = hasLetterPrefix && icaoCheckDigit(docData.slice(1)) === docPrinted;
|
|
50
|
+
const docOk = strictDocOk || turkishDocOk;
|
|
51
|
+
result.documentNumber = docOk ? 'pass' : 'fail';
|
|
52
|
+
result.birthDate = check(l2.slice(0, 6), l2[6]);
|
|
53
|
+
result.expiry = check(l2.slice(8, 14), l2[14]);
|
|
54
|
+
const composite = l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
|
|
55
|
+
const compStrict = check(composite, l2[29]);
|
|
56
|
+
// If the strict composite fails ONLY because of the Turkish letter-prefix
|
|
57
|
+
// doc number (which itself passed under the digits-only rule), don't surface a
|
|
58
|
+
// misleading failure — the SDK accepts these cards.
|
|
59
|
+
result.composite = compStrict === 'fail' && hasLetterPrefix && docOk ? 'pass' : compStrict;
|
|
60
|
+
// The Turkish convention was the deciding factor only when strict ICAO would
|
|
61
|
+
// have failed the doc number but the digits-only rule passes it.
|
|
62
|
+
const turkishLetterPrefix = !strictDocOk && turkishDocOk;
|
|
63
|
+
return {
|
|
64
|
+
checkDigits: result,
|
|
65
|
+
turkishLetterPrefix
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const MAX_BODY_BYTES = 4096;
|
|
69
|
+
const yesNo = b => b ? 'yes' : 'no';
|
|
70
|
+
|
|
71
|
+
/** Assemble the full report bundle. Pure — no I/O, no native calls. */
|
|
72
|
+
export const buildDiagnosticReport = params => {
|
|
73
|
+
const {
|
|
74
|
+
diag,
|
|
75
|
+
device,
|
|
76
|
+
scan,
|
|
77
|
+
sdkVersion,
|
|
78
|
+
sessionId
|
|
79
|
+
} = params;
|
|
80
|
+
const computed = computeTd1CheckDigits(scan.mrzText);
|
|
81
|
+
const checkDigits = diag.mrz.checkDigits ?? computed?.checkDigits;
|
|
82
|
+
// Set the Turkish-letter-prefix flag from the final accepted MRZ (the collector
|
|
83
|
+
// can't know it during scanning; the report does, from the MRZ text).
|
|
84
|
+
const turkishLetterPrefixApplied = diag.mrz.turkishLetterPrefixApplied || computed?.turkishLetterPrefix === true;
|
|
85
|
+
|
|
86
|
+
// --- diagnostics.json (technical, low-PII) ---
|
|
87
|
+
const diagnosticsObj = {
|
|
88
|
+
schemaVersion: diag.schemaVersion,
|
|
89
|
+
generatedAt: diag.generatedAt,
|
|
90
|
+
sessionId: sessionId ?? null,
|
|
91
|
+
sdk: {
|
|
92
|
+
platform: device.platform,
|
|
93
|
+
sdkVersion,
|
|
94
|
+
buildNumber: device.buildNumber
|
|
95
|
+
},
|
|
96
|
+
device,
|
|
97
|
+
scan: {
|
|
98
|
+
documentType: scan.documentType ?? diag.documentType ?? null,
|
|
99
|
+
dataSource: scan.dataSource ?? diag.dataSource ?? null,
|
|
100
|
+
totalDurationMs: diag.totalDurationMs,
|
|
101
|
+
camera: diag.camera
|
|
102
|
+
},
|
|
103
|
+
mrz: {
|
|
104
|
+
...diag.mrz,
|
|
105
|
+
checkDigits: checkDigits ?? null,
|
|
106
|
+
turkishLetterPrefixApplied
|
|
107
|
+
},
|
|
108
|
+
nfc: diag.nfc,
|
|
109
|
+
errors: diag.errors
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// --- scan-data.json (PII; user reviews before sending) ---
|
|
113
|
+
const scanDataObj = {
|
|
114
|
+
documentType: scan.documentType ?? diag.documentType ?? null,
|
|
115
|
+
dataSource: scan.dataSource ?? diag.dataSource ?? null,
|
|
116
|
+
mrzText: scan.mrzText ?? null,
|
|
117
|
+
fields: scan.mrzFields ?? null,
|
|
118
|
+
barcodeValue: scan.barcodeValue ?? null
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// --- compact email body (always under MAX_BODY_BYTES) ---
|
|
122
|
+
const nfc = diag.nfc;
|
|
123
|
+
const nfcLine = nfc.attempted ? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${nfc.succeeded ? 'success' : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'})`}` : 'not attempted';
|
|
124
|
+
const checksLine = checkDigits ? Object.entries(checkDigits).map(([k, v]) => `${k}:${v}`).join(' ') : 'n/a';
|
|
125
|
+
const body = ['TRUSTCHEX SCAN REPORT', `Device: ${device.brand} ${device.model} · ${device.systemName} ${device.systemVersion}${device.apiLevel ? ` (API ${device.apiLevel})` : ''}`, `SDK: ${sdkVersion} (build ${device.buildNumber}) · ${device.manufacturer} · ${device.deviceId}`, `Document: ${scanDataObj.documentType ?? 'unknown'} · source: ${scanDataObj.dataSource ?? 'unknown'}`, `MRZ: ${diag.mrz.reachedStable ? 'stable' : 'NOT stable'} after ${diag.mrz.framesProcessed} frames · checks: ${checksLine}`, `Camera: ${diag.camera.frames} frames · brightness ${diag.camera.avgBrightness} (low:${yesNo(diag.camera.hadLowBrightness)} blur:${yesNo(diag.camera.hadBlurryFrames)})`, `NFC: ${nfcLine}`, sessionId ? `Session: ${sessionId}` : '', '', 'Describe the issue here:', '', '', '— Full diagnostics and document images are attached. Please review before sending; do not edit the attachments. —'].filter(Boolean).join('\n');
|
|
126
|
+
const subject = `Trustchex scan report — ${scanDataObj.documentType ?? 'doc'} — ${device.brand} ${device.model} — v${sdkVersion}${sessionId ? ` — ${sessionId}` : ''}`;
|
|
127
|
+
return {
|
|
128
|
+
diagnosticsJson: JSON.stringify(diagnosticsObj, null, 2),
|
|
129
|
+
scanDataJson: JSON.stringify(scanDataObj, null, 2),
|
|
130
|
+
body: body.length > MAX_BODY_BYTES ? body.slice(0, MAX_BODY_BYTES - 1) + '…' : body,
|
|
131
|
+
subject
|
|
132
|
+
};
|
|
133
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Diagnostics collector for the "Report a scanning issue" feature.
|
|
5
|
+
*
|
|
6
|
+
* Accumulates a PII-light technical snapshot of a single scan session — camera
|
|
7
|
+
* quality, MRZ consensus, and (the highest-value, most device-specific part) a
|
|
8
|
+
* structured per-step NFC/eID read log. The result screen serialises this into
|
|
9
|
+
* the diagnostic email the user reviews and sends to support.
|
|
10
|
+
*
|
|
11
|
+
* Design notes:
|
|
12
|
+
* - This is a MODULE-LEVEL SINGLETON so the deep camera/EID call stack can record
|
|
13
|
+
* into it without threading a prop/context through every layer. Call `reset()`
|
|
14
|
+
* at the start of a scan flow.
|
|
15
|
+
* - It stores only TECHNICAL signals (counts, timings, brightness, APDU status
|
|
16
|
+
* words, pass/fail). It deliberately holds NO identity data, images, MRZ text,
|
|
17
|
+
* keys, or nonces — the scanned identity/images shown to the user come from the
|
|
18
|
+
* result screen's own state, not from here, and are attached separately with
|
|
19
|
+
* the user's explicit review in the email draft.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A single step of the NFC/eID read, with its outcome and timing. */
|
|
23
|
+
|
|
24
|
+
const emptyCamera = () => ({
|
|
25
|
+
frames: 0,
|
|
26
|
+
frameW: 0,
|
|
27
|
+
frameH: 0,
|
|
28
|
+
avgBrightness: 0,
|
|
29
|
+
minBrightness: 0,
|
|
30
|
+
maxBrightness: 0,
|
|
31
|
+
hadLowBrightness: false,
|
|
32
|
+
hadBlurryFrames: false
|
|
33
|
+
});
|
|
34
|
+
const emptyMrz = () => ({
|
|
35
|
+
framesProcessed: 0,
|
|
36
|
+
reachedStable: false,
|
|
37
|
+
stabilityFrames: 0,
|
|
38
|
+
minContestedMargin: 0,
|
|
39
|
+
turkishLetterPrefixApplied: false
|
|
40
|
+
});
|
|
41
|
+
const emptyNfc = () => ({
|
|
42
|
+
attempted: false,
|
|
43
|
+
chipDetected: false,
|
|
44
|
+
authProtocol: 'none',
|
|
45
|
+
paceFellBackToBac: false,
|
|
46
|
+
steps: [],
|
|
47
|
+
retryCount: 0,
|
|
48
|
+
succeeded: false
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Accumulates one scan session's diagnostics. A single shared instance is
|
|
53
|
+
* exported below; tests can construct their own.
|
|
54
|
+
*/
|
|
55
|
+
export class DiagnosticsCollector {
|
|
56
|
+
startedAtMs = 0;
|
|
57
|
+
camera = emptyCamera();
|
|
58
|
+
mrz = emptyMrz();
|
|
59
|
+
nfc = emptyNfc();
|
|
60
|
+
errors = [];
|
|
61
|
+
|
|
62
|
+
/** Begin a fresh session — call when a scan flow starts. */
|
|
63
|
+
reset(nowMs) {
|
|
64
|
+
this.startedAtMs = nowMs;
|
|
65
|
+
this.documentType = undefined;
|
|
66
|
+
this.dataSource = undefined;
|
|
67
|
+
this.camera = emptyCamera();
|
|
68
|
+
this.mrz = emptyMrz();
|
|
69
|
+
this.nfc = emptyNfc();
|
|
70
|
+
this.errors = [];
|
|
71
|
+
}
|
|
72
|
+
setDocument(documentType, dataSource) {
|
|
73
|
+
if (documentType) this.documentType = documentType;
|
|
74
|
+
if (dataSource) this.dataSource = dataSource;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merge a camera snapshot (called periodically as frames arrive). */
|
|
78
|
+
setCamera(c) {
|
|
79
|
+
this.camera = {
|
|
80
|
+
...this.camera,
|
|
81
|
+
...c
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Merge MRZ consensus metrics. `reachedStable` is STICKY (once true, stays
|
|
86
|
+
* true) so a later frame's flicker can't unset that the scan did stabilise;
|
|
87
|
+
* `maxMrzBandHeightPx` keeps the largest seen. */
|
|
88
|
+
setMrz(m) {
|
|
89
|
+
const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
|
|
90
|
+
const maxMrzBandHeightPx = Math.max(this.mrz.maxMrzBandHeightPx ?? 0, m.maxMrzBandHeightPx ?? 0) || undefined;
|
|
91
|
+
this.mrz = {
|
|
92
|
+
...this.mrz,
|
|
93
|
+
...m,
|
|
94
|
+
reachedStable,
|
|
95
|
+
maxMrzBandHeightPx
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- NFC: the structured per-step log ----
|
|
100
|
+
|
|
101
|
+
nfcAttemptStarted() {
|
|
102
|
+
// A fresh attempt: bump retry count if a previous attempt left steps.
|
|
103
|
+
if (this.nfc.attempted) this.nfc.retryCount += 1;
|
|
104
|
+
this.nfc.attempted = true;
|
|
105
|
+
}
|
|
106
|
+
nfcChipDetected() {
|
|
107
|
+
this.nfc.chipDetected = true;
|
|
108
|
+
}
|
|
109
|
+
nfcSetAuth(protocol, oid) {
|
|
110
|
+
this.nfc.authProtocol = protocol;
|
|
111
|
+
if (oid) this.nfc.paceOid = oid;
|
|
112
|
+
}
|
|
113
|
+
nfcPaceFellBackToBac(reason) {
|
|
114
|
+
this.nfc.paceFellBackToBac = true;
|
|
115
|
+
if (reason && !this.nfc.paceFallbackReason) {
|
|
116
|
+
this.nfc.paceFallbackReason = reason;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Record one completed step. Keep messages short and free of PII. */
|
|
121
|
+
nfcStep(step) {
|
|
122
|
+
this.nfc.steps.push(step);
|
|
123
|
+
if (!step.ok && !this.nfc.failureStep) {
|
|
124
|
+
this.nfc.failureStep = step.step;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
nfcResult(succeeded, errorCategory) {
|
|
128
|
+
this.nfc.succeeded = succeeded;
|
|
129
|
+
if (succeeded) {
|
|
130
|
+
// The read ultimately succeeded — transient per-attempt failures (e.g. a
|
|
131
|
+
// recovered DG2 retry, or the expected EF.CardAccess 6982 on a BAC-only
|
|
132
|
+
// card) must NOT be reported as the outcome. Clear the failure markers.
|
|
133
|
+
this.nfc.failureStep = undefined;
|
|
134
|
+
this.nfc.errorCategory = undefined;
|
|
135
|
+
} else if (errorCategory) {
|
|
136
|
+
this.nfc.errorCategory = errorCategory;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
addError(e) {
|
|
140
|
+
this.errors.push(e);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build the serialisable snapshot. */
|
|
144
|
+
snapshot(nowMs, isoNow) {
|
|
145
|
+
return {
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
generatedAt: isoNow,
|
|
148
|
+
documentType: this.documentType,
|
|
149
|
+
dataSource: this.dataSource,
|
|
150
|
+
totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
|
|
151
|
+
camera: {
|
|
152
|
+
...this.camera
|
|
153
|
+
},
|
|
154
|
+
mrz: {
|
|
155
|
+
...this.mrz
|
|
156
|
+
},
|
|
157
|
+
nfc: {
|
|
158
|
+
...this.nfc,
|
|
159
|
+
steps: this.nfc.steps.map(s => ({
|
|
160
|
+
...s
|
|
161
|
+
}))
|
|
162
|
+
},
|
|
163
|
+
errors: this.errors.map(e => ({
|
|
164
|
+
...e
|
|
165
|
+
}))
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Shared session-scoped collector used across the scan flow. */
|
|
171
|
+
export const diagnostics = new DiagnosticsCollector();
|
|
@@ -40,6 +40,12 @@ const FORMAT_GEOMETRY = {
|
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
42
|
const MRZ_CHAR = /^[A-Z0-9<]$/;
|
|
43
|
+
/**
|
|
44
|
+
* Floor applied to a frame's confidence multiplier. A frame ML Kit was very
|
|
45
|
+
* unsure about still contributes (it may carry the only reading of a cell), but
|
|
46
|
+
* at a fraction of a confident frame's weight.
|
|
47
|
+
*/
|
|
48
|
+
const MIN_CONFIDENCE_WEIGHT = 0.25;
|
|
43
49
|
/**
|
|
44
50
|
* Reconstructs fixed-width MRZ candidate line-sets for a frame's raw OCR text.
|
|
45
51
|
* Reuses the same reconstruction the single-frame validator uses, so the
|
|
@@ -181,7 +187,12 @@ export class MRZFrameAggregator {
|
|
|
181
187
|
* consensus is returned.
|
|
182
188
|
*/
|
|
183
189
|
addFrame(input) {
|
|
184
|
-
const
|
|
190
|
+
const baseWeight = input.weight && input.weight > 0 ? input.weight : 1;
|
|
191
|
+
// Fold OCR confidence into the weight (clamped to [floor, 1]). Absent
|
|
192
|
+
// confidence leaves the weight unchanged, so callers without per-symbol
|
|
193
|
+
// confidence (e.g. iOS ML Kit) keep the prior sharpness-only behaviour.
|
|
194
|
+
const confidenceFactor = typeof input.confidence === 'number' && Number.isFinite(input.confidence) ? Math.min(1, Math.max(MIN_CONFIDENCE_WEIGHT, input.confidence)) : 1;
|
|
195
|
+
const weight = baseWeight * confidenceFactor;
|
|
185
196
|
const lines = linesForFrame(input.text);
|
|
186
197
|
if (lines) {
|
|
187
198
|
// Determine the format this frame's lines fit (by line count + width).
|
|
@@ -244,18 +255,30 @@ export class MRZFrameAggregator {
|
|
|
244
255
|
* is what turns "looks valid this frame" into "the camera agrees across frames".
|
|
245
256
|
*/
|
|
246
257
|
consensusIsConfident(format) {
|
|
258
|
+
return this.minContestedMargin(format) >= this.minMargin;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Smallest winning vote margin across all CONTESTED cells (where >1 distinct
|
|
263
|
+
* char was voted). Returns Infinity when no cell is contested. Used both to
|
|
264
|
+
* gate confidence and to surface a diagnostic of how narrowly a look-alike was
|
|
265
|
+
* resolved at acceptance.
|
|
266
|
+
*/
|
|
267
|
+
minContestedMargin(format) {
|
|
247
268
|
const geom = FORMAT_GEOMETRY[format];
|
|
248
269
|
const tally = this.tallies[format];
|
|
270
|
+
let min = Infinity;
|
|
249
271
|
for (let li = 0; li < geom.lines; li++) {
|
|
250
272
|
for (let ci = 0; ci < geom.width; ci++) {
|
|
251
273
|
const cell = tally[li][ci];
|
|
252
274
|
const weights = Object.values(cell);
|
|
253
275
|
if (weights.length < 2) continue; // uncontested cell — no ambiguity
|
|
254
276
|
weights.sort((a, b) => b - a);
|
|
255
|
-
|
|
277
|
+
const margin = weights[0] - weights[1];
|
|
278
|
+
if (margin < min) min = margin;
|
|
256
279
|
}
|
|
257
280
|
}
|
|
258
|
-
return
|
|
281
|
+
return min;
|
|
259
282
|
}
|
|
260
283
|
|
|
261
284
|
/** Read out the winning character per cell for a format. */
|
|
@@ -293,7 +316,9 @@ export class MRZFrameAggregator {
|
|
|
293
316
|
mrz: null,
|
|
294
317
|
validation: null,
|
|
295
318
|
frames: this.frameCount,
|
|
296
|
-
stable: false
|
|
319
|
+
stable: false,
|
|
320
|
+
stableStreak: 0,
|
|
321
|
+
minContestedMargin: Infinity
|
|
297
322
|
};
|
|
298
323
|
}
|
|
299
324
|
let bestMrz = null;
|
|
@@ -338,7 +363,9 @@ export class MRZFrameAggregator {
|
|
|
338
363
|
mrz: bestMrz,
|
|
339
364
|
validation: bestValidation,
|
|
340
365
|
frames: this.frameCount,
|
|
341
|
-
stable: bestValid && confident && this.stableStreak >= this.stabilityTarget
|
|
366
|
+
stable: bestValid && confident && this.stableStreak >= this.stabilityTarget,
|
|
367
|
+
stableStreak: this.stableStreak,
|
|
368
|
+
minContestedMargin: bestFormat != null ? this.minContestedMargin(bestFormat) : Infinity
|
|
342
369
|
};
|
|
343
370
|
}
|
|
344
371
|
}
|