@goliapkg/sentori-react-native 6.2.0 → 6.3.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 (56) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +21 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  6. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  7. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
  8. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
  10. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  11. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  14. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  15. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  16. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  17. package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
  19. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  20. package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
  21. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  22. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  23. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
  24. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  27. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
  28. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  29. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
  30. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  31. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  32. package/ios/core/SentoriAttachment.swift +129 -0
  33. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
  34. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  35. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  36. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  37. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
  38. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  39. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  40. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  41. package/lib/push.d.ts.map +1 -1
  42. package/lib/push.js +51 -1
  43. package/lib/push.js.map +1 -1
  44. package/lib/scope.d.ts +6 -0
  45. package/lib/scope.d.ts.map +1 -1
  46. package/lib/scope.js +45 -1
  47. package/lib/scope.js.map +1 -1
  48. package/lib/transport.js +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/push.test.ts +99 -1
  51. package/src/push.ts +49 -1
  52. package/src/scope.ts +50 -1
  53. package/src/transport.ts +1 -1
  54. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  55. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  56. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -0,0 +1,588 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriPush.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import android.app.Activity
7
+ import android.content.Context
8
+ import java.net.HttpURLConnection
9
+ import java.net.URL
10
+ import java.util.concurrent.Executors
11
+ import java.util.concurrent.ScheduledFuture
12
+ import java.util.concurrent.TimeUnit
13
+ import org.json.JSONObject
14
+
15
+ /**
16
+ * Push, as an app writes it.
17
+ *
18
+ * Sentori.push.register(activity) { result ->
19
+ * if (result is SentoriPush.Result.Failure) { … }
20
+ * }
21
+ *
22
+ * The pieces underneath already existed — the POST_NOTIFICATIONS
23
+ * prompt, the FCM service, the token and tap buffers — and were
24
+ * reachable only through the React Native bridge. What was missing
25
+ * everywhere but JavaScript is the part that puts a token on the
26
+ * server, which is the only reason a registered device is reachable
27
+ * at all.
28
+ *
29
+ * Asked for by insight (2026-08-11): two apps with no React Native,
30
+ * blocked, and unwilling to reimplement the HTTP contract because a
31
+ * second implementation drifts silently on the one path nobody
32
+ * watches.
33
+ *
34
+ * Callback-based rather than suspending, so this is usable from Java
35
+ * and from a codebase that has not adopted coroutines. The callback
36
+ * runs on a background thread; touch UI from it at your own risk.
37
+ */
38
+ object SentoriPush {
39
+
40
+ /**
41
+ * Why a registration did not produce a device handle. Same five
42
+ * names as `PushRegisterFailure` in the React Native SDK and the
43
+ * Swift one, so a single set of integration notes covers all
44
+ * three and an operator reading a support thread does not have to
45
+ * translate.
46
+ */
47
+ enum class Failure(val reason: String) {
48
+ /** [Sentori.start] has not run. A wiring bug. */
49
+ NOT_INITIALISED("not-initialised"),
50
+ /**
51
+ * The user said no. Not an error: do not retry on a timer,
52
+ * and offer it again from a settings screen.
53
+ */
54
+ PERMISSION_DENIED("permission-denied"),
55
+ /**
56
+ * No Firebase in this build, or no `google-services.json`.
57
+ * Nothing to do at runtime.
58
+ */
59
+ NO_TRANSPORT("no-transport"),
60
+ /**
61
+ * FCM never handed back a token inside the window. Retrying
62
+ * later is reasonable.
63
+ */
64
+ TOKEN_TIMEOUT("token-timeout"),
65
+ /** Sentori answered non-2xx. Settings ▸ Push is where to look. */
66
+ SERVER_REJECTED("server-rejected"),
67
+ }
68
+
69
+ /**
70
+ * Registration never throws. A denied permission is an ordinary
71
+ * answer, and an opt-in that throws inside someone's ViewModel is
72
+ * the failure this SDK's contract with its host is written
73
+ * against.
74
+ */
75
+ sealed class Result {
76
+ /**
77
+ * The `device_tokens` row id. Revoking takes it, and so does a
78
+ * targeted send.
79
+ */
80
+ data class Success(val handle: String) : Result()
81
+
82
+ data class Failure(val reason: SentoriPush.Failure, val message: String) : Result()
83
+ }
84
+
85
+ private val lock = Any()
86
+ private var cachedHandle: String? = null
87
+ private var onMessage: ((Map<String, Any?>) -> Unit)? = null
88
+ private var onTap: ((Map<String, Any?>) -> Unit)? = null
89
+ private var drainTask: ScheduledFuture<*>? = null
90
+
91
+ private const val PREFS = "com.sentori.push"
92
+ private const val HANDLE_KEY = "handle"
93
+
94
+ /**
95
+ * Which installation this is, kept for as long as the app is
96
+ * installed.
97
+ *
98
+ * The server keys the device row on it, so a vendor rotating its
99
+ * token becomes an update of the row that already exists rather
100
+ * than a new row with a new address. Before this, a rotation
101
+ * silently retired whatever `spToken` a backend was holding.
102
+ *
103
+ * Generated here rather than issued by the server because it has
104
+ * to exist before the first registration, and it never leaves the
105
+ * device except in that registration — it is not the address, and
106
+ * an identifier that can claim a row must not be one that travels.
107
+ */
108
+ private const val INSTALL_KEY = "install_id"
109
+
110
+ /**
111
+ * The last token the vendor issued, so a sign-in can send the
112
+ * registration again without asking FCM for a token first.
113
+ *
114
+ * It is already in this app's private storage — FCM keeps its own
115
+ * copy there — so keeping it costs nothing new. The handle stays
116
+ * the capability; this is only an input to producing one.
117
+ */
118
+ private const val TOKEN_KEY = "native_token"
119
+
120
+ /** What the server was last told, so a repeat is not sent. */
121
+ @Volatile private var lastSentIdentity: String? = null
122
+
123
+ private val worker =
124
+ Executors.newSingleThreadScheduledExecutor { r ->
125
+ Thread(r, "sentori-push").apply { isDaemon = true }
126
+ }
127
+
128
+ /**
129
+ * Ask for permission, get a token, register it.
130
+ *
131
+ * Safe to call on every launch: Android returns its cached
132
+ * decision without re-prompting, and the server upserts on
133
+ * `(project, provider, token)`.
134
+ *
135
+ * Call [Sentori.user] first if the device should be reachable from
136
+ * an issue. Without it the registration carries no user key and
137
+ * the device receives broadcasts only — the dashboard shows that
138
+ * as "N devices, 0 addressable", which is the one symptom with no
139
+ * other explanation.
140
+ *
141
+ * [activity] is needed only for the Android 13+ runtime prompt;
142
+ * pass null to skip prompting and use whatever was already
143
+ * granted.
144
+ */
145
+ @JvmStatic
146
+ @JvmOverloads
147
+ fun register(
148
+ context: Context,
149
+ activity: Activity? = null,
150
+ timeoutMs: Long = 8_000,
151
+ onMessage: ((Map<String, Any?>) -> Unit)? = null,
152
+ onTap: ((Map<String, Any?>) -> Unit)? = null,
153
+ completion: (Result) -> Unit,
154
+ ) {
155
+ val config = SentoriConfig.current
156
+ if (config == null) {
157
+ deliver(completion, Result.Failure(Failure.NOT_INITIALISED, "Sentori.start has not run"))
158
+ return
159
+ }
160
+
161
+ // Bind before asking for anything: a tap that arrived while
162
+ // the app was dead is replayed as soon as the service starts,
163
+ // and a callback set afterwards misses it.
164
+ synchronized(lock) {
165
+ this.onMessage = onMessage
166
+ this.onTap = onTap
167
+ }
168
+
169
+ val appContext = context.applicationContext
170
+
171
+ // Whatever launched this Activity, before anything else can
172
+ // replace it. A cold start from a notification arrives here
173
+ // and nowhere else, and `onTap` used to need the host to
174
+ // forward it by hand — which nothing told the host to do.
175
+ SentoriNotificationTap.consume(activity)
176
+ val proceed = { status: String ->
177
+ worker.execute {
178
+ deliver(completion, finishRegister(appContext, config, status, timeoutMs))
179
+ }
180
+ }
181
+
182
+ val current = SentoriPushNotifications.currentPermission(appContext)
183
+ if (current == "notDetermined" && activity != null) {
184
+ // `timeoutMs` is a network budget — how long to wait for a
185
+ // device token. Spending it on a person reading a dialog
186
+ // is a category error: the default is eight seconds, and
187
+ // nobody answers a permission prompt in eight seconds.
188
+ SentoriPushNotifications.requestPermission(
189
+ activity,
190
+ timeoutMs = permissionTimeoutMs,
191
+ ) { proceed(it) }
192
+ } else {
193
+ proceed(current)
194
+ }
195
+ }
196
+
197
+ /**
198
+ * How long to wait for someone to answer the permission dialog.
199
+ *
200
+ * Separate from `timeoutMs`, which is about the network. A person
201
+ * may be reading, or may have put the phone down; two minutes
202
+ * covers the first and gives up on the second rather than leaving
203
+ * a registration that never reports anything.
204
+ */
205
+ @JvmStatic
206
+ var permissionTimeoutMs: Long = 120_000
207
+
208
+ /**
209
+ * A vendor has issued this device a new token; tell the server
210
+ * now rather than at the next launch.
211
+ *
212
+ * `onNewToken` fired and the SDK wrote the value into a field.
213
+ * Nothing sent it. So from the moment a token rotated until the
214
+ * host next called `register`, the server held a dead token: the
215
+ * sends went out, the vendor answered UNREGISTERED, quarantine
216
+ * retired the device, and it came back at the next launch under a
217
+ * different address. For an app that stays resident, "the next
218
+ * launch" is not a bounded wait.
219
+ *
220
+ * Only re-registers a device that has registered before —
221
+ * `spToken` on disk is the evidence. A token arriving for a
222
+ * device the host never registered is not something to act on
223
+ * unasked.
224
+ */
225
+ @JvmStatic
226
+ fun handleRotatedToken(context: Context, token: String) {
227
+ val appContext = context.applicationContext
228
+ val config = SentoriConfig.current ?: return
229
+ if (cachedDeviceHandle(appContext) == null) return
230
+ worker.execute {
231
+ when (val r = registerWithServer(appContext, token, config)) {
232
+ is Result.Success -> rememberHandle(appContext, r.handle, token)
233
+ is Result.Failure ->
234
+ android.util.Log.w(
235
+ "sentori",
236
+ "push re-register after token rotation failed " +
237
+ "(${r.reason.reason}): ${r.message}",
238
+ )
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Keep the address, in memory and on disk.
245
+ *
246
+ * Three callers now — the first registration, a rotation, and the
247
+ * live test — and three copies of four lines is how two of them
248
+ * end up writing different keys.
249
+ */
250
+ private fun rememberHandle(context: Context, handle: String, token: String? = null) {
251
+ synchronized(lock) { cachedHandle = handle }
252
+ val appContext = context.applicationContext
253
+ appContext
254
+ .getSharedPreferences(PREFS, Context.MODE_PRIVATE)
255
+ .edit()
256
+ .putString(HANDLE_KEY, handle)
257
+ .apply {
258
+ // Kept for the sign-in path below, which has no other
259
+ // way back to a token.
260
+ if (token != null) putString(TOKEN_KEY, token)
261
+ }
262
+ .apply()
263
+ // From here on, signing in or out updates the row by itself.
264
+ SentoriScope.setIdentityListener { identityChanged(appContext) }
265
+ }
266
+
267
+ /**
268
+ * Send the registration again because the person changed.
269
+ *
270
+ * Only for a device that has already registered — a stored token
271
+ * is the evidence, the same rule [handleRotatedToken] uses. Runs
272
+ * on the worker: `Sentori.user` is synchronous and stays that way.
273
+ */
274
+ private fun identityChanged(appContext: Context) {
275
+ val config = SentoriConfig.current ?: return
276
+ val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
277
+ val token = prefs.getString(TOKEN_KEY, null) ?: return
278
+ if (prefs.getString(HANDLE_KEY, null) == null) return
279
+
280
+ // `Sentori.user` is a verb an app may call on every screen,
281
+ // and one request per call is not free to a host.
282
+ val identity = "${SentoriScope.userKey}\u0000${SentoriScope.traits}"
283
+ if (identity == lastSentIdentity) return
284
+ lastSentIdentity = identity
285
+
286
+ worker.execute {
287
+ when (val r = registerWithServer(appContext, token, config)) {
288
+ is Result.Success -> rememberHandle(appContext, r.handle, token)
289
+ is Result.Failure -> {
290
+ // The row still names the previous person, so the
291
+ // next change has to be allowed to try again.
292
+ lastSentIdentity = null
293
+ android.util.Log.w(
294
+ "sentori",
295
+ "updating the device after a sign-in failed " +
296
+ "(${r.reason.reason}): ${r.message}",
297
+ )
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Register a vendor token directly, for the live test.
305
+ *
306
+ * The rotation path can only be exercised against a device that
307
+ * has registered, and there is no FCM in a test host to get a
308
+ * first token from. This is the same call `finishRegister` makes
309
+ * once it has one.
310
+ */
311
+ internal fun registerNativeTokenForTests(context: Context, token: String): String? {
312
+ val config = SentoriConfig.current ?: return null
313
+ return when (val r = registerWithServer(context.applicationContext, token, config)) {
314
+ is Result.Success -> {
315
+ rememberHandle(context, r.handle, token)
316
+ r.handle
317
+ }
318
+ else -> null
319
+ }
320
+ }
321
+
322
+ /** This installation's id, minted on first use and kept after. */
323
+ internal fun installId(ctx: Context): String {
324
+ val prefs = ctx.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
325
+ prefs.getString(INSTALL_KEY, null)?.let { return it }
326
+ val fresh = java.util.UUID.randomUUID().toString()
327
+ prefs.edit().putString(INSTALL_KEY, fresh).apply()
328
+ return fresh
329
+ }
330
+
331
+ /**
332
+ * Say it out loud, once, where the person wiring this up is
333
+ * looking.
334
+ *
335
+ * A failed `register` reported only to the server is invisible on
336
+ * the machine where the mistake was made: the integrator has to
337
+ * finish connecting the dashboard before it can tell them they
338
+ * have not finished connecting the dashboard. insight found their
339
+ * first-launch failure by adding a `Log.w` of their own and
340
+ * taking it out again.
341
+ *
342
+ * Warning, never error. A red line in someone else's logcat reads
343
+ * as "your app is broken", and a host team that believes that
344
+ * pulls the SDK out.
345
+ */
346
+ private fun deliver(completion: (Result) -> Unit, result: Result) {
347
+ if (result is Result.Failure) {
348
+ android.util.Log.w(
349
+ "sentori",
350
+ "push register failed (${result.reason.reason}): ${result.message}",
351
+ )
352
+ }
353
+ completion(result)
354
+ }
355
+
356
+ private fun finishRegister(
357
+ context: Context,
358
+ config: SentoriConfig,
359
+ status: String,
360
+ timeoutMs: Long,
361
+ ): Result {
362
+ if (status != "granted") {
363
+ return Result.Failure(Failure.PERMISSION_DENIED, "push permission '$status'")
364
+ }
365
+
366
+ SentoriPushNotifications.registerForRemoteNotifications(context)
367
+
368
+ val token = waitForToken(timeoutMs)
369
+ if (token == null) {
370
+ // An FCM that is not on the classpath reports an error
371
+ // rather than a token, and that is a build fact rather
372
+ // than something the user chose.
373
+ val err = SentoriPushNotifications.drainState()["error"] as? String
374
+ return if (err != null) {
375
+ Result.Failure(Failure.NO_TRANSPORT, err)
376
+ } else {
377
+ Result.Failure(Failure.TOKEN_TIMEOUT, "no device token within ${timeoutMs}ms")
378
+ }
379
+ }
380
+
381
+ return when (val r = registerWithServer(context, token, config)) {
382
+ is Result.Success -> {
383
+ rememberHandle(context, r.handle, token)
384
+ startDrain()
385
+ r
386
+ }
387
+ else -> r
388
+ }
389
+ }
390
+
391
+ /** The handle from an earlier [register], without a round trip. */
392
+ @JvmStatic
393
+ fun cachedDeviceHandle(context: Context): String? =
394
+ synchronized(lock) { cachedHandle }
395
+ ?: context
396
+ .getSharedPreferences(PREFS, Context.MODE_PRIVATE)
397
+ .getString(HANDLE_KEY, null)
398
+
399
+ /** Current permission without prompting. */
400
+ @JvmStatic
401
+ fun permissionStatus(context: Context): String =
402
+ SentoriPushNotifications.currentPermission(context.applicationContext)
403
+
404
+ /**
405
+ * Revoke the handle server-side and stop local delivery.
406
+ * Idempotent — repeat calls do nothing.
407
+ */
408
+ @JvmStatic
409
+ @JvmOverloads
410
+ fun unregister(context: Context, completion: ((Boolean) -> Unit)? = null) {
411
+ val appContext = context.applicationContext
412
+ val handle = cachedDeviceHandle(appContext)
413
+ synchronized(lock) {
414
+ cachedHandle = null
415
+ onMessage = null
416
+ onTap = null
417
+ drainTask?.cancel(false)
418
+ drainTask = null
419
+ }
420
+ appContext
421
+ .getSharedPreferences(PREFS, Context.MODE_PRIVATE)
422
+ .edit()
423
+ .remove(HANDLE_KEY)
424
+ .remove(TOKEN_KEY)
425
+ .apply()
426
+ // Nothing to follow the person to any more; a later sign-in
427
+ // must not resurrect a device the host just revoked.
428
+ SentoriScope.setIdentityListener(null)
429
+ lastSentIdentity = null
430
+ SentoriPushNotifications.unregisterForRemoteNotifications(appContext)
431
+
432
+ val config = SentoriConfig.current
433
+ if (handle == null || config == null) {
434
+ completion?.invoke(false)
435
+ return
436
+ }
437
+ worker.execute {
438
+ var conn: HttpURLConnection? = null
439
+ val ok =
440
+ try {
441
+ conn =
442
+ URL("${config.ingestUrl}/v1/push/tokens/$handle").openConnection()
443
+ as HttpURLConnection
444
+ conn.requestMethod = "DELETE"
445
+ conn.setRequestProperty("Authorization", "Bearer ${config.token}")
446
+ conn.connectTimeout = 15_000
447
+ conn.readTimeout = 15_000
448
+ conn.responseCode in 200..299
449
+ } catch (_: Throwable) {
450
+ false
451
+ } finally {
452
+ conn?.disconnect()
453
+ }
454
+ completion?.invoke(ok)
455
+ }
456
+ }
457
+
458
+ // ── internals ─────────────────────────────────────────────────
459
+
460
+ private fun waitForToken(timeoutMs: Long): String? {
461
+ val deadline = System.currentTimeMillis() + timeoutMs
462
+ while (System.currentTimeMillis() < deadline) {
463
+ val state = SentoriPushNotifications.drainState()
464
+ flush(state)
465
+ (state["token"] as? String)?.let { return it }
466
+ if (state["error"] != null) return null
467
+ try {
468
+ Thread.sleep(200)
469
+ } catch (_: InterruptedException) {
470
+ Thread.currentThread().interrupt()
471
+ return null
472
+ }
473
+ }
474
+ return null
475
+ }
476
+
477
+ private fun registerWithServer(
478
+ context: Context,
479
+ token: String,
480
+ config: SentoriConfig,
481
+ ): Result {
482
+ val body =
483
+ mutableMapOf<String, Any?>(
484
+ // `kind`, not `provider`. The React Native SDK sent
485
+ // `provider` for a year and earned a 422 for every
486
+ // registration it ever attempted.
487
+ "kind" to "fcm",
488
+ "nativeToken" to token,
489
+ // Which installation this is. The server keys the row
490
+ // on it, so a rotated token updates this device
491
+ // rather than creating a second one under a new
492
+ // address.
493
+ "installId" to installId(context),
494
+ // No `env`: FCM is one host, with no sandbox and
495
+ // production split for a token to be wrong about.
496
+ )
497
+ // The same identity hash every event carries, so the dashboard
498
+ // can address this device by the person who hit an issue.
499
+ // Absent until the host calls `Sentori.user`.
500
+ SentoriScope.userKey?.let { body["userKey"] = it }
501
+ // Attributes of the person rather than of the device, kept
502
+ // apart so a build channel called "pro" cannot answer a send
503
+ // aimed at the pro plan. Null leaves the row's traits alone;
504
+ // an empty map clears them, which is what signing out sends.
505
+ SentoriScope.traits?.let { body["traits"] = it }
506
+
507
+ var conn: HttpURLConnection? = null
508
+ return try {
509
+ conn =
510
+ URL("${config.ingestUrl}/v1/push/tokens").openConnection() as HttpURLConnection
511
+ conn.requestMethod = "POST"
512
+ conn.setRequestProperty("Content-Type", "application/json")
513
+ conn.setRequestProperty("Authorization", "Bearer ${config.token}")
514
+ conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
515
+ conn.connectTimeout = 15_000
516
+ conn.readTimeout = 15_000
517
+ conn.doOutput = true
518
+ conn.outputStream.use {
519
+ it.write(SentoriTransport.toJson(body).toString().toByteArray(Charsets.UTF_8))
520
+ }
521
+
522
+ val code = conn.responseCode
523
+ if (code !in 200..299) {
524
+ Result.Failure(Failure.SERVER_REJECTED, "HTTP $code")
525
+ } else {
526
+ val text = conn.inputStream.bufferedReader().readText()
527
+ // The handle is the `device_tokens` row id, a bare
528
+ // uuid. The RN SDK parsed it as an `ipt_*` string no
529
+ // server has ever returned.
530
+ // `spToken` is the name; `token_id` is the name the
531
+ // server shipped under. Reading the new one first and
532
+ // falling back matters in one direction that is easy
533
+ // to miss: a self-hosted deployment upgrades on its
534
+ // own schedule, so an SDK newer than its server is an
535
+ // ordinary state, and an SDK that only knew the new
536
+ // name would fail every registration against it.
537
+ val json = JSONObject(text)
538
+ val handle = json.optString("spToken").ifEmpty { json.optString("token_id") }
539
+ if (handle.isNullOrEmpty()) {
540
+ Result.Failure(Failure.SERVER_REJECTED, "server returned no device token id")
541
+ } else {
542
+ Result.Success(handle)
543
+ }
544
+ }
545
+ } catch (e: Throwable) {
546
+ Result.Failure(Failure.SERVER_REJECTED, e.message ?: e.javaClass.name)
547
+ } finally {
548
+ conn?.disconnect()
549
+ }
550
+ }
551
+
552
+ /**
553
+ * 1 Hz while registered. The FCM service buffers arrivals and
554
+ * taps; this hands them to the host.
555
+ */
556
+ private fun startDrain() {
557
+ synchronized(lock) {
558
+ if (drainTask != null) return
559
+ drainTask =
560
+ worker.scheduleWithFixedDelay(
561
+ { flush(SentoriPushNotifications.drainState()) },
562
+ 1,
563
+ 1,
564
+ TimeUnit.SECONDS,
565
+ )
566
+ }
567
+ }
568
+
569
+ @Suppress("UNCHECKED_CAST")
570
+ private fun flush(state: Map<String, Any?>) {
571
+ val (message, tap) = synchronized(lock) { onMessage to onTap }
572
+ if (message == null && tap == null) return
573
+ (state["notifications"] as? List<Map<String, Any?>>)?.forEach { message?.invoke(it) }
574
+ (state["taps"] as? List<Map<String, Any?>>)?.forEach { tap?.invoke(it) }
575
+ }
576
+
577
+ internal fun resetForTests() {
578
+ lastSentIdentity = null
579
+ SentoriScope.setIdentityListener(null)
580
+ synchronized(lock) {
581
+ cachedHandle = null
582
+ onMessage = null
583
+ onTap = null
584
+ drainTask?.cancel(false)
585
+ drainTask = null
586
+ }
587
+ }
588
+ }