# Android SDK

Official Android client SDK - Kotlin, minSdk 21, zero third-party runtime dependencies. The API mirrors the Mixpanel Android SDK, so migrating is mostly a find-and-replace of MixpanelAPI to Cohorly.

## Install

Distributed via JitPack from the public `cohorly-io/cohorly-android`
repository:

```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google(); mavenCentral()
        maven("https://jitpack.io")
    }
}

// app/build.gradle.kts
dependencies {
    implementation("com.github.cohorly-io.cohorly-android:cohorly-android:v0.3.0")
}
```

The host app must declare the `INTERNET` permission.

## Initialize

Call `Cohorly.getInstance` once, e.g. in `Application.onCreate()`. One
instance per project token.

```kotlin
import com.cohorly.android.Cohorly

val cohorly = Cohorly.getInstance(context, "YOUR_PROJECT_TOKEN")

cohorly.track("Plan Selected", mapOf("plan" to "pro"))

cohorly.identify("user-123")           // links the anonymous id via /alias
cohorly.people.set("plan", "pro")      // profile update via /engage

cohorly.registerSuperProperties(mapOf("ab_test" to "variant_b"))

cohorly.flush() // also auto-flushes every 5s / 20 events
```

## API

| Member                                                                           | Notes                                                                                                                |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `Cohorly.getInstance(context, token, apiHost[, config])`                         | Singleton per token. `CohorlyConfig` accepts `flushIntervalMs`, `batchSize`, `maxQueueSize`, `trackAutomaticEvents`. |
| `track(name[, JSONObject \| Map])`                                               | Queue an event (persisted to SharedPreferences).                                                                     |
| `identify(id)` / `reset()`                                                       | Set the distinct id (sends /alias while anonymous) / new anonymous id on logout.                                     |
| `registerSuperProperties[Once] / unregisterSuperProperty / clearSuperProperties` | Super properties merged into every event.                                                                            |
| `people.set / setOnce / increment / unset / deleteUser`                          | Profile updates sent to /engage.                                                                                     |
| `optOutTracking() / optInTracking()`                                             | GDPR opt-out; drops queued events while opted out.                                                                   |
| `flush()`                                                                        | Send now, bypassing any retry backoff window.                                                                        |
| `getFeatureFlag(key[, fallback])` / `isFeatureEnabled(key[, fallback])`          | Read a flag's variant/enabled value; never hits the network.                                                         |
| `getFeatureFlagDetails(key[, fallback])`                                         | The value plus `source` (`network` / `persistence` / `fallback`) and cache metadata.                                 |
| `reloadFeatureFlags()` / `onFeatureFlags(listener)`                              | Re-evaluate flags on the worker thread / subscribe to updates.                                                       |

## Feature flags

Flags are evaluated on `getInstance` (opt out with
`CohorlyConfig(loadFeatureFlags = false)`), on `identify()` and on `reset()`,
always on the worker thread, and persisted to storage scoped by distinct id;
reads never hit the network.

`CohorlyConfig.flagPersistence` (default
`FlagPersistenceMode.persistenceUntilNetworkSuccess`) controls how reads resolve
against the persisted cache:

| Policy                           | Behavior                                                                                                                                                                                                                                                                                               |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `networkOnly`                    | Never reads nor writes the cache on the serving path (identity changes still remove it - that is hygiene, not serving). Flags come only from network responses this session; a failed evaluation serves the fallback.                                                                                  |
| `networkFirst`                   | Prefers the network, but reads are synchronous, so a valid cache (inside the TTL) is served immediately like `persistenceUntilNetworkSuccess`. Strict about freshness: a cache past the TTL is dropped, never served.                                                                                  |
| `persistenceUntilNetworkSuccess` | Serves a valid cache immediately, so startup doesn't flicker; the first network success takes over and re-persists. A failed evaluation keeps the cache serving. Trades freshness for offline availability: an expired cache still serves while inside `CohorlyCore.FLAG_CACHE_MAX_STALE_MS` (7 days). |

The cache carries a `stored_at` timestamp and expires after
`CohorlyConfig.flagCacheTtlMs` (default **24 hours** - the Mixpanel reference
default shared by every Cohorly SDK). A legacy cache with no timestamp cannot
be dated, so it is served indefinitely rather than expired - the
graceful-upgrade path preserving the pre-TTL behavior - until the next
successful reload rewrites it in the stamped format. `identify()` and
`reset()` clear the previous identity's flags before fetching the new
identity's, so values never leak across users - `onFeatureFlags` listeners
are notified of the clear with an empty map, then again when the new
identity's flags land.

```kotlin
val variant = cohorly.getFeatureFlag("checkout-copy", fallback = "control")
val details = cohorly.getFeatureFlagDetails("checkout-copy")
// details.source: FlagSource.network | .persistence | .fallback
// details.enabled / details.payload; a fallback result reports
// enabled = truthiness of the fallback and payload = null
// details.persistedAt / details.ttlMs when source == FlagSource.persistence
```

