@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.
- package/android/build.gradle +6 -0
- package/android/src/main/java-core/com/sentori/Sentori.kt +33 -4
- package/android/src/main/java-core/com/sentori/SentoriAttachment.kt +153 -0
- package/android/src/main/java-core/com/sentori/SentoriConfig.kt +1 -1
- package/android/src/main/java-core/com/sentori/SentoriCrashHandler.kt +19 -0
- package/android/src/main/java-core/com/sentori/SentoriFirebaseMessagingService.kt +15 -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 +242 -13
- package/android/src/main/java-core/com/sentori/SentoriPushNotifications.kt +168 -17
- package/android/src/main/java-core/com/sentori/SentoriScope.kt +49 -2
- package/android/src/main/java-core/com/sentori/SentoriTransport.kt +95 -2
- package/android/src/test/java-core/com/sentori/SentoriAttachmentTest.kt +413 -0
- package/android/src/test/java-core/com/sentori/SentoriLiveServerTest.kt +43 -0
- package/android/src/test/java-core/com/sentori/SentoriPendingCrashTest.kt +194 -0
- package/android/src/test/java-core/com/sentori/SentoriPushTest.kt +556 -0
- package/ios/core/SentoriAttachment.swift +129 -0
- package/ios/core/SentoriCrashHandler.swift +15 -0
- package/ios/core/SentoriPushNotifications.swift +8 -0
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +155 -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 +203 -1
- package/src/push.ts +147 -1
- package/src/scope.ts +50 -1
- package/src/transport.ts +1 -1
- package/ios/core/SentoriPush.swift +0 -340
|
@@ -14,7 +14,10 @@ import org.junit.Assert.assertTrue
|
|
|
14
14
|
import org.junit.Before
|
|
15
15
|
import org.junit.Test
|
|
16
16
|
import org.junit.runner.RunWith
|
|
17
|
+
import org.robolectric.Robolectric
|
|
17
18
|
import org.robolectric.RobolectricTestRunner
|
|
19
|
+
import org.robolectric.Shadows.shadowOf
|
|
20
|
+
import org.robolectric.annotation.Config
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Push registration, as far as Robolectric can go.
|
|
@@ -109,6 +112,100 @@ class SentoriPushTest {
|
|
|
109
112
|
assertNull("a failure must cache nothing", SentoriPush.cachedDeviceHandle(context))
|
|
110
113
|
}
|
|
111
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
|
+
|
|
112
209
|
@Test
|
|
113
210
|
fun unregisterWithNothingRegisteredIsANoOp() {
|
|
114
211
|
val latch = CountDownLatch(1)
|
|
@@ -148,4 +245,463 @@ class SentoriPushTest {
|
|
|
148
245
|
)
|
|
149
246
|
assertNotNull(json)
|
|
150
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
|
+
}
|
|
151
707
|
}
|