@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
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// GENERATED MIRROR — do not edit.
|
|
2
|
+
// Source of truth: sdk/native/android/src/main/java/com/sentori/SentoriTransport.kt
|
|
3
|
+
// Run `node scripts/sync-native-core.mjs` after editing it.
|
|
4
|
+
package com.sentori
|
|
5
|
+
|
|
6
|
+
import java.io.File
|
|
7
|
+
import java.net.HttpURLConnection
|
|
8
|
+
import java.net.URL
|
|
9
|
+
import java.util.concurrent.Executors
|
|
10
|
+
import java.util.concurrent.ScheduledExecutorService
|
|
11
|
+
import java.util.concurrent.ScheduledFuture
|
|
12
|
+
import java.util.concurrent.TimeUnit
|
|
13
|
+
import org.json.JSONArray
|
|
14
|
+
import org.json.JSONObject
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The only place the SDK talks to the network.
|
|
18
|
+
*
|
|
19
|
+
* Ported from `sdk/react-native/src/transport.ts` and matching the
|
|
20
|
+
* Swift one: batch on a 5 s timer or a 10-deep queue, whichever comes
|
|
21
|
+
* first; three attempts with a doubling delay; a 429 waits out
|
|
22
|
+
* `retryAfterMs`; anything left after that spills to disk and drains
|
|
23
|
+
* on the next launch.
|
|
24
|
+
*
|
|
25
|
+
* The iron rule is harder here than in JavaScript. A JS verb returns
|
|
26
|
+
* because everything after it is a microtask; the JVM has no such
|
|
27
|
+
* floor, so "fire and forget" is a real queue on a real background
|
|
28
|
+
* thread. [enqueue] does one bounded append under a lock and returns —
|
|
29
|
+
* no encoding, no I/O.
|
|
30
|
+
*
|
|
31
|
+
* `HttpURLConnection` rather than OkHttp: a monitoring SDK that drags
|
|
32
|
+
* a networking library into every host app has already broken the
|
|
33
|
+
* footprint half of its own contract, and OkHttp would also mean this
|
|
34
|
+
* SDK's traffic goes through the same interceptors the host installed
|
|
35
|
+
* for its own — which is a surprising place for a crash report to be
|
|
36
|
+
* logged, retried or blocked.
|
|
37
|
+
*/
|
|
38
|
+
object SentoriTransport {
|
|
39
|
+
|
|
40
|
+
private const val FLUSH_INTERVAL_MS = 5_000L
|
|
41
|
+
private const val BATCH_SIZE = 10
|
|
42
|
+
private const val MAX_RETRY = 3
|
|
43
|
+
private const val MAX_PERSISTED = 1000
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bounded, because an unbounded in-memory queue is a leak with a
|
|
47
|
+
* nicer name. Past it the oldest go: a crash from ten minutes ago
|
|
48
|
+
* matters less than the one happening now.
|
|
49
|
+
*/
|
|
50
|
+
private const val MAX_QUEUED = 500
|
|
51
|
+
|
|
52
|
+
private val lock = Any()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Work that may only run once the server has taken particular
|
|
56
|
+
* events.
|
|
57
|
+
*
|
|
58
|
+
* Attachments are the reason: the server keys one on an event id
|
|
59
|
+
* it must already know, so uploading before that event lands is a
|
|
60
|
+
* guaranteed 404.
|
|
61
|
+
*
|
|
62
|
+
* Keyed on ids rather than "the next delivery", which was the
|
|
63
|
+
* first version and had a race at each end. `flush` hands the send
|
|
64
|
+
* to a worker and returns, so registering afterwards can miss a
|
|
65
|
+
* batch that has already come back — on a fast network the uploads
|
|
66
|
+
* then never happen at all, silently, which is the bug this exists
|
|
67
|
+
* to prevent. Registering beforehand instead lets an unrelated
|
|
68
|
+
* batch already in flight fire the block early, into a 404.
|
|
69
|
+
* Matching on ids has neither end.
|
|
70
|
+
*/
|
|
71
|
+
private val afterDelivery = mutableListOf<Pair<Set<String>, () -> Unit>>()
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run [block] once the server has accepted every event in [ids].
|
|
75
|
+
* If the server refuses them outright the block is discarded — they
|
|
76
|
+
* are not there to attach to. A batch that merely spilled keeps its
|
|
77
|
+
* waiters, since the drain retries it under the same ids.
|
|
78
|
+
*/
|
|
79
|
+
internal fun afterDelivery(ids: Set<String>, block: () -> Unit) {
|
|
80
|
+
if (ids.isEmpty()) return
|
|
81
|
+
synchronized(lock) { afterDelivery.add(ids to block) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private fun settle(events: List<Map<String, Any?>>, accepted: Boolean) {
|
|
85
|
+
val ids = events.mapNotNull { it["id"] as? String }.toSet()
|
|
86
|
+
if (ids.isEmpty()) return
|
|
87
|
+
val ready = mutableListOf<() -> Unit>()
|
|
88
|
+
synchronized(lock) {
|
|
89
|
+
val kept = mutableListOf<Pair<Set<String>, () -> Unit>>()
|
|
90
|
+
for ((waiting, block) in afterDelivery) {
|
|
91
|
+
if (waiting.none { id -> id in ids }) {
|
|
92
|
+
kept.add(waiting to block)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (!accepted) continue // refused: drop the waiter
|
|
96
|
+
// Subtract what landed rather than asking whether this
|
|
97
|
+
// one batch carried everything. A waiter on two events
|
|
98
|
+
// whose events go out in two batches is otherwise never
|
|
99
|
+
// due — it is a subset of neither.
|
|
100
|
+
val left = waiting - ids
|
|
101
|
+
if (left.isEmpty()) ready.add(block) else kept.add(left to block)
|
|
102
|
+
}
|
|
103
|
+
afterDelivery.clear()
|
|
104
|
+
afterDelivery.addAll(kept)
|
|
105
|
+
}
|
|
106
|
+
ready.forEach { it() }
|
|
107
|
+
}
|
|
108
|
+
private val queue = ArrayDeque<Map<String, Any?>>()
|
|
109
|
+
private val assertStats = mutableMapOf<String, MutableMap<String, Any?>>()
|
|
110
|
+
private var started = false
|
|
111
|
+
private var dropped = 0
|
|
112
|
+
private var delivered = 0
|
|
113
|
+
private var pending: ScheduledFuture<*>? = null
|
|
114
|
+
|
|
115
|
+
/** Where the spill lives. Set by [start]; null keeps it in memory only. */
|
|
116
|
+
private var spillDir: File? = null
|
|
117
|
+
|
|
118
|
+
private val worker: ScheduledExecutorService =
|
|
119
|
+
Executors.newSingleThreadScheduledExecutor { r ->
|
|
120
|
+
Thread(r, "sentori-transport").apply { isDaemon = true }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** O(1) on the calling thread: append, maybe schedule. */
|
|
124
|
+
@JvmStatic
|
|
125
|
+
fun enqueue(event: Map<String, Any?>) {
|
|
126
|
+
val due: Boolean
|
|
127
|
+
synchronized(lock) {
|
|
128
|
+
queue.addLast(event)
|
|
129
|
+
while (queue.size > MAX_QUEUED) {
|
|
130
|
+
queue.removeFirst()
|
|
131
|
+
dropped += 1
|
|
132
|
+
}
|
|
133
|
+
due = queue.size >= BATCH_SIZE
|
|
134
|
+
}
|
|
135
|
+
if (due) worker.execute { flush() } else scheduleFlush(FLUSH_INTERVAL_MS)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Assert outcomes aggregate rather than becoming events, and ride
|
|
140
|
+
* whatever batch goes out next — the liveness ledger without a
|
|
141
|
+
* heartbeat flood.
|
|
142
|
+
*/
|
|
143
|
+
@JvmStatic
|
|
144
|
+
fun countAssert(name: String, ok: Boolean, release: String) {
|
|
145
|
+
val idle: Boolean
|
|
146
|
+
synchronized(lock) {
|
|
147
|
+
val key = "$name$release"
|
|
148
|
+
val stat =
|
|
149
|
+
assertStats.getOrPut(key) {
|
|
150
|
+
mutableMapOf(
|
|
151
|
+
"name" to name,
|
|
152
|
+
"release" to release,
|
|
153
|
+
"passDelta" to 0,
|
|
154
|
+
"failDelta" to 0,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
val field = if (ok) "passDelta" else "failDelta"
|
|
158
|
+
stat[field] = (stat[field] as Int) + 1
|
|
159
|
+
idle = queue.isEmpty()
|
|
160
|
+
}
|
|
161
|
+
if (idle) scheduleFlush(FLUSH_INTERVAL_MS * 6)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@JvmStatic
|
|
165
|
+
@JvmOverloads
|
|
166
|
+
fun start(spill: File? = null) {
|
|
167
|
+
synchronized(lock) {
|
|
168
|
+
started = true
|
|
169
|
+
spillDir = spill
|
|
170
|
+
}
|
|
171
|
+
worker.execute { drainPersisted() }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@JvmStatic
|
|
175
|
+
fun flush() {
|
|
176
|
+
// Before touching the queue, not after: draining first and
|
|
177
|
+
// checking second destroys anything enqueued before init.
|
|
178
|
+
val config = SentoriConfig.current ?: return
|
|
179
|
+
|
|
180
|
+
val events: List<Map<String, Any?>>
|
|
181
|
+
val stats: List<Map<String, Any?>>
|
|
182
|
+
val lost: Int
|
|
183
|
+
synchronized(lock) {
|
|
184
|
+
if (!started || (queue.isEmpty() && assertStats.isEmpty())) return
|
|
185
|
+
events = queue.toList()
|
|
186
|
+
stats = assertStats.values.map { it.toMap() }
|
|
187
|
+
lost = dropped
|
|
188
|
+
queue.clear()
|
|
189
|
+
assertStats.clear()
|
|
190
|
+
dropped = 0
|
|
191
|
+
pending?.cancel(false)
|
|
192
|
+
pending = null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
val envelope = mutableMapOf<String, Any?>("events" to events)
|
|
196
|
+
if (stats.isNotEmpty()) envelope["assertStats"] = stats
|
|
197
|
+
config.backendHealthUrl?.let { envelope["backendHealthUrl"] = it }
|
|
198
|
+
// Say so rather than let the gap look like quiet: a hole in a
|
|
199
|
+
// timeline otherwise reads as a calm period.
|
|
200
|
+
if (lost > 0) envelope["droppedEvents"] = lost
|
|
201
|
+
|
|
202
|
+
worker.execute {
|
|
203
|
+
when (sendWithRetry(envelope, config)) {
|
|
204
|
+
Outcome.DELIVERED -> {
|
|
205
|
+
synchronized(lock) { delivered += events.size }
|
|
206
|
+
settle(events, accepted = true)
|
|
207
|
+
}
|
|
208
|
+
// Handled, but not accepted. Nothing to retry and
|
|
209
|
+
// nothing to count.
|
|
210
|
+
Outcome.DROPPED -> settle(events, accepted = false)
|
|
211
|
+
// Spilled, not lost: `drainPersisted` puts these back
|
|
212
|
+
// through this path on the next start with the same
|
|
213
|
+
// ids, so anything waiting on them keeps waiting.
|
|
214
|
+
else -> persist(events)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── the network ───────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
private enum class Outcome {
|
|
222
|
+
DELIVERED,
|
|
223
|
+
DROPPED,
|
|
224
|
+
RETRY_AFTER,
|
|
225
|
+
FAILED,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private var lastRetryAfterMs = 5_000L
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Returns the terminal outcome rather than a bool. "Stop trying"
|
|
232
|
+
* would conflate a 2xx with a 4xx — not worth retrying is not the
|
|
233
|
+
* same as delivered, and a counter built on the bool counts a
|
|
234
|
+
* rejected batch as a success.
|
|
235
|
+
*/
|
|
236
|
+
private fun sendWithRetry(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
|
|
237
|
+
var delay = 1_000L
|
|
238
|
+
repeat(MAX_RETRY) { attempt ->
|
|
239
|
+
when (val outcome = sendOnce(envelope, config)) {
|
|
240
|
+
Outcome.DELIVERED,
|
|
241
|
+
Outcome.DROPPED -> return outcome
|
|
242
|
+
Outcome.RETRY_AFTER -> {
|
|
243
|
+
if (attempt == MAX_RETRY - 1) return outcome
|
|
244
|
+
sleep(lastRetryAfterMs)
|
|
245
|
+
}
|
|
246
|
+
Outcome.FAILED -> {
|
|
247
|
+
if (attempt == MAX_RETRY - 1) return outcome
|
|
248
|
+
sleep(delay)
|
|
249
|
+
delay *= 2
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return Outcome.FAILED
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Test seam: skip the network and return this outcome.
|
|
258
|
+
* `0` delivered, `1` dropped, anything else failed.
|
|
259
|
+
*
|
|
260
|
+
* The iOS suite arrived here after two attempts to provoke a real
|
|
261
|
+
* failure by sending to a closed port: one CI runner dropped the
|
|
262
|
+
* packet and waited out the timeout, another answered in a way
|
|
263
|
+
* that read as a 4xx — and both times a working transport was
|
|
264
|
+
* reported broken. What these tests are about is the spill, the
|
|
265
|
+
* drain and the ordering, not TCP; `android-live-ingest` is what
|
|
266
|
+
* exercises the wire.
|
|
267
|
+
*/
|
|
268
|
+
internal var forcedOutcomeForTests: Int? = null
|
|
269
|
+
|
|
270
|
+
private fun sendOnce(envelope: Map<String, Any?>, config: SentoriConfig): Outcome {
|
|
271
|
+
forcedOutcomeForTests?.let {
|
|
272
|
+
return when (it) {
|
|
273
|
+
0 -> Outcome.DELIVERED
|
|
274
|
+
1 -> Outcome.DROPPED
|
|
275
|
+
else -> Outcome.FAILED
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
val body =
|
|
279
|
+
try {
|
|
280
|
+
toJson(envelope).toString()
|
|
281
|
+
} catch (_: Throwable) {
|
|
282
|
+
// Unencodable content is our problem, not the host's,
|
|
283
|
+
// and it will not become encodable on a retry.
|
|
284
|
+
return Outcome.DROPPED
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
var conn: HttpURLConnection? = null
|
|
288
|
+
return try {
|
|
289
|
+
conn = URL("${config.ingestUrl}/v1/events:batch").openConnection() as HttpURLConnection
|
|
290
|
+
conn.requestMethod = "POST"
|
|
291
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
292
|
+
conn.setRequestProperty("Authorization", "Bearer ${config.token}")
|
|
293
|
+
conn.setRequestProperty("Sentori-Sdk", "kotlin/${SentoriVersion.CURRENT}")
|
|
294
|
+
conn.connectTimeout = 15_000
|
|
295
|
+
conn.readTimeout = 15_000
|
|
296
|
+
conn.doOutput = true
|
|
297
|
+
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
|
298
|
+
|
|
299
|
+
when (val code = conn.responseCode) {
|
|
300
|
+
in 200..299 -> Outcome.DELIVERED
|
|
301
|
+
429 -> {
|
|
302
|
+
lastRetryAfterMs =
|
|
303
|
+
try {
|
|
304
|
+
val text = conn.errorStream?.bufferedReader()?.readText().orEmpty()
|
|
305
|
+
JSONObject(text).optLong("retryAfterMs", 5_000L)
|
|
306
|
+
} catch (_: Throwable) {
|
|
307
|
+
5_000L
|
|
308
|
+
}
|
|
309
|
+
Outcome.RETRY_AFTER
|
|
310
|
+
}
|
|
311
|
+
in 500..599 -> Outcome.FAILED
|
|
312
|
+
else -> {
|
|
313
|
+
// 4xx other than 429: our request is wrong and
|
|
314
|
+
// will be wrong again. Per-item outcomes are the
|
|
315
|
+
// server's business.
|
|
316
|
+
if (code >= 400) Outcome.DROPPED else Outcome.FAILED
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch (_: Throwable) {
|
|
320
|
+
Outcome.FAILED
|
|
321
|
+
} finally {
|
|
322
|
+
conn?.disconnect()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private fun sleep(ms: Long) {
|
|
327
|
+
try {
|
|
328
|
+
TimeUnit.MILLISECONDS.sleep(ms)
|
|
329
|
+
} catch (_: InterruptedException) {
|
|
330
|
+
Thread.currentThread().interrupt()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── json ──────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Hand-rolled rather than a serialization library, for the same
|
|
338
|
+
* reason as `HttpURLConnection`: `org.json` is in the platform, so
|
|
339
|
+
* it costs the host nothing.
|
|
340
|
+
*/
|
|
341
|
+
internal fun toJson(value: Any?): Any {
|
|
342
|
+
return when (value) {
|
|
343
|
+
null -> JSONObject.NULL
|
|
344
|
+
is Map<*, *> -> {
|
|
345
|
+
val o = JSONObject()
|
|
346
|
+
for ((k, v) in value) o.put(k.toString(), toJson(v))
|
|
347
|
+
o
|
|
348
|
+
}
|
|
349
|
+
is List<*> -> {
|
|
350
|
+
val a = JSONArray()
|
|
351
|
+
for (v in value) a.put(toJson(v))
|
|
352
|
+
a
|
|
353
|
+
}
|
|
354
|
+
is Number, is Boolean, is String -> value
|
|
355
|
+
else -> value.toString()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── the offline queue ─────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
private fun spillFile(): File? {
|
|
362
|
+
val dir = synchronized(lock) { spillDir } ?: return null
|
|
363
|
+
if (!dir.exists()) dir.mkdirs()
|
|
364
|
+
return File(dir, "pending-events.json")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private fun persist(events: List<Map<String, Any?>>) {
|
|
368
|
+
val file = spillFile() ?: return
|
|
369
|
+
try {
|
|
370
|
+
val all = readPersisted().toMutableList()
|
|
371
|
+
all.addAll(events)
|
|
372
|
+
// Newest wins: the file has to stop growing on a device
|
|
373
|
+
// that is offline for a week.
|
|
374
|
+
while (all.size > MAX_PERSISTED) all.removeAt(0)
|
|
375
|
+
file.writeText(toJson(all).toString())
|
|
376
|
+
} catch (_: Throwable) {
|
|
377
|
+
// A full disk must not become the host's problem.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private fun readPersisted(): List<Map<String, Any?>> {
|
|
382
|
+
val file = spillFile() ?: return emptyList()
|
|
383
|
+
if (!file.exists()) return emptyList()
|
|
384
|
+
return try {
|
|
385
|
+
val arr = JSONArray(file.readText())
|
|
386
|
+
(0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
|
|
387
|
+
} catch (_: Throwable) {
|
|
388
|
+
emptyList()
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private fun fromJson(o: JSONObject): Map<String, Any?> {
|
|
393
|
+
val out = mutableMapOf<String, Any?>()
|
|
394
|
+
for (k in o.keys()) {
|
|
395
|
+
out[k] =
|
|
396
|
+
when (val v = o.get(k)) {
|
|
397
|
+
is JSONObject -> fromJson(v)
|
|
398
|
+
JSONObject.NULL -> null
|
|
399
|
+
else -> v
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return out
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Read the spill, clear it, put the events back through the normal
|
|
407
|
+
* path — clearing first, so a batch that fails again spills once
|
|
408
|
+
* rather than doubling.
|
|
409
|
+
*/
|
|
410
|
+
private fun drainPersisted() {
|
|
411
|
+
val pendingEvents = readPersisted()
|
|
412
|
+
if (pendingEvents.isEmpty()) return
|
|
413
|
+
spillFile()?.delete()
|
|
414
|
+
pendingEvents.forEach { enqueue(it) }
|
|
415
|
+
flush()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── timer ─────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
private fun scheduleFlush(afterMs: Long) {
|
|
421
|
+
synchronized(lock) {
|
|
422
|
+
if (pending != null) return
|
|
423
|
+
pending =
|
|
424
|
+
worker.schedule(
|
|
425
|
+
{
|
|
426
|
+
synchronized(lock) { pending = null }
|
|
427
|
+
flush()
|
|
428
|
+
},
|
|
429
|
+
afterMs,
|
|
430
|
+
TimeUnit.MILLISECONDS,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── test seams ────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
internal fun resetForTests() {
|
|
438
|
+
synchronized(lock) {
|
|
439
|
+
spillFile()?.delete()
|
|
440
|
+
queue.clear()
|
|
441
|
+
assertStats.clear()
|
|
442
|
+
dropped = 0
|
|
443
|
+
delivered = 0
|
|
444
|
+
afterDelivery.clear()
|
|
445
|
+
forcedOutcomeForTests = null
|
|
446
|
+
started = false
|
|
447
|
+
pending?.cancel(false)
|
|
448
|
+
pending = null
|
|
449
|
+
spillDir = null
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
internal fun peekQueue(): List<Map<String, Any?>> = synchronized(lock) { queue.toList() }
|
|
454
|
+
|
|
455
|
+
internal fun peekAssertStats(): List<Map<String, Any?>> =
|
|
456
|
+
synchronized(lock) { assertStats.values.map { it.toMap() } }
|
|
457
|
+
|
|
458
|
+
internal fun peekPersisted(): List<Map<String, Any?>> = readPersisted()
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Events the server actually accepted. A test that checks only for
|
|
462
|
+
* the absence of a spill passes while retries are still in flight
|
|
463
|
+
* — which is how the iOS live suite went green against a server
|
|
464
|
+
* that had stored nothing.
|
|
465
|
+
*/
|
|
466
|
+
internal fun peekDelivered(): Int = synchronized(lock) { delivered }
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* How many blocks are still waiting on events. Without this a test
|
|
470
|
+
* cannot tell "discarded because the server refused it" from "has
|
|
471
|
+
* not been reached yet", and the version that tried instead
|
|
472
|
+
* flipped the forced outcome mid-send — measuring the race rather
|
|
473
|
+
* than the rule.
|
|
474
|
+
*/
|
|
475
|
+
internal fun peekWaiting(): Int = synchronized(lock) { afterDelivery.size }
|
|
476
|
+
}
|