From 425de6ba5f61b863f869aa9bac49942a7b175841 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 13:35:37 -0600 Subject: [PATCH 1/8] feat: lifecycle storage + bundle metadata foundation Foundational types for the application-lifecycle events feature. Pure additions; no behavior change to MetaRouterAnalyticsClient yet. - LifecycleStorage: SharedPreferences wrapper for (version, build) under com.metarouter.analytics.lifecycle, separate from identity prefs so reset() does not wipe install/update state - IdentityStorage.hasAnyValue(): identifies users that pre-date the lifecycle SDK (existing identity, no lifecycle keys) so the tracker can emit Application Updated instead of Application Installed - IdentityManager.hasAnyValue(): forwarder - AppContext.fromContext(): single-source-of-truth factory that reads PackageManager once. The lifecycle subsystem and DeviceContextProvider will share the cached snapshot so per-event enrichment never re-reads the manifest - LifecycleEventNames / LifecycleEventProperties: extracted constants used by the tracker and (later) the coordinator Refs: sc-38233 --- .../analytics/identity/IdentityManager.kt | 7 ++ .../lifecycle/LifecycleEventNames.kt | 28 +++++ .../analytics/storage/IdentityStorage.kt | 11 ++ .../analytics/storage/LifecycleStorage.kt | 36 ++++++ .../analytics/types/EventContext.kt | 61 +++++++++- .../analytics/storage/LifecycleStorageTest.kt | 113 ++++++++++++++++++ 6 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventNames.kt create mode 100644 metarouter-sdk/src/main/java/com/metarouter/analytics/storage/LifecycleStorage.kt create mode 100644 metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt 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/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/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..73be84f 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,62 @@ 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}") + AppContext( + name = UNKNOWN, + version = UNKNOWN, + build = UNKNOWN, + namespace = context.packageName + ) + } + } + } +} /** * Device information including manufacturer, model, device name, and type. 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()) + } +} From d244b06192aa117494809fc63400c10f1bf6785c Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:50:59 -0600 Subject: [PATCH 2/8] fix: AppContext fallback derives name from package + smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small follow-ups from code review on slice 1. - AppContext.fromContext: when the outer PackageManager read fails entirely, derive a best-effort `name` from the last segment of the package id rather than returning literal 'unknown'. Better attribution on ingest dashboards in the rare case the read throws (corrupted install / instrumentation contexts) and matches what most launchers show when applicationLabel resolution fails. - AppContextTest: direct Robolectric coverage for the factory. Slice 1 introduces fromContext standalone — it had no direct tests until now, only transitive coverage via DeviceContextProvider. Refs: sc-38233 --- .../analytics/types/EventContext.kt | 12 +++- .../analytics/types/AppContextTest.kt | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt 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 73be84f..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 @@ -77,11 +77,19 @@ data class AppContext( ) } 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 = UNKNOWN, + name = derivedName, version = UNKNOWN, build = UNKNOWN, - namespace = context.packageName + namespace = packageName ) } } 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) + } +} From 2f83fe3d1ad7416648e96de50ba7fce82c1c77f1 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 13:43:33 -0600 Subject: [PATCH 3/8] feat: LifecycleEventTracker algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle event tracker — install/update detection, cold-launch sequencing, foreground/background transitions, and the one-shot deep-link buffer. Standalone class; not yet wired into MetaRouterAnalyticsClient (slice 3 does the wiring). Cold-launch decision tree: - no persisted state, no identity → Application Installed - no persisted state, identity present → Application Updated with previous_version/previous_build = 'unknown' (SDK upgrade) - persisted (version, build) matches → no install/update event - persisted (version, build) differs → Application Updated with prior values Cold-launch Opened ordering depends on the foreground-state probe: - foreground at SDK init → emit Opened immediately and suppress the imminent ProcessLifecycleOwner.onStart so we don't double-emit - background at SDK init (silent push, JobScheduler, WorkManager) → defer Opened to the first true ON_START transition, which then emits with from_background=false as the cold-launch bridge Deep-link buffer is one-shot and last-write-wins. Multiple openURL calls before the next Opened keep only the most recent URL; the buffer clears once attached to an Opened payload. The tracker has no on/off switch — when the feature is disabled the host (LifecycleCoordinator, slice 3) simply never constructs an instance. AppContext is injected (the cached snapshot from slice 1) so the tracker never reads PackageManager itself. Refs: sc-38234 --- .../lifecycle/LifecycleEventTracker.kt | 194 ++++++++++++ .../lifecycle/LifecycleEventTrackerTest.kt | 277 ++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt create mode 100644 metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt 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..28c119d --- /dev/null +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt @@ -0,0 +1,194 @@ +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) + + /** + * Run the cold-launch detection + emission sequence. + * + * 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() { + 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` and `intent.getStringExtra(Intent.EXTRA_REFERRER)`). + */ + 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 (e: Throwable) { + // ProcessLifecycleOwner may not be available in some test contexts. + // Default to false (defer Opened) to avoid spurious double-emits. + false + } + } + } +} diff --git a/metarouter-sdk/src/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..470019e --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt @@ -0,0 +1,277 @@ +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) {} + } + + 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")) + } + + private fun mockUri(value: String): Uri { + val uri = mockk() + every { uri.toString() } returns value + return uri + } +} From f30267aa22a199a3b40e0b7f8c08886630965eae Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:54:01 -0600 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20tracker=20robustness=20=E2=80=94=20n?= =?UTF-8?q?arrow=20catch,=20idempotent=20onSdkReady,=20KDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from code review on slice 2. - defaultForegroundCheck: narrow the catch from Throwable to IllegalStateException + NoClassDefFoundError. The wide catch swallowed OOM / StackOverflowError silently. ProcessLifecycleOwner.get() throws ISE off the main thread; the lifecycle-process artifact may be stripped in test setups, hence NoClassDefFoundError. Anything else surfaces as a real bug. - onSdkReady: AtomicBoolean idempotency guard. If the host accidentally invokes the cold-launch sequence twice (future re-init path, test misuse) we used to re-emit Installed/Updated/Opened and stomp suppressNextForeground, which would eat the next legitimate foreground transition. - KDoc: openURL / handleDeepLink reference said EXTRA_REFERRER goes through getStringExtra. Per Android docs EXTRA_REFERRER is a Uri, not a String — getStringExtra on it is essentially always null. Point hosts at Activity.referrer?.host instead. - Tests cover the new idempotency guard and the onForeground-before- onSdkReady contract (documenting what happens if the host wiring invariant breaks). Refs: sc-38234 --- .../lifecycle/LifecycleEventTracker.kt | 24 ++++++++++--- .../lifecycle/LifecycleEventTrackerTest.kt | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) 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 index 28c119d..e4a7959 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt @@ -43,7 +43,15 @@ internal class LifecycleEventTracker( private val pendingDeepLink = AtomicReference(null) /** - * Run the cold-launch detection + emission sequence. + * 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). @@ -51,6 +59,8 @@ internal class LifecycleEventTracker( * otherwise defer it to the next foreground transition. */ fun onSdkReady() { + if (!coldLaunchRan.compareAndSet(false, true)) return + val currentVersion = appContext.version val currentBuild = appContext.build @@ -129,7 +139,9 @@ internal class LifecycleEventTracker( * The buffer is one-shot — cleared after emission. * * Hosts should call this from the receiving Activity's `onCreate` / `onNewIntent` - * (typically reading `intent.data` and `intent.getStringExtra(Intent.EXTRA_REFERRER)`). + * (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)) @@ -184,10 +196,14 @@ internal class LifecycleEventTracker( return try { ProcessLifecycleOwner.get().lifecycle.currentState .isAtLeast(Lifecycle.State.STARTED) - } catch (e: Throwable) { - // ProcessLifecycleOwner may not be available in some test contexts. + } 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/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt index 470019e..c78b952 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt @@ -269,6 +269,42 @@ class LifecycleEventTrackerTest { 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 From 045a88e93643f7b568c571c3fe9797f391fc32d4 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 14:04:07 -0600 Subject: [PATCH 5/8] feat: wire LifecycleCoordinator + openURL public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end integration of the lifecycle subsystem. After this slice the feature is fully functional behind `InitOptions.trackLifecycleEvents` — default stays false so existing customers are not affected on upgrade. Coordinator seam - LifecycleCoordinator wraps LifecycleEventTracker. MetaRouterAnalytics- Client no longer references the tracker directly; future session / attribution work has a clear place to land alongside lifecycle events. - Constructed only when trackLifecycleEvents=true. When the feature is off, lifecycleCoordinator is null and every dispatch site is a null-safe no-op. AppContext caching - AnalyticsClient reads AppContext.fromContext() exactly once at init and injects the same snapshot into both DeviceContextProvider and LifecycleEventTracker. The per-event enrichment path no longer hits PackageManager. Public API - handleDeepLink renamed to openURL across AnalyticsInterface, AnalyticsProxy, PendingCall, and the proxied call replay. Name matches Segment's iOS SDK and avoids the over-promise of 'handle' (the SDK does not route or parse the URL — it buffers it for the next Application Opened). - openURL on the client logs Logger.warn and no-ops when the feature is disabled. Silent no-op was bad DX; misconfiguration is now diagnosable from logcat. Opt-in default - InitOptions.trackLifecycleEvents flips from true to false. KDoc updated. InitOptionsTest renamed to assert the new default. Lifecycle ordering - onBackground emits Application Backgrounded BEFORE flush / flushToDisk so the event lands in the same drain (unchanged behavior; comment kept next to the code). Refs: sc-38235 --- .../analytics/AnalyticsInterface.kt | 26 ++++++ .../metarouter/analytics/AnalyticsProxy.kt | 11 +++ .../com/metarouter/analytics/InitOptions.kt | 8 +- .../analytics/MetaRouterAnalyticsClient.kt | 77 ++++++++++++++-- .../com/metarouter/analytics/PendingCall.kt | 3 + .../context/DeviceContextProvider.kt | 58 ++----------- .../lifecycle/LifecycleCoordinator.kt | 31 +++++++ .../analytics/AnalyticsExtensionsTest.kt | 2 + .../metarouter/analytics/InitOptionsTest.kt | 23 +++++ .../MetaRouterAnalyticsClientTest.kt | 87 ++++++++++++++++++- .../lifecycle/LifecycleCoordinatorTest.kt | 58 +++++++++++++ .../lifecycle/LifecycleEventTrackerTest.kt | 1 + 12 files changed, 326 insertions(+), 59 deletions(-) create mode 100644 metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt create mode 100644 metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt index 691beb5..dba1593 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -1,5 +1,7 @@ package com.metarouter.analytics +import android.net.Uri + /** * Public API for the MetaRouter Analytics SDK. * @@ -160,4 +162,28 @@ interface AnalyticsInterface { * @param enabled Whether to enable tracing */ fun setTracing(enabled: Boolean) + + /** + * Buffer a deep-link URL for the next `Application Opened` event. + * + * Call this from the receiving Activity's `onCreate` / `onNewIntent`, typically + * passing `intent.data` and the referrer host (e.g. read via + * `intent.getStringExtra(Intent.EXTRA_REFERRER)` or `Activity.referrer?.host`). + * + * The next `Application Opened` event the SDK emits will carry `url` and + * (when provided) `referring_application` properties. The buffer is one-shot — + * subsequent `Application Opened` events without a fresh call omit the props. + * If called multiple times before the next `Application Opened`, the most recent + * URL wins (last-write-wins; no queue). + * + * Logs a warning and is a no-op when `InitOptions.trackLifecycleEvents` is `false`. + * + * The method is named after the *signal* the host is forwarding, not what the SDK + * does with it — the SDK does not route, parse, or open the URL. Mirrors Segment's + * iOS API for cross-platform consistency. + * + * @param uri The deep-link URI that opened the app + * @param sourceApplication Optional referring application identifier + */ + fun openURL(uri: Uri, sourceApplication: String? = null) } diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt index 7f0c215..fa8b56c 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsProxy.kt @@ -1,5 +1,6 @@ package com.metarouter.analytics +import android.net.Uri import com.metarouter.analytics.utils.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Mutex @@ -202,6 +203,15 @@ class AnalyticsProxy( } } + override fun openURL(uri: Uri, sourceApplication: String?) { + val client = realClient.get() + if (client != null) { + client.openURL(uri, sourceApplication) + } else { + enqueue(PendingCall.OpenURL(uri, sourceApplication)) + } + } + /** * Enqueue a pending call, dropping oldest if at capacity. @@ -230,6 +240,7 @@ class AnalyticsProxy( is PendingCall.SetTracing -> client.setTracing(call.enabled) is PendingCall.SetAdvertisingId -> client.setAdvertisingId(call.advertisingId) is PendingCall.ClearAdvertisingId -> client.clearAdvertisingId() + is PendingCall.OpenURL -> client.openURL(call.uri, call.sourceApplication) is PendingCall.Flush -> client.flush() is PendingCall.Reset -> client.reset() is PendingCall.EnableDebugLogging -> client.enableDebugLogging() diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt index 3d54ce3..ef19ff0 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/InitOptions.kt @@ -16,6 +16,11 @@ import com.metarouter.analytics.utils.Logger * (default: 10000). Set to `0` to opt out of disk persistence entirely — the queue then * operates as a purely in-memory ring buffer, dropping the oldest event when full. * Negative values are rejected. + * @property trackLifecycleEvents Whether the SDK should automatically emit + * `Application Installed`, `Application Updated`, `Application Opened`, and + * `Application Backgrounded` events (default: `false` — opt-in). Set to `true` + * to enable. Existing customers upgrading the SDK do not begin emitting + * lifecycle events without explicitly enabling the flag. * * @throws IllegalArgumentException if validation fails */ @@ -25,7 +30,8 @@ data class InitOptions( val flushIntervalSeconds: Int = 10, val debug: Boolean = false, val maxQueueEvents: Int = 2000, - val maxDiskEvents: Int = 10000 + val maxDiskEvents: Int = 10000, + val trackLifecycleEvents: Boolean = false ) { init { validateWriteKey() diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt index 8be3ee7..f2ec5a4 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -4,11 +4,14 @@ import android.app.Application import android.content.ComponentCallbacks2 import android.content.Context import android.content.res.Configuration +import android.net.Uri import com.metarouter.analytics.context.DeviceContextProvider import com.metarouter.analytics.dispatcher.Dispatcher import com.metarouter.analytics.dispatcher.DispatcherConfig import com.metarouter.analytics.enrichment.EventEnrichmentService import com.metarouter.analytics.identity.IdentityManager +import com.metarouter.analytics.lifecycle.LifecycleCoordinator +import com.metarouter.analytics.lifecycle.LifecycleEventTracker import com.metarouter.analytics.network.AndroidNetworkMonitor import com.metarouter.analytics.network.CircuitBreaker import com.metarouter.analytics.network.DebouncedNetworkMonitor @@ -18,6 +21,8 @@ import com.metarouter.analytics.network.OkHttpNetworkClient import com.metarouter.analytics.queue.EventQueueInterface import com.metarouter.analytics.queue.PersistableEventQueue import com.metarouter.analytics.storage.EventDiskStore +import com.metarouter.analytics.storage.LifecycleStorage +import com.metarouter.analytics.types.AppContext import com.metarouter.analytics.types.BaseEvent import com.metarouter.analytics.types.EventType import com.metarouter.analytics.types.LifecycleState @@ -43,7 +48,10 @@ class MetaRouterAnalyticsClient private constructor( private val injectedCircuitBreaker: CircuitBreaker? = null, private val injectedDispatcher: Dispatcher? = null, private val injectedDiskStore: EventDiskStore? = null, - private val injectedNetworkMonitor: NetworkMonitor? = null + private val injectedNetworkMonitor: NetworkMonitor? = null, + private val injectedLifecycleStorage: LifecycleStorage? = null, + private val injectedLifecycleCoordinator: LifecycleCoordinator? = null, + private val injectedAppContext: AppContext? = null ) : AnalyticsInterface { companion object { @@ -75,7 +83,10 @@ class MetaRouterAnalyticsClient private constructor( circuitBreaker: CircuitBreaker? = null, dispatcher: Dispatcher? = null, diskStore: EventDiskStore? = null, - networkMonitor: NetworkMonitor? = null + networkMonitor: NetworkMonitor? = null, + lifecycleStorage: LifecycleStorage? = null, + lifecycleCoordinator: LifecycleCoordinator? = null, + appContext: AppContext? = null ): MetaRouterAnalyticsClient { val client = MetaRouterAnalyticsClient( context.applicationContext, @@ -88,7 +99,10 @@ class MetaRouterAnalyticsClient private constructor( circuitBreaker, dispatcher, diskStore, - networkMonitor + networkMonitor, + lifecycleStorage, + lifecycleCoordinator, + appContext ) client.initializeInternal() return client @@ -119,6 +133,9 @@ class MetaRouterAnalyticsClient private constructor( private var persistableEventQueue: PersistableEventQueue? = null private var componentCallbacks: ComponentCallbacks2? = null + // Lifecycle coordinator (null when InitOptions.trackLifecycleEvents is false) + private var lifecycleCoordinator: LifecycleCoordinator? = null + /** * Internal initialization. Sets up all components. */ @@ -138,9 +155,15 @@ class MetaRouterAnalyticsClient private constructor( val channelCapacity = options.maxQueueEvents eventChannel = Channel(capacity = channelCapacity) + // App metadata: read PackageManager once and share the snapshot across + // every consumer (DeviceContextProvider for per-event enrichment, the + // lifecycle tracker for install/update detection). Bundle/PackageInfo is + // OS-loaded and immutable post-process-start, so caching is safe. + val appContext = injectedAppContext ?: AppContext.fromContext(context) + // Initialize components (use injected or create new) identityManager = injectedIdentityManager ?: IdentityManager(context) - contextProvider = injectedContextProvider ?: DeviceContextProvider(context) + contextProvider = injectedContextProvider ?: DeviceContextProvider(context, appContext) enrichmentService = injectedEnrichmentService ?: EventEnrichmentService( identityManager = identityManager, contextProvider = contextProvider, @@ -230,9 +253,29 @@ class MetaRouterAnalyticsClient private constructor( componentCallbacks = callbacks } + // Build the lifecycle coordinator before flipping to READY so onReady() can run + // immediately after the state transition. The tracker emits via track(), which + // requires the lifecycle state to be READY. + lifecycleCoordinator = injectedLifecycleCoordinator ?: if (options.trackLifecycleEvents) { + val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) + val tracker = LifecycleEventTracker( + analytics = this, + storage = lifecycleStorage, + appContext = appContext, + identityManager = identityManager + ) + LifecycleCoordinator(tracker) + } else { + null + } + lifecycleState.set(LifecycleState.READY) Logger.log("MetaRouter SDK initialized") + // Run cold-launch detection + emission. Must happen after READY so enqueueEvent + // accepts the events. + lifecycleCoordinator?.onReady() + } catch (e: Exception) { Logger.error("Failed to initialize SDK: ${e.message}") lifecycleState.set(LifecycleState.IDLE) @@ -426,13 +469,16 @@ class MetaRouterAnalyticsClient private constructor( /** * Called when app goes to background. - * Flushes pending events and pauses the dispatcher. + * Emits `Application Backgrounded` (when enabled), flushes pending events, + * and pauses the dispatcher. The lifecycle event must be emitted before flush + * so it lands in the same drain. */ internal suspend fun onBackground() { if (lifecycleState.get() != LifecycleState.READY) { Logger.log("onBackground ignored - SDK not ready (state: ${lifecycleState.get()})") return } + lifecycleCoordinator?.onBackground() flush() persistableEventQueue?.flushToDisk() dispatcher.pause() @@ -440,19 +486,38 @@ class MetaRouterAnalyticsClient private constructor( /** * Called when app comes to foreground. - * Flushes pending events and resumes the dispatcher. + * Emits `Application Opened` (when enabled), flushes pending events, and + * resumes the dispatcher. */ internal fun onForeground() { if (lifecycleState.get() != LifecycleState.READY) { Logger.log("onForeground ignored - SDK not ready (state: ${lifecycleState.get()})") return } + lifecycleCoordinator?.onForeground() scope.launch { flush() } dispatcher.resume() } + override fun openURL(uri: Uri, sourceApplication: String?) { + if (lifecycleState.get() != LifecycleState.READY) { + Logger.log("openURL ignored - SDK not ready (state: ${lifecycleState.get()})") + return + } + val coordinator = lifecycleCoordinator + if (coordinator == null) { + Logger.warn( + "openURL called but trackLifecycleEvents is disabled — no-op. " + + "Set InitOptions.trackLifecycleEvents=true to buffer deep links " + + "for the next Application Opened event." + ) + return + } + coordinator.openURL(uri, sourceApplication) + } + override suspend fun reset() { if (!lifecycleState.compareAndSet(LifecycleState.READY, LifecycleState.RESETTING)) { Logger.warn("Cannot reset - SDK not in READY state (current: ${lifecycleState.get()})") diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt index a66abe1..c5ebf1e 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/PendingCall.kt @@ -1,5 +1,7 @@ package com.metarouter.analytics +import android.net.Uri + /** * Represents a queued analytics method call that will be replayed * once the real client is bound to the proxy. @@ -15,6 +17,7 @@ sealed class PendingCall { data class Alias(val newUserId: String) : PendingCall() data class SetTracing(val enabled: Boolean) : PendingCall() data class SetAdvertisingId(val advertisingId: String) : PendingCall() + data class OpenURL(val uri: Uri, val sourceApplication: String?) : PendingCall() object ClearAdvertisingId : PendingCall() object Flush : PendingCall() object Reset : PendingCall() diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt index 2e30020..2f176a0 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt @@ -1,7 +1,6 @@ package com.metarouter.analytics.context import android.content.Context -import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build @@ -29,7 +28,10 @@ import kotlin.math.roundToInt * - Cache generation is synchronized to prevent duplicate work * - Safe for concurrent access from multiple threads */ -class DeviceContextProvider(private val context: Context) { +class DeviceContextProvider( + private val context: Context, + private val appContext: AppContext = AppContext.fromContext(context) +) { companion object { private const val SDK_NAME = "metarouter-android-sdk" @@ -49,6 +51,8 @@ class DeviceContextProvider(private val context: Context) { /** * Generate fresh context information by collecting all metadata. + * `appContext` is the cached snapshot read at SDK init — `PackageManager` + * is not consulted on the per-event enrichment path. */ private fun generateContext(): EventContext { return EventContext( @@ -57,7 +61,7 @@ class DeviceContextProvider(private val context: Context) { timezone = getTimezone(), device = getDeviceContext(), os = getOSContext(), - app = getAppContext(), + app = appContext, screen = getScreenContext(), network = getNetworkContext() ) @@ -127,54 +131,6 @@ class DeviceContextProvider(private val context: Context) { ) } - /** - * Get app information from PackageManager. - * Collects app name, version, build number, and namespace (package name). - */ - private fun getAppContext(): AppContext { - return try { - val packageManager = context.packageManager - val packageName = context.packageName - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager.getPackageInfo(packageName, 0) - } - - val appName = try { - packageInfo.applicationInfo?.let { - packageManager.getApplicationLabel(it).toString() - } ?: UNKNOWN - } catch (e: Exception) { - UNKNOWN - } - - val versionName = packageInfo.versionName ?: UNKNOWN - val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.longVersionCode.toString() - } else { - @Suppress("DEPRECATION") - packageInfo.versionCode.toString() - } - - AppContext( - name = appName, - version = versionName, - build = versionCode, - namespace = packageName - ) - } catch (e: Exception) { - Logger.warn("Failed to get app context: ${e.message}") - AppContext( - name = UNKNOWN, - version = UNKNOWN, - build = UNKNOWN, - namespace = context.packageName - ) - } - } - /** * Get screen dimensions and pixel density from WindowManager. * Width/height are in points (dp), density is pixel ratio rounded to 2 decimal places. diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt new file mode 100644 index 0000000..c622d2c --- /dev/null +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleCoordinator.kt @@ -0,0 +1,31 @@ +package com.metarouter.analytics.lifecycle + +import android.net.Uri + +/** + * Bridge between the platform notification observer ([AppLifecycleObserver]) and the + * lifecycle event tracker. Single seam where future session/attribution work will land + * alongside lifecycle events without bloating [MetaRouterAnalyticsClient]. + * + * The coordinator is constructed only when `InitOptions.trackLifecycleEvents` is `true`; + * when the feature is disabled, the client never holds an instance and all coordinator + * calls are null-safe no-ops at the call sites. + */ +internal class LifecycleCoordinator( + private val tracker: LifecycleEventTracker +) { + /** Called from `AppLifecycleObserver.onStart` (`ProcessLifecycleOwner.ON_START`). */ + fun onForeground() = tracker.onForeground() + + /** Called from `AppLifecycleObserver.onStop` (`ProcessLifecycleOwner.ON_STOP`). */ + fun onBackground() = tracker.onBackground() + + /** + * Called once after the SDK transitions to `READY`. Runs the cold-launch + * detection + emission sequence (install/update + initial Opened). + */ + fun onReady() = tracker.onSdkReady() + + /** Buffer a deep link for the next `Application Opened` emission. */ + fun openURL(uri: Uri, sourceApplication: String?) = tracker.openURL(uri, sourceApplication) +} diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt index fcd28f0..b438140 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/AnalyticsExtensionsTest.kt @@ -1,5 +1,6 @@ package com.metarouter.analytics +import android.net.Uri import org.junit.Test import org.junit.Assert.* @@ -57,6 +58,7 @@ class AnalyticsExtensionsTest { override fun enableDebugLogging() {} override suspend fun getDebugInfo(): Map = emptyMap() override fun setTracing(enabled: Boolean) {} + override fun openURL(uri: Uri, sourceApplication: String?) {} } @Test diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt index a27cbc9..1b5b425 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/InitOptionsTest.kt @@ -328,4 +328,27 @@ class InitOptionsTest { unmockkStatic(Log::class) } } + + // ===== trackLifecycleEvents ===== + + @Test + fun `trackLifecycleEvents defaults to false (opt-in)`() { + val options = InitOptions( + writeKey = "test-key", + ingestionHost = "https://example.com" + ) + + assertEquals(false, options.trackLifecycleEvents) + } + + @Test + fun `trackLifecycleEvents explicit true is accepted`() { + val options = InitOptions( + writeKey = "test-key", + ingestionHost = "https://example.com", + trackLifecycleEvents = true + ) + + assertEquals(true, options.trackLifecycleEvents) + } } diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt index 855e449..2778e49 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/MetaRouterAnalyticsClientTest.kt @@ -48,7 +48,11 @@ class MetaRouterAnalyticsClientTest { ingestionHost = "https://events.example.com", flushIntervalSeconds = 10, debug = false, - maxQueueEvents = 100 + maxQueueEvents = 100, + // Disable lifecycle events by default so existing assertions on queueLength + // are not affected by automatic Installed/Opened emission. Lifecycle tests + // re-enable explicitly via options.copy(trackLifecycleEvents = true). + trackLifecycleEvents = false ) } @@ -628,4 +632,85 @@ class MetaRouterAnalyticsClientTest { assertNotNull(debugInfo["networkStatus"]) assertTrue((debugInfo["queueLength"] as Int) > 0) } + + // ===== Lifecycle Events ===== + + @Test + fun `cold launch with trackLifecycleEvents enabled emits at least install event and persists version`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + // Use a fresh, real context so lifecycle prefs and identity prefs start empty. + val identityManager = IdentityManager(realContext) + identityManager.reset() + val lifecycleStorage = com.metarouter.analytics.storage.LifecycleStorage(realContext) + lifecycleStorage.clear() + + val lifecycleOptions = options.copy(trackLifecycleEvents = true) + val client = MetaRouterAnalyticsClient.initialize( + realContext, + lifecycleOptions, + identityManager = identityManager, + lifecycleStorage = lifecycleStorage + ) + + // Cold-launch sequence persists the current (version, build) and enqueues at + // least the Installed event. The Opened event also fires when ProcessLifecycleOwner + // is in STARTED state — which Robolectric does not deterministically provide here, + // so we assert the lower bound and verify storage persistence directly. + awaitCondition { lifecycleStorage.getVersion() != null } + awaitCondition { (client.getDebugInfo()["queueLength"] as Int) >= 1 } + + client.reset() + } + + @Test + fun `trackLifecycleEvents false produces no lifecycle events`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + identityManager.reset() + val lifecycleStorage = com.metarouter.analytics.storage.LifecycleStorage(realContext) + lifecycleStorage.clear() + + // Default options has trackLifecycleEvents = false in this suite. + val client = MetaRouterAnalyticsClient.initialize( + realContext, + options, + identityManager = identityManager, + lifecycleStorage = lifecycleStorage + ) + + delay(100) + val info = client.getDebugInfo() + assertEquals(0, info["queueLength"]) + + client.reset() + } + + @Test + fun `lifecycle storage survives client reset`() = runBlocking { + val realContext: Context = ApplicationProvider.getApplicationContext() + val identityManager = IdentityManager(realContext) + identityManager.reset() + val lifecycleStorage = com.metarouter.analytics.storage.LifecycleStorage(realContext) + lifecycleStorage.clear() + + val lifecycleOptions = options.copy(trackLifecycleEvents = true) + val client = MetaRouterAnalyticsClient.initialize( + realContext, + lifecycleOptions, + identityManager = identityManager, + lifecycleStorage = lifecycleStorage + ) + // Wait for cold-launch to persist version/build. + awaitCondition { lifecycleStorage.getVersion() != null } + val storedVersion = lifecycleStorage.getVersion() + val storedBuild = lifecycleStorage.getBuild() + assertNotNull(storedVersion) + assertNotNull(storedBuild) + + client.reset() + + // Reset must NOT clear lifecycle prefs. + assertEquals(storedVersion, lifecycleStorage.getVersion()) + assertEquals(storedBuild, lifecycleStorage.getBuild()) + } } diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt new file mode 100644 index 0000000..791bd51 --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleCoordinatorTest.kt @@ -0,0 +1,58 @@ +package com.metarouter.analytics.lifecycle + +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +/** + * Coordinator is a thin pass-through to the tracker; the full algorithm is exercised + * by `LifecycleEventTrackerTest`. These tests confirm the seam exists and forwards + * each call exactly once with the same arguments. + */ +class LifecycleCoordinatorTest { + + @Test + fun `onForeground forwards to tracker`() { + val tracker = mockk(relaxed = true) + LifecycleCoordinator(tracker).onForeground() + verify(exactly = 1) { tracker.onForeground() } + } + + @Test + fun `onBackground forwards to tracker`() { + val tracker = mockk(relaxed = true) + LifecycleCoordinator(tracker).onBackground() + verify(exactly = 1) { tracker.onBackground() } + } + + @Test + fun `onReady forwards to tracker onSdkReady`() { + val tracker = mockk(relaxed = true) + LifecycleCoordinator(tracker).onReady() + verify(exactly = 1) { tracker.onSdkReady() } + } + + @Test + fun `openURL forwards uri and source application to tracker`() { + val tracker = mockk(relaxed = true) + val uri = mockk() + every { uri.toString() } returns "https://example.com" + + LifecycleCoordinator(tracker).openURL(uri, "com.referrer") + + verify(exactly = 1) { tracker.openURL(uri, "com.referrer") } + } + + @Test + fun `openURL forwards null source application unchanged`() { + val tracker = mockk(relaxed = true) + val uri = mockk() + every { uri.toString() } returns "https://example.com" + + LifecycleCoordinator(tracker).openURL(uri, null) + + verify(exactly = 1) { tracker.openURL(uri, null) } + } +} diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt index c78b952..54a5555 100644 --- a/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt @@ -44,6 +44,7 @@ class LifecycleEventTrackerTest { override fun enableDebugLogging() {} override suspend fun getDebugInfo(): Map = emptyMap() override fun setTracing(enabled: Boolean) {} + override fun openURL(uri: Uri, sourceApplication: String?) {} } private lateinit var analytics: RecordingAnalytics From 4e7a426769773d21cd1f8a2be082c8d589b62848 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:57:40 -0600 Subject: [PATCH 6/8] fix: gate tightening, onForeground ordering, EXTRA_REFERRER KDoc Slice 3 follow-ups from code review. - onForeground ordering: dispatcher.resume() now runs BEFORE coordinator.onForeground(). Mirrors iOS so the resumed dispatcher picks up the just-emitted Application Opened in its next tick rather than waiting for the following flush cycle. Functionally benign on Android (track() enqueues regardless of dispatcher state) but the cross-platform parity contract was an explicit constraint. - Coordinator gate: trackLifecycleEvents is now the sole on/off signal. Previously a test seam injection of injectedLifecycleCoordinator could install a coordinator while the flag was false. Production paths were fine via MetaRouter.kt, but the off-state should be structurally enforced regardless of the seam. - AnalyticsInterface.openURL KDoc: drop the misleading Intent.EXTRA_REFERRER + getStringExtra suggestion. Per Android docs EXTRA_REFERRER is a Uri (returns null via getStringExtra). Point hosts at Activity.referrer?.host, which is the canonical API for the calling-app host. - DeviceContextProvider: KDoc on the appContext constructor default noting it exists for test ergonomics only. Production code must pass the explicit cached snapshot. Refs: sc-38235 --- .../analytics/AnalyticsInterface.kt | 7 +++-- .../analytics/MetaRouterAnalyticsClient.kt | 28 ++++++++++++------- .../context/DeviceContextProvider.kt | 3 ++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt index dba1593..7c1177e 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/AnalyticsInterface.kt @@ -166,9 +166,10 @@ interface AnalyticsInterface { /** * Buffer a deep-link URL for the next `Application Opened` event. * - * Call this from the receiving Activity's `onCreate` / `onNewIntent`, typically - * passing `intent.data` and the referrer host (e.g. read via - * `intent.getStringExtra(Intent.EXTRA_REFERRER)` or `Activity.referrer?.host`). + * Call this from the receiving Activity's `onCreate` / `onNewIntent`, passing + * `intent.data` for the URI. Use `Activity.referrer?.host` for the referrer — + * `Intent.EXTRA_REFERRER` is documented as a `Uri`, not a String, so + * `getStringExtra` on it is virtually always `null`. * * The next `Application Opened` event the SDK emits will carry `url` and * (when provided) `referring_application` properties. The buffer is one-shot — diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt index f2ec5a4..48deba2 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/MetaRouterAnalyticsClient.kt @@ -256,15 +256,20 @@ class MetaRouterAnalyticsClient private constructor( // Build the lifecycle coordinator before flipping to READY so onReady() can run // immediately after the state transition. The tracker emits via track(), which // requires the lifecycle state to be READY. - lifecycleCoordinator = injectedLifecycleCoordinator ?: if (options.trackLifecycleEvents) { - val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) - val tracker = LifecycleEventTracker( - analytics = this, - storage = lifecycleStorage, - appContext = appContext, - identityManager = identityManager - ) - LifecycleCoordinator(tracker) + // + // `trackLifecycleEvents` is the sole gate: if disabled, no coordinator is built + // even when a test seam supplies one. Keeps the off-state structurally enforced. + lifecycleCoordinator = if (options.trackLifecycleEvents) { + injectedLifecycleCoordinator ?: run { + val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context) + val tracker = LifecycleEventTracker( + analytics = this, + storage = lifecycleStorage, + appContext = appContext, + identityManager = identityManager + ) + LifecycleCoordinator(tracker) + } } else { null } @@ -494,11 +499,14 @@ class MetaRouterAnalyticsClient private constructor( Logger.log("onForeground ignored - SDK not ready (state: ${lifecycleState.get()})") return } + // Resume dispatcher first, then emit. Mirrors iOS so the resumed dispatcher + // picks up the just-emitted Opened in its next tick rather than waiting for the + // following flush cycle. + dispatcher.resume() lifecycleCoordinator?.onForeground() scope.launch { flush() } - dispatcher.resume() } override fun openURL(uri: Uri, sourceApplication: String?) { diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt index 2f176a0..79224ae 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/context/DeviceContextProvider.kt @@ -30,6 +30,9 @@ import kotlin.math.roundToInt */ class DeviceContextProvider( private val context: Context, + // The `appContext` default is for test ergonomics only. Production code must pass + // the cached snapshot read once at SDK init in `MetaRouterAnalyticsClient` so the + // per-event enrichment path never re-reads `PackageManager`. private val appContext: AppContext = AppContext.fromContext(context) ) { From eb4d9c480af3b71b51dd60314a4977f1b4f137d1 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 14:50:10 -0600 Subject: [PATCH 7/8] docs: lifecycle events README section Adds the user-facing documentation for the lifecycle events feature shipped by sc-38233 / sc-38234 / sc-38235. - Top-level Lifecycle Events section with the four events table, cold-launch sequencing rules, persistence semantics, and the rationale for cold-launch suppression on background-launched processes (silent push, JobScheduler, etc.) - Opt-in framing: trackLifecycleEvents defaults to false, sample of how to enable, note that openURL is a no-op when disabled - Deep-link wiring snippets for Activity.onCreate / onNewIntent and App Links via verified intent filters - Privacy guidance with a URL-sanitization sample (auth tokens, OTPs, magic-link params) - 'Why no auto-instrumentation' rationale (no manifest mutation, no ActivityLifecycleCallbacks proxy, host control) - TOC entry and openURL listed in the Analytics Interface API ref Refs: sc-38236 --- README.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/README.md b/README.md index b4ee2d6..f794f7e 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,119 @@ 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 +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 + val source = intent.getStringExtra(Intent.EXTRA_REFERRER) + ?: 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 +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() +} + +MetaRouter.Analytics.client().openURL(sanitize(intent.data!!), source) +``` + +### 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. From fb9ad90af3e2c51cb81656b4b0464756b7bd3f5e Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 16:00:15 -0600 Subject: [PATCH 8/8] docs: README EXTRA_REFERRER fix + softer privacy snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 follow-ups from code review. - Activity entry-point snippet: drop the misleading intent.getStringExtra(Intent.EXTRA_REFERRER) suggestion. EXTRA_REFERRER is documented as a Uri, not a String, so the String overload always returns null. Use Activity.referrer?.host (the canonical API) instead. - Add an explicit imports block to the Activity snippet so it's copy-paste-runnable. - Soften intent.data!! in the privacy sample to a guarded let — a privacy-themed example shouldn't crash on a bare bang. - Add the android.net.Uri import to the sanitize() snippet for the same reason. Refs: sc-38236 --- README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f794f7e..5f232b6 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,11 @@ The SDK does not auto-instrument deep links. Hosts forward URLs explicitly via ` #### 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) @@ -638,8 +643,10 @@ class MainActivity : AppCompatActivity() { private fun forwardDeepLink(intent: Intent?) { val uri = intent?.data ?: return - val source = intent.getStringExtra(Intent.EXTRA_REFERRER) - ?: referrer?.host + // `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) } } @@ -665,6 +672,8 @@ Wire up your manifest as usual; nothing changes for the SDK side: 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() @@ -678,7 +687,9 @@ private fun sanitize(uri: Uri): Uri { return builder.build() } -MetaRouter.Analytics.client().openURL(sanitize(intent.data!!), source) +intent.data?.let { uri -> + MetaRouter.Analytics.client().openURL(sanitize(uri), referrer?.host) +} ``` ### Why no auto-instrumentation?