@goliapkg/sentori-react-native 6.2.1 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/android/build.gradle +6 -0
  2. package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
  3. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  4. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
  5. package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
  6. package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -0
  7. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  8. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  9. package/android/src/main/java-core/com/sentori/SentoriPush.kt +242 -13
  10. package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
  11. package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
  12. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
  13. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  14. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
  15. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  16. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
  17. package/ios/core/SentoriAttachment.swift +129 -0
  18. package/ios/core/SentoriCrashHandler.swift +15 -0
  19. package/ios/core/SentoriPushNotifications.swift +8 -0
  20. package/lib/push.d.ts.map +1 -1
  21. package/lib/push.js +155 -1
  22. package/lib/push.js.map +1 -1
  23. package/lib/scope.d.ts +6 -0
  24. package/lib/scope.d.ts.map +1 -1
  25. package/lib/scope.js +45 -1
  26. package/lib/scope.js.map +1 -1
  27. package/lib/transport.js +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/push.test.ts +203 -1
  30. package/src/push.ts +147 -1
  31. package/src/scope.ts +50 -1
  32. package/src/transport.ts +1 -1
  33. package/ios/core/SentoriPush.swift +0 -340
@@ -50,6 +50,61 @@ object SentoriTransport {
50
50
  private const val MAX_QUEUED = 500
51
51
 
52
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
+ }
53
108
  private val queue = ArrayDeque<Map<String, Any?>>()
54
109
  private val assertStats = mutableMapOf<String, MutableMap<String, Any?>>()
55
110
  private var started = false
@@ -146,10 +201,16 @@ object SentoriTransport {
146
201
 
147
202
  worker.execute {
148
203
  when (sendWithRetry(envelope, config)) {
149
- Outcome.DELIVERED -> synchronized(lock) { delivered += events.size }
204
+ Outcome.DELIVERED -> {
205
+ synchronized(lock) { delivered += events.size }
206
+ settle(events, accepted = true)
207
+ }
150
208
  // Handled, but not accepted. Nothing to retry and
151
209
  // nothing to count.
152
- Outcome.DROPPED -> Unit
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.
153
214
  else -> persist(events)
154
215
  }
155
216
  }
@@ -192,7 +253,28 @@ object SentoriTransport {
192
253
  return Outcome.FAILED
193
254
  }
194
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
+
195
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
+ }
196
278
  val body =
