@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
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriPushNotifications.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  // v2.10 — Android push notification bridge.
2
5
  //
3
6
  // Mirrors the iOS shape:
@@ -47,6 +50,10 @@ object SentoriPushNotifications {
47
50
 
48
51
  private var pendingPermissionCallback: ((String) -> Unit)? = null
49
52
 
53
+ private val watcher = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
54
+ Thread(r, "sentori-push-permission").apply { isDaemon = true }
55
+ }
56
+
50
57
  // ── status / permission ─────────────────────────────────────
51
58
 
52
59
  /** Returns `granted` / `denied` / `notDetermined` without
@@ -79,7 +86,12 @@ object SentoriPushNotifications {
79
86
  * Callbacks run on the main thread.
80
87
  */
81
88
  @JvmStatic
82
- 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
+ ) {
83
95
  val ctx = activity ?: run {
84
96
  completion("error:no-activity")
85
97
  return
@@ -96,35 +108,89 @@ object SentoriPushNotifications {
96
108
  completion("granted")
97
109
  return
98
110
  }
99
- pendingPermissionCallback = completion
111
+ synchronized(lock) { pendingPermissionCallback = completion }
100
112
  ActivityCompat.requestPermissions(
101
113
  ctx,
102
114
  arrayOf(Manifest.permission.POST_NOTIFICATIONS),
103
115
  PERMISSION_REQUEST_CODE,
104
116
  )
117
+ watchForPermission(ctx.applicationContext, timeoutMs)
105
118
  }
106
119
 
107
120
  /**
108
- * Hook for the host Activity's `onRequestPermissionsResult`. Not
109
- * mandatory — Android dispatches the result back to the same
110
- * Activity that requested it, but ActivityCompat's flow doesn't
111
- * give us a callback API on older devices. Hosts that want
112
- * 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.
113
133
  *
114
- * The Activity-based ActivityResultLauncher pattern would be
115
- * cleaner but requires the Activity to be a ComponentActivity;
116
- * we stick with ActivityCompat for broader RN host compat and
117
- * accept that the callback may not fire on every device — the
118
- * 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.
119
137
  */
120
138
  @JvmStatic
121
139
  fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
122
140
  if (requestCode != PERMISSION_REQUEST_CODE) return
123
- val cb = pendingPermissionCallback ?: return
124
- pendingPermissionCallback = null
125
141
  val granted = grantResults.isNotEmpty() &&
126
142
  grantResults[0] == PackageManager.PERMISSION_GRANTED
127
- 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
+ }
128
194
  }
129
195
 
130
196
  // ── register / unregister ───────────────────────────────────
@@ -227,8 +293,17 @@ object SentoriPushNotifications {
227
293
  }
228
294
  }
229
295
 
230
- /** Called when the user taps a notification (host wires this in
231
- * 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
+ */
232
307
  @JvmStatic
