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

# Android SDK

> Integrate your native Android app via the Embedded SDK for Android.

The Android SDK has many high-level and low-level methods to interact with Plaud devices. We recommend using the high-level methods outlined on this page to handle:

1. Device Connection: Connecting to & disconnecting from Plaud devices via mobile app
2. File Management: Syncing files from Plaud device to mobile app
3. Firmware Updates: Updating Plaud device firmware

These methods should cover majority of Plaud Embedded use cases. For advanced usage, see the [advanced Android SDK usage](/plaud-embedded/advanced-android-sdk).

## Prerequisites

| Technology | Version |
| - | - |
| Android | 5.0+ (minSdk 21) |
| compileSdk | 34 |
| Java Development Kit (JDK) | 17 |

<Note>
  The SDK ships native libraries for `arm64-v8a` / `armeabi-v7a`. For testing, you must use a physical device, not an emulator.
</Note>

<Tip>
  **Try the Plaud Embedded Skill** to have your coding agent help you with your Android implementation.

  ```bash theme={"system"}
  npx skills add Plaud-AI/plaud-embedded-skills
  ```

  Visit our [GitHub repo](https://github.com/Plaud-AI/plaud-embedded-skills.git) for more details on the skill.
</Tip>

## Installation

<Steps>
  <Step title="Clone the Plaud SDK repo">
    ```bash theme={"system"}
    git clone https://github.com/Plaud-AI/plaud-sdk-public.git
    ```
  </Step>

  <Step title="Copy and import the Plaud Android SDK as a dependency">
    The Android SDK ships as a pre-built `.aar`. Copy `sdk/android/plaud-sdk.aar` into your app module's `libs/` directory and add it as a dependency:

    ```groovy build.gradle theme={"system"}
    dependencies {
        implementation files('libs/plaud-sdk.aar')
    }
    ```
  </Step>
</Steps>

***

## Getting Started

Import `PlaudDeviceAgent` from `sdk`. `PlaudDeviceAgent` is a singleton `object`, so call it directly — there is no instance to construct:

```kotlin Kotlin icon="android" theme={"system"}
import sdk.PlaudDeviceAgent

// Initialize once with your app Context, user token, and regional domain
PlaudDeviceAgent.initSDK(
    context = applicationContext,
    userAccessToken = "user-token",
    customDomain = "platform-us.plaud.ai"  // domain only, no https://
)

// Assign the global listener to receive device events
PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener {
    override fun blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int) {
        // handshake complete — device is ready
    }
    // override only the callbacks you need
}
```

Use these methods and callbacks to drive device interactions between your mobile app and your users' Plaud devices.

***

## Methods

### Plaud Device SDK Initialization

The SDK is initialized with your app `Context`, a **User Token**, and your **regional** domain.

<Note>
  If you haven't onboarded to the Plaud Developer Platform, see our [quickstart onboarding steps](/plaud-embedded/quickstart#onboard-to-the-plaud-developer-platform).

  If you'd like more details on how to retrieve your User Token and your regional domain, see the [Authentication API reference](/plaud-embedded/auth-api-overview).
</Note>

```kotlin Kotlin icon="android" theme={"system"}
import sdk.PlaudDeviceAgent

PlaudDeviceAgent.initSDK(
    context = applicationContext,
    userAccessToken = "user-token",
    customDomain = "platform-us.plaud.ai"  // domain only, no https://
)
```

<ParamField path="context" type="Context" required>
  Your application `Context` (e.g. `applicationContext`).
</ParamField>

<ParamField path="userAccessToken" type="String" required>
  User Access Token (JWT), used for device authentication.
</ParamField>

<ParamField path="customDomain" type="String" required>
  Your regional Plaud server domain **without `https://` prefix**. All SDK network requests use this domain.

  For more information on how to find your regional server domain, see the [Authentication API docs](/plaud-embedded/auth-api-overview#find-your-region).
</ParamField>

### Permission Manager

BLE scanning requires runtime permissions on Android. The SDK's `sdk.permission.PermissionManager` requests the full set it needs and reports the result.

```kotlin Kotlin icon="android" theme={"system"}
import sdk.permission.PermissionManager

class ScanActivity : AppCompatActivity() {
    private val perms by lazy { PermissionManager(this) }
    private fun startScanning() {
        if (perms.hasAllPermissions()) {
            PlaudDeviceAgent.startScan()
        } else {
            perms.requestPermissions(this) { granted -> onPermissionResult(granted) }
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        perms.onRequestPermissionsResult(
            requestCode,
            arrayOf(*permissions),
            grantResults
        ) { granted -> onPermissionResult(granted) }
    }

    private fun onPermissionResult(granted: Boolean) {
        if (granted) PlaudDeviceAgent.startScan() else { /* ... */ }
    }
}
```

<ParamField path="activity" type="android.app.Activity" required>
  Android activity
</ParamField>

<ParamField path="onResult" type="(Boolean) -> Unit" required>
  Receives `true` only when every requested permission was granted.
</ParamField>

<Note>
  `PermissionManager` requests **`BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION`** on API 31+, and `BLUETOOTH`, `BLUETOOTH_ADMIN`, `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` on API ≤ 30. Note that it asks for location on Android 12+ as well.
</Note>

***

### Connecting (binding) to a Plaud Device

Binding a Plaud device generates a key-pair using the **user token** and creates an ownership lock on your users' Plaud device. This makes sure their files on device is always encrypted and can only be decrypted with a valid user token by your application.

Binding is a two step process:

1. **Local bind** over BLE, where the Embedded SDK runs the encrypted handshake and generates the key-pair on the device

2. **Cloud bind** that records the device/owner association so you can track device status remotely and via API.

<Steps>
  <Step title="Scan and connect over BLE">
    `.startScan()` delivers results on the `bleScanResult` callback defined in the [**PlaudDeviceAgentListener**](#plauddeviceagentlistener).

    <Warning>
      **Two things must be in place before `connectBleDevice(...)`, or the secure handshake fails:**

      1. The partner RSA key pair must have arrived. `initSDK(...)` fetches it over HTTP.
      2. The device serial number must be signed and stored, with `NiceBuildSdk.signAndStoreDeviceSn(deviceType, sn)`. The BLE layer reads that signature during the pre-handshake.
    </Warning>

    ```kotlin Kotlin icon="android" theme={"system"}
    import sdk.NiceBuildSdk

    PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener {
        override fun bleScanResult(devices: List<BleDevice>) {
            val device = devices.firstOrNull { it.serialNumber == targetSN } ?: return
            connect(device)
        }

        override fun bleConnectState(state: Int) {
            // 1 = GATT link up (handshake still pending), 0 = disconnected, 2 = failed.
            // Wait for bleBind before declaring success.
        }
    }

    PlaudDeviceAgent.startScan()

    private fun connect(bleDevice: BleDevice) = lifecycleScope.launch(Dispatchers.IO) {
        val sn = bleDevice.serialNumber ?: return@launch
        val deviceType = if (sn.startsWith("881")) "notepro" else "notepins"

        // Required before connecting: RSA key pair ready, then SN signed.
        val deadline = System.currentTimeMillis() + 10_000L
        while (!NiceBuildSdk.isPartnerDataReady() && System.currentTimeMillis() < deadline) delay(200)
        NiceBuildSdk.signAndStoreDeviceSn(deviceType, sn)

        PlaudDeviceAgent.connectBleDevice(bleDevice)
    }
    ```

    <ParamField path="bleDevice" type="BleDevice" required>
      A scanned device, as delivered by `bleScanResult`.
    </ParamField>

    <ParamField path="deviceToken" type="String">
      Optional second argument on the `connectBleDevice(bleDevice, deviceToken)` overload — a unique identifier for that device. Omit it to connect with an empty token, which is what most integrations do.

      The serial number is read from the `BleDevice` — you do not pass it here.
    </ParamField>
  </Step>

  <Step title="Cloud bind to sync binding states">
    `bleBind(status = 0)` means the handshake completed and the key-pair exists on the device. Call the `sdk/bind` API after a successful call.

    <Tip>
      Re-binding a device to the same owner is idempotent server-side, so the repeat calls have no side effects. See [Binding a Plaud Device to a User](/plaud-embedded/device-binding-api-overview#binding-a-plaud-device-to-a-user) for the full endpoint reference.
    </Tip>

    ```kotlin Kotlin icon="android" theme={"system"}
    override fun bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int) {
        if (status != 0 || sn == null) return      // 0 = handshake complete
        cloudBind(sn)
    }

    // POST https://<your-domain>/developer/api/open/partner/sdk/bind
    private fun cloudBind(sn: String) = scope.launch(Dispatchers.IO) {
        val body = JSONObject().put("type", snType).put("sn", sn)

        val request = Request.Builder()
            .url("https://$customDomain/developer/api/open/partner/sdk/bind")
            .header("Authorization", "Bearer $userAccessToken")
            .post(body.toString().toRequestBody("application/json".toMediaType()))
            .build()

        OkHttpClient().newCall(request).execute().use { resp ->
            // 403 = the device is already bound to another account
        }
    }
    ```

    <ParamField path="type" type="String" required>
      Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`).
    </ParamField>

    <ParamField path="sn" type="String" required>
      Device serial number.
    </ParamField>
  </Step>
</Steps>

#### PlaudDeviceAgentListener Callbacks

The [**PlaudDeviceAgentListener**](#plauddeviceagentlistener) is the single global listener for `PlaudDeviceAgent`. See the [Interfaces and Callbacks](#interfaces-and-callbacks) section for the full list.

| Callback | Description |
| - | - |
| `bleScanResult(devices: List<BleDevice>)` | Scan results updated |
| `bleConnectState(state: Int)` | `1` = **GATT link up, handshake still pending**, `0` = disconnected, `2` = connection failed |
| `bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int)` | Handshake complete — `status == 0` is the real connection success |
| `blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int)` | Device state reported after the handshake |
| `bleDepair(status: Int)` | Device confirmed the depair |
| `bleConnectStage(sn: String?, stage: String, detail: String?)` | Observability into the device handshake process |

<Warning>
  Plaud devices can only be bound to one mobile application. If a user is **uninstalling your mobile app, make sure to unbind your Plaud device.**
</Warning>

***

### Depair (unbind) a device

#### Standard unbind

Plaud devices **can only be bound to one application at a time**. This is done to properly secure and encrypt files stored on a Plaud device. When a user wants to unbind a Plaud device (whether to use with another Plaud Embedded App or the core Plaud app), unbind over both cloud and BLE.

Unbinding via the cloud allows you to track Connected Device statuses remotely and via API.

<Steps>
  <Step title="Unbind from the cloud">
    Removes the device/owner association in the Plaud registry (visible on the [Plaud developer portal](https://portal.plaud.ai/)). This does not require a BLE connection. Unbinding an already-unbound device is an idempotent no-op, so this is safe to call best-effort. See [Unbinding a Plaud Device to a User](/plaud-embedded/device-binding-api-overview#unbinding-a-plaud-device-to-a-user) for the full endpoint reference.

    ```kotlin Kotlin icon="android" theme={"system"}
    // POST https://<your-domain>/developer/api/open/partner/sdk/unbind
    val body = JSONObject().put("type", snType).put("sn", sn)

    val request = Request.Builder()
        .url("https://$customDomain/developer/api/open/partner/sdk/unbind")
        .header("Authorization", "Bearer $userAccessToken")
        .post(body.toString().toRequestBody("application/json".toMediaType()))
        .build()

    OkHttpClient().newCall(request).execute().close()
    ```

    <ParamField path="type" type="String" required>
      Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`).
    </ParamField>

    <ParamField path="sn" type="String" required>
      Device serial number.
    </ParamField>
  </Step>

  <Step title="Depair the device over BLE">
    Clears the ownership lock on the device itself using the `.depair(clear)` method. The device confirms the outcome on the `bleDepair(status: Int)` callback, which releases the BLE link with `.disconnect()`. Because an out-of-range or unresponsive device never answers, pair the callback with a short timeout that disconnects anyway, so the link is always released.

    ```kotlin Kotlin icon="android" theme={"system"}
    PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener {
        override fun bleDepair(status: Int) {
            PlaudDeviceAgent.disconnect()      // 0 = depaired
        }
    }

    PlaudDeviceAgent.depair(false)

    // The device may never answer — release the link anyway after a short timeout.
    scope.launch {
        delay(3_000)
        PlaudDeviceAgent.disconnect()
    }
    ```

    <ParamField path="clear" type="Boolean" default="false">
      Also clears the SDK's stored connection/bond state for the device. The Android template app passes `false` on the standard unbind path — the depair itself is what releases the device. Omit the argument to call the no-arg `depair()` overload, which is equivalent.

      **Keep `false` during [Device Recovery](#device-recovery)** — the recovery handshake has already force-cleared the device's key material, and clearing again there fights the flow that is about to rebind the device.
    </ParamField>
  </Step>
</Steps>

#### Device Recovery

In certain situations, the cloud and local bind state can go **out-of-sync**, causing the device to lock. The firmware still holds a previous user's client ID, so the current user's handshake is rejected — on Android this usually surfaces as a `bleConnectState(2)` connect failure after a \~10s timeout.

Device recovery re-handshakes with each previously bound client ID using the `GET /sdk/binding` endpoint. On a match, the stale ownership lock is wiped and the device can be bound to the current user. No reflashing tool is needed.

<Note>
  Recovery only applies when the cloud reports the device as **unbound** (`is_bind` is `false` or `null`). If `is_bind` is `true`, the device is genuinely owned by another account and that owner must unbind it first. The SDK cannot override an active binding.
</Note>

<Steps>
  <Step title="Get all previous clients">
    Use the [`sdk/binding` API](/plaud-embedded/device-binding-api-overview#recovering-a-plaud-device) to retrieve all previously bound clients, including bindings in other apps.

    ```kotlin Kotlin icon="android" theme={"system"}
    // GET /developer/api/open/partner/sdk/binding?type={type}&sn={sn}
    val request = Request.Builder()
        .url("https://$customDomain/developer/api/open/partner/sdk/binding?type=$snType&sn=$sn")
        .header("Authorization", "Bearer $userAccessToken")
        .get()
        .build()

    OkHttpClient().newCall(request).execute().use { resp ->
        val json = JSONObject(resp.body?.string() ?: "")
        val isBind = if (json.isNull("is_bind")) null else json.optBoolean("is_bind")
        val bindHistory = json.optJSONArray("bind_history")
        // isBind: null = signed but never bound
    }
    ```

    <ParamField path="type" type="String" required>
      Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`).
    </ParamField>

    <ParamField path="sn" type="String" required>
      Device serial number.
    </ParamField>

    **Response:**

    <ParamField path="is_bind" type="Boolean | null">
      `true` = bound to another account (stop — recovery is not possible), `false` = unbound, `null` = signed but never bound. Use Device Recovery only when this is **not** `true`.
    </ParamField>

    <ParamField path="bind_history" type="String[]">
      Previously bound client IDs, newest first.

      <Warning>
        `bind_history` records one entry **per bind event**, so the same client ID repeats for every past connect — 50+ duplicates of a single ID is normal. **Dedupe before you bound the list**, or all your attempts will retry the same key.
      </Warning>
    </ParamField>
  </Step>

  <Step title="Run device recovery on each client">
    For each previous client ID, `recoveryConnectBleDevice` uses that ID as the handshake token and connects with force-clear, so the firmware wipes its stale key material. The current user's SN signature and RSA material are reused — nothing has to be signed for the historical user — and the SDK restores the current user's handshake identity when the attempt ends.

    ```kotlin Kotlin icon="android" theme={"system"}
    // Try one previously bound client ID (dedupe bind_history first).
    PlaudDeviceAgent.recoveryConnectBleDevice(bleDevice, historicalId)

    PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener {
        override fun bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int) {
            if (status != 0) return                  // this ID matched the firmware lock
            PlaudDeviceAgent.depair(false)           // wipe the stale bond
        }

        override fun bleDepair(status: Int) {
            PlaudDeviceAgent.disconnect()
            PlaudDeviceAgent.startScan()             // depair changes the MAC — rescan
        }

        override fun bleScanResult(devices: List<BleDevice>) {
            val fresh = devices.firstOrNull { it.serialNumber == sn } ?: return
            // Reconnect as the current user — this rebinds the device.
            PlaudDeviceAgent.connectBleDevice(fresh)
        }

        override fun bleConnectState(state: Int) {
            // 0 / 2 while awaiting the handshake = this ID was refused;
            // disconnect and try the next bind_history entry.
        }
    }
    ```

    <ParamField path="bleDevice" type="BleDevice" required>
      The scanned device, from `bleScanResult`.
    </ParamField>

    <ParamField path="historicalUserId" type="String" required>
      One `bind_history` entry to try as the handshake token. Accepts either the raw `client_user_id` or its 32-hex form.
    </ParamField>
  </Step>
</Steps>

#### Offer recovery from both entry points

A locked device is hit on two different paths, and only one of them has a sheet on screen to show the failure:

* **User-initiated connect** — the connect sheet is open, so present the recovery prompt from its failure state.
* **Background auto-reconnect** (e.g. at launch) — nothing is watching. Emit the offer on a separate flow (the template uses a `recoveryOffers` `SharedFlow`) and let your host screen present it.

Stop the reconnect loop before prompting, or the retry job will fight the recovery flow.

***

### File Synchronization

The `exportAudio` method exports audio files from the Plaud device to your users' phone, reporting progress through the [**AudioExporter.ExportCallback**](#audioexporterexportcallback).

<Note>
  Plaud devices will record **up to 5 hours**. Recordings longer should be broken up.
</Note>

The `.getFileList` accesses files on your users' Plaud device.

```kotlin Kotlin icon="android" theme={"system"}
// Get file list from device
PlaudDeviceAgent.getFileList()

PlaudDeviceAgent.exportAudio(
    sessionId = sessionId,
    outputDir = outputDir,
    format = AudioExportFormat.WAV,
    channels = 1,
    callback = object : AudioExporter.ExportCallback {
        override fun onProgress(progress: Int, message: String) { }
        override fun onComplete(outputFile: File) {
            // decoded file is ready at outputFile
        }
    }
)
```

<ParamField path="sessionId" type="Long" required>
  Session ID
</ParamField>

<ParamField path="outputDir" type="File" required>
  Output directory
</ParamField>

<ParamField path="format" type="AudioExportFormat" required>
  `PCM` | `WAV` | `OPUS` | `MP3`. We recommend `MP3` — it plays everywhere and is accepted directly by the transcription upload API.
</ParamField>

<ParamField path="channels" type="Int">
  Number of audio channels (`1` = mono). Optional — an overload without this argument exists.
</ParamField>

<ParamField path="callback" type="AudioExporter.ExportCallback" required>
  `fun onProgress(progress: Int, message: String)`

  `fun onComplete(outputFile: File)`
</ParamField>

<Note>
  For large audio files, we recommend UX considerations:

  * Progress indicators and setting expectations for long transfers (i.e. "This file is large. May take \~X minutes")
  * Supporting **background/resumable** transfer so closing the app doesn't kill the session.
  * Using WiFi Fast Transfer (see below)
</Note>

***

### WiFi Fast Transfer

An alternative to a BLE (Bluetooth Low Energy) file transfer, WiFi Fast Transfer is \~10x faster than BLE transfers.

A transfer spans two objects: `PlaudDeviceAgent` opens the hotspot, starts and ends the session, and decodes each recording; `IWifiTransferAgent` (reached with `getWifiAgent()`) lists and deletes files during the session.

<Note>
  `getWifiAgent()` returns `IWifiTransferAgent?` — it is `null` until the SDK has a WiFi agent to hand out. Always reach it with a safe call (`getWifiAgent()?.…`).
</Note>

<Steps>
  <Step title="Ask the device to open its hotspot">
    The device opens a WiFi hotspot on request over BLE. The credentials arrive on the `bleWiFiOpen` listener callback.

    ```kotlin Kotlin icon="android" theme={"system"}
    PlaudDeviceAgent.setDeviceWiFi(open = true)
    ```

    <ParamField path="open" type="Boolean" required>
      `true` opens the device hotspot, `false` closes it.
    </ParamField>
  </Step>

  <Step title="Start the transfer session">
    Start the session from the `bleWiFiOpen` callback. The SDK handles joining the hotspot and the secure handshake for you.

    ```kotlin Kotlin icon="android" theme={"system"}
    PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener {
        override fun bleWiFiOpen(status: Int, ssid: String, password: String, url: String) {
            PlaudDeviceAgent.startWifiTransfer(userId, wifiCallback)
        }
        // ...
    }
    ```

    <ParamField path="userId" type="String" required>
      Identifier for the transfer session.
    </ParamField>

    <ParamField path="callback" type="IWifiTransferAgent.WifiTransferCallback" required>
      Drives the rest of the flow. See [**WifiTransferCallback**](#wifitransfercallback) below.
    </ParamField>

    Returns `false` if the session could not be opened.
  </Step>

  <Step title="Wait for READY, then list files">
    Connection state advances `NONE` → `CONNECTING` → `CONNECTED` → `HANDSHAKING` → `READY`. **No file command works before `READY`.**

    ```kotlin Kotlin icon="android" theme={"system"}
    val wifiCallback = object : IWifiTransferAgent.WifiTransferCallback {
        override fun onConnectionStateChanged(state: WifiConnectionState) {
            if (state == WifiConnectionState.READY) {
                PlaudDeviceAgent.getWifiAgent()?.getFileList()
            }
        }
        override fun onFileListReceived(files: List<WifiFileInfo>) {
            // export each session (next step)
        }
        override fun onError(code: Int, message: String) { }
        // ...
    }
    ```
  </Step>

  <Step title="Export each recording">
    `exportAudioViaWiFi` takes the same arguments as [`exportAudio`](#file-synchronization) and reports on the same `AudioExporter.ExportCallback`, so your existing export handling works unchanged over WiFi.

    ```kotlin Kotlin icon="android" theme={"system"}
    PlaudDeviceAgent.exportAudioViaWiFi(
        sessionId = file.sessionId,
        outputDir = outputDir,
        format = AudioExportFormat.MP3,
        channels = 1,
        callback = object : AudioExporter.ExportCallback {
            override fun onProgress(progress: Int, message: String) { }
            override fun onComplete(outputFile: File) {
                // decoded file is ready at outputFile
            }
        }
    )
    ```

    <Note>
      Use `exportAudioViaWiFi(...)` rather than the raw `downloadFile()` / `downloadAllFiles()` path on `IWifiTransferAgent`. Those write undecrypted `.opus` bytes straight to disk; `exportAudioViaWiFi` runs the same decode pipeline as BLE.
    </Note>
  </Step>

  <Step title="End the session">
    ```kotlin Kotlin icon="android" theme={"system"}
    PlaudDeviceAgent.endWiFiTransfer()
    ```

    <Warning>
      End the session with `endWiFiTransfer()`, not `getWifiAgent().stopWifiTransfer()`. Only `endWiFiTransfer()` also tells the device to close its hotspot over BLE — otherwise it stays open and drains the device battery.
    </Warning>
  </Step>
</Steps>

Use `PlaudDeviceAgent.isWifiTransferActive()` to check whether a session is currently open.

#### Deleting files over WiFi

`IWifiTransferAgent.deleteFiles(...)` removes several recordings in one call — the only batch delete in the SDK. The result arrives on `onFileDeleteCompleted`.

```kotlin Kotlin icon="android" theme={"system"}
PlaudDeviceAgent.getWifiAgent()?.deleteFiles(listOf(sessionId1, sessionId2))
```

<ParamField path="sessionIds" type="List<Long>" required>
  Session IDs of the recordings to delete from the device.
</ParamField>

#### WifiTransferCallback

The WiFi Fast Transfer flow is driven by the [IWifiTransferAgent.WifiTransferCallback](/plaud-embedded/android-sdk#iwifitransferagent-wifitransfercallback). All members are required — there are no default implementations.

| Callback | Description |
| - | - |
| `onConnectionStateChanged(state: WifiConnectionState)` | WiFi transfer connection state changed |
| `onHandshakeCompleted(info: String)` | Secure handshake completed; transfer is ready |
| `onFileListReceived(files: List<WifiFileInfo>)` | Result of the file-list request |
| `onTransferProgress(sessionId: Long, progress: Int, speed: Double)` | Per-file transfer progress (`progress` 0–100, `speed` in KB/s) |
| `onFileTransferCompleted(sessionId: Long, path: String)` | A single file finished downloading to `path` |
| `onBatchDownloadStarted(total: Int)` | Batch download started |
| `onBatchDownloadProgress(current: Int, total: Int, filename: String)` | Progress across a batch download |
| `onBatchDownloadCompleted(success: Int, failed: Int, results: List<BatchDownloadResult>)` | Batch download finished |
| `onFileDeleteCompleted(success: Boolean, deletedCount: Int, error: String?)` | File delete finished |
| `onWifiTransferStopped()` | WiFi transfer session stopped |
| `onDeviceBatteryUpdate(level: Int, charging: Boolean)` | Device battery / charging status update |
| `onError(code: Int, message: String)` | An error occurred during the transfer |

<Note>
  While faster than BLE transfers, we still recommend the following UX considerations for WiFi Fast Transfer syncs:

  * Progress indicators and setting expectations for long transfers (i.e. "This file is large. May take \~X minutes")
  * Supporting **background/resumable** transfer so closing the app doesn't kill the session.
</Note>

***

### Firmware Update (OTA)

The Embedded SDK handles the entire OTA flow across three calls: version query → download → MD5 verify → CRC → BLE packet push → device restart → reconnect. Each phase reports through the [**FirmwareUpdateCallback**](#firmwareupdatecallback).

<Warning>
  Firmware updates will wipe recordings from your users' Plaud device. Make sure their recordings are synced/exported to your API services before pushing firmware updates!
</Warning>

#### Check for Firmware Updates

```kotlin Kotlin icon="android" theme={"system"}
// Check for update
PlaudDeviceAgent.checkFirmwareUpdate(object : SimpleFirmwareUpdateCallback() {
    override fun onUpdateCheckResult(result: Result<FirmwareUpdateInfo>) {
        val info = result.getOrNull() ?: return
        if (!info.hasUpdate) return
        // proceed to download (below)
    }
})
```

<ParamField path="callback" type="FirmwareUpdateCallback" required>
  Callback that reports the check result as a `FirmwareUpdateInfo`.

  <Expandable title="FirmwareUpdateInfo">
    <ParamField path="hasUpdate" type="Boolean">
      Whether a firmware update is available
    </ParamField>

    <ParamField path="currentVersion" type="String">
      The device's current firmware version
    </ParamField>

    <ParamField path="isForceUpdate" type="Boolean">
      Whether the update is mandatory
    </ParamField>

    <ParamField path="versionResponse" type="DeviceVersionResponse">
      The full version-check response (latest version, download URL, MD5, release notes)
    </ParamField>
  </Expandable>
</ParamField>

#### Download Firmware Update

```kotlin Kotlin icon="android" theme={"system"}
// Download + MD5 verify
PlaudDeviceAgent.downloadFirmware(updateInfo, object : SimpleFirmwareUpdateCallback() {
    override fun onDownloadProgress(progress: UpdateProgress) {
        // progress.progress: 0 ~ 100
    }
    override fun onDownloadComplete(result: FirmwareDownloadResult) {
        if (result.success) {
            result.file?.let { /* install (below) */ }
        }
    }
})
```

<ParamField path="info" type="FirmwareUpdateInfo" required>
  The `FirmwareUpdateInfo` returned by `checkFirmwareUpdate`.
</ParamField>

<ParamField path="callback" type="FirmwareUpdateCallback" required>
  Reports download progress and completion.

  <Expandable title="UpdateProgress">
    <ParamField path="progress" type="Int">
      Progress from 0 to 100
    </ParamField>

    <ParamField path="message" type="String">
      Human-readable status message
    </ParamField>

    <ParamField path="detail" type="String">
      Additional detail for the current step
    </ParamField>

    <ParamField path="transferPhase" type="FirmwareTransferPhase">
      `TRANSFERRING` / `TRANSFER_COMPLETE_WAITING` / `DEVICE_RESTARTING` / `UPGRADE_COMPLETE` / `TRANSFER_FAILED` / `UPGRADE_FAILED`
    </ParamField>
  </Expandable>

  <Expandable title="FirmwareDownloadResult">
    <ParamField path="success" type="Boolean">
      Whether the download succeeded
    </ParamField>

    <ParamField path="file" type="File">
      The downloaded firmware file
    </ParamField>

    <ParamField path="md5Valid" type="Boolean">
      Whether the MD5 checksum verified
    </ParamField>

    <ParamField path="error" type="String">
      Error description if the download failed
    </ParamField>
  </Expandable>
</ParamField>

#### Install Firmware Update

```kotlin Kotlin icon="android" theme={"system"}
PlaudDeviceAgent.installFirmware(file, updateInfo, object : SimpleFirmwareUpdateCallback() {
    override fun onInstallProgress(progress: UpdateProgress) { }
    override fun onInstallComplete(result: FirmwareInstallResult) {
        if (result.success) {
            // device restarts and reconnects
        }
    }
})
```

<ParamField path="file" type="File" required>
  The firmware file from `FirmwareDownloadResult.file`
</ParamField>

<ParamField path="info" type="FirmwareUpdateInfo" required>
  The `FirmwareUpdateInfo` returned by `checkFirmwareUpdate`
</ParamField>

<ParamField path="callback" type="FirmwareUpdateCallback" required>
  Reports install progress and completion.

  <Expandable title="FirmwareInstallResult">
    <ParamField path="success" type="Boolean">
      Whether the install succeeded
    </ParamField>

    <ParamField path="error" type="String">
      Error description if the install failed
    </ParamField>
  </Expandable>
</ParamField>

***

## Interfaces and Callbacks

The Embedded SDK is callback-driven. Device events flow through a single global listener you assign to `PlaudDeviceAgent.listener`, while per-operation flows (audio export, WiFi transfer, firmware) take their own callback interface. These four cover most use cases.

<Note>
  Callbacks are delivered on the SDK's internal threads — **not** the main thread. Marshal to the main thread (e.g. `runOnUiThread { }` / a `Handler`) before touching UI or view state.
</Note>

### PlaudDeviceAgentListener

The primary listener for `PlaudDeviceAgent`. Assign it once to `PlaudDeviceAgent.listener` and it drives the entire BLE lifecycle — scan, connect, bind, device state, recording, and file sync. A single global listener receives every device event; all methods return `Unit`.

```kotlin Kotlin icon="android" theme={"system"}
PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener {
    override fun blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int) {
        // handshake complete — device is ready
    }
    // override only the callbacks you need
}
```

| Group | Callback | Description |
| - | - | - |
| Connection | `bleScanResult(devices: List<BleDevice>)` | Scan results updated |
| Connection | `bleScanOverTime()` | Scan window elapsed |
| Connection | `bleConnectState(state: Int)` | `1` = GATT link up (**not** connection success — the handshake follows), `0` = disconnected, `2` = failed. See [Connecting (binding) to a Plaud Device](#connecting-binding-to-a-plaud-device) |
| Connection | `bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int)` | Handshake complete — `status == 0` is the real connection success |
| Connection | `bleDepair(status: Int)` | Unpair / de-bind result |
| Connection | `blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int)` | Handshake / device state reported after the handshake completes |
| Connection | `bleDeviceName(name: String)` | Device name read / changed |
| Power & storage | `blePowerChange(power: Int, oldPower: Int)` | Battery % changed |
| Power & storage | `bleChargingState(isCharging: Boolean, level: Int)` | Charging state + battery level |
| Power & storage | `bleStorage(total: Long, free: Long, duration: Long)` | Bytes total/free + recordable seconds |
| Power & storage | `bleMicGain(gain: Int)` | Mic gain read |
| Recording | `bleRecordStart(sessionId: Long, start: Long, status: Int, scene: Int, startTime: Long, reason: Int)` | Recording started |
| Recording | `bleRecordStop(sessionId: Long, reason: Int, fileExist: Boolean, fileSize: Long)` | Recording stopped |
| Recording | `bleRecordPause(sessionId: Long, reason: Int, fileExist: Boolean, fileSize: Long)` | Recording paused |
| Recording | `bleRecordResume(sessionId: Long, start: Long, status: Int, scene: Int, startTime: Long)` | Recording resumed |
| File sync | `bleFileList(files: List<BleFile>)` | Result of `getFileList(...)` |
| File sync | `bleSyncFileHead(sessionId: Long, status: Int)` | Sync started for a file |
| File sync | `bleSyncFileTail(sessionId: Long, status: Int)` | Sync finished for a file |
| File sync | `bleData(sessionId: Long, timestamp: Long, data: ByteArray)` | A chunk of streaming audio/PCM bytes |
| File sync | `bleDataComplete()` | Data stream complete |
| File sync | `bleSyncFileStop()` | Sync stopped |
| File sync | `bleDeleteFile(sessionId: Long, status: Int)` | Result of `deleteFile(...)` |
| WiFi | `bleWiFiOpen(status: Int, ssid: String, password: String, url: String)` | Device opened its hotspot |
| OTA | `bleFotaResult(sessionId: Long, status: Int, message: String)` | Firmware push result |

<Note>
  The listener also delivers the WiFi auto-sync configuration results (`onWifiSyncEnabled`, `onWifiSyncListReceived`, `onWifiSyncConfigReceived`, `onWifiSyncConfigSet`, `onWifiSyncDeleteResult`, `onWifiSyncTestStarted`, `onWifiSyncTestResult`). These configure the device's own scheduled background upload and are distinct from WiFi Fast Transfer.
</Note>

### AudioExporter.ExportCallback

Reports progress, completion, and errors for `exportAudio(...)` (BLE) and `exportAudioViaWiFi(...)` (WiFi).

```kotlin Kotlin icon="android" theme={"system"}
val callback = object : AudioExporter.ExportCallback {
    override fun onProgress(progress: Int, message: String) {
        // progress: 0–100 (download bytes)
    }
    override fun onComplete(outputFile: File) {
        // decoded file is ready at outputFile
    }
    override fun onError(error: String) {
        // export failed
    }
    override fun onStageChanged(stage: ExportStage) {
        // optional — DOWNLOADING → TRANSCODING
    }
}
```

<ParamField path="onProgress(progress: Int, message: String)" type="fun" required>
  Export progress, `0`–`100` (download bytes), with a human-readable status message.
</ParamField>

<ParamField path="onComplete(outputFile: File)" type="fun" required>
  Called when decoding finishes; `outputFile` is the written file.
</ParamField>

<ParamField path="onError(error: String)" type="fun" required>
  Called if export fails, with a description of the error.
</ParamField>

<ParamField path="onStageChanged(stage: ExportStage)" type="fun">
  Optional (has a default implementation). Language-neutral phase signal: `DOWNLOADING` (bytes still transferring off the device) → `TRANSCODING` (only local decode/encode remains).
</ParamField>

### IWifiTransferAgent.WifiTransferCallback

WiFi Fast Transfer is split across both high-level interfaces, so you will use `IWifiTransferAgent` directly for any fast transfer. `PlaudDeviceAgent` owns the session lifecycle and the audio pipeline; `IWifiTransferAgent` owns the file operations and the callback you implement to drive them.

```kotlin Kotlin icon="android" theme={"system"}
val wifi: IWifiTransferAgent? = PlaudDeviceAgent.getWifiAgent()
```

#### WiFi Fast Transfer

| Functionality | Method |
| - | - |
| Open the device hotspot | `PlaudDeviceAgent.setDeviceWiFi(open = true)` |
| Start the session | `PlaudDeviceAgent.startWifiTransfer(userId, callback)` |
| List files on the device | `IWifiTransferAgent.getFileList()` |
| Download + decode one recording | `PlaudDeviceAgent.exportAudioViaWiFi(...)` |
| Delete recordings in a batch | `IWifiTransferAgent.deleteFiles(sessionIds)` |
| Check session state | `IWifiTransferAgent.getConnectionState()` / `checkPrerequisites()` |
| End the session | `PlaudDeviceAgent.endWiFiTransfer()` |

The typical flow: open the hotspot over BLE, start the session, wait for `READY`, list files, then export each one.

#### WifiTransferCallback

**12 required members** for Wifi Transfers, generally driven by `PlaudDeviceAgent.exportAudioViaWiFi`.

| Callback | Description |
| - | - |
| `onConnectionStateChanged(state)` | `READY` = handshake complete, safe to issue commands |
| `onHandshakeCompleted(info)` | Handshake detail string |
| `onFileListReceived(files)` | Result of `getFileList()`, as `WifiFileInfo` |
| `onTransferProgress(sessionId, progress, speed)` | `progress` 0–100, `speed` in KB/s |
| `onFileTransferCompleted(sessionId, path)` | A single file finished |
| `onBatchDownloadStarted(total)` | `downloadAllFiles()` began |
| `onBatchDownloadProgress(current, total, filename)` | Batch position |
| `onBatchDownloadCompleted(success, failed, results)` | Batch finished, with per-file `BatchDownloadResult` |
| `onFileDeleteCompleted(success, deletedCount, error)` | Result of `deleteFiles(...)` |
| `onDeviceBatteryUpdate(level, charging)` | Device battery over the WiFi session |
| `onWifiTransferStopped()` | Session ended |
| `onError(code, message)` | A command failed |

`WifiFileInfo` carries `sessionId`, `fileName`, `fileSize`, `duration` (ms), `timestamp` (epoch seconds), and `scene` — more per-file metadata than `BleFile` exposes over BLE.

### FirmwareUpdateCallback

Reports each phase of the OTA flow across `checkFirmwareUpdate(...)`, `downloadFirmware(...)`, and `installFirmware(...)`. Extend `SimpleFirmwareUpdateCallback()` to override only the methods you need — each has an empty default implementation.

```kotlin Kotlin icon="android" theme={"system"}
val callback = object : SimpleFirmwareUpdateCallback() {
    override fun onUpdateCheckResult(result: Result<FirmwareUpdateInfo>) {
        val info = result.getOrNull() ?: return
        if (info.hasUpdate) { /* proceed to download */ }
    }
    override fun onDownloadProgress(progress: UpdateProgress) { /* progress.progress: 0–100 */ }
    override fun onDownloadComplete(result: FirmwareDownloadResult) {
        if (result.success) { result.file?.let { /* install */ } }
    }
    override fun onInstallProgress(progress: UpdateProgress) { }
    override fun onInstallComplete(result: FirmwareInstallResult) {
        if (result.success) { /* device restarts and reconnects */ }
    }
}
```

| Callback | Description |
| - | - |
| `onUpdateCheckResult(result: Result<FirmwareUpdateInfo>)` | Version-check result. `FirmwareUpdateInfo` carries `hasUpdate`, `currentVersion`, `isForceUpdate`, and the full `versionResponse` |
| `onDownloadProgress(progress: UpdateProgress)` | Download progress (`progress` 0–100, plus `message`, `detail`, `transferPhase`) |
| `onDownloadComplete(result: FirmwareDownloadResult)` | Download + MD5 verify finished (`success`, `file`, `md5Valid`, `error`) |
| `onInstallProgress(progress: UpdateProgress)` | Install progress (BLE packet push + device restart) |
| `onInstallComplete(result: FirmwareInstallResult)` | Install finished (`success`, `error`) |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.