@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.
- package/SentoriReactNative.podspec +11 -4
- package/android/build.gradle +21 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -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 +588 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
- package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
- package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
- package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
- package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
- package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
- package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
- package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
- package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +51 -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 +99 -1
- package/src/push.ts +49 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
- 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
|
-
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
231
|
-
*
|
|
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")
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|