From 045a88e93643f7b568c571c3fe9797f391fc32d4 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 14:04:07 -0600 Subject: [PATCH 1/3] feat: wire LifecycleCoordinator + openURL public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end integration of the lifecycle subsystem. After this slice the feature is fully functional behind `InitOptions.trackLifecycleEvents` — default stays false so existing customers are not affected on upgrade. Coordinator seam - LifecycleCoordinator wraps LifecycleEventTracker. MetaRouterAnalytics- Client no longer references the tracker directly; future session / attribution work has a clear place to land alongside lifecycle events. - Constructed only when trackLifecycleEvents=true. When the feature is off, lifecycleCoordinator is null and every dispatch site is a null-safe no-op. AppContext caching - AnalyticsClient reads AppContext.fromContext() exactly once at init and injects the same snapshot into both DeviceContextProvider and LifecycleEventTracker. The per-event enrichment path no longer hits PackageManager. Public API - handleDeepLink renamed to openURL across AnalyticsInterface, AnalyticsProxy, PendingCall, and the proxied call replay. Name matches Segment's iOS SDK and avoids the over-promise of 'handle' (the SDK does not route or parse the URL — it buffers it for the next Application Opened). - openURL on the client logs Logger.warn and no-ops when the feature is disabled. Silent no-op was bad DX; misconfiguration is now diagnosable from logcat. Opt-in default - InitOptions.trackLifecycleEvents flips from true to false. KDoc updated. InitOptionsTest renamed to assert the new default. Lifecycle ordering - onBackground emits Application Backgrounded BEFORE flush / flushToDisk so the event lands in the same drain (unchanged behavior; comment kept next to the code). Refs: sc-38235 --- .../analytics/AnalyticsInterface.kt | 26 ++++++ .../metarouter/analytics/AnalyticsProxy.kt | 11 +++ .../com/metarouter/analytics/InitOptions.kt | 8 +- .../analytics/MetaRouterAnalyticsClient.kt | 77 ++++++++++++++-- .../com/metarouter/analytics/PendingCall.kt | 3 + .../context/DeviceContextProvider.kt | 58 ++----------- .../lifecycle/LifecycleCoordinator.kt | 31 +++++++ .../analytics/AnalyticsExtensionsTest.kt | 2 + .../metarouter/analytics/InitOptionsTest.kt | 23 +++++ .../MetaRouterAnalyticsClientTest.kt | 87 ++++++++++++++++++- .../lifecycle/LifecycleCoordinatorTest.kt | 58 +++++++++++++ .../lifecycle/LifecycleEventTrackerTest.kt | 1 + 12 files changed, 326 insertions(+), 59 deletions(-) create mode 100644 metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt create mode 100644 metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt 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 691beb5..dba1593 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -1,5 +1,7 @@ package com.metarouter.analytics +import android.net.Uri + /** * Public API for the MetaRouter Analytics SDK. * @@ -160,4 +162,28 @@ interface AnalyticsInterface { * @param enabled Whether to enable tracing */ fun setTracing(enabled: Boolean) + + /** + * Buffer a deep-link URL for the next `Application Opened` event. + * + * Call this from the receiving Activity's `onCreate` / `onNewIntent`, typically + * passing `intent.data` and the referrer host (e.g. read via + * `intent.getStringExtra(Intent.EXTRA_REFERRER)` or `Activity.referrer?.host`). + * + * The next `Application Opened` event the SDK emits will carry `url` and + * (when provided) `referring_application` properties. The buffer is one-shot — + * subsequent `Application Opened` events without a fresh call omit the props. + * If called multiple times before the next `Application Opened`, the most recent + * URL wins (last-write-wins; no queue). + * + * Logs a warning and is a no-op when `InitOptions.trackLifecycleEvents` is `false`. + * + * The method is named after the *signal* the host is forwarding, not what the SDK + * does with it — the SDK does not route, parse, or open the URL. Mirrors Segment's + * iOS API for cross-platform consistency. + * + * @param uri The deep-link URI that opened the app + * @param sourceApplication Optional referring application identifier + */ + fun openURL(uri: Uri, sourceApplication: String? = null) } 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 7f0c215..fa8b56c 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt @@ -1,5 +1,6 @@ package com.metarouter.analytics +import android.net.Uri import com.metarouter.analytics.utils.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Mutex @@ -202,6 +203,15 @@ class AnalyticsProxy( } } + override fun openURL(uri: Uri, sourceApplication: String?) { + val client = realClient.get() + if (client != null) { + client.openURL(uri, sourceApplication) + } else { + enqueue(PendingCall.OpenURL(uri, sourceApplication)) + } + } + /** * Enqueue a pending call, dropping oldest if at capacity. @@ -230,6 +240,7 @@ class AnalyticsProxy( is PendingCall.SetTracing -> client.setTracing(call.enabled) is PendingCall.SetAdvertisingId -> client.setAdvertisingId(call.advertisingId) is PendingCall.ClearAdvertisingId -> client.clearAdvertisingId() + is PendingCall.OpenURL -> client.openURL(call.uri, call.sourceApplication) is PendingCall.Flush -> client.flush() is PendingCall.Reset -> client.reset() is PendingCall.EnableDebugLogging -> client.enableDebugLogging() diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt index 3d54ce3..ef19ff0 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt @@ -16,6 +16,11 @@ import com.metarouter.analytics.utils.Logger * (default: 10000). Set to `0` to opt out of disk persistence entirely — the queue then * operates as a purely in-memory ring buffer, dropping the oldest event when full. * Negative values are rejected. + * @property trackLifecycleEvents Whether the SDK should automatically emit + * `Application Installed`, `Application Updated`, `Application Opened`, and + * `Application Backgrounded` events (default: `false` — opt-in). Set to `true` + * to enable. Existing customers upgrading the SDK do not begin emitting + * lifecycle events without explicitly enabling the flag. * * @throws IllegalArgumentException if validation fails */ @@ -25,7 +30,8 @@ data class InitOptions( val flushIntervalSeconds: Int = 10, val debug: Boolean = false, val maxQueueEvents: Int = 2000, - val maxDiskEvents: Int = 10000 + val maxDiskEvents: Int = 10000, + val trackLifecycleEvents: Boolean = false ) { init { validateWriteKey() 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 8be3ee7..f2ec5a4 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -4,11 +4,14 @@ import android.app.Application import android.content.ComponentCallbacks2 import android.content.Context import android.content.res.Configuration +import android.net.Uri import com.metarouter.analytics.context.DeviceContextProvider import com.metarouter.analytics.dispatcher.Dispatcher import com.metarouter.analytics.dispatcher.DispatcherConfig import com.metarouter.analytics.enrichment.EventEnrichmentService import com.metarouter.analytics.identity.IdentityManager +import com.metarouter.analytics.lifecycle.LifecycleCoordinator +import com.metarouter.analytics.lifecycle.LifecycleEventTracker import com.metarouter.analytics.network.AndroidNetworkMonitor import com.metarouter.analytics.network.CircuitBreaker import com.metarouter.analytics.network.DebouncedNetworkMonitor @@ -18,6 +21,8 @@ import com.metarouter.analytics.network.OkHttpNetworkClient import com.metarouter.analytics.queue.EventQueueInterface import com.metarouter.analytics.queue.PersistableEventQueue import com.metarouter.analytics.storage.EventDiskStore +import com.metarouter.analytics.storage.LifecycleStorage +import com.metarouter.analytics.types.AppContext import com.metarouter.analytics.types.BaseEvent import com.metarouter.analytics.types.EventType import com.metarouter.analytics.types.LifecycleState @@ -43,7 +48,10 @@ class MetaRouterAnalyticsClient private constructor( private val injectedCircuitBreaker: CircuitBreaker? = null, private val injectedDispatcher: Dispatcher? = null, private val injectedDiskStore: EventDiskStore? = null, - private val injectedNetworkMonitor: NetworkMonitor? = null + private val injectedNetworkMonitor: NetworkMonitor? = null, + private val injectedLifecycleStorage: LifecycleStorage? = null, + private val injectedLifecycleCoordinator: LifecycleCoordinator? = null, + private val injectedAppContext: AppContext? = null ) : AnalyticsInterface { companion object { @@ -75,7 +83,10 @@ class MetaRouterAnalyticsClient private constructor( circuitBreaker: CircuitBreaker? = null, dispatcher: Dispatcher? = null, diskStore: EventDiskStore? = null, - networkMonitor: NetworkMonitor? = null + networkMonitor: NetworkMonitor? = null, + lifecycleStorage: LifecycleStorage? = null, + lifecycleCoordinator: LifecycleCoordinator? = null, + appContext: AppContext? = null ): MetaRouterAnalyticsClient { val client = MetaRouterAnalyticsClient( context.applicationContext, @@ -88,7 +99,10 @@ class MetaRouterAnalyticsClient private constructor( circuitBreaker, dispatcher, diskStore, - networkMonitor + networkMonitor, + lifecycleStorage, + lifecycleCoordinator, + appContext ) client.initializeInternal() return client @@ -119,6 +133,9 @@ class MetaRouterAnalyticsClient private constructor( private var persistableEventQueue: PersistableEventQueue? = null private var componentCallbacks: ComponentCallbacks2? = null + // Lifecycle coordinator (null when InitOptions.trackLifecycleEvents is false) + private var lifecycleCoordinator: LifecycleCoordinator? = null + /** * Internal initialization. Sets up all components. */ @@ -138,9 +155,15 @@ class MetaRouterAnalyticsClient private constructor( val channelCapacity = options.maxQueueEvents eventChannel = Channel(capacity = channelCapacity) + // App metadata: read PackageManager once and share the snapshot across + // every consumer (DeviceContextProvider for per-event enrichment, the + // lifecycle tracker for install/update detection). Bundle/PackageInfo is + // OS-loaded and immutable post-process-start, so caching is safe. + val appContext = injectedAppContext ?: AppContext.fromContext(context) + // Initialize components (use injected or create new) identityManager = injectedIdentityManager ?: IdentityManager(context) - contextProvider = injectedContextProvider ?: DeviceContextProvider(context) + contextProvider = injectedContextProvider ?: DeviceContextProvider(context, appContext) enrichmentService = injectedEnrichmentService ?: EventEnrichmentService( identityManager = identityManager, contextProvider = contextProvider, @@ -230,9 +253,29 @@ class MetaRouterAnalyticsClient private constructor( componentCallbacks = callbacks } + // Build the lifecycle coordinator before flipping to READY so onReady() can run + // immediately after the state transition. The tracker emits via track(), which + // requires the lifecycle state to be READY. + lifecycleCoordinator = injectedLifecycleCoordinator ?: if (options.trackLifecycleEvents) { + val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) + val tracker = LifecycleEventTracker( + analytics = this, + storage = lifecycleStorage, + appContext = appContext, + identityManager = identityManager + ) + LifecycleCoordinator(tracker) + } else { + null + } + lifecycleState.set(LifecycleState.READY) Logger.log("MetaRouter SDK initialized") + // Run cold-launch detection + emission. Must happen after READY so enqueueEvent + // accepts the events. + lifecycleCoordinator?.onReady() + } catch (e: Exception) { Logger.error("Failed to initialize SDK: ${e.message}") lifecycleState.set(LifecycleState.IDLE) @@ -426,13 +469,16 @@ class MetaRouterAnalyticsClient private constructor( /** * Called when app goes to background. - * Flushes pending events and pauses the dispatcher. + * Emits `Application Backgrounded` (when enabled), flushes pending events, + * and pauses the dispatcher. The lifecycle event must be emitted before flush + * so it lands in the same drain. */ internal suspend fun onBackground() { if (lifecycleState.get() != LifecycleState.READY) { Logger.log("onBackground ignored - SDK not ready (state: ${lifecycleState.get()})") return } + lifecycleCoordinator?.onBackground() flush() persistableEventQueue?.flushToDisk() dispatcher.pause() @@ -440,19 +486,38 @@ class MetaRouterAnalyticsClient private constructor( /** * Called when app comes to foreground. - * Flushes pending events and resumes the dispatcher. + * Emits `Application Opened` (when enabled), flushes pending events, and + * resumes the dispatcher. */ internal fun onForeground() { if (lifecycleState.get() != LifecycleState.READY) { Logger.log("onForeground ignored - SDK not ready (state: ${lifecycleState.get()})") return } + lifecycleCoordinator?.onForeground() scope.launch { flush() } dispatcher.resume() } + override fun openURL(uri: Uri, sourceApplication: String?) { + if (lifecycleState.get() != LifecycleState.READY) { + Logger.log("openURL ignored - SDK not ready (state: ${lifecycleState.get()})") + return + } + val coordinator = lifecycleCoordinator + if (coordinator == null) { + Logger.warn( + "openURL called but trackLifecycleEvents is disabled — no-op. " + + "Set InitOptions.trackLifecycleEvents=true to buffer deep links " + + "for the next Application Opened event." + ) + return + } + coordinator.openURL(uri, sourceApplication) + } + override suspend fun reset() { if (!lifecycleState.compareAndSet(LifecycleState.READY, LifecycleState.RESETTING)) { Logger.warn("Cannot reset - SDK not in READY state (current: ${lifecycleState.get()})") diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt index a66abe1..c5ebf1e 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt @@ -1,5 +1,7 @@ package com.metarouter.analytics +import android.net.Uri + /** * Represents a queued analytics method call that will be replayed * once the real client is bound to the proxy. @@ -15,6 +17,7 @@ sealed class PendingCall { data class Alias(val newUserId: String) : PendingCall() data class SetTracing(val enabled: Boolean) : PendingCall() data class SetAdvertisingId(val advertisingId: String) : PendingCall() + data class OpenURL(val uri: Uri, val sourceApplication: String?) : PendingCall() object ClearAdvertisingId : PendingCall() object Flush : PendingCall() object Reset : PendingCall() diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt index 2e30020..2f176a0 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt @@ -1,7 +1,6 @@ package com.metarouter.analytics.context import android.content.Context -import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build @@ -29,7 +28,10 @@ import kotlin.math.roundToInt * - Cache generation is synchronized to prevent duplicate work * - Safe for concurrent access from multiple threads */ -class DeviceContextProvider(private val context: Context) { +class DeviceContextProvider( + private val context: Context, + private val appContext: AppContext = AppContext.fromContext(context) +) { companion object { private const val SDK_NAME = "metarouter-android-sdk" @@ -49,6 +51,8 @@ class DeviceContextProvider(private val context: Context) { /** * Generate fresh context information by collecting all metadata. + * `appContext` is the cached snapshot read at SDK init — `PackageManager` + * is not consulted on the per-event enrichment path. */ private fun generateContext(): EventContext { return EventContext( @@ -57,7 +61,7 @@ class DeviceContextProvider(private val context: Context) { timezone = getTimezone(), device = getDeviceContext(), os = getOSContext(), - app = getAppContext(), + app = appContext, screen = getScreenContext(), network = getNetworkContext() ) @@ -127,54 +131,6 @@ class DeviceContextProvider(private val context: Context) { ) } - /** - * Get app information from PackageManager. - * Collects app name, version, build number, and namespace (package name). - */ - private fun getAppContext(): AppContext { - return try { - val packageManager = context.packageManager - val packageName = context.packageName - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager.getPackageInfo(packageName, 0) - } - - val appName = try { - packageInfo.applicationInfo?.let { - packageManager.getApplicationLabel(it).toString() - } ?: UNKNOWN - } catch (e: Exception) { - UNKNOWN - } - - val versionName = packageInfo.versionName ?: UNKNOWN - val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.longVersionCode.toString() - } else { - @Suppress("DEPRECATION") - packageInfo.versionCode.toString() - } - - AppContext( - name = appName, - version = versionName, - build = versionCode, - namespace = packageName - ) - } catch (e: Exception) { - Logger.warn("Failed to get app context: ${e.message}") - AppContext( - name = UNKNOWN, - version = UNKNOWN, - build = UNKNOWN, - namespace = context.packageName - ) - } - } - /** * Get screen dimensions and pixel density from WindowManager. * Width/height are in points (dp), density is pixel ratio rounded to 2 decimal places. diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt new file mode 100644 index 0000000..c622d2c --- /dev/null +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt @@ -0,0 +1,31 @@ +package com.metarouter.analytics.lifecycle + +import android.net.Uri + +/** + * Bridge between the platform notification observer ([AppLifecycleObserver]) and the + * lifecycle event tracker. Single seam where future session/attribution work will land + * alongside lifecycle events without bloating [MetaRouterAnalyticsClient]. + * + * The coordinator is constructed only when `InitOptions.trackLifecycleEvents` is `true`; + * when the feature is disabled, the client never holds an instance and all coordinator + * calls are null-safe no-ops at the call sites. + */ +internal class LifecycleCoordinator( + private val tracker: LifecycleEventTracker +) { + /** Called from `AppLifecycleObserver.onStart` (`ProcessLifecycleOwner.ON_START`). */ + fun onForeground() = tracker.onForeground() + + /** Called from `AppLifecycleObserver.onStop` (`ProcessLifecycleOwner.ON_STOP`). */ + fun onBackground() = tracker.onBackground() + + /** + * Called once after the SDK transitions to `READY`. Runs the cold-launch + * detection + emission sequence (install/update + initial Opened). + */ + fun onReady() = tracker.onSdkReady() + + /** Buffer a deep link for the next `Application Opened` emission. */ + fun openURL(uri: Uri, sourceApplication: String?) = tracker.openURL(uri, sourceApplication) +} 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 fcd28f0..b438140 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt @@ -1,5 +1,6 @@ package com.metarouter.analytics +import android.net.Uri import org.junit.Test import org.junit.Assert.* @@ -57,6 +58,7 @@ class AnalyticsExtensionsTest { override fun enableDebugLogging() {} override suspend fun getDebugInfo(): Map = emptyMap() override fun setTracing(enabled: Boolean) {} + override fun openURL(uri: Uri, sourceApplication: String?) {} } @Test diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt index a27cbc9..1b5b425 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt @@ -328,4 +328,27 @@ class InitOptionsTest { unmockkStatic(Log::class) } } + + // ===== trackLifecycleEvents ===== + + @Test + fun `trackLifecycleEvents defaults to false (opt-in)`() { + val options = InitOptions( + writeKey = "test-key", + ingestionHost = "https://example.com" + ) + + assertEquals(false, options.trackLifecycleEvents) + } + + @Test + fun `trackLifecycleEvents explicit true is accepted`() { + val options = InitOptions( + writeKey = "test-key", + ingestionHost = "https://example.com", + trackLifecycleEvents = true + ) + + assertEquals(true, options.trackLifecycleEvents) + } } 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 855e449..2778e49 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt @@ -48,7 +48,11 @@ class MetaRouterAnalyticsClientTest { ingestionHost = "https://events.example.com", flushIntervalSeconds = 10, debug = false, - maxQueueEvents = 100 + maxQueueEvents = 100, + // Disable lifecycle events by default so existing assertions on queueLength + // are not affected by automatic Installed/Opened emission. Lifecycle tests + // re-enable explicitly via options.copy(trackLifecycleEvents = true). + trackLifecycleEvents = false ) } @@ -628,4 +632,85 @@ class MetaRouterAnalyticsClientTest { assertNotNull(debugInfo["networkStatus"]) assertTrue((debugInfo["queueLength"] as Int) > 0) } + + // ===== Lifecycle Events ===== + + @Test + fun `cold launch with trackLifecycleEvents enabled emits at least install event and persists version`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + // Use a fresh, real context so lifecycle prefs and identity prefs start empty. + val identityManager = IdentityManager(realContext) + identityManager.reset() + val lifecycleStorage = com.metarouter.analytics.storage.LifecycleStorage(realContext) + lifecycleStorage.clear() + + val lifecycleOptions = options.copy(trackLifecycleEvents = true) + val client = MetaRouterAnalyticsClient.initialize( + realContext, + lifecycleOptions, + identityManager = identityManager, + lifecycleStorage = lifecycleStorage + ) + + // Cold-launch sequence persists the current (version, build) and enqueues at + // least the Installed event. The Opened event also fires when ProcessLifecycleOwner + // is in STARTED state — which Robolectric does not deterministically provide here, + // so we assert the lower bound and verify storage persistence directly. + awaitCondition { lifecycleStorage.getVersion() != null } + awaitCondition { (client.getDebugInfo()["queueLength"] as Int) >= 1 } + + client.reset() + } + + @Test + fun `trackLifecycleEvents false produces no lifecycle events`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + identityManager.reset() + val lifecycleStorage = com.metarouter.analytics.storage.LifecycleStorage(realContext) + lifecycleStorage.clear() + + // Default options has trackLifecycleEvents = false in this suite. + val client = MetaRouterAnalyticsClient.initialize( + realContext, + options, + identityManager = identityManager, + lifecycleStorage = lifecycleStorage + ) + + delay(100) + val info = client.getDebugInfo() + assertEquals(0, info["queueLength"]) + + client.reset() + } + + @Test + fun `lifecycle storage survives client reset`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + identityManager.reset() + val lifecycleStorage = com.metarouter.analytics.storage.LifecycleStorage(realContext) + lifecycleStorage.clear() + + val lifecycleOptions = options.copy(trackLifecycleEvents = true) + val client = MetaRouterAnalyticsClient.initialize( + realContext, + lifecycleOptions, + identityManager = identityManager, + lifecycleStorage = lifecycleStorage + ) + // Wait for cold-launch to persist version/build. + awaitCondition { lifecycleStorage.getVersion() != null } + val storedVersion = lifecycleStorage.getVersion() + val storedBuild = lifecycleStorage.getBuild() + assertNotNull(storedVersion) + assertNotNull(storedBuild) + + client.reset() + + // Reset must NOT clear lifecycle prefs. + assertEquals(storedVersion, lifecycleStorage.getVersion()) + assertEquals(storedBuild, lifecycleStorage.getBuild()) + } } diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt new file mode 100644 index 0000000..791bd51 --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt @@ -0,0 +1,58 @@ +package com.metarouter.analytics.lifecycle + +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +/** + * Coordinator is a thin pass-through to the tracker; the full algorithm is exercised + * by `LifecycleEventTrackerTest`. These tests confirm the seam exists and forwards + * each call exactly once with the same arguments. + */ +class LifecycleCoordinatorTest { + + @Test + fun `onForeground forwards to tracker`() { + val tracker = mockk(relaxed = true) + LifecycleCoordinator(tracker).onForeground() + verify(exactly = 1) { tracker.onForeground() } + } + + @Test + fun `onBackground forwards to tracker`() { + val tracker = mockk(relaxed = true) + LifecycleCoordinator(tracker).onBackground() + verify(exactly = 1) { tracker.onBackground() } + } + + @Test + fun `onReady forwards to tracker onSdkReady`() { + val tracker = mockk(relaxed = true) + LifecycleCoordinator(tracker).onReady() + verify(exactly = 1) { tracker.onSdkReady() } + } + + @Test + fun `openURL forwards uri and source application to tracker`() { + val tracker = mockk(relaxed = true) + val uri = mockk() + every { uri.toString() } returns "https://example.com" + + LifecycleCoordinator(tracker).openURL(uri, "com.referrer") + + verify(exactly = 1) { tracker.openURL(uri, "com.referrer") } + } + + @Test + fun `openURL forwards null source application unchanged`() { + val tracker = mockk(relaxed = true) + val uri = mockk() + every { uri.toString() } returns "https://example.com" + + LifecycleCoordinator(tracker).openURL(uri, null) + + verify(exactly = 1) { tracker.openURL(uri, null) } + } +} diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt index c78b952..54a5555 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt @@ -44,6 +44,7 @@ class LifecycleEventTrackerTest { override fun enableDebugLogging() {} override suspend fun getDebugInfo(): Map = emptyMap() override fun setTracing(enabled: Boolean) {} + override fun openURL(uri: Uri, sourceApplication: String?) {} } private lateinit var analytics: RecordingAnalytics From 4e7a426769773d21cd1f8a2be082c8d589b62848 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:57:40 -0600 Subject: [PATCH 2/3] fix: gate tightening, onForeground ordering, EXTRA_REFERRER KDoc Slice 3 follow-ups from code review. - onForeground ordering: dispatcher.resume() now runs BEFORE coordinator.onForeground(). Mirrors iOS so the resumed dispatcher picks up the just-emitted Application Opened in its next tick rather than waiting for the following flush cycle. Functionally benign on Android (track() enqueues regardless of dispatcher state) but the cross-platform parity contract was an explicit constraint. - Coordinator gate: trackLifecycleEvents is now the sole on/off signal. Previously a test seam injection of injectedLifecycleCoordinator could install a coordinator while the flag was false. Production paths were fine via MetaRouter.kt, but the off-state should be structurally enforced regardless of the seam. - AnalyticsInterface.openURL KDoc: drop the misleading Intent.EXTRA_REFERRER + getStringExtra suggestion. Per Android docs EXTRA_REFERRER is a Uri (returns null via getStringExtra). Point hosts at Activity.referrer?.host, which is the canonical API for the calling-app host. - DeviceContextProvider: KDoc on the appContext constructor default noting it exists for test ergonomics only. Production code must pass the explicit cached snapshot. Refs: sc-38235 --- .../analytics/AnalyticsInterface.kt | 7 +++-- .../analytics/MetaRouterAnalyticsClient.kt | 28 ++++++++++++------- .../context/DeviceContextProvider.kt | 3 ++ 3 files changed, 25 insertions(+), 13 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 dba1593..7c1177e 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -166,9 +166,10 @@ interface AnalyticsInterface { /** * Buffer a deep-link URL for the next `Application Opened` event. * - * Call this from the receiving Activity's `onCreate` / `onNewIntent`, typically - * passing `intent.data` and the referrer host (e.g. read via - * `intent.getStringExtra(Intent.EXTRA_REFERRER)` or `Activity.referrer?.host`). + * Call this from the receiving Activity's `onCreate` / `onNewIntent`, passing + * `intent.data` for the URI. Use `Activity.referrer?.host` for the referrer — + * `Intent.EXTRA_REFERRER` is documented as a `Uri`, not a String, so + * `getStringExtra` on it is virtually always `null`. * * The next `Application Opened` event the SDK emits will carry `url` and * (when provided) `referring_application` properties. The buffer is one-shot — 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 f2ec5a4..48deba2 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -256,15 +256,20 @@ class MetaRouterAnalyticsClient private constructor( // Build the lifecycle coordinator before flipping to READY so onReady() can run // immediately after the state transition. The tracker emits via track(), which // requires the lifecycle state to be READY. - lifecycleCoordinator = injectedLifecycleCoordinator ?: if (options.trackLifecycleEvents) { - val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) - val tracker = LifecycleEventTracker( - analytics = this, - storage = lifecycleStorage, - appContext = appContext, - identityManager = identityManager - ) - LifecycleCoordinator(tracker) + // + // `trackLifecycleEvents` is the sole gate: if disabled, no coordinator is built + // even when a test seam supplies one. Keeps the off-state structurally enforced. + lifecycleCoordinator = if (options.trackLifecycleEvents) { + injectedLifecycleCoordinator ?: run { + val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) + val tracker = LifecycleEventTracker( + analytics = this, + storage = lifecycleStorage, + appContext = appContext, + identityManager = identityManager + ) + LifecycleCoordinator(tracker) + } } else { null } @@ -494,11 +499,14 @@ class MetaRouterAnalyticsClient private constructor( Logger.log("onForeground ignored - SDK not ready (state: ${lifecycleState.get()})") return } + // Resume dispatcher first, then emit. Mirrors iOS so the resumed dispatcher + // picks up the just-emitted Opened in its next tick rather than waiting for the + // following flush cycle. + dispatcher.resume() lifecycleCoordinator?.onForeground() scope.launch { flush() } - dispatcher.resume() } override fun openURL(uri: Uri, sourceApplication: String?) { diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt index 2f176a0..79224ae 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt @@ -30,6 +30,9 @@ import kotlin.math.roundToInt */ class DeviceContextProvider( private val context: Context, + // The `appContext` default is for test ergonomics only. Production code must pass + // the cached snapshot read once at SDK init in `MetaRouterAnalyticsClient` so the + // per-event enrichment path never re-reads `PackageManager`. private val appContext: AppContext = AppContext.fromContext(context) ) { From 8e3a3cd115d28f1d488ced6d97f55bfe19e224f0 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 8 Jul 2026 17:08:46 -0600 Subject: [PATCH 3/3] fix: openURL cold-launch timing KDoc + sentinel lifecycle test Review feedback on PR #32: - Document that an openURL call made while the SDK is still initializing replays after the cold-launch Application Opened has fired, so the deep link attaches to the next Opened (or is lost with the process). README slice (sc-38236) will restate for integrators. - Replace the static delay(100) in the lifecycle-disabled test with a sentinel event + awaitCondition. The channel is single-consumer FIFO, so asserting queueLength == 1 after the sentinel lands proves no lifecycle event was emitted during init. Co-Authored-By: Claude Fable 5 --- .../com/metarouter/analytics/AnalyticsInterface.kt | 6 ++++++ .../analytics/MetaRouterAnalyticsClientTest.kt | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 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 7c1177e..1f569f3 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -177,6 +177,12 @@ interface AnalyticsInterface { * If called multiple times before the next `Application Opened`, the most recent * URL wins (last-write-wins; no queue). * + * Cold-launch timing: with async `initialize`, a call made while the SDK is still + * initializing is queued by the proxy and replays after binding — by which time the + * cold-launch `Application Opened` may have already fired. The deep link then attaches + * to the *next* `Application Opened`, or not at all if the process is killed first + * (the buffer is in-memory only). + * * Logs a warning and is a no-op when `InitOptions.trackLifecycleEvents` is `false`. * * The method is named after the *signal* the host is forwarding, not what the SDK 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 2778e49..6c9c837 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt @@ -678,9 +678,14 @@ class MetaRouterAnalyticsClientTest { lifecycleStorage = lifecycleStorage ) - delay(100) - val info = client.getDebugInfo() - assertEquals(0, info["queueLength"]) + // Absence assertion: awaiting `queueLength == 0` would pass instantly even if a + // buggy lifecycle event were still in the event channel (channel -> queue hand-off + // is async). Instead push a sentinel through the same single-consumer FIFO channel + // and wait for it — any lifecycle event emitted during init was trySend-ed before + // the sentinel, so it would already be in the queue ahead of it. + client.track("Sentinel Event") + awaitCondition { (client.getDebugInfo()["queueLength"] as Int) >= 1 } + assertEquals(1, client.getDebugInfo()["queueLength"]) client.reset() }