Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are legacy overloads that are required for previous sdk versions and our need to support 23 as our min SDK version

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading