@@ -8,10 +8,14 @@ import androidx.test.core.app.ApplicationProvider
88import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
99import com.onesignal.core.internal.application.IApplicationLifecycleHandler
1010import com.onesignal.core.internal.application.IApplicationService
11+ import com.onesignal.logger.ILogTelemetry
12+ import com.onesignal.logger.IObservabilityEventRecorder
13+ import com.onesignal.logger.ObservabilityEvent
1114import com.onesignal.mocks.IOMockHelper
1215import com.onesignal.mocks.IOMockHelper.awaitIO
1316import com.onesignal.mocks.MockHelper
1417import io.kotest.core.spec.style.FunSpec
18+ import io.kotest.matchers.collections.shouldBeEmpty
1519import io.kotest.matchers.shouldBe
1620import io.mockk.every
1721import io.mockk.mockk
@@ -20,6 +24,32 @@ import org.robolectric.annotation.Config
2024
2125private const val SUBSCRIPTION_ID = " aaaabbbb-cccc-dddd-eeee-ffff00001111"
2226
27+ /* *
28+ * Captures what the detector records so tests can assert the event and its attributes. The
29+ * attach/detach/reset side belongs to the logger lifecycle and never reaches the detector.
30+ */
31+ private class RecorderSpy : IObservabilityEventRecorder {
32+ private val stored = mutableListOf<Pair <ObservabilityEvent , Map <String , String >>>()
33+
34+ val recorded: List <Pair <ObservabilityEvent , Map <String , String >>>
35+ get() = synchronized(stored) { stored.toList() }
36+
37+ override fun record (
38+ event : ObservabilityEvent ,
39+ attributes : Map <String , String >,
40+ ) {
41+ synchronized(stored) { stored.add(event to attributes) }
42+ }
43+
44+ override fun record (event : ObservabilityEvent ) = record(event, emptyMap())
45+
46+ override fun attach (telemetry : ILogTelemetry ) = Unit
47+
48+ override fun detach (telemetry : ILogTelemetry ) = Unit
49+
50+ override fun reset () = Unit
51+ }
52+
2353/* *
2454 * Drives the detector through synthetic focus/unfocus sequences with a controlled clock and
2555 * reads back the real (Robolectric) clipboard. Dwells are in milliseconds; the default cycle
@@ -36,6 +66,7 @@ private class Harness(
3666
3767 val context: Context = ApplicationProvider .getApplicationContext()
3868 val clipboard = context.getSystemService(Context .CLIPBOARD_SERVICE ) as ClipboardManager
69+ val recorder = RecorderSpy ()
3970
4071 private val handlerSlot = slot<IApplicationLifecycleHandler >()
4172 val detector: DeviceGestureDetector
@@ -57,7 +88,7 @@ private class Harness(
5788 it.consentRequired = consentRequired
5889 it.consentGiven = consentGiven
5990 }
60- detector = DeviceGestureDetector (applicationService, configModelStore)
91+ detector = DeviceGestureDetector (applicationService, configModelStore, recorder )
6192 detector.monotonicMillis = { nowMs }
6293 detector.start()
6394 }
@@ -226,4 +257,74 @@ class DeviceGestureDetectorTests : FunSpec({
226257 awaitIO()
227258 harness.clipText() shouldBe harness.expectedClip
228259 }
260+
261+ // ===== Observability event =====
262+ // Every recognised gesture records DEVICE_GESTURE with its outcome, whether or not an ID was
263+ // copied, so the backend can answer how often the gesture happens and how often it pays off.
264+
265+ test("a completed gesture records a copied event carrying the subscription ID ") {
266+ val harness = Harness ()
267+
268+ // Progress is silent: the event fires on recognition, not per cycle.
269+ repeat(5) { harness.cycle() }
270+ awaitIO()
271+ harness.recorder.recorded.shouldBeEmpty()
272+
273+ harness.cycle()
274+ awaitIO()
275+
276+ harness.recorder.recorded shouldBe
277+ listOf(
278+ ObservabilityEvent .DEVICE_GESTURE to
279+ mapOf(
280+ "gesture.result" to "copied",
281+ "gesture.push_subscription_id" to SUBSCRIPTION_ID ,
282+ ),
283+ )
284+ }
285+
286+ test("the remote kill switch records a disabled result without an ID ") {
287+ val harness = Harness (remoteFlags = listOf(DeviceGestureDetector .KILL_SWITCH_KEY ))
288+
289+ repeat(6) { harness.cycle() }
290+ awaitIO()
291+
292+ harness.recorder.recorded shouldBe
293+ listOf(ObservabilityEvent .DEVICE_GESTURE to mapOf("gesture.result" to "disabled"))
294+ }
295+
296+ test("a missing or local push subscription records a no_id result") {
297+ // Both shapes mean the same thing to the backend: the gesture ran before the device had
298+ // anything worth pasting.
299+ listOf(null, "local-$SUBSCRIPTION_ID ").forEach { subscriptionId ->
300+ val harness = Harness (subscriptionId = subscriptionId)
301+
302+ repeat(6) { harness.cycle() }
303+ awaitIO()
304+
305+ harness.recorder.recorded shouldBe
306+ listOf(ObservabilityEvent .DEVICE_GESTURE to mapOf("gesture.result" to "no_id"))
307+ }
308+ }
309+
310+ test("withheld privacy consent records nothing") {
311+ // The event would ship to the backend, and nothing may leave the device before consent.
312+ val harness = Harness (consentRequired = true, consentGiven = null)
313+
314+ repeat(6) { harness.cycle() }
315+ awaitIO()
316+
317+ harness.recorder.recorded.shouldBeEmpty()
318+ }
319+
320+ test("each recognition records its own event") {
321+ val harness = Harness ()
322+
323+ repeat(12) { harness.cycle() }
324+ awaitIO()
325+
326+ harness.recorder.recorded.map { it.first } shouldBe
327+ listOf(ObservabilityEvent .DEVICE_GESTURE , ObservabilityEvent .DEVICE_GESTURE )
328+ harness.recorder.recorded.map { it.second[" gesture.result" ] } shouldBe listOf("copied", "copied")
329+ }
229330})
0 commit comments