diff --git a/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt new file mode 100644 index 0000000..e4a7959 --- /dev/null +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/lifecycle/LifecycleEventTracker.kt @@ -0,0 +1,210 @@ +package com.metarouter.analytics.lifecycle + +import android.net.Uri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.metarouter.analytics.AnalyticsInterface +import com.metarouter.analytics.identity.IdentityManager +import com.metarouter.analytics.storage.LifecycleStorage +import com.metarouter.analytics.types.AppContext +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Owns install/update detection and emission of the four application-lifecycle + * events: `Application Installed`, `Application Updated`, `Application Opened`, + * and `Application Backgrounded`. + * + * The tracker has no on/off switch — when the feature is disabled, the host + * (currently `LifecycleCoordinator`) never constructs an instance. + */ +internal class LifecycleEventTracker( + private val analytics: AnalyticsInterface, + private val storage: LifecycleStorage, + private val appContext: AppContext, + private val identityManager: IdentityManager, + private val foregroundStateProvider: () -> Boolean = { defaultForegroundCheck() } +) { + + /** + * Set to `true` when the cold-launch path emits `Application Opened`. Causes the + * imminent (first) `onForeground` callback — which `ProcessLifecycleOwner.onStart` + * will fire right after registration — to be suppressed so we don't double-emit. + */ + private val suppressNextForeground = AtomicBoolean(false) + + /** + * Set to `true` when the cold-launch path is suppressed because the process started + * in the background (push, JobScheduler, etc.). The first true `onForeground` call + * consumes this and emits the cold-launch-style `Application Opened {from_background:false}`. + */ + private val coldLaunchOpenedDeferred = AtomicBoolean(false) + + private val pendingDeepLink = AtomicReference(null) + + /** + * Idempotency guard for [onSdkReady] — re-running the cold-launch sequence on a + * second call would re-emit Installed/Updated/Opened and stomp `suppressNextForeground`, + * eating the next legitimate foreground transition. + */ + private val coldLaunchRan = AtomicBoolean(false) + + /** + * Run the cold-launch detection + emission sequence. Idempotent — only the first call + * runs the sequence; subsequent calls are no-ops. + * + * 1. Detect install vs update vs no-op based on persisted (version, build) and identity. + * 2. Persist current (version, build). + * 3. Emit `Application Opened {from_background:false}` if the process is in foreground; + * otherwise defer it to the next foreground transition. + */ + fun onSdkReady() { + if (!coldLaunchRan.compareAndSet(false, true)) return + + val currentVersion = appContext.version + val currentBuild = appContext.build + + val storedVersion = storage.getVersion() + val storedBuild = storage.getBuild() + + when { + storedVersion == null && storedBuild == null -> { + if (identityManager.hasAnyValue()) { + // Existing user upgrading from a pre-lifecycle SDK build. + emitUpdated(currentVersion, currentBuild, LIFECYCLE_UNKNOWN, LIFECYCLE_UNKNOWN) + } else { + emitInstalled(currentVersion, currentBuild) + } + } + storedVersion != currentVersion || storedBuild != currentBuild -> { + emitUpdated( + currentVersion, + currentBuild, + storedVersion ?: LIFECYCLE_UNKNOWN, + storedBuild ?: LIFECYCLE_UNKNOWN + ) + } + else -> { + // Same version/build — no install/update event. + } + } + + storage.setVersionBuild(currentVersion, currentBuild) + + if (foregroundStateProvider()) { + emitOpened(fromBackground = false, version = currentVersion, build = currentBuild) + // The first observer-driven onForeground call (firing right after observer + // registration) must not double-emit. + suppressNextForeground.set(true) + } else { + // Background-launched process (push, JobScheduler, WorkManager, etc.). + // Defer the cold-launch Opened until the first true foreground entry. + coldLaunchOpenedDeferred.set(true) + } + } + + /** + * Called by the AppLifecycleObserver on `ProcessLifecycleOwner.onStart`. + * Emits `Application Opened` with appropriate `from_background` value, with + * dedup logic to avoid double-emit alongside the cold-launch path. + */ + fun onForeground() { + val version = appContext.version + val build = appContext.build + + when { + coldLaunchOpenedDeferred.compareAndSet(true, false) -> { + // Cold launch was suppressed because the process started in background. + // First true foreground entry emits the cold-launch-style Opened. + emitOpened(fromBackground = false, version = version, build = build) + } + suppressNextForeground.compareAndSet(true, false) -> { + // Cold-launch path already emitted Opened — suppress this duplicate. + } + else -> { + emitOpened(fromBackground = true, version = version, build = build) + } + } + } + + /** + * Called by the AppLifecycleObserver on `ProcessLifecycleOwner.onStop`. + */ + fun onBackground() { + analytics.track(LifecycleEventNames.BACKGROUNDED, emptyMap()) + } + + /** + * Buffer deep-link properties for the next `Application Opened` event. + * The buffer is one-shot — cleared after emission. + * + * Hosts should call this from the receiving Activity's `onCreate` / `onNewIntent` + * (typically reading `intent.data` for the URI and `Activity.referrer?.host` for the + * referrer — `Intent.EXTRA_REFERRER` is documented as a `Uri`, not a String, and + * `getStringExtra` on it is virtually always `null`). + */ + fun openURL(uri: Uri, sourceApplication: String?) { + pendingDeepLink.set(DeepLink(uri.toString(), sourceApplication)) + } + + private fun emitInstalled(version: String, build: String) { + analytics.track( + LifecycleEventNames.INSTALLED, + mapOf( + LifecycleEventProperties.VERSION to version, + LifecycleEventProperties.BUILD to build + ) + ) + } + + private fun emitUpdated( + version: String, + build: String, + previousVersion: String, + previousBuild: String + ) { + analytics.track( + LifecycleEventNames.UPDATED, + mapOf( + LifecycleEventProperties.VERSION to version, + LifecycleEventProperties.BUILD to build, + LifecycleEventProperties.PREVIOUS_VERSION to previousVersion, + LifecycleEventProperties.PREVIOUS_BUILD to previousBuild + ) + ) + } + + private fun emitOpened(fromBackground: Boolean, version: String, build: String) { + val props = mutableMapOf( + LifecycleEventProperties.VERSION to version, + LifecycleEventProperties.BUILD to build, + LifecycleEventProperties.FROM_BACKGROUND to fromBackground + ) + pendingDeepLink.getAndSet(null)?.let { deepLink -> + props[LifecycleEventProperties.URL] = deepLink.url + deepLink.referringApplication?.let { + props[LifecycleEventProperties.REFERRING_APPLICATION] = it + } + } + analytics.track(LifecycleEventNames.OPENED, props) + } + + private data class DeepLink(val url: String, val referringApplication: String?) + + companion object { + private fun defaultForegroundCheck(): Boolean { + return try { + ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + } catch (_: IllegalStateException) { + // ProcessLifecycleOwner.get() throws ISE off the main thread. + // Default to false (defer Opened) to avoid spurious double-emits. + false + } catch (_: NoClassDefFoundError) { + // androidx.lifecycle:lifecycle-process not on the runtime classpath + // (rare in production, can happen in test contexts that strip optional deps). + false + } + } + } +} diff --git a/metarouter-sdk/src/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..c78b952 --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/lifecycle/LifecycleEventTrackerTest.kt @@ -0,0 +1,313 @@ +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")) + } + + // ===== Idempotency / ordering invariants ===== + + @Test + fun `onSdkReady is idempotent`() { + val tracker = newTracker(foreground = true) + tracker.onSdkReady() + val firstCallCount = analytics.events.size + + tracker.onSdkReady() + + assertEquals( + "second onSdkReady should be a no-op", + firstCallCount, + analytics.events.size + ) + // suppressNextForeground armed by the first call should still consume the + // imminent observer-driven onForeground — verify we don't double-emit. + tracker.onForeground() + assertEquals(firstCallCount, analytics.events.size) + } + + @Test + fun `onForeground before onSdkReady emits resume-style Opened (documents wiring contract)`() { + // The host (LifecycleCoordinator in slice 3) is contractually required to + // invoke onSdkReady before any onForeground. This test documents what happens + // if that invariant breaks — wire-valid Opened with from_background=true, but + // missing the install/update detection that should run first. + val tracker = newTracker(foreground = true) + + tracker.onForeground() + + assertEquals(1, analytics.events.size) + assertEquals("Application Opened", analytics.events[0].event) + assertEquals(true, analytics.events[0].properties?.get("from_background")) + } + + private fun mockUri(value: String): Uri { + val uri = mockk() + every { uri.toString() } returns value + return uri + } +}