# Android

URL: https://docs.bithuman.ai/platforms/android

> Render Essence 2 and Expression 2 on Android phones, from bitHuman's Maven repository.

After the one-time model download, the only network traffic is usage reporting. [Why on the device](https://docs.bithuman.ai/deploy/on-device).

## Before you start

You feed 16 kHz mono speech in and pull picture frames out. Each model is one Gradle dependency, from bitHuman's Maven repository at `maven.bithuman.ai`.

| Detail | Expression 2 | Essence 2 |
|---|---|---|
| **Renders** | [any character from one portrait](https://docs.bithuman.ai/models/expression-2) | [a photoreal person from one portrait](https://docs.bithuman.ai/models/essence-2) |
| **Devices** | a physical `arm64-v8a` phone, `minSdk 26` | a physical `arm64-v8a` phone, `minSdk 29` |
| **Dependency** | `implementation("ai.bithuman:expression2-android:0.6.1")` | `implementation("ai.bithuman:essence2-android:0.9.5")` |
| **Credential** | an [API secret](https://docs.bithuman.ai/start/api-secret), Creator plan or higher | an API secret, Creator plan or higher |
| **First-run download** | about 160 MB | 226–281 MB |
| **Adds to your APK** | about 3 MB, plus a 70 MB accelerator runtime you can leave out | 12.1 MB |
| **Worked example** | [Android Expression 2](https://docs.bithuman.ai/examples/android-expression-2) | [Android Essence 2](https://docs.bithuman.ai/examples/android-essence-2) |

- **JDK 17, Gradle 8.11 or newer and Android Gradle Plugin 8.7 or newer.**
- **A paid plan** (Creator or higher): usage bills per second while the avatar runs ([pricing](https://docs.bithuman.ai/pricing)).
- **A physical arm64 phone.** Emulators cannot load the engines.
- **Frame rate depends on the phone.** Essence 2 plays at 25 frames a second, and the phone has to render at least that fast to keep up with live speech. Recent flagship chips render well above that; older ones, such as the Snapdragon 8 Gen 2 in a Galaxy Z Flip5, can fall below real time, more so as the phone warms. The [performance page](https://docs.bithuman.ai/performance) has the measured rates. Test on the phones your app targets.
- **Essence 1** is not available on phones: use Essence 2 or Expression 2 on devices ([First generation](https://docs.bithuman.ai/models/first-generation)).

## Install

Add bitHuman's Maven repository for the `ai.bithuman` group, restrict the build to `arm64-v8a`, and turn on legacy packaging so the engines' native libraries are extracted to disk. Merge the lines into your existing app module, which keeps its own `namespace`, `compileSdk` and Java 17 targets. The API secret reaches your code through `BuildConfig`.

```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        exclusiveContent {   // ai.bithuman resolves from bitHuman's repository only
            forRepository { maven { url = uri("https://maven.bithuman.ai") } }
            filter { includeGroup("ai.bithuman") }
        }
    }
}

// app/build.gradle.kts
// bithuman.apiSecret=… in local.properties (git-ignored), or BITHUMAN_API_SECRET in the environment
val bithumanApiSecret: String = run {
    val props = java.util.Properties()   // fully qualified, so these lines can go anywhere in the file
    val f = rootProject.file("local.properties")
    if (f.isFile) f.inputStream().use { props.load(it) }
    props.getProperty("bithuman.apiSecret") ?: System.getenv("BITHUMAN_API_SECRET") ?: ""
}

android {
    buildFeatures { buildConfig = true }
    defaultConfig {
        minSdk = 26   // 29 for Essence 2
        ndk { abiFilters += "arm64-v8a" }
        buildConfigField("String", "BITHUMAN_API_SECRET", "\"$bithumanApiSecret\"")
    }
    packaging { jniLibs { useLegacyPackaging = true } }   // required
}
dependencies {
    implementation("ai.bithuman:expression2-android:0.6.1")
    // or: implementation("ai.bithuman:essence2-android:0.9.5")
}
```

Put the secret in `local.properties` as `bithuman.apiSecret=…` (Android Studio keeps that file out of git), or export `BITHUMAN_API_SECRET` before you build; the example apps read it the same way. An empty secret makes the session refuse to start.

The build prints `Unable to strip the following libraries, packaging them as they are: libLiteRt.so, libQnn…`. It is expected when no NDK is installed.

The `dependencyResolutionManagement` block works unchanged in a Groovy `settings.gradle`. Only `ai.bithuman` comes from `maven.bithuman.ai`; the engines' own dependencies still come from `google()` and `mavenCentral()`.

`expression2-android` brings the Qualcomm accelerator runtime with it (`com.qualcomm.qti:qnn-litert-delegate:2.49.0` and `com.qualcomm.qti:qnn-runtime:2.49.0`). To keep the APK small and render on the CPU instead, exclude it:

```kotlin
implementation("ai.bithuman:expression2-android:0.6.1") {
    exclude(group = "com.qualcomm.qti")
}
```

## Authenticate

Pass your API secret ([create one](https://www.bithuman.ai/developer/api-keys)) in code before you download or create an avatar: `Expression2Credential.set(secret)` for Expression 2, `Essence2Credential.set(secret)` for Essence 2. That one call covers the download and the session. See [Your API secret](https://docs.bithuman.ai/start/api-secret).
> **Warning:** a `buildConfigField` compiles the secret into the APK, where anyone with the file can read it. Use it for local builds only.

A shipped app fetches its secret from your backend ([What a shipped app holds](https://docs.bithuman.ai/start/api-secret#what-a-shipped-app-holds)).

## Run your first avatar

Expression 2, with the published `wise-pup` avatar (agent code `A23WJF0199`). Call `render` off the main thread.

```kotlin
import ai.bithuman.expression2.Expression2Avatar
import ai.bithuman.expression2.Expression2Credential
import ai.bithuman.expression2.Expression2ModelStore
import ai.bithuman.expression2.Expression2Options
import android.content.Context
import android.graphics.Bitmap

/** [pcm16k] is 16 kHz mono float32 in [-1, 1]. */
fun render(context: Context, pcm16k: FloatArray, show: (Bitmap) -> Unit) {
    Expression2Credential.set(BuildConfig.BITHUMAN_API_SECRET)        // before fetch() and create()
    val model = Expression2ModelStore(context).fetch("A23WJF0199")     // ~160 MB, first run only

    Expression2Avatar.create(context, model, Expression2Options()).use { avatar ->
        val frame = avatar.newFrameBitmap()   // 416 x 720, allocate once
        avatar.feed(pcm16k)
        avatar.flushTail()                    // end of the utterance
        while (true) {
            if (avatar.pull(frame) != null) { show(frame); continue }
            if (!avatar.hasPendingTail && avatar.queuedFrames == 0) break
            Thread.sleep(10)                  // null means "not ready yet"
        }
    }
}
```

Expected: `show` receives 20 frames for each second of audio, and the avatar's lips follow the speech. The first `create()` after install prepares the accelerator once and takes noticeably longer than later launches, which reuse it. Create once, at app start, on a background thread.

Essence 2 takes 16-bit little-endian PCM bytes, as a 16 kHz mono WAV stores them, and fills an RGBA `ByteBuffer` sized from the identity:

```kotlin
import ai.bithuman.essence2.Essence2Avatar
import ai.bithuman.essence2.Essence2Credential
import ai.bithuman.essence2.Essence2ModelStore
import android.content.Context
import java.nio.ByteBuffer

fun render(context: Context, pcm16le: ByteArray, show: (ByteBuffer, Int, Int) -> Unit) {
    Essence2Credential.set(BuildConfig.BITHUMAN_API_SECRET)                   // before fetch() and create()
    val identity = Essence2ModelStore(context).fetch("A52DHS2219")            // sofia-ramirez; 226–281 MB, first run only

    Essence2Avatar.create(identity.dir).use { avatar ->
        val frame = avatar.newFrameBuffer()   // width * height * 4, RGBA
        avatar.feed(pcm16le)
        avatar.endOfAudio()
        var quietMs = 0
        while (quietMs < 5_000) {             // 5 s with no frame at all = finished
            if (avatar.pull(frame)) { show(frame, avatar.width, avatar.height); quietMs = 0; continue }
            Thread.sleep(10)                  // false means "not ready yet"; the first frame can take seconds
            quietMs += 10
        }
        avatar.checkRender()                  // throws Essence2RenderFailed if the engine stopped
        // a refused session throws Essence2MeteringRefused from pull() or idle()
    }
}
```

Frame size belongs to the identity (portrait 1080×1920, landscape 1920×1080 or 1280×720). Read `avatar.width` and `avatar.height`; do not hard-code them.

<div class="fig-end">

*Capture: The sofia-ramirez avatar speaking in the essence2-hello app on a Galaxy S25+, at 1080×1920. Captured on Samsung Galaxy S25+ (Android) · essence2-android 0.5.14 · sofia-ramirez (Essence 2) · 2026-09-23.* (https://docs.bithuman.ai/examples/android-essence-2/clip.mp4)

## Performance

| Configuration | Hardware | Essence 2 | Expression 2 |
|---|---|---|---|
| Android | Samsung Galaxy S25+ | 2.0× real time | 2.4× real time |
| Android · held 10 min | Samsung Galaxy S25+ | 1.4× real time | 2.2× real time |

× real time: seconds of video rendered per second; 1.0× or more holds a live conversation ([method](https://docs.bithuman.ai/performance#mobile)).

## Continue

The rest of this platform, inlined so one fetch covers it: [Build an Android app](https://docs.bithuman.ai/platforms/android/app.md) · [Android troubleshooting](https://docs.bithuman.ai/platforms/android/troubleshooting.md).

---

# Build an Android app

URL: https://docs.bithuman.ai/platforms/android/app

> Stream audio into an Android avatar and show its frames in your app.

## Integrate into your app

In a live conversation, keep one avatar open and stream into it.

| Job | Expression 2 | Essence 2 |
|---|---|---|
| Audio in | 16 kHz mono `FloatArray`, −1 to 1 | 16 kHz mono 16-bit little-endian PCM `ByteArray` |
| Stream audio as it arrives | `feed(chunk)` per chunk | `feed(chunk)` per chunk |
| Show frames | `pull(bitmap)`, 20 a second | `pull(buffer)`, 25 a second |
| End of a reply | `flushTail()` | `endOfAudio()` |
| Idle between replies | `avatar.idleLoop?.next(bitmap)` | `idle(buffer)` |
| Interrupt the reply | `resetState(true)` | `resetAudio()` |
| Check the session | `Expression2Exception` from `create` or `pull` | `Essence2MeteringRefused` from `pull`/`idle`; `checkRender()` throws `Essence2RenderFailed` if the engine stopped |
| Close it | `close()` | `close()` |

To feed the microphone, declare `<uses-permission android:name="android.permission.RECORD_AUDIO"/>` in your manifest and ask for it at runtime (`ActivityResultContracts.RequestPermission`) before you start `AudioRecord`; without it the buffers are silent. The SDKs need only `INTERNET`, which their AARs merge in for you.

Resample 24 kHz speech (OpenAI Realtime's) to 16 kHz, and close the avatar when the app leaves the screen: [Companion app](https://docs.bithuman.ai/build/companion-app#resample-speech-to-16-khz).

After your API secret is accepted, a network loss does not stop the session for 5 minutes of rendered video. After that, render calls throw a retryable exception until the connection returns. Usage is reported to your account when it does.

For a Flutter app, the [Flutter plugin](https://docs.bithuman.ai/platforms/flutter) wraps these engines.

## Complete example

Two apps you can clone and run on a phone, each with idle motion between replies:

- [Android Expression 2](https://docs.bithuman.ai/examples/android-expression-2): the `wise-pup` sample avatar.
- [Android Essence 2](https://docs.bithuman.ai/examples/android-essence-2): the `sofia-ramirez` sample avatar at full resolution.

## Platform notes

- **Release builds:** `isMinifyEnabled = true` needs nothing extra. Both AARs ship their own keep rules.
- **Two models in one app:** `essence2-android` needs `minSdk 29`. Raise the app to 29, or put each model in its own module.
- **Threads:** download and `create()` on a background thread. The first download is the size shown above, into app-private storage.
- **Check the version you resolved:** Gradle keeps an exact version, so read it back when behaviour differs from this page.

  ```bash
  ./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep ai.bithuman
  ```

- **Private avatars:** an avatar you created downloads with the secret you set with `Expression2Credential.set` or `Essence2Credential.set`; there is nothing else to pass.

## Reference

- [Android API reference](https://docs.bithuman.ai/platforms/android/reference): every public class in both AARs.
- Examples: [Expression 2](https://docs.bithuman.ai/examples/android-expression-2) · [Essence 2](https://docs.bithuman.ai/examples/android-essence-2), complete apps you can clone.
- [Flutter](https://docs.bithuman.ai/platforms/flutter): the Flutter plugin, built on these engines.
- [Changelog](https://docs.bithuman.ai/changelog) and [Downloads & versions](https://docs.bithuman.ai/downloads).
- FFmpeg in `essence2-android` is LGPL: [relink materials](https://docs.bithuman.ai/legal/android-ffmpeg-lgpl).

---

# Android troubleshooting

URL: https://docs.bithuman.ai/platforms/android/troubleshooting

> Fix Android SDK errors from Gradle, the secret and the device.

| Symptom | Cause | Fix |
|---|---|---|
| `Expression2Exception` from `create()` naming the API secret | no secret set | call `Expression2Credential.set(secret)` before `fetch()` and `create()` |
| `Essence2MeteringRefused` (`ai.bithuman.elevate.MeteringRefused` in stack traces) on the first Essence 2 `pull()` | no secret set | call `Essence2Credential.set(secret)` before `fetch()` and `create()` |
| A refusal reading *refusing to serve: API and SDK access starts at the Creator plan* | the account's plan does not include SDK access; from 2026-10-12 this includes Free accounts | [choose a plan](https://www.bithuman.ai/pricing?from=docs) |
| A refusal reading *this account is already running as many sessions as its plan allows* | every concurrent session of the plan is in use | `close()` the avatars you no longer show, or upgrade the plan |
| A refusal reading *this account is suspended* | the account is suspended | contact support at hello@bithuman.ai |
| A refusal reading *this account has no credits remaining (402)* (Essence 2: `Essence2MeteringRefused`; Expression 2: `Expression2Exception`) | the account is out of credits | [top up](https://www.bithuman.ai/billing#credits); the next meter report is accepted |
| `Essence2StoreException` from `fetch()` with `code` `OFFLINE` or `NETWORK_TRANSIENT` | no network, or bitHuman's server did not answer in time | call `fetch()` again later (`retriable` is true); the download keeps its bytes. It says nothing about the secret: do not sign the user out |
| `Essence2StoreException` from `fetch()` with `code == OUTDATED` | the avatar was published again while it downloaded: the server now describes other files | call `fetch()` again (`retriable` is true); it follows the current files. Decide on `code` and `retriable`, never on the message |
| *published before the renderer in this SDK* from `Essence2Bundle.open()`, or an `IllegalStateException` from Essence 2 `create()` naming the agent code | the avatar's files on the phone are older than the engine accepts, for example a folder staged by hand | `store.fetch(code, force = true)`, then open and create again |
| `Essence2StoreException` from `fetch()` | no secret was set when the store downloaded | call `Essence2Credential.set(secret)` before `fetch()` |
| Expression 2 renders slowly; `acceleratorNote` says no `libQnnTFLiteDelegate.so` | the accelerator runtime was excluded, or legacy packaging is off | keep the dependency whole and set `useLegacyPackaging = true` |
| The first Expression 2 `create()` after install is slow | the accelerator prepares the decoder once and keeps it; later launches reuse it | create on a background thread at app start; only the first launch after install pays it (and again after an SDK or OS update) |
| Download refused with `401` | the avatar is private | set its owner's API secret with `Expression2Credential.set` or `Essence2Credential.set` |
| `409 MODEL_NOT_GENERATED` on download | the agent has no model of that kind yet | [add the model](https://docs.bithuman.ai/api/agents#add-a-model-to-an-existing-agent), then retry |
| `Could not find ai.bithuman:expression2-android:…` (or `essence2-android`) | bitHuman's Maven repository is not in the build's repositories | add the `exclusiveContent` block for `https://maven.bithuman.ai` to `settings.gradle.kts`, as in [Install](https://docs.bithuman.ai/platforms/android#install) |
| Manifest merge fails on `minSdk` | `essence2-android` needs `minSdk 29` | raise the module to 29 |
| `Unresolved reference: BuildConfig` | the Android Gradle Plugin turns `BuildConfig` off by default | add `buildFeatures { buildConfig = true }` |
| `Unresolved reference 'MeteredDoorResolver'` | the resolver's public name is `Essence2MeteredDoorResolver` | you rarely need it: `Essence2Credential.set(secret)` covers downloads. To pass a secret explicitly: `import ai.bithuman.essence2.Essence2MeteredDoorResolver`, then `Essence2ModelStore(context, urlResolver = Essence2MeteredDoorResolver(secret))` |
| Gradle stops with `What went wrong:` followed only by a Java version number (such as `25.0.4.1`) | Gradle 8.11 cannot run on that JDK | set `JAVA_HOME` to JDK 17 or 21 and build again |
| The session is refused although you set a secret | `BuildConfig.BITHUMAN_API_SECRET` is empty | set `bithuman.apiSecret` in `local.properties` or `BITHUMAN_API_SECRET`, then rebuild |
| `UnsatisfiedLinkError` on an emulator | the engines are `arm64-v8a` only | run on a physical arm64 handset |
