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,168 @@
package com.onesignal.core.internal.gesture

import android.os.SystemClock
import com.onesignal.common.IDManager
import com.onesignal.common.threading.suspendifyOnIO
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.http.IHttpClient
import com.onesignal.core.internal.http.impl.OptionalHeaders
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.jwt.JwtTokenStore
import org.json.JSONObject
import java.text.DateFormat
import java.util.Date

/**
* Detects the test-user gesture: [REQUIRED_CYCLES] background/foreground cycles within
* [WINDOW_MS], then marks the current user as a test user by sending an Update User PATCH
* with `test_user_name` set to the device-local time. Sent straight through [IHttpClient]
* because the operation repo could replay a stale mark hours later.
*
* 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,
private val identityModelStore: IdentityModelStore,
private val jwtTokenStore: JwtTokenStore,
private val httpClient: IHttpClient,
) : 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() }

/** Wall clock for the human-readable `test_user_name` value. Test-only override. */
internal var wallClockMillis: () -> Long = { System.currentTimeMillis() }

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) {
markUserAsTestUser()
}
}

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

private fun markUserAsTestUser() {
val config = configModelStore.model
val identity = identityModelStore.model
val onesignalId = identity.onesignalId
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")
onesignalId.isEmpty() || IDManager.isLocalId(onesignalId) ->
Logging.info("DeviceGestureDetector: gesture detected before the user exists on the backend, nothing sent")
else -> sendTestUserUpdate(config.appId, onesignalId, identity.externalId)
}
}

private fun sendTestUserUpdate(
appId: String,
onesignalId: String,
externalId: String?,
) {
val testUserName =
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(wallClockMillis()))
// Same JWT lookup the update-user executors use; null skips the Authorization header.
val jwt = externalId?.let { jwtTokenStore.getJwt(it) }
suspendifyOnIO {
val body =
JSONObject().put(
"properties",
JSONObject().put(TEST_USER_NAME_PROPERTY, testUserName),
)
val response =
httpClient.patch(
"apps/$appId/users/by/${IdentityConstants.ONESIGNAL_ID}/$onesignalId",
body,
OptionalHeaders(jwt = jwt),
)
if (response.isSuccess) {
Logging.info("DeviceGestureDetector: marked user as test user \"$testUserName\"")
} else {
Logging.warn("DeviceGestureDetector: test user update failed (status ${response.statusCode})")
}
}
}

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"
internal const val TEST_USER_NAME_PROPERTY = "test_user_name"
}
}
Loading
Loading