From 2f83fe3d1ad7416648e96de50ba7fce82c1c77f1 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 13:43:33 -0600 Subject: [PATCH 1/2] 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 2/2] =?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