@goliapkg/sentori-react-native 6.2.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.
Files changed (41) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +15 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +244 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  6. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +3 -0
  7. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  8. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +3 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  10. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  11. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  14. package/android/src/main/java-core/com/sentori/SentoriPush.kt +359 -0
  15. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +3 -0
  16. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  17. package/android/src/main/java-core/com/sentori/SentoriScope.kt +60 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  19. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  20. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +383 -0
  21. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  22. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  23. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +106 -0
  24. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +151 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  27. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +16 -0
  28. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  29. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  30. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  31. package/ios/core/SentoriPush.swift +340 -0
  32. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +42 -0
  33. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  34. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  35. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  36. package/lib/transport.js +1 -1
  37. package/package.json +1 -1
  38. package/src/transport.ts +1 -1
  39. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  40. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  41. 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:
@@ -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,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
+ }
@@ -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
+ }