@amplytools/react-native-amply-sdk 0.13.0 → 0.14.0

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 (26) hide show
  1. package/AmplyReactNative.podspec +1 -1
  2. package/README.md +6 -0
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +8 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +2 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +17 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/AmplyInitializationOptions.kt +2 -0
  8. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +4 -0
  9. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +6 -0
  10. package/dist/src/__tests__/appLanguage.test.d.ts +43 -0
  11. package/dist/src/__tests__/appLanguage.test.d.ts.map +1 -0
  12. package/dist/src/__tests__/appLanguage.test.js +211 -0
  13. package/dist/src/index.d.ts +15 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +78 -1
  16. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +12 -0
  17. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  18. package/dist/tsconfig.tsbuildinfo +1 -1
  19. package/docs/ARCHITECTURE.md +2 -0
  20. package/ios/Sources/AmplyReactNative/AmplyModule.mm +25 -0
  21. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +7 -0
  22. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +7 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/appLanguage.test.ts +296 -0
  25. package/src/index.ts +99 -1
  26. package/src/nativeSpecs/NativeAmplyModule.ts +12 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `setAppLanguage` and the `appLanguage` initialization option.
3
+ *
4
+ * The SDK treats both as one setting with "last write wins": `appLanguage` in the config is the
5
+ * same as calling `setAppLanguage` at initialization. The native bridges cannot honour that order
6
+ * on their own for a call made BEFORE `initialize` — they buffer it and replay it after the SDK is
7
+ * built, which would let an earlier call overwrite the later config value. So the JS layer parks a
8
+ * pre-init call itself and folds it into the config it hands to native.
9
+ *
10
+ * Each test loads a fresh copy of the module: the parked value is module state, and leaking it
11
+ * from one test into the next would make every ordering assertion here meaningless.
12
+ */
13
+ type AmplyModule = typeof import('../index');
14
+
15
+ function createNativeModule() {
16
+ let initialized = false;
17
+ return {
18
+ initialize: jest.fn(async () => {
19
+ initialized = true;
20
+ }),
21
+ isInitialized: jest.fn(() => initialized),
22
+ setAppLanguage: jest.fn(),
23
+ setUserId: jest.fn(),
24
+ addListener: jest.fn(),
25
+ removeListeners: jest.fn(),
26
+ onSystemEvent: jest.fn(() => ({remove: jest.fn()})),
27
+ onDeepLink: jest.fn(() => ({remove: jest.fn()})),
28
+ onCampaignPresent: jest.fn(() => ({remove: jest.fn()})),
29
+ /** Test-only: pretend the SDK is (or is not) up without going through initialize. */
30
+ setInitialized(value: boolean) {
31
+ initialized = value;
32
+ },
33
+ };
34
+ }
35
+
36
+ type NativeModuleMock = ReturnType<typeof createNativeModule>;
37
+
38
+ const KEYS = {appId: 'id', apiKeyPublic: 'public', apiKeySecret: 'secret'};
39
+
40
+ function load(): {Amply: AmplyModule; native: NativeModuleMock} {
41
+ let Amply!: AmplyModule;
42
+ const native = createNativeModule();
43
+ jest.isolateModules(() => {
44
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
45
+ require('../nativeModule').__setNativeModule(native as never);
46
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
47
+ Amply = require('../index');
48
+ });
49
+ return {Amply, native};
50
+ }
51
+
52
+ describe('setAppLanguage after initialization', () => {
53
+ it.each(['pt_BR', 'zh-TW', 'FR', 'Français'])(
54
+ 'forwards %p to the native SDK verbatim — parsing and the warning for junk are the SDK’s job',
55
+ tag => {
56
+ const {Amply, native} = load();
57
+ native.setInitialized(true);
58
+
59
+ Amply.setAppLanguage(tag);
60
+
61
+ expect(native.setAppLanguage).toHaveBeenCalledTimes(1);
62
+ expect(native.setAppLanguage).toHaveBeenCalledWith(tag);
63
+ },
64
+ );
65
+
66
+ it('forwards null, which returns the SDK to automatic detection', () => {
67
+ const {Amply, native} = load();
68
+ native.setInitialized(true);
69
+
70
+ Amply.setAppLanguage(null);
71
+
72
+ expect(native.setAppLanguage).toHaveBeenCalledWith(null);
73
+ });
74
+
75
+ it('is on the default export as well', () => {
76
+ const {Amply, native} = load();
77
+ native.setInitialized(true);
78
+
79
+ Amply.default.setAppLanguage('de');
80
+
81
+ expect(native.setAppLanguage).toHaveBeenCalledWith('de');
82
+ });
83
+ });
84
+
85
+ describe('appLanguage in the initialization config', () => {
86
+ it('is passed through to the native initialize', async () => {
87
+ const {Amply, native} = load();
88
+
89
+ await Amply.initialize({...KEYS, appLanguage: 'pt_BR'});
90
+
91
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'pt_BR'}));
92
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('is left out of the native config when the caller did not give one', async () => {
96
+ const {Amply, native} = load();
97
+
98
+ await Amply.initialize(KEYS);
99
+
100
+ const [config] = native.initialize.mock.calls[0] as unknown as [Record<string, unknown>];
101
+ expect(config).not.toHaveProperty('appLanguage');
102
+ });
103
+ });
104
+
105
+ describe('setAppLanguage before initialization', () => {
106
+ it('is not sent to a native SDK that is not up yet', () => {
107
+ const {Amply, native} = load();
108
+
109
+ Amply.setAppLanguage('fr');
110
+
111
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('reaches the SDK as the config appLanguage when initialize names none', async () => {
115
+ const {Amply, native} = load();
116
+ Amply.setAppLanguage('fr');
117
+
118
+ await Amply.initialize(KEYS);
119
+
120
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'fr'}));
121
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('keeps only the last of several calls', async () => {
125
+ const {Amply, native} = load();
126
+ Amply.setAppLanguage('fr');
127
+ Amply.setAppLanguage('de');
128
+
129
+ await Amply.initialize(KEYS);
130
+
131
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
132
+ });
133
+
134
+ it('loses to an appLanguage given to the later initialize call', async () => {
135
+ const {Amply, native} = load();
136
+ Amply.setAppLanguage('fr');
137
+
138
+ await Amply.initialize({...KEYS, appLanguage: 'de'});
139
+
140
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
141
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('wins over a blank appLanguage in the config, which names no language', async () => {
145
+ const {Amply, native} = load();
146
+ Amply.setAppLanguage('fr');
147
+
148
+ await Amply.initialize({...KEYS, appLanguage: ' '});
149
+
150
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'fr'}));
151
+ });
152
+
153
+ // null cannot travel in the config — there it means "leave the setting alone" — so a parked
154
+ // clear is applied as a call once the SDK is up.
155
+ it('applies a parked null right after initialization', async () => {
156
+ const {Amply, native} = load();
157
+ Amply.setAppLanguage(null);
158
+
159
+ await Amply.initialize(KEYS);
160
+
161
+ const [config] = native.initialize.mock.calls[0] as unknown as [Record<string, unknown>];
162
+ expect(config).not.toHaveProperty('appLanguage');
163
+ expect(native.setAppLanguage).toHaveBeenCalledTimes(1);
164
+ expect(native.setAppLanguage).toHaveBeenCalledWith(null);
165
+ });
166
+
167
+ it('drops a parked null when the later initialize names a language', async () => {
168
+ const {Amply, native} = load();
169
+ Amply.setAppLanguage(null);
170
+
171
+ await Amply.initialize({...KEYS, appLanguage: 'de'});
172
+
173
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
174
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('survives an initialize that fails, and is used by the retry', async () => {
178
+ const {Amply, native} = load();
179
+ Amply.setAppLanguage('fr');
180
+ native.initialize.mockImplementationOnce(async () => {
181
+ throw new Error('AMP_INIT_FAILED');
182
+ });
183
+
184
+ await expect(Amply.initialize(KEYS)).rejects.toThrow('AMP_INIT_FAILED');
185
+ await Amply.initialize(KEYS);
186
+
187
+ expect(native.initialize).toHaveBeenLastCalledWith(expect.objectContaining({appLanguage: 'fr'}));
188
+ });
189
+
190
+ it('survives an initialize rejected for a missing key', async () => {
191
+ const {Amply, native} = load();
192
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
193
+ Amply.setAppLanguage('fr');
194
+
195
+ await expect(Amply.initialize({...KEYS, apiKeySecret: ''})).rejects.toMatchObject({
196
+ code: 'AMP_INVALID_CONFIG',
197
+ });
198
+ await Amply.initialize(KEYS);
199
+
200
+ expect(native.initialize).toHaveBeenCalledTimes(1);
201
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'fr'}));
202
+ error.mockRestore();
203
+ });
204
+ });
205
+
206
+ describe('setAppLanguage while initialize is in flight', () => {
207
+ it('is applied after initialization, so it wins over the config value it came after', async () => {
208
+ const {Amply, native} = load();
209
+ let finishInit!: () => void;
210
+ native.initialize.mockImplementationOnce(
211
+ () =>
212
+ new Promise<void>(resolve => {
213
+ finishInit = () => {
214
+ native.setInitialized(true);
215
+ resolve();
216
+ };
217
+ }),
218
+ );
219
+
220
+ const init = Amply.initialize({...KEYS, appLanguage: 'de'});
221
+ Amply.setAppLanguage('fr');
222
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
223
+ finishInit();
224
+ await init;
225
+
226
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
227
+ expect(native.setAppLanguage).toHaveBeenCalledTimes(1);
228
+ expect(native.setAppLanguage).toHaveBeenCalledWith('fr');
229
+ });
230
+
231
+ // The native SDK reports itself up before the initialize promise settles, so a call made in
232
+ // that window goes straight through. A value held from before initialize is older than it and
233
+ // must not be replayed over it when the promise finally resolves.
234
+ function hangInitialize(native: NativeModuleMock): {
235
+ comeUp: () => void;
236
+ resolve: () => void;
237
+ reject: (error: Error) => void;
238
+ } {
239
+ const control = {comeUp: () => {}, resolve: () => {}, reject: (_error: Error) => {}};
240
+ native.initialize.mockImplementationOnce(
241
+ () =>
242
+ new Promise<void>((resolve, reject) => {
243
+ control.comeUp = () => native.setInitialized(true);
244
+ control.resolve = resolve;
245
+ control.reject = reject;
246
+ }),
247
+ );
248
+ return control;
249
+ }
250
+
251
+ it('does not replay an older held null over a call the SDK already received', async () => {
252
+ const {Amply, native} = load();
253
+ Amply.setAppLanguage(null);
254
+ const init = hangInitialize(native);
255
+
256
+ const pending = Amply.initialize(KEYS);
257
+ init.comeUp();
258
+ Amply.setAppLanguage('fr');
259
+ init.resolve();
260
+ await pending;
261
+
262
+ expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
263
+ });
264
+
265
+ it('does not replay an older held tag over a call the SDK already received', async () => {
266
+ const {Amply, native} = load();
267
+ const init = hangInitialize(native);
268
+
269
+ const pending = Amply.initialize(KEYS);
270
+ Amply.setAppLanguage('de');
271
+ init.comeUp();
272
+ Amply.setAppLanguage('fr');
273
+ init.resolve();
274
+ await pending;
275
+
276
+ expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
277
+ });
278
+
279
+ it('does not hand a superseded held tag to the retry of a failed initialize', async () => {
280
+ const {Amply, native} = load();
281
+ Amply.setAppLanguage('de');
282
+ const init = hangInitialize(native);
283
+
284
+ const pending = Amply.initialize(KEYS);
285
+ init.comeUp();
286
+ Amply.setAppLanguage('fr');
287
+ init.reject(new Error('AMP_INIT_FAILED'));
288
+ await expect(pending).rejects.toThrow('AMP_INIT_FAILED');
289
+ native.setInitialized(false);
290
+ await Amply.initialize(KEYS);
291
+
292
+ const [retryConfig] = native.initialize.mock.calls[1] as unknown as [Record<string, unknown>];
293
+ expect(retryConfig).not.toHaveProperty('appLanguage');
294
+ expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
295
+ });
296
+ });
package/src/index.ts CHANGED
@@ -232,11 +232,108 @@ export async function initialize(config: AmplyInitializationConfig): Promise<voi
232
232
  if (config.debug || config.logLevel) {
233
233
  ensureDebugLogListener();
234
234
  }
