@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.
Files changed (56) hide show
  1. package/SentoriReactNative.podspec +11 -4
  2. package/android/build.gradle +21 -1
  3. package/android/src/main/java-core/com/sentori/Sentori.kt +273 -0
  4. package/android/src/main/{java → java-core}/com/sentori/SentoriAnrWatchdog.kt +3 -0
  5. package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
  6. package/android/src/main/java-core/com/sentori/SentoriConfig.kt +61 -0
  7. package/android/src/main/{java → java-core}/com/sentori/SentoriCrashHandler.kt +22 -0
  8. package/android/src/main/java-core/com/sentori/SentoriDevice.kt +61 -0
  9. package/android/src/main/{java → java-core}/com/sentori/SentoriFirebaseMessagingService.kt +18 -0
  10. package/android/src/main/{java → java-core}/com/sentori/SentoriForegroundActivity.kt +3 -0
  11. package/android/src/main/java-core/com/sentori/SentoriIdentity.kt +101 -0
  12. package/android/src/main/{java → java-core}/com/sentori/SentoriMobileVitals.kt +3 -0
  13. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeExceptionBridge.kt +3 -0
  14. package/android/src/main/{java → java-core}/com/sentori/SentoriNativeSignals.kt +3 -0
  15. package/android/src/main/java-core/com/sentori/SentoriNotificationTap.kt +148 -0
  16. package/android/src/main/java-core/com/sentori/SentoriPendingCrash.kt +154 -0
  17. package/android/src/main/java-core/com/sentori/SentoriPush.kt +588 -0
  18. package/android/src/main/{java → java-core}/com/sentori/SentoriPushNotifications.kt +171 -17
  19. package/android/src/main/{java → java-core}/com/sentori/SentoriReplayCapture.kt +3 -0
  20. package/android/src/main/java-core/com/sentori/SentoriScope.kt +107 -0
  21. package/android/src/main/{java → java-core}/com/sentori/SentoriScreenshotCapture.kt +3 -0
  22. package/android/src/main/java-core/com/sentori/SentoriSignalRing.kt +80 -0
  23. package/android/src/main/java-core/com/sentori/SentoriTransport.kt +476 -0
  24. package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
  25. package/android/src/test/{java → java-core}/com/sentori/SentoriCrashHandlerTest.kt +3 -0
  26. package/android/src/test/java-core/com/sentori/SentoriIdentityTest.kt +107 -0
  27. package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +149 -0
  28. package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
  29. package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +707 -0
  30. package/android/src/test/{java → java-core}/com/sentori/SentoriScreenshotCaptureTest.kt +3 -0
  31. package/android/src/test/java-core/com/sentori/SentoriVerbsTest.kt +185 -0
  32. package/ios/core/SentoriAttachment.swift +129 -0
  33. package/ios/{SentoriCrashHandler.swift → core/SentoriCrashHandler.swift} +31 -0
  34. package/ios/{SentoriHangWatchdog.swift → core/SentoriHangWatchdog.swift} +3 -0
  35. package/ios/{SentoriMobileVitals.swift → core/SentoriMobileVitals.swift} +3 -0
  36. package/ios/{SentoriNativeExceptionBridge.swift → core/SentoriNativeExceptionBridge.swift} +3 -0
  37. package/ios/{SentoriPushNotifications.swift → core/SentoriPushNotifications.swift} +50 -0
  38. package/ios/{SentoriReplayCapture.swift → core/SentoriReplayCapture.swift} +3 -0
  39. package/ios/{SentoriScreenshotCapture.swift → core/SentoriScreenshotCapture.swift} +14 -0
  40. package/ios/{SentoriThreadSampler.swift → core/SentoriThreadSampler.swift} +3 -0
  41. package/lib/push.d.ts.map +1 -1
  42. package/lib/push.js +51 -1
  43. package/lib/push.js.map +1 -1
  44. package/lib/scope.d.ts +6 -0
  45. package/lib/scope.d.ts.map +1 -1
  46. package/lib/scope.js +45 -1
  47. package/lib/scope.js.map +1 -1
  48. package/lib/transport.js +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/push.test.ts +99 -1
  51. package/src/push.ts +49 -1
  52. package/src/scope.ts +50 -1
  53. package/src/transport.ts +1 -1
  54. package/ios/Tests/SentoriCrashHandlerTests.swift +0 -59
  55. package/ios/Tests/SentoriScreenshotCaptureTests.swift +0 -59
  56. package/ios/Tests/SentoriThreadSamplerTests.swift +0 -96
