Skip to content
Open
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
@@ -1,5 +1,7 @@
package com.metarouter.analytics

import android.net.Uri

/**
* Public API for the MetaRouter Analytics SDK.
*
Expand Down Expand Up @@ -160,4 +162,29 @@ interface AnalyticsInterface {
* @param enabled Whether to enable tracing
*/
fun setTracing(enabled: Boolean)

/**
* Buffer a deep-link URL for the next `Application Opened` event.
*
* Call this from the receiving Activity's `onCreate` / `onNewIntent`, passing
* `intent.data` for the URI. Use `Activity.referrer?.host` for the referrer —
* `Intent.EXTRA_REFERRER` is documented as a `Uri`, not a String, so
* `getStringExtra` on it is virtually always `null`.
*
* The next `Application Opened` event the SDK emits will carry `url` and
* (when provided) `referring_application` properties. The buffer is one-shot —
* subsequent `Application Opened` events without a fresh call omit the props.
* If called multiple times before the next `Application Opened`, the most recent
* URL wins (last-write-wins; no queue).
*
* Logs a warning and is a no-op when `InitOptions.trackLifecycleEvents` is `false`.
*
* The method is named after the *signal* the host is forwarding, not what the SDK
* does with it — the SDK does not route, parse, or open the URL. Mirrors Segment's
* iOS API for cross-platform consistency.
*
* @param uri The deep-link URI that opened the app
* @param sourceApplication Optional referring application identifier
*/
fun openURL(uri: Uri, sourceApplication: String? = null)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.metarouter.analytics

import android.net.Uri
import com.metarouter.analytics.utils.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
Expand Down Expand Up @@ -202,6 +203,15 @@ class AnalyticsProxy(
}
}

override fun openURL(uri: Uri, sourceApplication: String?) {
val client = realClient.get()
if (client != null) {
client.openURL(uri, sourceApplication)
} else {
enqueue(PendingCall.OpenURL(uri, sourceApplication))
}
}


