@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.
Files changed (33) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
  3. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  4. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
  5. package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
  6. package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
  7. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  8. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  9. package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
  10. package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
  11. package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
  12. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
  13. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  14. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
  15. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  16. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
  17. package/ios/core/SentoriAttachment.swift +129 -0
  18. package/ios/core/SentoriCrashHandler.swift +15 -0
  19. package/ios/core/SentoriPushNotifications.swift +8 -0
  20. package/lib/push.d.ts.map +1 -1
  21. package/lib/push.js +155 -1
  22. package/lib/push.js.map +1 -1
  23. package/lib/scope.d.ts +6 -0
  24. package/lib/scope.d.ts.map +1 -1
  25. package/lib/scope.js +45 -1
  26. package/lib/scope.js.map +1 -1
  27. package/lib/transport.js +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/push.test.ts +203 -1
  30. package/src/push.ts +147 -1
  31. package/src/scope.ts +50 -1
  32. package/src/transport.ts +1 -1
  33. package/ios/core/SentoriPush.swift +0 -340
@@ -91,6 +91,35 @@ object SentoriPush {
91
91
  private const val PREFS = "com.sentori.push"
92
92
  private const val HANDLE_KEY = "handle"
93
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
+
94
123
  private val worker =
95
124
  Executors.newSingleThreadScheduledExecutor { r ->
96
125
  Thread(r, "sentori-push").apply { isDaemon = true }
@@ -125,7 +154,7 @@ object SentoriPush {
125
154
  ) {
126
155
  val config = SentoriConfig.current
127
156
  if (config == null) {
128
- completion(Result.Failure(Failure.NOT_INITIALISED, "Sentori.start has not run"))
157
+ deliver(completion, Result.Failure(Failure.NOT_INITIALISED, "Sentori.start has not run"))
129
158
  return
130
159
  }
131
160
 
@@ -138,20 +167,192 @@ object SentoriPush {
138
167
  }
139
168
 
140
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)
141
176
  val proceed = { status: String ->
142
177
  worker.execute {
143
- completion(finishRegister(appContext, config, status, timeoutMs))
178
+ deliver(completion, finishRegister(appContext, config, status, timeoutMs))
144
179
  }
145
180
  }
146
181
 
147
182
  val current = SentoriPushNotifications.currentPermission(appContext)
148
183
  if (current == "notDetermined" && activity != null) {
149
- SentoriPushNotifications.requestPermission(activity) { proceed(it) }
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) }
150
192
  } else {
151
193
  proceed(current)
152
194
  }
153
195
  }
154
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
+
155
356
  private fun finishRegister(
156
357
  context: Context,
157
358
  config: SentoriConfig,
@@ -177,14 +378,9 @@ object SentoriPush {
177
378
  }
178
379
  }
179
380
 
