From 425de6ba5f61b863f869aa9bac49942a7b175841 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 13:35:37 -0600 Subject: [PATCH 1/2] 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/2] 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) + } +}