/**
* Enqueue a pending call, dropping oldest if at capacity.
Expand Down Expand Up @@ -230,6 +240,7 @@ class AnalyticsProxy(
is PendingCall.SetTracing -> client.setTracing(call.enabled)
is PendingCall.SetAdvertisingId -> client.setAdvertisingId(call.advertisingId)
is PendingCall.ClearAdvertisingId -> client.clearAdvertisingId()
is PendingCall.OpenURL -> client.openURL(call.uri, call.sourceApplication)
is PendingCall.Flush -> client.flush()
is PendingCall.Reset -> client.reset()
is PendingCall.EnableDebugLogging -> client.enableDebugLogging()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import com.metarouter.analytics.utils.Logger
* (default: 10000). Set to `0` to opt out of disk persistence entirely — the queue then
* operates as a purely in-memory ring buffer, dropping the oldest event when full.
* Negative values are rejected.
* @property trackLifecycleEvents Whether the SDK should automatically emit
* `Application Installed`, `Application Updated`, `Application Opened`, and
* `Application Backgrounded` events (default: `false` — opt-in). Set to `true`
* to enable. Existing customers upgrading the SDK do not begin emitting
* lifecycle events without explicitly enabling the flag.
*
* @throws IllegalArgumentException if validation fails
*/
Expand All @@ -25,7 +30,8 @@ data class InitOptions(
val flushIntervalSeconds: Int = 10,
val debug: Boolean = false,
val maxQueueEvents: Int = 2000,
val maxDiskEvents: Int = 10000
val maxDiskEvents: Int = 10000,
val trackLifecycleEvents: Boolean = false
) {
init {
validateWriteKey()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import android.app.Application
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import com.metarouter.analytics.context.DeviceContextProvider
import com.metarouter.analytics.dispatcher.Dispatcher
import com.metarouter.analytics.dispatcher.DispatcherConfig
import com.metarouter.analytics.enrichment.EventEnrichmentService
import com.metarouter.analytics.identity.IdentityManager
import com.metarouter.analytics.lifecycle.LifecycleCoordinator
import com.metarouter.analytics.lifecycle.LifecycleEventTracker
import com.metarouter.analytics.network.AndroidNetworkMonitor
import com.metarouter.analytics.network.CircuitBreaker
import com.metarouter.analytics.network.DebouncedNetworkMonitor
Expand All @@ -18,6 +21,8 @@ import com.metarouter.analytics.network.OkHttpNetworkClient
import com.metarouter.analytics.queue.EventQueueInterface
import com.metarouter.analytics.queue.PersistableEventQueue
import com.metarouter.analytics.storage.EventDiskStore
import com.metarouter.analytics.storage.LifecycleStorage
import com.metarouter.analytics.types.AppContext
import com.metarouter.analytics.types.BaseEvent
import com.metarouter.analytics.types.EventType
import com.metarouter.analytics.types.LifecycleState
Expand All @@ -43,7 +48,10 @@ class MetaRouterAnalyticsClient private constructor(
private val injectedCircuitBreaker: CircuitBreaker? = null,
private val injectedDispatcher: Dispatcher? = null,
private val injectedDiskStore: EventDiskStore? = null,
private val injectedNetworkMonitor: NetworkMonitor? = null
private val injectedNetworkMonitor: NetworkMonitor? = null,
private val injectedLifecycleStorage: LifecycleStorage? = null,
private val injectedLifecycleCoordinator: LifecycleCoordinator? = null,
private val injectedAppContext: AppContext? = null
) : AnalyticsInterface {

companion object {
Expand Down Expand Up @@ -75,7 +83,10 @@ class MetaRouterAnalyticsClient private constructor(
circuitBreaker: CircuitBreaker? = null,
dispatcher: Dispatcher? = null,
diskStore: EventDiskStore? = null,
networkMonitor: NetworkMonitor? = null
networkMonitor: NetworkMonitor? = null,
lifecycleStorage: LifecycleStorage? = null,
lifecycleCoordinator: LifecycleCoordinator? = null,
appContext: AppContext? = null
): MetaRouterAnalyticsClient {
val client = MetaRouterAnalyticsClient(
context.applicationContext,
Expand All @@ -88,7 +99,10 @@ class MetaRouterAnalyticsClient private constructor(
circuitBreaker,
dispatcher,
diskStore,
networkMonitor
networkMonitor,
lifecycleStorage,
lifecycleCoordinator,
appContext
)
client.initializeInternal()
return client
Expand Down Expand Up @@ -119,6 +133,9 @@ class MetaRouterAnalyticsClient private constructor(
private var persistableEventQueue: PersistableEventQueue? = null
private var componentCallbacks: ComponentCallbacks2? = null

// Lifecycle coordinator (null when InitOptions.trackLifecycleEvents is false)
private var lifecycleCoordinator: LifecycleCoordinator? = null

/**
* Internal initialization. Sets up all components.
*/
Expand All @@ -138,9 +155,15 @@ class MetaRouterAnalyticsClient private constructor(
val channelCapacity = options.maxQueueEvents
eventChannel = Channel(capacity = channelCapacity)

// App metadata: read PackageManager once and share the snapshot across
// every consumer (DeviceContextProvider for per-event enrichment, the
// lifecycle tracker for install/update detection). Bundle/PackageInfo is
// OS-loaded and immutable post-process-start, so caching is safe.
val appContext = injectedAppContext ?: AppContext.fromContext(context)

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.

allows for DI for testing


// Initialize components (use injected or create new)
identityManager = injectedIdentityManager ?: IdentityManager(context)
contextProvider = injectedContextProvider ?: DeviceContextProvider(context)
contextProvider = injectedContextProvider ?: DeviceContextProvider(context, appContext)
enrichmentService = injectedEnrichmentService ?: EventEnrichmentService(
identityManager = identityManager,
contextProvider = contextProvider,
Expand Down Expand Up @@ -230,9 +253,34 @@ class MetaRouterAnalyticsClient private constructor(
componentCallbacks = callbacks
}

// Build the lifecycle coordinator before flipping to READY so onReady() can run
// immediately after the state transition. The tracker emits via track(), which
// requires the lifecycle state to be READY.
//
// `trackLifecycleEvents` is the sole gate: if disabled, no coordinator is built
// even when a test seam supplies one. Keeps the off-state structurally enforced.
lifecycleCoordinator = if (options.trackLifecycleEvents) {
injectedLifecycleCoordinator ?: run {
val lifecycleStorage = injectedLifecycleStorage ?: LifecycleStorage(context)
val tracker = LifecycleEventTracker(
analytics = this,
storage = lifecycleStorage,
appContext = appContext,
identityManager = identityManager
)
LifecycleCoordinator(tracker)
}
} else {
null
}

lifecycleState.set(LifecycleState.READY)
Logger.log("MetaRouter SDK initialized")

// Run cold-launch detection + emission. Must happen after READY so enqueueEvent
// accepts the events.
lifecycleCoordinator?.onReady()

} catch (e: Exception) {
Logger.error("Failed to initialize SDK: ${e.message}")
lifecycleState.set(LifecycleState.IDLE)
Expand Down Expand Up @@ -426,31 +474,56 @@ class MetaRouterAnalyticsClient private constructor(

/**
* Called when app goes to background.
* Flushes pending events and pauses the dispatcher.
* Emits `Application Backgrounded` (when enabled), flushes pending events,
* and pauses the dispatcher. The lifecycle event must be emitted before flush
* so it lands in the same drain.
*/
internal suspend fun onBackground() {
if (lifecycleState.get() != LifecycleState.READY) {
Logger.log("onBackground ignored - SDK not ready (state: ${lifecycleState.get()})")
return
}
lifecycleCoordinator?.onBackground()
flush()
persistableEventQueue?.flushToDisk()
dispatcher.pause()
}

/**
* Called when app comes to foreground.
* Flushes pending events and resumes the dispatcher.
* Emits `Application Opened` (when enabled), flushes pending events, and
* resumes the dispatcher.
*/
internal fun onForeground() {
if (lifecycleState.get() != LifecycleState.READY) {
Logger.log("onForeground ignored - SDK not ready (state: ${lifecycleState.get()})")
return
}
// Resume dispatcher first, then emit. Mirrors iOS so the resumed dispatcher
// picks up the just-emitted Opened in its next tick rather than waiting for the
// following flush cycle.
dispatcher.resume()
lifecycleCoordinator?.onForeground()
scope.launch {
flush()
}
dispatcher.resume()
}

override fun openURL(uri: Uri, sourceApplication: String?) {
if (lifecycleState.get() != LifecycleState.READY) {
Logger.log("openURL ignored - SDK not ready (state: ${lifecycleState.get()})")
return
}
val coordinator = lifecycleCoordinator
if (coordinator == null) {
Logger.warn(
"openURL called but trackLifecycleEvents is disabled — no-op. " +
"Set InitOptions.trackLifecycleEvents=true to buffer deep links " +
"for the next Application Opened event."
)
return
}
coordinator.openURL(uri, sourceApplication)
}

override suspend fun reset() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.metarouter.analytics

import android.net.Uri

/**
* Represents a queued analytics method call that will be replayed
* once the real client is bound to the proxy.
Expand All @@ -15,6 +17,7 @@ sealed class PendingCall {
data class Alias(val newUserId: String) : PendingCall()
data class SetTracing(val enabled: Boolean) : PendingCall()
data class SetAdvertisingId(val advertisingId: String) : PendingCall()
data class OpenURL(val uri: Uri, val sourceApplication: String?) : PendingCall()
object ClearAdvertisingId : PendingCall()
object Flush : PendingCall()
object Reset : PendingCall()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.metarouter.analytics.context

import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
Expand Down Expand Up @@ -29,7 +28,13 @@ import kotlin.math.roundToInt
* - Cache generation is synchronized to prevent duplicate work
* - Safe for concurrent access from multiple threads
*/
class DeviceContextProvider(private val context: Context) {
class DeviceContextProvider(
private val context: Context,
// The `appContext` default is for test ergonomics only. Production code must pass
// the cached snapshot read once at SDK init in `MetaRouterAnalyticsClient` so the
// per-event enrichment path never re-reads `PackageManager`.
private val appContext: AppContext = AppContext.fromContext(context)

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.

now reads the shared AppContext

) {

companion object {
private const val SDK_NAME = "metarouter-android-sdk"
Expand All @@ -49,6 +54,8 @@ class DeviceContextProvider(private val context: Context) {

/**
* Generate fresh context information by collecting all metadata.
* `appContext` is the cached snapshot read at SDK init — `PackageManager`
* is not consulted on the per-event enrichment path.
*/
private fun generateContext(): EventContext {
return EventContext(
Expand All @@ -57,7 +64,7 @@ class DeviceContextProvider(private val context: Context) {
timezone = getTimezone(),
device = getDeviceContext(),
os = getOSContext(),
app = getAppContext(),
app = appContext,
screen = getScreenContext(),
network = getNetworkContext()
)
Expand Down Expand Up @@ -127,54 +134,6 @@ class DeviceContextProvider(private val context: Context) {
)
}

/**
* Get app information from PackageManager.
* Collects app name, version, build number, and namespace (package name).
*/
private fun getAppContext(): 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 get app context: ${e.message}")
AppContext(
name = UNKNOWN,
version = UNKNOWN,
build = UNKNOWN,
namespace = context.packageName
)
}
}

/**
* Get screen dimensions and pixel density from WindowManager.
* Width/height are in points (dp), density is pixel ratio rounded to 2 decimal places.
Expand Down
Loading