Install
openclaw skills install @ahmadhussain0335/mobile-bleDesigns, implements and reviews Bluetooth Low Energy code in Android, iOS and Flutter apps: Android 12+ permissions, filtered scans, GATT operation queues, MTU, bonding, status 133, CoreBluetooth threading and state restoration, flutter_blue_plus, framing, CRC, safe firmware updates (DFU) and connection state machines. Use when writing or reviewing BLE scan, connect, read, write or notify code, reconnect logic or device protocols.
openclaw skills install @ahmadhussain0335/mobile-bleBuild and review the app side of a Bluetooth Low Energy link the way a senior connected-device engineer would: correct permissions per OS version, one GATT operation at a time, no assumptions about threads, explicit connection states, framed and checked messages, firmware updates that cannot brick the device, and logs that leak nothing. A good result works on real phones, recovers from drops, and survives hostile or corrupt input.
Not for: a full MASVS audit (use mobile-security), CRA or EN 18031 mapping (use mobile-eu-compliance),
general Kotlin or Swift style (use mobile-kotlin, mobile-swift).
minSdk, targetSdk, manifest permissions, foreground service declarations.Info.plist usage strings, UIBackgroundModes, restoration identifier.pubspec.yaml BLE and permission plugins and their versions.bluetooth-central and state restoration on iOS.Info.plist keys, entitlements, foreground service types and background
modes the change adds. These affect store review and signing and must be called out explicitly.| Area | Fact |
|---|---|
| Android 12+ (API 31) | BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE are runtime permissions; legacy BLUETOOTH and BLUETOOTH_ADMIN get maxSdkVersion="30" |
neverForLocation | Lets API 31+ scan without location; some beacons are filtered from results |
| Android 11 and lower | Scanning needs ACCESS_FINE_LOCATION |
| Android 13 (API 33) | writeCharacteristic(c, value, writeType) returns an Int status; byte-array callbacks; old forms deprecated |
| Android 14 (API 34) | Stack requests ATT MTU 517 on the first requestMtu and ignores later requests |
| Android 14 (API 34) | Every foreground service declares a type; connectedDevice needs FOREGROUND_SERVICE_CONNECTED_DEVICE |
| Android 16 (API 36) | Bond loss: system keeps the bond and shows a dialog; ACTION_KEY_MISSING and ACTION_ENCRYPTION_CHANGE intents (OEM behaviour varies) |
| Android 17 (API 37) | connectGatt(BluetoothGattConnectionSettings, Executor, BluetoothGattCallback) added; the Context overloads are deprecated; autonomous re-pairing after bond loss |
| iOS 13+ | NSBluetoothAlwaysUsageDescription is required to use Bluetooth |
| iOS | connect(_:options:) never times out; queue: nil delivers delegate calls on the main queue |
| iOS | Background scanning needs bluetooth-central and explicit service UUIDs |
| iOS 26 | Per TN3115, some relaunch cases are limited to apps that set up accessories with AccessorySetupKit |
| flutter_blue_plus 2.x | connect takes a required license: argument; commercial use needs a paid license |
Re-check these against the official pages before quoting them in a design document. Versions move.
<!-- BAD: location requested on Android 12+ for no reason, no legacy caps -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- GOOD: app never derives location from scans -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
// BAD: CBCentralManager created at launch, before the user knows why; no usage string
let central = CBCentralManager(delegate: self, queue: nil)
// GOOD: Info.plist has NSBluetoothAlwaysUsageDescription; create the manager when the feature starts
if CBManager.authorization == .denied { state = .bluetoothDenied; return }
central = CBCentralManager(delegate: self, queue: bleQueue)
neverForLocation and keep ACCESS_FINE_LOCATION.// BAD: unfiltered, low latency, never stopped
scanner.startScan(callback)
// GOOD
val filters = listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build())
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_BALANCED).build()
scanner.startScan(filters, settings, callback)
scanTimeout = scope.launch { delay(10.seconds); scanner.stopScan(callback) }
SCAN_MODE_LOW_POWER.SCAN_FAILED_SCANNING_TOO_FREQUENTLY, or silently no
results on some devices. The exact limit is not documented; do not restart scans in a tight loop.scanForPeripherals(withServices:options:); nil does not work in background.Android and iOS both drop or reject overlapping requests. Android returns false or
ERROR_GATT_WRITE_REQUEST_BUSY; the callback for the first request may never come.
// BAD: second call fails while the first is in flight
gatt.writeCharacteristic(cmd, bytes, WRITE_TYPE_DEFAULT)
gatt.readCharacteristic(status)
// GOOD: serialized queue, completes on the callback, times out if the callback never comes
suspend fun write(c: BluetoothGattCharacteristic, value: ByteArray) =
queue.run(timeout = 5.seconds) { gatt.writeCharacteristic(c, value, WRITE_TYPE_DEFAULT) == BluetoothStatusCodes.SUCCESS }
Android GATT callbacks arrive on a binder thread unless you pass a Handler or Executor.
iOS delegate calls arrive on the queue passed to CBCentralManager, or main when nil.
// BAD: mutates UI-facing state and a plain list from a binder thread
override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic, value: ByteArray) {
buffer.add(value); uiState.items += parse(value)
}
// GOOD: hand off to one serial context; StateFlow exposes the result
override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic, value: ByteArray) {
inbound.trySend(value.copyOf()) // Channel consumed on a limitedParallelism(1) dispatcher
}
// BAD: BLE work on the main queue blocks UI during bursts of notifications
central = CBCentralManager(delegate: self, queue: nil)
// GOOD: dedicated serial queue; hop to the main actor only for UI state
central = CBCentralManager(delegate: self, queue: DispatchQueue(label: "ble.central"))
Task { @MainActor in model.apply(event) }
characteristic.value on 33+// BAD: deprecated in API 33, not memory safe, value can change under you
characteristic.value = bytes
gatt.writeCharacteristic(characteristic)
// GOOD
val status = if (Build.VERSION.SDK_INT >= 33) {
gatt.writeCharacteristic(characteristic, bytes, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
legacyWrite(gatt, characteristic, bytes) // @Suppress("DEPRECATION") helper: sets value, calls old API
}
onCharacteristicChanged overloads when minSdk < 33; use the value parameter on 33+.connectGatt(BluetoothGattConnectionSettings, Executor, callback) behind a version check.Status 133 (0x85) is a generic stack error with no public constant. It shows up on connect failures, stale caches and too many open clients.
// BAD: reconnect on the same object, never closed, leaks a client interface per attempt
if (status != BluetoothGatt.GATT_SUCCESS) gatt.connect()
// GOOD
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (status != BluetoothGatt.GATT_SUCCESS || newState == BluetoothProfile.STATE_DISCONNECTED) {
gatt.close()
events.trySend(LinkEvent.Dropped(status)) // state machine schedules retry with backoff
return
}
if (newState == BluetoothProfile.STATE_CONNECTED) events.trySend(LinkEvent.Connected)
}
disconnect() is asynchronous. Call close() once you no longer need the object, including after timeouts.// BAD: assumes 512-byte packets because MTU was "requested"
gatt.requestMtu(517); send(chunk512)
// GOOD: payload per ATT write is MTU - 3, taken from onMtuChanged
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
maxPayload = if (status == BluetoothGatt.GATT_SUCCESS) mtu - 3 else 20
}
// GOOD: iOS negotiates MTU itself; ask per write type
let maxPayload = peripheral.maximumWriteValueLength(for: .withoutResponse)
CONNECTION_PRIORITY_BALANCED.// BAD: loop floods the stack; packets are dropped with no error
for chunk in chunks { peripheral.writeValue(chunk, for: tx, type: .withoutResponse) }
// GOOD
func pump() {
while peripheral.canSendWriteWithoutResponse, let chunk = outbox.popFirst() {
peripheral.writeValue(chunk, for: tx, type: .withoutResponse)
}
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { pump() }
ERROR_GATT_WRITE_REQUEST_BUSY as back-pressure.// BAD: booleans that drift apart
var isConnected = false; var isConnecting = false; var servicesReady = false
// GOOD
sealed interface Link {
data object Idle : Link
data class Connecting(val attempt: Int) : Link
data object Discovering : Link
data class Ready(val maxPayload: Int) : Link
data class Backoff(val attempt: Int, val reason: Int) : Link
data class Failed(val reason: FailReason) : Link
}
enum Link: Sendable { case idle, connecting(attempt: Int), discovering, ready(maxPayload: Int), backoff(attempt: Int), failed(FailReason) }
Ready. UI observes the state; it never drives callbacks directly.// BAD: length byte from the device used as-is
val len = data[1].toInt()
val payload = data.copyOfRange(2, 2 + len)
// GOOD: bounds-check every length, verify CRC, reject and count bad frames
fun parse(frame: ByteArray): Frame? {
if (frame.size < HEADER + CRC_SIZE) return null
val len = frame[2].toInt() and 0xFF
if (len > MAX_PAYLOAD || HEADER + len + CRC_SIZE != frame.size) return null
if (crc16(frame, 0, HEADER + len) != frame.readU16(HEADER + len)) return null
return Frame(seq = frame[1].toInt() and 0xFF, payload = frame.copyOfRange(HEADER, HEADER + len))
}
// BAD: background scan loop to find a known device again
while (true) { scanner.startScan(cb); delay(5_000); scanner.stopScan(cb) }
// GOOD: autoConnect for a known device; the stack waits for it to come back in range (API 37: settings overload)
gatt = device.connectGatt(context, /* autoConnect = */ true, callback, BluetoothDevice.TRANSPORT_LE)
// GOOD: retrieve by stored identifier and connect; the request does not time out
if let p = central.retrievePeripherals(withIdentifiers: [savedID]).first {
p.delegate = self
central.connect(p)
}
autoConnect = false) for the first, user-initiated connection; it is faster.// BAD: start a service from a BLE callback while in the background (throws on Android 12+)
override fun onCharacteristicChanged(...) { context.startForegroundService(intent) }
// GOOD: start the connectedDevice service while the app is visible, or use Companion Device Manager
startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<service android:name=".DeviceLinkService" android:exported="false"
android:foregroundServiceType="connectedDevice" />
UIBackgroundModes with bluetooth-central plus CBCentralManagerOptionRestoreIdentifierKey.
A user force quit stops relaunch. Details: Android, iOS.// BAD: blind stream, no checks, phone at 5 %, image trusted because the CRC matched
dfu.send(image)
// GOOD: preconditions, signed image verified by the bootloader, resumable, verified after reboot
require(battery.phone >= 30 && deviceInfo.battery >= 50) { "battery too low" } // thresholds from firmware team
require(sha256(image).contentEquals(release.sha256)) // download integrity
dfu.send(image, resumeFrom = device.confirmedOffset()) // device verifies the vendor signature
check(device.reportedVersion() == release.version)
// BAD
Log.d("BLE", "connected ${device.address} key=${sessionKey.toHex()} payload=${value.toHex()}")
// GOOD: redacted identifiers, no keys, payload sizes not contents
log.debug { "connected dev=${device.address.redact()} mtu=$mtu len=${value.size}" }
gatt.connect() again after an error instead of close() and a fresh connectGatt.Thread.sleep or fixed delays between GATT calls instead of a queue that waits for callbacks.ACCESS_BACKGROUND_LOCATION, BLUETOOTH_PRIVILEGED or extra permissions "just in case".maxSdkVersion="30", location only if neededInfo.plist: NSBluetoothAlwaysUsageDescription; bluetooth-central only if background is requiredclose() on every disconnect, failure and timeout; status 133 retried with capped backoffmaximumWriteValueLength(for:)ReadyautoConnect, CDM or retrievePeripherals(withIdentifiers:), not scan loopsconnectedDevice declared with its permission, or CDM usedFor a review or design check:
## BLE review: <app / module / branch>
Scope: <files>. Platforms: <Android | iOS | Flutter>. Firmware doc: <name and version, or "missing">.
### Findings
1. [Blocker] Android -- ble/GattClient.kt:88 -- reconnect calls gatt.connect() after status 133, gatt never closed
Why: leaks a GATT client per attempt; after a few failures every connect returns 133 until Bluetooth restarts
Fix:
```kotlin
gatt.close(); stateMachine.on(LinkEvent.Dropped(status))
```
2. [Major] iOS -- BLE/Central.swift:41 -- writes without response in a loop, no canSendWriteWithoutResponse check
Why: chunks are dropped silently during DFU
Fix: pump from peripheralIsReady(toSendWriteWithoutResponse:)
### Protocol
| Check | Status | Evidence |
|---|---|---|
| Length fields bounds-checked | Missing | `FrameParser.kt:22` trusts `data[1]` |
| CRC verified on every frame | Met | `Crc16.kt`, CRC-16/CCITT-FALSE per firmware doc 2.3 |
### Manifest, Info.plist and background changes
- <permission / key / service type added or required, with store-review impact>
### Questions
- <anything the firmware document does not define>
### Verdict
Request changes -- 1 Blocker, 1 Major.
Severity: Blocker (bricked device, data corruption, security or privacy breach, crash), Major (lost data, stuck connection, battery drain, wrong behaviour on common phones), Minor, Nit. For a new protocol, the agent fills the protocol checklist from Protocol and DFU instead.
mobile-security -- MASVS checks for pairing, key storage, logging and exported componentsmobile-eu-compliance -- CRA and EN 18031 duties for companion apps, secure update evidencemobile-kotlin, mobile-swift, mobile-flutter -- language rules for the BLE layer codemobile-testing -- fakes, virtual time and device-lab strategymobile-performance -- battery cost of scans, connection priority and background workmobile-code-review -- general review dimensions alongside the BLE-specific findings