Android
MetaRouter Android SDK
Capture behavioral data directly from your Android app and route it through MetaRouter to any destination — without juggling multiple vendor SDKs.
Why use the Mobile SDK?
One integration, unlimited destinations
Stop embedding separate SDKs for analytics, marketing, and data warehouses. Collect once, route everywhere through MetaRouter.
Update routing without app releases
Add or remove destinations server-side. No code changes, no app store review cycles.
Reliable delivery, even offline
Events are queued locally and delivered automatically when connectivity returns. Built-in retry logic handles transient failures.
Automatic identity resolution
Track users from first app open through sign-up and beyond. The SDK manages anonymous IDs, persists identity across sessions, and connects pre-login activity to known users.
Privacy-ready by design
Push your consent management platform's decisions into the SDK and every event carries them, in the same shape as the web SDK, so your cluster enforces one set of consent rules for web and mobile. Built-in support for advertising ID consent, easy opt-out handling, and clean user data reset for GDPR/CCPA compliance.
Lightweight footprint
No bloated dependencies. Minimal impact on app size and startup time.
Quick start
1) Add the SDK
Add JitPack to your project's settings.gradle.kts:
dependencyResolutionManagement {
repositories {
maven { url = uri("https://jitpack.io") }
}
}Then add the dependency in your module’s build.gradle.kts:
dependencies {
implementation("com.github.metarouterio:android-sdk:1.6.0")
}2) Initialize
Add this to your Application.onCreate():
val analytics = MetaRouter.Analytics.initialize(
context = applicationContext,
options = InitOptions(
writeKey = "YOUR_WRITE_KEY",
ingestionHost = "https://YOUR_CLUSTER.YOUR_SITE.com"
)
)Your writeKey and ingestionHost are available in the MetaRouter dashboard.
From 1.6.0 you can also reach the client anywhere, including before initialize, through MetaRouter.Analytics.shared:
MetaRouter.Analytics.shared.track("Order Completed")3) Start tracking
// Track user actions
analytics.track(
"Product Viewed",
"sku" to "SHOE-123",
"price" to 89.99
)
// Identify known users
analytics.identify(
"user-456",
"email" to "[email protected]",
"plan" to "premium"
)
// Track screen views
analytics.screen(
"Product Detail",
"category" to "Footwear"
)Events are batched and delivered automatically. No additional setup required.
Core capabilities
Event tracking
| Method | Purpose |
|---|---|
track(event, properties) | Capture user actions — purchases, clicks, feature usage |
screen(name, properties) | Record screen views for navigation analytics |
page(name, properties) | Record page views (web semantics, if applicable) |
User identity
| Method | Purpose |
|---|---|
identify(userId, traits) | Associate events with a known user |
group(groupId, traits) | Associate users with a company, team, or account |
alias(newUserId) | Link anonymous activity to a newly identified user |
getAnonymousId() | Returns the anonymous ID the SDK assigned to this device |
Lifecycle & privacy
| Method | Purpose |
|---|---|
flush() | Send queued events immediately |
reset() | Clear identity and queued events (use on logout); consent and provider context are kept |
setAdvertisingId(id) | Set GAID for attribution (with user consent) |
clearAdvertisingId() | Remove GAID when user opts out |
Consent & provider context
| Method | Purpose |
|---|---|
registerConsentCategories(categories) | Declare the consent categories your pipeline gates on (before initialize) |
setConsent(consent) | Push the user's current consent; stamped on every event |
getConsent() / clearConsent() | Read the current consent / withdraw it |
subscribeToConsent { } | Observe consent changes on the main thread |
setProviderContext(provider, key, value) | Stamp a vendor value (e.g. Firebase appInstanceId) on every event |
Identity that just works
The SDK automatically handles the complexity of user identity:
Before login — Users are assigned a stable anonymous ID
On login — Call identify() to attach a known user ID
Across sessions — Identity persists through app restarts
On logout — Call reset() to clear everything and start fresh
Connect anonymous browsing to authenticated users with alias():
// User browses anonymously, then signs up
analytics.track("Product Viewed", "sku" to "ABC") // tracked as anonymous
// User creates account
analytics.alias("new-user-789") // links anonymous → known
analytics.identify("new-user-789", "email" to "[email protected]")
// Full journey is now connected in your downstream toolsConfiguration Options
Use InitOptions to configure delivery cadence, logging, in-memory buffering, and disk-backed persistence.
val analytics = MetaRouter.Analytics.initialize(
context = applicationContext,
options = InitOptions(
writeKey = "YOUR_WRITE_KEY", // Required
ingestionHost = "https://YOUR_CLUSTER.mr-in.com", // Required
flushIntervalSeconds = 10, // Optional (default: 10)
debug = false, // Optional (default: false)
maxQueueEvents = 2000, // Optional (default: 2000)
maxDiskEvents = 10000, // Optional (default: 10000)
sessionTimeoutMinutes = 30, // Optional (default: 30)
fireSessionStarted = false // Optional (default: false)
)
)| Option | Required | Default | Description |
|---|---|---|---|
writeKey | Yes | — | Your MetaRouter write key. Must not be empty. |
ingestionHost | Yes | — | Your MetaRouter ingestion endpoint. Must be a valid http/https URL with no trailing slash. |
flushIntervalSeconds | No | 10 | How often the SDK attempts to send queued events. Values below 1 are clamped to 1. |
debug | No | false | Enables verbose MetaRouter logcat output. Can also be toggled at runtime via analytics.enableDebugLogging(). |
maxQueueEvents | No | 2000 | Maximum events held in the in-memory queue. Must be greater than 0. Queue is also bounded by a 5 MB byte cap. |
maxDiskEvents | No | 10000 | Maximum unsent events retained on disk for crash safety and offline recovery. Set to 0 to disable disk persistence. Negative values are clamped to 0. |
trackLifecycleEvents | No | false | Automatically emit the four Application * lifecycle events. |
sessionTimeoutMinutes | No | 30 | Minutes of inactivity before the next event starts a new session. Values below 1 are clamped to 1. |
fireSessionStarted | No | false | Emit a Session Started track event each time a new session begins — for destinations that need an explicit session-start signal (e.g. GA4's session_start). |
onConfigError | No | null | Called synchronously by initialize when the options were constructed from invalid configuration. See Invalid configuration below. |
Invalid configuration
Requires SDK 1.5.0+.
Constructing InitOptions never throws. If the configuration is invalid — an empty write key, or an ingestion host that isn't an http(s) URL with a host — the SDK responds by build type when you call initialize:
- Debug builds throw
IllegalArgumentExceptionimmediately, so configuration mistakes surface during development. - Release builds never crash your app: the SDK logs an error, calls your
onConfigErrorcallback if you set one, and stays inert for the session — no events are sent, and every API remains safe to call. Callinginitializeagain with valid configuration recovers the session.
Numeric options out of range (flushIntervalSeconds, maxQueueEvents, maxDiskEvents,sessionTimeoutMinutes) are clamped to their nearest valid value with a logged warning rather than failing.
val options = InitOptions(
writeKey = writeKeyFromRemoteConfig,
ingestionHost = hostFromRemoteConfig,
onConfigError = { error ->
crashReporter.log("MetaRouter disabled: ${error.description}")
}
)getDebugInfo()["configError"] carries the error description for a refused session, so misconfiguration is diagnosable from the same API as every other SDK state.
Lifecycle Events
Turn on automatic tracking of the four standard application-lifecycle events — Application Installed, Application Updated, Application Opened, and Application Backgrounded — to anchor attribution, retention, and session reporting without instrumenting each one by hand. They flow through the same pipeline as your own track calls.
Enable it
Lifecycle events are opt-in. Set trackLifecycleEvents = true in InitOptions:
val analytics = MetaRouter.Analytics.initialize(
context = applicationContext,
options = InitOptions(
writeKey = "your-write-key",
ingestionHost = "https://your-ingestion-endpoint.com",
trackLifecycleEvents = true,
)
)With the flag left at its default (false), the SDK emits none of these events and recordOpenedUrl(...) is a no-op. Upgrading the SDK never turns them on silently — an app has to opt in.
The events
| Event | When it fires | Properties |
|---|---|---|
Application Installed | First launch after a fresh install (no prior identity or lifecycle state on the device) | version, build |
Application Updated | First launch after the app's version/build changes — also the first launch after upgrading from a pre-lifecycle SDK build | version, build, previous_version, previous_build ("unknown" for the SDK-upgrade case) |
Application Opened | Cold launch while in the foreground, and each background → foreground resume | version, build, from_background (false on cold launch, true on resume), plus url / referring_application when a deep link was recorded |
Application Backgrounded | App moves to the background | — |
On cold launch, Application Installed/Updated fire before Application Opened, so attribution sees the install or update ahead of the session start. Foreground and background transitions are driven by ProcessLifecycleOwner, so they track the whole app process rather than individual activities.
Install/update state is stored per-device, in a namespace separate from identity — calling reset() clears the user but keeps the install/update history.
Deep links
The SDK doesn't auto-capture deep links; you forward the URL so it can be attached to the next Application Opened event.
| Method | Purpose |
|---|---|
recordOpenedUrl(uri: Uri, sourceApplication: String? = null) | Record a deep-link URL to attach to the next Application Opened event. |
Call it from your entry activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
forwardDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
forwardDeepLink(intent)
}
private fun forwardDeepLink(intent: Intent?) {
val uri = intent?.data ?: return
// `Activity.referrer` gives the calling app's host.
MetaRouter.Analytics.client().recordOpenedUrl(uri, referrer?.host)
}
}The next Application Opened the SDK emits carries url (and referring_application when you pass a source). The buffer is one-shot — cleared once that event fires — and last-write-wins if you record more than one URL before it.
Deep-link URLs often carry secrets — auth tokens, OTPs, magic-link codes. The SDK forwards the URL as-is and does not sanitize it. Strip sensitive query parameters before callingrecordOpenedUrl(...).
Notes
- Disabling disk persistence —
maxDiskEvents = 0puts the queue in pure in-memory ring buffer mode. Oldest events are dropped whenmaxQueueEvents(or the 5 MB byte cap) is hit, and events will not survive process kill. Use for privacy-constrained or ephemeral environments. - Snapshot location — Disk snapshots are stored under
noBackupFilesDir(excluded from Android Auto Backup) and are cleared onreset()or after successful rehydration on next launch.
Advertising & attribution
For ad attribution, you can include the Google Advertising ID (GAID):
// Only after obtaining user consent
analytics.setAdvertisingId(advertisingId)
// When user opts out
analytics.clearAdvertisingId()Privacy note: Always obtain explicit consent before collecting advertising IDs. See the GitHub docs for implementation details:
https://github.com/metarouterio/android-sdk#advertising-id-gaid
WebView Bridge
Hybrid screens don't have to be a blind spot. If parts of your app render as web content inside a WebView, the bridge captures the events your pages fire and delivers them through the native SDK, so web and native activity arrive at your cluster as one user, one pipeline, with native identity and device context on every event.
Attach each WebView when you create it, before loadUrl:
val webView = WebView(context)
analytics.attachWebView(webView, allowedOrigins = listOf("https://www.example.com"))
webView.loadUrl("https://www.example.com/checkout")Pages loaded from an allowed origin get a window.metarouterBridge object with track and page methods. Properties can be a plain object or a JSON string so a typical integration is a few lines in your existing tag manager:
function logEvent(name, params) {
if (!window.metarouterBridge) return;
var value = JSON.stringify(params);
name === 'page_view'
? window.metarouterBridge.page(name, value)
: window.metarouterBridge.track(name, value);
}The page surface is identical on Android and iOS, one web integration serves both apps.
What arrives at your cluster: the page contributes the event and its page facts (context.page: url, path, search, title, referrer); the SDK contributes everything durable: anonymousId/userId, device and app context, timestamps, message IDs, and — from 1.6.0 — the current consent and any provider context your app has set. Duplicate deliveries are dropped automatically, and every message gets an acknowledgment or a specific error code (malformed input is rejected), never silently lost.
Things to remember:
- Attach before
loadUrlregistrations only apply to page loads that start afterwards. - Origins are explicit (
https://host[:port]), no wildcards. The allowlist is what keeps ads and redirects in the same WebView from injecting events into your stream. - Attachment survives
reset()and re-initialization; no re-attach needed. - On devices whose WebView provider lacks the required features, the bridge logs a warning and captures nothing — it never falls back to an unscoped mechanism.
Debugging: adb logcat -s MetaRouter shows attach confirmations, rejected messages with their error codes, and duplicate drops. From Chrome DevTools (chrome://inspect), check typeof window.metarouterBridge, or watch native replies live with window.__metaRouterNativeChannel.onmessage = e => console.log(e.data).
Requires SDK 1.4.0+.
Session Tracking
Requires SDK 1.5.0+.
Every event automatically carries the analytics session it belongs to:
"context": {
"providers": {
"metarouter": {
"sessionID": "1757400000000",
"sessionCount": 3
}
}
}This is the same path and shape the MetaRouter web SDK produces, so pipeline mappings — for example GA4's session_id and session_number — read one field no matter which platform sent the event. sessionID is the epoch-millisecond timestamp of the session's first event, as a string;sessionCount is a lifetime counter for the install, starting at 1.
There is nothing to enable. A session starts with the first event after launch, extends with every tracked, lifecycle, or WebView-bridge event, and ends after sessionTimeoutMinutes (default 30) of inactivity — the window slides, so continuous activity keeps one session alive indefinitely. Sessions survive app restarts that happen inside the window, and survive reset(): a user logging out mid-session does not split the session.
Session Started event
Set fireSessionStarted = true to emit a Session Started track event (no properties) each time a new session begins:
val options = InitOptions(
writeKey = "YOUR_WRITE_KEY",
ingestionHost = "https://your-ingestion-host.com",
sessionTimeoutMinutes = 30, // default
fireSessionStarted = true // default false
)It is off by default, so upgrading the SDK never changes your event volume.
Reading the current session
val sessionId: String? = analytics.getSessionId()Returns null before the first event of the process — sessions are created by events, and reading the id never starts or extends one. The currentsessionId and sessionCount also appear in getDebugInfo().
Consent
Requires SDK 1.6.0+.
Your app owns consent acquisition — a CMP SDK callback, a cookie read out of a WebView, your own preference screen. The SDK stores it, completes it, and puts it on every event at context.consent, in the same shape the MetaRouter web SDK emits, so your cluster enforces one set of per-integration rules for web and mobile:
"context": {
"consent": {
"explicit": true,
"optOut": { "c0001": false, "c0002": false, "c0004": true }
}
}explicit—truewhen the user made an explicit choice,falsefor an implicit or regional default. You set it.optOut— opt-out semantics:truemeans blocked. Keys are your CMP's category codes, lowercased.
Consent never gates delivery: events always send, and your cluster applies the rules per integration.
1) Register categories — before initialize
initializeMetaRouter.Analytics.shared.registerConsentCategories(listOf(
ConsentCategory("c0001", defaultOptOut = false), // strictly necessary
ConsentCategory("c0002"), // performance
ConsentCategory("c0004"), // targeting
))
MetaRouter.Analytics.initialize(applicationContext, options)Registration lets the SDK complete a sparse CMP payload: known categories keep their value, unknown ones are dropped, missing ones take their registered default (opt-out unless you say otherwise). Register before initialize, because launch events such as Application Opened are stamped as soon as they are created.
2) Push consent whenever it changes
MetaRouter.Analytics.shared.setConsent(Consent(
explicit = true,
optOut = mapOf("c0002" to false, "c0004" to true)
))The change takes effect immediately, persists across launches, and survives reset() — logging out never re-enables tracking the user opted out of. Only clearConsent() removes it. Until the first setConsent, events carry the implicit default (explicit: false, every category at its registered default).
If you pass the CMP's own epoch-millisecond timestamp, a snapshot older than one you already sent is ignored and setConsent returns false, so a cached replay cannot overwrite a fresher choice.
OneTrust
import com.metarouter.analytics.consent.adapters.OneTrustAdapter
// From the OneTrust SDK's category statuses
MetaRouter.Analytics.shared.setConsent(
OneTrustAdapter.normalize(mapOf("C0001" to 1, "C0002" to 1, "C0004" to 0), explicit = true)
)
// From the OptanonConsent cookie's `groups` value (percent-encoded or the whole cookie)
MetaRouter.Analytics.shared.setConsent(
OneTrustAdapter.normalize(groups = cookieValue, explicit = true)
)Pass explicit = true from the banner-interaction callback and false for a regional default. If the cookie is absent, don't call setConsent: an empty value reads as "opted out of everything". Any other CMP can build a Consent directly.
Observing and withdrawing
val subscription = MetaRouter.Analytics.shared.subscribeToConsent { consent ->
// Main thread: safe to update UI. Fires with the current value, then on every change.
}
subscription.close() // keep the subscription until you no longer need updates
MetaRouter.Analytics.shared.clearConsent() // back to the implicit defaultBefore initialize: the SDK registers a small AndroidX App Startup initializer so consent and provider context are saved and readable from process start, the same as on iOS. It runs before Application.onCreate, reads one small preferences file, and can be removed from your manifest — see the GitHub README for the opt-out and its trade-offs.
Provider Context
Requires SDK 1.6.0+.
Some destinations only accept an event that carries a vendor's own identifier — one your app can read but a web page cannot. GA4 app streams, for example, need the Firebase app instance id. setProviderContext stamps such a value onto every event, WebView-bridge events included:
import com.google.firebase.analytics.FirebaseAnalytics
// On every launch, as early as possible. The id arrives asynchronously; a null id
// (analytics collection disabled) removes the key.
FirebaseAnalytics.getInstance(this).appInstanceId.addOnSuccessListener { id ->
MetaRouter.Analytics.shared.setProviderContext("googleFirebase", "appInstanceId", id)
}"context": {
"providers": {
"googleFirebase": { "appInstanceId": "c0ffee1234567890abcdef" },
"metarouter": { "sessionID": "1757400000000", "sessionCount": 3 }
}
}- Provider and key names are sent exactly as written, since your pipeline mappings match them verbatim. Any provider your pipeline reads works.
- Values persist across launches and survive
reset(). Anullvalue removes the key;clearProviderContext("googleFirebase")removes a provider,clearAllProviderContext()removes everything. - On the install launch the Firebase id usually arrives after the first launch events; from the next launch the saved value covers them. Re-set values on every launch and clear ones that rotate.
- Bounded because it rides on every event: up to 10 providers, 10 keys each, 256 bytes per value.
metarouteris reserved for the SDK's session block. A rejected write returnsfalseand logs a warning.
Requirements
| Component | Version |
|---|---|
| Android | API 23+ (6.0 Marshmallow) |
| Kotlin | 2.0+ |
Updated 1 day ago