Skip to content

Commit 73643d9

Browse files
committed
feat: [SDK-5088] record the device gesture as an observability event
DeviceGestureDetector now takes the IObservabilityEventRecorder from the container and records ObservabilityEvent.DEVICE_GESTURE each time the gesture is recognised. gesture.result is copied, no_id or disabled, and a copied event also carries gesture.push_subscription_id, the value that went on the clipboard. The copied result is recorded from the main-thread block after the clip is set, so it never overstates. A gesture under withheld privacy consent records nothing, because the event ships to the backend. Tests drive the detector with a recorder spy and pin the attribute names and values, since the log backend is queried by them.
1 parent 05e8e6b commit 73643d9

2 files changed

Lines changed: 137 additions & 3 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/gesture/DeviceGestureDetector.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import com.onesignal.core.internal.application.IApplicationService
1111
import com.onesignal.core.internal.config.ConfigModelStore
1212
import com.onesignal.core.internal.startup.IStartableService
1313
import com.onesignal.debug.internal.logging.Logging
14+
import com.onesignal.logger.IObservabilityEventRecorder
15+
import com.onesignal.logger.ObservabilityEvent
1416

1517
/**
1618
* Detects the test-device gesture: [REQUIRED_CYCLES] background/foreground cycles within
@@ -28,10 +30,14 @@ import com.onesignal.debug.internal.logging.Logging
2830
* [com.onesignal.core.internal.config.ConfigModel.sdkRemoteFeatureFlags] list because
2931
* [com.onesignal.core.internal.features.IFeatureManager] only resolves keys the KMP catalog
3032
* registers.
33+
*
34+
* Every recognised gesture also records [ObservabilityEvent.DEVICE_GESTURE], with its outcome
35+
* and the copied ID, so the gesture's usage can be measured.
3136
*/
3237
internal class DeviceGestureDetector(
3338
private val applicationService: IApplicationService,
3439
private val configModelStore: ConfigModelStore,
40+
private val eventRecorder: IObservabilityEventRecorder,
3541
) : IStartableService,
3642
IApplicationLifecycleHandler {
3743
/**
@@ -102,12 +108,17 @@ internal class DeviceGestureDetector(
102108
val config = configModelStore.model
103109
val subscriptionId = config.pushSubscriptionId
104110
when {
111+
// Not recorded either: nothing about the device may ship before consent.
105112
config.consentRequired == true && config.consentGiven != true ->
106113
Logging.debug("DeviceGestureDetector: gesture detected but privacy consent is not granted")
107-
config.sdkRemoteFeatureFlags.any { it.equals(KILL_SWITCH_KEY, ignoreCase = true) } ->
114+
config.sdkRemoteFeatureFlags.any { it.equals(KILL_SWITCH_KEY, ignoreCase = true) } -> {
108115
Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely")
109-
subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) ->
116+
recordGesture(GestureResult.DISABLED)
117+
}
118+
subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) -> {
110119
Logging.info("DeviceGestureDetector: gesture detected before the push subscription exists, nothing copied")
120+
recordGesture(GestureResult.NO_ID)
121+
}
111122
else -> writeToClipboard(subscriptionId)
112123
}
113124
}
@@ -122,10 +133,30 @@ internal class DeviceGestureDetector(
122133
// No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation.
123134
clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, clipText(subscriptionId)))
124135
Logging.info("DeviceGestureDetector: push subscription ID copied to clipboard")
136+
recordGesture(GestureResult.COPIED, copiedId = subscriptionId)
125137
}
126138
}
127139
}
128140

141+
/** Recorded once the outcome is known, so `copied` means the clip was actually set. */
142+
private fun recordGesture(
143+
result: GestureResult,
144+
copiedId: String? = null,
145+
) {
146+
val attributes = mutableMapOf(ATTRIBUTE_RESULT to result.wire)
147+
if (copiedId != null) {
148+
attributes[ATTRIBUTE_PUSH_SUBSCRIPTION_ID] = copiedId
149+
}
150+
eventRecorder.record(ObservabilityEvent.DEVICE_GESTURE, attributes)
151+
}
152+
153+
/** Wire values of `gesture.result`, which backend queries match on. */
154+
private enum class GestureResult(val wire: String) {
155+
COPIED("copied"),
156+
NO_ID("no_id"),
157+
DISABLED("disabled"),
158+
}
159+
129160
companion object {
130161
internal const val REQUIRED_CYCLES = 6
131162
internal const val WINDOW_MS = 30_000L
@@ -135,6 +166,8 @@ internal class DeviceGestureDetector(
135166

136167
internal const val KILL_SWITCH_KEY = "sdk_device_gesture_disabled"
137168
private const val CLIP_LABEL = "OneSignal subscription ID"
169+
private const val ATTRIBUTE_RESULT = "gesture.result"
170+
private const val ATTRIBUTE_PUSH_SUBSCRIPTION_ID = "gesture.push_subscription_id"
138171

139172
/**
140173
* The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/gesture/DeviceGestureDetectorTests.kt

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import androidx.test.core.app.ApplicationProvider
88
import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
99
import com.onesignal.core.internal.application.IApplicationLifecycleHandler
1010
import com.onesignal.core.internal.application.IApplicationService
11+
import com.onesignal.logger.ILogTelemetry
12+
import com.onesignal.logger.IObservabilityEventRecorder
13+
import com.onesignal.logger.ObservabilityEvent
1114
import com.onesignal.mocks.IOMockHelper
1215
import com.onesignal.mocks.IOMockHelper.awaitIO
1316
import com.onesignal.mocks.MockHelper
1417
import io.kotest.core.spec.style.FunSpec
18+
import io.kotest.matchers.collections.shouldBeEmpty
1519
import io.kotest.matchers.shouldBe
1620
import io.mockk.every
1721
import io.mockk.mockk
@@ -20,6 +24,32 @@ import org.robolectric.annotation.Config
2024

2125
private 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

Comments
 (0)