# Get Partner Token Source: https://docs.plaud.ai/api-reference/authentication-api/get-partner-token /openapi/auth.json post /oauth/partner/access-token Application-level token. Exchange your `client_id` and `secret_key` for a partner access token, which is used to mint individual user tokens. # Get User Token Source: https://docs.plaud.ai/api-reference/authentication-api/get-user-token /openapi/auth.json post /open/partner/users/access-token User-level token. Exchange a partner access token for a per-user access token. This is the token the Embedded SDK and file upload endpoints accept. # Refresh Partner Token Source: https://docs.plaud.ai/api-reference/authentication-api/refresh-partner-token /openapi/auth.json post /oauth/partner/access-token/refresh Exchange a valid `refresh_token` for a new partner access token. Refresh before the current token expires. # Bind Device Source: https://docs.plaud.ai/api-reference/device-binding-api/bind-device /openapi/binding.json post /open/partner/sdk/bind Registers the device/owner association in the Plaud registry. Re-binding a device to the same owner is idempotent, so multiple calls will not have side effects. # Get Device Binding Source: https://docs.plaud.ai/api-reference/device-binding-api/get-device-binding /openapi/binding.json get /open/partner/sdk/binding Returns the remote binding state of a device along with every client ID it has previously been bound to, including bindings made in other apps. Use this endpoint to check device state remotely, and to drive [Device Recovery](/plaud-embedded/ios-sdk#device-recovery) when the cloud and local bind state go out of sync and the device locks. Recovery only applies when `is_bind` is **not** `true`; if `is_bind` is `true` the device is owned by another account and that owner must unbind it first. # Unbind Device Source: https://docs.plaud.ai/api-reference/device-binding-api/unbind-device /openapi/binding.json post /open/partner/sdk/unbind Removes the device/owner association in the Plaud registry. Unbinding an already-unbound device is idempotent, so multiple calls have no side effects. Plaud devices can only be bound to one application at a time. Unbind over both cloud and BLE — call this endpoint and call `depair(clear: true)` on the Embedded SDK — so the cloud and local bind state stay in sync. # Complete Multipart Upload Source: https://docs.plaud.ai/api-reference/file-upload-api/complete-multipart-upload /openapi/file.json post /open/partner/files/upload/complete-upload Merge the uploaded parts into a single file. Submit the `file_id` and `upload_id` from generate-presigned-urls along with the `ETag` collected from each part's `PUT` response.The returned `DownloadUrl` is valid for **24 hours**. # Generate Presigned Upload URLs Source: https://docs.plaud.ai/api-reference/file-upload-api/generate-presigned-upload-urls /openapi/file.json post /open/partner/files/upload/generate-presigned-urls Request presigned S3 URLs for a multipart upload. The file is split into chunks of 5 MB with one presigned URL per part. For each part returned: 1. **Send raw bytes to S3** — `PUT PresignedUrl` the raw bytes of the chunk to (no auth needed). 2. **Collect the `ETag`** from each S3 response header — These will be needed in the [complete-upload endpoint](/api-reference/file-upload-api/complete-multipart-upload). # Get Transcription Task Source: https://docs.plaud.ai/api-reference/transcription-api/get-transcription-task /openapi/transcription.json get /open/partner/ai/transcriptions/{transcription_id} Retrieve the status and results of a transcription task. Keep polling while the status is `PENDING`, `RECEIVED`, `STARTED`, or `PROGRESS`. When the status is `SUCCESS`, the `data` object is populated. # Submit Audio for Transcription Source: https://docs.plaud.ai/api-reference/transcription-api/submit-audio-for-transcription /openapi/transcription.json post /open/partner/ai/transcriptions/ Submit an audio file for transcription. Pass the pre-signed download URL returned by complete-upload. Transcription runs asynchronously — use the returned `transcription_id` to poll for results. Rate Limit: **60 req/min**, Max recording duration: **24 hr**, Max diarization: **6 hr** # Platform Overview Source: https://docs.plaud.ai/overview Build with real-world conversation data — power your own product with our device SDK and transcription API, or automate workflows by extracting your Plaud data through the MCP. **If you're building your own user-facing product**, integrate your mobile app with: 1. **Plaud devices** for your users to record on first-class hardware 2. **Plaud's transcription models** to turn speech to insights Plaud becomes infrastructure for your application. Connect your agent to **your personal Plaud data** to access all of your recordings and notes. 1. Search your recordings 2. Retrieve transcripts 3. AI-generated notes Works with any MCP-compatible or terminal-based agent. ## Build Your Way Whether you are: * Building a product on top of Plaud's hardware and infrastructure with [Plaud Embedded](/plaud-embedded/overview) * Connecting your agents to your personal Plaud data with [Plaud MCP & CLI](/plaud-mcp-cli/mcp) **Start building on Plaud's Developer Platform** for your conversation use cases. # Low-Level Android SDK Methods Source: https://docs.plaud.ai/plaud-embedded/advanced-android-sdk Low-level Embedded SDK methods for advanced use cases ## High-level Facades The Plaud Embedded Android SDK has two high-level interfaces: 1. **PlaudDeviceAgent**: a Kotlin `object` you call statically 2. **IWifiTransferAgent**: the WiFi Fast Transfer session and file operations These high-level methods and callbacks should cover most use cases including: 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 See our [Android SDK reference](/plaud-embedded/android-sdk) for examples and documentation on these high-level facades. ``` Your app │ ├── PlaudDeviceAgent ─────┬───────────────┐ (High-level) ├── IWifiTransferAgent ───┤ │ │ ▼ ▼ │ NiceBuildSdk FirmwareUpdate │ (low-level) Manager │ │ │ │ ▼ │ │ IBleAgent ◄──────────┘ │ (low-level) └─────────────────────────┴───────────────────► device ``` Additionally, using PlaudDeviceAgent abstracts many details that low-level interfaces will not handle. | Managed Behavior | Purpose | | --------------------------- | ------------------------------------------------------------------------------------------------------ | | Response management | One listener to handle different response objects (`StorageRsp`, `GetStateRsp`, …) | | Callback plumbing | Supplies the `OnRequest` / `OnResponse` pairs every `IBleAgent` call requires | | Audio pipeline | `exportAudio` handles download, caching, E2EE decrypt, and transcode to `mp3` / `wav` / `opus` / `pcm` | | Firmware orchestration | Version query, MD5 verify, CRC, BLE packet push, and reconnect after restart | | Optional listener callbacks | `PlaudDeviceAgentListener` has **0** required members — override only what you need | You can mix the high-level and low-level facades. For example if there's a behavior that `PlaudDeviceAgent` does not include, you can use `IBleAgent` to cover that functionality. ## Low-level Facades The Android SDK has a few lower-level interfaces that `PlaudDeviceAgent` wraps over: 1. **IBleAgent**: the BLE protocol library 2. **NiceBuildSdk**: helper SDK for crypto and cloud API calls 3. **PartnerApiManager**: manages the backend endpoints for device signing and RSA keypair generation **We do not recommend using these low-level facades as main drivers. Use them as coverage for functionality not exposed by PlaudDeviceAgent**. The `workflow` methods and models have been deprecated and will be removed in future SDK versions. *** ### IBleAgent Reference `IBleAgent` is the raw BLE transport, reached through the protocol library's `TntAgent` singleton: ```kotlin Kotlin icon="android" theme={"system"} import com.tinnotech.penblesdk.TntAgent import com.tinnotech.penblesdk.core.IBleAgent val ble: IBleAgent = TntAgent.getInstant().bleAgent ``` It exposes **165 methods** — roughly four times the facade's surface — so most device features the SDK supports but `PlaudDeviceAgent` doesn't expose are reachable here. #### The callback convention Every `IBleAgent` command takes an `OnRequest` / `OnResponse` pair instead of reporting to a global listener. ```kotlin Kotlin icon="android" theme={"system"} ble.getStorage( object : AgentCallback.OnRequest { override fun onRequestResult(success: Boolean, code: Int, msg: String?) { // the command reached the device (or didn't) } }, object : AgentCallback.OnResponse { override fun onResponse(rsp: StorageRsp) { // the device replied — typed response object, not primitives } } ) ``` `OnRequest` fires when the command is written to the device; `OnResponse` fires when the device answers. A successful `OnRequest` with no `OnResponse` means the device accepted the command but never replied. #### Device Connection | `PlaudDeviceAgent` | `IBleAgent` | | ------------------------------------------------------- | -------------------------------------------------------------------- | | `startScan()` | `scanBle(true)` | | `stopScan()` | `scanBle(false)` | | `isConnected()` | `isBtConnected()` | | `connectBleDevice(bleDevice, deviceToken)` | `connectionBLE(device, handshakeToken, deviceToken, "", false)` | | `recoveryConnectBleDevice(bleDevice, historicalUserId)` | `connectionBLE(device, , "", "", true)` | | `disconnect()` | `disconnectBle()` | | `depair(clear)` | `depair(clear, onRequest, onResponse)` | | `getState()` | `getState(onRequest, onResponse)` | ```kotlin Kotlin icon="android" theme={"system"} val ble = TntAgent.getInstant().bleAgent TntAgent.getInstant().addBleAgentListeners(myBleAgentListener) ble.scanBle(true) // From scanBleDeviceReceiver(...) on your BleAgentListener: ble.connectionBLE( device, // the scanned BleDevice NiceBuildSdk.resolveHandshakeToken(userJwt), // handshake token from the JWT `sub` deviceToken, // your per-device token "", // userName false // isForceClear ) ``` The device to connect to, as delivered by `scanBleDeviceReceiver(...)`. Parsed from the user JWT's `sub` claim. `NiceBuildSdk.resolveHandshakeToken(...)` does this for you — the facade calls it internally. Per-device token. This is the value `PlaudDeviceAgent.connectBleDevice(bleDevice, deviceToken)` forwards; pass `""` for the no-token overload. Owner name written to the device. The facade passes `""` (iOS passes `"Plaud"`). `connectBleDevice(...)` passes `false`; `recoveryConnectBleDevice(...)` passes `true`. See [Device Recovery at the BLE Layer](#device-recovery-at-the-ble-layer). #### Recording & Device State ```kotlin Kotlin icon="android" theme={"system"} // Recording — each takes OnRequest/OnResponse callbacks ble.startRecord(scene, onRequest, onResponse) ble.stopRecord(onRequest, onResponse) ble.recordPause(sessionId, onRequest, onResponse) ble.recordResume(sessionId, onRequest, onResponse) ble.startImmediateRecord(...); ble.stopImmediateRecord(...) ble.startSyncRecord(...) // State reads ble.getState(...); ble.getStorage(...); ble.getDeviceStatus(...) ble.getBatteryLevel(...); ble.isCharging(); ble.getBattStatus(...) ble.getSerialNumber(...); ble.getSsn(...); ble.getMacAddress() ble.getCurrentConnectedDevice(); ble.getCurrentConnectedBleMac() ble.isRecording(); ble.isScanningBle(); ble.isSupportWifi(); ble.isUsbState() ``` `IBleAgent` also exposes the full device-settings surface the facade only partially wraps. Each setting is a `get…` / `set…` pair. ```kotlin Kotlin icon="android" theme={"system"} // Backlight getBackLightTime() / setBackLightTime(...) getBackLightBrightness() / setBackLightBrightness(...) // Language, scene & mode getLanguage() / setLanguage(...) getRecScene() / setRecScene(...) getRecMode() / setRecMode(...) openVAD(...) getVadSensitivity() / setVadSensitivity(...) // Gains & audio getVpuGain() / setVpuGain(...) getMICGain() / setMICGain(...) getVpuCLK() / setVpuCLK(...) getBatteryMode() / setBatteryMode(...) getAudioChannel() isOggAudio(); isNoNsAgc() // Automation toggles getAutoRecord() / setAutoRecord(...) getAutoStopRecord() / setAutoStopRecord(...) getAutoPowerOff() / setAutoPowerOff(...) getAutoSync() / setAutoSync(...) getAutoDeleteRecordFile() / setAutoDeleteRecordFile(...) getSaveRAWFile() / setSaveRAWFile(...) getFindMyState() / setFindMyState(...) getIBeaconWakeup() / setIBeaconWakeup(...) getSwitchHandlerId() / setSwitchHandlerId(...) // Naming, LED, alarm, privacy getBleDeviceName() / setBleDeviceName(...) getLedStatus() / setLedStatus(...) getAlarmRec() / setAlarmRec(...) setPrivacy(...); setActive(...); restoreFactorySettings(...) // Generic channel for settings without a dedicated method getCommonParams(...) / setCommonParams(...) / commonSettings(...) // Device logs, markings, WiFi credentials, websocket, misc getDeviceLogFileList(...); startSyncDeviceLogFile(...); stopSyncDeviceLogFile(...) deleteDeviceLogFile(...) getRecMarkings(...); getRecTags(...) getWifiList(...); getWifiInfo(...); setSyncWifi(...); deleteWifiInfo(...) testWifiInfo(...); testWifiResult(...); getRouterSsid(...) / setRouterSsid(...) getWebsocket(...) / setWebsocket(...); setWebsocketUrl(...); testWebsocket(...) getSDFlashCID(...); resetPassword(...); resetFindMy(...) setSoundPlusToken(...); syncTime(...); bleRateTest(...) setMtu(...); setPriorityHigh(); setPriorityLow(); updateRssi(...) ``` #### File Sync ```kotlin Kotlin icon="android" theme={"system"} ble.getRecSessions(startSessionId, onRequest, onResponse) ble.syncFileStart(sessionId, start, end, onRequest, onHeadRsp, onTailRsp, voiceDataCollector) ble.syncFileStop(onRequest, onResponse) ble.syncFileDel(sessionId, onRequest, onResponse) ble.clearRecordFile(onRequest, onResponse) ble.fileDataCheck(...) ``` The session ID to start listing from. `0` lists everything. The recording to sync or delete. Start byte offset. Use `0` for the whole file, or resume from a prior offset. End byte offset. `0` transfers to the end of the file. Receives streamed audio bytes. Build one with `VoiceDataCreatorFactory.newOriginalData()` to write raw device bytes to a file. `PlaudDeviceAgent` handles this. There is no format conversion and no decryption at this layer — the collector receives exactly what the device sends. `PlaudDeviceAgent.exportAudio(...)` handles this conversion and decryption. #### Firmware Push ```kotlin Kotlin icon="android" theme={"system"} ble.appFotaPush(filePath, fromVersion, toVersion, thirdVersion, otaPushListener) ble.isFotaPushing() ble.interruptFotaPush() ble.getSyncOtaFileInfo(onRequest, onResponse) ble.appGetThirdVersion(...) ``` `FirmwareUpdateManager` composes the full flow on top of this from querying the version to MD5 verification, CRC, and the post-restart reconnect. Android's firmware version check queries `GET /api/sdk/latest-version` with an `Authorization` header, while iOS uses the partner endpoint with `X-Device-Signature`. #### BleAgentListener `IBleAgent`'s listener is registered on `TntAgent`: ```kotlin Kotlin icon="android" theme={"system"} TntAgent.getInstant().addBleAgentListeners(listener) TntAgent.getInstant().removeBleAgentListeners(listener) ``` | Interface | Members | Required | | -------------------------- | ------- | -------- | | `BleAgentListener` | 29 | **27** | | `PlaudDeviceAgentListener` | 32 | **0** | Conforming to `BleAgentListener` means implementing 27 members, and it hands you undocumented protocol response types instead of primitives. Unless you need an event the facade doesn't re-emit, keep your listener on `PlaudDeviceAgentListener`. | `BleAgentListener` | `PlaudDeviceAgentListener` | | --------------------------------------------------------- | ----------------------------------------------------------------------- | | `scanBleDeviceReceiver(BleDevice)` — one device at a time | `bleScanResult(List)` — accumulated list | | `deviceStatusRsp(String, GetStateRsp)` | `blePenState(state, privacy, keyState, uDisk)` | | `deviceOpStorageRsp(String, StorageRsp)` | `bleStorage(total, free, duration)` | | `deviceOpRecordStart(String, RecordStartRsp)` | `bleRecordStart(sessionId, start, status, scene, startTime, reason)` | | `deviceFotaResult(String, AppFotaPushRsp)` | `bleFotaResult(sessionId, status, message)` | | `batteryLevelUpdate` **+** `chargingStatusChange` | `bleChargingState(isCharging, level)` — merged | | `bleConnectStage(String, String, String)` | `bleConnectStage(sn, stage, detail)` — identical, straight pass-through | Events available only on `BleAgentListener`: | Group | Callbacks | | ----------------- | --------------------------------------------------------------------------------------------- | | Connection detail | `btStatusChange`, `bleConnectFail`, `scanFail`, `handshakeWaitSure`, `sendMoreFailDisconnect` | | Link quality | `rssiChange`, `mtuChange` | | Sensors | `stickAngles`, `motorStatus`, `deviceNewFeature`, `deviceStatusDetailed` | | Device logs | `deviceLogSyncData`, `deviceLogSyncStop`, `deviceLogSyncEnd` | | WiFi / OTA detail | `deviceWifiSyncStartRsp`, `deviceSwitchWifiMode`, `deviceFotaThirdVersion` | | Settings | `commonSettingNotify` *(default)* | #### Connect stages — `bleConnectStage` ```kotlin Kotlin icon="android" theme={"system"} fun bleConnectStage(sn: String?, stage: String, detail: String?) ``` `bleConnectStage` is the diagnostics stream for the connect/handshake sequence — the only way to see *which* layer refused a rejected connection. `sn` may be `null` before the serial number is known. | Argument | Values | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `stage` | `start`, `gatt_connect`, `set_notify`, `set_battery_notify`, `read_battery`, `set_data_notify`, `pre_handshake`, `send_rsa_public`, `first_handshake`, `two_handshake`, `handshake_get_ssn`, `change_handshake_timeout`, `sync_time` | | `detail` | Stage detail or failure reason — `ok`, `old_protocol_ok`, `status_` (`status_1` = token mismatch), `sn_signature_empty`, `sn_signature_invalid`, `user_rsa_public_key_empty`, `bind_token_empty`, `send_fail` | | `detail` | What it means | | --------------------------- | --------------------------------------------------------------------------------------- | | `sn_signature_empty` | Your app skipped or lost the `sn-sign` step | | `sn_signature_invalid` | The cached signature is stale — re-sign the SN | | `user_rsa_public_key_empty` | `gen-key` has not completed; poll `NiceBuildSdk.isPartnerDataReady()` before connecting | | `bind_token_empty` | No handshake token was supplied to the connect | | `status_1` | Token mismatch — the firmware is locked to another account | This matters more on Android than on iOS, because [your app drives `sn-sign` itself](#device-security). A rejection at `pre_handshake` / `send_rsa_public` / `first_handshake`, or a `detail` of `status_1`, is the signature of a device still locked to a previous account — the cue for [Device Recovery](#device-recovery-at-the-ble-layer). Before SDK 1.0.13 all of these collapsed into an undifferentiated `bleConnectState(2)`. The callback itself is not new — it has been a default-bodied member of `BleAgentListener` since the first public release — but 1.0.13 is the release where the facade's bridge started forwarding it instead of swallowing it, so you no longer have to implement `BleAgentListener` to receive it. *** ### PartnerApiManager `PartnerApiManager` wraps the partner authentication endpoints. Like `IBleAgent`, reach it via the lower facade: ```kotlin Kotlin icon="android" theme={"system"} val api = NiceBuildSdk.getPartnerApiManager() ``` ```kotlin Kotlin icon="android" theme={"system"} class PartnerApiManager { // sdk.network.manager fun setUserAccessToken(token: String) fun getUserAccessToken(): String fun hasUserAccessToken(): Boolean fun updateBaseUrl(url: String) fun clearToken() // → POST …/open/partner/sdk/sn-sign suspend fun signDeviceSn(deviceType: String, sn: String): SnSignResponse? // → POST …/open/partner/sdk/gen-key suspend fun generateRsaKeyPair(): GenKeyResponse? // → POST …/open/partner/sdk/metadata suspend fun postDeviceMetadata(signature: String, request: MetadataRequest): Boolean } ``` #### Device Security Partner endpoints for device authentication: | Endpoint | Method | Auth header | Driven by | | --------------- | ------ | ------------------------------------ | -------------------------------------- | | `…/sdk/gen-key` | `POST` | `Authorization: Bearer ` | `PartnerApiManager.generateRsaKeyPair` | | `…/sdk/sn-sign` | `POST` | `Authorization: Bearer ` | `PartnerApiManager.signDeviceSn` | `initSDK(...)` fetches the key pair asynchronously. Poll `isPartnerDataReady()` before connecting. ```kotlin Kotlin icon="android" theme={"system"} val deadline = System.currentTimeMillis() + 10_000L while (!NiceBuildSdk.isPartnerDataReady() && System.currentTimeMillis() < deadline) { delay(200) } ``` Or, without a coroutine, use the callback form: ```kotlin Kotlin icon="android" theme={"system"} NiceBuildSdk.ensurePartnerDataReady { ready -> /* … */ } ``` `signAndStoreDeviceSn(...)` signs the SN and writes the result where the BLE layer reads it during the pre-handshake. ```kotlin Kotlin icon="android" theme={"system"} val signed = NiceBuildSdk.signAndStoreDeviceSn(deviceType, sn) // suspend // or: NiceBuildSdk.signDeviceSnAsync(deviceType, sn) { ok -> /* … */ } ``` Device family, derived from the first three characters of the serial number. | SN prefix | `deviceType` | | --------- | ------------ | | `881` | `notepro` | | `882` | `notepins` | Device serial number, from `BleDevice.getSerialNumber()`. ```kotlin Kotlin icon="android" theme={"system"} PlaudDeviceAgent.connectBleDevice(bleDevice) ``` The SN signature is stored **in memory only** — there is no Keystore-backed cache and no persistence across process death. Every fresh launch needs a live `sn-sign` call before its first connect, so the device requires network reachability at that moment. Re-sign as a fallback whenever the stored signature is missing. The RSA key pair itself *is* persisted, wrapped by an AES master key in the Android Keystore, and is what feeds audio E2EE decryption: ```kotlin Kotlin icon="android" theme={"system"} val privateKeyPem: String = NiceBuildSdk.getSecurePrivateKey() NiceBuildSdk.clearPartnerData() // drop the stored partner data ``` `NiceBuildSdk.bindDevice(...)` / `unbindDevice(...)` are **not** the cloud bind documented in the [Android SDK reference](/plaud-embedded/android-sdk). They post to a different service (`/api/devices/bind`) with a different credential. For the documented flow, call `developer/api/open/partner/sdk/bind` directly with the user token. # Low-Level iOS SDK Methods Source: https://docs.plaud.ai/plaud-embedded/advanced-ios-sdk Low-level Embedded SDK methods for advanced use cases ## High-level Facades The Plaud Embedded iOS SDK has a set of high-level interfaces for basic usage. These include: 1. **PlaudDeviceAgent** 2. **PlaudWiFiAgent** These high-level methods and callbacks in these facades should cover most use cases including: 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 See our [iOS SDK reference](/plaud-embedded/ios-sdk) for examples and documentation on these high-level facades. ``` Your app │ ├── PlaudDeviceAgent ──────┐ (High-level) ├── PlaudWiFiAgent ───┐ │ │ │ │ │ ▼ ▼ │ WiFiAgent BleAgent │ (low-level) (low-level) │ │ │ └───────────────────────┴───┴──► device ``` Additionally, using PlaudDeviceAgent & PlaudWiFiAgent abstracts many details that low-level interfaces will not handle. | Managed Behavior | Purpose | | --------------------------- | --------------------------------------------------------------------------------------------------------- | | Analytics event per call | Logged on every gated call | | Permission gating | A call that fails the gate silently no-ops instead of reaching the device | | Parameter validation | Guards on `sessionId`, `outputDir`, `startSessionId`, etc. | | Partner pre-handshake | `sn-sign` / `gen-key` are run and injected before connect (required on protocol V20+ devices) | | E2EE decryption | `syncFile` transparently decrypts on protocol V20+ devices | | Audio format conversion | `exportAudio` decodes to `opus` / `mp3` / `wav` / `pcm` | | Optional delegate callbacks | `PlaudDeviceAgentProtocol` has **1** required member and optional callbacks for you to override as needed | You can mix high-level and low-level facades. For example if there's a behavior that `PlaudDeviceAgent` does not include, you can use `BleAgent` to cover that functionality. ## Low-level Facades The iOS SDK has a few lower-level interfaces that our high-level interfaces (PlaudDeviceAgent and PlaudWiFiAgent) wrap over: 1. **BleAgent**: Bluetooth Low Energy (BLE) transport 2. **WiFiAgent**: WiFi agent with low-level methods for WiFi fast transfers 3. **PlaudPartnerApiManager**: Manages the backend endpoints for device signing and RSA keypair generation **We do not recommend using these low-level facades as main drivers. Use them as coverage for functionality not exposed by PlaudDeviceAgent and PlaudWiFiAgent**. The `PlaudWorkflowManager` has been deprecated and will be removed in future SDK versions. *** ### BleAgent Reference Import `BleAgent` through `PlaudDeviceBasicSDK`. You can then access BleAgent through the shared singleton or through the facade's escape hatch: ```swift Swift icon="swift" theme={"system"} import PlaudDeviceBasicSDK // either work let ble = BleAgent.shared let ble = PlaudDeviceAgent.shared.bleAgent ``` #### BleAgent State Read-only state is available directly on the agent, which is useful when you need a synchronous answer rather than waiting for a delegate callback: | Property | Type | Description | | --------------------- | ------------------- | ------------------------------------------------------ | | `isPoweredOn` | `Bool` | The phone's Bluetooth radio is on | | `isConnected` | `Bool` | A device is currently connected | | `isBinded` | `Bool` | The connected device is bound | | `isRecording` | `Bool` | The device is recording | | `isDownloading` | `Bool` | A file sync is in progress | | `isWiFiOpen` | `Bool` | The device's hotspot is open | | `needDecode` | `Bool` | Incoming file data still needs decoding | | `scene` / `sessionId` | `Int` | Current recording scene and session | | `bleDevice` | `BleDevice?` | The connected device model | | `delegate` | `BleAgentProtocol?` | Event sink — see [BleAgentProtocol](#bleagentprotocol) | #### Device Connection | `PlaudDeviceAgent` | `BleAgent` | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `startScan()` | `setFilter(name: nil)` → `startScan()` | | `stopScan()` | `stopScan()` | | `isConnected()` | `isDeviceConnect()` | | `connectBleDevice(bleDevice:deviceToken:)` | partner pre-handshake → `connectBleDevice(bleDevice:_:_:_:)` | | `recoveryConnectBleDevice(bleDevice:historicalUserId:)` | pin the historical id as the handshake token → `connectBleDevice(bleDevice:_:_:isForceClear: true)` | | `disconnect()` | `disconnect()` | | `depair(clear:)` | `depair(clear:)` — pass-through, no added logic | ```swift Swift icon="swift" theme={"system"} BleAgent.shared.delegate = self BleAgent.shared.initBluetooth() BleAgent.shared.setFilter(name: nil) BleAgent.shared.startScan() // From your bleScanResult callback: BleAgent.shared.connectBleDevice( bleDevice: device, userId, // devToken nil, // userName false // isForceClear ) ``` The device to connect to, as delivered by `bleScanResult(bleDevices:)`. Per-device token. The facade passes the user identifier here and rejects an empty string by emitting `bleConnectState(state: 2)`; the low-level agent performs no such validation. Owner name written to the device. The facade passes `"Plaud"`. `connectBleDevice(bleDevice:deviceToken:)` passes `false`; `recoveryConnectBleDevice(bleDevice:historicalUserId:)` passes `true`. See [Device Recovery at the BLE Layer](#device-recovery-at-the-ble-layer). Other lifecycle and auth methods on `BleAgent`: ```swift Swift icon="swift" theme={"system"} func initBluetooth() func disInitBluetooth() func setUserIdentifier(_ appKey: String, _ bindToken: String, _ hkServer: Bool = false) func checkAppKey(_ appKey: String) func setBinding(_ token: String) func setFilter(name: String?) // single-name filter, nil to disable func setFilter(_ names: [String]) // multi-name filter func startLoopScan() // continuous scan func isAuthOk() -> Bool func isSNTempChecked() -> Bool func reCheckSNIfNeed() func depair(clear: Bool = false) func openLog(_ opened: Bool, logBlock: ((String) -> Void)? = nil, wlogBlock: ((String) -> Void)? = nil) ``` `BleAgent.connectBleDevice(...)` does **not** run the partner pre-handshake, so connecting a protocol V20+ device (NotePro and newer) through the low-level agent fails unless you inject the handshake material yourself. See [Device Security](#device-security). #### Recording & Device State ```swift Swift icon="swift" theme={"system"} // Recording func startRecord(_ scene: Int = 0) func stopRecord() func pauseRecord(_ sessionId: Int) func resumeRecord(_ sessionId: Int) // State reads — results arrive on the delegate func getState() func getStorage() func getChargingState() func readPower() func getDeviceStatus() func setHeartBeat(status: Int) func setDeviceActive(status: Int) func setPrivacy(onOff: Int) func restoreFactory() ``` `BleAgent` also exposes the full device-settings surface that the facade only partially wraps. Each setting is a `read…` / `set…` pair, with the result delivered on the corresponding `BleAgentProtocol` callback. ```swift Swift icon="swift" theme={"system"} // Backlight func readBacklightDuration(); func setBacklight(duration: BacklightDuration) func readBacklightBright(); func setBacklight(bright: BacklightBright) // Language, scene & mode func readLanguage(); func setLanguage(type: LanguageType) func readRecScene(); func setRecScene(type: RecScene) func readRecMode(); func setRecMode(type: RecMode) func openVAD(open: Bool) func readVadSensitivity(); func setVadSensitivity(sensitivity: VadSensitivity) // Gains func readVpuGain(); func setVpuGain(gain: VpuGain) func readMicGain(); func setMicGain(value: Int) func readBatteryMode(); func setBatteryMode(value: Int) // Toggles func readSwitchHandler(); func setSwitchHandler(id: Int) func readAutoPowerOff(); func setAutoPowerOff(value: Int) func readRawWaveEnabled(); func setRawWaveEnabled(value: Int) func readRecordingAfterDisConnetEnabled(); func setRecordingAfterDisConnetEnabled(value: Int) func readSyncWhenIdleEnabled(); func setSyncWhenIdleEnabled(value: Int) func readFindMyState(); func setFindMyState(value: Int) func readVPUCLK(); func setVPUCLK(value: Int) func readStopRecordingAfterCharging(); func setStopRecordingAfterCharging(value: Int) func readAutoClear(); func saveAutoClear(_ open: Bool) // Naming, LED, alarm recording func readBleName(); func setBleName(name: String) func getLedState(); func setLedState(onOff: Int) func getAlarmRec(); func setAlarmRec(start: Int, duration: Int, repeatMode: Int) // Generic channel for settings without a dedicated method func getCommonParams(dataType: Int) func setCommonParams(dataType: Int, value: String) // Device logs, find-my, SoundPlus, SD flash, BLE rate test func getDeviceLogList(logType: Int) func startSyncDeviceLogFile(logType: Int); func stopSyncDeviceLogFile() func deleteDeviceLogFile(logType: Int) func resetFindmy() func setSoundPlusToken(licenseKey: String) func getSDFLASHCID() func startBleRateTest(_ packSize: Int = 80); func stopBleRateTest() ``` Most setters ship in two forms: an `@objc` variant taking a raw `Int` (e.g. `setRecScene(value: Int)`) and a Swift-only variant taking a typed enum (`setRecScene(type: RecScene)`). Prefer the typed variant. #### File Sync ```swift Swift icon="swift" theme={"system"} func getFileList(uid: Int, sessionId: Int, onlyOne: Bool = false) func syncFile(sessionId: Int, start: Int, end: Int, decode: Bool) func stopSyncFile() func deleteFile(sessionId: Int) func clearAllFile() func getMarking(_ sessionId: Int) func getRecordMarkingTags(uid: Int, startTimestamp: Int, endTimestamp: Int) ``` Request identifier echoed back on the response. The facade passes a timestamp. On `getFileList`, the session ID to start listing from. On `syncFile` / `deleteFile`, the recording to act on. Return a single file rather than the list from `sessionId` onward. This is what `PlaudDeviceAgent.getFile(sessionId:)` sets. Start byte offset. Use `0` for the whole file, or a `BleFile.offset` to resume. End byte offset. `0` transfers to the end of the file. Decode the stream in transit. Pass `false` for E2EE (protocol V20+) devices and decrypt the result yourself; `true` otherwise. File bytes arrive on `bleData(sessionId:start:data:)` and finish with `bleDataComplete()`. There is no format conversion at this layer — that is what `PlaudDeviceAgent.exportAudio(...)` adds on top. On a protocol V20+ device, `syncFile(decode: true)` returns bytes that are still E2EE-encrypted. Sync with `decode: false` and pass the file through `AudioFileDecryptor.decryptAudioFile(inputPath:privateKeyPem:)` using the private key from [`gen-key`](#device-security), or use `PlaudDeviceAgent.syncFile(...)`, which does this for you. #### Firmware Push ```swift Swift icon="swift" theme={"system"} func pushFotaInfo(_ uid: Int, _ fromVersion: String, _ toVersion: String, _ thirdVersion: Int = 0, _ fileSize: Int, _ crc: Int) func pushFotaPack(_ offset: Int, packData: Data, postDelayUs: NSNumber?) func pushFotaComplete(_ uid: Int, _ status: Int) func getUpdateInfo(_ callback: @escaping (Int, UpdateInfo?) -> Void) ``` #### Encryption ```swift Swift icon="swift" theme={"system"} var isSecureChannelEstablished: Bool { get } var isEncryptionSupported: Bool { get } func getEncryptionKey() -> String? func getEncryptionNonce() -> String? func getEncryptionAD() -> String? func getEncryptionParameters() -> [String: String]? // Transport encryption (ChaCha20-Poly1305 / AES-256) func decryptFileData(_ encryptedData: Data, key: String?, nonce: String?, ad : String?) throws -> Data func decryptFile(inputPath: String, outputPath: String, key: String?, nonce: String?, ad: String?) -> Bool // End-to-end (RSA-wrapped) audio func decryptE2EEAudioFile(inputPath: String, outputPath: String?, privateKey Pem: String) throws -> String func isE2EEEncryptedFile(path: String) -> Bool func getE2EEFileHeader(path: String) -> PlaudEncryptHeader? ``` #### BleAgentProtocol `BleAgentProtocol` is `BleAgent`'s delegate. `PlaudDeviceAgent` conforms to it internally and re-emits a filtered, mostly-optional subset to your `PlaudDeviceAgentProtocol`. | Protocol | Required members | Optional members | | -------------------------------------------------- | --------------------- | --------------------- | | `BleAgentProtocol` (`PlaudBleSDK`) | **96** | 1 (`bleConnectStage`) | | `PlaudDeviceAgentProtocol` (`PlaudDeviceBasicSDK`) | **1** (`blePenState`) | 49 | | `PlaudWiFiAgentProtocol` | 0 | all | Conforming to `BleAgentProtocol` means implementing 96 members. Unless you need an event the facade does not re-emit, keep your delegate on `PlaudDeviceAgentProtocol`. Where the two layers differ, the facade narrows the payload: | Callback | `BleAgentProtocol` | `PlaudDeviceAgentProtocol` | | ----------------- | ------------------------------------------- | --------------------------------- | | `blePenState` | + `versionType: String, versionCode: Int` | 7 params, no version fields | | `bleRecordStart` | `(sessionId:start:status:scene:startTime:)` | + `reason: Int` | | `bleConnectStage` | `(sn:stage:detail:)` | identical — straight pass-through | ##### Connect stages — `bleConnectStage` ```swift Swift icon="swift" theme={"system"} @objc optional func bleConnectStage(sn: String?, stage: String, detail: String?) ``` `bleConnectStage` is the diagnostics stream for the connect/handshake sequence — the only way to see *which* layer refused a rejected connection. `sn` may be `nil` before the serial number is known. | Argument | Values | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `stage` | `start`, `gatt_connect`, `set_notify`, `set_battery_notify`, `read_battery`, `set_data_notify`, `pre_handshake`, `send_rsa_public`, `first_handshake`, `two_handshake`, `handshake_get_ssn`, `change_handshake_timeout`, `sync_time` | | `detail` | Stage detail or failure reason — `ok`, `status_` (`status_1` = token mismatch), `sn_signature_empty`, `sn_signature_invalid`, `user_rsa_public_key_empty`, `bind_token_empty` | The `stage` strings are the raw values of the `BleAgent.ConnectStage` enum, and the callback delivers them as `String`, not as the enum case names. | `detail` | What it means | | --------------------------- | ---------------------------------------------------------- | | `sn_signature_empty` | `sn-sign` never ran, or the cached signature was lost | | `sn_signature_invalid` | The cached signature is stale — re-sign the SN | | `user_rsa_public_key_empty` | `gen-key` has not completed; the key pair is missing | | `bind_token_empty` | No handshake token was supplied to the connect | | `status_1` | Token mismatch — the firmware is locked to another account | A rejection at `pre_handshake` / `send_rsa_public` / `first_handshake`, or a `detail` of `status_1`, is the signature of a device still locked to a previous account — that is the cue for [Device Recovery](#device-recovery-at-the-ble-layer). Before SDK 1.0.13 all of these collapsed into an undifferentiated `bleConnectState(state: 2)`. The callback itself is not new — it has been `BleAgentProtocol`'s single optional member all along — but 1.0.13 is the release where `PlaudDeviceAgent` started re-emitting it, so you no longer have to drop to the BLE layer to receive it. *** ### WiFiAgent Reference #### WiFi Fast Transfer Each `PlaudWiFiAgent` method maps to a single `WiFiAgent` command: | `PlaudWiFiAgent` | `WiFiAgent` | Command | | --------------------- | ------------------------------ | ------- | | `connectWifi(_:_:_:)` | `connectWifi(_:_:_:)` | — | | `listenPort(_:_:)` | `listenPort(_:_:)` | — | | `disconnect()` | `disconnect()` | — | | `isConnectedTo(_:)` | `getCurrentWiFiName() == ssid` | — | | `getFileList(_:_:_:)` | `appGetFileList(_:_:_:)` | 11 | | `syncFile(_:_:_:_:)` | `appSyncFile(_:_:_:_:)` | 12 | | `deleteFile(_:_:)` | `appDeleteFile(_:_:)` | 14 | | `stopSyncFile(_:_:)` | `appStopSyncFile(_:_:)` | 15 | The flow is the same as the high-level PlaudWiFiAgent: open the device hotspot, then hand the device to the WiFi layer and wait for `wifiHandshake(0)` before issuing any file command. ```swift Swift icon="swift" theme={"system"} import PlaudDeviceBasicSDK WiFiAgent.shared.delegate = self WiFiAgent.shared.bleDevice = BleAgent.shared.bleDevice // required before connecting // From bleWiFiOpen(_:_:_:_:) on PlaudDeviceAgentProtocol: WiFiAgent.shared.connectWifi(wifiName, wifiPass, 60) extension SyncManager: WiFiAgentProtocol { func wifiHandshake(_ status: Int) { guard status == 0 else { return } WiFiAgent.shared.appGetFileList(Int(Date().timeIntervalSince1970), 0, false) } func wifiFileList(_ files: [BleFile]) { guard let file = files.first else { return } WiFiAgent.shared.appSyncFile(file.sessionId, 0, 0, file.scenes) } func wifiSyncFileData(_ sessionId: Int, _ offset: Int, _ count: Int, _ binData: Data) { // append binData at offset } func wifiDataComplete() { /* all bytes received */ } } ``` The device hotspot SSID, delivered as `wifiName` on `bleWiFiOpen(_:_:_:_:)`. The hotspot passphrase, delivered as `wifiPass` on `bleWiFiOpen(_:_:_:_:)`. Association timeout in seconds. `listenPort(_:_:)` defaults to `30`. On `appSyncFile` / `appStopSyncFile` / `appDeleteFile`, the file's own `BleFile.scenes` value. A mismatch is rejected by the device with a non-zero `wifiSyncFile` status. Additional transport controls not surfaced on the facade: ```swift Swift icon="swift" theme={"system"} func cancelConnectWifi() func clearAllWiFiConfigurations() // removes SSIDs this app configured func getCurrentWiFiNameWithRetry(maxRetries: Int = 3, delay: TimeInterval = 1.0) -> String? func isWebSocketConnected() -> Bool func appExtendWifiExitTime() // keep the hotspot alive longer func appWiFiRate(_ onOff: Bool, _ packSize: Int) func appGetLogs(_ begin: Bool) func startPushOTA(_ uid: Int, _ fileSize: Int, crc: Int, _ toVersion: Int) ``` #### WiFiAgentProtocol `WiFiAgentProtocol` is the low-level delegate. Unlike `PlaudWiFiAgentProtocol`, which is entirely optional, most of `WiFiAgentProtocol` are required. | Callback | Description | | ----------------------------------------------------------- | ------------------------------------------------------------------------ | | `wifiHandshake(_ status: Int)` | `0` = handshake complete, ready to issue commands | | `wifiFileList(_ files: [BleFile])` | Result of `appGetFileList(...)` | | `wifiFileListFail(_ status: Int)` | File listing failed | | `wifiSyncFile(_ sessionId: Int, _ status: Int)` | `0` = transfer accepted; non-zero = rejected (commonly a scene mismatch) | | `wifiSyncFileData(_:_:_:_:)` | A chunk of file data at an offset | | `wifiDataComplete()` | All bytes received | | `wifiSyncFileStop(_ status: Int)` | Transfer stopped or aborted | | `wifiFileDelete(_ sessionId: Int, _ status: Int)` | Result of `appDeleteFile(...)` | | `wifiCommonErr(_ cmd: Int, _ status: Int)` | A command failed, identified by command number | | `wifiPower(_ power: Int, _ voltage: Int)` | Device battery over the WiFi session | | `wifiRate(_:_:_:)` / `wifiRateFail(_:)` | Throughput test results | | `wifiLogs(_ logData: Data?)` / `wifiLogsFail(_:)` | Device log retrieval | | `wifiClientFail()` / `wifiClose(_ status: Int)` | Session dropped or closed | | `wifiTips(_ tips: Int)` | Device-side advisory code | | `penRequestOTAData(start:end:payloadSize:uid:sendRatePPS:)` | Device requesting an OTA chunk over WiFi | | `wifiOTAStatus(_ status: Int, _ uid: Int)` | OTA-over-WiFi status | `wifiCommonErr(cmd: 16, status: 0)` at the end of a transfer is expected and indicates a successful sync. *** ### PlaudPartnerApiManager `PlaudPartnerApiManager` wraps the partner authentication endpoints. Like `BleAgent`, reach it via the facade or the singleton: ```swift Swift icon="swift" theme={"system"} let api = PlaudDeviceAgent.shared.getPartnerApiManager() // or: PlaudPartnerApiManager.shared ``` ```swift Swift icon="swift" theme={"system"} final public class PlaudPartnerApiManager { public static let shared: PlaudPartnerApiManager public func setUserAccessToken(_ token: String?) public func getUserAccessToken() -> String? // → POST …/open/partner/sdk/sn-sign public func signDeviceSn(deviceType: String, sn: String, completion: @escaping (Result) -> Void) // → POST …/open/partner/sdk/gen-key public func generateRsaKeyPair( completion: @escaping (Result) -> Void) } ``` There is no `sn-verify` method on iOS. Signature **verification** happens on the device during the BLE pre-handshake, not through a client API call. #### Device Security Partner endpoints for device authentication: | Endpoint | Method | Auth header | Driven by | | --------------- | ------ | ------------------------------------ | ------------------------------------------- | | `…/sdk/gen-key` | `POST` | `Authorization: Bearer ` | `PlaudPartnerApiManager.generateRsaKeyPair` | | `…/sdk/sn-sign` | `POST` | `Authorization: Bearer ` | `PlaudPartnerApiManager.signDeviceSn` | The high-level `PlaudDeviceAgent` drives this entire flow. You only need these APIs if you are connecting through `BleAgent` directly or minting keys on your own backend. `initSDK(userAccessToken:customDomain:)` and every `setUserAccessToken(_:)` call fetch a fresh RSA key pair, store it, and mirror it into the BLE layer. A new key pair **invalidates every cached `sn-sign` signature**, since signatures are bound to the key pair that produced them. A response missing `private_key` is a hard failure — the public key alone is not enough to complete the encrypted pre-handshake. `connectBleDevice(...)` runs the partner handshake before it reaches `BleAgent`: Methods like `PlaudDeviceAgent.syncFile(...)` will use the symmetric key from the encrypted file header before decoding audio. # Android SDK Source: https://docs.plaud.ai/plaud-embedded/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 | The SDK ships native libraries for `arm64-v8a` / `armeabi-v7a`. For testing, you must use a physical device, not an emulator. **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. ## Installation ```bash theme={"system"} git clone https://github.com/Plaud-AI/plaud-sdk-public.git ``` 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') } ``` *** ## 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. 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). ```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:// ) ``` Your application `Context` (e.g. `applicationContext`). User Access Token (JWT), used for device authentication. 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). ### 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, 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 { /* ... */ } } } ``` Android activity Receives `true` only when every requested permission was granted. `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. *** ### 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 a device requires an API call to Plaud's cloud services, so you can track device statuses remotely. And a local bind triggered by the Embedded SDK to verify and generate keys on Plaud device. For the full cloud-side reference, see the [Device Binding APIs](/plaud-embedded/device-binding-api-overview). `.startScan()` delivers results on the `bleScanResult` callback defined in the [**PlaudDeviceAgentListener**](#plauddeviceagentlistener). ```kotlin Kotlin icon="android" theme={"system"} PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener { override fun bleScanResult(devices: List) { val match = devices.firstOrNull { it.serialNumber == lastSN } ?: return connect(match) // see the next step } override fun bleConnectState(state: Int) { /* 1=connected, 0=disconnected, 2=failed */ } override fun bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int) { /* status == 0 → bound */ } override fun blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int) { /* handshake complete */ } // ... recording / file-list / battery / storage callbacks } PlaudDeviceAgent.startScan() ``` Registers the device/owner association in the Plaud registry. Re-binding a device to the same owner is idempotent, so multiple calls will not have 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. ```kotlin Kotlin icon="android" theme={"system"} // POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind val body = JSONObject() .put("type", snType) // e.g. "notepro" / "notepins" .put("sn", sn) val request = Request.Builder() .url("https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind") .header("Authorization", "Bearer $userAccessToken") .post(body.toString().toRequestBody("application/json".toMediaType())) .build() ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. A `403` response means the device is already bound to **another** account. Connects and generates the key-pair on the device itself. The bind result is delivered on `bleBind(sn, status, protVersion, timezone)`. **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. ```kotlin Kotlin icon="android" theme={"system"} import sdk.NiceBuildSdk private fun connect(bleDevice: BleDevice) = lifecycleScope.launch(Dispatchers.IO) { val sn = bleDevice.serialNumber ?: return@launch val deviceType = if (sn.startsWith("881")) "notepro" else "notepins" // 1. Wait for the RSA key pair fetched by initSDK. val deadline = System.currentTimeMillis() + 10_000L while (!NiceBuildSdk.isPartnerDataReady() && System.currentTimeMillis() < deadline) { delay(200) } // 2. Sign the SN — the handshake reads the stored signature. if (!NiceBuildSdk.signAndStoreDeviceSn(deviceType, sn)) { // network unreachable or token rejected — the handshake will fail } PlaudDeviceAgent.connectBleDevice(bleDevice) } ``` A scanned device, as delivered by `bleScanResult`. 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. #### PlaudDeviceAgentListener Callbacks | Callback | Description | | -------------------------------------------------------------------- | -------------------------------------------------------------------- | | `bleScanResult(devices: List)` | Scan results updated | | `bleConnectState(state: Int)` | Connection state — `1` = connected, `0` = disconnected, `2` = failed | | `bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int)` | Device bound successfully | | `blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int)` | Secure handshake complete | 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.** ### 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. 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. Send an authenticated `POST` to the partner unbind endpoint on your regional domain. 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:///developer/api/open/partner/sdk/unbind val body = JSONObject() .put("type", snType) // e.g. "notepro" / "notepins" .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() ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. Clears the pairing/handshake on the device itself. Requires the device to be connected. On success the SDK disconnects and clears the session. The result is delivered on the `bleDepair(status: Int)` listener callback. ```kotlin Kotlin icon="android" theme={"system"} PlaudDeviceAgent.listener = object : PlaudDeviceAgentListener { override fun bleDepair(status: Int) { if (status == 0) { // device unpaired and disconnected } } // ... } PlaudDeviceAgent.depair(clear = false) ``` Whether to also clear the device's own stored bond list. **Pass `false` for the normal unbind flow** — this is what the [Android Starter App](/plaud-embedded/android-starter-app) does. Omit the argument to call the no-arg `depair()` overload. Reserve `clear = true` for recovering a device whose on-device pairing state is stale. `bleDepair` is best-effort — a device that is out of range or unresponsive never answers. Pair the callback with a timeout (a few seconds) and call `PlaudDeviceAgent.disconnect()` either way, so the BLE link is always released. #### 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 `bleConnectState(2)`. 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. 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. 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" expandable theme={"system"} try { val url = "https://platform-us.plaud.ai/developer/api/open/partner/sdk/binding" + "?type=${getDeviceType(sn)}&sn=$sn" val request = Request.Builder().url(url) .header("Authorization", "Bearer $userAccessToken") .get() .build() OkHttpClient().newCall(request).execute().use { resp -> if (!resp.isSuccessful) null else { val json = JSONObject(resp.body?.string() ?: "") CloudBindingInfo( // JSON null -> null (three-state) isBind = if (json.isNull("is_bind")) null else json.optBoolean("is_bind"), bindHistory = json.optJSONArray("bind_history")?.let { arr -> (0 until arr.length()).mapNotNull { arr.optString(it).takeIf { id -> id.isNotBlank() } } } ?: emptyList() ) } } } catch (e: Exception) { null } ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. **Response:** `true` = bound to another account, `false` = unbound, `null` = signed but never bound. Use Device Recovery only when this is **not** `true`. Previously bound client IDs, newest first. `bind_history` records one entry **per bind event**. For each previous client ID, `recoveryConnectBleDevice` uses that ID as the handshake token and connects with force-clear, wiping the device's stale data. ```kotlin Kotlin icon="android" expandable theme={"system"} suppressAutoReconnect = true recoveryInProgress = true try { for (historicalId in history) { val signal = CompletableDeferred() recoverySignal = signal PlaudDeviceAgent.recoveryConnectBleDevice(bleDevice, historicalId) val unlocked = withTimeoutOrNull(25_000L) { signal.await() } ?: false if (!unlocked) { // This ID did not match the firmware lock — try the next one. PlaudDeviceAgent.disconnect() delay(1_000) continue } // Matched — wipe the stale bond and wait for the device's confirmation. depairAndAwait() recoveryInProgress = false delay(1_500) // depair changes the MAC, so rescan and match by SN before reconnecting. val fresh = rescanForDevice(sn) ?: return@launch failRecovery( "The device was unlocked but did not reappear — please rescan and connect it." ) // Reconnect as the current user — this rebinds the device. withContext(Dispatchers.Main) { suppressAutoReconnect = false connect(device, currentUserId) } return@launch } } finally { recoveryInProgress = false recoverySignal = null } } ``` The scanned device, from `bleScanResult`. One `bind_history` entry to try as the handshake token. *** ### File Synchronization The `exportAudio` method exports audio files from the Plaud device to your users' phone, reporting progress through the [**AudioExporter.ExportCallback**](#audioexporterexportcallback). Plaud devices will record **up to 5 hours**. Recordings longer should be broken up. 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 } } ) ``` Session ID Output directory `PCM` | `WAV` | `OPUS` | `MP3`. We recommend `MP3` — it plays everywhere and is accepted directly by the transcription upload API. Number of audio channels (`1` = mono). Optional — an overload without this argument exists. `fun onProgress(progress: Int, message: String)` `fun onComplete(outputFile: File)` 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) *** ### 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. `getWifiAgent()` returns `IWifiTransferAgent?` — it is `null` until the SDK has a WiFi agent to hand out. Always reach it with a safe call (`getWifiAgent()?.…`). 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) ``` `true` opens the device hotspot, `false` closes it. 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) } // ... } ``` Identifier for the transfer session. Drives the rest of the flow. See [**WifiTransferCallback**](#wifitransfercallback) below. Returns `false` if the session could not be opened. 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) { // export each session (next step) } override fun onError(code: Int, message: String) { } // ... } ``` `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 } } ) ``` 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. ```kotlin Kotlin icon="android" theme={"system"} PlaudDeviceAgent.endWiFiTransfer() ``` 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. 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)) ``` Session IDs of the recordings to delete from the device. #### 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)` | 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)` | 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 | 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. *** ### 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). 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! #### Check for Firmware Updates ```kotlin Kotlin icon="android" theme={"system"} // Check for update PlaudDeviceAgent.checkFirmwareUpdate(object : SimpleFirmwareUpdateCallback() { override fun onUpdateCheckResult(result: Result) { val info = result.getOrNull() ?: return if (!info.hasUpdate) return // proceed to download (below) } }) ``` Callback that reports the check result as a `FirmwareUpdateInfo`. Whether a firmware update is available The device's current firmware version Whether the update is mandatory The full version-check response (latest version, download URL, MD5, release notes) #### 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) */ } } } }) ``` The `FirmwareUpdateInfo` returned by `checkFirmwareUpdate`. Reports download progress and completion. Progress from 0 to 100 Human-readable status message Additional detail for the current step `TRANSFERRING` / `TRANSFER_COMPLETE_WAITING` / `DEVICE_RESTARTING` / `UPGRADE_COMPLETE` / `TRANSFER_FAILED` / `UPGRADE_FAILED` Whether the download succeeded The downloaded firmware file Whether the MD5 checksum verified Error description if the download failed #### 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 } } }) ``` The firmware file from `FirmwareDownloadResult.file` The `FirmwareUpdateInfo` returned by `checkFirmwareUpdate` Reports install progress and completion. Whether the install succeeded Error description if the install failed *** ## 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. 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. ### 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)` | Scan results updated | | Connection | `bleScanOverTime()` | Scan window elapsed | | Connection | `bleConnectState(state: Int)` | `1` = connected, `0` = disconnected, `2` = failed | | Connection | `bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int)` | Binding result — `status == 0` → bound OK | | 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)` | 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 | 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. ### 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 } } ``` Export progress, `0`–`100` (download bytes), with a human-readable status message. Called when decoding finishes; `outputFile` is the written file. Called if export fails, with a description of the error. Optional (has a default implementation). Language-neutral phase signal: `DOWNLOADING` (bytes still transferring off the device) → `TRANSCODING` (only local decode/encode remains). ### 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) { 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)` | 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`) | # Android Starter App Source: https://docs.plaud.ai/plaud-embedded/android-starter-app Build a branded Android app that connects to Plaud devices in minutes using the Android starter app. This guide walks through the process of building a native Android [Starter App](/plaud-embedded/starter-app-specs) that: 1. Connects to Plaud devices 2. Syncs recordings from Plaud device to mobile phone 3. Uploads recordings from your users' mobile phone to Plaud's file storage 4. Transcribes recordings with the [Transcription API](/plaud-embedded/transcription-api-overview) starter-app ## Onboard to the Plaud Developer Platform Sign in to the [Plaud Developer Portal](https://portal.plaud.ai/) (It's completely free with a generous device connection limit and transcription usage). Create an **Embedded SDK Application** to receive your **Client ID** and **Secret Key**. app credentials ## Set Up the Starter App **Try the Plaud Embedded Skill** to have your coding agent help you through your Starter App deployment. ```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. ### Prerequisites * Android Studio (JDK 17) * minSdk 21 (Android 5.0+), compileSdk 34 * A **physical Android device** — the SDK libraries are `arm64-v8a`/`armeabi-v7a`, Android simulators are not supported * A Plaud device for end-to-end testing ### Clone the starter app You can find the GitHub repository for the [starter app here](https://github.com/Plaud-AI/plaud-sdk-public). ```bash theme={"system"} git clone https://github.com/Plaud-AI/plaud-sdk-public.git cd plaud-sdk-public/android ``` Install [Android Studio](https://developer.android.com/) to build and test your Android Starter app. ```bash theme={"system"} brew install --cask android-studio ``` ### Retrieve a user token The `USER_ACCESS_TOKEN` is the per-user JWT your backend mints by calling `POST /open/partner/users/access-token`. See the [Authorization API reference](/plaud-embedded/auth-api-overview) for the full exchange flow. ### Configure credentials After navigating to the `/android` directory, create a `local.properties` file (gitignored) and add the **user token, client ID, and API key** (if using transcription). ```bash local.properties theme={"system"} sdk.dir=/path/to/your/Android/sdk # Required for SDK initialization PLAUD_USER_ACCESS_TOKEN=your-jwt-token # Required for Transcription API (optional, not needed for device features) PLAUD_CLIENT_ID=your-client-id PLAUD_API_KEY=your-api-key ``` ### (Optional) Apply branding Four places control the entire visual identity: #### App Name Navigate to `app/src/main/res/values/strings.xml`. You can modify the app name, and other text on the welcome screen. ```xml strings.xml theme={"system"} Plaud Template App name Get Started Connect device later ``` #### App icon Create new image assets directly in Android Studio. Right-click `res`, then go to `New > Image Asset`. android-studio image asset #### Theme colors In `res/values/colors.xml`, you have full control over the starter app's primary colors and color palette. ```xml colors.xml theme={"system"} #F9F9F9 #1F1F1F #A3A3A3 #EBEBEB ``` ## Run & test with a real device Open **Android Studio**, and wait for gradle to finish building the app. Once indexing and build is finished, **connect your physical Android device** and run the app. android studio Verify app launches, device pairs, recording syncs, and transcript appears on your Android app. **Unbind your Plaud device after testing and before uninstalling the Starter App!** Plaud devices can only be bound to one application at a time (tied to your Partner Token). You will not be able to bind your Plaud device to another app (or the Plaud App) before unbinding from the Starter App. ## Publishing to the Google Play Store If you don't have a [Google Play Developer Account](https://play.google.com/console/signup), you will need to sign up to publish your app to the Play Store . There is a one-time \$25 registration fee, and a verification process that may take a few days. In `app/build.gradle`, replace the template's application ID with your own **unique** reverse-domain identifier and bump the `compileSdk`. **Sdk 35** is the minimum version that Google Play will accept due to security and performance APIs that are only compatible with 35+. ```kotlin app/build.gradle theme={"system"} android { namespace 'com.plaud.template'// [!code --] namespace 'com.yourorg.yourapp'// [!code ++] compileSdk 34// [!code --] compileSdk 35// [!code ++] defaultConfig { applicationId "com.plaud.template"// [!code --] applicationId "com.yourorg.yourapp"// [!code ++] minSdk 21 targetSdk 34// [!code ++] ``` Bump `versionCode` on every upload — Play Console rejects a build that reuses a `versionCode` already in the track. You can use your editor's find and replace or this bash command to replace all strings in the `app/` directory. ```bash theme={"system"} rg -l 'com\.plaud\.template' app/ | xargs sed -i '' 's/com\.plaud\.template/com.yourorg.yourapp/g' ``` Play requires every build to be signed with a consistent upload key. In Android Studio, go to `Build > Generate Signed App Bundle / APK`, choose **Android App Bundle**, then `Create new...` to generate a keystore. android key If you lose your key, you will have to go through Google's key reset process. Finish the `Build > Generate Signed App Bundle / APK` flow with the `release` build variant. Android Studio writes the bundle to `app/release/app-release.aab`. In the [Google Play Console](https://play.google.com/console), select `Create app`, then set your app name, default language, app type, and free/paid status. The application ID from Step 2 is claimed when you upload your first bundle. Under `Testing > Internal testing`, create a release and upload your `.aab`. Internal Testing on Google Play Add your testers by email or Google Group, then share the opt-in link with them. Under the `Internal app sharing` tab, enable sharing. Allow your email list to access and download your app. Internal Sharing on Google Play Work through the `Dashboard` checklist. Items to emphasize for a conversation capture app are: 1. **Data safety** 2. **Permissions declaration**: the SDK requires Bluetooth and (on Android 11 and below) location permissions to scan for and connect to Plaud devices. 3. **Privacy policy** 4. **Store listing assets** — app icon (512×512), feature graphic (1024×500), and at least two phone screenshots. Once the checklist is complete, submit your production release for review! Google's review typically takes a few days for a first submission. # API Playground Source: https://docs.plaud.ai/plaud-embedded/api-playground # Authentication APIs Source: https://docs.plaud.ai/plaud-embedded/auth-api-overview Authenticate to Plaud APIs to connect (bind) devices to your mobile app and use the Transcription API As a Plaud Partner on the developer platform, you will need two types of tokens to authenticate to Plaud Embedded: 1. **Partner Token** - This is an **application-level token** you can use to authenticate all of your users. 2. **User Token** - This is a **user-level token** used to authenticate their device to your mobile app using the [Embedded SDK](/plaud-embedded/ios-sdk). ## Using the Authentication API ### Prerequisites Use Plaud's Authentication APIs to retrieve your Partner Token and User Token using your app's `client_id` and `client_secret` found in the [developer portal](https://portal.plaud.ai/). app credentials Try the [Plaud Embedded API Playground](https://plaud-embedded-playground.vercel.app/) to see every step of the transcription process with your own client credentials. End-to-end, from **authentication** to **recording audio** to **uploading** to **transcription**. ### Exchange Credentials for a Partner Token The Partner Token is an application-level token to issue user-level tokens. ```http theme={"system"} POST https://platform-us.plaud.ai/developer/api/oauth/partner/access-token Authorization: Basic base64(client_id:secret_key) Content-Type: application/x-www-form-urlencoded ``` ```json theme={"system"} { "access_token": "eyJhbGciOiJSUz...", "refresh_token": "eyJhbGci...", "token_type": "bearer", "expires_in": 3600 } ``` ### Mint a User Token with Your Partner Token The User Token is a user-specific token. This token will be used in the [Embedded SDK](/plaud-embedded/ios-sdk) to bind to your users' devices and upload files. ```http theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/users/access-token Authorization: Bearer Content-Type: application/json { "user_id": "", "expires_in": 86400 } ``` ```json theme={"system"} { "access_token": "eyJhbGci...", "token_type": "bearer", "expires_in": 86400 } ``` Plaud's cloud services are hosted in the U.S. by default. If you're interested in multi-region hosting in Japan, Europe, and Singapore, please [reach out to our sales team](https://dev.plaud.ai/contact). # Billing Source: https://docs.plaud.ai/plaud-embedded/billing Prices and billing for Plaud Embedded apps Plaud Embedded has a PAYG model that scales along **connected devices** and **transcription hours**. | Usage Type | Definition | | ------------------- | ----------------------------------------------------------------------------------------------------------------- | | Connected Devices | Number of Plaud devices your Embedded mobile app has connected to via the [Embedded SDK](/plaud-embedded/ios-sdk) | | Transcription Hours | Hours of audio transcribed via the [Transcription API](/plaud-embedded/transcription-api-overview) | Connected Devices are measured as the **maximum number of devices connected to your mobile app in a given month**. If you had 25 connected devices at the beginning of the month and 20, your connected device count for the month would be 25. Join our [Partner Program](https://plaud-embedded-partner-us.bixgrow.com/) to track your customers' device purchases, qualify for volume discounts, and participate in revenue-based incentives in the future. partner program ## Free Usage As a new Plaud Embedded developer, you have: * **50 free Connected Devices** * **300 hours of free transcription** (after first device connection) The 50 free Connected Devices should cover all testing and even small rollouts to your end-users. We've had many customers roll out pilots to their users on the free tier! See examples in our [customer stories](https://dev.plaud.ai/customers). customer ss Once you've connected a device via the [Embedded SDK](/plaud-embedded/ios-sdk), your 300 transcription hours will be unlocked for your testing and pilots. ## PAYG Pricing After your free usage, billing will be charged through a PAYG model: | Usage Type | Definition | Rate | | ------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------- | | Connected Devices | Number of Plaud devices your Embedded mobile app has connected to via the [Embedded SDK](/plaud-embedded/ios-sdk) | **\$15/device (per month)** | | Transcription Hours | Hours of audio transcribed via the [Transcription API](/plaud-embedded/transcription-api-overview) | **\$0.28/hour** | Charges occur **at the end of the month**. Any payment failures will have a 7 day grace period before services are denied for your Embedded app. ## Enterprise Plans The PAYG model does not have device or transcription usage limits. If you're interested in an enterprise plan for HIPAA compliance, volume discounts, and dedicated support, please reach out to our team by filling out our [contact form](https://dev.plaud.ai/contact/). # Changelog Source: https://docs.plaud.ai/plaud-embedded/changelog Device Recovery and Binding Visibility Encrypted devices that were left bound to a previous user can now be recovered directly from the Embedded SDK, and connection attempts report where they are in the handshake. * `recoveryConnectBleDevice` reclaims an encrypted device still holding a prior user's credentials, using the historical user ID from bind history * Connection stage callbacks surface each step of the BLE handshake, along with the reason a connection failed * All SDK error messages and progress text returned to your app are now in English on both iOS and Android [Android Embedded SDK](/plaud-embedded/android-sdk) release with full feature parity with the iOS Embedded SDK. * Covers scanning, connection, recording, file listing/sync/delete, settings, WiFi provisioning, WiFi fast transfer, and OTA MP3 export now supported over iOS WiFi fast transfer Transfer Insights & Export Progress The Embedded SDK now surfaces real-time transfer speed and more accurate export progress across both WiFi fast transfer and BLE. * Live download speed reporting for WiFi fast transfer and BLE transfers * End-to-end export progress (0–100%) across download and conversion * Batch download support to export all recordings in a single call MP3 Export & WiFi Fast Transfer The Embedded SDK now supports new audio export and transfer options for recordings. * MP3 export format added alongside existing audio formats * WiFi fast transfer for quicker recording downloads from your device Developer Platform Beta Launch The Plaud Developer Platform is now available in beta. Customers can integrate Plaud recordings, transcripts, and AI summaries into their own applications using the Embedded SDK. * Embedded SDK for iOS and Android * Starter app to get started # Data Retention Source: https://docs.plaud.ai/plaud-embedded/data-retention How data is stored with Plaud Embedded With Plaud Embedded, you maintain control of your users' conversation data. There are two components of our system your users' data may flow into: 1. **Plaud devices**: Both the Note Pro and Note Pin S have 64GB of local storage 2. **Plaud cloud services**: For transcribing audio files While Plaud Embedded was designed for builders to integrate with our hardware (Plaud devices) and software (transcription models), you **do NOT have to opt in to both**. ### Complete Plaud Embedded Integration data diagram ## Full Data Custody For users with strict data retention policies, integrating with **just Plaud devices** keeps your users' data completely on your users' devices and your application services. data diagram ## Multi-Region / Specific Region Requirements For users using Plaud's Transcription API, services are hosted in U.S. regions by default. If you have specific requirements (i.e. GDPR or multi-region support), your data can be processed in these select regions: | Region | Public host | Status | | --------- | ------------------------------------ | --------------------------------------------- | | US | `platform-us.plaud.ai/developer/api` | Default | | Japan | `platform-jp.plaud.ai/developer/api` | [Contact Sales](https://dev.plaud.ai/contact) | | Europe | `platform-eu.plaud.ai/developer/api` | [Contact Sales](https://dev.plaud.ai/contact) | | Singapore | `platform-sg.plaud.ai/developer/api` | [Contact Sales](https://dev.plaud.ai/contact) | Transcriptions processed with our APIs are **retained for 7 days by default** (this can be configured on custom plans). # Device Binding APIs Source: https://docs.plaud.ai/plaud-embedded/device-binding-api-overview APIs to register and sync Plaud device state Binding a Plaud device involves two steps: 1. **Cloud bind**: registers the device/owner association in the Plaud registry so you can track Connected Device state remotely 2. **Local device bind**: triggered over BLE by the Embedded SDK, which verifies and generates the key pair on-device The `/bind`, `/unbind`, and `/binding` are used to update the Plaud registry to keep your local device state and remote device state in-sync at all times. ## Using the Device Binding APIs ### Prerequisites The Device Binding APIs authenticate with a **User Token**. Use the [Authentication API](/plaud-embedded/auth-api-overview) if you don't have a User Token or need a new one. ### Binding a Plaud Device to a User 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 are always encrypted and can only be decrypted with a valid User Token by your application. The `POST /sdk/bind` endpoint registers the device/owner association in the Plaud registry. ```http theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind Content-Type: application/json Authorization: Bearer { "type": "notepro", "sn": "8810000000000001" } ``` ```json theme={"system"} { "type": "notepro", "sn": "8810000000000001", "is_bind": true } ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. After the cloud bind succeeds, run the local bind over BLE with the Embedded SDK. See [iOS](/plaud-embedded/ios-sdk#connecting-binding-to-a-plaud-device) or [Android](/plaud-embedded/android-sdk#connecting-binding-to-a-plaud-device) for the SDK side of the flow. Plaud devices can only be bound to one mobile application. If a user is **uninstalling your mobile app, make sure to unbind their Plaud device.** ### Unbinding a Plaud Device to a User 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. The `POST /sdk/unbind` endpoint removes the device/owner association in the Plaud registry. ```http theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/unbind Content-Type: application/json Authorization: Bearer { "type": "notepro", "sn": "8810000000000001" } ``` ```json theme={"system"} { "type": "notepro", "sn": "8810000000000001", "is_bind": false } ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. Pair this call with the SDK's depair over BLE, which clears the pairing/handshake on the device itself. See [iOS](/plaud-embedded/ios-sdk#standard-unbind) or [Android](/plaud-embedded/android-sdk#standard-unbind) for the SDK side of the flow. ### Recovering a Plaud Device In certain situations, the cloud and local bind state can go **out-of-sync**, causing the device to lock. Device recovery re-handshakes with each previously bound client ID until one matches the lock on the device, at which point the stale ownership lock is wiped and the device can be bound to the current user. The `GET /sdk/binding` endpoint returns the remote binding state of a device along with every client ID it has previously been bound to, including bindings made in other apps. ```http theme={"system"} GET https://platform-us.plaud.ai/developer/api/open/partner/sdk/binding?type=notepro&sn=8810000000000001 Authorization: Bearer ``` ```json theme={"system"} { "is_bind": false, "bind_history": [ "b7c1f0a2-4d5e-4a3b-9c8d-1e2f3a4b5c6d", "3f9e8d7c-6b5a-4938-8271-0a1b2c3d4e5f" ] } ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. **Response:** `true` = bound to another account (stop — recovery is not possible), `false` = unbound, `null` = signed but never bound. Run Device Recovery only when this is **not** `true`. Previously bound client IDs, newest first. `bind_history` records one entry **per bind event**, so the same client ID can appear more than once — de-duplicate before using it for Device Recovery. 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. Pass each client ID from `bind_history` to the SDK's recovery connect, which uses that ID as the handshake token and connects with force-clear. See [iOS](/plaud-embedded/ios-sdk#device-recovery) or [Android](/plaud-embedded/android-sdk#device-recovery) for the SDK side of the flow. # Plaud Devices Source: https://docs.plaud.ai/plaud-embedded/devices Devices designed for conversation-based work Plaud devices are built for executives, sales teams, clinicians, and anyone who needs their conversation data turned into insights and actions. On Plaud devices, user data is always kept **private and secure**, meaning: * Encrypted-at-rest * Full control over what files are shared and synced * GDPR, SOC2, and HIPAA compliant practices Plaud currently has two flagship devices: Designed for phone calls and conversation-heavy workflows. Designed for hands-free, on-the-go use in field work, healthcare, retail, and beyond. Plaud Embedded currently only supports the Plaud Note Pro and Plaud NotePin S. The Plaud Note and Plaud NotePin are NOT supported under Plaud Embedded. | Specs | Plaud Note Pro | Plaud NotePin S | | ---------------------------------- | ----------------------------------- | --------------------------- | | Battery | 30–50 h (endurance mode) | 20 h | | Microphones | 4 MEMS, 1 VPU | 2 MEMS | | Connectivity | Dual-band Wi-Fi + Bluetooth | Dual-band Wi-Fi + Bluetooth | | Storage | 64G | 64G | | Mode | Smart dual-mode (calls + in-person) | In-person conversations | | Max single-file recording duration | 5h | 5h | Join our [Partner Program](https://plaud-embedded-partner-us.bixgrow.com/) to track your customers' device purchases, qualify for volume discounts, and participate in revenue-based incentives in the future. partner program # File Upload API Source: https://docs.plaud.ai/plaud-embedded/file-api-overview Upload your user's conversation audio to Plaud storage and get a download URL to pass to the Transcription API. The [Transcription API](/plaud-embedded/transcription-api-overview) accepts any publicly accessible audio URL. **This File Upload API is optional**. You can host your audio files on your own storage if you prefer. The File Upload API uploads audio files from your users' mobile apps to Plaud storage. Once files are uploaded, these files can be passed to the [Transcription API](/plaud-embedded/transcription-api-overview) to take advantage of Plaud's transcription and ASR models. Using the `POST /generate-presigned-urls` endpoint, Plaud will send an array of pre-signed S3 upload endpoints for you to upload your users' audio files You must send **\<`ChunkSize`** of data to each `PresignedUrl`. `PUT` the raw bytes of each chunk directly to its presigned S3 URL (no auth needed), then read the `ETag` (Entity Tag) response header for each part. Send your list of `ETag`s to the `POST /complete-upload` endpoint to receive your file's `DownloadUrl` ## Using the File Upload API ## Prerequisites The File Upload API authenticates with a **User Token**. Use the [Authentication API](/plaud-embedded/auth-api-overview) if you don't have a User Token or need a new one. Try the [Plaud Embedded API Playground](https://plaud-embedded-playground.vercel.app/) to see every step of the transcription process with your own client credentials. End-to-end, from **authentication** to **recording audio** to **uploading** to **transcription**. ### Generate Presigned Upload URLs Request presigned S3 URLs for a multipart upload from Plaud's API. The number of presigned URLs will depend on your file size. ```http theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/files/upload/generate-presigned-urls Content-Type: application/json Authorization: Bearer { "filesize": 10485760, "filetype": "mp3" } ``` ```json theme={"system"} { "FileId": "file_xxx", "UploadId": "upload_xxx", "ChunkSize": 5242880, "Parts": [ { "PartNumber": 1, "PresignedUrl": "https://plaud-bucket.s3.amazonaws.com/..." }, { "PartNumber": 2, "PresignedUrl": "https://plaud-bucket.s3.amazonaws.com/..." } ] } ``` ### Upload Each Chunk to S3 For each part, `PUT` up to the `ChunkSize` of raw bytes (5MB) to its `PresignedUrl`. These AWS S3 calls are presigned thus no authentication is needed. Keep the `ETag` (Entity Tags) found in the response headers from each `PUT` call. These are needed in `POST /complete-upload`. ```http theme={"system"} PUT [PresignedUrl] [raw bytes of chunk] ``` The S3 response includes the `ETag` header for that part: ```http theme={"system"} ETag: "abc123..." ``` ### Complete the Multipart Upload Submit the: 1. `file_id` from the first step 2. `upload_id` from the first step 3. Array of `ETag` with its corresponding `PartNumber` from the second step ```http theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/files/upload/complete-upload Content-Type: application/json Authorization: Bearer { "file_id": "file_xxx", "upload_id": "upload_xxx", "part_list": [ { "PartNumber": 1, "ETag": "\"abc123...\"" }, { "PartNumber": 2, "ETag": "\"def456...\"" } ], "filetype": "mp3", "file_md5": "9e107d9d372bb6826bd81d3542a419d6" } ``` ```json theme={"system"} { "FileId": "file_xxx", "FileType": "mp3", "DownloadUrl": "https://plaud-bucket.s3.amazonaws.com/...", "FileMd5": "9e107d9d372bb6826bd81d3542a419d6" } ``` The returned `DownloadUrl` is valid for **24 hours**. Pass it as `file_url` to the [Transcription API](/plaud-embedded/transcription-api-overview) to transcribe your file! Plaud's cloud services are hosted in the U.S. by default. If you're interested in multi-region hosting in Japan, Europe, and Singapore, please [reach out to our sales team](https://dev.plaud.ai/contact). # Using Flutter Source: https://docs.plaud.ai/plaud-embedded/flutter You can use Plaud's iOS and Android SDK in Flutter through a [Flutter plugin](https://docs.flutter.dev/packages-and-plugins/developing-packages#plugin). Use our `plaud_sdk` plugin as a starter template for: 1. Connecting to Plaud Devices 2. Syncing audio files 3. Transcription demo app ss For advanced usage of the Embedded SDK methods for use cases like WiFi fast transfers, we recommend adding more methods to this plugin or using the native [Embedded iOS SDK](/plaud-embedded/ios-sdk) directly ## How it works Flutter uses [platform channels](https://docs.flutter.dev/platform-integration/platform-channels) to let your Flutter app call native platform code. A [plugin](https://docs.flutter.dev/packages-and-plugins/developing-packages#plugin) packages that native code so it can be dropped into any Flutter app. Plaud Embedded's plugin can be added as a path dependency, imported as a typed Dart library, and run as native code via the [Embedded iOS SDK](/plaud-embedded/ios-sdk) and [Embedded Android SDK](/plaud-embedded/android-sdk). ``` your Flutter code │ import 'package:plaud_sdk/plaud_sdk.dart' ▼ ┌─────────────────────────────┐ │ Dart layer (lib/*.dart) │ static PlaudSdk API, fully typed, │ │ guard with isPlaudSdkAvailable off-iOS └─────────────────────────────┘ │ MethodChannel (calls) / EventChannel (typed streams) ┌─────────────────────────────┐ │ Plaud native SDK │ three precompiled .xcframeworks │ (ios/Frameworks/*) │ BLE / Device / WiFi └─────────────────────────────┘ ``` Plaud's Flutter plugin uses a `MethodChannel` for method calls and an `EventChannel` for event listening, passing data between the Dart layer and the native SDK. ## Running the Demo App The demo app is included in the Plaud Embedded Flutter repo as reference for implementing the plugin in your own app and seeing how it works. ```bash theme={"system"} git clone https://github.com/Plaud-AI/embedded-flutter.git ``` ```bash theme={"system"} cd embedded-flutter flutter pub get cp .env.example .env ``` You can retrieve your environment credentials from the [developer portal](https://portal.plaud.ai/) and generate a token from our [API playground](https://plaud-embedded-playground.vercel.app) Open `ios/Runner.xcworkspace` once to set your Apple developer team (Runner → Signing & Capabilities). Then **run on a physical device** to test out the demo app with your Plaud devices. Credentials are compile-time defines, so `--dart-define-from-file=.env` is required on every run. ```bash theme={"system"} flutter run --dart-define-from-file=.env ``` demo app ss ## How to Integrate with your Flutter App Try the Embedded Flutter Skill to upload this doc and the codebase context for your agent. ```bash theme={"system"} npx skills add Plaud-AI/embedded_flutter ``` ### Prerequisites | Tool | Notes | | --------------------- | ------------------------------------------ | | Flutter | Dart SDK 3.12+ | | Xcode | 15.x+, with a physical iPhone + Apple ID | | CocoaPods | `brew install cocoapods` | | iOS deployment target | **15.1** (the Plaud frameworks require it) | ### Step 1: Clone the Plaud Embedded Flutter Repo ```bash theme={"system"} git clone https://github.com/Plaud-AI/embedded_flutter.git ``` This repo includes: 1. Flutter plugin (`plugins/plaud_sdk`) for the Plaud iOS and Android SDK for basic functionality 2. Example Flutter app 3. Skill for implementing the Plaud Embedded plugin within projects ### Step 2: Copy the Plaud Embedded Plugin into your App At the root of your Flutter project, copy `plugins/plaud_sdk` into your project's `plugins` directory. ```bash theme={"system"} cp -R plugins/plaud_sdk your-app/plugins/plaud_sdk ``` Then depend on the plugin via a path reference in your `pubspec.yaml`: ```yaml pubspec.yaml theme={"system"} dependencies: plaud_sdk: path: plugins/plaud_sdk ``` ```bash theme={"system"} flutter pub get ``` Flutter's plugin autolinking handles importing the plaud\_sdk plugin; no extra configuration is needed. ### Step 3: Configure Permissions **For iOS**, set the deployment target to **15.1** in `ios/Podfile` (`platform :ios, '15.1'`) and the Runner Xcode target — the Plaud frameworks require it, don't go lower. Then add BLE permissions to `ios/Runner/Info.plist` for the Plaud SDK to leverage iOS's native bluetooth functionality (without the first key, the app hard-crashes the moment it touches Bluetooth). ```xml Info.plist theme={"system"} NSBluetoothAlwaysUsageDescription Plaud uses Bluetooth to connect to your recorder and sync recordings. UIBackgroundModes bluetooth-central ``` **For Android**, the permissions are in your manifest from the plugin. Permissions requests are done at runtime. ### Step 4: Use the Plaud SDK from Flutter You can now import the `plaud_sdk` library and use the [Embedded iOS SDK](/plaud-embedded/ios-sdk) and [Embedded Android SDK](/plaud-embedded/android-sdk) straight from your Flutter project in Dart. ```dart theme={"system"} import 'package:plaud_sdk/plaud_sdk.dart'; if (!isPlaudSdkAvailable) { /* off-device: show a "device required" state */ } await PlaudSdk.initSDK( userAccessToken: token, // per-user Bearer JWT customDomain: 'platform-us.plaud.ai', // domain only, no https:// userId: 'your-app-user-id', // default connect deviceToken ); final subs = [ PlaudSdk.onScanResult.listen((devices) {/* show devices */}), PlaudSdk.onConnectState.listen((s) { if (s.connected) PlaudSdk.getFileList(); // load recordings on connect }), PlaudSdk.onFileList.listen((files) {/* show recordings */}), PlaudSdk.onExportProgress.listen((p) {/* p.progress (0–100), p.message */}), ]; await PlaudSdk.startScan(); await PlaudSdk.connectBleDevice(uuid: device.uuid); // from an onScanResult device final export = await PlaudSdk.exportAudio( sessionId: file.sessionId, format: PlaudAudioFormat.mp3, ); // export.outputPath → the decoded mp3 under Documents/PlaudExports for (final s in subs) { s.cancel(); } ``` ### Step 5: Run Your App ```bash theme={"system"} flutter pub get flutter run --dart-define-from-file=.env ``` For the full list of relevant SDK methods for interacting with Plaud devices, see our [iOS SDK reference](/plaud-embedded/ios-sdk) and [Android SDK reference](/plaud-embedded/android-sdk). # How Plaud Embedded Works Source: https://docs.plaud.ai/plaud-embedded/how-plaud-embedded-works We'll walk through a complete Plaud Embedded implementation to understand how the SDKs and APIs fit together. Multiple SDKs and APIs can be a lot to juggle. **Don't worry!** We'll walk through the steps you need to know to implement Plaud Embedded end-to-end. ## The End-to-End Flow In general, integrating with Plaud Embedded end-to-end is done through the following steps: Try the [Plaud Embedded API Playground](https://plaud-embedded-playground.vercel.app/) to see every step of the transcription process with your own client credentials. End-to-end, from **authentication** to **recording audio** to **uploading** to **transcription**. Let's go through each step. ### Retrieving Tokens and Authenticating Plaud Embedded's SDK and APIs have two different authentication methods to be aware of: 1. User Token - user-level tokens with your **user's unique ID** and minted with a **Partner Token** 2. Client ID & API Key - used for the Transcription API A Partner Token is an **application-level** token. A single Partner Token can mint multiple **User Tokens**. Use the [Authentication APIs](/plaud-embedded/auth-api-overview) to retrieve your Partner Tokens and mint User Tokens. ```http Exchanging client credentials for Partner Token expandable theme={"system"} POST https://platform-us.plaud.ai/developer/api/oauth/partner/access-token Authorization: Basic base64(client_id:secret_key) Content-Type: application/x-www-form-urlencoded //Returns { "access_token": "eyJhbGciOiJSUz...", "refresh_token": "eyJhbGci...", "token_type": "bearer", "expires_in": 3600 } ``` ```http Minting User Token expandable theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/users/access-token Authorization: Bearer Content-Type: application/json { "user_id": "", "expires_in": 86400 } //Returns { "access_token": "eyJhbGci...", "token_type": "bearer", "expires_in": 86400 } ``` ### Binding Plaud Devices to Your Mobile App **Binding Plaud Devices** is the process of linking your users' Plaud device to your mobile application. The reason we use the term **"bind"** is because devices can only be linked to one application at a time. When your application **binds to your users' Plaud devices**, your partner/user credentials are used for encrypting, decrypting, and syncing your users' data with your app. This is how your users' data is kept secure and only syncs data with your application (not just any Plaud Embedded application). ```swift Binding to Plaud devices using the Embedded SDK expandable theme={"system"} // Configure Embedded SDK private let customDomain = "platform-us.plaud.ai" func configure(userId: String) { RecordingStore.shared.userId = userId PlaudDeviceAgent.shared.initSDK( userAccessToken: userAccessToken, customDomain: customDomain ) } // Scan for Plaud devices func startScan() { cachedBleDevices.removeAll() scannedDevicesSubject.send([]) connectionStateSubject.send(.scanning) PlaudDeviceAgent.shared.startScan() } // Connect to Plaud devices via Bluetooth func connect(_ device: ScannedDevice, userId: String) { guard let bleDevice = cachedBleDevices[device.serialNumber] else { return } connectionStateSubject.send(.connecting(device)) PlaudDeviceAgent.shared.connectBleDevice(bleDevice: bleDevice, deviceToken: userId) } ``` ### Syncing Files from Plaud Device to Your Mobile App Plaud devices have their own storage and audio is stored on-device while recording. After a recording is finished, you must sync audio files from your users' Plaud device to their mobile app. This can be done via Bluetooth Low Energy (BLE) or WiFi Fast Transfer. WiFi Fast Transfer is **\~10x** faster. It works by opening a hotspot connection between your Plaud device and your users' phone. WiFi Fast Transfer requires the **HotSpot Entitlement** when configuring your iOS app ```swift Syncing files with BLE and WiFi Fast Transfer expandable theme={"system"} // BLE Sync PlaudDeviceAgent.shared.exportAudio( sessionId: file.sessionId, outputDir: outputDir, format: .wav, channels: 1, callback: callback ) // WiFi Fast Transfer PlaudDeviceAgent.shared.setDeviceWiFi(open: true) // In bleWiFiOpen callback: PlaudWiFiAgent.shared.bleDevice = BleAgent.shared.bleDevice PlaudWiFiAgent.shared.connectWifi(ssid, password, 60) // After wifiHandshake(status: 0): PlaudWiFiAgent.shared.exportAudioViaWiFi(...) ``` ### (Optional) Uploading Files Using the File Upload API The [Transcription API](/plaud-embedded/transcription-api-overview) accepts any publicly accessible audio URL. You can host your files on your own storage, or use Plaud's File Upload API. If you choose to use Plaud's managed S3 storage, the upload is done in 3 steps: 1. Generating presigned upload URLs to Plaud's S3 storage 2. Uploading your audio files to the presigned upload URLs in chunks 3. Sending the chunk information to complete the multipart upload ```http Generating presigned upload URLs expandable theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/files/upload/generate-presigned-urls Content-Type: application/json Authorization: Bearer { "filesize": 10485760, "filetype": "mp3" } // Returns { "FileId": "file_xxx", "UploadId": "upload_xxx", "ChunkSize": 5242880, "Parts": [ { "PartNumber": 1, "PresignedUrl": "https://plaud-bucket.s3.amazonaws.com/..." }, { "PartNumber": 2, "PresignedUrl": "https://plaud-bucket.s3.amazonaws.com/..." } ] } ``` ```http Uploading chunks to presigned storage URLs theme={"system"} PUT [PresignedUrl] [raw bytes of chunk] // Returns ETag: "abc123..." ``` ```http Completing upload with the part mappings expandable theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/files/upload/complete-upload Content-Type: application/json Authorization: Bearer { "file_id": "file_xxx", "upload_id": "upload_xxx", "part_list": [ { "PartNumber": 1, "ETag": "\"abc123...\"" }, { "PartNumber": 2, "ETag": "\"def456...\"" } ], "filetype": "mp3", "file_md5": "9e107d9d372bb6826bd81d3542a419d6" } // Returns { "FileId": "file_xxx", "FileType": "mp3", "DownloadUrl": "https://plaud-bucket.s3.amazonaws.com/...", "FileMd5": "9e107d9d372bb6826bd81d3542a419d6" } ``` ### Triggering and Pulling Transcriptions from the Transcriptions API The Transcription API allows you to transcribe any file uploaded to Plaud's storage in an asynchronous way. * **Trigger a transcription task** - behind the scenes, Plaud is running your uploaded audio file through noise reduction, speaker detection, language recognition, and other speech-to-text pipeline steps * **Poll transcription status** - use the Transcription API to check the status of a task, and on the `SUCCESS` status, the transcription data will be available. ```http Submit a transcription task expandable theme={"system"} POST https://platform-us.plaud.ai/developer/api/open/partner/ai/transcriptions/ Content-Type: application/json X-Client-Api-Key: [API_KEY] X-Client-Id: [CLIENT_ID] { "file_url": "", "params": { "transcribe": { "language": "auto", "model": "plaud-fast-whisper" }, "vad": { "decode_silence": false }, "diarization": { "enabled": false, "return_embedding": false } } } // Returns { "transcription_id": "task_exec_xxx", "status": "PENDING", "data": {} } ``` ```http Check and retrieve transcription expandable theme={"system"} GET https://platform-us.plaud.ai/developer/api/open/partner/ai/transcriptions/[TRANSCRIPTION_ID] X-Client-Api-Key: [API_KEY] X-Client-Id: [CLIENT_ID] // Returns { "transcription_id": "task_exec_xxx", "status": "SUCCESS", "data": { "text": "Meeting started at 10am...", "language": "en", "duration": 1843, "segments": [ { "start": 0, "end": 4.2, "text": "Meeting started at 10am.", "speaker": "Speaker 1" } ] } } ``` That's the general flow for Plaud Embedded apps! You're now ready to get started implementing the Embedded SDK. For new builders to build on top of our iOS template with the Embedded SDK pre-implemented (Also a great reference) For developers ready to jump in and implement the Embedded SDK in their existing mobile app # iOS SDK Source: https://docs.plaud.ai/plaud-embedded/ios-sdk Integrate your iOS app via the Embedded SDK for iOS. Start with the iOS SDK's high-level interfaces (**PlaudDeviceAgent and PlaudWiFiAgent**) 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 iOS SDK usage](/plaud-embedded/advanced-ios-sdk). ## Installation **Requirements: iOS 14.0+, Xcode 16.0+** The iOS SDK ships as pre-built frameworks. | Framework | Action | | ------------------------------- | --------------------- | | `PlaudBleSDK.framework` | Embed & Sign | | `PlaudWiFiSDK.framework` | Embed & Sign | | `PlaudDeviceBasicSDK.framework` | Embed & Sign | | `PlaudDeviceBasicSDK.bundle` | Copy Bundle Resources | ```bash theme={"system"} git clone https://github.com/Plaud-AI/plaud-sdk-public.git ``` Frameworks are located at `sdk/ios/` in the [Plaud SDK repo](https://github.com/Plaud-AI/plaud-sdk-public/tree/main/sdk/ios). ```bash theme={"system"} cp -R sdk/ios your/ios-app/library ``` SDK frameworks are compiled for `arm64` (physical devices only). Simulator is not supported. **Try the Plaud Embedded Skill** to have your coding agent help you with your iOS 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. *** ## Getting Started Import `PlaudDeviceAgent` and `PlaudWiFiAgent` through `PlaudDeviceBasicSDK`. Both facades are accessed through their shared singleton: ```swift Swift icon="swift" theme={"system"} import PlaudDeviceBasicSDK let deviceAgent = PlaudDeviceAgent.shared let wifiAgent = PlaudWiFiAgent.shared // Initialize once with your user token and regional domain deviceAgent.initSDK( userAccessToken: "user-token", customDomain: "platform-us.plaud.ai" // domain only, no https:// ) // Assign delegates to receive device and transfer events deviceAgent.delegate = self wifiAgent.delegate = self ``` Use the facade 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 a **User Token** and your **regional** domain. 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 the token exchange flow, see the [Authentication API reference](/plaud-embedded/auth-api-overview). ```swift Swift icon="swift" theme={"system"} import PlaudDeviceBasicSDK PlaudDeviceAgent.shared.initSDK( userAccessToken: "user-token", customDomain: "platform-us.plaud.ai" // domain only, no https:// ) ``` User Access Token (JWT), used for device authentication. The handshake token is automatically parsed from the JWT `sub` field. 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). #### Refreshing User Token If the User Token is refreshed (e.g., after re-login), you can update it: ```swift Swift icon="swift" theme={"system"} PlaudDeviceAgent.shared.setUserAccessToken(newToken) ``` Refreshed User Token (JWT) This automatically updates the handshake token and refreshes the RSA key pair. *** ### 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 a device requires an API call to Plaud's cloud services, so you can track device statuses remotely. And a local bind triggered by the Embedded SDK to verify and generate keys on Plaud device. For the full cloud-side reference, see the [Device Binding APIs](/plaud-embedded/device-binding-api-overview). Registers the device/owner association in the Plaud registry. Re-binding a device to the same owner is idempotent, so multiple calls will not have 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. ```swift Swift icon="swift" theme={"system"} // POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind let body: [String: String] = [ "type": snType, // e.g. "notepro" / "notepins" "sn": sn ] var request = URLRequest(url: URL(string: "https://platform-us.plaud.ai/developer/api/open/partner/sdk/bind")!) request.httpMethod = "POST" request.setValue("Bearer \(userAccessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) URLSession.shared.dataTask(with: request).resume() ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. A `403` response means the device is already bound to **another** account. Scans for the device, connects, and generates the key-pair on the device itself. The `.startScan()` will call the `bleScanResult` callback defined in the [**PlaudDeviceAgentProtocol**](#plauddeviceagentprotocol), and the bind result is delivered on `bleBind(sn:status:protVersion:timezone:)`. ```swift Swift icon="swift" theme={"system"} extension DeviceManager: PlaudDeviceAgentProtocol { func bleScanResult(bleDevices: [BleDevice]) { guard let match = bleDevices.first(where: { $0.serialNumber == lastSN }) else { return } PlaudDeviceAgent.shared.connectBleDevice(bleDevice: match, deviceToken: userId) } func bleConnectState(state: Int) { switch state { case 1: // connected case 0: // disconnected case 2, -1, -2: // connection failed default: break } } func bleBind(sn: String?, status: Int, protVersion: Int, timezone: Int) { /* status == 0 → bound */ } func blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int, findMyToken: Int, hasSndpKey: Int, deviceAccessToken: Int) { /* handshake complete */ } // ... recording / file-list / battery / storage callbacks } PlaudDeviceAgent.shared.delegate = self PlaudDeviceAgent.shared.startScan() ``` A scanned/connected device If needed, a unique identifier for that device. There is also a `connectBleDevice(bleDevice:)` overload that omits it. #### PlaudDeviceAgentProtocol Callbacks The [**PlaudDeviceAgentProtocol**](#plauddeviceagentprotocol) is a delegate protocol with a few key callbacks for the **PlaudDeviceAgent**. See the [Protocols](#protocols) section for the full list of callbacks. | Callback | Description | | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `bleScanResult(bleDevices: [BleDevice])` | Scan results updated | | `bleConnectState(state: Int)` | `1` = connected, `0` = disconnected, `2` / `-1` / `-2` = connection failed | | `bleBind(sn:status:protVersion:timezone:)` | Device bound successfully (`status == 0`) | | `blePenState(state:privacy:keyState:uDisk:findMyToken:hasSndpKey:deviceAccessToken:)` | Secure handshake complete | | `bleConnectStage(sn: String?, stage: String, detail: String?)` | Observability to the device handshake process | 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.** *** ### 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. Removes the device/owner association in the Plaud registry (visible on the [Plaud developer portal](https://portal.plaud.ai/)). Unbinding an already-unbound device is idempotent, so multiple calls have no side effects. 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. ```swift Swift icon="swift" theme={"system"} // POST https://platform-us.plaud.ai/developer/api/open/partner/sdk/unbind let body: [String: String] = [ "type": snType, // e.g. "notepro" / "notepins" "sn": sn ] var request = URLRequest(url: URL(string: "https://platform-us.plaud.ai/developer/api/open/partner/sdk/unbind")!) request.httpMethod = "POST" request.setValue("Bearer \(userAccessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) URLSession.shared.dataTask(with: request).resume() ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. Clears the pairing/handshake on the device itself. Requires the device to be connected. On success the SDK disconnects and clears the session. The result is delivered on the `bleDepair(_ status: Int)` delegate callback. ```swift Swift icon="swift" theme={"system"} PlaudDeviceAgent.shared.delegate = self extension DeviceManager: PlaudDeviceAgentProtocol { func bleDepair(_ status: Int) { if status == 0 { // device unpaired and disconnected } } // ... } PlaudDeviceAgent.shared.depair(clear: true) ``` **Pass `true`** to clear all connections. The argument defaults to `false`, so pass it explicitly for the unbind flow. #### Device Recovery In certain situations, the cloud and local bind state can go **out-of-sync**, causing the device to lock. 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. 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 cannot override an active binding. 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. ```swift Swift icon="swift" expandable theme={"system"} let url = URL(string: "https://platform-us.plaud.ai/developer/api/open/partner/sdk/binding?type=\(type)&sn=\(sn)")! var request = URLRequest(url: url) request.setValue("Bearer \(userAccessToken)", forHTTPHeaderField: "Authorization") session.dataTask(with: request) { data, response, error in if error != nil { completion(nil) return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard status == 200, let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { completion(nil) // an unknown device returns a bare 404 with no error body return } let isBind = json["is_bind"] as? Bool // JSON null → nil (three-state) let history = (json["bind_history"] as? [String]) ?? [] completion((isBind: isBind, bindHistory: history)) }.resume() ``` Device type string derived from the SN prefix (`881` → `notepro`, `882` → `notepins`). Device serial number. **Response:** `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`. Previously bound client IDs, newest first. `bind_history` records one entry **per bind event** For each previous client ID, `recoveryConnectBleDevice` uses that ID as the handshake token and connects with force-clear. When a client ID matches the one on the device, the handshake is accepted, the stale lock is cleared, and a rescan is initiated. ```swift Swift icon="swift" expandable theme={"system"} var seen = Set() let history = Array(bindHistory.filter { seen.insert($0).inserted }.prefix(5)) suppressAutoReconnect = true recoveryInProgress = true defer { recoveryInProgress = false; recoveryAttemptResult = nil } for historicalId in history { let semaphore = DispatchSemaphore(value: 0) var unlocked = false recoveryAttemptResult = { ok in unlocked = ok; semaphore.signal() } DispatchQueue.main.async { PlaudDeviceAgent.shared.recoveryConnectBleDevice(bleDevice: bleDevice, historicalUserId: historicalId) } _ = semaphore.wait(timeout: .now() + 25) // handshake timeout recoveryAttemptResult = nil if !unlocked { // This ID did not match the firmware lock — try the next one. DispatchQueue.main.async { PlaudDeviceAgent.shared.disconnect() } Thread.sleep(forTimeInterval: 1.0) continue } // Matched — wipe the stale bond and wait for the device's confirmation. let depairSemaphore = DispatchSemaphore(value: 0) recoveryDepairDone = { depairSemaphore.signal() } DispatchQueue.main.async { PlaudDeviceAgent.shared.depair(clear: false) } _ = depairSemaphore.wait(timeout: .now() + 5) recoveryDepairDone = nil recoveryInProgress = false DispatchQueue.main.async { PlaudDeviceAgent.shared.disconnect() } Thread.sleep(forTimeInterval: 1.5) // depair changes the MAC, so rescan and match by SN before reconnecting. let sn = device.serialNumber let rescanSemaphore = DispatchSemaphore(value: 0) var freshDevice: BleDevice? recoveryRescanSN = sn recoveryRescanHook = { dev in freshDevice = dev; rescanSemaphore.signal() } DispatchQueue.main.async { PlaudDeviceAgent.shared.startScan() } let rescanned = rescanSemaphore.wait(timeout: .now() + 20) recoveryRescanSN = nil recoveryRescanHook = nil DispatchQueue.main.async { PlaudDeviceAgent.shared.stopScan() } guard rescanned == .success, let fresh = freshDevice else { failRecovery("The device was unlocked but did not reappear — please rescan and connect it.") return } // Reconnect as the current user — this rebinds the device. DispatchQueue.main.async { [weak self] in guard let self else { return } self.suppressAutoReconnect = false let scanned = ScannedDevice(name: fresh.name, serialNumber: sn, rssi: fresh.rssi) self.connect(scanned, userId: currentUserId) } return } ``` The scanned device, from `bleScanResult`. One `bind_history` entry to try as the handshake token. *** ### File Synchronization The `exportAudio` method exports audio files from Plaud device to your users' phone, reporting progress through the [**AudioExportCallback**](#audioexportcallback). The `.getFileList` accesses files on your users' Plaud device, and the `.deleteFile` method will delete an audio file off of your users' Plaud device. Plaud devices will record **up to 5 hours**. Recordings longer should be broken up. Request the file list, then export each session from the `bleFileList` callback. `exportAudio` reports back on an [**AudioExportCallback**](#audioexportcallback) you supply — the delegate below and the export callback are separate objects. ```swift Swift icon="swift" theme={"system"} PlaudDeviceAgent.shared.getFileList(startSessionId: 0) extension SyncManager: PlaudDeviceAgentProtocol { func bleFileList(bleFiles: [BleFile]) { guard let next = bleFiles.first else { return } exportHandler = ExportHandler() // retain it — see the note below PlaudDeviceAgent.shared.exportAudio( sessionId: next.sessionId, outputDir: outputDir, format: .mp3, channels: 1, callback: exportHandler! ) } } private final class ExportHandler: NSObject, AudioExportCallback { func onProgress(_ progress: Int, message: String) { } func onComplete(outputPath: String) { // decoded file is ready at outputPath } func onError(_ error: String) { } } ``` `AudioExportCallback` is an `@objc` protocol, so your conformer must be an `NSObject` subclass. Session ID Output Directory `.pcm` (0) | `.mp3` (1) | `.wav` (2) | `.opus` (3). We recommend `.mp3` — it plays everywhere and is accepted directly by the transcription upload API. Number of audio channels in the exported file (`1` = mono). See [**AudioExportCallback**](#audioexportcallback) for details. func onProgress(\_ progress: Int, message: String) func onComplete(outputPath: String) func onError(\_ error: String) 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** transfers so the app can sync files while the screen is off or another app is in the foreground. * See our [background sync reference](/plaud-embedded/background-syncs) * Supporting **resumable** transfer so the app can resume transfers after closing. * Using WiFi Fast Transfer (see below) #### Delete File from Device Once an audio file has been synced, you can delete the file from your users' Plaud device with the `.deleteFile` method. ```swift Swift icon="swift" theme={"system"} PlaudDeviceAgent.shared.deleteFile(sessionId: sessionId) ``` Session ID *** ### WiFi Fast Transfer An alternative to a BLE (Bluetooth Low Energy) file transfer, WiFi Fast Transfer is \~10x faster than BLE transfers. Requires the `Hotspot Configuration` entitlement in your iOS app settings. `PlaudWiFiAgent` is a high-level facade that manages the WiFi Fast Transfer lifecycle for most cases. ```swift theme={"system"} PlaudDeviceAgent.shared.setDeviceWiFi(open: true) extension DeviceManager: PlaudDeviceAgentProtocol { func bleWiFiOpen(_ status: Int, _ wifiName: String, _ wholeName: String, _ wifiPass: String) { guard status == 0 else { return } PlaudWiFiAgent.shared.bleDevice = BleAgent.shared.bleDevice PlaudWiFiAgent.shared.delegate = self // 2. Give the device ~3s to bring its hotspot fully up before joining // join to the SDK with timeout and retry DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { PlaudWiFiAgent.shared.connectWifi(wholeName, wifiPass, 180) } } } extension DeviceManager: PlaudWiFiAgentProtocol { func wifiHandshake(_ status: Int) { guard status == 0 else { return } PlaudWiFiAgent.shared.getFileList(Int(Date().timeIntervalSince1970), 0, false) } func wifiFileList(_ files: [BleFile]) { guard let next = files.first else { return } wifiExportHandler = WiFiExportHandler() // retain it — the SDK does not PlaudWiFiAgent.shared.exportAudioViaWiFi( sessionId: next.sessionId, outputDir: outputDir, format: .mp3, channels: 1, callback: wifiExportHandler! ) } } ``` The `wholeName` value from `bleWiFiOpen`. The `wifiPass` value from `bleWiFiOpen`. Join timeout in seconds. The SDK re-applies the hotspot configuration and retries internally until handshake or timeout. #### Ending Wifi Fast Transfer Close the session on every exit path — success, failure, and user cancel — or the device stays in WiFi mode (and keeps draining battery) until its own \~2 minute firmware timeout. ```swift theme={"system"} PlaudDeviceAgent.shared.setDeviceWiFi(open: false) PlaudDeviceAgent.shared.endWiFiTransfer() ``` `endWiFiTransfer()` only reaches the device while BLE is up. Use `PlaudDeviceAgent.shared.isWiFiTransferActive` to check whether a session is currently open. For lower-level control, you can use `PlaudWiFiAgent.shared.syncFile`. ```swift theme={"system"} PlaudWiFiAgent.shared.syncFile( file.sessionId, // sessionId 0, // start offset 0, // end (0 = whole file) file.scenes // scene — match the file's own value, not the default 1 ) extension SyncManager: PlaudWiFiAgentProtocol { func wifiSyncFile(_ sessionId: Int, _ status: Int) { // status == 0 → accepted; non-zero → rejected (e.g. scene mismatch) } func wifiSyncFileData(_ sessionId: Int, _ offset: Int, _ count: Int, _ binData: Data) { // Append binData at offset to your file handle } func wifiDataComplete() { // All bytes received — close the file } func wifiSyncFileStop(_ status: Int) { // Transfer stopped/aborted (also triggered by stopSyncFile(_:_ scene:)) } } ``` Session ID of the recording Start offset in bytes. Use `0` to transfer from the beginning, or a `BleFile.offset` to resume. End offset in bytes. `0` transfers the whole file. The file's scene, from `BleFile.scenes`. Must match the file's own value or the transfer is rejected; defaults to `1`. If you encounter the error message: `wifiCommonErr(cmd: 16, status: 0)`, this is an expected behavior and indicates a successful sync. #### Protocol Callbacks The WiFi Fast Transfer has two key callbacks, one on the [**PlaudDeviceAgentProtocol**](#plauddeviceagentprotocol) and another on the [**AudioExportCallback**](#audioexportcallback). | Protocol | Callback | Description | | ------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------- | | PlaudDeviceAgent Protocol | `bleWiFiOpen(_ status: Int, _ wifiName: String, _ wholeName: String, _ wifiPass: String)` | Called when Plaud device opens hotspot | | AudioExportCallback | `onComplete` | Called when a file has downloaded | 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** transfers so the app can sync files while the screen is off or another app is in the foreground. * See our [background sync reference](/plaud-embedded/background-syncs) * Supporting **resumable** transfer so the app can resume transfers after closing. *** ### Firmware Update (OTA) The Embedded SDK handles the entire OTA flow: version query → download → MD5 verify → CRC → BLE packet push → device restart → reconnect. 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! #### Check for Firmware Updates ```swift Swift icon="swift" theme={"system"} // Check for update PlaudDeviceAgent.shared.checkFirmwareUpdate { result in guard result.hasUpdate else { return } print("New version: \(result.latestVersion), release notes: \(result.releaseNotes)") } ``` Callback function with type PlaudFirmwareCheckResult Whether a firmware update is available The device's current firmware version The latest available firmware version Numeric version code Release notes for the update URL to download the firmware MD5 checksum for verification Whether the update is mandatory #### Run the Firmware Update `startFirmwareUpdate` performs the whole flow in one call — download, install, and device restart — reporting each stage through the `progress` closure. ```swift Swift icon="swift" theme={"system"} // One-call firmware update PlaudDeviceAgent.shared.startFirmwareUpdate( progress: { phase, percentage in // phase: .downloading / .installing / .restarting / .complete // percentage: 0.0 ~ 1.0 }, completion: { result in if result.success { print("Updated to \(result.version)") } else { print("Failed: \(result.errorMessage ?? "")") } } ) ``` Callback that reports firmware update progress. | Case | Description | | ------------- | ----------------------------------------- | | `downloading` | Firmware binary is being downloaded | | `installing` | Firmware is being installed on the device | | `restarting` | Device is restarting after installation | | `complete` | Update finished successfully | * `phase`: Current phase of the update (`PlaudFirmwarePhase`) * `percentage`: Progress from 0.0 to 1.0 Callback when the update completes. Whether the update succeeded The firmware version after update Error description if update failed If you already have the firmware file downloaded, use `pushFirmwareFile()` instead: #### Push Firmware Update ```swift Swift icon="swift" theme={"system"} PlaudDeviceAgent.shared.pushFirmwareFile( filePath: localPath, toVersion: "V1.2.8", progress: { phase, pct in }, completion: { result in } ) ``` Full path to the locally downloaded firmware file Target firmware version to update to (e.g., "V1.2.8") Callback that reports firmware update progress. * `phase`: Current phase (`downloading` / `installing` / `restarting` / `complete`) * `percentage`: Progress from 0.0 to 1.0 Callback when the update completes with `success`, `version`, and `errorMessage` fields. *** ## Protocols The Embedded SDK is delegate-driven. You implement protocols and assign yourself as the delegate to receive device events, transfer progress, and results. These three protocols should cover most use cases. Most callbacks are delivered on the SDK's internal dispatch queues — **not** the main thread. Marshal to the main queue before touching UIKit or published state. ### PlaudDeviceAgentProtocol The primary delegate for `PlaudDeviceAgent`. Assign it once and it drives the entire BLE lifecycle — scan, connect, bind, device state, recording, and file sync. ```swift Swift icon="swift" theme={"system"} PlaudDeviceAgent.shared.delegate = self extension DeviceManager: PlaudDeviceAgentProtocol { // REQUIRED — overall device state after handshake func blePenState(state: Int, privacy: Int, keyState: Int, uDisk: Int, findMyToken: Int, hasSndpKey: Int, deviceAccessToken: Int) { // ... } } ``` `blePenState` is the **only required** member. Every other callback is `@objc optional` — implement only the ones you need. The most commonly used callbacks, grouped by concern: | Group | Callback | Description | | ------------ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Connection | `blePenState(...)` | **Required.** Device state reported after the handshake completes | | Connection | `bleConnectState(state: Int)` | `1` = connected, `0` = disconnected, `2` / `-1` / `-2` = connection failed | | Connection | `bleConnectStage(sn: String?, stage: String, detail: String?)` | The stages include `"start"`, `"gatt_connect"`, `"set_notify"`, `"set_battery_notify"`, `"read_battery"`, `"set_data_notify"`, `"pre_handshake"`, `"send_rsa_public"`, `"first_handshake"`, `"two_handshake"`, `"handshake_get_ssn"`, `"change_handshake_timeout"`, `"sync_time"` | | Connection | `bleScanResult(bleDevices: [BleDevice])` | Scan results updated | | Connection | `bleScanOverTime()` | Scan timed out with no results | | Connection | `bleBind(sn:status:protVersion:timezone:)` | Binding status updated | | Device state | `bleStorage(total: Int, free: Int, duration: Int)` | Storage usage on the device | | Device state | `bleChargingState(isCharging: Bool, level: Int)` | Charging / battery level changed | | Recording | `bleRecordStart(sessionId:start:status:scene:startTime:reason:)` | Recording started | | Recording | `bleRecordStop(sessionId:reason:fileExist:fileSize:)` | Recording stopped | | Recording | `blePcmData(sessionId:millsec:pcmData:isMusic:)` | Live PCM chunks for waveform / metering | | File sync | `bleFileList(bleFiles: [BleFile])` | Result of `getFileList(...)` | | File sync | `bleData(sessionId: Int, start: Int, data: Data)` | A chunk of file data during sync | | File sync | `bleDataComplete()` | File transfer finished | | File sync | `bleDeleteFile(sessionId: Int, status: Int)` | Result of `deleteFile(...)` | | WiFi | `bleWiFiOpen(_ status: Int, _ wifiName: String, _ wholeName: String, _ wifiPass: String)` | Device opened its hotspot — hand off to `PlaudWiFiAgent` | | OTA | `bleFotaResult(uid: Int, status: Int, errmsg: String?)` | Firmware push result | The SDK also exposes a lower-level `BleAgentProtocol` on `BleAgent` with 97 members, **96 of them required**. Prefer `PlaudDeviceAgentProtocol` — the facade handles the handshake, decryption, and format conversion for you, and lets you implement only the callbacks you care about. ### AudioExportCallback Reports progress, completion, and errors for `.exportAudio` (BLE) and `.exportAudioViaWiFi` (WiFi). ```swift Swift icon="swift" theme={"system"} extension SyncManager: AudioExportCallback { func onProgress(_ progress: Int, message: String) { // progress: 0–100 } func onComplete(outputPath: String) { // decoded file is ready at outputPath } func onError(_ error: String) { // ... } } ``` Export progress, `0`–`100`, with a human-readable status message. Called when decoding finishes; `outputPath` is the full path to the written file. Called if export fails, with a description of the error. ### PlaudWiFiAgentProtocol The delegate for `PlaudWiFiAgent`, used during WiFi Fast Transfer. Assign it before calling `connectWifi(...)`. The handshake must complete (`wifiHandshake` with status `0`) before listing or transferring files. ```swift Swift icon="swift" theme={"system"} PlaudWiFiAgent.shared.delegate = self extension DeviceManager: PlaudWiFiAgentProtocol { func wifiHandshake(_ status: Int) { guard status == 0 else { return } // 0 = handshake done, ready to transfer // begin transfer } } ``` All members are `@objc optional`. | Callback | Description | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `wifiHandshake(_ status: Int)` | `0` = handshake complete, ready to list/transfer | | `wifiConnectionStatus(_ ssid: String, _ connected: Bool)` | WiFi connection state changed | | `wifiFileList(_ files: [BleFile])` | Result of `getFileList(_ uid:_ sessionId:_ single:)` over WiFi — all three arguments are unlabeled; pass a unique `uid` (e.g. a timestamp), `0` for `sessionId` to list everything, and `false` for `single` | | `wifiSyncFileData(_ sessionId:_ offset:_ count:_ binData:)` | A chunk of file data | | `wifiDataComplete()` | Single-file transfer finished | | `wifiDownloadAllProgress(_ totalFiles:_ currentFileIndex:_ currentFile:_ totalProgress:)` | Progress while downloading all files | | `wifiDownloadAllCompleted(_ completedFiles: Int, _ failedFiles: Int)` | Batch download finished | | `wifiCommonErr(_ cmd: Int, _ status: Int)` | A command failed | | `wifiClose(_ status: Int)` | WiFi transfer session closed | WiFi Fast Transfer requires the `Hotspot Configuration` entitlement in your app. See [WiFi Fast Transfer](#wifi-fast-transfer) for the full connect-and-transfer flow. # iOS Starter App Source: https://docs.plaud.ai/plaud-embedded/ios-starter-app Build a branded iOS app that connects to Plaud devices in minutes using the iOS starter app. This guide walks through the process of building an iOS [Starter App](/plaud-embedded/starter-app-specs) that: 1. Connects to Plaud devices 2. Syncs recordings from Plaud device to mobile phone 3. Uploads recordings from your users' mobile phone to Plaud's file storage 4. Transcribes recordings with the [Transcription API](/plaud-embedded/transcription-api-overview) starter-app ## Video Tutorial