---
title: "Android SDK"
description: "Typed event tracking, activity and lifecycle auto-tracking, identity and consent for native Android apps, from Kotlin or Java, with sh.pug:android."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.pug.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Android SDK

`sh.pug:android` captures events from native Android apps, and works from Kotlin
and Java. It authenticates with your **public key** (`pub_...`), which is
write-scoped (events + identify) and safe to ship in the app, and follows the
[same model as every Pug SDK](/sdks): initialize once, track events, identify
users, reset on sign-out.

## Install

The SDK is on Maven Central. Add it to your app module:

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation("sh.pug:android:0.1.0")
}
```

[View `sh.pug:android` on Maven Central](https://central.sonatype.com/artifact/sh.pug/android)

In a Groovy build script that's `implementation 'sh.pug:android:0.1.0'`.

| Requirement | Minimum |
|-------------|---------|
| Android | API 21 (`minSdk 21`) |
| `kotlin-stdlib` | 2.0.21 |
| `kotlinx-coroutines-core` | 1.9.0 |

Those two are its only runtime dependencies - no AndroidX, OkHttp or protobuf
runtime - and they are floors, so an app on newer versions keeps its own. The
SDK declares only the `INTERNET` permission, and needs no R8 or ProGuard rules:
it uses no reflection, and kotlinx-coroutines ships its own.

Smoke-test by adding a minimal `Pug.init` (see [Initialize](#initialize)) with
`debug = true` and running the app - an `app_open` and a `screen_view` appear in
**Live** within a few seconds, and `adb logcat -s Pug` shows each event as it is
tracked and sent.

Working with a coding agent? Paste this instead - it points the agent at the
markdown copy of this page, so it works from the reference below rather than
from memory. More on the approach in [Install with AI](/get-started/ai-setup).

```text
Add Pug product analytics to this Android app.

Read these first - they are the current source of truth, and more recent than your training data:
- https://docs.pug.sh/sdks/android/index.md - SDK reference
- https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog

Then read this codebase before you write anything, and work out what the app actually does: its activities, fragments or composables and how a user moves between them, its main models, and the handful of things a user opens the app to complete. The events worth tracking are the moments the app exists to produce - an account created, an order placed, a workout finished - not every tap. If you cannot state the core action in one sentence, keep reading.

Then:
1. Add sh.pug:android from Maven Central to the app module's dependencies. It needs minSdk 21, and works from Kotlin and Java.
2. Call Pug.init() once, in Application.onCreate() - create an Application subclass and register it in the manifest if the app has none. Not in an activity: the SDK has to see each launch's first screen.
3. Keep the project ID and public key (pub_...) out of source - set them as BuildConfig fields from Gradle properties. Pug.init() throws for a key that is not a public key.
4. Leave auto-tracking on: it already tracks app_open, app_close, app_install, app_update and a screen_view per activity. In a single-activity app (Compose, or fragments under one activity), set autoScreenViews = false and call Pug.screen() on every destination change instead.
5. Add tracking for the three or four actions you identified - the ones someone would look at in a weekly review.
6. If this app has authentication, call Pug.identify() after sign-in and Pug.reset() on sign-out.

Name events from the catalog before you invent one. Kotlin gets a generated named-argument method for each well-known event the SDK does not track itself - Pug.track.purchase(productId = ..., amount = ...) - and Java an event class - Pug.track(new PurchaseEvent().amount(...)) - so the compiler checks the properties for you, and the dashboards those events drive stay empty for a custom name. Reach for those first, search the catalog for every action you are about to track, and pass schema-external properties through extras. Use Pug.track(kind, properties) with a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not.

