From eb4d9c480af3b71b51dd60314a4977f1b4f137d1 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 14:50:10 -0600 Subject: [PATCH 1/2] docs: lifecycle events README section Adds the user-facing documentation for the lifecycle events feature shipped by sc-38233 / sc-38234 / sc-38235. - Top-level Lifecycle Events section with the four events table, cold-launch sequencing rules, persistence semantics, and the rationale for cold-launch suppression on background-launched processes (silent push, JobScheduler, etc.) - Opt-in framing: trackLifecycleEvents defaults to false, sample of how to enable, note that openURL is a no-op when disabled - Deep-link wiring snippets for Activity.onCreate / onNewIntent and App Links via verified intent filters - Privacy guidance with a URL-sanitization sample (auth tokens, OTPs, magic-link params) - 'Why no auto-instrumentation' rationale (no manifest mutation, no ActivityLifecycleCallbacks proxy, host control) - TOC entry and openURL listed in the Analytics Interface API ref Refs: sc-38236 --- README.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/README.md b/README.md index b4ee2d6..f794f7e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A lightweight Android analytics SDK that transmits events to your MetaRouter clu - [Compatibility](#-compatibility) - [Debugging](#debugging) - [Identity Persistence](#identity-persistence) +- [Lifecycle Events](#lifecycle-events) - [Advertising ID (GAID)](#advertising-id-gaid) - [Using the alias() Method](#using-the-alias-method) - [License](#license) @@ -218,6 +219,7 @@ The analytics client provides the following methods: - `clearAdvertisingId()`: Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking - `getAnonymousId(): String` (suspend): Retrieve the current anonymous ID. Suspends until the SDK is initialized and ready, then returns immediately on subsequent calls. - `setTracing(enabled: Boolean)`: Enable or disable tracing headers on API requests. When enabled, includes a `Trace: true` header for debugging request flows +- `openURL(uri: Uri, sourceApplication: String? = null)`: Buffer a deep-link URL for the next `Application Opened` event. See [Lifecycle Events](#lifecycle-events) for wiring details - `flush()`: Flush events immediately (suspending) - `reset()`: Reset analytics state and clear all stored data (suspending). Also available as fire-and-forget via `MetaRouter.Analytics.reset()` - `enableDebugLogging()`: Enable debug logging @@ -575,6 +577,119 @@ All identity data is stored in **Android SharedPreferences** (`com.metarouter.an - Thread-safe access with built-in synchronization - Cleared only on app uninstall or explicit `reset()` call +## Lifecycle Events + +Opt-in automatic emission of four canonical application-lifecycle events: `Application Installed`, `Application Updated`, `Application Opened`, and `Application Backgrounded`. These anchor attribution, retention, and session reporting on top of the standard event pipeline. + +### Enabling + +Lifecycle events are **opt-in**. Set `trackLifecycleEvents = true` on `InitOptions` to enable: + +```kotlin +val analytics = MetaRouter.Analytics.initialize( + context = applicationContext, + options = InitOptions( + writeKey = "your-write-key", + ingestionHost = "https://your-ingestion-endpoint.com", + trackLifecycleEvents = true, + ) +) +``` + +When the flag is `false` (the default), the SDK never emits these events and `openURL(...)` is a no-op that logs a warning. Existing customers upgrading the SDK do **not** start emitting lifecycle events without explicitly setting the flag. + +### Events + +| Event | When it fires | Properties | +|---|---|---| +| `Application Installed` | First launch after a truly fresh install (no prior identity, no prior lifecycle storage) | `version`, `build` | +| `Application Updated` | First launch after a `(version, build)` change OR first launch after upgrading from a pre-lifecycle SDK build (existing identity, no lifecycle storage) | `version`, `build`, `previous_version`, `previous_build` (set to `"unknown"` for the SDK-upgrade case) | +| `Application Opened` | Cold launch (foreground at SDK init) and on every `ProcessLifecycleOwner.ON_START` resume | `version`, `build`, `from_background` (false on cold launch, true on resume), optional `url` and `referring_application` from `openURL(...)` | +| `Application Backgrounded` | `ProcessLifecycleOwner.ON_STOP`. Emitted **before** the dispatcher's flush-to-disk so the event ships in the same drain | (none) | + +### Cold-launch sequencing + +On cold launch, install/update events fire **before** `Application Opened` so attribution pipelines see the install/update before the session start. + +For background-launched processes (silent push, `JobScheduler`, `WorkManager`, broadcast receiver, content provider) where `ProcessLifecycleOwner.lifecycle.currentState` is below `STARTED` at SDK init, the cold-launch `Application Opened` is suppressed. The first subsequent `ON_START` transition emits the bridge event with `from_background = false` so analytics still see one Opened per app session — just at the moment the user actually engages. + +### Persistence + +`(version, build)` are persisted to SharedPreferences under `com.metarouter.analytics.lifecycle`, a separate file from identity prefs. `reset()` clears identity but **not** lifecycle storage — install/update is device-scope, not user-scope. Re-launching after `reset()` with the same `(version, build)` emits only `Application Opened`. + +### Deep links + +The SDK does not auto-instrument deep links. Hosts forward URLs explicitly via `analytics.openURL(uri, sourceApplication)`. The next `Application Opened` event the SDK emits will carry `url` and (if provided) `referring_application` properties. The buffer is **one-shot** (cleared on emit) and **last-write-wins** (multiple calls before the next Opened keep only the most recent URL). + +#### Activity entry point + +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + forwardDeepLink(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // singleTop / singleTask resumed via a fresh intent + forwardDeepLink(intent) + } + + private fun forwardDeepLink(intent: Intent?) { + val uri = intent?.data ?: return + val source = intent.getStringExtra(Intent.EXTRA_REFERRER) + ?: referrer?.host + MetaRouter.Analytics.client().openURL(uri, source) + } +} +``` + +#### App Links (verified deep links) + +Wire up your manifest as usual; nothing changes for the SDK side: + +```xml + + + + + + + + +``` + +### Privacy + +Deep-link URLs frequently carry sensitive material — auth tokens, OTPs, magic-link secrets, query parameters with PII. The SDK forwards whatever URI you hand it and does not sanitize. Strip or redact secrets in the host before calling `openURL(...)`: + +```kotlin +private fun sanitize(uri: Uri): Uri { + val sensitiveParams = setOf("token", "otp", "code", "auth", "secret") + val builder = uri.buildUpon().clearQuery() + uri.queryParameterNames + .filterNot { it.lowercase() in sensitiveParams } + .forEach { name -> + uri.getQueryParameters(name).forEach { value -> + builder.appendQueryParameter(name, value) + } + } + return builder.build() +} + +MetaRouter.Analytics.client().openURL(sanitize(intent.data!!), source) +``` + +### Why no auto-instrumentation? + +Several reasons: + +- **No manifest mutation.** A library that rewrites your manifest's intent filters fights with build pipelines, app-bundle splits, and dynamic feature modules. +- **No `ActivityLifecycleCallbacks` proxy.** Auto-forwarding every Activity's `onCreate`/`onNewIntent` would force the SDK to interpret URLs that aren't deep links (e.g., internal navigation routed via `Intent`), and it would fight any lifecycle-callbacks instrumentation the host already runs. +- **Privacy footgun.** Auto-forwarded URLs would ship sensitive material without the host having a chance to sanitize. +- **Host control.** You already know which entry points are deep-link entry points. Forwarding from those specific Activities is a one-liner; auto-instrumentation would be lossy and surprising. + ## Using the alias() Method The `alias()` method connects an **anonymous user** (tracked by `anonymousId`) to a **known user ID**. It's used to link pre-login activity to post-login identity. From fb9ad90af3e2c51cb81656b4b0464756b7bd3f5e Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 16:00:15 -0600 Subject: [PATCH 2/2] docs: README EXTRA_REFERRER fix + softer privacy snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 follow-ups from code review. - Activity entry-point snippet: drop the misleading intent.getStringExtra(Intent.EXTRA_REFERRER) suggestion. EXTRA_REFERRER is documented as a Uri, not a String, so the String overload always returns null. Use Activity.referrer?.host (the canonical API) instead. - Add an explicit imports block to the Activity snippet so it's copy-paste-runnable. - Soften intent.data!! in the privacy sample to a guarded let — a privacy-themed example shouldn't crash on a bare bang. - Add the android.net.Uri import to the sanitize() snippet for the same reason. Refs: sc-38236 --- README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f794f7e..5f232b6 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,11 @@ The SDK does not auto-instrument deep links. Hosts forward URLs explicitly via ` #### Activity entry point ```kotlin +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.metarouter.analytics.MetaRouter + class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -638,8 +643,10 @@ class MainActivity : AppCompatActivity() { private fun forwardDeepLink(intent: Intent?) { val uri = intent?.data ?: return - val source = intent.getStringExtra(Intent.EXTRA_REFERRER) - ?: referrer?.host + // `Activity.referrer` is the canonical Android API for the calling app's host. + // (Don't use `Intent.EXTRA_REFERRER` with `getStringExtra` — it's documented as + // a `Uri`, so the String overload returns null.) + val source = referrer?.host MetaRouter.Analytics.client().openURL(uri, source) } } @@ -665,6 +672,8 @@ Wire up your manifest as usual; nothing changes for the SDK side: Deep-link URLs frequently carry sensitive material — auth tokens, OTPs, magic-link secrets, query parameters with PII. The SDK forwards whatever URI you hand it and does not sanitize. Strip or redact secrets in the host before calling `openURL(...)`: ```kotlin +import android.net.Uri + private fun sanitize(uri: Uri): Uri { val sensitiveParams = setOf("token", "otp", "code", "auth", "secret") val builder = uri.buildUpon().clearQuery() @@ -678,7 +687,9 @@ private fun sanitize(uri: Uri): Uri { return builder.build() } -MetaRouter.Analytics.client().openURL(sanitize(intent.data!!), source) +intent.data?.let { uri -> + MetaRouter.Analytics.client().openURL(sanitize(uri), referrer?.host) +} ``` ### Why no auto-instrumentation?