197
279
  try {
198
280
  toJson(envelope).toString()
@@ -359,6 +441,8 @@ object SentoriTransport {
359
441
  assertStats.clear()
360
442
  dropped = 0
361
443
  delivered = 0
444
+ afterDelivery.clear()
445
+ forcedOutcomeForTests = null
362
446
  started = false
363
447
  pending?.cancel(false)
364
448
  pending = null
@@ -380,4 +464,13 @@ object SentoriTransport {
380
464
  * that had stored nothing.
381
465
  */
382
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 }
383
476
  }
@@ -0,0 +1,413 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriAttachmentTest.kt
3
+ // Run `node scripts/sync-native-core.mjs` after editing it.
4
+ package com.sentori
5
+
6
+ import androidx.test.core.app.ApplicationProvider
7
+ import java.io.File
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+ import org.junit.After
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+
19
+ /**
20
+ * The screenshot taken as the app died, and the view tree behind it.
21
+ *
22
+ * Both were already being captured and written into the crash file on
23
+ * both platforms — and then nothing uploaded them. The event arrived,
24
+ * the evidence did not, and the dashboard showed a crash with an empty
25
+ * viewport that looked like a capture bug.
26
+ *
27
+ * Two things can go wrong here and neither shows up as an error:
28
+ * uploading before the server has the event (a 404 nobody reads), and
29
+ * refusing a kind the server would have accepted.
30
+ *
31
+ * Mirrors `SentoriAttachmentTests.swift`.
32
+ */
33
+ @RunWith(RobolectricTestRunner::class)
34
+ class SentoriAttachmentTest {
35
+
36
+ private val uploads = mutableListOf<Triple<String, String, String>>()
37
+
38
+ @Before
39
+ fun setUp() {
40
+ SentoriTransport.resetForTests()
41
+ SentoriConfig.resetForTests()
42
+ SentoriCrashHandler.consumePending()
43
+ uploads.clear()
44
+ SentoriAttachment.recorderForTests = { id, kind, body ->
45
+ synchronized(uploads) { uploads.add(Triple(id, kind, body)) }
46
+ }
47
+ }
48
+
49
+ @After
50
+ fun tearDown() {
51
+ SentoriAttachment.recorderForTests = null
52
+ SentoriTransport.resetForTests()
53
+ SentoriConfig.resetForTests()
54
+ }
55
+
56
+ /**
57
+ * `start()` with no directory leaves the spill a no-op, which made
58
+ * the "nothing uploads when the batch never lands" test wait five
59
+ * seconds for a file that could never be written — a test failing
60
+ * on its own setup while the code under it was correct.
61
+ */
62
+ private fun spillDir(): File =
63
+ File(
64
+ ApplicationProvider.getApplicationContext<android.content.Context>().cacheDir,
65
+ "sentori-spill",
66
+ )
67
+ .apply {
68
+ deleteRecursively()
69
+ mkdirs()
70
+ }
71
+
72
+ private fun configure() {
73
+ SentoriConfig.set(
74
+ SentoriConfig(
75
+ token = "st_test",
76
+ ingestUrl = "http://127.0.0.1:9",
77
+ release = "app@1.0.0",
78
+ environment = "test",
79
+ ),
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Poll rather than sleep: a fixed wait encodes a guess about how
85
+ * fast the machine is, and CI is slower than this one.
86
+ *
87
+ * The default is generous because a passing wait returns
88
+ * immediately and only a failing one spends the budget. Five
89
+ * seconds bought nothing and cost two red builds on the iOS side.
90
+ */
91
+ private fun waitUntil(what: String, timeoutMs: Long = 30_000, cond: () -> Boolean) {
92
+ val deadline = System.currentTimeMillis() + timeoutMs
93
+ while (System.currentTimeMillis() < deadline) {
94
+ if (cond()) return
95
+ Thread.sleep(20)
96
+ }
97
+ throw AssertionError("$what — not after ${timeoutMs}ms")
98
+ }
99
+
100
+ /** A crash file with a screenshot in it, as the handler writes it. */
101
+ private fun crashFile(id: String): JSONObject =
102
+ JSONObject().apply {
103
+ put("id", id)
104
+ put("timestamp", "2026-08-11T09:00:00.000Z")
105
+ put("kind", "error")
106
+ put("platform", "android")
107
+ put("release", "app@1.0.0")
108
+ put("environment", "test")
109
+ put(
110
+ "device",
111
+ JSONObject().apply {
112
+ put("os", "android")
113
+ put("osVersion", "15")
114
+ put("model", "Pixel 10 Pro")
115
+ },
116
+ )
117
+ put("app", JSONObject().apply { put("version", "1.0.0") })
118
+ put(
119
+ "error",
120
+ JSONObject().apply {
121
+ put("type", "IllegalStateException")
122
+ put("message", "boom")
123
+ },
124
+ )
125
+ put(
126
+ "_pendingAttachments",
127
+ JSONArray().apply {
128
+ put(
129
+ JSONObject().apply {
130
+ put("kind", "screenshot")
131
+ put("base64", "aGVsbG8=")
132
+ put("mediaType", "image/webp")
133
+ put("source", "android")
134
+ },
135
+ )
136
+ put(
137
+ JSONObject().apply {
138
+ put("kind", "viewTree")
139
+ put("base64", "eyJhIjoxfQ==")
140
+ put("mediaType", "application/json")
141
+ put("source", "android")
142
+ },
143
+ )
144
+ },
145
+ )
146
+ }
147
+
148
+ // ── the allowlist ─────────────────────────────────────────────
149
+
150
+ /**
151
+ * The first version of this list had three entries — `replay`,
152
+ * `screens`, `screenshot` — and the crash handler writes
153
+ * `viewTree`. The delivery path would have dropped, on the way
154
+ * out, the very evidence it exists to deliver.
155
+ */
156
+ @Test
157
+ fun everyKindTheCrashHandlerWritesIsAccepted() {
158
+ for (kind in listOf("screenshot", "viewTree")) {
159
+ assertTrue(
160
+ "the crash handler writes '$kind' and this list would refuse it",
161
+ kind in SentoriAttachment.KNOWN,
162
+ )
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Kept in step with `KINDS` in
168
+ * `self-hosted/server/src/handlers/sdk/events_attachments.rs`,
169
+ * where anything else is a 400 and the CHECK constraint behind it
170
+ * would refuse the row anyway.
171
+ */
172
+ @Test
173
+ fun theAllowlistIsTheServersList() {
174
+ assertEquals(
175
+ setOf(
176
+ "logTail",
177
+ "replay",
178
+ "screens",
179
+ "screenshot",
180
+ "sessionTrail",
181
+ "stateSnapshot",
182
+ "viewTree",
183
+ ),
184
+ SentoriAttachment.KNOWN,
185
+ )
186
+ assertEquals(setOf("android", "ios", "js"), SentoriAttachment.KNOWN_SOURCES)
187
+ }
188
+
189
+ @Test
190
+ fun anUnknownKindIsDroppedRatherThanPosted() {
191
+ configure()
192
+ SentoriAttachment.upload("e1", "heapDump", "aGk=", "application/octet-stream")
193
+ assertTrue(uploads.isEmpty())
194
+ }
195
+
196
+ @Test
197
+ fun nothingIsPostedWithoutAConfig() {
198
+ SentoriAttachment.upload("e1", "screenshot", "aGk=", "image/webp")
199
+ assertTrue(uploads.isEmpty())
200
+ }
201
+
202
+ // ── the body ──────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Built by hand, so it is worth reading back. The failure mode is
206
+ * a 2xx that stored the wrong bytes: without the transfer encoding
207
+ * the server keeps the base64 *text* as the image, which renders
208
+ * as nothing and reads as a capture problem.
209
+ */
210
+ @Test
211
+ fun multipartBodyIsWhatTheServerParses() {
212
+ val body =
213
+ SentoriAttachment.multipartBody(
214
+ "BOUND",
215
+ "screenshot",
216
+ "image/webp",
217
+ "aGVsbG8=",
218
+ "android",
219
+ )
220
+
221
+ assertTrue(body.startsWith("--BOUND\r\n"))
222
+ assertTrue(body.endsWith("--BOUND--\r\n"))
223
+ assertTrue(
224
+ body.contains(
225
+ "Content-Disposition: form-data; name=\"file\"; filename=\"screenshot.bin\"\r\n",
226
+ ),
227
+ )
228
+ assertTrue(body.contains("Content-Type: image/webp\r\n"))
229
+ assertTrue(body.contains("Content-Transfer-Encoding: base64\r\n"))
230
+ assertTrue(body.contains("\r\n\r\naGVsbG8=\r\n"))
231
+ assertTrue(
232
+ body.contains("Content-Disposition: form-data; name=\"source\"\r\n\r\nandroid\r\n"),
233
+ )
234
+
235
+ // Two opening delimiters, no more and no fewer.
236
+ assertEquals(2, body.split("--BOUND\r\n").size - 1)
237
+ }
238
+
239
+ /** The bytes are identical to what the Swift package would send. */
240
+ @Test
241
+ fun theBodyMatchesTheOneSwiftBuilds() {
242
+ val body =
243
+ SentoriAttachment.multipartBody("B", "viewTree", "application/json", "eyJhIjoxfQ==", "ios")
244
+ assertEquals(
245
+ "--B\r\n" +
246
+ "Content-Disposition: form-data; name=\"file\"; filename=\"viewTree.bin\"\r\n" +
247
+ "Content-Type: application/json\r\n" +
248
+ "Content-Transfer-Encoding: base64\r\n" +
249
+ "\r\neyJhIjoxfQ==\r\n" +
250
+ "--B\r\n" +
251
+ "Content-Disposition: form-data; name=\"source\"\r\n" +
252
+ "\r\nios\r\n" +
253
+ "--B--\r\n",
254
+ body,
255
+ )
256
+ }
257
+
258
+ // ── the ordering ──────────────────────────────────────────────
259
+
260
+ /**
261
+ * The one that matters. An upload keyed on an event the server has
262
+ * not seen is a 404, and it wins that race every time: the batch
263
+ * waits for a flush and the upload does not.
264
+ */
265
+ @Test
266
+ fun attachmentsUploadOnlyAfterTheBatchLands() {
267
+ configure()
268
+ SentoriCrashHandler.installForTesting(ApplicationProvider.getApplicationContext())
269
+ SentoriTransport.start(spillDir())
270
+ SentoriTransport.forcedOutcomeForTests = 0 // DELIVERED
271
+
272
+ val id = "019ff080-2aeb-7e30-aba1-4431b296d120"
273
+ SentoriCrashHandler.persistRawForTesting(crashFile(id))
274
+ SentoriPendingCrash.ship()
275
+
276
+ waitUntil("both blobs upload after the batch lands") {
277
+ synchronized(uploads) { uploads.size == 2 }
278
+ }
279
+
280
+ assertEquals(setOf("screenshot", "viewTree"), uploads.map { it.second }.toSet())
281
+ for (upload in uploads) {
282
+ assertEquals("an attachment keyed on anything but the event id is a 404", id, upload.first)
283
+ }
284
+ assertTrue(uploads.any { it.third.contains("aGVsbG8=") })
285
+ assertTrue(uploads.any { it.third.contains("Content-Type: application/json") })
286
+ }
287
+
288
+ /**
289
+ * If the batch was refused or spilled to disk, the events are not
290
+ * on the server and there is nothing to attach to. Uploading
291
+ * anyway spends the user's bandwidth on a guaranteed 404.
292
+ */
293
+ @Test
294
+ fun nothingUploadsWhenTheBatchNeverLands() {
295
+ configure()
296
+ SentoriCrashHandler.installForTesting(ApplicationProvider.getApplicationContext())
297
+ SentoriTransport.start(spillDir())
298
+ SentoriTransport.forcedOutcomeForTests = 2 // FAILED
299
+
300
+ val spilledId = "019ff080-2aeb-7e30-aba1-4431b296d121"
301
+ SentoriCrashHandler.persistRawForTesting(crashFile(spilledId))
302
+ SentoriPendingCrash.ship()
303
+
304
+ // The event has to reach the spill before the absence of an
305
+ // upload means anything — otherwise this passes by being early.
306
+ //
307
+ // Ask for this crash, not for a count: the spill is one file
308
+ // the whole process shares, so a size check is an assertion
309
+ // about what every other test left behind.
310
+ waitUntil("the failed batch spills", 60_000) {
311
+ SentoriTransport.peekPersisted().any { it["id"] as? String == spilledId }
312
+ }
313
+
314
+ assertTrue(
315
+ "a spilled batch has no event on the server to attach to",
316
+ synchronized(uploads) { uploads.isEmpty() },
317
+ )
318
+ }
319
+
320
+ // ── the hook itself ───────────────────────────────────────────
321
+
322
+ /** Registered for events that are still queued, it waits. */
323
+ @Test
324
+ fun aHookWaitsUntilItsOwnEventsLand() {
325
+ configure()
326
+ SentoriTransport.start(spillDir())
327
+ SentoriTransport.forcedOutcomeForTests = 0 // DELIVERED
328
+
329
+ var fired = 0
330
+ SentoriTransport.afterDelivery(setOf("a", "b")) { fired += 1 }
331
+
332
+ SentoriTransport.enqueue(mapOf("id" to "a", "kind" to "error"))
333
+ SentoriTransport.flush()
334
+ waitUntil("the first batch lands") { SentoriTransport.peekDelivered() >= 1 }
335
+ assertEquals("half its events have landed — it is not due yet", 0, fired)
336
+
337
+ SentoriTransport.enqueue(mapOf("id" to "b", "kind" to "error"))
338
+ SentoriTransport.flush()
339
+ waitUntil("the hook fires once both have landed") { fired == 1 }
340
+ }
341
+
342
+ /**
343
+ * Registered for events the server refuses outright, it is
344
+ * dropped: there is nothing on the server to attach to, and
345
+ * uploading anyway spends a device's bandwidth on a 404.
346
+ */
347
+ @Test
348
+ fun aHookIsDiscardedWhenTheServerRefusesItsEvents() {
349
+ configure()
350
+ SentoriTransport.start(spillDir())
351
+ SentoriTransport.forcedOutcomeForTests = 1 // DROPPED
352
+
353
+ var fired = false
354
+ SentoriTransport.afterDelivery(setOf("a")) { fired = true }
355
+ assertEquals(1, SentoriTransport.peekWaiting())
356
+
357
+ SentoriTransport.enqueue(mapOf("id" to "a", "kind" to "error"))
358
+ SentoriTransport.flush()
359
+
360
+ waitUntil("the refusal discards the waiter") { SentoriTransport.peekWaiting() == 0 }
361
+ assertFalse("the events it waited on were refused", fired)
362
+ }
363
+
364
+ /**
365
+ * The shape of the bug CI caught. A hook registered after its
366
+ * batch has already been accepted never fires — which is why
367
+ * `ship` registers before it enqueues, not after it flushes.
368
+ */
369
+ @Test
370
+ fun aHookRegisteredTooLateNeverFires() {
371
+ configure()
372
+ SentoriTransport.start(spillDir())
373
+ SentoriTransport.forcedOutcomeForTests = 0
374
+
375
+ SentoriTransport.enqueue(mapOf("id" to "a", "kind" to "error"))
376
+ SentoriTransport.flush()
377
+ waitUntil("the batch lands") { SentoriTransport.peekDelivered() >= 1 }
378
+
379
+ var fired = false
380
+ SentoriTransport.afterDelivery(setOf("a")) { fired = true }
381
+ SentoriTransport.enqueue(mapOf("id" to "z", "kind" to "error"))
382
+ SentoriTransport.flush()
383
+ waitUntil("a later batch lands") { SentoriTransport.peekDelivered() >= 2 }
384
+
385
+ assertFalse(
386
+ "it fired on someone else's batch — an upload keyed on an event that " +
387
+ "batch never carried is a 404",
388
+ fired,
389
+ )
390
+ }
391
+
392
+ /**
393
+ * The blobs travel in the file and never on the wire. An event
394
+ * carrying a base64 screenshot inside its JSON is a megabyte in a
395
+ * batch that is meant to be a few kilobytes.
396
+ */
397
+ @Test
398
+ fun pendingAttachmentsNeverGoOnTheWire() {
399
+ configure()
400
+ SentoriCrashHandler.installForTesting(ApplicationProvider.getApplicationContext())
401
+ SentoriTransport.forcedOutcomeForTests = 0
402
+ SentoriCrashHandler.persistRawForTesting(crashFile("019ff080-2aeb-7e30-aba1-4431b296d122"))
403
+ SentoriPendingCrash.ship()
404
+
405
+ val queued = SentoriTransport.peekQueue() + SentoriTransport.peekPersisted()
406
+ for (event in queued) {
407
+ assertFalse(
408
+ "the screenshot bytes must not ride inside the event",
409
+ event.toString().contains("aGVsbG8="),
410
+ )
411
+ }
412
+ }
413
+ }
@@ -3,10 +3,12 @@
3
3
  // Run `node scripts/sync-native-core.mjs` after editing it.
4
4
  package com.sentori
5
5
 
6
+ import androidx.test.core.app.ApplicationProvider
6
7
  import java.io.File
7
8
  import org.json.JSONObject
8
9
  import org.junit.After
9
10
  import org.junit.Assert.assertEquals
11
+ import org.junit.Assert.assertNotNull
10
12
  import org.junit.Assert.assertTrue
11
13
  import org.junit.Assume.assumeTrue
12
14
  import org.junit.Before
@@ -102,5 +104,46 @@ class SentoriLiveServerTest {
102
104
  SentoriTransport.peekDelivered() > 0,
103
105
  )
104
106
  assertEquals(36, id.length)
107
+
108
+ // The multipart body against a real parser.
109
+ //
110
+ // Everything else about attachments is asserted as a string,
111
+ // and a string that looks right is exactly what React Native
112
+ // shipped for a release while every upload failed on the
113
+ // device. The body is hand-built; the only proof it is
114
+ // well-formed is a server that read it.
115
+ val uploaded = java.util.concurrent.CountDownLatch(1)
116
+ var ok = false
117
+ SentoriAttachment.upload(
118
+ eventId = id,
119
+ kind = "screenshot",
120
+ base64 = ONE_PIXEL_JPEG_BASE64,
121
+ mediaType = "image/jpeg",
122
+ ) { result ->
123
+ ok = result
124
+ uploaded.countDown()
125
+ }
126
+ assertTrue(
127
+ "the attachment upload never came back",
128
+ uploaded.await(30, java.util.concurrent.TimeUnit.SECONDS),
129
+ )
130
+ assertTrue(
131
+ "the server refused the multipart body — it parses this, or attachments " +
132
+ "die silently on every device",
133
+ ok,
134
+ )
135
+ }
136
+
137
+ companion object {
138
+ /**
139
+ * The smallest valid JPEG, base64. Content matters: the server
140
+ * stores what it decodes, and a payload that is not an image
141
+ * would pass the upload and fail the viewer.
142
+ */
143
+ private const val ONE_PIXEL_JPEG_BASE64 =
144
+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a" +
145
+ "HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA" +
146
+ "AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=="
105
147
  }
148
+
106
149
  }