From 22edba400574c9625d62932ba0bea0a4fdbd422f Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:21:21 -0600 Subject: [PATCH 1/8] feat: expose getAnonymousId() on AnalyticsInterface Add a synchronous `getAnonymousId(): String?` method to the public analytics surface so SDK consumers can read the current anonymous ID. - AnalyticsInterface: new method with KDoc - MetaRouterAnalyticsClient: returns identityManager value when READY, null otherwise - AnalyticsProxy: forwards to bound client, returns null when unbound (read-only, not queued as a pending call) - Tests for concrete client (ready, stable, post-reset) and proxy (unbound-null, bound-forwarding, no-enqueue) --- .../analytics/AnalyticsInterface.kt | 11 +++++ .../metarouter/analytics/AnalyticsProxy.kt | 4 ++ .../analytics/MetaRouterAnalyticsClient.kt | 9 ++++ .../analytics/AnalyticsExtensionsTest.kt | 1 + .../analytics/AnalyticsProxyTest.kt | 24 ++++++++++ .../MetaRouterAnalyticsClientTest.kt | 45 +++++++++++++++++++ 6 files changed, 94 insertions(+) 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..ad431ff 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -120,6 +120,17 @@ interface AnalyticsInterface { */ fun enableDebugLogging() + /** + * Returns the current anonymous ID, or null if the SDK is not yet ready + * or no anonymous ID is currently available. + * + * This is a synchronous, side-effect-free read. It does not generate or + * persist an anonymous ID. + * + * @return The current anonymous ID, or null + */ + fun getAnonymousId(): String? + /** * Get detailed debug information about the SDK state. * 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..31010c1 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt @@ -162,6 +162,10 @@ class AnalyticsProxy( } } + override fun getAnonymousId(): String? { + return realClient.get()?.getAnonymousId() + } + override suspend fun getDebugInfo(): Map { // Use mutex to ensure consistent read with bind/unbind operations return mutex.withLock { 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 18fb3c1..45522c2 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -483,6 +483,15 @@ class MetaRouterAnalyticsClient private constructor( } } + // ===== Identity Read Methods ===== + + override fun getAnonymousId(): String? { + if (lifecycleState.get() != LifecycleState.READY) { + return null + } + return identityManager.getAnonymousId() + } + // ===== Debug Methods ===== override fun enableDebugLogging() { 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..a99f340 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt @@ -54,6 +54,7 @@ class AnalyticsExtensionsTest { override suspend fun flush() {} override suspend fun reset() {} override fun enableDebugLogging() {} + override fun getAnonymousId(): String? = null 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..df3fc9c 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt @@ -293,6 +293,30 @@ class AnalyticsProxyTest { assertEquals(clientInfo + ("bound" to true), debugInfo) } + // ===== getAnonymousId ===== + + @Test + fun `getAnonymousId returns null before binding`() = runTest { + assertNull(proxy.getAnonymousId()) + } + + @Test + fun `getAnonymousId forwards to real client after binding`() = runTest { + every { mockClient.getAnonymousId() } returns "anon-123" + + proxy.bind(mockClient) + + assertEquals("anon-123", proxy.getAnonymousId()) + verify { mockClient.getAnonymousId() } + } + + @Test + fun `getAnonymousId does not enqueue a pending call`() = runTest { + proxy.getAnonymousId() + + assertEquals(0, proxy.pendingCallCount()) + } + // ===== Queue Overflow ===== @Test 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 4780a43..a8624f4 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,49 @@ class MetaRouterAnalyticsClientTest { assertTrue((debugInfo["queueLength"] as Int) > 0) } + // ===== getAnonymousId ===== + + @Test + fun `getAnonymousId returns non-null when ready`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + val client = MetaRouterAnalyticsClient.initialize( + context, options, identityManager = identityManager + ) + + val anonymousId = client.getAnonymousId() + assertNotNull(anonymousId) + 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() + assertNotNull(id1) + assertEquals(id1, id2) + } + + @Test + fun `getAnonymousId returns null after reset`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + val client = MetaRouterAnalyticsClient.initialize( + context, options, identityManager = identityManager + ) + assertNotNull(client.getAnonymousId()) + + client.reset() + + assertNull(client.getAnonymousId()) + } + // ===== Reset ===== @Test From 251bd71fe42ea40dcd18bde2ad64d6a762e51a22 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:24:10 -0600 Subject: [PATCH 2/8] fix: remove unneeded action --- .github/workflows/add-to-project.yml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .github/workflows/add-to-project.yml diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml deleted file mode 100644 index 0607710..0000000 --- a/.github/workflows/add-to-project.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Add PR to Mobile SDK Board - -on: - pull_request: - types: [opened] - -jobs: - add-to-project: - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@v1 - with: - project-url: https://github.com/orgs/metarouterio/projects/1 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} From 9ce113769d3dad673c5a03c34396882eac142542 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:42:00 -0600 Subject: [PATCH 3/8] chore: README update --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index bf31469..31f0c82 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 +- `getAnonymousId(): String?`: Retrieve the current anonymous ID. Returns `null` if the client hasn't been initialized yet. This is a synchronous, side-effect-free read. - `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 = analytics.getAnonymousId() +// Returns null if the client hasn't finished initializing yet +``` + **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. From 464ebef1b9d5b5c71ace41d5585ddb6cbae262f4 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:43:06 -0600 Subject: [PATCH 4/8] chore: removing unneeded comment --- .../main/java/com/metarouter/analytics/AnalyticsInterface.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 ad431ff..f444e80 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -124,8 +124,7 @@ interface AnalyticsInterface { * Returns the current anonymous ID, or null if the SDK is not yet ready * or no anonymous ID is currently available. * - * This is a synchronous, side-effect-free read. It does not generate or - * persist an anonymous ID. + * This is a synchronous, side-effect-free read. * * @return The current anonymous ID, or null */ From eaedb0b3ea6cb9e346f778c5e5e95f0dab218a52 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:56:26 -0600 Subject: [PATCH 5/8] fix: improving the API surface --- .../metarouter/analytics/AnalyticsInterface.kt | 11 ++++++----- .../com/metarouter/analytics/AnalyticsProxy.kt | 6 ++++-- .../analytics/MetaRouterAnalyticsClient.kt | 6 +++--- .../analytics/AnalyticsExtensionsTest.kt | 2 +- .../metarouter/analytics/AnalyticsProxyTest.kt | 8 +++++--- .../analytics/MetaRouterAnalyticsClientTest.kt | 15 ++++++++------- 6 files changed, 27 insertions(+), 21 deletions(-) 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 f444e80..ff87879 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -121,14 +121,15 @@ interface AnalyticsInterface { fun enableDebugLogging() /** - * Returns the current anonymous ID, or null if the SDK is not yet ready - * or no anonymous ID is currently available. + * Returns the current anonymous ID. * - * This is a synchronous, side-effect-free read. + * This is a synchronous, side-effect-free read. The SDK must be initialized + * before calling this method. * - * @return The current anonymous ID, or null + * @return The current anonymous ID + * @throws IllegalStateException if the SDK is not in the READY state */ - fun getAnonymousId(): String? + fun getAnonymousId(): String /** * Get detailed debug information about the SDK state. 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 31010c1..672a7ac 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt @@ -162,8 +162,10 @@ class AnalyticsProxy( } } - override fun getAnonymousId(): String? { - return realClient.get()?.getAnonymousId() + override fun getAnonymousId(): String { + val client = realClient.get() + ?: throw IllegalStateException("Cannot get anonymousId - SDK not bound") + return client.getAnonymousId() } override suspend fun getDebugInfo(): Map { 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 45522c2..995daa6 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -485,9 +485,9 @@ class MetaRouterAnalyticsClient private constructor( // ===== Identity Read Methods ===== - override fun getAnonymousId(): String? { - if (lifecycleState.get() != LifecycleState.READY) { - return null + override fun getAnonymousId(): String { + check(lifecycleState.get() == LifecycleState.READY) { + "Cannot get anonymousId - SDK not ready (state: ${lifecycleState.get()})" } return identityManager.getAnonymousId() } 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 a99f340..35a1dad 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt @@ -54,7 +54,7 @@ class AnalyticsExtensionsTest { override suspend fun flush() {} override suspend fun reset() {} override fun enableDebugLogging() {} - override fun getAnonymousId(): String? = null + override fun getAnonymousId(): String = "test-anon-id" 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 df3fc9c..b7feba4 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt @@ -296,8 +296,10 @@ class AnalyticsProxyTest { // ===== getAnonymousId ===== @Test - fun `getAnonymousId returns null before binding`() = runTest { - assertNull(proxy.getAnonymousId()) + fun `getAnonymousId throws before binding`() = runTest { + assertThrows(IllegalStateException::class.java) { + proxy.getAnonymousId() + } } @Test @@ -312,7 +314,7 @@ class AnalyticsProxyTest { @Test fun `getAnonymousId does not enqueue a pending call`() = runTest { - proxy.getAnonymousId() + try { proxy.getAnonymousId() } catch (_: IllegalStateException) {} assertEquals(0, proxy.pendingCallCount()) } 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 a8624f4..ba15651 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt @@ -337,7 +337,7 @@ class MetaRouterAnalyticsClientTest { // ===== getAnonymousId ===== @Test - fun `getAnonymousId returns non-null when ready`() = runBlocking { + fun `getAnonymousId returns value when ready`() = runBlocking { val realContext: Context = ApplicationProvider.getApplicationContext() val identityManager = IdentityManager(realContext) val client = MetaRouterAnalyticsClient.initialize( @@ -345,8 +345,7 @@ class MetaRouterAnalyticsClientTest { ) val anonymousId = client.getAnonymousId() - assertNotNull(anonymousId) - assertTrue(anonymousId!!.isNotBlank()) + assertTrue(anonymousId.isNotBlank()) } @Test @@ -359,22 +358,24 @@ class MetaRouterAnalyticsClientTest { val id1 = client.getAnonymousId() val id2 = client.getAnonymousId() - assertNotNull(id1) assertEquals(id1, id2) } @Test - fun `getAnonymousId returns null after reset`() = runBlocking { + fun `getAnonymousId throws after reset`() = runBlocking { val realContext: Context = ApplicationProvider.getApplicationContext() val identityManager = IdentityManager(realContext) val client = MetaRouterAnalyticsClient.initialize( context, options, identityManager = identityManager ) - assertNotNull(client.getAnonymousId()) + client.getAnonymousId() // should not throw client.reset() - assertNull(client.getAnonymousId()) + assertThrows(IllegalStateException::class.java) { + client.getAnonymousId() + } + Unit } // ===== Reset ===== From 90e7c19d5db055a1ee0bc5d289e24f7b7fc7f629 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:57:06 -0600 Subject: [PATCH 6/8] chore: updating README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 31f0c82..1cb322e 100644 --- a/README.md +++ b/README.md @@ -215,7 +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 -- `getAnonymousId(): String?`: Retrieve the current anonymous ID. Returns `null` if the client hasn't been initialized yet. This is a synchronous, side-effect-free read. +- `getAnonymousId(): String`: Retrieve the current anonymous ID. This is a synchronous, side-effect-free read. - `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()` From 6ae456c3284ee6aa01b9dedc54d7252e8c5afe58 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 12:17:15 -0600 Subject: [PATCH 7/8] fix: changing to suspend implementation --- README.md | 6 +-- .../analytics/AnalyticsInterface.kt | 11 ----- .../metarouter/analytics/AnalyticsProxy.kt | 6 --- .../com/metarouter/analytics/MetaRouter.kt | 30 +++++++++++++ .../analytics/MetaRouterAnalyticsClient.kt | 2 +- .../analytics/AnalyticsExtensionsTest.kt | 1 - .../analytics/AnalyticsProxyTest.kt | 26 ----------- .../metarouter/analytics/MetaRouterTest.kt | 44 +++++++++++++++++++ 8 files changed, 78 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 1cb322e..70f0822 100644 --- a/README.md +++ b/README.md @@ -215,7 +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 -- `getAnonymousId(): String`: Retrieve the current anonymous ID. This is a synchronous, side-effect-free read. +- `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()` @@ -401,8 +401,8 @@ The `anonymousId` is a unique identifier automatically generated for each device **Reading the current value:** ```kotlin -val anonId = analytics.getAnonymousId() -// Returns null if the client hasn't finished initializing yet +val anonId = MetaRouter.Analytics.getAnonymousId() +// Suspends until the SDK is ready, then returns the anonymous ID ``` **Use case:** 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 ff87879..a624a43 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -120,17 +120,6 @@ interface AnalyticsInterface { */ fun enableDebugLogging() - /** - * Returns the current anonymous ID. - * - * This is a synchronous, side-effect-free read. The SDK must be initialized - * before calling this method. - * - * @return The current anonymous ID - * @throws IllegalStateException if the SDK is not in the READY state - */ - fun getAnonymousId(): String - /** * Get detailed debug information about the SDK state. * 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 672a7ac..a84460e 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt @@ -162,12 +162,6 @@ class AnalyticsProxy( } } - override fun getAnonymousId(): String { - val client = realClient.get() - ?: throw IllegalStateException("Cannot get anonymousId - SDK not bound") - return client.getAnonymousId() - } - override suspend fun getDebugInfo(): Map { // Use mutex to ensure consistent read with bind/unbind operations return mutex.withLock { 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..9a719a5 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 @@ -115,6 +120,7 @@ object MetaRouter { lifecycleObserver?.register() proxy.bind(client) + boundSignal.complete(Unit) } } @@ -185,6 +191,26 @@ 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 { + if (!initializationStarted.get()) { + throw IllegalStateException( + "MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first." + ) + } + boundSignal.await() + return store.get()!!.getAnonymousId() + } + /** * Enable or disable debug logging. * @@ -215,6 +241,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 +265,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 995daa6..714fc94 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -485,7 +485,7 @@ class MetaRouterAnalyticsClient private constructor( // ===== Identity Read Methods ===== - override fun getAnonymousId(): String { + 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 35a1dad..417f1a4 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt @@ -54,7 +54,6 @@ class AnalyticsExtensionsTest { override suspend fun flush() {} override suspend fun reset() {} override fun enableDebugLogging() {} - override fun getAnonymousId(): String = "test-anon-id" 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 b7feba4..6b2fe05 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsProxyTest.kt @@ -293,32 +293,6 @@ class AnalyticsProxyTest { assertEquals(clientInfo + ("bound" to true), debugInfo) } - // ===== getAnonymousId ===== - - @Test - fun `getAnonymousId throws before binding`() = runTest { - assertThrows(IllegalStateException::class.java) { - proxy.getAnonymousId() - } - } - - @Test - fun `getAnonymousId forwards to real client after binding`() = runTest { - every { mockClient.getAnonymousId() } returns "anon-123" - - proxy.bind(mockClient) - - assertEquals("anon-123", proxy.getAnonymousId()) - verify { mockClient.getAnonymousId() } - } - - @Test - fun `getAnonymousId does not enqueue a pending call`() = runTest { - try { proxy.getAnonymousId() } catch (_: IllegalStateException) {} - - assertEquals(0, proxy.pendingCallCount()) - } - // ===== Queue Overflow ===== @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 From 3d80bdb91178ed999ef5fdebfd95c98d5792e0bd Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Thu, 16 Apr 2026 12:49:45 -0600 Subject: [PATCH 8/8] fix: cleaning up from PR review --- .../com/metarouter/analytics/MetaRouter.kt | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) 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 9a719a5..fd8661f 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouter.kt @@ -62,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) } } @@ -202,13 +206,26 @@ object MetaRouter { * @throws IllegalStateException if MetaRouter has not been initialized */ suspend fun getAnonymousId(): String { - if (!initializationStarted.get()) { - throw IllegalStateException( - "MetaRouter not initialized. Call MetaRouter.Analytics.initialize() first." - ) + // 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 } - boundSignal.await() - return store.get()!!.getAnonymousId() + + signal.await() + + val client = store.get() + ?: throw IllegalStateException( + "MetaRouter bound signal completed but client store is empty (likely reset during getAnonymousId)" + ) + return client.getAnonymousId() } /**