A read that resolves to the fallback reports no exposure. Reads of known
flags emit `$feature_flag_called` once per (key, value) per identity, stamped
with `$feature_flag_source` (`"network"` or `"persistence"`) plus
`$feature_flag_persisted_at` and `$feature_flag_ttl_ms` for persistence reads;
the same value served first from persistence and then from the network is
reported once.

## Auto-captured properties

Every event includes `$lib` = `"android"`, `$lib_version`, `$os`,
`$os_version`, `$manufacturer`, `$brand`, `$model`, `$screen_dpi`,
`$screen_height`, `$screen_width`, `$app_version_string`,
`$app_build_number`, `$carrier`, `$has_nfc`, `$has_telephone`,
`$bluetooth_version` and `$google_play_services`. `$wifi` and
`$bluetooth_enabled` are only collected when the app holds the
corresponding permission. Geo properties are derived server-side from the
client IP.

## Automatic events

Opt-in via `trackAutomaticEvents = true` (default off, matching the iOS
and React Native SDKs): `$ae_first_open` (once ever), `$ae_updated` (on
version change) and `$ae_session` with `$ae_session_length` when the app
backgrounds after a session of 10s or more.

The offline queue survives process death (SharedPreferences, capped at
1000 events, oldest dropped) and follows the shared Cohorly retry
contract: exponential backoff on 429/5xx/network errors honoring
`Retry-After`, batch halving on 413, permanent drop on 400.

## Crashes

Crash reporting is **off by default**. When on, the SDK reports these as
`$exception` events:

| What                                      | API level | Event                                                                                                                                                                                      |
| ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Uncaught Java/Kotlin exception            | all       | Mechanism `uncaught_exception`, level `fatal`, with the cause chain and other threads.                                                                                                     |
| ANR that killed the app                   | 30+       | Type `ANR`, mechanism `anr`, level `fatal`, main thread stack.                                                                                                                             |
| Native crash (NDK signal)                 | 30+       | Mechanism `exit_info`, level `fatal`. On API 31+ the type is the signal (for example `SIGSEGV`) with the tombstone frames. On API 30 there is no tombstone: type `NativeCrash`, no frames. |
| Low-memory kill in the foreground         | 30+       | Type `LowMemoryKill`, level `warning`.                                                                                                                                                     |
| Killed for excessive resource use         | 30+       | Type `ExcessiveResourceUsage`, level `warning`.                                                                                                                                            |
| Main thread blocked 5 s (opt-in watchdog) | all       | Type `ANR`, mechanism `anr`, level `error`.                                                                                                                                                |

Turn it on with the config flag. It starts on the **next** launch: the reporter
is installed by a startup `ContentProvider` before `Application.onCreate`, and
the config it reads is written by the current launch.

```kotlin
import com.cohorly.android.Cohorly
import com.cohorly.android.core.CohorlyConfig

Cohorly.getInstance(
    context,
    "YOUR_PROJECT_TOKEN",
    config = CohorlyConfig(nativeCrashReporting = true, anrWatchdog = false),
)
```

The flag persists the crash config under `noBackupFilesDir/cohorly-crash/` with
the release `<applicationId>@<versionName>+<versionCode>`, which is also the
key the `@cohorly/cli` Gradle plugin uploads R8 mappings under. `identify()`
and `reset()` keep the stored identity current, and `optOutTracking()` turns
crash reporting off until `optInTracking()`. Leaving the flag false never
touches that config, because a React Native layer may own it.

- **The handler chains.** It writes the report to disk, tries to send it for at
  most 2 seconds, then always calls the handler that was installed before it
  (Crashlytics, Sentry or the platform's). On the first activity resume it
  reinstalls itself on top if something replaced it without chaining.
- **Pending reports are sent at the next launch**, before anything else, one
  event per request, oldest first. A `400` or `413` drops the report; `401`,
  `429`, `5xx` and network errors keep it. At most 50 reports wait on disk.
- **Crash-time values travel with the report**: time, release, dist, identity,
  OS version and model. ANRs and native crashes are read from
  `ApplicationExitInfo` on the next launch, so their release and identity come
  from a small process state summary set at startup. Without one the event says
  `android@unknown`, never the release that happens to be running now.
- **Each exit is reported once.** The first launch with reporting on only
  records a watermark, so exits from before it are never sent.
- **Symbolication.** Java frames are sent as thrown, R8-obfuscated, and resolved
  on the server. Upload the R8 `mapping.txt` for each release with the
  [CLI](/cli).
- **Limits.** 20 threads and 100 frames per stack (the crashing end is kept).
  ANRs, native crashes and kills need API 30+. Do not enable it alongside
  another crash reporter's NDK or ANR handling.

Optional manifest meta-data overrides the token and host in the persisted
config, and `com.cohorly.ANR_WATCHDOG` enables the watchdog:

```xml
<application>
    <meta-data android:name="com.cohorly.ANR_WATCHDOG" android:value="true" />
</application>
```

See [Error tracking](/error-tracking) for how these events are grouped and
triaged.
