Skip to content
Draft
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 @@ -22,6 +22,7 @@ import com.onesignal.core.internal.device.impl.DeviceService
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 @@ -98,14 +99,21 @@ internal class CoreModule : IModule {
.provides<IBackgroundManager>()
.provides<IStartableService>()

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

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

// Crash Uploader (crash handler is initialized directly in OneSignalImp for early initialization)
builder.register<OneSignalCrashUploaderWrapper>().provides<IStartableService>()

// 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,150 @@
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.common.threading.suspendifyOnMain
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.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging

/**
* Detects the test-device gesture: [REQUIRED_CYCLES] background/foreground cycles within
* [WINDOW_MS], then copies the push subscription ID to the clipboard so the person can paste
* it into the dashboard. The clip is self-describing (see [clipText]) because it lands on the
* clipboard of anyone who happens to perform the gesture, and a bare UUID there is a mystery.
*
* 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 inside it takes sustained five-second round trips.
*
* Adding [KILL_SWITCH_KEY] to the app's enabled feature keys disables the gesture. Absent
* means enabled, so a device that has never fetched flags still has it. Reads the raw
* [com.onesignal.core.internal.config.ConfigModel.sdkRemoteFeatureFlags] list because
* [com.onesignal.core.internal.features.IFeatureManager] only resolves keys the KMP catalog
* registers.
*/
internal class DeviceGestureDetector(
private val applicationService: IApplicationService,
private val configModelStore: ConfigModelStore,
) : IStartableService,
IApplicationLifecycleHandler {
/**
* Monotonic clock, so wall-clock jumps from NTP or manual time changes cannot stretch or
* shrink the 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.uptimeMillis() }

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()
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 -> {
Logging.verbose(
"DeviceGestureDetector: ignored a ${now - backgroundedAt}ms background blip (rotation filter)",
)
false
}
else -> {
cycleTimestamps.add(now)
cycleTimestamps.removeAll { now - it > WINDOW_MS }
Logging.verbose(
"DeviceGestureDetector: cycle ${cycleTimestamps.size}/$REQUIRED_CYCLES within the window " +
"(background ${now - backgroundedAt}ms)",
)
if (cycleTimestamps.size >= REQUIRED_CYCLES) {
cycleTimestamps.clear()
true
} else {
false
}
}
}
}
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 {
config.consentRequired == true && config.consentGiven != true ->
Logging.debug("DeviceGestureDetector: gesture detected but privacy consent is not granted")
config.sdkRemoteFeatureFlags.any { it.equals(KILL_SWITCH_KEY, ignoreCase = true) } ->
Logging.debug("DeviceGestureDetector: gesture detected but disabled remotely")
subscriptionId.isNullOrEmpty() || IDManager.isLocalId(subscriptionId) ->
Logging.info("DeviceGestureDetector: gesture detected before the push subscription exists, nothing copied")
else -> writeToClipboard(subscriptionId)
}
}

private fun writeToClipboard(subscriptionId: String) {
suspendifyOnMain {
val context = applicationService.appContext
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard == null) {
Logging.warn("DeviceGestureDetector: clipboard service unavailable, nothing copied")
} else {
// No EXTRA_IS_SENSITIVE: the Android 13+ copy preview is the person's confirmation.
clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, clipText(context, subscriptionId)))
Logging.info("DeviceGestureDetector: push subscription ID copied to clipboard")
}
}
}

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

internal const val KILL_SWITCH_KEY = "sdk_device_gesture_disabled"
private const val CLIP_LABEL = "OneSignal subscription ID"

/** Names what the ID is and which app copied it, so an accidental copy explains itself. */
internal fun clipText(
context: Context,
subscriptionId: String,
): String {
val appName =
context.applicationInfo.loadLabel(context.packageManager).toString().ifBlank { context.packageName }
return "OneSignal subscription ID for $appName: $subscriptionId"
}
}
}
Loading
Loading