@smileid/usesmileid 12.0.1 → 12.0.2

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 (160) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/camera/camera_defaults.d.ts +2 -0
  4. package/dist/camera/camera_defaults.d.ts.map +1 -1
  5. package/dist/camera/camera_defaults.js +2 -0
  6. package/dist/camera/camera_defaults.js.map +1 -1
  7. package/dist/core/exception/flow_exceptions.d.ts +4 -2
  8. package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
  9. package/dist/core/exception/flow_exceptions.js +3 -2
  10. package/dist/core/exception/flow_exceptions.js.map +1 -1
  11. package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
  12. package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
  13. package/dist/core/exception/missing_job_type_exception.js +2 -4
  14. package/dist/core/exception/missing_job_type_exception.js.map +1 -1
  15. package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
  16. package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
  17. package/dist/data/api/smile_id_api.d.ts +4 -0
  18. package/dist/data/api/smile_id_api.d.ts.map +1 -1
  19. package/dist/data/api/smile_id_api.js +30 -27
  20. package/dist/data/api/smile_id_api.js.map +1 -1
  21. package/dist/data/model/job_request.d.ts +28 -12
  22. package/dist/data/model/job_request.d.ts.map +1 -1
  23. package/dist/data/model/token_payload.d.ts +78 -0
  24. package/dist/data/model/token_payload.d.ts.map +1 -0
  25. package/dist/data/model/token_payload.js +132 -0
  26. package/dist/data/model/token_payload.js.map +1 -0
  27. package/dist/data/model/user_details_payload.d.ts +39 -0
  28. package/dist/data/model/user_details_payload.d.ts.map +1 -0
  29. package/dist/data/model/user_details_payload.js +53 -0
  30. package/dist/data/model/user_details_payload.js.map +1 -0
  31. package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
  32. package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
  33. package/dist/data/util/use_smile_id_token_decoder.js +20 -8
  34. package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
  35. package/dist/l10n/strings_en.d.ts +4 -1
  36. package/dist/l10n/strings_en.d.ts.map +1 -1
  37. package/dist/l10n/strings_en.js +4 -1
  38. package/dist/l10n/strings_en.js.map +1 -1
  39. package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
  40. package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
  41. package/dist/l10n/use_smile_id_localizations.js +59 -3
  42. package/dist/l10n/use_smile_id_localizations.js.map +1 -1
  43. package/dist/metadata/metadata_configuration_mappers.js +1 -1
  44. package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
  45. package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
  46. package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
  47. package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
  48. package/dist/presentation/components/use_smile_id_button.js +8 -2
  49. package/dist/presentation/components/use_smile_id_button.js.map +1 -1
  50. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
  51. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
  52. package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
  53. package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
  54. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
  55. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
  56. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
  57. package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
  58. package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
  59. package/dist/presentation/flow/flow_navigation_manager.js +77 -45
  60. package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
  61. package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
  62. package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
  63. package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
  64. package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
  65. package/dist/presentation/flow/use_smile_id_flow.js +35 -8
  66. package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
  67. package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
  68. package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
  69. package/dist/presentation/flow/validation/flow_validator.js +19 -27
  70. package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
  71. package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
  72. package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
  73. package/dist/presentation/flow/validation/job_type_validator.js +52 -27
  74. package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
  75. package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
  76. package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
  77. package/dist/presentation/flow/validation/user_details_validator.js +40 -0
  78. package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
  79. package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
  80. package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
  81. package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
  82. package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
  83. package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
  84. package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
  85. package/package.json +3 -3
  86. package/src/__tests__/react_native_mock.tsx +22 -0
  87. package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
  88. package/src/camera/__tests__/camera_permissions.test.ts +72 -0
  89. package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
  90. package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
  91. package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
  92. package/src/camera/camera_defaults.ts +3 -0
  93. package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
  94. package/src/core/exception/flow_exceptions.test.ts +10 -0
  95. package/src/core/exception/flow_exceptions.ts +3 -2
  96. package/src/core/exception/missing_job_type_exception.ts +2 -4
  97. package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
  98. package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
  99. package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
  100. package/src/data/api/smile_id_api.ts +36 -49
  101. package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
  102. package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
  103. package/src/data/model/__tests__/token_payload.test.ts +224 -0
  104. package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
  105. package/src/data/model/job_request.ts +27 -12
  106. package/src/data/model/token_payload.ts +182 -0
  107. package/src/data/model/user_details_payload.ts +84 -0
  108. package/src/data/repository/__tests__/network_retries.test.ts +1 -1
  109. package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
  110. package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
  111. package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
  112. package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
  113. package/src/data/util/use_smile_id_token_decoder.ts +29 -8
  114. package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
  115. package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
  116. package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
  117. package/src/l10n/strings_en.ts +5 -1
  118. package/src/l10n/use_smile_id_localizations.ts +80 -3
  119. package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
  120. package/src/metadata/__tests__/geolocation.test.ts +150 -0
  121. package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
  122. package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
  123. package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
  124. package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
  125. package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
  126. package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
  127. package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
  128. package/src/metadata/metadata_configuration_mappers.ts +1 -1
  129. package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
  130. package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
  131. package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
  132. package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
  133. package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
  134. package/src/presentation/components/use_smile_id_button.tsx +7 -2
  135. package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
  136. package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
  137. package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
  138. package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
  139. package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
  140. package/src/presentation/flow/flow_navigation_manager.ts +102 -92
  141. package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
  142. package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
  143. package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
  144. package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
  145. package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
  146. package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
  147. package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
  148. package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
  149. package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
  150. package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
  151. package/src/presentation/flow/validation/flow_validator.ts +30 -31
  152. package/src/presentation/flow/validation/job_type_validator.ts +73 -39
  153. package/src/presentation/flow/validation/user_details_validator.ts +53 -0
  154. package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
  155. package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
  156. package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
  157. package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
  158. package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
  159. package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
  160. package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
