@goliapkg/sentori-react-native 6.2.1 → 6.4.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.
- package/android/build.gradle +6 -0
- package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
- package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
- package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
- package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
- package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
- package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
- package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/core/SentoriCrashHandler.swift +15 -0
- package/ios/core/SentoriPushNotifications.swift +8 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +155 -1
- package/lib/push.js.map +1 -1
- package/lib/scope.d.ts +6 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +45 -1
- package/lib/scope.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/push.test.ts +203 -1
- package/src/push.ts +147 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/core/SentoriPush.swift +0 -340
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
register,
|
|
16
16
|
} from '../push';
|
|
17
17
|
import { __resetForTests as resetConfig, setConfig } from '../config';
|
|
18
|
-
import { __resetForTests as resetScope } from '../scope';
|
|
18
|
+
import { __resetForTests as resetScope, setUser } from '../scope';
|
|
19
19
|
|
|
20
20
|
const baseConfig = {
|
|
21
21
|
token: 'st_test',
|
|
@@ -202,3 +202,205 @@ describe('push.register', () => {
|
|
|
202
202
|
expect(seen).not.toBeNull();
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
// A device registers at launch; the person signs in ten seconds
|
|
207
|
+
// later. Until now nothing updated the row, so it carried no user for
|
|
208
|
+
// the life of the install — and a send aimed at that user reached
|
|
209
|
+
// nobody and reported success.
|
|
210
|
+
describe('push registration follows the person', () => {
|
|
211
|
+
/** Record every /v1/push/tokens body, so a test can say what the
|
|
212
|
+
* server was told rather than that it was told something. */
|
|
213
|
+
function recordingFetch(): Array<Record<string, unknown>> {
|
|
214
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
215
|
+
globalThis.fetch = ((_url: string, init?: { body?: string }) => {
|
|
216
|
+
if (init?.body != null) {
|
|
217
|
+
seen.push(JSON.parse(init.body) as Record<string, unknown>);
|
|
218
|
+
}
|
|
219
|
+
return Promise.resolve({
|
|
220
|
+
ok: true,
|
|
221
|
+
status: 202,
|
|
222
|
+
json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
|
|
223
|
+
});
|
|
224
|
+
}) as unknown as typeof fetch;
|
|
225
|
+
return seen;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `user()` hashes on a promise, so the key lands a tick later and
|
|
229
|
+
* the re-registration is sent after that. */
|
|
230
|
+
const settle = () => new Promise((r) => setTimeout(r, 10));
|
|
231
|
+
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
resetPush();
|
|
234
|
+
resetConfig();
|
|
235
|
+
resetScope();
|
|
236
|
+
setPlatform('ios');
|
|
237
|
+
setConfig(baseConfig);
|
|
238
|
+
__setNativeForTests(grantingNative());
|
|
239
|
+
});
|
|
240
|
+
afterEach(() => {
|
|
241
|
+
resetPush();
|
|
242
|
+
resetConfig();
|
|
243
|
+
resetScope();
|
|
244
|
+
setPlatform(null);
|
|
245
|
+
__setNativeForTests(undefined);
|
|
246
|
+
globalThis.fetch = realFetch;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('sends the identity when the person signs in after registering', async () => {
|
|
250
|
+
const seen = recordingFetch();
|
|
251
|
+
await register();
|
|
252
|
+
expect(seen).toHaveLength(1);
|
|
253
|
+
expect(seen[0]?.userKey).toBeUndefined();
|
|
254
|
+
|
|
255
|
+
setUser({ id: 'usr_123', traits: { plan: 'pro' } });
|
|
256
|
+
await settle();
|
|
257
|
+
|
|
258
|
+
// Two assertions, because either alone passes on a mistake: a
|
|
259
|
+
// count alone passes if the update carries nothing, and a key
|
|
260
|
+
// alone passes if the update never happened and this is still the
|
|
261
|
+
// first request.
|
|
262
|
+
expect(seen).toHaveLength(2);
|
|
263
|
+
expect(typeof seen[1]?.userKey).toBe('string');
|
|
264
|
+
expect(seen[1]?.traits).toEqual({ plan: 'pro' });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('does not send anything when the same person is set again', async () => {
|
|
268
|
+
const seen = recordingFetch();
|
|
269
|
+
await register();
|
|
270
|
+
setUser({ id: 'usr_123', traits: { plan: 'pro' } });
|
|
271
|
+
await settle();
|
|
272
|
+
const after = seen.length;
|
|
273
|
+
|
|
274
|
+
// `user()` is a verb an app may call on every screen. One request
|
|
275
|
+
// per call is not free to a host, and the iron rule is that we are.
|
|
276
|
+
setUser({ id: 'usr_123', traits: { plan: 'pro' } });
|
|
277
|
+
await settle();
|
|
278
|
+
expect(seen).toHaveLength(after);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('clears the traits when the person signs out', async () => {
|
|
282
|
+
const seen = recordingFetch();
|
|
283
|
+
await register();
|
|
284
|
+
setUser({ id: 'usr_123', traits: { plan: 'pro' } });
|
|
285
|
+
await settle();
|
|
286
|
+
setUser(null);
|
|
287
|
+
await settle();
|
|
288
|
+
|
|
289
|
+
const last = seen[seen.length - 1];
|
|
290
|
+
expect(last?.userKey).toBeUndefined();
|
|
291
|
+
// `{}` rather than absent: absent means "keep what is there", so
|
|
292
|
+
// a signed-out device would stay selectable as a pro user.
|
|
293
|
+
expect(last?.traits).toEqual({});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('does not register a device that never registered', async () => {
|
|
297
|
+
const seen = recordingFetch();
|
|
298
|
+
setUser({ id: 'usr_123' });
|
|
299
|
+
await settle();
|
|
300
|
+
expect(seen).toHaveLength(0);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// A vendor token is not an address. It rotates — on a reinstall, a
|
|
305
|
+
// restore, cleared app data — and the server keys the row on it, so a
|
|
306
|
+
// rotation wrote a *new* row under a *new* spToken and every backend
|
|
307
|
+
// holding the old one was addressing nothing.
|
|
308
|
+
//
|
|
309
|
+
// The native SDKs were given an installId and a rotation report. This
|
|
310
|
+
// package was not: its registration goes through JS, and the native
|
|
311
|
+
// rotation hook it does have refuses to act on a device the *native*
|
|
312
|
+
// side never registered — which, for a React Native app, is every
|
|
313
|
+
// device.
|
|
314
|
+
describe('push survives the vendor rotating its token', () => {
|
|
315
|
+
function recordingFetch(): Array<Record<string, unknown>> {
|
|
316
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
317
|
+
globalThis.fetch = ((_url: string, init?: { body?: string }) => {
|
|
318
|
+
if (init?.body != null) {
|
|
319
|
+
seen.push(JSON.parse(init.body) as Record<string, unknown>);
|
|
320
|
+
}
|
|
321
|
+
return Promise.resolve({
|
|
322
|
+
ok: true,
|
|
323
|
+
status: 202,
|
|
324
|
+
json: () => Promise.resolve({ spToken: 'dev-1', token_id: 'dev-1' }),
|
|
325
|
+
});
|
|
326
|
+
}) as unknown as typeof fetch;
|
|
327
|
+
return seen;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
beforeEach(() => {
|
|
331
|
+
resetPush();
|
|
332
|
+
resetConfig();
|
|
333
|
+
resetScope();
|
|
334
|
+
setPlatform('ios');
|
|
335
|
+
setConfig(baseConfig);
|
|
336
|
+
});
|
|
337
|
+
afterEach(() => {
|
|
338
|
+
resetPush();
|
|
339
|
+
resetConfig();
|
|
340
|
+
resetScope();
|
|
341
|
+
setPlatform(null);
|
|
342
|
+
__setNativeForTests(undefined);
|
|
343
|
+
globalThis.fetch = realFetch;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('tells the server which installation this is', async () => {
|
|
347
|
+
__setNativeForTests(grantingNative());
|
|
348
|
+
const seen = recordingFetch();
|
|
349
|
+
await register();
|
|
350
|
+
const installId = seen[0]?.installId;
|
|
351
|
+
expect(typeof installId).toBe('string');
|
|
352
|
+
expect(String(installId).length).toBeGreaterThan(8);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('keeps the same installation across registrations', async () => {
|
|
356
|
+
__setNativeForTests(grantingNative());
|
|
357
|
+
const seen = recordingFetch();
|
|
358
|
+
await register();
|
|
359
|
+
await register();
|
|
360
|
+
expect(seen).toHaveLength(2);
|
|
361
|
+
// Asserting equality alone passes when neither call sent one —
|
|
362
|
+
// `undefined === undefined` — which is the state this test
|
|
363
|
+
// exists to reject. So: a string, and the same string.
|
|
364
|
+
expect(typeof seen[0]?.installId).toBe('string');
|
|
365
|
+
expect(seen[1]?.installId).toBe(seen[0]?.installId);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('reports a token that rotated while the app was running', async () => {
|
|
369
|
+
// The vendor hands back a different token on the second drain,
|
|
370
|
+
// which is what a rotation looks like from here.
|
|
371
|
+
let handed = 0;
|
|
372
|
+
__setNativeForTests(
|
|
373
|
+
grantingNative({
|
|
374
|
+
pushDrainState: () => {
|
|
375
|
+
handed += 1;
|
|
376
|
+
return Promise.resolve({
|
|
377
|
+
notifications: [],
|
|
378
|
+
taps: [],
|
|
379
|
+
token: handed <= 1 ? 'abc123' : 'rotated456',
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
const seen = recordingFetch();
|
|
385
|
+
await register();
|
|
386
|
+
expect(seen).toHaveLength(1);
|
|
387
|
+
|
|
388
|
+
// One turn of the drain loop, which already runs at 1 Hz and
|
|
389
|
+
// already reads the token — it just threw it away.
|
|
390
|
+
await new Promise((r) => setTimeout(r, 1400));
|
|
391
|
+
|
|
392
|
+
expect(seen).toHaveLength(2);
|
|
393
|
+
expect(seen[1]?.nativeToken).toBe('rotated456');
|
|
394
|
+
// Same installation, or the server writes a second row and the
|
|
395
|
+
// rotation has achieved exactly what it used to.
|
|
396
|
+
expect(seen[1]?.installId).toBe(seen[0]?.installId);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('does not report the same token twice', async () => {
|
|
400
|
+
__setNativeForTests(grantingNative());
|
|
401
|
+
const seen = recordingFetch();
|
|
402
|
+
await register();
|
|
403
|
+
await new Promise((r) => setTimeout(r, 2400));
|
|
404
|
+
expect(seen).toHaveLength(1);
|
|
405
|
+
});
|
|
406
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { logger, pushSignal } from '@goliapkg/sentori-core'
|
|
25
25
|
|
|
26
|
-
import { currentUserKey } from './scope.js'
|
|
26
|
+
import { currentUserKey, currentUserTraits, onIdentityChange } from './scope.js'
|
|
27
27
|
|
|
28
28
|
// AppState is RN-only; we treat it dynamically so the SDK keeps
|
|
29
29
|
// importing cleanly under Bun / web.
|
|
@@ -44,8 +44,25 @@ import {
|
|
|
44
44
|
} from './native.js'
|
|
45
45
|
|
|
46
46
|
const STORAGE_KEY = 'sentori.push.ipt'
|
|
47
|
+
/// Which installation this is.
|
|
48
|
+
///
|
|
49
|
+
/// Minted once and kept, so the server can key the device row on it
|
|
50
|
+
/// rather than on the vendor's token. Without one a rotation writes a
|
|
51
|
+
/// *new* row under a *new* spToken, and every backend holding the old
|
|
52
|
+
/// one is addressing nothing — with nothing to tell it. The native
|
|
53
|
+
/// SDKs were given this; this package registers from JS and was not.
|
|
54
|
+
const INSTALL_KEY = 'sentori.push.install'
|
|
47
55
|
|
|
48
56
|
let _cachedIpt: null | string = null
|
|
57
|
+
// The last token the vendor handed us, kept so a sign-in can re-send
|
|
58
|
+
// the registration without asking the OS for a token again.
|
|
59
|
+
let _lastNativeToken: null | string = null
|
|
60
|
+
let _lastOptions: PushRegisterOptions = {}
|
|
61
|
+
// What the server was last told. A re-registration that would send
|
|
62
|
+
// the same thing is not sent: `user()` is a verb an app may call on
|
|
63
|
+
// every screen, and one HTTP request per call is not free to a host.
|
|
64
|
+
let _lastSentIdentity: string | null = null
|
|
65
|
+
let _installId: null | string = null
|
|
49
66
|
let _drainInterval: ReturnType<typeof setInterval> | null = null
|
|
50
67
|
let _appStateSubscription: { remove: () => void } | null = null
|
|
51
68
|
let _backgrounded = false
|
|
@@ -176,9 +193,13 @@ export async function register(opts: PushRegisterOptions = {}): Promise<PushRegi
|
|
|
176
193
|
const token = await waitForToken(opts.tokenTimeoutMs ?? 8000)
|
|
177
194
|
const ipt = await registerWithServer(cfg, token, opts)
|
|
178
195
|
_cachedIpt = ipt
|
|
196
|
+
_lastNativeToken = token
|
|
197
|
+
_lastOptions = opts
|
|
179
198
|
void persistIpt(ipt)
|
|
180
199
|
opts.onToken?.(ipt)
|
|
181
200
|
bindBufferDrain(opts.onMessage, opts.onTap)
|
|
201
|
+
// From here on, signing in or out updates the row by itself.
|
|
202
|
+
onIdentityChange(reRegisterAfterIdentityChange)
|
|
182
203
|
return { ok: true, ipt }
|
|
183
204
|
} catch (e) {
|
|
184
205
|
const err = e instanceof Error ? e : new Error(String(e))
|
|
@@ -199,6 +220,29 @@ function fail(
|
|
|
199
220
|
return { ok: false, message, reason }
|
|
200
221
|
}
|
|
201
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Send the registration again because the person changed.
|
|
225
|
+
*
|
|
226
|
+
* Only for a device that has already registered — the vendor token is
|
|
227
|
+
* the evidence, the same rule the native SDKs use for a rotated
|
|
228
|
+
* token. Nothing here throws and nothing here is awaited by the host:
|
|
229
|
+
* `user()` is synchronous and stays that way.
|
|
230
|
+
*/
|
|
231
|
+
function reRegisterAfterIdentityChange(): void {
|
|
232
|
+
const token = _lastNativeToken
|
|
233
|
+
const cfg = tryGetRuntimeConfig()
|
|
234
|
+
if (token == null || cfg == null) return
|
|
235
|
+
const identity = JSON.stringify([currentUserKey() ?? null, currentUserTraits() ?? null])
|
|
236
|
+
if (identity === _lastSentIdentity) return
|
|
237
|
+
_lastSentIdentity = identity
|
|
238
|
+
void registerWithServer(cfg, token, _lastOptions).catch((e: unknown) => {
|
|
239
|
+
// A failed update leaves the row pointing at the previous person,
|
|
240
|
+
// so the next change must be allowed to try again.
|
|
241
|
+
_lastSentIdentity = null
|
|
242
|
+
logger.warn('push', 'updating the device after a sign-in failed', e)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
202
246
|
/** Internal carrier so the helpers below can name which failure they
|
|
203
247
|
* are without every one of them returning a result object. */
|
|
204
248
|
class PushRegisterError extends Error {
|
|
@@ -228,6 +272,9 @@ export async function unregister(): Promise<void> {
|
|
|
228
272
|
}
|
|
229
273
|
}
|
|
230
274
|
nativePushUnregister()
|
|
275
|
+
onIdentityChange(undefined)
|
|
276
|
+
_lastNativeToken = null
|
|
277
|
+
_lastSentIdentity = null
|
|
231
278
|
_cachedIpt = null
|
|
232
279
|
void clearPersistedIpt()
|
|
233
280
|
teardownBufferDrain()
|
|
@@ -295,6 +342,10 @@ async function registerWithServer(
|
|
|
295
342
|
// `kind`, not `provider` — the server's field name, which this
|
|
296
343
|
// sent for a year as `provider` and got a 422 for every time.
|
|
297
344
|
const body: Record<string, unknown> = {
|
|
345
|
+
// Which installation this is. The server keys the row on it, so a
|
|
346
|
+
// rotated token updates this device rather than creating a second
|
|
347
|
+
// one under a new address.
|
|
348
|
+
installId: await installId(),
|
|
298
349
|
kind: isAndroid ? 'fcm' : 'apns',
|
|
299
350
|
nativeToken,
|
|
300
351
|
// The same salted identity hash every event carries, so the
|
|
@@ -310,6 +361,12 @@ async function registerWithServer(
|
|
|
310
361
|
// out it went nowhere. (Insight asked whether it was stored; it was
|
|
311
362
|
// not — 2026-08-11.)
|
|
312
363
|
if (opts.metadata != null) body.metadata = opts.metadata
|
|
364
|
+
// Attributes of the person rather than of the device, kept apart so
|
|
365
|
+
// a build channel called "pro" cannot answer a send aimed at the pro
|
|
366
|
+
// plan. Absent leaves whatever the row already had; `{}` clears it,
|
|
367
|
+
// which is what signing out sends.
|
|
368
|
+
const traits = currentUserTraits()
|
|
369
|
+
if (traits != null) body.traits = traits
|
|
313
370
|
const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
|
|
314
371
|
method: 'POST',
|
|
315
372
|
headers: {
|
|
@@ -362,6 +419,45 @@ function teardownBufferDrain(): void {
|
|
|
362
419
|
async function pumpOnce(): Promise<void> {
|
|
363
420
|
const state = await pushDrainState()
|
|
364
421
|
flushBuffered(state.notifications, state.taps)
|
|
422
|
+
await reportRotationIfAny(state.token)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// Tell the server when the vendor has handed the app a different
|
|
426
|
+
/// token from the one it registered with.
|
|
427
|
+
///
|
|
428
|
+
/// Both native layers already route a rotation into the *native*
|
|
429
|
+
/// `SentoriPush.handleRotatedToken`, and that refuses to act on a
|
|
430
|
+
/// device the native side never registered — which, for a React
|
|
431
|
+
/// Native app, is every device: registration goes through JS and the
|
|
432
|
+
/// handle lives in AsyncStorage. So the rotation arrived nowhere, and
|
|
433
|
+
/// the server held a dead token until the host next called
|
|
434
|
+
/// `register()`. For an app that stays resident that is not a bounded
|
|
435
|
+
/// wait.
|
|
436
|
+
///
|
|
437
|
+
/// This is the drain loop that already runs at 1 Hz and already reads
|
|
438
|
+
/// the token; it threw it away. Foreground only, like the rest of the
|
|
439
|
+
/// loop — a rotation while backgrounded is reported on the next tick
|
|
440
|
+
/// after the app comes back, which is bounded.
|
|
441
|
+
async function reportRotationIfAny(token: string | undefined): Promise<void> {
|
|
442
|
+
if (token == null || token === '' || token === _lastNativeToken) return
|
|
443
|
+
// Only a device that has registered: a token arriving for one that
|
|
444
|
+
// never did is not something to act on unasked, which is the rule
|
|
445
|
+
// both native SDKs follow.
|
|
446
|
+
if (_lastNativeToken == null) return
|
|
447
|
+
const cfg = tryGetRuntimeConfig()
|
|
448
|
+
if (cfg == null) return
|
|
449
|
+
const previous = _lastNativeToken
|
|
450
|
+
_lastNativeToken = token
|
|
451
|
+
try {
|
|
452
|
+
const ipt = await registerWithServer(cfg, token, _lastOptions)
|
|
453
|
+
_cachedIpt = ipt
|
|
454
|
+
void persistIpt(ipt)
|
|
455
|
+
} catch (e) {
|
|
456
|
+
// Put it back, or one failed report means the rotation is never
|
|
457
|
+
// mentioned again and the device is unreachable for good.
|
|
458
|
+
_lastNativeToken = previous
|
|
459
|
+
logger.warn('push', 'reporting a rotated token failed', e)
|
|
460
|
+
}
|
|
365
461
|
}
|
|
366
462
|
|
|
367
463
|
function flushBuffered(
|
|
@@ -487,6 +583,51 @@ function startAppStateWatch(): void {
|
|
|
487
583
|
}
|
|
488
584
|
}
|
|
489
585
|
|
|
586
|
+
/// This installation's id, minted on first use and kept after.
|
|
587
|
+
///
|
|
588
|
+
/// Falls back to a module-scoped value when there is no AsyncStorage,
|
|
589
|
+
/// which is the same fallback the handle uses: worse than persistent,
|
|
590
|
+
/// better than nothing, and it still keeps one launch's rotations
|
|
591
|
+
/// pointed at one row.
|
|
592
|
+
async function installId(): Promise<string> {
|
|
593
|
+
if (_installId != null) return _installId
|
|
594
|
+
const storage = await tryAsyncStorage()
|
|
595
|
+
if (storage) {
|
|
596
|
+
try {
|
|
597
|
+
const seen = await storage.getItem(INSTALL_KEY)
|
|
598
|
+
if (typeof seen === 'string' && seen.length > 0) {
|
|
599
|
+
_installId = seen
|
|
600
|
+
return seen
|
|
601
|
+
}
|
|
602
|
+
} catch {
|
|
603
|
+
// An unreadable store is a store with nothing in it.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const fresh = randomId()
|
|
607
|
+
_installId = fresh
|
|
608
|
+
if (storage) {
|
|
609
|
+
try {
|
|
610
|
+
await storage.setItem(INSTALL_KEY, fresh)
|
|
611
|
+
} catch (e) {
|
|
612
|
+
logger.warn('push', 'AsyncStorage.setItem failed', e)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return fresh
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/// A uuid, without requiring one to exist.
|
|
619
|
+
///
|
|
620
|
+
/// `crypto.randomUUID` is present on newer Hermes and absent on older
|
|
621
|
+
/// ones, and this must not be the line that decides whether push
|
|
622
|
+
/// works on an older engine.
|
|
623
|
+
function randomId(): string {
|
|
624
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
|
|
625
|
+
if (typeof c?.randomUUID === 'function') return c.randomUUID()
|
|
626
|
+
let out = ''
|
|
627
|
+
for (let i = 0; i < 32; i++) out += Math.floor(Math.random() * 16).toString(16)
|
|
628
|
+
return out
|
|
629
|
+
}
|
|
630
|
+
|
|
490
631
|
async function persistIpt(ipt: string): Promise<void> {
|
|
491
632
|
const storage = await tryAsyncStorage()
|
|
492
633
|
if (!storage) return
|
|
@@ -542,9 +683,14 @@ function joinUrl(base: string, path: string): string {
|
|
|
542
683
|
* into the next. Production code paths must not call this. */
|
|
543
684
|
export function __resetForTests(): void {
|
|
544
685
|
_cachedIpt = null
|
|
686
|
+
_installId = null
|
|
545
687
|
_onMessage = undefined
|
|
546
688
|
_onTap = undefined
|
|
547
689
|
_ackQueue = []
|
|
690
|
+
_lastNativeToken = null
|
|
691
|
+
_lastOptions = {}
|
|
692
|
+
_lastSentIdentity = null
|
|
693
|
+
onIdentityChange(undefined)
|
|
548
694
|
teardownBufferDrain()
|
|
549
695
|
}
|
|
550
696
|
|
package/src/scope.ts
CHANGED
|
@@ -9,12 +9,50 @@ let _user: User | null = null;
|
|
|
9
9
|
let _userKey: string | undefined;
|
|
10
10
|
let _context: Record<string, unknown> = {};
|
|
11
11
|
let _hashGeneration = 0;
|
|
12
|
+
let _traits: Record<string, unknown> | undefined;
|
|
13
|
+
|
|
14
|
+
// What to tell when the person changes.
|
|
15
|
+
//
|
|
16
|
+
// The device row carries the identity, and until now nothing updated
|
|
17
|
+
// it after registration: an app that registers for push at launch and
|
|
18
|
+
// signs in ten seconds later — which is every app with a login screen
|
|
19
|
+
// — held a row with no user on it for the life of the install. A send
|
|
20
|
+
// aimed at that user reached nobody and reported success.
|
|
21
|
+
//
|
|
22
|
+
// A callback rather than a direct call because push.ts already imports
|
|
23
|
+
// this file; the arrow has to point one way.
|
|
24
|
+
let _onIdentityChange: (() => void) | undefined;
|
|
25
|
+
|
|
26
|
+
/** Register interest in identity changes. Only push does. */
|
|
27
|
+
export const onIdentityChange = (fn: (() => void) | undefined): void => {
|
|
28
|
+
_onIdentityChange = fn;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const announce = (): void => {
|
|
32
|
+
try {
|
|
33
|
+
_onIdentityChange?.();
|
|
34
|
+
} catch {
|
|
35
|
+
// NEVER rule: whatever the listener does, user() returns.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
12
38
|
|
|
13
39
|
export const setUser = safeFn('user', function setUser(u: User | null): void {
|
|
14
40
|
_user = u;
|
|
15
41
|
_userKey = undefined;
|
|
42
|
+
// A call to `user()` describes the person completely, so one made
|
|
43
|
+
// without traits means they have none — not "leave the last ones".
|
|
44
|
+
// Absent and empty are different on the wire: absent keeps what the
|
|
45
|
+
// row has, and a signed-out device that kept them would still be
|
|
46
|
+
// selectable as whoever just left.
|
|
47
|
+
_traits = u?.traits == null ? {} : { ...u.traits };
|
|
16
48
|
const generation = ++_hashGeneration;
|
|
17
|
-
if (u === null)
|
|
49
|
+
if (u === null) {
|
|
50
|
+
// Signing out is an identity change too, and the one where a
|
|
51
|
+
// stale row matters most: the device would otherwise keep
|
|
52
|
+
// answering to the person who just left.
|
|
53
|
+
announce();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
18
56
|
// The wire carries only a salted hash — raw identity never leaves
|
|
19
57
|
// the device (breadth × depth needs distinctness, not identity).
|
|
20
58
|
// Hashing is async (WebCrypto); the verb stays synchronous and the
|
|
@@ -24,6 +62,9 @@ export const setUser = safeFn('user', function setUser(u: User | null): void {
|
|
|
24
62
|
.then((hashes) => {
|
|
25
63
|
if (generation === _hashGeneration) {
|
|
26
64
|
_userKey = hashes.id ?? hashes.email;
|
|
65
|
+
// Announced after the hash lands, not before: the whole point
|
|
66
|
+
// is to send the new key, and the key is what arrives late.
|
|
67
|
+
announce();
|
|
27
68
|
}
|
|
28
69
|
})
|
|
29
70
|
.catch(() => {
|
|
@@ -39,12 +80,20 @@ export const patchContext = safeFn('context', function patchContext(
|
|
|
39
80
|
|
|
40
81
|
export const currentUserKey = (): string | undefined => _userKey;
|
|
41
82
|
|
|
83
|
+
/** The person's attributes, for the device row. Undefined when the
|
|
84
|
+
* host has not set any — which is different from `{}`, the value that
|
|
85
|
+
* clears them. */
|
|
86
|
+
export const currentUserTraits = (): Record<string, unknown> | undefined =>
|
|
87
|
+
_traits === undefined ? undefined : { ..._traits };
|
|
88
|
+
|
|
42
89
|
export const currentContext = (): Record<string, unknown> | undefined =>
|
|
43
90
|
Object.keys(_context).length > 0 ? { ..._context } : undefined;
|
|
44
91
|
|
|
45
92
|
export const __resetForTests = (): void => {
|
|
46
93
|
_user = null;
|
|
47
94
|
_userKey = undefined;
|
|
95
|
+
_traits = undefined;
|
|
48
96
|
_context = {};
|
|
97
|
+
_onIdentityChange = undefined;
|
|
49
98
|
_hashGeneration++;
|
|
50
99
|
};
|
package/src/transport.ts
CHANGED
|
@@ -20,7 +20,7 @@ const STORAGE_KEY = '@sentori/pending';
|
|
|
20
20
|
const MAX_PERSISTED = 1000;
|
|
21
21
|
|
|
22
22
|
// Pinned to package.json by a test — bump both together.
|
|
23
|
-
const SDK_VERSION = '6.
|
|
23
|
+
const SDK_VERSION = '6.4.0';
|
|
24
24
|
|
|
25
25
|
let _queue: WireEvent[] = [];
|
|
26
26
|
let _assertStats = new Map<string, AssertStat>();
|