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..1f569f3 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,35 @@ 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`, 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 — + * 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). + * + * 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 + * 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..48deba2 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,34 @@ 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. + // + // `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 + } + 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 +474,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,17 +491,39 @@ 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 } + // 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?) { + 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() { 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..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 @@ -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,13 @@ 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, + // 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) +) { companion object { private const val SDK_NAME = "metarouter-android-sdk" @@ -49,6 +54,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 +64,7 @@ class DeviceContextProvider(private val context: Context) { timezone = getTimezone(), device = getDeviceContext(), os = getOSContext(), - app = getAppContext(), + app = appContext, screen = getScreenContext(), network = getNetworkContext() ) @@ -127,54 +134,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..6c9c837 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,90 @@ 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 + ) + + // 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() + } + + @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