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) + } +}