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..32dae9b 100644 --- a/metarouter-sdk/src/main/java/com/metarouter/analytics/types/EventContext.kt +++ b/metarouter-sdk/src/main/java/com/metarouter/analytics/types/EventContext.kt @@ -1,5 +1,9 @@ package com.metarouter.analytics.types +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.metarouter.analytics.utils.Logger import kotlinx.serialization.Serializable /** @@ -27,7 +31,70 @@ data class AppContext( val version: String, val build: String, val namespace: String -) +) { + companion object { + private const val UNKNOWN = "unknown" + + /** + * Read app metadata from `PackageManager` once. Safe to cache for the + * process lifetime — the manifest values are immutable post-install. + * + * Both the lifecycle subsystem and `DeviceContextProvider` consume this + * cached snapshot so per-event enrichment does not re-read the manifest. + */ + fun fromContext(context: Context): AppContext { + return try { + val packageManager = context.packageManager + val packageName = context.packageName + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0) + } + + val appName = try { + packageInfo.applicationInfo?.let { + packageManager.getApplicationLabel(it).toString() + } ?: UNKNOWN + } catch (e: Exception) { + UNKNOWN + } + + val versionName = packageInfo.versionName ?: UNKNOWN + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode.toString() + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toString() + } + + AppContext( + name = appName, + version = versionName, + build = versionCode, + namespace = packageName + ) + } catch (e: Exception) { + Logger.warn("Failed to read AppContext from PackageManager: ${e.message}") + // Derive a best-effort `name` from the package's last segment when the + // PackageManager read fails entirely. Better than literal "unknown" on + // ingest dashboards and matches what most launchers show when the + // applicationLabel resolution itself fails. + val packageName = context.packageName + val derivedName = packageName.substringAfterLast('.') + .takeIf { it.isNotBlank() } + ?: UNKNOWN + AppContext( + name = derivedName, + version = UNKNOWN, + build = UNKNOWN, + namespace = packageName + ) + } + } + } +} /** * Device information including manufacturer, model, device name, and type. diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt new file mode 100644 index 0000000..45e35a7 --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/storage/LifecycleStorageTest.kt @@ -0,0 +1,113 @@ +package com.metarouter.analytics.storage + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LifecycleStorageTest { + + private lateinit var context: Context + private lateinit var storage: LifecycleStorage + private lateinit var identityStorage: IdentityStorage + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + storage = LifecycleStorage(context) + identityStorage = IdentityStorage(context) + storage.clear() + identityStorage.clear() + } + + @After + fun tearDown() { + storage.clear() + identityStorage.clear() + } + + @Test + fun `get returns null when not set`() { + assertNull(storage.getVersion()) + assertNull(storage.getBuild()) + } + + @Test + fun `setVersionBuild persists both fields`() { + storage.setVersionBuild("1.4.0", "42") + + assertEquals("1.4.0", storage.getVersion()) + assertEquals("42", storage.getBuild()) + } + + @Test + fun `setVersionBuild overwrites previous values`() { + storage.setVersionBuild("1.0.0", "1") + storage.setVersionBuild("2.0.0", "2") + + assertEquals("2.0.0", storage.getVersion()) + assertEquals("2", storage.getBuild()) + } + + @Test + fun `data persists across instances`() { + storage.setVersionBuild("1.4.0", "42") + + val storage2 = LifecycleStorage(context) + assertEquals("1.4.0", storage2.getVersion()) + assertEquals("42", storage2.getBuild()) + } + + @Test + fun `lifecycle storage is independent of identity storage clear`() { + storage.setVersionBuild("1.4.0", "42") + identityStorage.set(IdentityStorage.KEY_ANONYMOUS_ID, "anon-id") + identityStorage.set(IdentityStorage.KEY_USER_ID, "user-id") + + // Clearing identity storage must NOT touch lifecycle storage + identityStorage.clear() + + assertNull(identityStorage.get(IdentityStorage.KEY_ANONYMOUS_ID)) + assertNull(identityStorage.get(IdentityStorage.KEY_USER_ID)) + assertEquals("1.4.0", storage.getVersion()) + assertEquals("42", storage.getBuild()) + } + + @Test + fun `keys match cross-platform contract`() { + assertEquals("metarouter:lifecycle:version", LifecycleStorage.KEY_VERSION) + assertEquals("metarouter:lifecycle:build", LifecycleStorage.KEY_BUILD) + } + + @Test + fun `clear removes both fields`() { + storage.setVersionBuild("1.4.0", "42") + + storage.clear() + + assertNull(storage.getVersion()) + assertNull(storage.getBuild()) + } + + @Test + fun `identityStorage hasAnyValue reflects whether keys exist`() { + // Empty initially + assertEquals(false, identityStorage.hasAnyValue()) + + identityStorage.set(IdentityStorage.KEY_ANONYMOUS_ID, "anon") + assertEquals(true, identityStorage.hasAnyValue()) + + identityStorage.clear() + assertEquals(false, identityStorage.hasAnyValue()) + + identityStorage.set(IdentityStorage.KEY_USER_ID, "user") + assertEquals(true, identityStorage.hasAnyValue()) + } +} diff --git a/metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt b/metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt new file mode 100644 index 0000000..a1e6b3b --- /dev/null +++ b/metarouter-sdk/src/test/java/com/metarouter/analytics/types/AppContextTest.kt @@ -0,0 +1,57 @@ +package com.metarouter.analytics.types + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Smoke coverage for the `AppContext.fromContext` factory. The full `DeviceContextProvider` + * test exercises this transitively, but this slice introduces the factory standalone and + * deserves direct coverage. + */ +@RunWith(RobolectricTestRunner::class) +class AppContextTest { + + @Test + fun `fromContext populates namespace from package`() { + val ctx: Context = ApplicationProvider.getApplicationContext() + val app = AppContext.fromContext(ctx) + + assertEquals(ctx.packageName, app.namespace) + } + + @Test + fun `fromContext returns non-blank fields`() { + val ctx: Context = ApplicationProvider.getApplicationContext() + val app = AppContext.fromContext(ctx) + + assertTrue("name should not be blank", app.name.isNotBlank()) + assertTrue("version should not be blank", app.version.isNotBlank()) + assertTrue("build should not be blank", app.build.isNotBlank()) + assertTrue("namespace should not be blank", app.namespace.isNotBlank()) + } + + @Test + fun `fromContext is stable across calls`() { + val ctx: Context = ApplicationProvider.getApplicationContext() + val first = AppContext.fromContext(ctx) + val second = AppContext.fromContext(ctx) + + assertEquals(first, second) + } + + @Test + fun `equality is structural`() { + val a = AppContext("App", "1.0", "100", "com.example") + val b = AppContext("App", "1.0", "100", "com.example") + val c = AppContext("App", "1.1", "100", "com.example") + + assertEquals(a, b) + assertNotEquals(a, c) + } +}