235
- await getNativeModule().initialize(config);
235
+ // Taken now, synchronously: a call made after this point is later than the config and must
236
+ // stay parked so it is applied after it.
237
+ const parked = takeParkedAppLanguage();
238
+ try {
239
+ await getNativeModule().initialize(withParkedAppLanguage(config, parked));
240
+ } catch (error) {
241
+ // Nothing reached the SDK, so the call still has to — unless a newer one replaced it.
242
+ if (parkedAppLanguage == null && isLatestLanguageWrite(parked)) {
243
+ parkedAppLanguage = parked;
244
+ }
245
+ throw error;
246
+ }
247
+ // A parked clear cannot travel in the config, where null means "leave it alone". Replayed only
248
+ // if nothing newer came in while initialize was in flight: the native SDK reports itself up
249
+ // before this promise settles, and a call made in that window has already reached it.
250
+ if (parked?.tag === null && !hasText(config.appLanguage) && isLatestLanguageWrite(parked)) {
251
+ getNativeModule().setAppLanguage(null);
252
+ }
253
+ flushParkedAppLanguage();
236
254
  // Anything the host registered before this point was parked rather than lost.
237
255
  replayPendingRegistrations();
238
256
  }
239
257
 
258
+ /**
259
+ * A {@link setAppLanguage} call made before the SDK was up. Boxed so that a parked `null` — a
260
+ * request to return to automatic detection — is distinguishable from nothing parked.
261
+ *
262
+ * The native bridges buffer such calls too, but replay them AFTER building the SDK, which applies
263
+ * the config's `appLanguage` first. An earlier call would then overwrite a later config value,
264
+ * the opposite of "last write wins". Parking it here lets {@link initialize} put the two in the
265
+ * order they were made.
266
+ */
267
+ let parkedAppLanguage: LanguageWrite | null = null;
268
+
269
+ /**
270
+ * One {@link setAppLanguage} call. `seq` orders it against every other call, forwarded or held:
271
+ * a held value is applied only while it is still the latest write, so one taken before
272
+ * initialize cannot be replayed over a call the native SDK received while initialize was in
273
+ * flight.
274
+ */
275
+ interface LanguageWrite {
276
+ readonly tag: string | null;
277
+ readonly seq: number;
278
+ }
279
+
280
+ /** Bumped by every {@link setAppLanguage} call, whether it is forwarded or held. */
281
+ let languageWriteSeq = 0;
282
+
283
+ function isLatestLanguageWrite(write: LanguageWrite | null): write is LanguageWrite {
284
+ return write != null && write.seq === languageWriteSeq;
285
+ }
286
+
287
+ function hasText(value: string | null | undefined): value is string {
288
+ return typeof value === 'string' && value.trim().length > 0;
289
+ }
290
+
291
+ function takeParkedAppLanguage(): LanguageWrite | null {
292
+ const parked = parkedAppLanguage;
293
+ parkedAppLanguage = null;
294
+ return parked;
295
+ }
296
+
297
+ /** The config's own appLanguage wins: initialize was called after the parked call. */
298
+ function withParkedAppLanguage(
299
+ config: AmplyInitializationConfig,
300
+ parked: LanguageWrite | null,
301
+ ): AmplyInitializationConfig {
302
+ if (hasText(config.appLanguage) || parked == null || parked.tag === null) {
303
+ return config;
304
+ }
305
+ return {...config, appLanguage: parked.tag};
306
+ }
307
+
308
+ function flushParkedAppLanguage(): void {
309
+ const parked = takeParkedAppLanguage();
310
+ if (isLatestLanguageWrite(parked)) {
311
+ getNativeModule().setAppLanguage(parked.tag);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Tell Amply the language your app's UI is in, so sheets, stories and mini-apps show in the
317
+ * matching language and campaigns can target it. Use this when the app chooses its language
318
+ * itself (an in-app language picker, i18next, …) rather than following the device.
319
+ *
320
+ * Any common tag format works — `'pt_BR'`, `'pt-BR'`, `'zh-TW'`, `'fr'`, any case. A value that
321
+ * is not a language tag is ignored by the SDK with a warning log. The value is persisted across
322
+ * launches; pass `null` to return to automatic detection. The device language is never changed.
323
+ *
324
+ * Safe to call before {@link initialize}: the value is held and applied at initialization, and
325
+ * an `appLanguage` passed to a later `initialize` call wins over it.
326
+ * @param tag The language tag, or null to clear
327
+ */
328
+ export function setAppLanguage(tag: string | null): void {
329
+ languageWriteSeq += 1;
330
+ if (isSdkUp()) {
331
+ getNativeModule().setAppLanguage(tag);
332
+ return;
333
+ }
334
+ parkedAppLanguage = {tag, seq: languageWriteSeq};
335
+ }
336
+
240
337
  /**
241
338
  * Set the user ID for analytics. Pass null to clear.
242
339
  * @param userId The user ID to set, or null to clear
@@ -642,6 +739,7 @@ export default {
642
739
  addSystemEventsListener,
643
740
  removeAllListeners,
644
741
  setUserId,
742
+ setAppLanguage,
645
743
  setLogLevel,
646
744
  getLogLevel,
647
745
  setCustomProperty,
@@ -46,6 +46,12 @@ export type AmplyInitializationConfig = {
46
46
  * Takes precedence over `debug` if both are specified.
47
47
  */
48
48
  logLevel?: LogLevel | null;
49
+ /**
50
+ * The language the app's UI is in, in any common format (`'pt_BR'`, `'zh-TW'`, `'fr'`).
51
+ * The same as calling `setAppLanguage` right after initialization, and persisted the same
52
+ * way. Omitted, null or blank leaves the current setting alone.
53
+ */
54
+ appLanguage?: string | null;
49
55
  };
50
56
 
51
57
  export type EventType = 'custom' | 'system';
@@ -202,6 +208,12 @@ export interface Spec extends TurboModule {
202
208
  * @param userId The user ID to set, or null to clear
203
209
  */
204
210
  setUserId(userId: string | null): void;
211
+ /**
212
+ * Set the language the app's UI is in. Any common tag format; null returns to automatic
213
+ * detection. Persisted by the SDK across launches.
214
+ * @param tag The language tag, or null to clear
215
+ */
216
+ setAppLanguage(tag: string | null): void;
205
217
  /**
206
218
  * Set the log level at runtime.
207
219
  * @param level The log level to set