@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,112 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as React from "react";
3
+ import { create as createRenderer, act } from "react-test-renderer";
4
+ import {
5
+ UseSmileIDLocalizationsProvider,
6
+ useStableLocalizations,
7
+ useUseSmileIDLocalizations,
8
+ } from "../use_smile_id_localizations_provider";
9
+ import { UseSmileIDLocalizations } from "../use_smile_id_localizations";
10
+
11
+ function renderReader(children: React.ReactNode) {
12
+ let renderer!: ReturnType<typeof createRenderer>;
13
+ act(() => {
14
+ renderer = createRenderer(<>{children}</>);
15
+ });
16
+ return renderer;
17
+ }
18
+
19
+ describe("useUseSmileIDLocalizations", () => {
20
+ it("returns the English default when no provider wraps the caller", () => {
21
+ const seen: string[] = [];
22
+ function Reader() {
23
+ seen.push(useUseSmileIDLocalizations().siConsentAllow);
24
+ return null;
25
+ }
26
+
27
+ renderReader(<Reader />);
28
+
29
+ expect(seen).toEqual(["Allow"]);
30
+ });
31
+
32
+ it("returns the provider's instance when one is mounted", () => {
33
+ const localizations = new UseSmileIDLocalizations({
34
+ si_consent_allow: "Autoriser",
35
+ });
36
+ const seen: UseSmileIDLocalizations[] = [];
37
+ function Reader() {
38
+ seen.push(useUseSmileIDLocalizations());
39
+ return null;
40
+ }
41
+
42
+ renderReader(
43
+ <UseSmileIDLocalizationsProvider value={localizations}>
44
+ <Reader />
45
+ </UseSmileIDLocalizationsProvider>,
46
+ );
47
+
48
+ expect(seen[0]).toBe(localizations);
49
+ expect(seen[0]?.siConsentAllow).toBe("Autoriser");
50
+ });
51
+ });
52
+
53
+ describe("useStableLocalizations", () => {
54
+ it("builds a localizations instance from the overrides map", () => {
55
+ const overrides = { si_consent_allow: "Autoriser" };
56
+ const seen: UseSmileIDLocalizations[] = [];
57
+ function Reader() {
58
+ seen.push(useStableLocalizations(overrides));
59
+ return null;
60
+ }
61
+
62
+ renderReader(<Reader />);
63
+
64
+ expect(seen[0]).toBeInstanceOf(UseSmileIDLocalizations);
65
+ expect(seen[0]?.siConsentAllow).toBe("Autoriser");
66
+ });
67
+
68
+ it("keeps one instance across re-renders for a stable overrides object", () => {
69
+ const overrides = { si_consent_allow: "Autoriser" };
70
+ const seen: UseSmileIDLocalizations[] = [];
71
+ function Reader(_props: { tick: number }) {
72
+ seen.push(useStableLocalizations(overrides));
73
+ return null;
74
+ }
75
+
76
+ let renderer!: ReturnType<typeof createRenderer>;
77
+ act(() => {
78
+ renderer = createRenderer(<Reader tick={0} />);
79
+ });
80
+ act(() => {
81
+ renderer.update(<Reader tick={1} />);
82
+ });
83
+
84
+ expect(seen).toHaveLength(2);
85
+ expect(seen[0]).toBe(seen[1]);
86
+ });
87
+
88
+ it("rebuilds when the partner passes a fresh overrides object", () => {
89
+ const seen: UseSmileIDLocalizations[] = [];
90
+ function Reader({
91
+ overrides,
92
+ }: {
93
+ overrides: Partial<Record<string, string>>;
94
+ }) {
95
+ seen.push(useStableLocalizations(overrides));
96
+ return null;
97
+ }
98
+
99
+ let renderer!: ReturnType<typeof createRenderer>;
100
+ act(() => {
101
+ renderer = createRenderer(
102
+ <Reader overrides={{ si_consent_allow: "Autoriser" }} />,
103
+ );
104
+ });
105
+ act(() => {
106
+ renderer.update(<Reader overrides={{ si_consent_allow: "Permitir" }} />);
107
+ });
108
+
109
+ expect(seen[0]).not.toBe(seen[1]);
110
+ expect(seen[1]?.siConsentAllow).toBe("Permitir");
111
+ });
112
+ });
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { UseSmileIDLocalizations } from "../use_smile_id_localizations";
3
+ import { enStrings } from "../strings_en";
4
+
5
+ /**
6
+ * Every localized accessor is a two-line `_s(key, enStrings[key])` /
7
+ * `_t(key, enStrings[key], params)` wrapper. Hand-writing one test per
8
+ * accessor would be ~130 near-identical tests, so this suite drives the
9
+ * whole surface off the prototype: a Proxy overrides map reports which
10
+ * `si_*` key each accessor asks for, which is then checked against the
11
+ * English default it returns. A typo'd key (the realistic defect — a
12
+ * copy-pasted getter reading its neighbour's key) fails here.
13
+ */
14
+
15
+ const PARAM_VALUE = "SENTINEL-PARAM";
16
+
17
+ type Accessor = {
18
+ readonly name: string;
19
+ readonly arity: number;
20
+ readonly call: (instance: UseSmileIDLocalizations) => string;
21
+ };
22
+
23
+ const accessors: readonly Accessor[] = Object.entries(
24
+ Object.getOwnPropertyDescriptors(UseSmileIDLocalizations.prototype),
25
+ )
26
+ .filter(([name]) => name.startsWith("si"))
27
+ .map(([name, descriptor]): Accessor => {
28
+ if (descriptor.get) {
29
+ return {
30
+ name,
31
+ arity: 0,
32
+ call: (instance) =>
33
+ (instance as unknown as Record<string, string>)[name] as string,
34
+ };
35
+ }
36
+ const method = descriptor.value as (...args: string[]) => string;
37
+ const arity = method.length;
38
+ return {
39
+ name,
40
+ arity,
41
+ call: (instance) =>
42
+ method.apply(
43
+ instance,
44
+ Array.from({ length: arity }, () => PARAM_VALUE),
45
+ ),
46
+ };
47
+ });
48
+
49
+ /** Overrides map that reports which key an accessor looked up. */
50
+ function keyProbe(): Partial<Record<string, string>> {
51
+ return new Proxy(
52
+ {},
53
+ {
54
+ get: (_target, property) =>
55
+ typeof property === "string" ? `<<${property}>>` : undefined,
56
+ },
57
+ ) as Partial<Record<string, string>>;
58
+ }
59
+
60
+ function lookedUpKey(accessor: Accessor): string {
61
+ const probed = accessor.call(new UseSmileIDLocalizations(keyProbe()));
62
+ const match = /^<<(.+)>>$/.exec(probed);
63
+ if (!match?.[1]) {
64
+ throw new Error(
65
+ `${accessor.name} did not resolve through the overrides map (got ${probed})`,
66
+ );
67
+ }
68
+ return match[1];
69
+ }
70
+
71
+ describe("UseSmileIDLocalizations — accessor surface", () => {
72
+ it("exposes an accessor for every localized string", () => {
73
+ expect(accessors.length).toBeGreaterThan(100);
74
+ });
75
+
76
+ it.each(accessors.map((accessor) => [accessor.name, accessor] as const))(
77
+ "%s reads its own si_ key and defaults to the English string",
78
+ (_name, accessor) => {
79
+ const key = lookedUpKey(accessor);
80
+
81
+ expect(key).toMatch(/^si_/);
82
+ expect(enStrings).toHaveProperty(key);
83
+
84
+ const english = (enStrings as Record<string, string>)[key] as string;
85
+ const actual = accessor.call(new UseSmileIDLocalizations());
86
+ const expected =
87
+ accessor.arity === 0
88
+ ? english
89
+ : english.replace(
90
+ /\{[A-Za-z_][A-Za-z0-9_]*\}/g,
91
+ `\u2068${PARAM_VALUE}\u2069`,
92
+ );
93
+
94
+ expect(actual).toBe(expected);
95
+ },
96
+ );
97
+
98
+ it("maps each accessor to a distinct key — no copy-paste collisions", () => {
99
+ const keys = accessors.map(lookedUpKey);
100
+
101
+ expect(new Set(keys).size).toBe(keys.length);
102
+ });
103
+
104
+ it("leaves no canonical string unreachable through the public surface", () => {
105
+ const reached = new Set(accessors.map(lookedUpKey));
106
+ const orphans = Object.keys(enStrings).filter((key) => !reached.has(key));
107
+
108
+ expect(orphans).toEqual([]);
109
+ });
110
+
111
+ it("substitutes named tokens only in the parameterized accessors", () => {
112
+ const parameterized = accessors.filter((accessor) => accessor.arity > 0);
113
+ expect(parameterized.length).toBeGreaterThan(0);
114
+
115
+ for (const accessor of parameterized) {
116
+ const rendered = accessor.call(new UseSmileIDLocalizations());
117
+ expect(rendered).toContain(PARAM_VALUE);
118
+ expect(rendered).not.toMatch(/\{[A-Za-z_][A-Za-z0-9_]*\}/);
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("UseSmileIDLocalizations — locale registry", () => {
124
+ const originalResolver = UseSmileIDLocalizations.localeResolver;
125
+
126
+ beforeEach(() => {
127
+ UseSmileIDLocalizations.clearRegistry();
128
+ });
129
+
130
+ afterEach(() => {
131
+ UseSmileIDLocalizations.clearRegistry();
132
+ UseSmileIDLocalizations.localeResolver = originalResolver;
133
+ });
134
+
135
+ it("reports an empty registry until a locale is registered", () => {
136
+ expect(UseSmileIDLocalizations.hasRegisteredLocales()).toBe(false);
137
+
138
+ UseSmileIDLocalizations.register("fr", { si_consent_allow: "Autoriser" });
139
+
140
+ expect(UseSmileIDLocalizations.hasRegisteredLocales()).toBe(true);
141
+ });
142
+
143
+ it("clearRegistry empties it again", () => {
144
+ UseSmileIDLocalizations.register("fr", { si_consent_allow: "Autoriser" });
145
+ UseSmileIDLocalizations.clearRegistry();
146
+
147
+ expect(UseSmileIDLocalizations.hasRegisteredLocales()).toBe(false);
148
+ });
149
+
150
+ it("strips ARB metadata and non-string values on register", () => {
151
+ UseSmileIDLocalizations.register("fr", {
152
+ "@@locale": "fr",
153
+ "@si_consent_allow": { description: "Allow" },
154
+ si_consent_allow: "Autoriser",
155
+ si_consent_deny: 42,
156
+ });
157
+ UseSmileIDLocalizations.localeResolver = () => "fr";
158
+
159
+ const resolved = UseSmileIDLocalizations.forActiveLocale();
160
+
161
+ expect(resolved.siConsentAllow).toBe("Autoriser");
162
+ expect(resolved.siConsentDeny).toBe("Deny");
163
+ });
164
+
165
+ it("re-registering a locale replaces the prior translations", () => {
166
+ UseSmileIDLocalizations.register("fr", { si_consent_allow: "Ancien" });
167
+ UseSmileIDLocalizations.register("fr", { si_consent_allow: "Autoriser" });
168
+ UseSmileIDLocalizations.localeResolver = () => "fr";
169
+
170
+ expect(UseSmileIDLocalizations.forActiveLocale().siConsentAllow).toBe(
171
+ "Autoriser",
172
+ );
173
+ });
174
+
175
+ it("prefers an exact locale match over the language-only entry", () => {
176
+ UseSmileIDLocalizations.register("pt", { si_consent_allow: "Permitir" });
177
+ UseSmileIDLocalizations.register("pt-BR", {
178
+ si_consent_allow: "Permitir (BR)",
179
+ });
180
+ UseSmileIDLocalizations.localeResolver = () => "pt-BR";
181
+
182
+ expect(UseSmileIDLocalizations.forActiveLocale().siConsentAllow).toBe(
183
+ "Permitir (BR)",
184
+ );
185
+ });
186
+
187
+ it("falls back to the language subtag when the region variant is unregistered", () => {
188
+ UseSmileIDLocalizations.register("pt", { si_consent_allow: "Permitir" });
189
+ UseSmileIDLocalizations.localeResolver = () => "pt-BR";
190
+
191
+ expect(UseSmileIDLocalizations.forActiveLocale().siConsentAllow).toBe(
192
+ "Permitir",
193
+ );
194
+ });
195
+
196
+ it("falls back to English when neither the locale nor its language is registered", () => {
197
+ UseSmileIDLocalizations.register("fr", { si_consent_allow: "Autoriser" });
198
+ UseSmileIDLocalizations.localeResolver = () => "sw-KE";
199
+
200
+ expect(UseSmileIDLocalizations.forActiveLocale().siConsentAllow).toBe(
201
+ "Allow",
202
+ );
203
+ });
204
+
205
+ it("falls back to English when the registry is empty", () => {
206
+ UseSmileIDLocalizations.localeResolver = () => "fr";
207
+
208
+ expect(UseSmileIDLocalizations.forActiveLocale().siConsentAllow).toBe(
209
+ "Allow",
210
+ );
211
+ });
212
+
213
+ it("resolves the device language subtag by default", () => {
214
+ const language = UseSmileIDLocalizations.localeResolver();
215
+
216
+ expect(language).toMatch(/^[a-z]{2,3}$/);
217
+ expect(language).not.toContain("-");
218
+ });
219
+
220
+ it("falls back to English when Intl is unavailable", () => {
221
+ const originalIntl = globalThis.Intl;
222
+ // Concrete scenario: a JS engine (or a locked-down Hermes build)
223
+ // without full-ICU — the resolver must not throw at flow mount.
224
+ (globalThis as { Intl?: unknown }).Intl = undefined;
225
+ try {
226
+ expect(originalResolver()).toBe("en");
227
+ } finally {
228
+ globalThis.Intl = originalIntl;
229
+ }
230
+ });
231
+ });
@@ -81,6 +81,8 @@ export const enStrings = {
81
81
  si_capture_hold_steady: "Hold still…",
82
82
  si_capture_timeout: "Capture timed out. Try again.",
83
83
  si_capture_failed: "Capture failed. Please try again.",
84
+ si_capture_camera_unavailable:
85
+ "No camera frames received. Close other apps using the camera, then try again.",
84
86
  si_capture_adjusting: "Adjusting…",
85
87
  si_capture_bright_light: "Please move away from direct light",
86
88
  si_processing: "Processing…",
@@ -104,6 +106,8 @@ export const enStrings = {
104
106
  "Thank you for your submission. We are currently verifying your details.",
105
107
  si_processing_failed_title: "Submission Failed",
106
108
  si_processing_failed_message: "Reasons for failure",
109
+ si_processing_error_no_connection: "No internet connection.",
110
+ si_processing_error_generic: "Something went wrong. Try again.",
107
111
  si_processing_continue: "Continue",
108
112
  si_processing_retry: "Retry",
109
113
  si_processing_exit: "Exit",
@@ -169,4 +173,4 @@ export const enStrings = {
169
173
  export type SiKey = keyof typeof enStrings;
170
174
 
171
175
  /** Total canonical key count — pinned in tests to catch a missed addition/deletion at PR time. */
172
- export const CANONICAL_KEY_COUNT = 119 as const;
176
+ export const CANONICAL_KEY_COUNT = 122 as const;
@@ -1,5 +1,28 @@
1
1
  import { enStrings, type SiKey } from "./strings_en";
2
2
 
3
+ // Unicode bidi isolates: without them a substituted value of the opposite direction reorders the whole sentence.
4
+ const FIRST_STRONG_ISOLATE = "⁨";
5
+ const POP_DIRECTIONAL_ISOLATE = "⁩";
6
+
7
+ // `__DEV__` is bundler-injected; outside Metro-like environments a bare reference throws rather than reading falsy.
8
+ function isDevBuild(): boolean {
9
+ return typeof __DEV__ !== "undefined" && __DEV__;
10
+ }
11
+
12
+ const warnedLocales = new Set<string>();
13
+
14
+ function warnLocaleFallback(locale: string): void {
15
+ if (!isDevBuild() || warnedLocales.has(locale)) return;
16
+ warnedLocales.add(locale);
17
+ // eslint-disable-next-line no-console
18
+ console.warn(
19
+ `[UseSmileID] No translations registered for locale '${locale}' — the SDK ` +
20
+ "is rendering English. Call UseSmileIDLocalizations.register('" +
21
+ locale +
22
+ "', …) at startup, or set b.localizations explicitly.",
23
+ );
24
+ }
25
+
3
26
  /**
4
27
  * Localized string surface for the SDK. Canonical `si_*` keys default to English
5
28
  * (`strings_en.ts`); partners override per-locale by passing an `overrides` map to
@@ -42,6 +65,7 @@ export class UseSmileIDLocalizations {
42
65
  // JS hook for discovering bundled translation assets.
43
66
 
44
67
  private static _registry = new Map<string, Partial<Record<string, string>>>();
68
+ private static _registryRevision = 0;
45
69
 
46
70
  /**
47
71
  * Default device-locale resolver: reads the BCP 47 locale via
@@ -71,11 +95,13 @@ export class UseSmileIDLocalizations {
71
95
  if (typeof value === "string") filtered[key] = value;
72
96
  }
73
97
  UseSmileIDLocalizations._registry.set(locale, filtered);
98
+ UseSmileIDLocalizations._registryRevision += 1;
74
99
  }
75
100
 
76
101
  /** Clear the locale registry. Useful for tests; partners rarely call. */
77
102
  static clearRegistry(): void {
78
103
  UseSmileIDLocalizations._registry.clear();
104
+ UseSmileIDLocalizations._registryRevision += 1;
79
105
  }
80
106
 
81
107
  /** Whether any locale has been registered. */
@@ -83,6 +109,11 @@ export class UseSmileIDLocalizations {
83
109
  return UseSmileIDLocalizations._registry.size > 0;
84
110
  }
85
111
 
112
+ /** Identity of the translations `forActiveLocale()` would resolve right now; it changes on a locale switch or a late `register(...)`. */
113
+ static get activeLocaleKey(): string {
114
+ return `${UseSmileIDLocalizations.localeResolver()}#${UseSmileIDLocalizations._registryRevision}`;
115
+ }
116
+
86
117
  /**
87
118
  * Resolve the active locale via `localeResolver()` and return the matching
88
119
  * `UseSmileIDLocalizations`. Lookup order: exact match (`'pt-BR'`), then
@@ -99,26 +130,54 @@ export class UseSmileIDLocalizations {
99
130
  const fallback = UseSmileIDLocalizations._registry.get(language);
100
131
  if (fallback) return new UseSmileIDLocalizations(fallback);
101
132
  }
133
+ warnLocaleFallback(locale);
102
134
  return new UseSmileIDLocalizations();
103
135
  }
104
136
 
137
+ /** One dev-build warning per instance, so a partly translated bundle can't flood the console. */
138
+ private _warnedMissingKey = false;
139
+
140
+ private _lookup(key: SiKey, fallback: string): string {
141
+ const override = this._overrides[key];
142
+ if (override !== undefined) return override;
143
+ if (
144
+ isDevBuild() &&
145
+ !this._warnedMissingKey &&
146
+ Object.keys(this._overrides).length > 0
147
+ ) {
148
+ this._warnedMissingKey = true;
149
+ // eslint-disable-next-line no-console
150
+ console.warn(
151
+ `[UseSmileID] '${key}' is missing from the supplied translations and is ` +
152
+ "rendering English; other keys may be too. Compare your overrides " +
153
+ "against the si_* key set in the SDK's English strings.",
154
+ );
155
+ }
156
+ return fallback;
157
+ }
158
+
105
159
  /** @internal Static-fallback string lookup. */
106
160
  private _s(key: SiKey, fallback: string): string {
107
- return this._overrides[key] ?? fallback;
161
+ return this._lookup(key, fallback);
108
162
  }
109
163
 
110
164
  /**
111
165
  * @internal Templated lookup; replaces every `{name}` with `params[name]`. A
112
166
  * placeholder absent from the translation is left as-is (the `{name}` literal).
167
+ * Substituted values are bidi-isolated so they cannot reorder the sentence around them.
113
168
  */
114
169
  private _t(
115
170
  key: SiKey,
116
171
  fallback: string,
117
172
  params: Readonly<Record<string, string>>,
118
173
  ): string {
119
- let value = this._overrides[key] ?? fallback;
174
+ let value = this._lookup(key, fallback);
120
175
  for (const [name, replacement] of Object.entries(params)) {
121
- value = value.split(`{${name}}`).join(replacement);
176
+ const isolated =
177
+ replacement === ""
178
+ ? replacement
179
+ : `${FIRST_STRONG_ISOLATE}${replacement}${POP_DIRECTIONAL_ISOLATE}`;
180
+ value = value.split(`{${name}}`).join(isolated);
122
181
  }
123
182
  return value;
124
183
  }
@@ -389,6 +448,12 @@ export class UseSmileIDLocalizations {
389
448
  get siCaptureHoldSteady(): string {
390
449
  return this._s("si_capture_hold_steady", enStrings.si_capture_hold_steady);
391
450
  }
451
+ get siCaptureCameraUnavailable(): string {
452
+ return this._s(
453
+ "si_capture_camera_unavailable",
454
+ enStrings.si_capture_camera_unavailable,
455
+ );
456
+ }
392
457
  get siCaptureTimeout(): string {
393
458
  return this._s("si_capture_timeout", enStrings.si_capture_timeout);
394
459
  }
@@ -492,6 +557,18 @@ export class UseSmileIDLocalizations {
492
557
  enStrings.si_processing_failed_message,
493
558
  );
494
559
  }
560
+ get siProcessingErrorNoConnection(): string {
561
+ return this._s(
562
+ "si_processing_error_no_connection",
563
+ enStrings.si_processing_error_no_connection,
564
+ );
565
+ }
566
+ get siProcessingErrorGeneric(): string {
567
+ return this._s(
568
+ "si_processing_error_generic",
569
+ enStrings.si_processing_error_generic,
570
+ );
571
+ }
495
572
  get siProcessingContinue(): string {
496
573
  return this._s("si_processing_continue", enStrings.si_processing_continue);
497
574
  }
@@ -0,0 +1,165 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // `vi.mock` is hoisted above the imports, so anything its factory closes over
4
+ // must be created with `vi.hoisted` or it is still in the TDZ when the factory
5
+ // runs.
6
+ const { loadExpoSensors } = vi.hoisted(() => ({ loadExpoSensors: vi.fn() }));
7
+
8
+ vi.mock("../optional_native_modules", () => ({ loadExpoSensors }));
9
+
10
+ import { DeviceOrientationCollector } from "../device_orientation_collector";
11
+
12
+ /**
13
+ * Sampling behaviour of the accelerometer collector: the ~15 Hz throttle,
14
+ * the 90 s session cap, the movement-span computation, and the graceful
15
+ * no-op when `expo-sensors` is absent. The empty-session contract is pinned
16
+ * separately in `device_orientation_collector.test.ts`.
17
+ */
18
+
19
+ type Sample = { x: number; y: number; z: number };
20
+
21
+ function makeSensors() {
22
+ const listeners: Array<(data: Sample) => void> = [];
23
+ const remove = vi.fn(() => {
24
+ listeners.length = 0;
25
+ });
26
+ const setUpdateInterval = vi.fn();
27
+ const addListener = vi.fn((listener: (data: Sample) => void) => {
28
+ listeners.push(listener);
29
+ return { remove };
30
+ });
31
+ return {
32
+ module: { Accelerometer: { setUpdateInterval, addListener } },
33
+ emit: (sample: Sample) => {
34
+ for (const listener of [...listeners]) listener(sample);
35
+ },
36
+ setUpdateInterval,
37
+ addListener,
38
+ remove,
39
+ listenerCount: () => listeners.length,
40
+ };
41
+ }
42
+
43
+ describe("DeviceOrientationCollector — sampling", () => {
44
+ beforeEach(() => {
45
+ vi.useFakeTimers();
46
+ vi.setSystemTime(new Date("2026-07-30T00:00:00.000Z"));
47
+ loadExpoSensors.mockReset();
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.useRealTimers();
52
+ });
53
+
54
+ it("subscribes at the ~15 Hz sampling interval on start", () => {
55
+ const sensors = makeSensors();
56
+ loadExpoSensors.mockReturnValue(sensors.module);
57
+
58
+ new DeviceOrientationCollector().start();
59
+
60
+ expect(sensors.setUpdateInterval).toHaveBeenCalledWith(66);
61
+ expect(sensors.addListener).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it("records epoch-stamped samples and the movement span", () => {
65
+ const sensors = makeSensors();
66
+ loadExpoSensors.mockReturnValue(sensors.module);
67
+ const collector = new DeviceOrientationCollector();
68
+ collector.start();
69
+
70
+ const firstStamp = Date.now();
71
+ sensors.emit({ x: 0, y: 0, z: 1 });
72
+ vi.advanceTimersByTime(100);
73
+ const secondStamp = Date.now();
74
+ sensors.emit({ x: 0, y: 0, z: 2 });
75
+
76
+ expect(collector.devicePositions).toEqual([
77
+ `${firstStamp},0,0,1`,
78
+ `${secondStamp},0,0,2`,
79
+ ]);
80
+ // |1 − 0.981| = 0.019 and |2 − 0.981| = 1.019 → span 1.
81
+ expect(collector.deviceMovementDetected).toBeCloseTo(1, 10);
82
+ });
83
+
84
+ it("throttles samples arriving faster than the interval", () => {
85
+ const sensors = makeSensors();
86
+ loadExpoSensors.mockReturnValue(sensors.module);
87
+ const collector = new DeviceOrientationCollector();
88
+ collector.start();
89
+
90
+ sensors.emit({ x: 1, y: 0, z: 0 });
91
+ vi.advanceTimersByTime(10);
92
+ sensors.emit({ x: 2, y: 0, z: 0 });
93
+ vi.advanceTimersByTime(10);
94
+ sensors.emit({ x: 3, y: 0, z: 0 });
95
+
96
+ expect(collector.devicePositions).toHaveLength(1);
97
+ expect(collector.devicePositions[0]).toContain(",1,0,0");
98
+ });
99
+
100
+ it("stops sampling once the 90 s session cap is exceeded", () => {
101
+ const sensors = makeSensors();
102
+ loadExpoSensors.mockReturnValue(sensors.module);
103
+ const collector = new DeviceOrientationCollector();
104
+ collector.start();
105
+
106
+ sensors.emit({ x: 0, y: 0, z: 1 });
107
+ vi.advanceTimersByTime(90 * 1000 + 1);
108
+ sensors.emit({ x: 0, y: 0, z: 9 });
109
+
110
+ expect(collector.devicePositions).toHaveLength(1);
111
+ expect(sensors.remove).toHaveBeenCalledTimes(1);
112
+ expect(sensors.listenerCount()).toBe(0);
113
+ });
114
+
115
+ it("start() is idempotent while a session is already running", () => {
116
+ const sensors = makeSensors();
117
+ loadExpoSensors.mockReturnValue(sensors.module);
118
+ const collector = new DeviceOrientationCollector();
119
+
120
+ collector.start();
121
+ collector.start();
122
+
123
+ expect(sensors.addListener).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("stop() unsubscribes but preserves the collected data", () => {
127
+ const sensors = makeSensors();
128
+ loadExpoSensors.mockReturnValue(sensors.module);
129
+ const collector = new DeviceOrientationCollector();
130
+ collector.start();
131
+ sensors.emit({ x: 0, y: 0, z: 1 });
132
+
133
+ collector.stop();
134
+
135
+ expect(sensors.remove).toHaveBeenCalledTimes(1);
136
+ expect(collector.devicePositions).toHaveLength(1);
137
+ // A second stop on an already-stopped collector is harmless.
138
+ collector.stop();
139
+ expect(sensors.remove).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("a restart clears the previous session's samples", () => {
143
+ const sensors = makeSensors();
144
+ loadExpoSensors.mockReturnValue(sensors.module);
145
+ const collector = new DeviceOrientationCollector();
146
+ collector.start();
147
+ sensors.emit({ x: 0, y: 0, z: 1 });
148
+ collector.stop();
149
+
150
+ collector.start();
151
+ expect(collector.devicePositions).toHaveLength(0);
152
+ expect(collector.deviceMovementDetected).toBeNull();
153
+ });
154
+
155
+ it("no-ops when expo-sensors is not installed", () => {
156
+ loadExpoSensors.mockReturnValue(null);
157
+ const collector = new DeviceOrientationCollector();
158
+
159
+ collector.start();
160
+ collector.stop();
161
+
162
+ expect(collector.devicePositions).toHaveLength(0);
163
+ expect(collector.deviceMovementDetected).toBeNull();
164
+ });
165
+ });