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.

View on GitHub GitHub release

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

MethodPurpose
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

MethodPurpose
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

MethodPurpose
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

MethodPurpose
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 tools

Configuration 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)
    )
)
OptionRequiredDefaultDescription
writeKeyYes—Your MetaRouter write key. Must not be empty.
ingestionHostYes—Your MetaRouter ingestion endpoint. Must be a valid http/https URL with no trailing slash.
flushIntervalSecondsNo10How often the SDK attempts to send queued events. Values below 1 are clamped to 1.
debugNofalseEnables verbose MetaRouter logcat output. Can also be toggled at runtime via analytics.enableDebugLogging().
maxQueueEventsNo2000Maximum events held in the in-memory queue. Must be greater than 0. Queue is also bounded by a 5 MB byte cap.
maxDiskEventsNo10000Maximum unsent events retained on disk for crash safety and offline recovery. Set to 0 to disable disk persistence. Negative values are clamped to 0.
trackLifecycleEventsNofalseAutomatically emit the four Application * lifecycle events.
sessionTimeoutMinutesNo30Minutes of inactivity before the next event starts a new session. Values below 1 are clamped to 1.
fireSessionStartedNofalseEmit 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).
onConfigErrorNonullCalled 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 IllegalArgumentException immediately, so configuration mistakes surface during development.
  • Release builds never crash your app: the SDK logs an error, calls youronConfigError callback if you set one, and stays inert for the session — no events are sent, and every API remains safe to call. Callinginitialize again 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

EventWhen it firesProperties
Application InstalledFirst launch after a fresh install (no prior identity or lifecycle state on the device)version, build
Application UpdatedFirst launch after the app's version/build changes — also the first launch after upgrading from a pre-lifecycle SDK buildversion, build, previous_version, previous_build ("unknown" for the SDK-upgrade case)
Application OpenedCold launch while in the foreground, and each background → foreground resumeversion, build, from_background (false on cold launch, true on resume), plus url / referring_application when a deep link was recorded
Application BackgroundedApp 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.

MethodPurpose
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 calling recordOpenedUrl(...).

Notes

  • Disabling disk persistence — maxDiskEvents = 0 puts the queue in pure in-memory ring buffer mode. Oldest events are dropped when maxQueueEvents (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 on reset() 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 loadUrl registrations 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 — true when the user made an explicit choice, false for an implicit or regional default. You set it.
  • optOut — opt-out semantics: true means 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

MetaRouter.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 default

Before 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(). A null value 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. metarouter is reserved for the SDK's session block. A rejected write returns false and logs a warning.

Requirements

ComponentVersion
AndroidAPI 23+ (6.0 Marshmallow)
Kotlin2.0+