diff --git a/README.md b/README.md
index b4ee2d6..5f232b6 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ A lightweight Android analytics SDK that transmits events to your MetaRouter clu
- [Compatibility](#-compatibility)
- [Debugging](#debugging)
- [Identity Persistence](#identity-persistence)
+- [Lifecycle Events](#lifecycle-events)
- [Advertising ID (GAID)](#advertising-id-gaid)
- [Using the alias() Method](#using-the-alias-method)
- [License](#license)
@@ -218,6 +219,7 @@ The analytics client provides the following methods:
- `clearAdvertisingId()`: Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking
- `getAnonymousId(): String` (suspend): Retrieve the current anonymous ID. Suspends until the SDK is initialized and ready, then returns immediately on subsequent calls.
- `setTracing(enabled: Boolean)`: Enable or disable tracing headers on API requests. When enabled, includes a `Trace: true` header for debugging request flows
+- `openURL(uri: Uri, sourceApplication: String? = null)`: Buffer a deep-link URL for the next `Application Opened` event. See [Lifecycle Events](#lifecycle-events) for wiring details
- `flush()`: Flush events immediately (suspending)
- `reset()`: Reset analytics state and clear all stored data (suspending). Also available as fire-and-forget via `MetaRouter.Analytics.reset()`
- `enableDebugLogging()`: Enable debug logging
@@ -575,6 +577,130 @@ All identity data is stored in **Android SharedPreferences** (`com.metarouter.an
- Thread-safe access with built-in synchronization
- Cleared only on app uninstall or explicit `reset()` call
+## Lifecycle Events
+
+Opt-in automatic emission of four canonical application-lifecycle events: `Application Installed`, `Application Updated`, `Application Opened`, and `Application Backgrounded`. These anchor attribution, retention, and session reporting on top of the standard event pipeline.
+
+### Enabling
+
+Lifecycle events are **opt-in**. Set `trackLifecycleEvents = true` on `InitOptions` to enable:
+
+```kotlin
+val analytics = MetaRouter.Analytics.initialize(
+ context = applicationContext,
+ options = InitOptions(
+ writeKey = "your-write-key",
+ ingestionHost = "https://your-ingestion-endpoint.com",
+ trackLifecycleEvents = true,
+ )
+)
+```
+
+When the flag is `false` (the default), the SDK never emits these events and `openURL(...)` is a no-op that logs a warning. Existing customers upgrading the SDK do **not** start emitting lifecycle events without explicitly setting the flag.
+
+### Events
+
+| Event | When it fires | Properties |
+|---|---|---|
+| `Application Installed` | First launch after a truly fresh install (no prior identity, no prior lifecycle storage) | `version`, `build` |
+| `Application Updated` | First launch after a `(version, build)` change OR first launch after upgrading from a pre-lifecycle SDK build (existing identity, no lifecycle storage) | `version`, `build`, `previous_version`, `previous_build` (set to `"unknown"` for the SDK-upgrade case) |
+| `Application Opened` | Cold launch (foreground at SDK init) and on every `ProcessLifecycleOwner.ON_START` resume | `version`, `build`, `from_background` (false on cold launch, true on resume), optional `url` and `referring_application` from `openURL(...)` |
+| `Application Backgrounded` | `ProcessLifecycleOwner.ON_STOP`. Emitted **before** the dispatcher's flush-to-disk so the event ships in the same drain | (none) |
+
+### Cold-launch sequencing
+
+On cold launch, install/update events fire **before** `Application Opened` so attribution pipelines see the install/update before the session start.
+
+For background-launched processes (silent push, `JobScheduler`, `WorkManager`, broadcast receiver, content provider) where `ProcessLifecycleOwner.lifecycle.currentState` is below `STARTED` at SDK init, the cold-launch `Application Opened` is suppressed. The first subsequent `ON_START` transition emits the bridge event with `from_background = false` so analytics still see one Opened per app session — just at the moment the user actually engages.
+
+### Persistence
+
+`(version, build)` are persisted to SharedPreferences under `com.metarouter.analytics.lifecycle`, a separate file from identity prefs. `reset()` clears identity but **not** lifecycle storage — install/update is device-scope, not user-scope. Re-launching after `reset()` with the same `(version, build)` emits only `Application Opened`.
+
+### Deep links
+
+The SDK does not auto-instrument deep links. Hosts forward URLs explicitly via `analytics.openURL(uri, sourceApplication)`. The next `Application Opened` event the SDK emits will carry `url` and (if provided) `referring_application` properties. The buffer is **one-shot** (cleared on emit) and **last-write-wins** (multiple calls before the next Opened keep only the most recent URL).
+
+#### Activity entry point
+
+```kotlin
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.metarouter.analytics.MetaRouter
+
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ forwardDeepLink(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ // singleTop / singleTask resumed via a fresh intent
+ forwardDeepLink(intent)
+ }
+
+ private fun forwardDeepLink(intent: Intent?) {
+ val uri = intent?.data ?: return
+ // `Activity.referrer` is the canonical Android API for the calling app's host.
+ // (Don't use `Intent.EXTRA_REFERRER` with `getStringExtra` — it's documented as
+ // a `Uri`, so the String overload returns null.)
+ val source = referrer?.host
+ MetaRouter.Analytics.client().openURL(uri, source)
+ }
+}
+```
+
+#### App Links (verified deep links)
+
+Wire up your manifest as usual; nothing changes for the SDK side:
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+### Privacy
+
+Deep-link URLs frequently carry sensitive material — auth tokens, OTPs, magic-link secrets, query parameters with PII. The SDK forwards whatever URI you hand it and does not sanitize. Strip or redact secrets in the host before calling `openURL(...)`:
+
+```kotlin
+import android.net.Uri
+
+private fun sanitize(uri: Uri): Uri {
+ val sensitiveParams = setOf("token", "otp", "code", "auth", "secret")
+ val builder = uri.buildUpon().clearQuery()
+ uri.queryParameterNames
+ .filterNot { it.lowercase() in sensitiveParams }
+ .forEach { name ->
+ uri.getQueryParameters(name).forEach { value ->
+ builder.appendQueryParameter(name, value)
+ }
+ }
+ return builder.build()
+}
+
+intent.data?.let { uri ->
+ MetaRouter.Analytics.client().openURL(sanitize(uri), referrer?.host)
+}
+```
+
+### Why no auto-instrumentation?
+
+Several reasons:
+
+- **No manifest mutation.** A library that rewrites your manifest's intent filters fights with build pipelines, app-bundle splits, and dynamic feature modules.
+- **No `ActivityLifecycleCallbacks` proxy.** Auto-forwarding every Activity's `onCreate`/`onNewIntent` would force the SDK to interpret URLs that aren't deep links (e.g., internal navigation routed via `Intent`), and it would fight any lifecycle-callbacks instrumentation the host already runs.
+- **Privacy footgun.** Auto-forwarded URLs would ship sensitive material without the host having a chance to sanitize.
+- **Host control.** You already know which entry points are deep-link entry points. Forwarding from those specific Activities is a one-liner; auto-instrumentation would be lossy and surprising.
+
## Using the alias() Method
The `alias()` method connects an **anonymous user** (tracked by `anonymousId`) to a **known user ID**. It's used to link pre-login activity to post-login identity.
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..7c1177e 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,29 @@ 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).
+ *
+ * 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/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/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/main/java/com/metarouter/analytics/lifecycle/LifecycleEventNames.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventNames.kt
new file mode 100644
index 0000000..db65a64
--- /dev/null
+++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventNames.kt
@@ -0,0 +1,28 @@
+package com.metarouter.analytics.lifecycle
+
+/**
+ * Centralized event-name constants for the four application-lifecycle events.
+ * Single source of truth shared by the tracker (emission) and any future
+ * coordinator / observer wiring.
+ */
+internal object LifecycleEventNames {
+ const val INSTALLED = "Application Installed"
+ const val UPDATED = "Application Updated"
+ const val OPENED = "Application Opened"
+ const val BACKGROUNDED = "Application Backgrounded"
+}
+
+/**
+ * Centralized property-name constants for lifecycle event payloads.
+ */
+internal object LifecycleEventProperties {
+ const val VERSION = "version"
+ const val BUILD = "build"
+ const val PREVIOUS_VERSION = "previous_version"
+ const val PREVIOUS_BUILD = "previous_build"
+ const val FROM_BACKGROUND = "from_background"
+ const val URL = "url"
+ const val REFERRING_APPLICATION = "referring_application"
+}
+
+internal const val LIFECYCLE_UNKNOWN = "unknown"
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..e4a7959
--- /dev/null
+++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt
@@ -0,0 +1,210 @@
+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.identity.IdentityManager
+import com.metarouter.analytics.storage.LifecycleStorage
+import com.metarouter.analytics.types.AppContext
+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`.
+ *
+ * The tracker has no on/off switch — when the feature is disabled, the host
+ * (currently `LifecycleCoordinator`) never constructs an instance.
+ */
+internal class LifecycleEventTracker(
+ private val analytics: AnalyticsInterface,
+ private val storage: LifecycleStorage,
+ private val appContext: AppContext,
+ private val identityManager: IdentityManager,
+ 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)
+
+ /**
+ * Idempotency guard for [onSdkReady] — re-running the cold-launch sequence on a
+ * second call would re-emit Installed/Updated/Opened and stomp `suppressNextForeground`,
+ * eating the next legitimate foreground transition.
+ */
+ private val coldLaunchRan = AtomicBoolean(false)
+
+ /**
+ * Run the cold-launch detection + emission sequence. Idempotent — only the first call
+ * runs the sequence; subsequent calls are no-ops.
+ *
+ * 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 (!coldLaunchRan.compareAndSet(false, true)) return
+
+ val currentVersion = appContext.version
+ val currentBuild = appContext.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, LIFECYCLE_UNKNOWN, LIFECYCLE_UNKNOWN)
+ } else {
+ emitInstalled(currentVersion, currentBuild)
+ }
+ }
+ storedVersion != currentVersion || storedBuild != currentBuild -> {
+ emitUpdated(
+ currentVersion,
+ currentBuild,
+ storedVersion ?: LIFECYCLE_UNKNOWN,
+ storedBuild ?: LIFECYCLE_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() {
+ val version = appContext.version
+ val build = appContext.build
+
+ 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() {
+ analytics.track(LifecycleEventNames.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` for the URI and `Activity.referrer?.host` for the
+ * referrer — `Intent.EXTRA_REFERRER` is documented as a `Uri`, not a String, and
+ * `getStringExtra` on it is virtually always `null`).
+ */
+ fun openURL(uri: Uri, sourceApplication: String?) {
+ pendingDeepLink.set(DeepLink(uri.toString(), sourceApplication))
+ }
+
+ private fun emitInstalled(version: String, build: String) {
+ analytics.track(
+ LifecycleEventNames.INSTALLED,
+ mapOf(
+ LifecycleEventProperties.VERSION to version,
+ LifecycleEventProperties.BUILD to build
+ )
+ )
+ }
+
+ private fun emitUpdated(
+ version: String,
+ build: String,
+ previousVersion: String,
+ previousBuild: String
+ ) {
+ analytics.track(
+ LifecycleEventNames.UPDATED,
+ mapOf(
+ LifecycleEventProperties.VERSION to version,
+ LifecycleEventProperties.BUILD to build,
+ LifecycleEventProperties.PREVIOUS_VERSION to previousVersion,
+ LifecycleEventProperties.PREVIOUS_BUILD to previousBuild
+ )
+ )
+ }
+
+ private fun emitOpened(fromBackground: Boolean, version: String, build: String) {
+ val props = mutableMapOf(
+ LifecycleEventProperties.VERSION to version,
+ LifecycleEventProperties.BUILD to build,
+ LifecycleEventProperties.FROM_BACKGROUND to fromBackground
+ )
+ pendingDeepLink.getAndSet(null)?.let { deepLink ->
+ props[LifecycleEventProperties.URL] = deepLink.url
+ deepLink.referringApplication?.let {
+ props[LifecycleEventProperties.REFERRING_APPLICATION] = it
+ }
+ }
+ analytics.track(LifecycleEventNames.OPENED, props)
+ }
+
+ private data class DeepLink(val url: String, val referringApplication: String?)
+
+ companion object {
+ private fun defaultForegroundCheck(): Boolean {
+ return try {
+ ProcessLifecycleOwner.get().lifecycle.currentState
+ .isAtLeast(Lifecycle.State.STARTED)
+ } catch (_: IllegalStateException) {
+ // ProcessLifecycleOwner.get() throws ISE off the main thread.
+ // Default to false (defer Opened) to avoid spurious double-emits.
+ false
+ } catch (_: NoClassDefFoundError) {
+ // androidx.lifecycle:lifecycle-process not on the runtime classpath
+ // (rare in production, can happen in test contexts that strip optional deps).
+ 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/main/java/com/metarouter/analytics/types/EventContext.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/types/EventContext.kt
index 80f83e3..32dae9b 100644
--- a/metarouter-sdk/src/main/java/com/metarouter/analytics/types/EventContext.kt
+++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/types/EventContext.kt
@@ -1,5 +1,9 @@
package com.metarouter.analytics.types
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import com.metarouter.analytics.utils.Logger
import kotlinx.serialization.Serializable
/**
@@ -27,7 +31,70 @@ data class AppContext(
val version: String,
val build: String,
val namespace: String
-)
+) {
+ companion object {
+ private const val UNKNOWN = "unknown"
+
+ /**
+ * Read app metadata from `PackageManager` once. Safe to cache for the
+ * process lifetime — the manifest values are immutable post-install.
+ *
+ * Both the lifecycle subsystem and `DeviceContextProvider` consume this
+ * cached snapshot so per-event enrichment does not re-read the manifest.
+ */
+ fun fromContext(context: Context): 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 read AppContext from PackageManager: ${e.message}")
+ // Derive a best-effort `name` from the package's last segment when the
+ // PackageManager read fails entirely. Better than literal "unknown" on
+ // ingest dashboards and matches what most launchers show when the
+ // applicationLabel resolution itself fails.
+ val packageName = context.packageName
+ val derivedName = packageName.substringAfterLast('.')
+ .takeIf { it.isNotBlank() }
+ ?: UNKNOWN
+ AppContext(
+ name = derivedName,
+ version = UNKNOWN,
+ build = UNKNOWN,
+ namespace = packageName
+ )
+ }
+ }
+ }
+}
/**
* Device information including manufacturer, model, device name, and type.
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
new file mode 100644
index 0000000..54a5555
--- /dev/null
+++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt
@@ -0,0 +1,314 @@
+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.identity.IdentityManager
+import com.metarouter.analytics.storage.LifecycleStorage
+import com.metarouter.analytics.types.AppContext
+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.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 openURL(uri: Uri, sourceApplication: String?) {}
+ }
+
+ private lateinit var analytics: RecordingAnalytics
+ private lateinit var storage: LifecycleStorage
+ private lateinit var identityManager: IdentityManager
+
+ private val currentVersion = "1.4.0"
+ private val currentBuild = "42"
+ private val appContext = AppContext(
+ name = "Test App",
+ version = currentVersion,
+ build = currentBuild,
+ namespace = "com.example.test"
+ )
+
+ @Before
+ fun setup() {
+ val ctx: Context = ApplicationProvider.getApplicationContext()
+ analytics = RecordingAnalytics()
+ storage = LifecycleStorage(ctx)
+ storage.clear()
+ identityManager = mockk()
+ every { identityManager.hasAnyValue() } returns false
+ }
+
+ private fun newTracker(
+ foreground: Boolean = true
+ ): LifecycleEventTracker = LifecycleEventTracker(
+ analytics = analytics,
+ storage = storage,
+ appContext = appContext,
+ identityManager = identityManager,
+ 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)
+ assertEquals(0, analytics.events[0].properties?.size ?: 0)
+ }
+
+ // ===== Deep-link buffer =====
+
+ @Test
+ fun `deep link before next Opened is included and buffer cleared after emit`() {
+ val tracker = newTracker(foreground = true)
+
+ tracker.openURL(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.openURL(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)
+ }
+
+ @Test
+ fun `multiple openURL calls before Opened keep only the last URL`() {
+ val tracker = newTracker(foreground = true)
+
+ tracker.openURL(mockUri("https://example.com/first"), "com.first")
+ tracker.openURL(mockUri("https://example.com/second"), "com.second")
+ tracker.onSdkReady()
+
+ val opened = analytics.events.first { it.event == "Application Opened" }
+ assertEquals("https://example.com/second", opened.properties?.get("url"))
+ assertEquals("com.second", opened.properties?.get("referring_application"))
+ }
+
+ // ===== Idempotency / ordering invariants =====
+
+ @Test
+ fun `onSdkReady is idempotent`() {
+ val tracker = newTracker(foreground = true)
+ tracker.onSdkReady()
+ val firstCallCount = analytics.events.size
+
+ tracker.onSdkReady()
+
+ assertEquals(
+ "second onSdkReady should be a no-op",
+ firstCallCount,
+ analytics.events.size
+ )
+ // suppressNextForeground armed by the first call should still consume the
+ // imminent observer-driven onForeground — verify we don't double-emit.
+ tracker.onForeground()
+ assertEquals(firstCallCount, analytics.events.size)
+ }
+
+ @Test
+ fun `onForeground before onSdkReady emits resume-style Opened (documents wiring contract)`() {
+ // The host (LifecycleCoordinator in slice 3) is contractually required to
+ // invoke onSdkReady before any onForeground. This test documents what happens
+ // if that invariant breaks — wire-valid Opened with from_background=true, but
+ // missing the install/update detection that should run first.
+ val tracker = newTracker(foreground = true)
+
+ tracker.onForeground()
+
+ assertEquals(1, analytics.events.size)
+ assertEquals("Application Opened", analytics.events[0].event)
+ assertEquals(true, analytics.events[0].properties?.get("from_background"))
+ }
+
+ 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())
+ }
+}
diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt
new file mode 100644
index 0000000..a1e6b3b
--- /dev/null
+++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt
@@ -0,0 +1,57 @@
+package com.metarouter.analytics.types
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Smoke coverage for the `AppContext.fromContext` factory. The full `DeviceContextProvider`
+ * test exercises this transitively, but this slice introduces the factory standalone and
+ * deserves direct coverage.
+ */
+@RunWith(RobolectricTestRunner::class)
+class AppContextTest {
+
+ @Test
+ fun `fromContext populates namespace from package`() {
+ val ctx: Context = ApplicationProvider.getApplicationContext()
+ val app = AppContext.fromContext(ctx)
+
+ assertEquals(ctx.packageName, app.namespace)
+ }
+
+ @Test
+ fun `fromContext returns non-blank fields`() {
+ val ctx: Context = ApplicationProvider.getApplicationContext()
+ val app = AppContext.fromContext(ctx)
+
+ assertTrue("name should not be blank", app.name.isNotBlank())
+ assertTrue("version should not be blank", app.version.isNotBlank())
+ assertTrue("build should not be blank", app.build.isNotBlank())
+ assertTrue("namespace should not be blank", app.namespace.isNotBlank())
+ }
+
+ @Test
+ fun `fromContext is stable across calls`() {
+ val ctx: Context = ApplicationProvider.getApplicationContext()
+ val first = AppContext.fromContext(ctx)
+ val second = AppContext.fromContext(ctx)
+
+ assertEquals(first, second)
+ }
+
+ @Test
+ fun `equality is structural`() {
+ val a = AppContext("App", "1.0", "100", "com.example")
+ val b = AppContext("App", "1.0", "100", "com.example")
+ val c = AppContext("App", "1.1", "100", "com.example")
+
+ assertEquals(a, b)
+ assertNotEquals(a, c)
+ }
+}