diff --git a/README.md b/README.md index 6058314..6552462 100644 --- a/README.md +++ b/README.md @@ -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()` @@ -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 ``` diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt index a624a43..691beb5 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -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 diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt index a84460e..7f0c215 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt @@ -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 @@ -21,6 +22,9 @@ class AnalyticsProxy( private val pendingCalls = ArrayDeque() private val mutex = Mutex() + @Volatile + private var boundSignal = CompletableDeferred() + /** * Bind a real client and replay all pending calls. * This is idempotent - calling bind() again after binding is a no-op. @@ -46,6 +50,7 @@ class AnalyticsProxy( // Set the real client atomically realClient.set(client) + boundSignal.complete(Unit) } } @@ -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) { @@ -231,6 +246,7 @@ class AnalyticsProxy( synchronized(pendingCalls) { pendingCalls.clear() } + boundSignal = CompletableDeferred() } } 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 fd8661f..1bda258 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt @@ -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 @@ -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() - @Volatile private var lifecycleObserver: AppLifecycleObserver? = null @@ -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) } } @@ -124,7 +114,6 @@ object MetaRouter { lifecycleObserver?.register() proxy.bind(client) - boundSignal.complete(Unit) } } @@ -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 = 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. * @@ -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) @@ -282,7 +235,6 @@ 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 e125e3f..8be3ee7 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -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()})" } diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt index 417f1a4..fcd28f0 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt @@ -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 = emptyMap() override fun setTracing(enabled: Boolean) {} diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt index 6b2fe05..143934f 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt @@ -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 @@ -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(relaxed = true) + coEvery { secondClient.getAnonymousId() } returns "anon-second" + proxy.bind(secondClient) + + assertEquals("anon-second", result.await()) + } } 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 1d765cb..855e449 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt @@ -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 ===== 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 1ba30b1..ff4227b 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterTest.kt @@ -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