@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.
Files changed (61) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +57 -3
  2. package/ios/Camera/TrustchexCameraView.swift +20 -15
  3. package/lib/module/Screens/Static/ResultScreen.js +42 -6
  4. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  5. package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
  6. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  7. package/lib/module/Shared/EIDReader/eidReader.js +149 -29
  8. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  9. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  10. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  11. package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
  12. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  13. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  14. package/lib/module/Translation/Resources/en.js +5 -0
  15. package/lib/module/Translation/Resources/tr.js +5 -0
  16. package/lib/module/Trustchex.js +20 -4
  17. package/lib/module/version.js +1 -1
  18. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  19. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  20. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  21. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  23. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  26. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  29. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  30. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  31. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  32. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +33 -0
  33. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  35. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  37. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  38. package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
  39. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  40. package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -0
  41. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  42. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  43. package/lib/typescript/src/version.d.ts +1 -1
  44. package/package.json +5 -1
  45. package/src/Screens/Static/ResultScreen.tsx +46 -1
  46. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  47. package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
  48. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  49. package/src/Shared/Components/NavigationManager.tsx +7 -7
  50. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  51. package/src/Shared/EIDReader/eidReader.ts +166 -46
  52. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  53. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  54. package/src/Shared/Libs/diagnostics.ts +251 -0
  55. package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
  56. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  57. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  58. package/src/Translation/Resources/en.ts +6 -0
  59. package/src/Translation/Resources/tr.ts +6 -0
  60. package/src/Trustchex.tsx +26 -3
  61. 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 weight = input.weight && input.weight > 0 ? input.weight : 1;
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
- if (weights[0] - weights[1] < this.minMargin) return false;
374
+ const margin = weights[0] - weights[1];
375
+ if (margin < min) min = margin;
323
376
  }
324
377
  }
325
- return true;
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:',