@@ -0,0 +1,338 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as React from "react";
3
+ import { create as createRenderer, act } from "react-test-renderer";
4
+
5
+ // `vi.mock` is hoisted above the imports, so anything its factory closes over
6
+ // must be created with `vi.hoisted` or it is still in the TDZ when the factory
7
+ // runs.
8
+ const { screenProps } = vi.hoisted(() => ({
9
+ screenProps: { current: {} as Record<string, unknown> },
10
+ }));
11
+
12
+ // Stand in for the leaf screen so these tests exercise the orchestration
13
+ // (submission kick-off, state mapping, exit routing) rather than re-testing
14
+ // the processing UI, which has its own component suite.
15
+ vi.mock("../../../screen/use_smile_id_processing_screen", () => ({
16
+ UseSmileIDProcessingScreen: (props: Record<string, unknown>) => {
17
+ screenProps.current = props;
18
+ return null;
19
+ },
20
+ }));
21
+
22
+ import { OrchestratedProcessingScreen } from "../orchestrated_processing_screen";
23
+ import { FlowNavigationProvider } from "../../flow_navigation_context";
24
+ import {
25
+ NetworkServerException,
26
+ NetworkTransportException,
27
+ NetworkUnknownException,
28
+ ProcessingCancelledException,
29
+ } from "../../../../core/exception";
30
+ import { JobType } from "../../../../core/models/job_type";
31
+ import { ProcessingConfigBuilder } from "../../dsl/builder/screens_builder";
32
+ import type { FlowNavigationManager } from "../../flow_navigation_manager";
33
+ import type { JobSubmissionState } from "../../job_submission_state";
34
+
35
+ type Fn = ReturnType<typeof vi.fn>;
36
+
37
+ type FakeManager = FlowNavigationManager & {
38
+ beginSubmissionIfNeeded: Fn;
39
+ retrySubmission: Fn;
40
+ navigateToNext: Fn;
41
+ exit: Fn;
42
+ };
43
+
44
+ type ManagerOverrides = {
45
+ readonly jobType?: number;
46
+ readonly submissionState?: JobSubmissionState;
47
+ readonly frontDocumentPath?: string;
48
+ readonly selfiePath?: string;
49
+ readonly documentFrameAspectRatio?: number;
50
+ };
51
+
52
+ function makeManager(overrides: ManagerOverrides = {}): FakeManager {
53
+ return {
54
+ beginSubmissionIfNeeded: vi.fn(),
55
+ retrySubmission: vi.fn(),
56
+ navigateToNext: vi.fn(),
57
+ navigateBack: vi.fn(),
58
+ exit: vi.fn(),
59
+ jobSubmissionState: overrides.submissionState ?? { kind: "notAttempted" },
60
+ documentFrameAspectRatio: overrides.documentFrameAspectRatio,
61
+ captured: {
62
+ ...(overrides.frontDocumentPath !== undefined
63
+ ? { frontDocumentPath: overrides.frontDocumentPath }
64
+ : {}),
65
+ ...(overrides.selfiePath !== undefined
66
+ ? { selfiePath: overrides.selfiePath }
67
+ : {}),
68
+ },
69
+ configuration: {
70
+ network:
71
+ overrides.jobType !== undefined
72
+ ? { config: { jobType: overrides.jobType } }
73
+ : undefined,
74
+ },
75
+ } as unknown as FakeManager;
76
+ }
77
+
78
+ function makeConfiguration(
79
+ overrides: Partial<ProcessingConfigBuilder> = {},
80
+ ): ProcessingConfigBuilder {
81
+ return Object.assign(new ProcessingConfigBuilder(), overrides);
82
+ }
83
+
84
+ function renderWith(
85
+ manager: FlowNavigationManager,
86
+ configuration: ProcessingConfigBuilder = makeConfiguration(),
87
+ ) {
88
+ act(() => {
89
+ createRenderer(
90
+ <FlowNavigationProvider manager={manager}>
91
+ <OrchestratedProcessingScreen configuration={configuration} />
92
+ </FlowNavigationProvider>,
93
+ );
94
+ });
95
+ }
96
+
97
+ beforeEach(() => {
98
+ screenProps.current = {};
99
+ });
100
+
101
+ describe("OrchestratedProcessingScreen — submission lifecycle", () => {
102
+ it("kicks off submission exactly once on mount", () => {
103
+ const manager = makeManager();
104
+ renderWith(manager);
105
+
106
+ expect(manager.beginSubmissionIfNeeded).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("retry re-fires submission rather than navigating", () => {
110
+ const manager = makeManager({
111
+ submissionState: { kind: "failure", error: new Error("network down") },
112
+ });
113
+ renderWith(manager);
114
+
115
+ (screenProps.current.onRetry as () => void)();
116
+
117
+ expect(manager.retrySubmission).toHaveBeenCalledTimes(1);
118
+ expect(manager.navigateToNext).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("continue advances the flow", () => {
122
+ const manager = makeManager();
123
+ renderWith(manager);
124
+
125
+ (screenProps.current.onContinue as () => void)();
126
+
127
+ expect(manager.navigateToNext).toHaveBeenCalledTimes(1);
128
+ });
129
+
130
+ it("exit ends the flow with a typed ProcessingCancelledException", () => {
131
+ const manager = makeManager();
132
+ renderWith(manager);
133
+
134
+ (screenProps.current.onExit as () => void)();
135
+
136
+ const [[reason]] = manager.exit.mock.calls as [
137
+ [{ kind: string; error: unknown }],
138
+ ];
139
+ expect(reason.kind).toBe("failed");
140
+ expect(reason.error).toBeInstanceOf(ProcessingCancelledException);
141
+ expect(manager.navigateToNext).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("exit after a failed submission delivers that failure, not a cancellation", () => {
145
+ const submissionError = new Error("401 — invalid API key");
146
+ const manager = makeManager({
147
+ submissionState: { kind: "failure", error: submissionError },
148
+ });
149
+ renderWith(manager);
150
+
151
+ (screenProps.current.onExit as () => void)();
152
+
153
+ const [[reason]] = manager.exit.mock.calls as [
154
+ [{ kind: string; error: unknown }],
155
+ ];
156
+ expect(reason.kind).toBe("failed");
157
+ expect(reason.error).toBe(submissionError);
158
+ });
159
+
160
+ it("exit while the job is still in flight is a cancellation", () => {
161
+ const manager = makeManager({ submissionState: { kind: "loading" } });
162
+ renderWith(manager);
163
+
164
+ (screenProps.current.onExit as () => void)();
165
+
166
+ const [[reason]] = manager.exit.mock.calls as [
167
+ [{ kind: string; error: unknown }],
168
+ ];
169
+ expect(reason.error).toBeInstanceOf(ProcessingCancelledException);
170
+ });
171
+ });
172
+
173
+ describe("OrchestratedProcessingScreen — submission state mapping", () => {
174
+ it("shows loading during the pre-submission window so the card is never empty", () => {
175
+ renderWith(makeManager({ submissionState: { kind: "notAttempted" } }));
176
+ expect(screenProps.current.state).toEqual({ kind: "loading" });
177
+ });
178
+
179
+ it("shows loading while the job is in flight", () => {
180
+ renderWith(makeManager({ submissionState: { kind: "loading" } }));
181
+ expect(screenProps.current.state).toEqual({ kind: "loading" });
182
+ });
183
+
184
+ it("surfaces the server's message on success", () => {
185
+ renderWith(
186
+ makeManager({
187
+ submissionState: {
188
+ kind: "success",
189
+ response: {
190
+ userId: "user-1",
191
+ jobId: "job-1",
192
+ status: "approved",
193
+ message: "Job approved",
194
+ },
195
+ },
196
+ }),
197
+ );
198
+
199
+ expect(screenProps.current.state).toEqual({
200
+ kind: "success",
201
+ message: "Job approved",
202
+ });
203
+ });
204
+
205
+ it("surfaces the server's own text on a server failure", () => {
206
+ renderWith(
207
+ makeManager({
208
+ submissionState: {
209
+ kind: "failure",
210
+ error: new NetworkServerException({
211
+ statusCode: 401,
212
+ message: "Invalid token",
213
+ }),
214
+ },
215
+ }),
216
+ );
217
+
218
+ expect(screenProps.current.state).toEqual({
219
+ kind: "failed",
220
+ errorMessage: "Invalid token",
221
+ });
222
+ });
223
+
224
+ it("shows the no-connection copy on a transport failure", () => {
225
+ renderWith(
226
+ makeManager({
227
+ submissionState: {
228
+ kind: "failure",
229
+ error: new NetworkTransportException({ cause: new Error("ECONNREFUSED") }),
230
+ },
231
+ }),
232
+ );
233
+
234
+ expect(screenProps.current.state).toEqual({
235
+ kind: "failed",
236
+ errorMessage: "No internet connection.",
237
+ });
238
+ });
239
+
240
+ it("shows the generic copy for unknown network failures", () => {
241
+ renderWith(
242
+ makeManager({
243
+ submissionState: {
244
+ kind: "failure",
245
+ error: new NetworkUnknownException({
246
+ cause: new Error("ENOENT /data/user/0/…/selfie.jpg"),
247
+ }),
248
+ },
249
+ }),
250
+ );
251
+
252
+ expect(screenProps.current.state).toEqual({
253
+ kind: "failed",
254
+ errorMessage: "Something went wrong. Try again.",
255
+ });
256
+ });
257
+
258
+ it("surfaces the error message on other failures", () => {
259
+ renderWith(
260
+ makeManager({
261
+ submissionState: { kind: "failure", error: new Error("upload failed") },
262
+ }),
263
+ );
264
+
265
+ expect(screenProps.current.state).toEqual({
266
+ kind: "failed",
267
+ errorMessage: "upload failed",
268
+ });
269
+ });
270
+ });
271
+
272
+ describe("OrchestratedProcessingScreen — artifact rendering", () => {
273
+ it("renders the captured front document for a document job", () => {
274
+ renderWith(
275
+ makeManager({
276
+ jobType: JobType.documentVerification,
277
+ frontDocumentPath: "file:///front.jpg",
278
+ documentFrameAspectRatio: 1.586,
279
+ }),
280
+ );
281
+
282
+ expect(screenProps.current.documentImageUri).toBe("file:///front.jpg");
283
+ expect(screenProps.current.documentAspectRatio).toBeCloseTo(1.586, 5);
284
+ expect(screenProps.current.showFaceOval).toBe(true);
285
+ });
286
+
287
+ it("omits the document aspect ratio when the manager has none", () => {
288
+ renderWith(
289
+ makeManager({
290
+ jobType: JobType.documentVerification,
291
+ frontDocumentPath: "file:///front.jpg",
292
+ }),
293
+ );
294
+
295
+ expect(screenProps.current.documentImageUri).toBe("file:///front.jpg");
296
+ expect("documentAspectRatio" in screenProps.current).toBe(false);
297
+ });
298
+
299
+ it("ignores a captured document on a selfie-only job", () => {
300
+ renderWith(
301
+ makeManager({
302
+ jobType: JobType.smartSelfieEnrollment,
303
+ frontDocumentPath: "file:///front.jpg",
304
+ selfiePath: "file:///selfie.jpg",
305
+ }),
306
+ );
307
+
308
+ expect("documentImageUri" in screenProps.current).toBe(false);
309
+ expect(screenProps.current.capturedSelfiePath).toBe("file:///selfie.jpg");
310
+ expect(screenProps.current.showFaceOval).toBe(true);
311
+ });
312
+
313
+ it("hides the face oval for a job that captures neither", () => {
314
+ renderWith(makeManager({ jobType: JobType.enhancedKyc }));
315
+
316
+ expect(screenProps.current.showFaceOval).toBe(false);
317
+ expect("capturedSelfiePath" in screenProps.current).toBe(false);
318
+ });
319
+
320
+ it("treats a flow with no network config as the unknown job type", () => {
321
+ renderWith(makeManager());
322
+
323
+ expect(screenProps.current.showFaceOval).toBe(false);
324
+ });
325
+
326
+ it("forwards the partner's progress and attribution switches", () => {
327
+ renderWith(
328
+ makeManager(),
329
+ makeConfiguration({
330
+ showProgressPercentage: false,
331
+ showAttribution: false,
332
+ }),
333
+ );
334
+
335
+ expect(screenProps.current.showProgressPercentage).toBe(false);
336
+ expect(screenProps.current.showAttribution).toBe(false);
337
+ });
338
+ });
@@ -7,7 +7,15 @@ import {
7
7
  type ProcessingState,
8
8
  } from "../../screen/use_smile_id_processing_screen";
9
9
  import { UseSmileIDOrchestratedScreenHost } from "./use_smile_id_orchestrated_screen_host";
10
- import { ProcessingCancelledException } from "../../../core/exception";
10
+ import {
11
+ NetworkResponseParseException,
12
+ NetworkTimeoutException,
13
+ NetworkTransportException,
14
+ NetworkUnknownException,
15
+ ProcessingCancelledException,
16
+ } from "../../../core/exception";
17
+ import { useUseSmileIDLocalizations } from "../../../l10n/use_smile_id_localizations_provider";
18
+ import type { UseSmileIDLocalizations } from "../../../l10n/use_smile_id_localizations";
11
19
  import {
12
20
  JobType,
13
21
  requiresDocument,
@@ -68,9 +76,11 @@ function ProcessingScreenBody(props: ProcessingScreenBodyProps) {
68
76
  : undefined;
69
77
  const documentAspectRatio = navigationManager.documentFrameAspectRatio;
70
78
 
79
+ const loc = useUseSmileIDLocalizations();
80
+
71
81
  return (
72
82
  <UseSmileIDProcessingScreen
73
- state={mapToProcessingState(navigationManager.jobSubmissionState)}
83
+ state={mapToProcessingState(navigationManager.jobSubmissionState, loc)}
74
84
  showFaceOval={requiresSelfie(jobType) || requiresDocument(jobType)}
75
85
  showProgressPercentage={props.processingConfig.showProgressPercentage}
76
86
  showAttribution={props.processingConfig.showAttribution}
@@ -86,19 +96,29 @@ function ProcessingScreenBody(props: ProcessingScreenBodyProps) {
86
96
  onExit={() =>
87
97
  navigationManager.exit({
88
98
  kind: "failed",
89
- error: new ProcessingCancelledException(),
99
+ error: exitError(navigationManager.jobSubmissionState),
90
100
  })
91
101
  }
92
102
  />
93
103
  );
94
104
  }
95
105
 
106
+ /** The error the flow exits with when the user leaves the processing screen; an already-failed submission is delivered as its own failure. */
107
+ function exitError(state: JobSubmissionState): Error {
108
+ return state.kind === "failure"
109
+ ? state.error
110
+ : new ProcessingCancelledException();
111
+ }
112
+
96
113
  /**
97
114
  * Translate `JobSubmissionState` to the screen's `ProcessingState`.
98
115
  * `notAttempted` maps to `loading` so the brief pre-submission window
99
116
  * doesn't render an empty card.
100
117
  */
101
- function mapToProcessingState(state: JobSubmissionState): ProcessingState {
118
+ function mapToProcessingState(
119
+ state: JobSubmissionState,
120
+ loc: UseSmileIDLocalizations,
121
+ ): ProcessingState {
102
122
  switch (state.kind) {
103
123
  case "notAttempted":
104
124
  case "loading":
@@ -106,6 +126,23 @@ function mapToProcessingState(state: JobSubmissionState): ProcessingState {
106
126
  case "success":
107
127
  return { kind: "success", message: state.response.message };
108
128
  case "failure":
109
- return { kind: "failed", errorMessage: state.error.message };
129
+ return { kind: "failed", errorMessage: userFacingMessage(state.error, loc) };
130
+ }
131
+ }
132
+
133
+ /** Transport/timeout and parse/unknown network failures carry technical text — every other failure keeps its message. */
134
+ function userFacingMessage(error: Error, loc: UseSmileIDLocalizations): string {
135
+ if (
136
+ error instanceof NetworkTransportException ||
137
+ error instanceof NetworkTimeoutException
138
+ ) {
139
+ return loc.siProcessingErrorNoConnection;
140
+ }
141
+ if (
142
+ error instanceof NetworkResponseParseException ||
143
+ error instanceof NetworkUnknownException
144
+ ) {
145
+ return loc.siProcessingErrorGeneric;
110
146
  }
147
+ return error.message;
111
148
  }
@@ -1,5 +1,5 @@
1
- import { useEffect, useMemo, useReducer, useRef } from "react";
2
- import { Text, useColorScheme, View } from "react-native";
1
+ import { useEffect, useMemo, useReducer, useRef, useState } from "react";
2
+ import { AppState, Text, useColorScheme, View } from "react-native";
3
3
  import type { FlowConfiguration } from "./config/flow_configuration";
4
4
  import type { FlowBuildResult } from "./dsl/flow_build_result";
5
5
  import {
@@ -102,6 +102,9 @@ export type FlowSuccessRendererProps = {
102
102
  readonly configuration: FlowConfiguration;
103
103
  };
104
104
 
105
+ /** Ceiling on the first-frame font wait — past it the flow renders regardless. */
106
+ const FONT_REGISTRATION_TIMEOUT_MS = 1000;
107
+
105
108
  /**
106
109
  * Walks the partner through the configured screen list, dispatches
107
110
  * frames to the chosen analyzer, submits the resulting artifacts, and
@@ -141,10 +144,17 @@ export function FlowSuccessRenderer({
141
144
  ? defaultDarkTheme
142
145
  : defaultLightTheme;
143
146
 
144
- // Registers the bundled DM Sans faces (no-op after the first flow).
145
- // Rendering does not wait on this — text re-renders into DM Sans the
146
- // moment registration completes.
147
- useUseSmileIDFonts();
147
+ // The first frame waits on font registration: a label laid out in the system font and re-measured in DM Sans can settle clipped.
148
+ const fontsLoaded = useUseSmileIDFonts();
149
+ const [fontWaitElapsed, setFontWaitElapsed] = useState(false);
150
+ useEffect(() => {
151
+ if (fontsLoaded) return;
152
+ const timer = setTimeout(
153
+ () => setFontWaitElapsed(true),
154
+ FONT_REGISTRATION_TIMEOUT_MS,
155
+ );
156
+ return () => clearTimeout(timer);
157
+ }, [fontsLoaded]);
148
158
  const enableCrashReporting =
149
159
  configuration.config?.enableCrashReporting !== false;
150
160
  // Sandbox sessions are test traffic and must not pollute the production
@@ -185,6 +195,9 @@ export function FlowSuccessRenderer({
185
195
  const onResultRef = useRef(configuration.onResult);
186
196
  onResultRef.current = configuration.onResult;
187
197
 
198
+ // Latched by `handleExit` so an unmounted-before-finishing flow can deliver exactly once.
199
+ const finishedRef = useRef(false);
200
+
188
201
  // Force-update reducer drives re-renders when the manager's cursor
189
202
  // or captured artifacts change. Cheaper than mirroring state into
190
203
  // React state — the manager is the single source of truth.
@@ -220,6 +233,7 @@ export function FlowSuccessRenderer({
220
233
 
221
234
  const manager = useMemo(() => {
222
235
  const handleExit = (reason: FlowExitReason) => {
236
+ finishedRef.current = true;
223
237
  const onResult = onResultRef.current;
224
238
  try {
225
239
  if (reason.kind === "completed") {
@@ -271,6 +285,17 @@ export function FlowSuccessRenderer({
271
285
  };
272
286
  }, [manager]);
273
287
 
288
+ // A flow unmounted before finishing delivers a cancelled result exactly once.
289
+ useEffect(
290
+ () => () => {
291
+ if (!finishedRef.current) {
292
+ finishedRef.current = true;
293
+ onResultRef.current(cancelled());
294
+ }
295
+ },
296
+ [],
297
+ );
298
+
274
299
  // Resolve localizations:
275
300
  // 1. Explicit `configuration.localizations` (partner-set on the
276
301
  // builder) — wins over everything.
@@ -279,13 +304,23 @@ export function FlowSuccessRenderer({
279
304
  // registered locales with `UseSmileIDLocalizations.register(...)`.
280
305
  // 3. English defaults — no provider mounted, screens read from
281
306
  // the context's default English-only instance.
307
+ // A device locale change requires leaving the app, so re-reading on foreground is what makes a mid-flow switch observable.
308
+ const [localeEpoch, bumpLocaleEpoch] = useReducer((x: number) => x + 1, 0);
309
+ useEffect(() => {
310
+ const subscription = AppState.addEventListener("change", (nextState) => {
311
+ if (nextState === "active") bumpLocaleEpoch();
312
+ });
313
+ return () => subscription.remove();
314
+ }, []);
315
+
316
+ const activeLocaleKey = UseSmileIDLocalizations.activeLocaleKey;
282
317
  const resolved = useMemo(() => {
283
318
  if (configuration.localizations) return configuration.localizations;
284
319
  if (UseSmileIDLocalizations.hasRegisteredLocales()) {
285
320
  return UseSmileIDLocalizations.forActiveLocale();
286
321
  }
287
322
  return null;
288
- }, [configuration.localizations]);
323
+ }, [configuration.localizations, activeLocaleKey, localeEpoch]);
289
324
 
290
325
  const step = manager.currentScreen;
291
326
 
@@ -331,7 +366,16 @@ export function FlowSuccessRenderer({
331
366
  // resolve — the context default (English) covers the no-provider
332
367
  // path without an extra wrapper.
333
368
  const themed = (
334
- <UseSmileIDThemeProvider value={theme}>{inner}</UseSmileIDThemeProvider>
369
+ <UseSmileIDThemeProvider value={theme}>
370
+ {fontsLoaded || fontWaitElapsed ? (
371
+ inner
372
+ ) : (
373
+ <View
374
+ style={{ flex: 1, backgroundColor: theme.colors.background }}
375
+ testID="si_flow_font_gate"
376
+ />
377
+ )}
378
+ </UseSmileIDThemeProvider>
335
379
  );
336
380
 
337
381
  if (resolved) {