180
- return when (val r = registerWithServer(token, config)) {
381
+ return when (val r = registerWithServer(context, token, config)) {
181
382
  is Result.Success -> {
182
- synchronized(lock) { cachedHandle = r.handle }
183
- context
184
- .getSharedPreferences(PREFS, Context.MODE_PRIVATE)
185
- .edit()
186
- .putString(HANDLE_KEY, r.handle)
187
- .apply()
383
+ rememberHandle(context, r.handle, token)
188
384
  startDrain()
189
385
  r
190
386
  }
@@ -221,7 +417,16 @@ object SentoriPush {
221
417
  drainTask?.cancel(false)
222
418
  drainTask = null
223
419
  }
224
- appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(HANDLE_KEY).apply()
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
225
430
  SentoriPushNotifications.unregisterForRemoteNotifications(appContext)
226
431
 
227
432
  val config = SentoriConfig.current
@@ -269,7 +474,11 @@ object SentoriPush {
269
474
  return null
270
475
  }
271
476
 
272
- private fun registerWithServer(token: String, config: SentoriConfig): Result {
477
+ private fun registerWithServer(
478
+ context: Context,
479
+ token: String,
480
+ config: SentoriConfig,
481
+ ): Result {
273
482
  val body =
274
483
  mutableMapOf<String, Any?>(
275
484
  // `kind`, not `provider`. The React Native SDK sent
@@ -277,6 +486,11 @@ object SentoriPush {
277
486
  // registration it ever attempted.
278
487
  "kind" to "fcm",
279
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),
280
494
  // No `env`: FCM is one host, with no sandbox and
281
495
  // production split for a token to be wrong about.
282
496
  )
@@ -284,6 +498,11 @@ object SentoriPush {
284
498
  // can address this device by the person who hit an issue.
285
499
  // Absent until the host calls `Sentori.user`.
286
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 }
287
506
 
288
507
  var conn: HttpURLConnection? = null
289
508
  return try {
@@ -308,7 +527,15 @@ object SentoriPush {
308
527
  // The handle is the `device_tokens` row id, a bare
309
528
  // uuid. The RN SDK parsed it as an `ipt_*` string no
310
529
  // server has ever returned.
311
- val handle = JSONObject(text).optString("token_id")
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") }
312
539
  if (handle.isNullOrEmpty()) {
313
540
  Result.Failure(Failure.SERVER_REJECTED, "server returned no device token id")
314
541
  } else {
@@ -348,6 +575,8 @@ object SentoriPush {
348
575
  }
349
576
 
350
577
  internal fun resetForTests() {
578
+ lastSentIdentity = null
579
+ SentoriScope.setIdentityListener(null)
351
580
  synchronized(lock) {
352
581
  cachedHandle = null
353
582
  onMessage = null
@@ -50,6 +50,10 @@ object SentoriPushNotifications {
50
50
 
51
51
  private var pendingPermissionCallback: ((String) -> Unit)? = null
52
52
 
53
+ private val watcher = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
54
+ Thread(r, "sentori-push-permission").apply { isDaemon = true }
55
+ }
56
+
53
57
  // ── status / permission ─────────────────────────────────────
54
58
 
55
59
  /** Returns `granted` / `denied` / `notDetermined` without
@@ -82,7 +86,12 @@ object SentoriPushNotifications {
82
86
  * Callbacks run on the main thread.
83
87
  */
84
88
  @JvmStatic
85
- fun requestPermission(activity: Activity?, completion: (String) -> Unit) {
89
+ @JvmOverloads
90
+ fun requestPermission(
91
+ activity: Activity?,
92
+ timeoutMs: Long = 60_000,
93
+ completion: (String) -> Unit,
94
+ ) {
86
95
  val ctx = activity ?: run {
87
96
  completion("error:no-activity")
88
97
  return
@@ -99,35 +108,89 @@ object SentoriPushNotifications {
99
108
  completion("granted")
100
109
  return
101
110
  }
102
- pendingPermissionCallback = completion
111
+ synchronized(lock) { pendingPermissionCallback = completion }
103
112
  ActivityCompat.requestPermissions(
104
113
  ctx,
105
114
  arrayOf(Manifest.permission.POST_NOTIFICATIONS),
106
115
  PERMISSION_REQUEST_CODE,
107
116
  )
117
+ watchForPermission(ctx.applicationContext, timeoutMs)
108
118
  }
109
119
 
110
120
  /**
111
- * Hook for the host Activity's `onRequestPermissionsResult`. Not
112
- * mandatory — Android dispatches the result back to the same
113
- * Activity that requested it, but ActivityCompat's flow doesn't
114
- * give us a callback API on older devices. Hosts that want
115
- * deterministic delivery can call this from their override.
121
+ * Fast path for hosts that forward `onRequestPermissionsResult`.
122
+ *
123
+ * It used to be the *only* path, and the comment here said the
124
+ * callback might not fire because "the JS drain loop will still
125
+ * pick up the granted state next tick". That is true of React
126
+ * Native and of nothing else. A native host calls `register` and
127
+ * never hears of this method, so the callback was never invoked,
128
+ * `finishRegister` never ran, and a first launch registered
129
+ * nothing at all — silently, with the user having tapped Allow.
130
+ * The next launch worked, because by then the permission was
131
+ * already granted and the flow never suspended. New users simply
132
+ * did not get push until they happened to reopen the app.
116
133
  *
117
- * The Activity-based ActivityResultLauncher pattern would be
118
- * cleaner but requires the Activity to be a ComponentActivity;
119
- * we stick with ActivityCompat for broader RN host compat and
120
- * accept that the callback may not fire on every device — the
121
- * JS drain loop will still pick up the `granted` state next tick.
134
+ * Nothing needs to call this now. It stays because forwarding the
135
+ * result resolves in milliseconds instead of on the next poll,
136
+ * and because hosts that already call it should keep working.
122
137
  */
123
138
  @JvmStatic
124
139
  fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
125
140
  if (requestCode != PERMISSION_REQUEST_CODE) return
126
- val cb = pendingPermissionCallback ?: return
127
- pendingPermissionCallback = null
128
141
  val granted = grantResults.isNotEmpty() &&
129
142
  grantResults[0] == PackageManager.PERMISSION_GRANTED
130
- cb(if (granted) "granted" else "denied")
143
+ settlePermission(if (granted) "granted" else "denied")
144
+ }
145
+
146
+ /** Deliver a pending permission outcome exactly once. */
147
+ private fun settlePermission(status: String) {
148
+ val cb = synchronized(lock) {
149
+ pendingPermissionCallback.also { pendingPermissionCallback = null }
150
+ } ?: return
151
+ cb(status)
152
+ }
153
+
154
+ /**
155
+ * Watch the permission the framework actually holds, and settle
156
+ * when it changes.
157
+ *
158
+ * `checkSelfPermission` flips the moment the user answers the
159
+ * dialog, whether or not anyone forwarded the result — so this
160
+ * needs no cooperation from the host, works on any Activity
161
+ * rather than only a `ComponentActivity`, and cannot be defeated
162
+ * by a host that does not know the hook exists.
163
+ *
164
+ * On a background thread, because the caller's thread is never
165
+ * ours to block. If the user never answers, this settles as
166
+ * `notDetermined` at the deadline rather than hanging: a
167
+ * registration that reports nothing is the failure this whole
168
+ * change is about.
169
+ */
170
+ private fun watchForPermission(ctx: Context, timeoutMs: Long) {
171
+ watcher.execute {
172
+ val deadline = System.currentTimeMillis() + timeoutMs
173
+ while (System.currentTimeMillis() < deadline) {
174
+ if (synchronized(lock) { pendingPermissionCallback } == null) return@execute
175
+ if (ContextCompat.checkSelfPermission(
176
+ ctx,
177
+ Manifest.permission.POST_NOTIFICATIONS,
178
+ ) == PackageManager.PERMISSION_GRANTED
179
+ ) {
180
+ settlePermission("granted")
181
+ return@execute
182
+ }
183
+ try {
184
+ Thread.sleep(250)
185
+ } catch (_: InterruptedException) {
186
+ return@execute
187
+ }
188
+ }
189
+ // Denied and never-answered are the same observation from
190
+ // here: the framework only tells them apart through an
191
+ // Activity that may be gone by now. Say what was seen.
192
+ settlePermission("notDetermined")
193
+ }
131
194
  }
132
195
 
133
196
  // ── register / unregister ───────────────────────────────────
@@ -230,8 +293,17 @@ object SentoriPushNotifications {
230
293
  }
231
294
  }
232
295
 
233
- /** Called when the user taps a notification (host wires this in
234
- * Activity.onCreate to forward the intent extras). */
296
+ /**
297
+ * Record a tap.
298
+ *
299
+ * This used to be the only route to `register(onTap:)`, and its
300
+ * comment said the host wires it in `Activity.onCreate`. Nothing
301
+ * in the SDK called it and the docs never mentioned it, so a
302
+ * native host's `onTap` could not fire at all. `SentoriNotificationTap`
303
+ * now reaches this on its own, from a pending intent the SDK owns
304
+ * or from the intent an Activity was launched with; this stays
305
+ * public because a host that already forwards should keep working.
306
+ */
235
307
  @JvmStatic
236
308
  fun handleNotificationTap(extras: Map<String, Any?>) {
237
309
  synchronized(lock) {
@@ -283,6 +355,85 @@ object SentoriPushNotifications {
283
355
  mgr.createNotificationChannel(channel)
284
356
  }
285
357
 
358
+ /**
359
+ * Put a data message in the tray, with a pending intent of ours.
360
+ *
361
+ * Nothing here posted a notification before, which left both
362
+ * kinds of message broken in a different way. A `data` message
363
+ * reached `onMessage` and the user saw nothing — insight's device
364
+ * said "No notifications" while the callback had fired. A
365
+ * `notification` message was drawn by the system, which never
366
+ * calls this service, so the tap had no way home. Posting it here
367
+ * is what makes one path work end to end.
368
+ *
369
+ * Only messages that carry something to display are posted. A
370
+ * data message without a title or a body is a silent instruction
371
+ * to the app, and an app that uses those would not thank us for
372
+ * turning each one into a notification the user has to dismiss.
373
+ */
374
+ @JvmStatic
375
+ fun postNotification(ctx: Context, data: Map<String, String>) {
376
+ val title = data["title"]?.takeIf { it.isNotBlank() }
377
+ val body = data["body"]?.takeIf { it.isNotBlank() }
378
+ if (title == null && body == null) return
379
+ if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) return
380
+
381
+ ensureChannel(ctx)
382
+ val id = (data["google.message_id"] ?: data["id"] ?: title ?: "").hashCode()
383
+ val builder = androidx.core.app.NotificationCompat.Builder(
384
+ ctx,
385
+ data["channelId"]?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_ID,
386
+ )
387
+ .setSmallIcon(smallIcon(ctx))
388
+ .setAutoCancel(true)
389
+ .setPriority(androidx.core.app.NotificationCompat.PRIORITY_DEFAULT)
390
+ title?.let { builder.setContentTitle(it) }
391
+ body?.let { builder.setContentText(it) }
392
+ SentoriNotificationTap.pendingIntent(ctx, data, id)?.let { builder.setContentIntent(it) }
393
+
394
+ try {
395
+ NotificationManagerCompat.from(ctx).notify(id, builder.build())
396
+ } catch (t: Throwable) {
397
+ // Losing the tray entry is not worth taking the host down
398
+ // for — but losing it in silence is what made this cost
399
+ // insight an afternoon. The channel got created, so they
400
+ // knew the code had reached `notify`; the tray was empty,
401
+ // `dumpsys notification` had nothing, and logcat had not
402
+ // one word. They worked backwards from the channel's
403
+ // existence to a throw nobody had reported.
404
+ //
405
+ // The argument for this line is the same one made when
406
+ // registration failures started logging, one release
407
+ // earlier — and then this catch was written silent in the
408
+ // same release. A failure the host cannot see is the
409
+ // hardest kind there is.
410
+ android.util.Log.w("sentori", "push notification not shown: $t")
411
+ }
412
+ }
413
+
414
+ /**
415
+ * A small icon that will not throw.
416
+ *
417
+ * The host's own, because a push SDK that ships its own artwork
418
+ * puts a stranger's mark in someone else's tray. But
419
+ * `applicationInfo.icon` is `0` for an app that declares no
420
+ * `android:icon` — insight's did not, it drew its logo from a
421
+ * launch theme — and `notify` throws on an icon of 0 rather than
422
+ * drawing something ugly. The result was a notification that
423
+ * silently never appeared.
424
+ *
425
+ * A system icon in that case: unbranded and plain, but visible,
426
+ * and visible is the whole point of the tray.
427
+ */
428
+ private fun smallIcon(ctx: Context): Int {
429
+ val declared = try {
430
+ ctx.applicationInfo.icon
431
+ } catch (_: Throwable) {
432
+ 0
433
+ }
434
+ return if (declared != 0) declared else android.R.drawable.ic_dialog_info
435
+ }
436
+
286
437
  private fun isFirebaseAvailable(): Boolean {
287
438
  return try {
288
439
  Class.forName("com.google.firebase.messaging.FirebaseMessaging")
@@ -12,8 +12,29 @@ object SentoriScope {
12
12
 
13
13
  private val lock = Any()
14
14
  private var _userKey: String? = null
15
+ private var _traits: Map<String, Any?>? = null
15
16
  private val _context = mutableMapOf<String, Any?>()
16
17
 
18
+ /**
19
+ * What to tell when the person changes.
20
+ *
21
+ * The push device row carries the identity, and nothing updated it
22
+ * after registration: an app that registers at launch and signs in
23
+ * ten seconds later — which is every app with a login screen —
24
+ * held a row with no user on it for the life of the install. A
25
+ * send aimed at that person reached nobody and said it had worked.
26
+ *
27
+ * A callback rather than a call into [SentoriPush], so this file
28
+ * keeps knowing nothing about push.
29
+ */
30
+ @Volatile private var onIdentityChange: (() -> Unit)? = null
31
+
32
+ /** Register interest in identity changes. Only push does. */
33
+ @JvmStatic
34
+ fun setIdentityListener(listener: (() -> Unit)?) {
35
+ onIdentityChange = listener
36
+ }
37
+
17
38
  /**
18
39
  * Identify the person using the app. Only the hash goes on the
19
40
  * wire; the id and email stay on the device.
@@ -27,9 +48,22 @@ object SentoriScope {
27
48
  * Pass null for both to forget the user on sign-out.
28
49
  */
29
50
  @JvmStatic
30
- fun setUser(id: String?, email: String?) {
51
+ @JvmOverloads
52
+ fun setUser(id: String?, email: String?, traits: Map<String, Any?>? = null) {
31
53
  val key = SentoriIdentity.userKey(id, email)
32
- synchronized(lock) { _userKey = key }
54
+ synchronized(lock) {
55
+ _userKey = key
56
+ // A call describes the person completely, so one made
57
+ // without traits means they have none rather than "leave
58
+ // the last ones". Absent and empty differ on the wire —
59
+ // absent keeps what the row has, and a signed-out device
60
+ // that kept them would still be selectable as whoever
61
+ // just left.
62
+ _traits = traits?.toMap() ?: emptyMap()
63
+ }
64
+ // Outside the lock: a listener that registers a device must
65
+ // not be holding this while it makes a request.
66
+ runCatching { onIdentityChange?.invoke() }
33
67
  }
34
68
 
35
69
  /** Merge keys into the ambient context. Later calls win per key. */
@@ -42,6 +76,17 @@ object SentoriScope {
42
76
  val userKey: String?
43
77
  get() = synchronized(lock) { _userKey }
44
78
 
79
+ /**
80
+ * The person's attributes, for the push device row.
81
+ *
82
+ * Null until the host has called [setUser] at all, which is
83
+ * different from an empty map: null leaves the row's traits alone,
84
+ * empty clears them.
85
+ */
86
+ @JvmStatic
87
+ val traits: Map<String, Any?>?
88
+ get() = synchronized(lock) { _traits }
89
+
45
90
  /**
46
91
  * Null rather than an empty map, so an event with no context omits
47
92
  * the field instead of carrying `{}`.
@@ -54,7 +99,9 @@ object SentoriScope {
54
99
  fun clear() {
55
100
  synchronized(lock) {
56
101
  _userKey = null
102
+ _traits = null
57
103
  _context.clear()
58
104
  }
105
+ onIdentityChange = null
59
106
  }
60
107
  }