233
308
  fun handleNotificationTap(extras: Map<String, Any?>) {
234
309
  synchronized(lock) {
@@ -280,6 +355,85 @@ object SentoriPushNotifications {
280
355
  mgr.createNotificationChannel(channel)
281
356
  }
282
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
+
283
437
  private fun isFirebaseAvailable(): Boolean {
284
438
  return try {
285
439
  Class.forName("com.google.firebase.messaging.FirebaseMessaging")
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriReplayCapture.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import android.app.Activity
@@ -0,0 +1,107 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriScope.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ /**
7
+ * Ambient scope: the current user and the context patch that ride
8
+ * every outgoing event. Two verbs own this state; everything else
9
+ * reads it.
10
+ */
11
+ object SentoriScope {
12
+
13
+ private val lock = Any()
14
+ private var _userKey: String? = null
15
+ private var _traits: Map<String, Any?>? = null
16
+ private val _context = mutableMapOf<String, Any?>()
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
+
38
+ /**
39
+ * Identify the person using the app. Only the hash goes on the
40
+ * wire; the id and email stay on the device.
41
+ *
42
+ * Unlike the JavaScript version this is genuinely synchronous —
43
+ * WebCrypto's digest is a promise, so `scope.ts` sets the key a
44
+ * tick later and events sent in that gap carry none.
45
+ * `MessageDigest` has no such gap, so the first event after this
46
+ * call is already addressable.
47
+ *
48
+ * Pass null for both to forget the user on sign-out.
49
+ */
50
+ @JvmStatic
51
+ @JvmOverloads
52
+ fun setUser(id: String?, email: String?, traits: Map<String, Any?>? = null) {
53
+ val key = SentoriIdentity.userKey(id, email)
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() }
67
+ }
68
+
69
+ /** Merge keys into the ambient context. Later calls win per key. */
70
+ @JvmStatic
71
+ fun patchContext(patch: Map<String, Any?>) {
72
+ synchronized(lock) { _context.putAll(patch) }
73
+ }
74
+
75
+ @JvmStatic
76
+ val userKey: String?
77
+ get() = synchronized(lock) { _userKey }
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
+
90
+ /**
91
+ * Null rather than an empty map, so an event with no context omits
92
+ * the field instead of carrying `{}`.
93
+ */
94
+ @JvmStatic
95
+ val context: Map<String, Any?>?
96
+ get() = synchronized(lock) { if (_context.isEmpty()) null else _context.toMap() }
97
+
98
+ @JvmStatic
99
+ fun clear() {
100
+ synchronized(lock) {
101
+ _userKey = null
102
+ _traits = null
103
+ _context.clear()
104
+ }
105
+ onIdentityChange = null
106
+ }
107
+ }
@@ -1,3 +1,6 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
1
4
  package com.sentori
2
5
 
3
6
  import android.app.Application
@@ -0,0 +1,80 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriSignalRing.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import kotlin.math.roundToLong
7
+
8
+ /**
9
+ * What the user was doing for the last sixty seconds, shipped inside
10
+ * `payload.signals` when an error or warn goes out.
11
+ *
12
+ * Bounded and overwrite-oldest. [push] is on the hot path — a tap
13
+ * handler, a navigation — so it does one append and at most one
14
+ * removal under a lock, and nothing else.
15
+ *
16
+ * The window matches the replay buffer deliberately: at 30 s of
17
+ * signals against 60 s of replay, the left half of a case timeline had
18
+ * frames and no events, which reads as "nothing happened" rather than
19
+ * "we were not looking" (insight, round 4).
20
+ */
21
+ object SentoriSignalRing {
22
+
23
+ private data class Entry(val at: Long, val kind: String, val data: Map<String, Any?>?)
24
+
25
+ private const val DEFAULT_CAPACITY = 100
26
+ private const val DEFAULT_WINDOW_MS = 60_000L
27
+
28
+ private val lock = Any()
29
+ private val entries = ArrayDeque<Entry>()
30
+ private var capacity = DEFAULT_CAPACITY
31
+ private var windowMs = DEFAULT_WINDOW_MS
32
+
33
+ @JvmStatic
34
+ fun configure(capacity: Int, windowMs: Long) {
35
+ synchronized(lock) {
36
+ if (capacity > 0) this.capacity = capacity
37
+ if (windowMs > 0) this.windowMs = windowMs
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Record one signal. Any [kind] is accepted — the server does not
43
+ * enumerate them, so a host can push its own without waiting for
44
+ * an SDK release. The five event kinds are a different list and
45
+ * are validated.
46
+ */
47
+ @JvmStatic
48
+ @JvmOverloads
49
+ fun push(kind: String, data: Map<String, Any?>? = null) {
50
+ synchronized(lock) {
51
+ entries.addLast(Entry(System.currentTimeMillis(), kind, data))
52
+ while (entries.size > capacity) entries.removeFirst()
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The ring relative to [now], windowed, oldest first — the shape
58
+ * `payload.signals` carries. `t` is seconds before now, negative,
59
+ * to one decimal.
60
+ */
61
+ @JvmStatic
62
+ @JvmOverloads
63
+ fun snapshot(now: Long = System.currentTimeMillis()): List<Map<String, Any?>> {
64
+ val (all, window) = synchronized(lock) { entries.toList() to windowMs }
65
+ val cutoff = now - window
66
+ return all.filter { it.at >= cutoff }.map { e ->
67
+ val out = mutableMapOf<String, Any?>(
68
+ "t" to ((e.at - now) / 100.0).roundToLong() / 10.0,
69
+ "kind" to e.kind,
70
+ )
71
+ if (e.data != null) out["data"] = e.data
72
+ out
73
+ }
74
+ }
75
+
76
+ @JvmStatic
77
+ fun clear() {
78
+ synchronized(lock) { entries.clear() }
79
+ }
80
+ }