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..57d36c8 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,22 @@ interface AnalyticsInterface { * @param enabled Whether to enable tracing */ fun setTracing(enabled: Boolean) + + /** + * Buffer deep-link properties 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. + * + * No-op when `InitOptions.trackLifecycleEvents` is `false`. + * + * @param uri The deep-link URI that opened the app + * @param sourceApplication Optional referring application identifier + */ + fun handleDeepLink(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..8adc74f 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 handleDeepLink(uri: Uri, sourceApplication: String?) { + val client = realClient.get() + if (client != null) { + client.handleDeepLink(uri, sourceApplication) + } else { + enqueue(PendingCall.HandleDeepLink(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.HandleDeepLink -> client.handleDeepLink(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..21a50d8 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,9 @@ 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: `true`). Set to `false` to opt out. * * @throws IllegalArgumentException if validation fails */ @@ -25,7 +28,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 = true ) { 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..fd3d675 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,13 @@ 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.LifecycleEventTracker import com.metarouter.analytics.network.AndroidNetworkMonitor import com.metarouter.analytics.network.CircuitBreaker import com.metarouter.analytics.network.DebouncedNetworkMonitor @@ -18,6 +20,7 @@ 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.BaseEvent import com.metarouter.analytics.types.EventType import com.metarouter.analytics.types.LifecycleState @@ -43,7 +46,9 @@ 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 injectedLifecycleTracker: LifecycleEventTracker? = null ) : AnalyticsInterface { companion object { @@ -75,7 +80,9 @@ class MetaRouterAnalyticsClient private constructor( circuitBreaker: CircuitBreaker? = null, dispatcher: Dispatcher? = null, diskStore: EventDiskStore? = null, - networkMonitor: NetworkMonitor? = null + networkMonitor: NetworkMonitor? = null, + lifecycleStorage: LifecycleStorage? = null, + lifecycleTracker: LifecycleEventTracker? = null ): MetaRouterAnalyticsClient { val client = MetaRouterAnalyticsClient( context.applicationContext, @@ -88,7 +95,9 @@ class MetaRouterAnalyticsClient private constructor( circuitBreaker, dispatcher, diskStore, - networkMonitor + networkMonitor, + lifecycleStorage, + lifecycleTracker ) client.initializeInternal() return client @@ -119,6 +128,9 @@ class MetaRouterAnalyticsClient private constructor( private var persistableEventQueue: PersistableEventQueue? = null private var componentCallbacks: ComponentCallbacks2? = null + // Lifecycle event tracker (null when InitOptions.trackLifecycleEvents is false) + private var lifecycleTracker: LifecycleEventTracker? = null + /** * Internal initialization. Sets up all components. */ @@ -230,9 +242,29 @@ class MetaRouterAnalyticsClient private constructor( componentCallbacks = callbacks } + // Build lifecycle event tracker before flipping to READY so onSdkReady() can run + // immediately after the state transition. The tracker emits via track(), which + // requires the lifecycle state to be READY. + lifecycleTracker = injectedLifecycleTracker ?: if (options.trackLifecycleEvents) { + val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) + LifecycleEventTracker( + analytics = this, + storage = lifecycleStorage, + contextProvider = contextProvider, + identityManager = identityManager, + enabled = true + ) + } 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. + lifecycleTracker?.onSdkReady() + } catch (e: Exception) { Logger.error("Failed to initialize SDK: ${e.message}") lifecycleState.set(LifecycleState.IDLE) @@ -426,13 +458,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 } + lifecycleTracker?.onBackground() flush() persistableEventQueue?.flushToDisk() dispatcher.pause() @@ -440,19 +475,29 @@ 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 } + lifecycleTracker?.onForeground() scope.launch { flush() } dispatcher.resume() } + override fun handleDeepLink(uri: Uri, sourceApplication: String?) { + if (lifecycleState.get() != LifecycleState.READY) { + Logger.log("handleDeepLink ignored - SDK not ready (state: ${lifecycleState.get()})") + return + } + lifecycleTracker?.handleDeepLink(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..3ed15d7 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 HandleDeepLink(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/identity/IdentityManager.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/identity/IdentityManager.kt index 165860e..98f1027 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/identity/IdentityManager.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/identity/IdentityManager.kt @@ -83,4 +83,11 @@ class IdentityManager( storage.clear() Logger.log("Identity manager reset") } + + /** + * Returns true if identity storage already has any value. Used by lifecycle-event + * detection to tell a true fresh install from a user upgrading from a pre-lifecycle + * SDK build (where the version/build keys would be missing despite prior usage). + */ + fun hasAnyValue(): Boolean = storage.hasAnyValue() } diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt new file mode 100644 index 0000000..00eb334 --- /dev/null +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt @@ -0,0 +1,229 @@ +package com.metarouter.analytics.lifecycle + +import android.net.Uri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.metarouter.analytics.AnalyticsInterface +import com.metarouter.analytics.context.DeviceContextProvider +import com.metarouter.analytics.identity.IdentityManager +import com.metarouter.analytics.storage.LifecycleStorage +import com.metarouter.analytics.utils.Logger +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Owns install/update detection and emission of the four application-lifecycle events: + * `Application Installed`, `Application Updated`, `Application Opened`, and + * `Application Backgrounded`. + * + * Wiring: + * - [onSdkReady] is called once after the client transitions to READY. It runs the cold-launch + * sequence (install/update detection → `Application Opened {from_background:false}`). + * - [onForeground] / [onBackground] are called from the existing `AppLifecycleObserver` hooks + * for `background → active` and `active → background` transitions. + * - [handleDeepLink] buffers deep-link properties for inclusion on the next `Application Opened`. + * + * When [enabled] is `false`, every entry point is a no-op. + */ +internal class LifecycleEventTracker( + private val analytics: AnalyticsInterface, + private val storage: LifecycleStorage, + private val contextProvider: DeviceContextProvider, + private val identityManager: IdentityManager, + private val enabled: Boolean, + private val foregroundStateProvider: () -> Boolean = { defaultForegroundCheck() } +) { + + /** + * Set to `true` when the cold-launch path emits `Application Opened`. Causes the + * imminent (first) `onForeground` callback — which `ProcessLifecycleOwner.onStart` + * will fire right after registration — to be suppressed so we don't double-emit. + */ + private val suppressNextForeground = AtomicBoolean(false) + + /** + * Set to `true` when the cold-launch path is suppressed because the process started + * in the background (push, JobScheduler, etc.). The first true `onForeground` call + * consumes this and emits the cold-launch-style `Application Opened {from_background:false}`. + */ + private val coldLaunchOpenedDeferred = AtomicBoolean(false) + + private val pendingDeepLink = AtomicReference(null) + + /** + * Run the cold-launch detection + emission sequence. Idempotent — only the first call + * after construction has effect. + * + * 1. Detect install vs update vs no-op based on persisted (version, build) and identity. + * 2. Persist current (version, build). + * 3. Emit `Application Opened {from_background:false}` if the process is in foreground; + * otherwise defer it to the next foreground transition. + */ + fun onSdkReady() { + if (!enabled) return + + val app = contextProvider.getContext().app + if (app == null) { + Logger.warn("LifecycleEventTracker: app context unavailable, skipping cold-launch sequence") + return + } + + val currentVersion = app.version + val currentBuild = app.build + + val storedVersion = storage.getVersion() + val storedBuild = storage.getBuild() + + when { + storedVersion == null && storedBuild == null -> { + if (identityManager.hasAnyValue()) { + // Existing user upgrading from a pre-lifecycle SDK build. + emitUpdated(currentVersion, currentBuild, UNKNOWN, UNKNOWN) + } else { + emitInstalled(currentVersion, currentBuild) + } + } + storedVersion != currentVersion || storedBuild != currentBuild -> { + emitUpdated( + currentVersion, + currentBuild, + storedVersion ?: UNKNOWN, + storedBuild ?: UNKNOWN + ) + } + else -> { + // Same version/build — no install/update event. + } + } + + storage.setVersionBuild(currentVersion, currentBuild) + + if (foregroundStateProvider()) { + emitOpened(fromBackground = false, version = currentVersion, build = currentBuild) + // The first observer-driven onForeground call (firing right after observer + // registration) must not double-emit. + suppressNextForeground.set(true) + } else { + // Background-launched process (push, JobScheduler, WorkManager, etc.). + // Defer the cold-launch Opened until the first true foreground entry. + coldLaunchOpenedDeferred.set(true) + } + } + + /** + * Called by the AppLifecycleObserver on `ProcessLifecycleOwner.onStart`. + * Emits `Application Opened` with appropriate `from_background` value, with + * dedup logic to avoid double-emit alongside the cold-launch path. + */ + fun onForeground() { + if (!enabled) return + + val app = contextProvider.getContext().app + val version = app?.version ?: UNKNOWN + val build = app?.build ?: UNKNOWN + + when { + coldLaunchOpenedDeferred.compareAndSet(true, false) -> { + // Cold launch was suppressed because the process started in background. + // First true foreground entry emits the cold-launch-style Opened. + emitOpened(fromBackground = false, version = version, build = build) + } + suppressNextForeground.compareAndSet(true, false) -> { + // Cold-launch path already emitted Opened — suppress this duplicate. + } + else -> { + emitOpened(fromBackground = true, version = version, build = build) + } + } + } + + /** + * Called by the AppLifecycleObserver on `ProcessLifecycleOwner.onStop`. + */ + fun onBackground() { + if (!enabled) return + analytics.track(EVENT_BACKGROUNDED, emptyMap()) + } + + /** + * Buffer deep-link properties for the next `Application Opened` event. + * The buffer is one-shot — cleared after emission. + * + * Hosts should call this from the receiving Activity's `onCreate` / `onNewIntent` + * (typically reading `intent.data` and `intent.getStringExtra(Intent.EXTRA_REFERRER)`). + */ + fun handleDeepLink(uri: Uri, sourceApplication: String?) { + if (!enabled) return + pendingDeepLink.set(DeepLink(uri.toString(), sourceApplication)) + } + + private fun emitInstalled(version: String, build: String) { + analytics.track( + EVENT_INSTALLED, + mapOf( + PROP_VERSION to version, + PROP_BUILD to build + ) + ) + } + + private fun emitUpdated( + version: String, + build: String, + previousVersion: String, + previousBuild: String + ) { + analytics.track( + EVENT_UPDATED, + mapOf( + PROP_VERSION to version, + PROP_BUILD to build, + PROP_PREVIOUS_VERSION to previousVersion, + PROP_PREVIOUS_BUILD to previousBuild + ) + ) + } + + private fun emitOpened(fromBackground: Boolean, version: String, build: String) { + val props = mutableMapOf( + PROP_VERSION to version, + PROP_BUILD to build, + PROP_FROM_BACKGROUND to fromBackground + ) + pendingDeepLink.getAndSet(null)?.let { deepLink -> + props[PROP_URL] = deepLink.url + deepLink.referringApplication?.let { props[PROP_REFERRING_APPLICATION] = it } + } + analytics.track(EVENT_OPENED, props) + } + + private data class DeepLink(val url: String, val referringApplication: String?) + + companion object { + const val EVENT_INSTALLED = "Application Installed" + const val EVENT_UPDATED = "Application Updated" + const val EVENT_OPENED = "Application Opened" + const val EVENT_BACKGROUNDED = "Application Backgrounded" + + const val PROP_VERSION = "version" + const val PROP_BUILD = "build" + const val PROP_PREVIOUS_VERSION = "previous_version" + const val PROP_PREVIOUS_BUILD = "previous_build" + const val PROP_FROM_BACKGROUND = "from_background" + const val PROP_URL = "url" + const val PROP_REFERRING_APPLICATION = "referring_application" + + private const val UNKNOWN = "unknown" + + private fun defaultForegroundCheck(): Boolean { + return try { + ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + } catch (e: Throwable) { + // ProcessLifecycleOwner may not be available in some test contexts. + // Default to false (defer Opened) to avoid spurious double-emits. + false + } + } + } +} diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/IdentityStorage.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/IdentityStorage.kt index eeeb9d2..2b5a508 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/IdentityStorage.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/IdentityStorage.kt @@ -15,6 +15,17 @@ class IdentityStorage(context: Context) { fun get(key: String): String? = prefs.getString(key, null) + /** + * Returns true if any identity-related key is currently set in storage. + * Used to distinguish a fresh install from an upgrade that pre-dates lifecycle tracking. + */ + fun hasAnyValue(): Boolean { + return prefs.contains(KEY_ANONYMOUS_ID) || + prefs.contains(KEY_USER_ID) || + prefs.contains(KEY_GROUP_ID) || + prefs.contains(KEY_ADVERTISING_ID) + } + /** * Store a value asynchronously (non-blocking). * The write is guaranteed to be ordered with other apply() calls and will persist. diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/LifecycleStorage.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/LifecycleStorage.kt new file mode 100644 index 0000000..ced0ac1 --- /dev/null +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/storage/LifecycleStorage.kt @@ -0,0 +1,36 @@ +package com.metarouter.analytics.storage + +import android.content.Context + +/** + * Persistent storage for the last-seen app `version` and `build`. + * + * Lives in a dedicated `SharedPreferences` file so [IdentityStorage.clear] + * (and `MetaRouterAnalyticsClient.reset()`) cannot wipe install/update state. + */ +class LifecycleStorage(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getVersion(): String? = prefs.getString(KEY_VERSION, null) + + fun getBuild(): String? = prefs.getString(KEY_BUILD, null) + + fun setVersionBuild(version: String, build: String) { + prefs.edit() + .putString(KEY_VERSION, version) + .putString(KEY_BUILD, build) + .apply() + } + + fun clear() { + prefs.edit().clear().apply() + } + + companion object { + private const val PREFS_NAME = "com.metarouter.analytics.lifecycle" + + const val KEY_VERSION = "metarouter:lifecycle:version" + const val KEY_BUILD = "metarouter:lifecycle:build" + } +} 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..cc30276 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 handleDeepLink(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..dc6860a 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 true`() { + val options = InitOptions( + writeKey = "test-key", + ingestionHost = "https://example.com" + ) + + assertEquals(true, options.trackLifecycleEvents) + } + + @Test + fun `trackLifecycleEvents explicit false is accepted`() { + val options = InitOptions( + writeKey = "test-key", + ingestionHost = "https://example.com", + trackLifecycleEvents = false + ) + + assertEquals(false, 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/LifecycleEventTrackerTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt new file mode 100644 index 0000000..7b997d3 --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt @@ -0,0 +1,293 @@ +package com.metarouter.analytics.lifecycle + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.metarouter.analytics.AnalyticsInterface +import com.metarouter.analytics.context.DeviceContextProvider +import com.metarouter.analytics.identity.IdentityManager +import com.metarouter.analytics.storage.LifecycleStorage +import com.metarouter.analytics.types.AppContext +import com.metarouter.analytics.types.EventContext +import com.metarouter.analytics.types.LibraryContext +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for the cold-launch / foreground / background emission rules. + * Uses a recording AnalyticsInterface and a real LifecycleStorage backed by Robolectric. + */ +@RunWith(RobolectricTestRunner::class) +class LifecycleEventTrackerTest { + + private data class TrackedEvent(val event: String, val properties: Map?) + + private class RecordingAnalytics : AnalyticsInterface { + val events = mutableListOf() + override fun track(event: String, properties: Map?) { + events.add(TrackedEvent(event, properties)) + } + override fun identify(userId: String, traits: Map?) {} + override fun group(groupId: String, traits: Map?) {} + override fun screen(name: String, properties: Map?) {} + override fun page(name: String, properties: Map?) {} + override fun alias(newUserId: String) {} + override fun setAdvertisingId(advertisingId: String) {} + override fun clearAdvertisingId() {} + override suspend fun flush() {} + override suspend fun reset() {} + override suspend fun getAnonymousId(): String = "anon" + override fun enableDebugLogging() {} + override suspend fun getDebugInfo(): Map = emptyMap() + override fun setTracing(enabled: Boolean) {} + override fun handleDeepLink(uri: Uri, sourceApplication: String?) {} + } + + private lateinit var analytics: RecordingAnalytics + private lateinit var storage: LifecycleStorage + private lateinit var contextProvider: DeviceContextProvider + private lateinit var identityManager: IdentityManager + + private val currentVersion = "1.4.0" + private val currentBuild = "42" + + @Before + fun setup() { + val ctx: Context = ApplicationProvider.getApplicationContext() + analytics = RecordingAnalytics() + storage = LifecycleStorage(ctx) + storage.clear() + contextProvider = mockk() + identityManager = mockk() + + every { contextProvider.getContext() } returns EventContext( + library = LibraryContext("metarouter-android-sdk", "1.0.0"), + app = AppContext( + name = "Test App", + version = currentVersion, + build = currentBuild, + namespace = "com.example.test" + ) + ) + every { identityManager.hasAnyValue() } returns false + } + + private fun newTracker( + enabled: Boolean = true, + foreground: Boolean = true + ): LifecycleEventTracker = LifecycleEventTracker( + analytics = analytics, + storage = storage, + contextProvider = contextProvider, + identityManager = identityManager, + enabled = enabled, + foregroundStateProvider = { foreground } + ) + + // ===== Cold-launch: install / update detection ===== + + @Test + fun `cold launch with no stored version and no identity emits Installed then Opened`() { + every { identityManager.hasAnyValue() } returns false + + newTracker().onSdkReady() + + assertEquals(2, analytics.events.size) + assertEquals("Application Installed", analytics.events[0].event) + assertEquals(currentVersion, analytics.events[0].properties?.get("version")) + assertEquals(currentBuild, analytics.events[0].properties?.get("build")) + + assertEquals("Application Opened", analytics.events[1].event) + assertEquals(false, analytics.events[1].properties?.get("from_background")) + assertEquals(currentVersion, analytics.events[1].properties?.get("version")) + assertEquals(currentBuild, analytics.events[1].properties?.get("build")) + + assertEquals(currentVersion, storage.getVersion()) + assertEquals(currentBuild, storage.getBuild()) + } + + @Test + fun `cold launch with no stored version but identity present emits Updated unknown`() { + every { identityManager.hasAnyValue() } returns true + + newTracker().onSdkReady() + + assertEquals(2, analytics.events.size) + val updated = analytics.events[0] + assertEquals("Application Updated", updated.event) + assertEquals(currentVersion, updated.properties?.get("version")) + assertEquals(currentBuild, updated.properties?.get("build")) + assertEquals("unknown", updated.properties?.get("previous_version")) + assertEquals("unknown", updated.properties?.get("previous_build")) + + assertEquals("Application Opened", analytics.events[1].event) + } + + @Test + fun `cold launch with matching stored version emits only Opened`() { + storage.setVersionBuild(currentVersion, currentBuild) + + newTracker().onSdkReady() + + assertEquals(1, analytics.events.size) + assertEquals("Application Opened", analytics.events[0].event) + assertEquals(false, analytics.events[0].properties?.get("from_background")) + } + + @Test + fun `cold launch with different stored version emits Updated with previous values`() { + storage.setVersionBuild("1.3.2", "37") + + newTracker().onSdkReady() + + assertEquals(2, analytics.events.size) + val updated = analytics.events[0] + assertEquals("Application Updated", updated.event) + assertEquals(currentVersion, updated.properties?.get("version")) + assertEquals(currentBuild, updated.properties?.get("build")) + assertEquals("1.3.2", updated.properties?.get("previous_version")) + assertEquals("37", updated.properties?.get("previous_build")) + } + + @Test + fun `cold launch with same version but different build counts as updated`() { + storage.setVersionBuild(currentVersion, "37") + + newTracker().onSdkReady() + + assertEquals(2, analytics.events.size) + assertEquals("Application Updated", analytics.events[0].event) + assertEquals("37", analytics.events[0].properties?.get("previous_build")) + } + + // ===== Foreground-state guards ===== + + @Test + fun `cold launch in non-foreground state defers Opened until first onForeground`() { + val tracker = newTracker(foreground = false) + tracker.onSdkReady() + + assertEquals(1, analytics.events.size) + assertEquals("Application Installed", analytics.events[0].event) + + tracker.onForeground() + + assertEquals(2, analytics.events.size) + val opened = analytics.events[1] + assertEquals("Application Opened", opened.event) + assertEquals(false, opened.properties?.get("from_background")) + + tracker.onForeground() + assertEquals(3, analytics.events.size) + assertEquals("Application Opened", analytics.events[2].event) + assertEquals(true, analytics.events[2].properties?.get("from_background")) + } + + @Test + fun `first onForeground after cold-launch foreground emit is suppressed`() { + val tracker = newTracker(foreground = true) + tracker.onSdkReady() + assertEquals(2, analytics.events.size) + + tracker.onForeground() + + assertEquals(2, analytics.events.size) + } + + @Test + fun `two background-foreground cycles emit two Opened from_background true`() { + val tracker = newTracker(foreground = true) + tracker.onSdkReady() + tracker.onForeground() // suppressed + assertEquals(2, analytics.events.size) + + tracker.onBackground() + tracker.onForeground() + tracker.onBackground() + tracker.onForeground() + + val opened = analytics.events.filter { it.event == "Application Opened" } + assertEquals(3, opened.size) + assertEquals(false, opened[0].properties?.get("from_background")) + assertEquals(true, opened[1].properties?.get("from_background")) + assertEquals(true, opened[2].properties?.get("from_background")) + } + + // ===== Background ===== + + @Test + fun `onBackground emits Application Backgrounded with empty properties`() { + val tracker = newTracker() + tracker.onSdkReady() + analytics.events.clear() + + tracker.onBackground() + + assertEquals(1, analytics.events.size) + assertEquals("Application Backgrounded", analytics.events[0].event) + assertTrue(analytics.events[0].properties?.isEmpty() ?: true) + } + + // ===== Disabled flag ===== + + @Test + fun `disabled tracker emits no events on any entry point`() { + val tracker = newTracker(enabled = false, foreground = true) + + tracker.onSdkReady() + tracker.onForeground() + tracker.onBackground() + tracker.handleDeepLink(mockUri("https://example.com"), "com.example") + + assertTrue(analytics.events.isEmpty()) + assertNull(storage.getVersion()) + assertNull(storage.getBuild()) + } + + // ===== Deep-link buffer ===== + + @Test + fun `deep link before next Opened is included and buffer cleared after emit`() { + val tracker = newTracker(foreground = true) + + tracker.handleDeepLink(mockUri("https://example.com/x"), "com.referrer.app") + tracker.onSdkReady() + + val opened = analytics.events.first { it.event == "Application Opened" } + assertEquals("https://example.com/x", opened.properties?.get("url")) + assertEquals("com.referrer.app", opened.properties?.get("referring_application")) + + tracker.onForeground() // suppressed (cold-launch consumed) + tracker.onBackground() + tracker.onForeground() + val laterOpened = analytics.events.last { it.event == "Application Opened" } + assertNull(laterOpened.properties?.get("url")) + assertNull(laterOpened.properties?.get("referring_application")) + } + + @Test + fun `deep link without source application omits referring_application`() { + val tracker = newTracker(foreground = true) + + tracker.handleDeepLink(mockUri("https://example.com"), null) + tracker.onSdkReady() + + val opened = analytics.events.first { it.event == "Application Opened" } + assertEquals("https://example.com", opened.properties?.get("url")) + assertFalse(opened.properties?.containsKey("referring_application") ?: false) + } + + private fun mockUri(value: String): Uri { + val uri = mockk() + every { uri.toString() } returns value + return uri + } +} diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt new file mode 100644 index 0000000..45e35a7 --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt @@ -0,0 +1,113 @@ +package com.metarouter.analytics.storage + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LifecycleStorageTest { + + private lateinit var context: Context + private lateinit var storage: LifecycleStorage + private lateinit var identityStorage: IdentityStorage + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + storage = LifecycleStorage(context) + identityStorage = IdentityStorage(context) + storage.clear() + identityStorage.clear() + } + + @After + fun tearDown() { + storage.clear() + identityStorage.clear() + } + + @Test + fun `get returns null when not set`() { + assertNull(storage.getVersion()) + assertNull(storage.getBuild()) + } + + @Test + fun `setVersionBuild persists both fields`() { + storage.setVersionBuild("1.4.0", "42") + + assertEquals("1.4.0", storage.getVersion()) + assertEquals("42", storage.getBuild()) + } + + @Test + fun `setVersionBuild overwrites previous values`() { + storage.setVersionBuild("1.0.0", "1") + storage.setVersionBuild("2.0.0", "2") + + assertEquals("2.0.0", storage.getVersion()) + assertEquals("2", storage.getBuild()) + } + + @Test + fun `data persists across instances`() { + storage.setVersionBuild("1.4.0", "42") + + val storage2 = LifecycleStorage(context) + assertEquals("1.4.0", storage2.getVersion()) + assertEquals("42", storage2.getBuild()) + } + + @Test + fun `lifecycle storage is independent of identity storage clear`() { + storage.setVersionBuild("1.4.0", "42") + identityStorage.set(IdentityStorage.KEY_ANONYMOUS_ID, "anon-id") + identityStorage.set(IdentityStorage.KEY_USER_ID, "user-id") + + // Clearing identity storage must NOT touch lifecycle storage + identityStorage.clear() + + assertNull(identityStorage.get(IdentityStorage.KEY_ANONYMOUS_ID)) + assertNull(identityStorage.get(IdentityStorage.KEY_USER_ID)) + assertEquals("1.4.0", storage.getVersion()) + assertEquals("42", storage.getBuild()) + } + + @Test + fun `keys match cross-platform contract`() { + assertEquals("metarouter:lifecycle:version", LifecycleStorage.KEY_VERSION) + assertEquals("metarouter:lifecycle:build", LifecycleStorage.KEY_BUILD) + } + + @Test + fun `clear removes both fields`() { + storage.setVersionBuild("1.4.0", "42") + + storage.clear() + + assertNull(storage.getVersion()) + assertNull(storage.getBuild()) + } + + @Test + fun `identityStorage hasAnyValue reflects whether keys exist`() { + // Empty initially + assertEquals(false, identityStorage.hasAnyValue()) + + identityStorage.set(IdentityStorage.KEY_ANONYMOUS_ID, "anon") + assertEquals(true, identityStorage.hasAnyValue()) + + identityStorage.clear() + assertEquals(false, identityStorage.hasAnyValue()) + + identityStorage.set(IdentityStorage.KEY_USER_ID, "user") + assertEquals(true, identityStorage.hasAnyValue()) + } +}