Skip to content
Closed
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
35 changes: 23 additions & 12 deletions android/src/main/java/com/amplitude/android/Amplitude.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.amplitude.android.migration.MigrationManager
import com.amplitude.android.plugins.AnalyticsConnectorIdentityPlugin
import com.amplitude.android.plugins.AnalyticsConnectorPlugin
import com.amplitude.android.plugins.AndroidContextPlugin
import com.amplitude.android.plugins.AndroidContextPlugin.Companion.validDeviceId
import com.amplitude.android.plugins.AndroidLifecyclePlugin
import com.amplitude.android.plugins.AndroidNetworkConnectivityCheckerPlugin
import com.amplitude.android.storage.AndroidStorageContextV3
Expand Down Expand Up @@ -97,14 +98,15 @@ open class Amplitude internal constructor(
if (this.configuration.offline != AndroidNetworkConnectivityCheckerPlugin.Disabled) {
add(AndroidNetworkConnectivityCheckerPlugin())
}
androidContextPlugin =
object : AndroidContextPlugin() {
override fun setDeviceId(deviceId: String) {
// call internal method to set deviceId immediately i.e. dont' wait for build() to complete
this@Amplitude.setDeviceIdInternal(deviceId)
}
}
androidContextPlugin = AndroidContextPlugin()
add(androidContextPlugin)
val deviceId =
configuration.deviceId
?: store.deviceId?.takeIf { validDeviceId(it, allowAppSetId = false) }
?: androidContextPlugin.createDeviceId()
// Persist device ID synchronously during build (not via coroutine dispatch)
store.deviceId = deviceId
idContainer.identityManager.editIdentity().setDeviceId(deviceId).commit()
add(GetAmpliExtrasPlugin())
add(AndroidLifecyclePlugin(activityLifecycleCallbacks))
add(AnalyticsConnectorIdentityPlugin())
Expand All @@ -126,20 +128,29 @@ open class Amplitude internal constructor(

/**
* Reset identity:
* - reset userId to "null"
* - reset deviceId via AndroidContextPlugin
* - reset userId to null
* - generate and set a new deviceId
* @return the Amplitude instance
*/
override fun reset(): Amplitude {
this.setUserId(null)
amplitudeScope.launch(amplitudeDispatcher) {
isBuilt.await()
idContainer.identityManager.editIdentity().setDeviceId(null).commit()
androidContextPlugin.initializeDeviceId(configuration as Configuration)
setUserId(null)
setDeviceId(androidContextPlugin.createDeviceId())
}
return this
}

override fun setUserId(userId: String?): Amplitude {
super.setUserId(userId)
return this
}

override fun setDeviceId(deviceId: String): Amplitude {
super.setDeviceId(deviceId)
Copy link

Choose a reason for hiding this comment

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

Deferred identity callbacks can overwrite newer immediate state

Medium Severity

The dual-write architecture introduces a race: the Android setDeviceId/setUserId overrides write to store immediately, then super launches a deferred coroutine that commits to the identity manager, whose AnalyticsIdentityListener callback writes to store again. When two calls happen in succession with different values, the deferred callback from the first call can temporarily revert the store to the stale value, causing events enriched during that window (on storageIODispatcher) to pick up the wrong identity — the exact problem this PR aims to fix.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a pre-existing race in core's coroutine dispatch + listener pattern, not introduced by this PR. Noted, in case it becomes an issue later.

Copy link

Choose a reason for hiding this comment

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

I think this is valid - state would be temporarily overwritten by AnalyticsIdentityListener, leading to the same mismatch between what was set and what we add to events. Does AnalyticsIdentityListener still have to update the state on initialization if we're already updating state from the user/device id setters?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It looks to me that the onUserIdChange/onDeviceIdChange is the one flagged here.
So I made those noop, since we're updating state ourselves anyway.

Copy link

Choose a reason for hiding this comment

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

Can experiment edit the identity too? The callbacks on AnalyticsIdentityListener still may be used even if they aren't used here.

I do see a race condition here specifically in the IdentityUpdateType.Initialized path in AnalyticsIdentityListener -

init(deviceId: A) // sets state deviceId: A, async kicks off AnalyticsIdentityListener.init
setDeviceId: B // sets state deviceId: B
init async callback // sets state deviceId: A again

return this
}

@GuardedAmplitudeFeature
fun onEnterForeground(timestamp: Long) {
(timeline as Timeline).onEnterForeground(timestamp)
Expand Down
25 changes: 16 additions & 9 deletions android/src/main/java/com/amplitude/android/Timeline.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.amplitude.android

import com.amplitude.android.Amplitude.Companion.END_SESSION_EVENT
import com.amplitude.android.Amplitude.Companion.START_SESSION_EVENT
import com.amplitude.android.EventQueueMessage.EnterForeground
import com.amplitude.android.EventQueueMessage.Event
import com.amplitude.android.EventQueueMessage.ExitForeground
import com.amplitude.core.Storage
import com.amplitude.core.Storage.Constants
import com.amplitude.core.Storage.Constants.LAST_EVENT_ID
Expand All @@ -10,6 +13,7 @@ import com.amplitude.core.Storage.Constants.PREVIOUS_SESSION_ID
import com.amplitude.core.events.BaseEvent
import com.amplitude.core.platform.Timeline
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
Expand Down Expand Up @@ -65,34 +69,31 @@ class Timeline(
incomingEvent.timestamp = System.currentTimeMillis()
}

val result = eventMessageChannel.trySend(EventQueueMessage.Event(incomingEvent))
if (result.isFailure) {
amplitude.logger.error("Failed to enqueue event: ${incomingEvent.eventType}. Channel is closed or full.")
}
eventMessageChannel.trySendAndLog(Event(incomingEvent))
}

internal fun onEnterForeground(timestamp: Long) {
eventMessageChannel.trySend(EventQueueMessage.EnterForeground(timestamp))
eventMessageChannel.trySendAndLog(EnterForeground(timestamp))
}

internal fun onExitForeground(timestamp: Long) {
eventMessageChannel.trySend(EventQueueMessage.ExitForeground(timestamp))
eventMessageChannel.trySendAndLog(ExitForeground(timestamp))
}

/**
* Process an event message from the event queue.
*/
private suspend fun processEventMessage(message: EventQueueMessage) {
when (message) {
is EventQueueMessage.EnterForeground -> {
is EnterForeground -> {
val stopAndStartSessionEvents = startNewSessionIfNeeded(message.timestamp)
foreground.set(true)
processAndPersistEvents(stopAndStartSessionEvents)
}
is EventQueueMessage.Event -> {
is Event -> {
processEvent(message.event)
}
is EventQueueMessage.ExitForeground -> {
is ExitForeground -> {
foreground.set(false)
refreshSessionTime(message.timestamp)
}
Expand Down Expand Up @@ -202,6 +203,12 @@ class Timeline(
return sessionId > DEFAULT_SESSION_ID
}

private fun Channel<EventQueueMessage>.trySendAndLog(event: EventQueueMessage) =
trySend(event)
.onFailure {
amplitude.logger.error("Failed to enqueue event $event. Channel is closed or full.")
}

private fun Storage.readLong(
key: Constants,
default: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ import com.amplitude.common.android.AndroidContextProvider
import com.amplitude.core.Amplitude
import com.amplitude.core.RestrictedAmplitudeFeature
import com.amplitude.core.events.BaseEvent
import com.amplitude.core.platform.Plugin
import com.amplitude.core.platform.plugins.ContextPlugin
import java.util.UUID

@OptIn(RestrictedAmplitudeFeature::class)
open class AndroidContextPlugin : Plugin {
override val type: Plugin.Type = Plugin.Type.Before
override lateinit var amplitude: Amplitude
open class AndroidContextPlugin : ContextPlugin() {
private lateinit var contextProvider: AndroidContextProvider

override fun setup(amplitude: Amplitude) {
Expand All @@ -26,8 +24,6 @@ open class AndroidContextPlugin : Plugin {
configuration.trackingOptions.shouldTrackAdid(),
configuration.trackingOptions.shouldTrackAppSetId(),
)
initializeDeviceId(configuration)

amplitude.diagnosticsClient.setTag(
name = "sdk.${SDK_LIBRARY}.version",
value = SDK_VERSION,
Expand All @@ -39,45 +35,56 @@ open class AndroidContextPlugin : Plugin {
return event
}

fun initializeDeviceId(configuration: Configuration) {
// Check configuration
var deviceId = configuration.deviceId
if (deviceId != null) {
setDeviceId(deviceId)
return
}

// Check store
deviceId = amplitude.store.deviceId
if (deviceId != null && validDeviceId(deviceId) && !deviceId.endsWith("S")) {
return
}
/**
* Create a device ID from available sources.
* Priority: advertising ID -> app set ID -> random UUID
*/
internal fun createDeviceId(): String {
val configuration = amplitude.configuration as Configuration

// Check new device id per install
// Check advertising ID (if enabled and not per-install)
if (!configuration.newDeviceIdPerInstall &&
configuration.useAdvertisingIdForDeviceId &&
!contextProvider.isLimitAdTrackingEnabled()
) {
val advertisingId = contextProvider.advertisingId
if (advertisingId != null && validDeviceId(advertisingId)) {
setDeviceId(advertisingId)
return
contextProvider.advertisingId?.let { advertisingId ->
if (validDeviceId(advertisingId)) {
return advertisingId
}
}
}

// Check app set id
// Check app set ID (if enabled)
if (configuration.useAppSetIdForDeviceId) {
val appSetId = contextProvider.appSetId
if (appSetId != null && validDeviceId(appSetId)) {
setDeviceId("${appSetId}S")
return
contextProvider.appSetId?.let { appSetId ->
if (validDeviceId(appSetId)) {
return "$appSetId$DEVICE_ID_SUFFIX_APP_SET_ID"
}
}
}

// Generate random id
val generatedUuid = UUID.randomUUID().toString()
val randomId = generatedUuid + "R"
setDeviceId(randomId)
// Generate random ID
return UUID.randomUUID().toString() + DEVICE_ID_SUFFIX_RANDOM
}

@Deprecated(
message = "Use createDeviceId() instead. Amplitude now handles setting the deviceId.",
replaceWith = ReplaceWith("createDeviceId()"),
)
fun initializeDeviceId(configuration: Configuration) {
val deviceId =
configuration.deviceId
?: amplitude.store.deviceId?.takeIf { validDeviceId(it, allowAppSetId = false) }
?: createDeviceId()
amplitude.setDeviceId(deviceId)
Copy link

Choose a reason for hiding this comment

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

this saved to storage synchronously in the previous version - could you please maintain that behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The initializeDeviceId is no longer called internally, deprecated it just in case some external usage exists for some reason.

Now the actual init path is in buildInternal() which now persists directly

Copy link

Choose a reason for hiding this comment

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

Previously all the calls here went from setDeviceId -> amplitude.setDeviceIdInternal (via the AndroidContextPlugin override) -> idContainer.identityManager.editIdentity().setDeviceId(deviceId).commit(). It seems like this now waits for isBuilt in the core amplitude instance.

}

@Deprecated(
message = "Use Amplitude.setDeviceId() instead. The plugin should not set identity directly.",
replaceWith = ReplaceWith("amplitude.setDeviceId(deviceId)"),
)
protected open fun setDeviceId(deviceId: String) {
amplitude.setDeviceId(deviceId)
}

private fun applyContextData(event: BaseEvent) {
Expand Down Expand Up @@ -171,19 +178,37 @@ open class AndroidContextPlugin : Plugin {
}
}

protected open fun setDeviceId(deviceId: String) {
amplitude.setDeviceId(deviceId)
}

companion object {
const val PLATFORM = "Android"
const val SDK_LIBRARY = "amplitude-analytics-android"
const val SDK_VERSION = BuildConfig.AMPLITUDE_VERSION

/**
* Device ID suffix indicating the ID was derived from App Set ID.
*
* When a stored device ID ends with this suffix, [createDeviceId] will attempt
* to regenerate it to potentially upgrade to a better identifier (e.g., advertising ID
* if the user later grants permission).
*/
private const val DEVICE_ID_SUFFIX_APP_SET_ID = "S"

/**
* Device ID suffix indicating the ID is a randomly generated UUID.
*/
private const val DEVICE_ID_SUFFIX_RANDOM = "R"

private val INVALID_DEVICE_IDS =
setOf("", "9774d56d682e549c", "unknown", "000000000000000", "Android", "DEFACE", "00000000-0000-0000-0000-000000000000")

fun validDeviceId(deviceId: String): Boolean {
return !(deviceId.isEmpty() || INVALID_DEVICE_IDS.contains(deviceId))
fun validDeviceId(
deviceId: String,
allowAppSetId: Boolean = true,
): Boolean {
return when {
deviceId.isEmpty() || INVALID_DEVICE_IDS.contains(deviceId) -> false
!allowAppSetId && deviceId.endsWith(DEVICE_ID_SUFFIX_APP_SET_ID) -> false
else -> true
}
}
}
}
Loading