diff --git a/README.md b/README.md index bf31469..70f0822 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,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. - `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()` @@ -397,6 +398,13 @@ The `anonymousId` is a unique identifier automatically generated for each device - Remains stable across app sessions until `reset()` is called - Cleared on `reset()` and a **new** UUID is generated on next `initialize()` +**Reading the current value:** + +```kotlin +val anonId = MetaRouter.Analytics.getAnonymousId() +// Suspends until the SDK is ready, then returns the anonymous ID +``` + **Use case:** Track user behavior before they log in or create an account, then connect pre-login and post-login activity using the `alias()` method. diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt index 88157aa..fd8661f 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt @@ -3,6 +3,7 @@ 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 @@ -30,6 +31,10 @@ 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() + @Volatile private var lifecycleObserver: AppLifecycleObserver? = null @@ -57,7 +62,11 @@ object MetaRouter { initializeInternal(context, options) } catch (e: Exception) { Logger.error("Background initialization failed: ${e.message}") - // Reset flag to allow retry + // 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) } } @@ -115,6 +124,7 @@ object MetaRouter { lifecycleObserver?.register() proxy.bind(client) + boundSignal.complete(Unit) } } @@ -185,6 +195,39 @@ 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 = 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. * @@ -215,6 +258,9 @@ 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) @@ -236,6 +282,7 @@ object MetaRouter { lifecycleObserver = null store.clear() proxy.resetForTesting() + boundSignal = CompletableDeferred() initializationStarted.set(false) } } diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt index c04cfe0..79fa22e 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -500,6 +500,15 @@ class MetaRouterAnalyticsClient private constructor( } } + // ===== Identity Read Methods ===== + + fun getAnonymousId(): String { + check(lifecycleState.get() == LifecycleState.READY) { + "Cannot get anonymousId - SDK not ready (state: ${lifecycleState.get()})" + } + return identityManager.getAnonymousId() + } + // ===== Debug Methods ===== override fun enableDebugLogging() { diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt index 0cc9d9e..1d765cb 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt @@ -1,6 +1,8 @@ package com.metarouter.analytics import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.metarouter.analytics.identity.IdentityManager import com.metarouter.analytics.network.FakeNetworkMonitor import com.metarouter.analytics.types.EventType import io.mockk.* @@ -332,6 +334,50 @@ class MetaRouterAnalyticsClientTest { assertTrue((debugInfo["queueLength"] as Int) > 0) } + // ===== getAnonymousId ===== + + @Test + fun `getAnonymousId returns value when ready`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + val client = MetaRouterAnalyticsClient.initialize( + context, options, identityManager = identityManager + ) + + val anonymousId = client.getAnonymousId() + assertTrue(anonymousId.isNotBlank()) + } + + @Test + fun `getAnonymousId returns stable value across calls`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + val client = MetaRouterAnalyticsClient.initialize( + context, options, identityManager = identityManager + ) + + val id1 = client.getAnonymousId() + val id2 = client.getAnonymousId() + assertEquals(id1, id2) + } + + @Test + fun `getAnonymousId throws after reset`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + val client = MetaRouterAnalyticsClient.initialize( + context, options, identityManager = identityManager + ) + client.getAnonymousId() // should not throw + + client.reset() + + assertThrows(IllegalStateException::class.java) { + client.getAnonymousId() + } + Unit + } + // ===== Reset ===== @Test diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterTest.kt index 3057220..1ba30b1 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterTest.kt @@ -163,6 +163,50 @@ 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 + } + } + + @Test + fun `getAnonymousId returns value after initializeAndWait`() = runTest { + MetaRouter.initializeAndWait(context, options) + + val anonId = MetaRouter.Analytics.getAnonymousId() + assertNotNull(anonId) + } + + @Test + fun `getAnonymousId returns stable value across calls`() = runTest { + MetaRouter.initializeAndWait(context, options) + + val id1 = MetaRouter.Analytics.getAnonymousId() + val id2 = MetaRouter.Analytics.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