@@ -0,0 +1,707 @@
1
+ // GENERATED MIRROR — do not edit.
2
+ // Source of truth: sdk/native/android/src/test/java/com/sentori/SentoriPushTest.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.util.concurrent.CountDownLatch
8
+ import java.util.concurrent.TimeUnit
9
+ import org.junit.After
10
+ import org.junit.Assert.assertEquals
11
+ import org.junit.Assert.assertNotNull
12
+ import org.junit.Assert.assertNull
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.Robolectric
18
+ import org.robolectric.RobolectricTestRunner
19
+ import org.robolectric.Shadows.shadowOf
20
+ import org.robolectric.annotation.Config
21
+
22
+ /**
23
+ * Push registration, as far as Robolectric can go.
24
+ *
25
+ * There is no FCM on the classpath here and no Google Play services,
26
+ * so the honest coverage is the shape of the failures and the shape of
27
+ * the request — not a real token. `scripts/android-live-ingest.sh`
28
+ * proves the ingest route against a real server; a device proves the
29
+ * rest, and nothing here pretends otherwise.
30
+ *
31
+ * Deliberately the same assertions as `SentoriPushTests.swift`.
32
+ */
33
+ @RunWith(RobolectricTestRunner::class)
34
+ class SentoriPushTest {
35
+
36
+ private val context get() = ApplicationProvider.getApplicationContext<android.content.Context>()
37
+
38
+ @Before
39
+ fun setUp() {
40
+ SentoriConfig.resetForTests()
41
+ SentoriScope.clear()
42
+ SentoriPush.resetForTests()
43
+ }
44
+
45
+ @After
46
+ fun tearDown() {
47
+ SentoriConfig.resetForTests()
48
+ SentoriPush.resetForTests()
49
+ }
50
+
51
+ private fun registerBlocking(timeoutMs: Long = 1_000): SentoriPush.Result {
52
+ val latch = CountDownLatch(1)
53
+ var result: SentoriPush.Result? = null
54
+ SentoriPush.register(context, activity = null, timeoutMs = timeoutMs) {
55
+ result = it
56
+ latch.countDown()
57
+ }
58
+ assertTrue("register never called back", latch.await(30, TimeUnit.SECONDS))
59
+ return result!!
60
+ }
61
+
62
+ @Test
63
+ fun registerBeforeStartReportsItRatherThanThrowing() {
64
+ val r = registerBlocking()
65
+ assertTrue("expected a failure before start, got $r", r is SentoriPush.Result.Failure)
66
+ assertEquals(
67
+ SentoriPush.Failure.NOT_INITIALISED,
68
+ (r as SentoriPush.Result.Failure).reason,
69
+ )
70
+ assertEquals("not-initialised", r.reason.reason)
71
+ }
72
+
73
+ @Test
74
+ fun everyFailureHasTheSameNameAsTheOtherTwoSdks() {
75
+ // Same strings as `PushRegisterFailure` in React Native and
76
+ // `SentoriPush.Failure` in Swift, so one set of integration
77
+ // notes covers all three.
78
+ assertEquals("not-initialised", SentoriPush.Failure.NOT_INITIALISED.reason)
79
+ assertEquals("permission-denied", SentoriPush.Failure.PERMISSION_DENIED.reason)
80
+ assertEquals("no-transport", SentoriPush.Failure.NO_TRANSPORT.reason)
81
+ assertEquals("token-timeout", SentoriPush.Failure.TOKEN_TIMEOUT.reason)
82
+ assertEquals("server-rejected", SentoriPush.Failure.SERVER_REJECTED.reason)
83
+ }
84
+
85
+ @Test
86
+ fun registerWithoutFcmFailsWithoutThrowing() {
87
+ Sentori.start(
88
+ SentoriConfig(
89
+ token = "st_test",
90
+ ingestUrl = "http://127.0.0.1:9",
91
+ release = "app@1.0.0",
92
+ environment = "test",
93
+ ),
94
+ context,
95
+ )
96
+
97
+ // No Firebase on the classpath, so this ends at permission or
98
+ // at the token wait. Either is fine; reaching the end without
99
+ // a throw and without a hang is the assertion.
100
+ val r = registerBlocking()
101
+ assertTrue("Robolectric cannot actually register, got $r", r is SentoriPush.Result.Failure)
102
+ val reason = (r as SentoriPush.Result.Failure).reason
103
+ assertTrue(
104
+ "unexpected reason ${reason.reason}: ${r.message}",
105
+ reason in
106
+ listOf(
107
+ SentoriPush.Failure.PERMISSION_DENIED,
108
+ SentoriPush.Failure.NO_TRANSPORT,
109
+ SentoriPush.Failure.TOKEN_TIMEOUT,
110
+ ),
111
+ )
112
+ assertNull("a failure must cache nothing", SentoriPush.cachedDeviceHandle(context))
113
+ }
114
+
115
+ /**
116
+ * The one that was missing, and the reason a whole class of user
117
+ * never got push.
118
+ *
119
+ * `requestPermission` parked its callback in a field and left it
120
+ * to `handlePermissionResult` — a hook the host has to forward
121
+ * from its own `onRequestPermissionsResult`, which the docs never
122
+ * mentioned and a native host has no reason to know about. So the
123
+ * callback was never called, `finishRegister` never ran, and a
124
+ * first launch registered nothing: the dialog appeared, the user
125
+ * tapped Allow, and the SDK did not continue. It worked on the
126
+ * *next* launch, because the permission was granted by then and
127
+ * the flow never suspended at all.
128
+ *
129
+ * Every test in this file passed throughout, because every one of
130
+ * them passes `activity = null` and skips the prompt entirely.
131
+ *
132
+ * This one takes an Activity, never forwards the result — exactly
133
+ * what a host that has not read the source does — and grants the
134
+ * permission the way the framework does. What is asserted is only
135
+ * that `register` comes back at all. Whether it then succeeds
136
+ * needs an FCM that is not on this classpath.
137
+ */
138
+ @Test
139
+ @Config(sdk = [33])
140
+ fun registerContinuesWhenPermissionIsGrantedWithoutTheHostForwardingIt() {
141
+ SentoriConfig.set(
142
+ SentoriConfig(
143
+ token = "st_test",
144
+ ingestUrl = "http://127.0.0.1:9",
145
+ release = "app@1.0.0",
146
+ environment = "test",
147
+ ),
148
+ )
149
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
150
+ shadowOf(app).denyPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
151
+ val activity = Robolectric.buildActivity(android.app.Activity::class.java).setup().get()
152
+
153
+ val latch = CountDownLatch(1)
154
+ var result: SentoriPush.Result? = null
155
+ SentoriPush.permissionTimeoutMs = 20_000
156
+ SentoriPush.register(app, activity = activity, timeoutMs = 500) {
157
+ result = it
158
+ latch.countDown()
159
+ }
160
+
161
+ // Not registered yet: the dialog is up and nobody has answered.
162
+ assertNull("register reported before the permission was answered", result)
163
+
164
+ // The user taps Allow. Nothing forwards the result — this is
165
+ // the whole point.
166
+ shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
167
+
168
+ assertTrue(
169
+ "register never came back after the permission was granted — the first " +
170
+ "launch after a fresh install registers nothing and says nothing",
171
+ latch.await(30, TimeUnit.SECONDS),
172
+ )
173
+ assertNotNull(result)
174
+ }
175
+
176
+ /**
177
+ * And when nobody ever answers, it still reports rather than
178
+ * leaving the caller with a callback that never fires.
179
+ */
180
+ @Test
181
+ @Config(sdk = [33])
182
+ fun registerReportsWhenThePermissionDialogIsIgnored() {
183
+ SentoriConfig.set(
184
+ SentoriConfig(
185
+ token = "st_test",
186
+ ingestUrl = "http://127.0.0.1:9",
187
+ release = "app@1.0.0",
188
+ environment = "test",
189
+ ),
190
+ )
191
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
192
+ shadowOf(app).denyPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
193
+ val activity = Robolectric.buildActivity(android.app.Activity::class.java).setup().get()
194
+
195
+ val latch = CountDownLatch(1)
196
+ var result: SentoriPush.Result? = null
197
+ SentoriPush.permissionTimeoutMs = 1_500
198
+ SentoriPush.register(app, activity = activity, timeoutMs = 500) {
199
+ result = it
200
+ latch.countDown()
201
+ }
202
+
203
+ assertTrue("register never gave up", latch.await(30, TimeUnit.SECONDS))
204
+ val failure = result as? SentoriPush.Result.Failure
205
+ assertNotNull("expected a failure, got $result", failure)
206
+ assertEquals(SentoriPush.Failure.PERMISSION_DENIED, failure!!.reason)
207
+ }
208
+
209
+ @Test
210
+ fun unregisterWithNothingRegisteredIsANoOp() {
211
+ val latch = CountDownLatch(1)
212
+ var ok: Boolean? = null
213
+ SentoriPush.unregister(context) {
214
+ ok = it
215
+ latch.countDown()
216
+ }
217
+ assertTrue(latch.await(10, TimeUnit.SECONDS))
218
+ assertEquals("nothing to revoke", false, ok)
219
+ assertNull(SentoriPush.cachedDeviceHandle(context))
220
+ }
221
+
222
+ @Test
223
+ fun theRegistrationBodyIsTheOneTheServerAccepts() {
224
+ // The field names are the whole reason push never worked for a
225
+ // year: the RN SDK sent `provider` where the server reads
226
+ // `kind`, and parsed an `ipt_*` handle no server has ever
227
+ // returned. Pinned here rather than left to a reviewer.
228
+ SentoriScope.setUser("usr_123", null)
229
+ val body =
230
+ mapOf(
231
+ "kind" to "fcm",
232
+ "nativeToken" to "abcd",
233
+ "userKey" to SentoriScope.userKey,
234
+ )
235
+ val json = org.json.JSONObject(SentoriTransport.toJson(body).toString())
236
+
237
+ assertEquals("fcm", json.getString("kind"))
238
+ assertTrue("the server has no such field", !json.has("provider"))
239
+ // FCM is one host; an `env` here would be a claim about a
240
+ // sandbox/production split that does not exist.
241
+ assertTrue("FCM has no environment split", !json.has("env"))
242
+ assertEquals(
243
+ SentoriIdentity.hash("id", "usr_123"),
244
+ json.getString("userKey"),
245
+ )
246
+ assertNotNull(json)
247
+ }
248
+ // ── taps ──────────────────────────────────────────────────────
249
+
250
+ /**
251
+ * The second half of the same bug as the permission one.
252
+ *
253
+ * `handleNotificationTap` was reachable only from the host, whose
254
+ * own doc comment told it to forward intent extras — advice that
255
+ * appeared nowhere a host would read. So `register(onTap:)` took
256
+ * a callback that could never be called. insight measured it: the
257
+ * tray entry appeared, the app opened, silence.
258
+ *
259
+ * Here the app is launched by a notification and the host does
260
+ * nothing at all.
261
+ */
262
+ @Test
263
+ @Config(sdk = [33])
264
+ fun aColdStartFromANotificationDeliversTheTapWithoutTheHostForwardingIt() {
265
+ SentoriConfig.set(
266
+ SentoriConfig(
267
+ token = "st_test",
268
+ ingestUrl = "http://127.0.0.1:9",
269
+ release = "app@1.0.0",
270
+ environment = "test",
271
+ ),
272
+ )
273
+ SentoriNotificationTap.resetForTests()
274
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
275
+ shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
276
+
277
+ // What the system hands an Activity opened from a tap.
278
+ val intent = android.content.Intent(app, android.app.Activity::class.java)
279
+ .putExtra("google.message_id", "0:1786545260399414%c9712c12")
280
+ .putExtra("title", "Crash in checkout")
281
+ val activity = Robolectric.buildActivity(android.app.Activity::class.java, intent)
282
+ .setup().get()
283
+
284
+ val taps = mutableListOf<Map<String, Any?>>()
285
+ val latch = CountDownLatch(1)
286
+ SentoriPush.register(
287
+ app,
288
+ activity = activity,
289
+ timeoutMs = 300,
290
+ onTap = { taps.add(it) },
291
+ ) { latch.countDown() }
292
+ assertTrue("register never came back", latch.await(30, TimeUnit.SECONDS))
293
+
294
+ val deadline = System.currentTimeMillis() + 10_000
295
+ while (taps.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(50)
296
+
297
+ assertTrue(
298
+ "onTap never fired for the notification that launched the app — the host " +
299
+ "passed a callback that could not be called",
300
+ taps.isNotEmpty(),
301
+ )
302
+ assertEquals("0:1786545260399414%c9712c12", taps[0]["google.message_id"])
303
+ }
304
+
305
+ /** An ordinary launch is not a tap. */
306
+ @Test
307
+ @Config(sdk = [33])
308
+ fun anOrdinaryLaunchIsNotReportedAsATap() {
309
+ SentoriNotificationTap.resetForTests()
310
+ val activity = Robolectric.buildActivity(android.app.Activity::class.java).setup().get()
311
+ val taps = mutableListOf<Map<String, Any?>>()
312
+ SentoriPushNotifications.drainState()
313
+
314
+ SentoriNotificationTap.consume(activity)
315
+ val state = SentoriPushNotifications.drainState()
316
+
317
+ @Suppress("UNCHECKED_CAST")
318
+ val drained = state["taps"] as? List<Map<String, Any?>> ?: emptyList()
319
+ taps.addAll(drained)
320
+ assertTrue(
321
+ "a launch with no notification behind it was reported as a tap, which " +
322
+ "puts a notification in every session that never had one",
323
+ taps.isEmpty(),
324
+ )
325
+ }
326
+
327
+ /** The same tap is not reported twice. */
328
+ @Test
329
+ @Config(sdk = [33])
330
+ fun oneTapIsDeliveredOnce() {
331
+ SentoriNotificationTap.resetForTests()
332
+ SentoriPushNotifications.drainState()
333
+ val extras = android.os.Bundle().apply {
334
+ putString("google.message_id", "0:dedupe-me")
335
+ putString("title", "once")
336
+ }
337
+
338
+ SentoriNotificationTap.consume(extras)
339
+ SentoriNotificationTap.consume(extras)
340
+
341
+ @Suppress("UNCHECKED_CAST")
342
+ val taps = SentoriPushNotifications.drainState()["taps"] as? List<Map<String, Any?>>
343
+ ?: emptyList()
344
+ assertEquals(
345
+ "the launch intent is visible to more than one caller; counting it twice " +
346
+ "turns one open into two",
347
+ 1,
348
+ taps.size,
349
+ )
350
+ }
351
+
352
+ // ── the tray ──────────────────────────────────────────────────
353
+
354
+ /**
355
+ * A data message the user can actually see.
356
+ *
357
+ * Nothing posted a notification before, so a `data` message
358
+ * reached `onMessage` and left the tray empty — insight's device
359
+ * said "No notifications" while the callback had fired. The
360
+ * server sends `data` on purpose, so this is the whole visible
361
+ * half of a push.
362
+ */
363
+ @Test
364
+ @Config(sdk = [33])
365
+ fun aDataMessageWithSomethingToShowReachesTheTray() {
366
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
367
+ shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
368
+ val mgr = app.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
369
+ as android.app.NotificationManager
370
+ mgr.cancelAll()
371
+
372
+ SentoriPushNotifications.postNotification(
373
+ app,
374
+ mapOf("google.message_id" to "0:show-me", "title" to "Crash in checkout",
375
+ "body" to "3 users affected"),
376
+ )
377
+
378
+ val posted = shadowOf(mgr).allNotifications
379
+ assertEquals("the user saw nothing", 1, posted.size)
380
+ assertNotNull(
381
+ "no pending intent, so the tap cannot come back",
382
+ posted[0].contentIntent,
383
+ )
384
+ }
385
+
386
+ /**
387
+ * Through the service, not around it.
388
+ *
389
+ * The first version of the test above called `postNotification`
390
+ * directly, so removing the call from `onMessageReceived` left it
391
+ * green — a unit test of a function nobody calls proves the
392
+ * function, not the feature. This drives a real `RemoteMessage`
393
+ * into the real service.
394
+ */
395
+ @Test
396
+ @Config(sdk = [33])
397
+ fun theServiceTurnsADataMessageIntoATrayEntryAndACallback() {
398
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
399
+ shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
400
+ val mgr = app.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
401
+ as android.app.NotificationManager
402
+ mgr.cancelAll()
403
+ SentoriPushNotifications.drainState()
404
+
405
+ val service = Robolectric.buildService(SentoriFirebaseMessagingService::class.java)
406
+ .create().get()
407
+ val message = com.google.firebase.messaging.RemoteMessage.Builder("to@fcm")
408
+ .setMessageId("0:through-the-service")
409
+ .addData("sentori", "1")
410
+ .addData("title", "Crash in checkout")
411
+ .addData("body", "3 users affected")
412
+ .build()
413
+
414
+ service.onMessageReceived(message)
415
+
416
+ assertEquals(
417
+ "a data message left the tray empty — the user sees nothing and has " +
418
+ "nothing to tap",
419
+ 1,
420
+ shadowOf(mgr).allNotifications.size,
421
+ )
422
+ @Suppress("UNCHECKED_CAST")
423
+ val received = SentoriPushNotifications.drainState()["notifications"]
424
+ as? List<Map<String, Any?>> ?: emptyList()
425
+ assertEquals(1, received.size)
426
+ }
427
+
428
+ /**
429
+ * A host with no `android:icon` still gets its tray entry.
430
+ *
431
+ * `applicationInfo.icon` is `0` for an app that declares none —
432
+ * insight's drew its logo from a launch theme — and `notify`
433
+ * throws on an icon of 0. That throw went into a catch written
434
+ * for a different reason and disappeared: the channel existed,
435
+ * the callback fired, the tray was empty, and logcat said
436
+ * nothing. They found it by reasoning backwards from the channel.
437
+ *
438
+ * Robolectric accepts an icon of 0 without complaint, so the
439
+ * first version of this test — post one, assert one arrived —
440
+ * passed with the bug still in place and proved nothing. What is
441
+ * asserted instead is the icon this code hands the framework,
442
+ * which is the part that is ours. That a real device rejects a 0
443
+ * is a fact about Android, and the fix is to never hand it one.
444
+ */
445
+ @Test
446
+ @Config(sdk = [33])
447
+ fun aHostWithNoIconStillGetsItsTrayEntry() {
448
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
449
+ shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
450
+ val mgr = app.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
451
+ as android.app.NotificationManager
452
+ mgr.cancelAll()
453
+
454
+ val declared = app.applicationInfo.icon
455
+ app.applicationInfo.icon = 0
456
+ try {
457
+ SentoriPushNotifications.postNotification(
458
+ app,
459
+ mapOf("google.message_id" to "0:no-icon", "title" to "Crash in checkout"),
460
+ )
461
+ val posted = shadowOf(mgr).allNotifications
462
+ assertEquals(1, posted.size)
463
+ assertTrue(
464
+ "a small icon of 0 is what a device throws on, and the throw lands " +
465
+ "in a catch — no tray entry, no log, nothing to go on",
466
+ posted[0].smallIcon.resId != 0,
467
+ )
468
+ } finally {
469
+ app.applicationInfo.icon = declared
470
+ }
471
+ }
472
+
473
+ /**
474
+ * A silent data message stays silent. An app that uses data
475
+ * messages to tell itself something would not thank us for
476
+ * turning each one into a notification to dismiss.
477
+ */
478
+ @Test
479
+ @Config(sdk = [33])
480
+ fun aDataMessageWithNothingToShowIsNotPosted() {
481
+ val app = ApplicationProvider.getApplicationContext<android.app.Application>()
482
+ shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
483
+ val mgr = app.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
484
+ as android.app.NotificationManager
485
+ mgr.cancelAll()
486
+
487
+ SentoriPushNotifications.postNotification(app, mapOf("sync" to "inbox"))
488
+
489
+ assertTrue(shadowOf(mgr).allNotifications.isEmpty())
490
+ }
491
+
492
+ // ── rotation ──────────────────────────────────────────────────
493
+
494
+ /**
495
+ * A listener that records what was posted to it.
496
+ *
497
+ * `com.sun.net.httpserver` is not on the Android compile
498
+ * classpath, and a mocked HTTP client would only prove that the
499
+ * SDK called the thing the test replaced. A socket is what the
500
+ * SDK will actually write to.
501
+ */
502
+ private class Recorder : AutoCloseable {
503
+ val bodies = java.util.Collections.synchronizedList(mutableListOf<String>())
504
+ private val socket = java.net.ServerSocket(0, 4, java.net.InetAddress.getLoopbackAddress())
505
+ val port: Int get() = socket.localPort
506
+ private val thread = Thread {
507
+ while (!socket.isClosed) {
508
+ try {
509
+ socket.accept().use { c ->
510
+ val input = c.getInputStream()
511
+ val head = StringBuilder()
512
+ while (!head.endsWith("\r\n\r\n")) {
513
+ val b = input.read()
514
+ if (b < 0) return@use
515
+ head.append(b.toChar())
516
+ }
517
+ val len = Regex("(?i)content-length: *(\\d+)")
518
+ .find(head)?.groupValues?.get(1)?.toIntOrNull() ?: 0
519
+ val body = ByteArray(len)
520
+ var read = 0
521
+ while (read < len) {
522
+ val n = input.read(body, read, len - read)
523
+ if (n < 0) break
524
+ read += n
525
+ }
526
+ bodies.add(String(body, 0, read, Charsets.UTF_8))
527
+ val payload =
528
+ """{"spToken":"019ff000-0000-7000-8000-000000000001","is_new":false}"""
529
+ c.getOutputStream().write(
530
+ ("HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\n" +
531
+ "Content-Length: ${payload.toByteArray().size}\r\n" +
532
+ "Connection: close\r\n\r\n" + payload).toByteArray()
533
+ )
534
+ c.getOutputStream().flush()
535
+ }
536
+ } catch (_: Throwable) {
537
+ // the close below is what ends this loop
538
+ }
539
+ }
540
+ }.apply { isDaemon = true; start() }
541
+
542
+ override fun close() {
543
+ socket.close()
544
+ thread.interrupt()
545
+ }
546
+ }
547
+
548
+ private fun configureAt(port: Int) {
549
+ SentoriConfig.set(
550
+ SentoriConfig(
551
+ token = "st_test",
552
+ ingestUrl = "http://127.0.0.1:$port",
553
+ release = "app@1.0.0",
554
+ environment = "test",
555
+ ),
556
+ )
557
+ }
558
+
559
+ /**
560
+ * A vendor rotating its token has to reach the server, and the
561
+ * only thing that proves it is a request arriving.
562
+ *
563
+ * The first version of this asserted that the spToken had not
564
+ * changed — which is exactly what happens when the SDK does
565
+ * nothing at all, so it passed against the code it was written to
566
+ * catch. `onNewToken` used to write the value into a field and
567
+ * send it to nobody; a test that cannot tell that apart from
568
+ * working is not a test.
569
+ */
570
+ @Test
571
+ @Config(sdk = [33])
572
+ fun aRotatedTokenIsReportedToTheServer() {
573
+ Recorder().use { rec ->
574
+ val ctx = ApplicationProvider.getApplicationContext<android.content.Context>()
575
+ configureAt(rec.port)
576
+
577
+ // A device that has registered, which is the only kind a
578
+ // rotation should act on.
579
+ val first = SentoriPush.registerNativeTokenForTests(ctx, "token-before")
580
+ assertNotNull("the first registration did not return an spToken", first)
581
+
582
+ // The vendor rotates. This is what `onNewToken` calls.
583
+ SentoriPush.handleRotatedToken(ctx, "token-after")
584
+
585
+ val deadline = System.currentTimeMillis() + 15_000
586
+ while (rec.bodies.size < 2 && System.currentTimeMillis() < deadline) Thread.sleep(50)
587
+
588
+ assertEquals(
589
+ "the rotation reached nobody — the server keeps the dead token until " +
590
+ "the host next calls register, and the device receives nothing",
591
+ 2,
592
+ rec.bodies.size,
593
+ )
594
+ assertTrue(
595
+ "the rotation did not carry the new token: ${rec.bodies[1]}",
596
+ rec.bodies[1].contains("token-after"),
597
+ )
598
+ // Same installation both times, which is what keeps the
599
+ // address still rather than writing a second row.
600
+ val install = Regex("\"installId\":\"([^\"]+)\"")
601
+ val a = install.find(rec.bodies[0])?.groupValues?.get(1)
602
+ val b = install.find(rec.bodies[1])?.groupValues?.get(1)
603
+ assertNotNull("the registration carried no installId: ${rec.bodies[0]}", a)
604
+ assertEquals("the rotation reported a different installation", a, b)
605
+ }
606
+ }
607
+
608
+ /** A device the host never registered is not registered behind its back. */
609
+ @Test
610
+ @Config(sdk = [33])
611
+ fun aRotationForAnUnregisteredDeviceIsIgnored() {
612
+ Recorder().use { rec ->
613
+ val ctx = ApplicationProvider.getApplicationContext<android.content.Context>()
614
+ ctx.getSharedPreferences("com.sentori.push", android.content.Context.MODE_PRIVATE)
615
+ .edit().clear().apply()
616
+ SentoriPush.resetForTests()
617
+ configureAt(rec.port)
618
+
619
+ SentoriPush.handleRotatedToken(ctx, "token-nobody-asked-for")
620
+ Thread.sleep(1_000)
621
+
622
+ assertTrue(
623
+ "a device that never registered was registered by a vendor callback",
624
+ rec.bodies.isEmpty(),
625
+ )
626
+ }
627
+ }
628
+
629
+
630
+ /**
631
+ * A device registers at launch; the person signs in ten seconds
632
+ * later. Nothing updated the row, so it carried no user for the
633
+ * life of the install — and a send aimed at that person reached
634
+ * nobody and reported success.
635
+ */
636
+ @Test
637
+ fun signingInAfterRegisteringUpdatesTheDevice() {
638
+ Recorder().use { rec ->
639
+ val ctx = ApplicationProvider.getApplicationContext<android.content.Context>()
640
+ configureAt(rec.port)
641
+
642
+ assertNotNull(SentoriPush.registerNativeTokenForTests(ctx, "token-1"))
643
+ assertEquals(1, rec.bodies.size)
644
+ assertTrue(
645
+ "the first registration already carried a user: ${rec.bodies[0]}",
646
+ !rec.bodies[0].contains("userKey"),
647
+ )
648
+
649
+ Sentori.user("usr_123", null, mapOf("plan" to "pro"))
650
+
651
+ val deadline = System.currentTimeMillis() + 15_000
652
+ while (rec.bodies.size < 2 && System.currentTimeMillis() < deadline) Thread.sleep(50)
653
+
654
+ // Both halves matter: a count alone passes when the update
655
+ // carries nothing, and a key alone passes when no update
656
+ // happened and this is still the first request.
657
+ assertEquals(
658
+ "signing in reached nobody — the device row keeps no user, so a send " +
659
+ "aimed at that person matches no device and reports success",
660
+ 2,
661
+ rec.bodies.size,
662
+ )
663
+ assertTrue(
664
+ "the update carried no identity: ${rec.bodies[1]}",
665
+ rec.bodies[1].contains("\"userKey\""),
666
+ )
667
+ assertTrue(
668
+ "the update carried no traits: ${rec.bodies[1]}",
669
+ rec.bodies[1].contains("\"plan\""),
670
+ )
671
+ }
672
+ }
673
+
674
+ /**
675
+ * `Sentori.user` is a verb an app may call on every screen. One
676
+ * request per call is not free to a host, and the iron rule is
677
+ * that this SDK is.
678
+ */
679
+ @Test
680
+ fun settingTheSamePersonAgainSendsNothing() {
681
+ Recorder().use { rec ->
682
+ val ctx = ApplicationProvider.getApplicationContext<android.content.Context>()
683
+ configureAt(rec.port)
684
+ assertNotNull(SentoriPush.registerNativeTokenForTests(ctx, "token-1"))
685
+
686
+ Sentori.user("usr_123", null, mapOf("plan" to "pro"))
687
+ val deadline = System.currentTimeMillis() + 15_000
688
+ while (rec.bodies.size < 2 && System.currentTimeMillis() < deadline) Thread.sleep(50)
689
+ val after = rec.bodies.size
690
+
691
+ Sentori.user("usr_123", null, mapOf("plan" to "pro"))
692
+ Thread.sleep(500)
693
+ assertEquals("the same person was sent twice", after, rec.bodies.size)
694
+ }
695
+ }
696
+
697
+ /** A device that never registered is not registered by a sign-in. */
698
+ @Test
699
+ fun signingInOnADeviceThatNeverRegisteredSendsNothing() {
700
+ Recorder().use { rec ->
701
+ configureAt(rec.port)
702
+ Sentori.user("usr_123", null, mapOf("plan" to "pro"))
703
+ Thread.sleep(500)
704
+ assertEquals(0, rec.bodies.size)
705
+ }
706
+ }
707
+ }