Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.onesignal.core.internal.device.impl.FidEnvService
import com.onesignal.core.internal.device.impl.InstallIdService
import com.onesignal.core.internal.features.FeatureManager
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.gesture.DeviceGestureDetector
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.core.internal.http.impl.HttpClient
import com.onesignal.core.internal.http.impl.HttpConnectionFactory
Expand Down Expand Up @@ -104,6 +105,9 @@ internal class CoreModule : IModule {
.provides<IBackgroundManager>()
.provides<IStartableService>()

// Device gesture
builder.register<DeviceGestureDetector>().provides<IStartableService>()

// Purchase Tracking
builder.register<TrackGooglePurchase>().provides<IStartableService>()

Expand All @@ -119,8 +123,12 @@ internal class CoreModule : IModule {
)
}.provides<IObservabilityEventRecorder>()

// Register dummy services in the event they are not configured. These dummy services
// will throw an error message if the associated functionality is attempted to be used.
registerMisconfiguredFallbacks(builder)
}

// Register dummy services in the event they are not configured. These dummy services
// will throw an error message if the associated functionality is attempted to be used.
private fun registerMisconfiguredFallbacks(builder: ServiceBuilder) {
builder.register<MisconfiguredNotificationsManager>().provides<INotificationsManager>()
builder.register<MisconfiguredIAMManager>().provides<IInAppMessagesManager>()
builder.register<MisconfiguredLocationManager>().provides<ILocationManager>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package com.onesignal.core.internal.gesture

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.SystemClock
import com.onesignal.common.IDManager
import com.onesignal.core.internal.application.IApplicationLifecycleHandler
import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.features.FeatureFlag
import com.onesignal.logger.IObservabilityEventRecorder
import com.onesignal.logger.ObservabilityEvent

/**
* Detects the test-device gesture: [REQUIRED_CYCLES] background/foreground cycles within
* [WINDOW_MS], then copies the push subscription ID to the clipboard, prefixed `os:` (see
* [clipText]), so the person can paste it into the dashboard. Without a subscription it copies
* [NO_SUBSCRIPTION_CLIP_TEXT] instead, so someone following the docs can tell the gesture worked.
*
* A cycle is an unfocus/focus pair whose background phase lasts at least
* [MIN_BACKGROUND_DWELL_MS]; the floor filters the synthetic pair
* [com.onesignal.core.internal.application.impl.ApplicationService.onOrientationChanged]
* fires when an activity declaring orientation in `configChanges` rotates. The window is the
* only rate rule; six cycles fit inside it at round trips of five seconds or faster.
*
* [FeatureFlag.SDK_DEVICE_GESTURE_DISABLED] turns the gesture off. Absent means enabled, so a
* device that has never fetched flags still has it.
*
* Every recognised gesture also records [ObservabilityEvent.DEVICE_GESTURE], with its outcome
* and the copied ID, so the gesture's usage can be measured.
*/
internal class DeviceGestureDetector(
private val applicationService: IApplicationService,
private val configModelStore: ConfigModelStore,
private val featureManager: IFeatureManager,
private val eventRecorder: IObservabilityEventRecorder,
) : IStartableService,
IApplicationLifecycleHandler {
/**
* Monotonic and keeps counting through deep sleep, so neither a wall-clock jump nor a doze
* can stretch or shrink the window; an awake-only clock would stop at each lock and stitch
* visits hours apart into one window. Test-only override; kept out of the constructor so the
* IoC's reflection-based resolver still picks the only constructor (see the class KDoc on
* [com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService]).
*/
internal var monotonicMillis: () -> Long = { SystemClock.elapsedRealtime() }

private var lastUnfocusedAt: Long? = null
private val cycleTimestamps = mutableListOf<Long>()

override fun start() {
applicationService.addApplicationLifecycleHandler(this)
}

override fun onFocus(firedOnSubscribe: Boolean) {
// The subscribe-time replay is not a background-to-foreground transition, and it can
// arrive on a non-main thread during startup.
if (firedOnSubscribe) {
return
}
val now = monotonicMillis()
// Logged after the lock: Logging calls app listeners synchronously.
var progress: String? = null
val completedGesture =
synchronized(this) {
val backgroundedAt = lastUnfocusedAt
lastUnfocusedAt = null
when {
// Cold start or first focus after start(); nothing to pair with.
backgroundedAt == null -> false
// Faster than any human app switch; rotation produces synthetic pairs like this.
now - backgroundedAt < MIN_BACKGROUND_DWELL_MS -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider (Grok): This dwell is wall-clock between onUnfocused and onFocus, not “time the app was backgrounded.” onOrientationChanged fires those back-to-back but with other sync handlers in between (WebViewManager.onActivityStoppedremoveAllViews()). If that work exceeds 250ms, a configChanges rotation counts as a cycle. Tests only inject a 1ms blip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is. The floor is a time filter by design, and there is no measurement of that in-between work exceeding 250 ms; if a rotation ever counts as a cycle it shows up as accidental copies in the event data, and the floor can be raised then.

progress = "ignored a ${now - backgroundedAt}ms background blip (rotation filter)"
false
}
else -> {
cycleTimestamps.add(now)
cycleTimestamps.removeAll { now - it > WINDOW_MS }
progress =
"cycle ${cycleTimestamps.size}/$REQUIRED_CYCLES within the window " +
"(background ${now - backgroundedAt}ms)"
if (cycleTimestamps.size >= REQUIRED_CYCLES) {
cycleTimestamps.clear()
true
} else {
false
}
}
}
}
progress?.let { Logging.verbose("DeviceGestureDetector: $it") }
if (completedGesture) {
copySubscriptionIdToClipboard()
}
}

override fun onUnfocused() {
val now = monotonicMillis()
synchronized(this) {
lastUnfocusedAt = now
}
}

private fun copySubscriptionIdToClipboard() {
val config = configModelStore.model
val subscriptionId = config.pushSubscriptionId
when {
// Not recorded either: nothing about the device may ship before consent.
config.consentRequired == true && config.consentGiven != true ->
Logging.debug("DeviceGestureDetector: gesture detected but privacy consent is not granted")
featureManager.isEnabled(FeatureFlag.SDK_DEVICE_GESTURE_DISABLED) -> {
Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely")
recordGesture(GestureResult.DISABLED)
}
subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) ->
writeToClipboard(NO_SUBSCRIPTION_CLIP_TEXT, GestureResult.NO_ID)
else -> writeToClipboard(clipText(subscriptionId), GestureResult.COPIED, copiedId = subscriptionId)
}
}

