@goliapkg/sentori-react-native 6.1.0 → 6.2.1
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 +15 -1
- package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -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/SentoriPush.kt +359 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
- package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -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 +383 -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 +106 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -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/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -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/core/SentoriPush.swift +340 -0
- package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -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 +7 -6
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +8 -0
- package/lib/push.js.map +1 -1
- package/lib/transport.js +1 -1
- package/package.json +1 -1
- package/src/push.ts +14 -6
- 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
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
private val worker =
|
|
95
|
+
Executors.newSingleThreadScheduledExecutor { r ->
|
|
96
|
+
Thread(r, "sentori-push").apply { isDaemon = true }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Ask for permission, get a token, register it.
|
|
101
|
+
*
|
|
102
|
+
* Safe to call on every launch: Android returns its cached
|
|
103
|
+
* decision without re-prompting, and the server upserts on
|
|
104
|
+
* `(project, provider, token)`.
|
|
105
|
+
*
|
|
106
|
+
* Call [Sentori.user] first if the device should be reachable from
|
|
107
|
+
* an issue. Without it the registration carries no user key and
|
|
108
|
+
* the device receives broadcasts only — the dashboard shows that
|
|
109
|
+
* as "N devices, 0 addressable", which is the one symptom with no
|
|
110
|
+
* other explanation.
|
|
111
|
+
*
|
|
112
|
+
* [activity] is needed only for the Android 13+ runtime prompt;
|
|
113
|
+
* pass null to skip prompting and use whatever was already
|
|
114
|
+
* granted.
|
|
115
|
+
*/
|
|
116
|
+
@JvmStatic
|
|
117
|
+
@JvmOverloads
|
|
118
|
+
fun register(
|
|
119
|
+
context: Context,
|
|
120
|
+
activity: Activity? = null,
|
|
121
|
+
timeoutMs: Long = 8_000,
|
|
122
|
+
onMessage: ((Map<String, Any?>) -> Unit)? = null,
|
|
123
|
+
onTap: ((Map<String, Any?>) -> Unit)? = null,
|
|
124
|
+
completion: (Result) -> Unit,
|
|
125
|
+
) {
|
|
126
|
+
val config = SentoriConfig.current
|
|
127
|
+
if (config == null) {
|
|
128
|
+
completion(Result.Failure(Failure.NOT_INITIALISED, "Sentori.start has not run"))
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Bind before asking for anything: a tap that arrived while
|
|
133
|
+
// the app was dead is replayed as soon as the service starts,
|
|
134
|
+
// and a callback set afterwards misses it.
|
|
135
|
+
synchronized(lock) {
|
|
136
|
+
this.onMessage = onMessage
|
|
137
|
+
this.onTap = onTap
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
val appContext = context.applicationContext
|
|
141
|
+
val proceed = { status: String ->
|
|
142
|
+
worker.execute {
|
|
143
|
+
completion(finishRegister(appContext, config, status, timeoutMs))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
val current = SentoriPushNotifications.currentPermission(appContext)
|
|
148
|
+
if (current == "notDetermined" && activity != null) {
|
|
149
|
+
SentoriPushNotifications.requestPermission(activity) { proceed(it) }
|
|
150
|
+
} else {
|
|
151
|
+
proceed(current)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun finishRegister(
|
|
156
|
+
context: Context,
|
|
157
|
+
config: SentoriConfig,
|
|
158
|
+
status: String,
|
|
159
|
+
timeoutMs: Long,
|
|
160
|
+
): Result {
|
|
161
|
+
if (status != "granted") {
|
|
162
|
+
return Result.Failure(Failure.PERMISSION_DENIED, "push permission '$status'")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
SentoriPushNotifications.registerForRemoteNotifications(context)
|
|
166
|
+
|
|
167
|
+
val token = waitForToken(timeoutMs)
|
|
168
|
+
if (token == null) {
|
|
169
|
+
// An FCM that is not on the classpath reports an error
|
|
170
|
+
// rather than a token, and that is a build fact rather
|
|
171
|
+
// than something the user chose.
|
|
172
|
+
val err = SentoriPushNotifications.drainState()["error"] as? String
|
|
173
|
+
return if (err != null) {
|
|
174
|
+
Result.Failure(Failure.NO_TRANSPORT, err)
|
|
175
|
+
} else {
|
|
176
|
+
Result.Failure(Failure.TOKEN_TIMEOUT, "no device token within ${timeoutMs}ms")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return when (val r = registerWithServer(token, config)) {
|
|
181
|
+
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()
|
|
188
|
+
startDrain()
|
|
189
|
+
r
|
|
190
|
+
}
|
|
191
|
+
else -> r
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The handle from an earlier [register], without a round trip. */
|
|
196
|
+
@JvmStatic
|
|
197
|
+
fun cachedDeviceHandle(context: Context): String? =
|
|
198
|
+
synchronized(lock) { cachedHandle }
|
|
199
|
+
?: context
|
|
200
|
+
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
201
|
+
.getString(HANDLE_KEY, null)
|
|
202
|
+
|
|
203
|
+
/** Current permission without prompting. */
|
|
204
|
+
@JvmStatic
|
|
205
|
+
fun permissionStatus(context: Context): String =
|
|
206
|
+
SentoriPushNotifications.currentPermission(context.applicationContext)
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Revoke the handle server-side and stop local delivery.
|
|
210
|
+
* Idempotent — repeat calls do nothing.
|
|
211
|
+
*/
|
|
212
|
+
@JvmStatic
|
|
213
|
+
@JvmOverloads
|
|
214
|
+
fun unregister(context: Context, completion: ((Boolean) -> Unit)? = null) {
|
|
215
|
+
val appContext = context.applicationContext
|
|
216
|
+
val handle = cachedDeviceHandle(appContext)
|
|
217
|
+
synchronized(lock) {
|
|
218
|
+
cachedHandle = null
|
|
219
|
+
onMessage = null
|
|
220
|
+
onTap = null
|
|
221
|
+
drainTask?.cancel(false)
|
|
222
|
+
drainTask = null
|
|
223
|
+
}
|
|
224
|
+
appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(HANDLE_KEY).apply()
|
|
225
|
+
SentoriPushNotifications.unregisterForRemoteNotifications(appContext)
|
|
226
|
+
|
|
227
|
+
val config = SentoriConfig.current
|
|
228
|
+
if (handle == null || config == null) {
|
|
229
|
+
completion?.invoke(false)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
worker.execute {
|
|
233
|
+
var conn: HttpURLConnection? = null
|
|
234
|
+
val ok =
|
|
235
|
+
try {
|
|
236
|
+
conn =
|
|
237
|
+
URL("${config.ingestUrl}/v1/push/tokens/$handle").openConnection()
|
|
238
|
+
as HttpURLConnection
|
|
239
|
+
conn.requestMethod = "DELETE"
|
|
240
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
241
|
+
conn.connectTimeout = 15_000
|
|
242
|
+
conn.readTimeout = 15_000
|
|
243
|
+
conn.responseCode in 200..299
|
|
244
|
+
} catch (_: Throwable) {
|
|
245
|
+
false
|
|
246
|
+
} finally {
|
|
247
|
+
conn?.disconnect()
|
|
248
|
+
}
|
|
249
|
+
completion?.invoke(ok)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── internals ─────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
private fun waitForToken(timeoutMs: Long): String? {
|
|
256
|
+
val deadline = System.currentTimeMillis() + timeoutMs
|
|
257
|
+
while (System.currentTimeMillis() < deadline) {
|
|
258
|
+
val state = SentoriPushNotifications.drainState()
|
|
259
|
+
flush(state)
|
|
260
|
+
(state["token"] as? String)?.let { return it }
|
|
261
|
+
if (state["error"] != null) return null
|
|
262
|
+
try {
|
|
263
|
+
Thread.sleep(200)
|
|
264
|
+
} catch (_: InterruptedException) {
|
|
265
|
+
Thread.currentThread().interrupt()
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun registerWithServer(token: String, config: SentoriConfig): Result {
|
|
273
|
+
val body =
|
|
274
|
+
mutableMapOf<String, Any?>(
|
|
275
|
+
// `kind`, not `provider`. The React Native SDK sent
|
|
276
|
+
// `provider` for a year and earned a 422 for every
|
|
277
|
+
// registration it ever attempted.
|
|
278
|
+
"kind" to "fcm",
|
|
279
|
+
"nativeToken" to token,
|
|
280
|
+
// No `env`: FCM is one host, with no sandbox and
|
|
281
|
+
// production split for a token to be wrong about.
|
|
282
|
+
)
|
|
283
|
+
// The same identity hash every event carries, so the dashboard
|
|
284
|
+
// can address this device by the person who hit an issue.
|
|
285
|
+
// Absent until the host calls `Sentori.user`.
|
|
286
|
+
SentoriScope.userKey?.let { body["userKey"] = it }
|
|
287
|
+
|
|
288
|
+
var conn: HttpURLConnection? = null
|
|
289
|
+
return try {
|
|
290
|
+
conn =
|
|
291
|
+
URL("${config.ingestUrl}/v1/push/tokens").openConnection() as HttpURLConnection
|
|
292
|
+
conn.requestMethod = "POST"
|
|
293
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
294
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
295
|
+
conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
|
|
296
|
+
conn.connectTimeout = 15_000
|
|
297
|
+
conn.readTimeout = 15_000
|
|
298
|
+
conn.doOutput = true
|
|
299
|
+
conn.outputStream.use {
|
|
300
|
+
it.write(SentoriTransport.toJson(body).toString().toByteArray(Charsets.UTF_8))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
val code = conn.responseCode
|
|
304
|
+
if (code !in 200..299) {
|
|
305
|
+
Result.Failure(Failure.SERVER_REJECTED, "HTTP $code")
|
|
306
|
+
} else {
|
|
307
|
+
val text = conn.inputStream.bufferedReader().readText()
|
|
308
|
+
// The handle is the `device_tokens` row id, a bare
|
|
309
|
+
// uuid. The RN SDK parsed it as an `ipt_*` string no
|
|
310
|
+
// server has ever returned.
|
|
311
|
+
val handle = JSONObject(text).optString("token_id")
|
|
312
|
+
if (handle.isNullOrEmpty()) {
|
|
313
|
+
Result.Failure(Failure.SERVER_REJECTED, "server returned no device token id")
|
|
314
|
+
} else {
|
|
315
|
+
Result.Success(handle)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} catch (e: Throwable) {
|
|
319
|
+
Result.Failure(Failure.SERVER_REJECTED, e.message ?: e.javaClass.name)
|
|
320
|
+
} finally {
|
|
321
|
+
conn?.disconnect()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 1 Hz while registered. The FCM service buffers arrivals and
|
|
327
|
+
* taps; this hands them to the host.
|
|
328
|
+
*/
|
|
329
|
+
private fun startDrain() {
|
|
330
|
+
synchronized(lock) {
|
|
331
|
+
if (drainTask != null) return
|
|
332
|
+
drainTask =
|
|
333
|
+
worker.scheduleWithFixedDelay(
|
|
334
|
+
{ flush(SentoriPushNotifications.drainState()) },
|
|
335
|
+
1,
|
|
336
|
+
1,
|
|
337
|
+
TimeUnit.SECONDS,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@Suppress("UNCHECKED_CAST")
|
|
343
|
+
private fun flush(state: Map<String, Any?>) {
|
|
344
|
+
val (message, tap) = synchronized(lock) { onMessage to onTap }
|
|
345
|
+
if (message == null && tap == null) return
|
|
346
|
+
(state["notifications"] as? List<Map<String, Any?>>)?.forEach { message?.invoke(it) }
|
|
347
|
+
(state["taps"] as? List<Map<String, Any?>>)?.forEach { tap?.invoke(it) }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
internal fun resetForTests() {
|
|
351
|
+
synchronized(lock) {
|
|
352
|
+
cachedHandle = null
|
|
353
|
+
onMessage = null
|
|
354
|
+
onTap = null
|
|
355
|
+
drainTask?.cancel(false)
|
|
356
|
+
drainTask = null
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -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:
|
|
@@ -0,0 +1,60 @@
|
|
|
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 val _context = mutableMapOf<String, Any?>()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Identify the person using the app. Only the hash goes on the
|
|
19
|
+
* wire; the id and email stay on the device.
|
|
20
|
+
*
|
|
21
|
+
* Unlike the JavaScript version this is genuinely synchronous —
|
|
22
|
+
* WebCrypto's digest is a promise, so `scope.ts` sets the key a
|
|
23
|
+
* tick later and events sent in that gap carry none.
|
|
24
|
+
* `MessageDigest` has no such gap, so the first event after this
|
|
25
|
+
* call is already addressable.
|
|
26
|
+
*
|
|
27
|
+
* Pass null for both to forget the user on sign-out.
|
|
28
|
+
*/
|
|
29
|
+
@JvmStatic
|
|
30
|
+
fun setUser(id: String?, email: String?) {
|
|
31
|
+
val key = SentoriIdentity.userKey(id, email)
|
|
32
|
+
synchronized(lock) { _userKey = key }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Merge keys into the ambient context. Later calls win per key. */
|
|
36
|
+
@JvmStatic
|
|
37
|
+
fun patchContext(patch: Map<String, Any?>) {
|
|
38
|
+
synchronized(lock) { _context.putAll(patch) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@JvmStatic
|
|
42
|
+
val userKey: String?
|
|
43
|
+
get() = synchronized(lock) { _userKey }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Null rather than an empty map, so an event with no context omits
|
|
47
|
+
* the field instead of carrying `{}`.
|
|
48
|
+
*/
|
|
49
|
+
@JvmStatic
|
|
50
|
+
val context: Map<String, Any?>?
|
|
51
|
+
get() = synchronized(lock) { if (_context.isEmpty()) null else _context.toMap() }
|
|
52
|
+
|
|
53
|
+
@JvmStatic
|
|
54
|
+
fun clear() {
|
|
55
|
+
synchronized(lock) {
|
|
56
|
+
_userKey = null
|
|
57
|
+
_context.clear()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|