Android consent has two states, starts granted and is saved across launches, which is not the same as the Web SDK - check the consent section before you add a banner. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this app does, then list the files you changed, the event names you chose, and the Gradle properties I need to set.
```

## Initialize

Call `Pug.init` **once**, in `Application.onCreate`, with your project ID and
public key - every other call needs it first. Find your project ID in the
dashboard URL (`/p/<projectId>`), and your public key on your project
**Overview** or under **Settings > API Keys**.

```kotlin
import android.app.Application
import sh.pug.android.Options
import sh.pug.android.Pug

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Pug.init(this, "YOUR_PROJECT_ID", Options("pub_your_public_key"))
    }
}
```

If the app has no `Application` subclass yet, register this one on the
`<application>` element of your manifest with `android:name=".App"`. Initializing
there, before any activity starts, is what lets the SDK see the first screen of
each launch; initialized later, it tracks from the next activity on.

`Pug.init` **throws `IllegalArgumentException`** for a blank project ID, a key
that isn't a public key (`pub_...`) or that holds a space or control character -
such as a trailing newline - or an endpoint that isn't an http(s) URL. It does no
disk or network work on the calling thread, so it is safe on the main thread
under StrictMode, and the SDK's sockets carry a `TrafficStats` tag, so
`detectUntaggedSockets` doesn't flag them. A second `Pug.init` is ignored with a
warning until `Pug.destroy()`, and so is any call made before `Pug.init`.

Every other call returns at once, can be made from any thread, and **never
throws**: calls run in order on one background thread, and failures are logged
rather than raised.

Options are set in a block after the key; the ones left out keep their defaults.
From Java, build them with `new Options.Builder("pub_...")` - see
[From Java](#from-java).

```kotlin
Options("pub_your_public_key") {
    debug = BuildConfig.DEBUG
    batchMaxSize = 20
    defaultTrackingConsent = TrackingConsent.DENIED
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `endpoint` | `String` | `https://api.pugs.dev` | API base URL: set it only when self-hosting. |
| `debug` | `Boolean` | `false` | Log debug messages: each event tracked, sent or dropped, and why. Warnings and errors are logged regardless. |
| `dryRun` | `Boolean` | `false` | Build and log events without sending anything; `identify` isn't sent either. Consent is unaffected. |
| `batchMaxSize` | `Int` | `10` | Events per request, at most 1000: a batch goes out once this many are queued, or `batchMaxWaitMs` after the first of them. |
| `batchMaxWaitMs` | `Long` | `5000` | How long the first queued event waits for a full batch. |
| `batchMaxQueueSize` | `Int` | `1000` | Undelivered events to keep; beyond this, the oldest is dropped. |
| `sessionIdleTimeoutMs` | `Long` | 30 min | A session ends after this long without events. |
| `sessionMaxDurationMs` | `Long` | 24 h | A session ends this long after it began. |
| `defaultTrackingConsent` | `TrackingConsent` | `GRANTED` | Consent until the user chooses. See [Tracking consent](#tracking-consent). |
| `persistTrackingConsent` | `Boolean` | `true` | Save the user's choice for the next launch. See [Tracking consent](#tracking-consent). |
| `autoOpenCloseEvents` | `Boolean` | `true` | Track `app_open` and `app_close`. Screen views and install events have their own switches. See [Auto-tracking](#auto-tracking). |
| `autoScreenViews` | `Boolean` | `true` | Track a `screen_view` each time an activity resumes on a different screen. |
| `autoInstallEvents` | `Boolean` | `true` | Track `app_install` and `app_update`. |
| `logger` | `Logger` | Logcat, tag `Pug` | Receive the SDK's messages instead. A logger that throws is contained. |
| `storage` | `Storage` | files | Keep the SDK's state somewhere else. `MemoryStorage()` keeps nothing on the device. |

Nothing in `Options` throws: a number out of range is logged and replaced by its
default, and a `batchMaxSize` above 1000 is capped.

### Delivery and storage

Events are queued and sent in batches, one request at a time. A request that
fails for a reason that may pass - no network, a timeout, a server error, a
captive portal - is retried with exponential backoff, from `batchMaxWaitMs` (at
least a second) up to five minutes. One the server rejects for good is dropped
and logged as an error. Requests are capped at 4 MiB, so a single event over
that, which only very large properties make, is dropped when tracked.

Undelivered events, identity and consent live in files under the app's
`noBackupFilesDir`, so they survive restarts but aren't restored onto another
device by Auto Backup. Writes are grouped and land within a second, and at once
when the app leaves the foreground, on `Pug.flush()` and on `Pug.destroy()`. If
a custom `storage` throws, the SDK falls back to memory until `Pug.destroy()` or
the process ends.

In a multi-process app, call `Pug.init` in one process only, usually the main
one - processes would otherwise share the SDK's files.

## Track events

Events are the foundation of every analytic in Pug. The first argument is the
event **name** (the `kind`): custom names work without a schema, while
[well-known events](/reference/well-known-events) add typed properties and
richer dashboards.

```kotlin
// Custom event - any name
Pug.track("button_clicked", mapOf("label" to "Sign up", "screen" to "pricing"))

// Typed well-known event - compile-time checked
Pug.track.purchase(productId = "sku-1", amount = 99.50, currency = "USD")
```

A kind may contain letters, digits, `_`, `.` and `-`; any other kind is dropped
with a warning. Properties are read during the call, so the map can be changed
or reused as soon as `track` returns. A key starting with `$` is reserved for
[auto-properties](/reference/auto-properties) and dropped with a warning - the
property, not the event.

**Property values** map by type:

| Value | Sent as |
|-------|---------|
| `String`, `CharSequence`, `Char`, an enum (its name) | string, cut to 1024 UTF-8 bytes |
| `Boolean` | bool |
| `Int`, `Long`, `Short`, `Byte`, `BigInteger`, unsigned integers, `AtomicInteger`, `AtomicLong` | int - one beyond the signed 64-bit range as a string of its digits |
| `Double`, `Float`, any other `Number` | double - `NaN` and infinities are dropped |
| `Date`, `Calendar`, `Instant` (API 26+) | timestamp |
| `Map`, `Iterable`, arrays | JSON string |
| `null` | omitted |
| anything else, such as `UUID` or `Uri` | its `toString()`, cut to 1024 UTF-8 bytes |

`TrackOptions` (on `Pug.track` and every typed method):

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `immediate` | `Boolean` | `false` | Send the event on its own right away instead of with the next batch. If that send fails, it joins the queue for a retry. |
| `timestamp` | `Long?` | time of the call | Override the event time as **epoch milliseconds**, for an event recorded after the fact. A time before 1970 or after 9999 is logged and replaced by the time of the call. |

```kotlin
Pug.track("order_completed", mapOf("orderId" to "o-1"), TrackOptions { immediate = true })
```

`Pug.track` is also a namespace with a generated method per well-known event,
118 of them: every event in the catalog but the four web-only ones (`page_view`,
`click`, `rage_click`, `dead_click`) and the five the SDK
[tracks itself](#auto-tracking). An event's properties are its arguments -
required ones first and non-null, optional ones nullable with a default - and
two more follow: `extras` for properties the schema doesn't define, and
`options`.

```kotlin
Pug.track.search(query = "trail shoes")
Pug.track.videoPlay(videoId = "v1", positionMs = 1_500)
Pug.track.addToCart(
    productId = "sku-1",
    extras = mapOf("coupon" to "SPRING"),
    options = TrackOptions { immediate = true },
)
```

An extra named like an argument is dropped with a warning when that argument is
set. Durations are milliseconds in a parameter ending in `Ms`, and are sent in
seconds as text: `positionMs = 1_500` sends `position` as `"1.5s"`. Values are
sent as given - the schemas name and type the properties, but ranges and
patterns aren't checked on the device.

The kinds are constants in `Events` (`Events.ADD_TO_CART` is `"add_to_cart"`).
Tracking a well-known kind with `Pug.track(kind, properties)` works too, and a
whole number given for a decimal property is sent as a decimal (`"price" to 10`
arrives as `10.0`), so both paths fill the same column. With `debug` on, such an
event missing a required property is logged.

### From Java

```java
Pug.init(this, "YOUR_PROJECT_ID",
        new Options.Builder("pub_your_public_key")
                .debug(BuildConfig.DEBUG)
                .defaultTrackingConsent(TrackingConsent.DENIED)
                .build());

Map<String, Object> properties = new HashMap<>();
properties.put("cartId", "c1");
Pug.track("checkout_started", properties);
Pug.track("order_completed", properties, new TrackOptions.Builder().immediate(true).build());

Pug.track(new PurchaseEvent().orderId("o-1").amount(89).currency("USD").extra("coupon", "FALL10"));
Pug.track(new AddToCartEvent("sku-1").price(9.99).quantity(2));
Pug.track.signup(); // an event without properties has no class
```

Each well-known event with properties has a class in `sh.pug.android.events`:
the properties the event requires are constructor arguments, every other
property has a setter (numbers as primitives, so `amount(89)` compiles), and
`extra(key, value)` adds one the event doesn't define. A property never set
isn't sent. From Java, `Pug.track` has methods only for the events without
properties, such as `Pug.track.signup()`, so each event has one typed form
there. A `null` passed for a required argument drops the event with a warning
instead of throwing, and the `Events` constants work in a `switch`.

## Identify and sessions

Identity turns anonymous events into actionable profiles. The pattern: events
accumulate against an anonymous ID, you `Pug.identify()` on sign-in to merge
that history into a known profile, and `Pug.reset()` on sign-out. Use a
**stable, unique** external ID - your database user ID, not an email (which can
change). Only the **first** identify after an anonymous session merges the prior
activity; the same external ID always resolves to the same profile, and traits
are merged (never removed) server-side.

```kotlin
// On sign-in
Pug.identify("user-123", mapOf("email" to "user@example.com", "plan" to "pro"))

// On sign-out
Pug.reset()

// New session ID, identity kept
Pug.rotateSession()
```

`Pug.identify(externalId, traits)` returns at once and **never throws**. Once the
server accepts it, events carry the external ID as their distinct ID. Traits may
nest maps and lists. A blank ID, or one starting with the reserved
`cookieless-` prefix, is rejected with an error in the log. A failed `identify`
is logged and not retried, so events keep their current distinct ID until a
later call succeeds.

`reset()` starts a new anonymous ID and session and forgets the external ID (the
profile stays in the dashboard). Events queued before it keep the user they were
tracked for, and it sends them without waiting for the next batch.
`Pug.flush()` sends what's queued now and writes the SDK's state to disk;
`Pug.destroy()` stops the SDK after a final flush of at most ten seconds, keeping
identity, saved consent and undelivered events for the next `Pug.init`, which
waits for the teardown to finish.

| Function | Forgets the external ID | New anonymous ID | New session ID |
|----------|-------------------------|------------------|----------------|
| `rotateSession()` | No | No | Yes |
| `reset()` | Yes | Yes | Yes |

### Sessions

Sessions group events within a single visit. The SDK creates a session ID on the
first event, extends it on each event within the idle window, and starts a new
one once the idle or max-duration timeout passes - identity is preserved across
the boundary.

1. **First event** (session ID created)
2. **Events within timeout** (same session ID)
3. **Idle past timeout** (session expires)
4. **Next event** (new session ID (identity kept))

The session is stored with the rest of the SDK's state and restored on the next
launch, with the timeouts checked again. Configure them at
[init](#initialize) with `sessionIdleTimeoutMs` and `sessionMaxDurationMs`. For
the anonymous->identified model, see [Core concepts](/get-started/concepts).

## Auto-tracking

With the defaults, the SDK tracks the app's lifecycle, its activities and its
installs with no `Pug.track` calls:

| Event | When | Option |
|-------|------|--------|
| `app_open` | The app comes to the foreground: an activity starts while none was started. | `autoOpenCloseEvents` |
| `app_close` | The app leaves the foreground: its last started activity stops, but not for a rotation or other configuration change. Sent at once, not batched. | `autoOpenCloseEvents` |
| `screen_view` | An activity resumes on a different screen: `screenName` is its class's simple name, `screenClass` the full one. | `autoScreenViews` |
| `app_install` | The first launch with the SDK on this device, with `appVersion` and `installSource`. | `autoInstallEvents` |
| `app_update` | The first launch of a new app version, with `appVersion` and `previousVersion`. | `autoInstallEvents` |

Every event also carries the SDK's
[auto-properties](/reference/auto-properties#sdk-set-properties-android) -
device, OS, app version, display, locale, time zone and the current screen - and
the server adds geo. `$networkType` is set only when your app holds
`ACCESS_NETWORK_STATE`, which the SDK doesn't declare. The device name is never
collected.

When the app leaves the foreground the SDK sends what is queued and writes its
state to disk, whatever `autoOpenCloseEvents` says, since the process may be
frozen or killed from then on.

**Screens** are activities until you name them. An activity that resumes after
being only paused, as under a permission dialog, is the same screen and isn't
tracked again. For a screen that isn't an activity, such as a composable or a
fragment, call `Pug.screen`:

```kotlin
Pug.screen("Settings")
```

It tracks a `screen_view` unless that screen is already the current one, and
every event after it carries the screen as `$url` and the one before as
`$referrer`. Activities feed the same state, so in a **single-activity app** -
Compose, or fragments under one activity - set `autoScreenViews = false` and name
every screen yourself; otherwise the activity is reported as the screen each
time it resumes, as when the app returns to the foreground. With Jetpack
Navigation, a destination listener does it:

```kotlin
navController.addOnDestinationChangedListener { _, destination, _ ->
    (destination.route ?: destination.label)?.let { Pug.screen(it.toString()) }
}
```

**Installs and updates** are decided once at launch, from the SDK's own state on
the device: `app_install` when the SDK has never run there, and `app_update`,
carrying `previousVersion`, on the first launch after `versionName` changes. An
app without a `versionName` reports neither. `installSource` is the package name
of the app that installed yours, such as `com.android.vending` for Google Play,
and is left out when none is recorded, as for an app installed with `adb`.

Because the check reads the SDK's state rather than the app's install date,
**adding the SDK to an app that is already out** reports each existing user's
first launch with it as an `app_install`. Turning `autoInstallEvents` on later,
in an app already shipping the SDK, doesn't, and neither does a
destroy-and-init cycle.

## Consent and privacy

> **Not the Web SDK's default**
>
> **Android defaults to granted** and writes identity from the first event. The
> [Web SDK](/sdks/web#tracking-consent) defaults to `'cookieless'`: events flow,
> nothing is stored on the device. They are not interchangeable, so an app that
> ships both surfaces needs the Android side configured explicitly if you want the
> Web behaviour.

### Tracking consent

`sh.pug:android` gates capture with **two** states, `TrackingConsent.GRANTED`
and `TrackingConsent.DENIED`, and **starts granted**. To wait for the user's
choice, start denied, and show your banner while the choice is still pending:

```kotlin
Pug.init(this, "YOUR_PROJECT_ID", Options("pub_your_public_key") {
    defaultTrackingConsent = TrackingConsent.DENIED
})

Pug.whenConsentReady { if (Pug.isConsentPending()) showConsentBanner() }

Pug.optInTracking()   // the user accepted
Pug.optOutTracking()  // the user declined
```

The choice is saved, so the banner shows only until the user answers, and an
opt-out made in your settings screen still holds after a restart. While consent
is denied, events and `identify` are dropped, not kept for later. Opting out
drops queued events unsent and removes the stored identity; opting in again
starts a new anonymous identity.

When the user opts in, the SDK tracks what consent held back: an `app_install`
or `app_update` it couldn't track at launch and, if the app is on screen,
`app_open` and a `screen_view` of the current screen. A consent banner on the
first launch therefore loses none of them.

The consent getters answer at once and never wait. Right after `Pug.init`, while
the SDK is still reading the saved choice on its own thread, they answer "not
known yet" - which is why the banner check above runs in
`Pug.whenConsentReady { ... }`, whose block runs on the main thread as soon as
they answer for this user, or on the main thread's next turn if they already do.

| Call | Returns | Not known yet |
|------|---------|---------------|
| `getTrackingConsent()` | The consent in effect | `null` |
| `isTrackingEnabled()` | Whether consent is granted | `false` |
| `isConsentPending()` | Whether the user has yet to choose | `true` |

If your app keeps the choice itself and calls `optInTracking()` or
`optOutTracking()` on every launch, set `persistTrackingConsent = false`: the
choice then lasts until `Pug.destroy()` or the process ends. Consent is
independent of `dryRun`, which suppresses delivery without changing consent.

### Redacting PII

`sh.pug:android` has no device-side redaction - no `beforeSend` hook - so keep
PII out of the properties you pass to `Pug.track` yourself.

## Upgrading

**0.1.0** is the first release of `sh.pug:android`, so there is nothing to
upgrade from yet.

Source: https://docs.pug.sh/sdks/android/index.mdx
