Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ The analytics client provides the following methods:
- `alias(newUserId: String)`: Connect anonymous users to known user IDs. See [Using the alias() Method](#using-the-alias-method) for details
- `setAdvertisingId(advertisingId: String)`: Set the Google Advertising ID (GAID) for ad tracking. See [Advertising ID](#advertising-id-gaid) section for usage and compliance requirements
- `clearAdvertisingId()`: Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking
- `MetaRouter.Analytics.getAnonymousId(): String` (suspend): Retrieve the current anonymous ID. Suspends until the SDK is initialized and ready, then returns immediately on subsequent calls.
- `getAnonymousId(): String` (suspend): Retrieve the current anonymous ID. Suspends until the SDK is initialized and ready, then returns immediately on subsequent calls.
- `setTracing(enabled: Boolean)`: Enable or disable tracing headers on API requests. When enabled, includes a `Trace: true` header for debugging request flows
- `flush()`: Flush events immediately (suspending)
- `reset()`: Reset analytics state and clear all stored data (suspending). Also available as fire-and-forget via `MetaRouter.Analytics.reset()`
Expand Down Expand Up @@ -404,7 +404,8 @@ The `anonymousId` is a unique identifier automatically generated for each device
**Reading the current value:**

```kotlin
val anonId = MetaRouter.Analytics.getAnonymousId()
// analytics is the AnalyticsInterface returned from initialize() / initializeAndWait()
val anonId = analytics.getAnonymousId()
// Suspends until the SDK is ready, then returns the anonymous ID
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,18 @@ interface AnalyticsInterface {
*/
suspend fun reset()

/**
/**
* Get the current anonymous ID, suspending until the SDK is ready.
*
* If initialization is still in progress, this suspends until the
* client is bound and ready, then returns the anonymous ID.
* If already initialized, returns immediately.
*
* @return The current anonymous ID
*/
suspend fun getAnonymousId(): String

/**
* Enable debug logging for troubleshooting.
*
* When enabled, the SDK will log detailed information about initialization, event
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.metarouter.analytics

import com.metarouter.analytics.utils.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicReference
Expand All @@ -21,6 +22,9 @@ class AnalyticsProxy(
private val pendingCalls = ArrayDeque<PendingCall>()
private val mutex = Mutex()

@Volatile
private var boundSignal = CompletableDeferred<Unit>()

/**
* Bind a real client and replay all pending calls.
* This is idempotent - calling bind() again after binding is a no-op.
Expand All @@ -46,6 +50,7 @@ class AnalyticsProxy(

// Set the real client atomically
realClient.set(client)
boundSignal.complete(Unit)
}
}

Expand Down Expand Up @@ -178,6 +183,16 @@ class AnalyticsProxy(
}
}

override suspend fun getAnonymousId(): String {
val signal = boundSignal
signal.await()
val client = realClient.get()
?: throw IllegalStateException(
"AnalyticsProxy bound signal completed but client is null (likely unbound during getAnonymousId)"
)
return client.getAnonymousId()
}

override fun setTracing(enabled: Boolean) {
val client = realClient.get()
if (client != null) {
Expand Down Expand Up @@ -231,6 +246,7 @@ class AnalyticsProxy(
synchronized(pendingCalls) {
pendingCalls.clear()
}
boundSignal = CompletableDeferred()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.metarouter.analytics
import android.content.Context
import com.metarouter.analytics.lifecycle.AppLifecycleObserver
import com.metarouter.analytics.utils.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -31,10 +30,6 @@ object MetaRouter {
/** Atomic flag to ensure only one initialization attempt proceeds. */
private val initializationStarted = AtomicBoolean(false)

/** Signal that completes when the real client is bound to the proxy. */
@Volatile
private var boundSignal = CompletableDeferred<Unit>()

@Volatile
private var lifecycleObserver: AppLifecycleObserver? = null

Expand Down Expand Up @@ -62,11 +57,6 @@ object MetaRouter {
initializeInternal(context, options)
} catch (e: Exception) {
Logger.error("Background initialization failed: ${e.message}")
// Wake current awaiters with failure, then swap in a fresh
// signal so a retry's later boundSignal.complete(Unit) isn't
// swallowed by an already-completed deferred.
boundSignal.completeExceptionally(e)
boundSignal = CompletableDeferred()
initializationStarted.set(false)
}
}
Expand Down Expand Up @@ -124,7 +114,6 @@ object MetaRouter {
lifecycleObserver?.register()

proxy.bind(client)
boundSignal.complete(Unit)
}
}

Expand Down Expand Up @@ -195,39 +184,6 @@ object MetaRouter {
resetInternal()
}

/**
* Get the current anonymous ID, suspending until the SDK is ready.
*
* If initialization is still in progress, this suspends until the
* client is bound and ready, then returns the anonymous ID.
* If already initialized, returns immediately.
*
* @return The current anonymous ID
* @throws IllegalStateException if MetaRouter has not been initialized
*/
suspend fun getAnonymousId(): String {
// Capture the current boundSignal reference under the same lock
// reset uses, so we can't observe a mid-reset state where the flag
// is still true but boundSignal has already been swapped for a
// fresh (never-completing) deferred.
val signal: CompletableDeferred<Unit> = initMutex.withLock {
if (!initializationStarted.get()) {
throw IllegalStateException(
"MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first."
)
}
boundSignal
}

signal.await()

val client = store.get()
?: throw IllegalStateException(
"MetaRouter bound signal completed but client store is empty (likely reset during getAnonymousId)"
)
return client.getAnonymousId()
}

/**
* Enable or disable debug logging.
*
Expand Down Expand Up @@ -258,9 +214,6 @@ object MetaRouter {
// Reset the proxy so it can be bound to a new client
proxy.unbind()

// Reset bound signal for next initialization
boundSignal = CompletableDeferred()

// Reset initialization flag
initializationStarted.set(false)

Expand All @@ -282,7 +235,6 @@ object MetaRouter {
lifecycleObserver = null
store.clear()
proxy.resetForTesting()
boundSignal = CompletableDeferred()
initializationStarted.set(false)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ class MetaRouterAnalyticsClient private constructor(

// ===== Identity Read Methods =====

fun getAnonymousId(): String {
override suspend fun getAnonymousId(): String {
check(lifecycleState.get() == LifecycleState.READY) {
"Cannot get anonymousId - SDK not ready (state: ${lifecycleState.get()})"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class AnalyticsExtensionsTest {
override fun clearAdvertisingId() {}
override suspend fun flush() {}
override suspend fun reset() {}
override suspend fun getAnonymousId(): String = "mock-anon-id"
override fun enableDebugLogging() {}
override suspend fun getDebugInfo(): Map<String, Any?> = emptyMap()
override fun setTracing(enabled: Boolean) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import com.metarouter.analytics.utils.Logger
import io.mockk.*
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
Expand Down Expand Up @@ -382,4 +385,61 @@ class AnalyticsProxyTest {
// All events should have been processed (either replayed or forwarded)
assertTrue(proxy.isBound())
}

// ===== getAnonymousId =====

@Test
fun `getAnonymousId returns value after bind`() = runTest {
coEvery { mockClient.getAnonymousId() } returns "anon-123"

proxy.bind(mockClient)

assertEquals("anon-123", proxy.getAnonymousId())
}

@Test
fun `getAnonymousId suspends until bind completes`() = runTest {
coEvery { mockClient.getAnonymousId() } returns "anon-456"

val result = async { proxy.getAnonymousId() }

// Give the coroutine a chance to start and suspend on the boundSignal.
// It must not complete before bind() runs.
delay(50)
assertFalse("getAnonymousId should still be suspended before bind", result.isCompleted)

proxy.bind(mockClient)

assertEquals("anon-456", result.await())
}

@Test
fun `getAnonymousId returns stable value across calls`() = runTest {
coEvery { mockClient.getAnonymousId() } returns "anon-stable"

proxy.bind(mockClient)

val id1 = proxy.getAnonymousId()
val id2 = proxy.getAnonymousId()
assertEquals(id1, id2)
}

@Test
fun `getAnonymousId hangs after unbind until next bind`() = runTest {
coEvery { mockClient.getAnonymousId() } returns "anon-first"
proxy.bind(mockClient)
assertEquals("anon-first", proxy.getAnonymousId())

proxy.unbind()

val result = async { proxy.getAnonymousId() }
val pollResult = withTimeoutOrNull(100) { result.await() }
assertNull("getAnonymousId should suspend while unbound", pollResult)

val secondClient = mockk<AnalyticsInterface>(relaxed = true)
coEvery { secondClient.getAnonymousId() } returns "anon-second"
proxy.bind(secondClient)

assertEquals("anon-second", result.await())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,12 @@ class MetaRouterAnalyticsClientTest {

client.reset()

assertThrows(IllegalStateException::class.java) {
try {
client.getAnonymousId()
fail("Expected IllegalStateException")
} catch (e: IllegalStateException) {
// Expected - lifecycle is no longer READY after reset
}
Unit
}

// ===== Reset =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,50 +163,25 @@ class MetaRouterTest {
assertFalse(Logger.debugEnabled)
}

// ===== getAnonymousId =====

@Test
fun `getAnonymousId throws if not initialized`() = runTest {
try {
MetaRouter.Analytics.getAnonymousId()
fail("Expected IllegalStateException")
} catch (e: IllegalStateException) {
// Expected
}
}
// ===== getAnonymousId (via client) =====

@Test
fun `getAnonymousId returns value after initializeAndWait`() = runTest {
MetaRouter.initializeAndWait(context, options)
val client = MetaRouter.initializeAndWait(context, options)

val anonId = MetaRouter.Analytics.getAnonymousId()
val anonId = client.getAnonymousId()
assertNotNull(anonId)
}

@Test
fun `getAnonymousId returns stable value across calls`() = runTest {
MetaRouter.initializeAndWait(context, options)
val client = MetaRouter.initializeAndWait(context, options)

val id1 = MetaRouter.Analytics.getAnonymousId()
val id2 = MetaRouter.Analytics.getAnonymousId()
val id1 = client.getAnonymousId()
val id2 = client.getAnonymousId()
assertEquals(id1, id2)
}

@Test
fun `getAnonymousId throws after resetAndWait`() = runTest {
MetaRouter.initializeAndWait(context, options)
MetaRouter.Analytics.getAnonymousId() // should work

MetaRouter.Analytics.resetAndWait()

try {
MetaRouter.Analytics.getAnonymousId()
fail("Expected IllegalStateException")
} catch (e: IllegalStateException) {
// Expected
}
}

// ===== Reset =====

@Test
Expand Down
Loading