package com.plugin.blec
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicInteger as AtomicInt
import java.util.ArrayDeque
import java.util.Base64
import java.util.UUID
class Peripheral(
private val context: Context,
private val device: BluetoothDevice,
private val plugin: BlecPlugin
) {
// Retry state for connect/discover
private var connectAttempts = 0
private val maxConnectAttempts = 4
private val connectRetryDelayMs = 350L
private var discoverAttempts = 0
private val maxDiscoverAttempts = 3
private val discoverRetryDelayMs = 200L
private val CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private val base64Encoder: Base64.Encoder = Base64.getEncoder()
private var connected = false
private var bonded = false
private var gatt: BluetoothGatt? = null
// The BluetoothGatt returned by connectGatt before the connection is
// established. Kept so a cancelled/failed connect can still close it
// instead of leaking an open client interface.
private var pendingGatt: BluetoothGatt? = null
private var services: List<BluetoothGattService> = listOf()
private val characteristics: MutableMap<Pair<UUID, UUID>, BluetoothGattCharacteristic> = mutableMapOf()
private var onConnectionStateChange: ((connected: Boolean, error: String) -> Unit)? = null
private var onServicesDiscovered: ((connected: Boolean, error: String) -> Unit)? = null
private var notifyChannel: Channel? = null
private val onReadInvoke: MutableMap<Pair<UUID, UUID>, ReadOp> = mutableMapOf()
private val writeQueueLock = Any()
private val writeQueue: ArrayDeque<PendingWrite> = ArrayDeque()
private var activeWrite: PendingWrite? = null
private var onDescriptorInvoke: DescriptorOp? = null
private var onMtuInvoke: Invoke? = null
private var currentMtu = 517;
private val retryHandler = Handler(Looper.getMainLooper())
// maxAttempts for writes; only counts actual failure, waiting on the BT-chip (WRITE_REQUEST_BUSY) does not count as an attempt
private val maxAttempts = 100
private val writeRetryDelayMs = 50L
/**
* Status codes of a finished read/write that are worth retrying: generic
* stack failures that commonly succeed on the next attempt. Everything else
* (e.g. an ATT error the peripheral answered with, like "read not
* permitted" or "unlikely error") is a real result and is reported to the
* caller right away instead of being retried up to [maxAttempts] times.
*/
private fun isTransientStatus(status: Int): Boolean {
return when (status) {
133, // GATT_ERROR: generic Android stack failure
62, // GATT_CONN_CANCEL
129, // GATT_INTERNAL_ERROR
BluetoothGatt.GATT_CONNECTION_CONGESTED,
BluetoothGatt.GATT_FAILURE -> true
else -> false
}
}
// Android keeps the ACL open for 1 s after the last GATT client closed
// (l2cap link idle timeout) before it terminates the link; a connect within
// that second reuses the link without the peripheral noticing anything.
private val linkIdleTimeoutMs = 1500L
private val writeCallbackWaitNoResponseMs = 1500L
private val writeCallbackWaitWithResponseMs = 3000L
// If the stack never reports the disconnect, give up waiting and close the
// gatt ourselves so the caller isn't stuck with a link it cannot release.
private val disconnectFallbackMs = 8000L
private val writeCount: AtomicInt = AtomicInt(0)
private fun runOnMain(block: () -> Unit) {
retryHandler.postDelayed(block, 1L)
}
private data class PendingWrite(
val id: Int,
val key: Pair<UUID, UUID>,
val characteristic: BluetoothGattCharacteristic,
var invoke: Invoke?,
val data: ByteArray,
val withResponse: Boolean,
var attempt: Int = 0,
val timeoutAfter: Long = 0L,
var timeSentAt: Long = 0L,
)
private data class ReadOp(
val invoke: Invoke,
var attempt: Int = 1,
)
private data class DescriptorOp(
val invoke: Invoke,
val data: ByteArray,
var attempt: Int = 1,
)
private enum class Event {
DeviceConnected,
DeviceDisconnected
}
private fun sendEvent(event: Event) {
val channel = this.plugin.eventChannel ?: return
val data = JSONObject()
if (event == Event.DeviceConnected) {
data.put("DeviceConnected", this.device.address)
} else if (event == Event.DeviceDisconnected) {
data.put("DeviceDisconnected", this.device.address)
}
Log.v("Peripheral", "sending event $data")
channel.send(data)
if (event == Event.DeviceDisconnected) {
reportLinkStillHeld(channel)
}
}
/**
* Our BluetoothGatt is disconnected, but the phone may still hold the radio
* link to the device for another GATT client (another app, or a leaked
* BluetoothGatt). The peripheral then never sees a disconnect and a later
* connect silently reuses the old link. Make that visible.
*
* The stack keeps the ACL for [linkIdleTimeoutMs] after the last client
* closes before it terminates the link, so the check runs after that grace
* period. It is skipped when we connected again ourselves in the meantime
* (then the reuse of the link is intended).
*/
@SuppressLint("MissingPermission")
private fun reportLinkStillHeld(channel: Channel) {
retryHandler.postDelayed({
if (this.gatt != null || this.pendingGatt != null) {
return@postDelayed
}
val stillConnected = try {
val manager = context.getSystemService(BluetoothManager::class.java)
manager?.getConnectionState(this.device, BluetoothProfile.GATT) == BluetoothProfile.STATE_CONNECTED
} catch (e: Exception) {
Log.w("Peripheral", "Could not query the connection state after disconnect: ${e.message}")
false
}
if (!stillConnected) {
return@postDelayed
}
Log.w(
"Peripheral",
"Disconnected from ${this.device.address} ${linkIdleTimeoutMs}ms ago, but the phone still has a GATT link to it: " +
"another BluetoothGatt client holds the connection, the device will not see a disconnect"
)
val data = JSONObject()
data.put("LinkStillConnected", this.device.address)
channel.send(data)
}, linkIdleTimeoutMs)
}
private val callback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothGatt.STATE_CONNECTED && gatt != null) {
this@Peripheral.connected = true
this@Peripheral.gatt = gatt
this@Peripheral.pendingGatt = null
this@Peripheral.onConnectionStateChange?.invoke(true, "")
this@Peripheral.sendEvent(Event.DeviceConnected)
} else {
// Either a connection failure (status != GATT_SUCCESS) or a
// disconnection. In both cases the BluetoothGatt instance must
// be closed to release the underlying client interface,
// otherwise repeated connect/disconnect cycles run into the
// 30 client limit and start failing with status 133.
this@Peripheral.connected = false
val existingGatt = this@Peripheral.gatt ?: this@Peripheral.pendingGatt ?: gatt
this@Peripheral.gatt = null
this@Peripheral.pendingGatt = null
try {
existingGatt?.close()
} catch (e: Exception) {
Log.w("Peripheral", "Failed to close gatt: ${e.message}")
}
val error = if (status != BluetoothGatt.GATT_SUCCESS) {
"Connection failed. Status: $status (${statusCodeName(status)}), State: $newState"
} else {
"Disconnected. State: $newState"
}
this@Peripheral.onConnectionStateChange?.invoke(false, error)
// Nothing queued can complete without a connection; resolving
// the invokes here keeps the Rust side from waiting for calls
// that will never come back.
this@Peripheral.failPendingOperations(error)
this@Peripheral.sendEvent(Event.DeviceDisconnected)
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
Log.d("Peripheral", "onServicesDiscovered status $status, services ${gatt.services}")
if (status != BluetoothGatt.GATT_SUCCESS) {
// Android BLE edge-case handling: status 133, 62, 129 are
// commonly transient and can succeed on a second attempt.
val isEdgeCase = (status == 133 || status == 62 || status == 129)
if (isEdgeCase && discoverAttempts < maxDiscoverAttempts) {
discoverAttempts += 1
Log.w(
"Peripheral",
"Service discovery failed (status $status ${statusCodeName(status)}), retrying attempt $discoverAttempts/$maxDiscoverAttempts"
)
retryHandler.postDelayed({
if (this@Peripheral.connected) {
gatt.discoverServices()
} else {
this@Peripheral.onServicesDiscovered?.invoke(
false,
"Service discovery aborted: disconnected during retry"
)
discoverAttempts = 0
}
}, discoverRetryDelayMs)
return
}
discoverAttempts = 0
this@Peripheral.services = listOf()
this@Peripheral.onServicesDiscovered?.invoke(
false,
"No services discovered. Status $status (${statusCodeName(status)}) after ${discoverAttempts + 1} attempt(s)"
)
} else {
discoverAttempts = 0
this@Peripheral.services = gatt.services
for (s in gatt.services) {
for (c in s.characteristics) {
this@Peripheral.characteristics[Pair(c.uuid, c.service.uuid)] = c
}
}
this@Peripheral.onServicesDiscovered?.invoke(true, "")
}
}
// Android 13 and upper
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
handleCharacteristicChanged(characteristic, value)
}
// Android 12 and below
@Suppress("OVERRIDE_DEPRECATION")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
@Suppress("DEPRECATION")
val value = characteristic.value
if (value != null) {
handleCharacteristicChanged(characteristic, value)
} else {
Log.e("Peripheral", "Value received onCharacteristicChanged is null")
}
}
// Extract the common logic into a helper function
private fun handleCharacteristicChanged(
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
this@Peripheral.notifyChannel?.let {
synchronized(it) {
val notification = JSONObject();
notification.put("uuid", characteristic.uuid)
notification.put("serviceUuid", characteristic.service.uuid)
notification.put("data", base64Encoder.encodeToString(value))
it.send(notification)
}
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onCharacteristicWrite(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
val nonNullGatt = gatt ?: return
val nonNullCharacteristic = characteristic ?: return
runOnMain {
processOnCharWrite(nonNullGatt, nonNullCharacteristic, status)
}
}
@Suppress("OVERRIDE_DEPRECATION")
fun processOnCharWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
Log.v("Peripheral", "onCharacteristicWrite for ${characteristic.uuid} with status $status")
val key = Pair(characteristic.uuid, characteristic.service.uuid)
val current = this@Peripheral.activeWrite
if (current == null || current.key != key) {
Log.w("Peripheral", "Received stale write callback for $key, ignoring")
return
}
// The status is the only reliable success signal: since Android 13
// `writeCharacteristic(charac, value, type)` no longer updates
// `characteristic.value`, so comparing it against the written bytes
// can fail.
val success = status == BluetoothGatt.GATT_SUCCESS
if (success) {
Log.v("Peripheral", "Write with id $current.id succeeded!")
this@Peripheral.activeWrite = null
current.invoke?.resolve()
} else {
current.timeSentAt = 0L
Log.v("Peripheral", "Write with id $current.id attempt $current.attempt failed!")
if (this@Peripheral.isTransientStatus(status) && current.attempt < this@Peripheral.maxAttempts && !this@Peripheral.isWriteTimedOut(current)) {
current.attempt += 1
Log.w(
"Peripheral",
"Write on ${characteristic.uuid} failed (status $status, attempt ${current.attempt}/${this@Peripheral.maxAttempts})"
)
} else {
this@Peripheral.activeWrite = null
current.invoke?.reject(
"Write to characteristic ${characteristic.uuid} failed after ${current.attempt} attempts with status $status (${
statusCodeName(
status
)
})"
)
}
}
this@Peripheral.processWriteQueue()
}
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int
) {
val key = Pair(characteristic.uuid, characteristic.service.uuid)
val op = synchronized(this@Peripheral.onReadInvoke) {
this@Peripheral.onReadInvoke.remove(key)
}
if (op == null) {
Log.e("Peripheral", "Did not find the pending invoke for read on $key")
return
}
if (status == BluetoothGatt.GATT_SUCCESS) {
val res = JSONObject()
res.put("value", base64Encoder.encodeToString(value))
op.invoke.resolve(res)
return
}
if (this@Peripheral.isTransientStatus(status) && op.attempt < this@Peripheral.maxAttempts) {
val nextAttempt = op.attempt + 1
Log.w(
"Peripheral",
"Read on ${characteristic.uuid} failed (status $status, attempt ${op.attempt}/${this@Peripheral.maxAttempts}), retrying"
)
this@Peripheral.retryHandler.postDelayed({
this@Peripheral.startRead(key, characteristic, op.copy(attempt = nextAttempt))
}, this@Peripheral.writeRetryDelayMs)
} else {
op.invoke.reject(
"Read from characteristic ${characteristic.uuid} failed after ${op.attempt} attempts with status $status (${
statusCodeName(
status
)
})"
)
}
}
override fun onDescriptorWrite(
gatt: BluetoothGatt?,
descriptor: BluetoothGattDescriptor?,
status: Int
) {
val op = this@Peripheral.onDescriptorInvoke
this@Peripheral.onDescriptorInvoke = null
if (op == null) {
Log.e("Peripheral", "Did not find the pending invoke for descriptor write")
return
}
if (status == BluetoothGatt.GATT_SUCCESS) {
if (descriptor?.uuid != CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR) {
op.invoke.reject("unexpected write to descriptor: ${descriptor?.uuid}")
} else {
op.invoke.resolve()
}
return
}
val desc = descriptor
if (this@Peripheral.isTransientStatus(status) && op.attempt < this@Peripheral.maxAttempts && desc != null) {
val nextAttempt = op.attempt + 1
Log.w(
"Peripheral",
"Descriptor write failed (status $status, attempt ${op.attempt}/${this@Peripheral.maxAttempts}), retrying"
)
this@Peripheral.retryHandler.postDelayed({
this@Peripheral.startDescriptorWrite(desc, op.copy(attempt = nextAttempt))
}, this@Peripheral.writeRetryDelayMs)
} else {
op.invoke.reject(
"descriptor write failed after ${op.attempt} attempts with status $status (${
statusCodeName(
status
)
})"
)
}
}
override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
Log.d("Peripheral", "MTU changed to $mtu with status $status")
currentMtu = mtu
val invoke = this@Peripheral.onMtuInvoke
this@Peripheral.onMtuInvoke = null
if (status != BluetoothGatt.GATT_SUCCESS) {
invoke?.reject("mtu change failed: $status")
} else {
val res = JSONObject()
res.put("mtu", mtu)
invoke?.resolve(res)
}
}
}
@SuppressLint("MissingPermission")
fun connect(invoke: Invoke) {
Log.d("Peripheral", "connect ${this.device.address}")
if (this.connected && this.gatt != null) {
// Reconnecting would replace the open BluetoothGatt and leak the
// old one. Report the existing connection instead.
Log.d("Peripheral", "already connected, reusing the existing gatt")
invoke.resolve()
sendEvent(Event.DeviceConnected)
return
}
connectAttempts = 0
connectInternal(invoke)
}
@SuppressLint("MissingPermission")
private fun connectInternal(invoke: Invoke) {
this.onConnectionStateChange = { success, error ->
if (success) {
this@Peripheral.onConnectionStateChange = null
invoke.resolve()
} else {
// Android BLE edge-case handling: status 133/62/129 are
// commonly transient. Retry a few times before giving up.
val isEdgeCase = error.contains("Status: 133") ||
error.contains("Status: 62") ||
error.contains("Status: 129")
if (isEdgeCase && connectAttempts < maxConnectAttempts) {
connectAttempts += 1
Log.w(
"Peripheral",
"Connect failed ($error), retrying attempt $connectAttempts/$maxConnectAttempts"
)
retryHandler.postDelayed({
connectInternal(invoke)
}, connectRetryDelayMs)
} else {
this@Peripheral.onConnectionStateChange = null
invoke.reject(error)
}
}
}
// Explicitly request the LE transport. Without this, dual-mode
// peripherals can be connected over BR/EDR which then fails the GATT
// operations (often surfacing as status 133).
runOnMain {
try {
this.pendingGatt = this.device.connectGatt(context, false, this.callback, BluetoothDevice.TRANSPORT_LE)
} catch (e: Exception) {
Log.e("Peripheral", "Exception during connectGatt: ${e.message}")
this@Peripheral.onConnectionStateChange = null
invoke.reject("Exception during connectGatt: ${e.message}")
}
}
}
@SuppressLint("MissingPermission")
fun discoverServices(invoke: Invoke) {
val gatt = this.gatt
if (gatt == null) {
invoke.reject("No gatt server connected")
return
}
discoverAttempts = 0
this.onServicesDiscovered = { success, error ->
if (success) {
invoke.resolve()
} else {
invoke.reject(error)
}
this.onServicesDiscovered = null
}
runOnMain {
if (!gatt.discoverServices()) {
invoke.reject("failed to start service discovery");
}
Log.d("Peripheral", "service discovery started")
}
}
fun isConnected(): Boolean {
return this.connected
}
/// True while this peripheral holds a BluetoothGatt, established or still
/// being connected. Such a peripheral must not be replaced by a fresh one
/// from a scan result, that would leak the open gatt.
fun hasGatt(): Boolean {
return this.gatt != null || this.pendingGatt != null
}
@SuppressLint("MissingPermission")
fun isBonded(): Boolean {
return this.device.bondState == BluetoothDevice.BOND_BONDED
}
@SuppressLint("MissingPermission")
fun disconnect(invoke: Invoke, onComplete: () -> Unit = {}) {
val gatt = this.gatt
if (gatt == null) {
this.connected = false
// A connectGatt that never completed still holds a client
// interface; cancel it explicitly.
val pending = this.pendingGatt
this.pendingGatt = null
if (pending != null) {
Log.d("Peripheral", "cancelling pending connection")
runOnMain {
try {
pending.disconnect()
pending.close()
} catch (e: Exception) {
Log.w("Peripheral", "Failed to cancel pending connection: ${e.message}")
}
}
}
failPendingOperations("device disconnected")
onComplete()
invoke.resolve()
return
}
// Wait for the disconnect callback before resolving so the caller
// doesn't immediately try to reconnect while the stack is still
// cleaning up (which on Android often fails with status 133). The
// BluetoothGatt is closed inside onConnectionStateChange.
var finished = false
val finish = {
if (!finished) {
finished = true
this@Peripheral.onConnectionStateChange = null
failPendingOperations("device disconnected")
onComplete()
invoke.resolve()
}
}
this.onConnectionStateChange = { _, _ -> finish() }
// Without this the caller would be stuck if the callback never arrives
// (which happens when the link is already gone at the radio level).
retryHandler.postDelayed({
if (!finished) {
Log.w("Peripheral", "no disconnect callback within ${disconnectFallbackMs}ms, closing gatt")
this.gatt = null
this.connected = false
try {
gatt.close()
} catch (e: Exception) {
Log.w("Peripheral", "Failed to close gatt: ${e.message}")
}
sendEvent(Event.DeviceDisconnected)
finish()
}
}, disconnectFallbackMs)
runOnMain { gatt.disconnect() }
}
/// Rejects every write that is queued or in flight. Called when there is no
/// connection left to complete them on.
private fun failPendingWrites(reason: String) {
val pending: MutableList<PendingWrite> = mutableListOf()
synchronized(this.writeQueueLock) {
this.activeWrite?.let { pending.add(it) }
this.activeWrite = null
while (this.writeQueue.isNotEmpty()) {
pending.add(this.writeQueue.removeFirst())
}
}
if (pending.isEmpty()) {
return
}
Log.w("Peripheral", "Rejecting ${pending.size} pending write(s): $reason")
for (op in pending) {
op.invoke?.reject("Write to characteristic ${op.key.first} failed: $reason")
}
}
/// Rejects every operation waiting for a gatt callback, so no invoke is
/// left pending forever after the connection is gone.
private fun failPendingOperations(reason: String) {
failPendingWrites(reason)
val reads = synchronized(this.onReadInvoke) {
val pending = this.onReadInvoke.entries.map { Pair(it.key, it.value) }
this.onReadInvoke.clear()
pending
}
for ((key, op) in reads) {
op.invoke.reject("Read from characteristic ${key.first} failed: $reason")
}
val descriptorOp = this.onDescriptorInvoke
this.onDescriptorInvoke = null
descriptorOp?.invoke?.reject("descriptor write failed: $reason")
val mtuInvoke = this.onMtuInvoke
this.onMtuInvoke = null
mtuInvoke?.reject("mtu request failed: $reason")
val onServicesDiscovered = this.onServicesDiscovered
this.onServicesDiscovered = null
onServicesDiscovered?.invoke(false, "service discovery failed: $reason")
}
class ResCharacteristic(
private val uuid: String,
private val properties: Int,
private val descriptors: List<String>
) {
fun toJson(): JSONObject {
val ret = JSONObject()
ret.put("uuid", uuid)
ret.put("properties", properties)
val descriptors = JSONArray()
for (desc in this.descriptors) {
descriptors.put(desc)
}
ret.put("descriptors", descriptors)
return ret
}
}
class ResService(
private val uuid: String,
private val primary: Boolean,
private val characs: List<ResCharacteristic>,
) {
fun toJson(): JSONObject {
val ret = JSONObject()
ret.put("uuid", uuid)
ret.put("primary", primary)
val characs = JSONArray()
for (char in this.characs) {
characs.put(char.toJson())
}
ret.put("characs", characs)
return ret
}
}
fun services(invoke: Invoke) {
val services = JSONArray()
for (service in this.services) {
val characs: MutableList<ResCharacteristic> = mutableListOf()
for (charac in service.characteristics) {
characs.add(
ResCharacteristic(
charac.uuid.toString(),
charac.properties,
charac.descriptors.map { desc -> desc.uuid.toString() },
)
)
}
services.put(
ResService(
service.uuid.toString(),
service.type == BluetoothGattService.SERVICE_TYPE_PRIMARY,
characs
).toJson()
)
}
val res = JSONObject()
res.put("result", services)
invoke.resolve(res)
}
fun setNotifyChannel(channel: Channel) {
this.notifyChannel = channel;
}
@SuppressLint("MissingPermission")
fun write(invoke: Invoke, args: WriteParams) {
val key = Pair(args.characteristic!!, args.service!!)
val charac = this.characteristics[key]
if (charac == null) {
invoke.reject("Characterisitc ${args.characteristic} not found")
return
}
val id = this.writeCount.getAndIncrement()
val timeoutAfter = if (args.timeout > 0) System.currentTimeMillis() + args.timeout else 0L
val op = PendingWrite(id, key, charac, null, args.data!!, args.withResponse, timeoutAfter = timeoutAfter)
if (!args.skipWaitingForWriteToComplete) {
op.invoke = invoke
}
var shouldStart = false
synchronized(this.writeQueueLock) {
this.writeQueue.addLast(op)
shouldStart = this.activeWrite == null
}
if (args.skipWaitingForWriteToComplete) {
Log.v(
"Peripheral",
"write: skipWaitingForWriteToComplete is true, resolving immediately without waiting for write to complete"
)
val start = System.currentTimeMillis()
invoke.resolve()
val duration = System.currentTimeMillis() - start
if (duration > 5) {
Log.d("Peripheral", "write: skipWaitingForWriteToComplete - invoke.resolve took $duration ms")
}
}
if (shouldStart) {
runOnMain {
processWriteQueue()
}
}
}
@SuppressLint("MissingPermission")
private fun processWriteQueue() {
// this function always runs on the main thread; therefore we do not need to synchronize activeWrite
if (this.activeWrite == null) {
synchronized(this.writeQueueLock) {
// filter timed-out or over-attempt writes out of the queue before starting the next one
val to_reject: MutableList<PendingWrite> = mutableListOf()
if (this.activeWrite == null) {
val queueSize = this.writeQueue.size
var iter = 0;
while (iter < queueSize) {
val pendingWrite = this.writeQueue.removeFirst()
if (pendingWrite.attempt > maxAttempts || isWriteTimedOut(pendingWrite)) {
to_reject.add(pendingWrite)
} else {
this.writeQueue.addLast(pendingWrite)
}
iter++
}
}
for (iter in to_reject) {
if (iter.attempt > maxAttempts) {
Log.w(
"Peripheral",
"Discarding queued write to ${iter.key.first} after ${iter.attempt} attempts without success"
)
} else {
Log.w(
"Peripheral",
"Discarding queued write to ${iter.key.first} that timed out in queue after waiting for ${iter.timeoutAfter - System.currentTimeMillis()} ms"
)
}
}
if (this.writeQueue.isNotEmpty()) {
this.activeWrite = this.writeQueue.removeFirst()
}
}
}
val current = this.activeWrite ?: return
// already sent, waiting for onCharacteristicWrite callback.
if (current.timeSentAt > 0L) {
val elapsedSinceSent = System.currentTimeMillis() - current.timeSentAt
val waitUntilRetry = callbackWaitMs(current)
if (elapsedSinceSent < waitUntilRetry) {
retryHandler.postDelayed(
{ processWriteQueue() }, waitUntilRetry - elapsedSinceSent
)
return
}
if (current.withResponse) {
// A with-response write that was accepted by the stack is
// executed by the device even if the callback is late, so
// resending would trigger the operation a second time. Keep
// waiting until the write times out instead.
if (!isWriteTimedOut(current)) {
retryHandler.postDelayed({ processWriteQueue() }, waitUntilRetry)
return
}
synchronized(this.writeQueueLock) {
if (this.activeWrite == current) {
this.activeWrite = null
}
}
Log.w(
"Peripheral",
"Write with id ${current.id} timed out waiting for the write callback"
)
current.invoke?.reject("Write to characteristic ${current.key.first} timed out waiting for the write callback")
return processWriteQueue()
}
// Callback window elapsed without a response — resend.
current.timeSentAt = 0L
}
val charac = current.characteristic
val op = current
val gatt = this.gatt
if (gatt == null) {
// Disconnected while writes were queued: nothing can complete, so
// reject instead of leaving the invokes pending forever.
Log.w("Peripheral", "No gatt server connected, dropping pending writes")
failPendingWrites("no gatt server connected")
return
}
val writeType = if (op.withResponse) {
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
} else {
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
}
val status: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(charac, op.data, writeType)
} else {
@Suppress("DEPRECATION")
charac.writeType = writeType
@Suppress("DEPRECATION")
charac.value = op.data
@Suppress("DEPRECATION")
if (gatt.writeCharacteristic(charac)) {
BluetoothGatt.GATT_SUCCESS
} else {
BluetoothGatt.GATT_FAILURE
}
}
if (status != BluetoothGatt.GATT_SUCCESS) {
if (status == BluetoothStatusCodes.ERROR_GATT_WRITE_REQUEST_BUSY) {
discardTimedOutQueuedWrites()
} else {
Log.w(
"Peripheral",
"Failed to start write on ${charac.uuid} (status $status, attempt ${op.attempt}/$maxAttempts)"
)
op.attempt += 1
}
if (isWriteTimedOut(op)) {
synchronized(this.writeQueueLock) {
if (this.activeWrite == current) {
this.activeWrite = null
}
}
op.invoke?.reject("Write to characteristic ${charac.uuid} timed out")
return processWriteQueue()
}
if (op.attempt < maxAttempts) {
synchronized(this.writeQueueLock) {
if (this.activeWrite == current) {
this.activeWrite = op
}
}
retryHandler.postDelayed({
processWriteQueue()
}, writeRetryDelayMs)
} else {
synchronized(this.writeQueueLock) {
if (this.activeWrite == current) {
this.activeWrite = null
}
}
op.invoke?.reject(
"Failed to start write on characteristic ${charac.uuid} after ${op.attempt} attempts: status $status (${
statusCodeName(
status
)
})"
)
return processWriteQueue()
}
} else {
op.timeSentAt = System.currentTimeMillis()
retryHandler.postDelayed({
processWriteQueue()
}, 5L)
}
}
private fun callbackWaitMs(op: PendingWrite): Long {
return if (op.withResponse) {
writeCallbackWaitWithResponseMs
} else {
writeCallbackWaitNoResponseMs
}
}
private fun isWriteTimedOut(op: PendingWrite): Boolean {
return op.timeoutAfter > 0L && System.currentTimeMillis() >= op.timeoutAfter
}
private fun discardTimedOutQueuedWrites() {
val timedOutWrites: MutableList<PendingWrite> = mutableListOf()
synchronized(this.writeQueueLock) {
if (this.writeQueue.isEmpty()) {
return
}
val remaining = ArrayDeque<PendingWrite>()
while (this.writeQueue.isNotEmpty()) {
val next = this.writeQueue.removeFirst()
if (isWriteTimedOut(next)) {
timedOutWrites.add(next)
} else {
remaining.addLast(next)
}
}
this.writeQueue.addAll(remaining)
}
for (timedOut in timedOutWrites) {
timedOut.invoke?.reject("Write to characteristic ${timedOut.key.first} timed out in queue while stalled (WRITE_REQUEST_BUSY)")
}
if (timedOutWrites.isNotEmpty()) {
Log.w(
"Peripheral",
"Discarded ${timedOutWrites.size} timed-out queued writes while handling WRITE_REQUEST_BUSY"
)
}
}
@SuppressLint("MissingPermission")
fun read(invoke: Invoke) {
val args = ReadParams.from(invoke.args)
val key = Pair(args.characteristic!!, args.service!!)
val charac = this.characteristics[key]
if (charac == null) {
invoke.reject("Characteristic ${args.characteristic} not found")
return
}
startRead(key, charac, ReadOp(invoke))
}
@SuppressLint("MissingPermission")
private fun startRead(key: Pair<UUID, UUID>, charac: BluetoothGattCharacteristic, op: ReadOp) {
runOnMain {
val gatt = this.gatt
if (gatt == null) {
op.invoke.reject("No gatt server connected")
return@runOnMain
}
synchronized(this.onReadInvoke) {
if (this.onReadInvoke[key] != null) {
this.onReadInvoke[key]!!.invoke.reject("read was overwritten before finishing")
}
this.onReadInvoke[key] = op
}
if (!gatt.readCharacteristic(charac)) {
synchronized(this.onReadInvoke) {
this.onReadInvoke.remove(key)
}
if (op.attempt < maxAttempts) {
val nextAttempt = op.attempt + 1
Log.w(
"Peripheral",
"Failed to start read on ${charac.uuid} (attempt ${op.attempt}/$maxAttempts), retrying"
)
retryHandler.postDelayed({
startRead(key, charac, op.copy(attempt = nextAttempt))
}, writeRetryDelayMs)
} else {
op.invoke.reject("Failed to start read on characteristic ${charac.uuid} after ${op.attempt} attempts")
}
}
}
}
@SuppressLint("MissingPermission")
fun subscribe(invoke: Invoke, enabled: Boolean) {
val args = ReadParams.from(invoke.args)
val charac = this.characteristics[Pair(args.characteristic!!, args.service!!)]
if (charac == null) {
invoke.reject("Characteristic ${args.characteristic} not found")
return
}
val descriptor: BluetoothGattDescriptor? =
charac.getDescriptor(CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR)
if (descriptor == null) {
invoke.reject("CCCD descriptor not found on characteristic ${args.characteristic}")
return
}
runOnMain {
val gatt = this.gatt
if (gatt == null) {
invoke.reject("No gatt server connected")
return@runOnMain
}
if (!gatt.setCharacteristicNotification(charac, enabled)) {
invoke.reject("Failed to set notification status")
return@runOnMain
}
val data =
if (enabled) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
startDescriptorWrite(descriptor, DescriptorOp(invoke, data))
}
}
@SuppressLint("MissingPermission")
private fun startDescriptorWrite(descriptor: BluetoothGattDescriptor, op: DescriptorOp) {
runOnMain {
val gatt = this.gatt
if (gatt == null) {
op.invoke.reject("No gatt server connected")
return@runOnMain
}
if (this.onDescriptorInvoke != null) {
this.onDescriptorInvoke!!.invoke.reject("descriptor write was overwritten before finishing")
}
this.onDescriptorInvoke = op
val status: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, op.data)
} else {
@Suppress("DEPRECATION")
descriptor.value = op.data
@Suppress("DEPRECATION")
if (gatt.writeDescriptor(descriptor)) {
BluetoothGatt.GATT_SUCCESS
} else {
BluetoothGatt.GATT_FAILURE
}
}
if (status != BluetoothGatt.GATT_SUCCESS) {
this.onDescriptorInvoke = null
if (op.attempt < maxAttempts) {
val nextAttempt = op.attempt + 1
Log.w(
"Peripheral",
"Failed to start descriptor write (status $status, attempt ${op.attempt}/$maxAttempts), retrying"
)
retryHandler.postDelayed({
startDescriptorWrite(descriptor, op.copy(attempt = nextAttempt))
}, writeRetryDelayMs)
} else {
op.invoke.reject(
"Failed to start descriptor write after ${op.attempt} attempts: status $status (${
statusCodeName(
status
)
})"
)
}
}
}
}
@SuppressLint("MissingPermission")
fun requestMtu(invoke: Invoke, mtu: Int) {
if (this.onMtuInvoke != null) {
this.onMtuInvoke!!.reject("mtu request was overwritten before finishing")
}
onMtuInvoke = invoke
runOnMain {
val gatt = this.gatt
if (gatt == null) {
this@Peripheral.onMtuInvoke = null
invoke.reject("No gatt server connected")
return@runOnMain
}
if (!gatt.requestMtu(mtu)) {
this@Peripheral.onMtuInvoke = null
invoke.reject("Failed to request mtu")
}
}
}
private fun statusCodeName(status: Int): String {
// Translate the BluetoothStatusCodes / BluetoothGatt error code into a
// human readable name. Only the GATT-relevant codes are mapped here.
return when (status) {
0 -> "SUCCESS"
1 -> "GATT_INVALID_HANDLE"
2 -> "GATT_READ_NOT_PERMITTED"
3 -> "GATT_WRITE_NOT_PERMITTED"
4 -> "GATT_INVALID_PDU"
5 -> "GATT_INSUFFICIENT_AUTHENTICATION"
6 -> "GATT_REQUEST_NOT_SUPPORTED"
7 -> "GATT_INVALID_OFFSET"
8 -> "GATT_ERROR"
13 -> "GATT_CONN_TIMEOUT"
15 -> "GATT_CONN_TERMINATE_PEER_USER"
16 -> "GATT_CONN_TERMINATE_LOCAL_HOST"
19 -> "GATT_CONN_FAIL_ESTABLISH"
22 -> "GATT_CONN_LMP_TIMEOUT"
62 -> "GATT_CONN_CANCEL"
133 -> "GATT_ERROR (133)"
137 -> "GATT_CONN_TERMINATE_DUE_TO_MIC_FAILURE"
257 -> "GATT_FAILURE"
200 -> "ERROR_GATT_WRITE_NOT_ALLOWED"
201 -> "ERROR_GATT_WRITE_REQUEST_BUSY"
else -> "UNKNOWN ($status)"
}
}
}