/** Writes on the focus callback itself: the clipboard needs no particular thread, and a hop would only add a gap. */
@Suppress("TooGenericExceptionCaught")
private fun writeToClipboard(
text: String,
result: GestureResult,
copiedId: String? = null,
) {
val context = applicationService.appContext
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard == null) {
Logging.warn("DeviceGestureDetector: clipboard service unavailable, nothing copied")
return
}
val written =
try {
// No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation.
clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, text))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider (Opus+Grok; Sol on the hop): setPrimaryClip is void and can be a silent no-op on API 29+ if the app has lost focus. Recording copied/no_id here overstates the KDoc guarantee that a result never claims a clipboard change that did not happen.

suspendifyOnMain goes IO→Main, so the 6th focus can unfocus again before this runs. Read primaryClip back before recording, or dispatch while still on the focus path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IO to Main hop is gone in 03042cd: the write now runs on the focus callback itself, so it happens while the app still has focus. We deliberately never read the clipboard back, since reads are what Android restricts and shows to the user; the write is a synchronous, guarded call, and nothing is recorded if it throws.

true
} catch (e: Exception) {
// A lifecycle callback must survive a misbehaving clipboard service.
Logging.warn("DeviceGestureDetector: clipboard write failed, nothing copied", e)
false
}
if (written) {
Logging.info("DeviceGestureDetector: clipboard set, gesture result ${result.wire}")
recordGesture(result, copiedId)
}
}

/**
* `copied` and `no_id` are recorded after the clip is set, so neither claims a change that
* did not happen. `disabled` is recorded at the decision, since nothing is written.
*/
private fun recordGesture(
result: GestureResult,
copiedId: String? = null,
) {
val attributes = mutableMapOf(ATTRIBUTE_RESULT to result.wire)
if (copiedId != null) {
attributes[ATTRIBUTE_PUSH_SUBSCRIPTION_ID] = copiedId
}
eventRecorder.record(ObservabilityEvent.DEVICE_GESTURE, attributes)
}

/** Wire values of `gesture.result`, which backend queries match on. */
private enum class GestureResult(val wire: String) {
COPIED("copied"),
NO_ID("no_id"),
DISABLED("disabled"),
}

companion object {
internal const val REQUIRED_CYCLES = 6
internal const val WINDOW_MS = 30_000L

/** Shortest background phase a human can produce; anything faster is synthetic. */
internal const val MIN_BACKGROUND_DWELL_MS = 250L

private const val CLIP_LABEL = "OneSignal subscription ID"
private const val CLIP_PREFIX = "os: "
internal const val NO_SUBSCRIPTION_CLIP_TEXT = CLIP_PREFIX + "no subscription ID yet"
private const val ATTRIBUTE_RESULT = "gesture.result"
private const val ATTRIBUTE_PUSH_SUBSCRIPTION_ID = "gesture.push_subscription_id"

/**
* The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and
* for anyone who copied it by accident.
*/
internal fun clipText(subscriptionId: String): String = CLIP_PREFIX + subscriptionId
}
}
Loading