Debugging BLE on Android: Tools, Packet Sniffers, and Log Analysis
BLE bugs are some of the hardest problems in mobile development. The connection drops silently, a characteristic write returns a status code you have never seen, or the peripheral just stops advertising for no apparent reason. Unlike HTTP debugging where you can inspect requests in a proxy, BLE operates at the radio layer, and Android's Bluetooth stack adds its own layer of complexity on top.
At DEVSFLOW in Toronto, we build BLE-connected apps for neurotechnology companies like RE-AK Technologies and CLEIO. Over the past several years, we have developed a systematic debugging workflow for BLE on Android that catches issues faster and reduces the guesswork. This article covers the full toolchain: from nRF Connect for quick validation to HCI snoop logs for deep packet analysis, plus patterns for building debug tooling directly into your app.
1. nRF Connect for Mobile: Your First Line of Defense
Before writing a single line of Kotlin, you should validate that your peripheral is behaving correctly using Nordic Semiconductor's nRF Connect for Mobile. This free app is the most important BLE debugging tool on Android, and every developer working with Bluetooth should have it installed.
nRF Connect lets you scan for peripherals, inspect their advertisement data, connect, discover services and characteristics, and manually read or write values. This is invaluable for isolating whether a bug lives in your app code or on the firmware side.
Key debugging workflows with nRF Connect
- Advertisement validation: Check that your peripheral advertises the correct service UUIDs, local name, and manufacturer-specific data. Filter by RSSI to confirm signal strength at your expected operating distance.
- Service discovery verification: After connecting, verify the full GATT table. Confirm that all services, characteristics, and descriptors are present with the correct UUIDs and properties (read, write, notify, indicate).
- Manual characteristic testing: Write values to characteristics and read responses. Enable notifications and observe incoming data. This tells you immediately whether the firmware is working before your app code enters the picture.
- MTU negotiation check: nRF Connect shows the negotiated MTU after connection. If your app is failing to transfer large payloads, check here first to confirm what MTU the peripheral actually supports.
- Bonding and pairing: Test the pairing flow manually. If your peripheral requires encryption, nRF Connect will trigger the Android pairing dialog, letting you verify the flow works independently of your app.
One pattern we use frequently: connect with nRF Connect and your app simultaneously from two different Android devices. This lets you compare GATT discovery results side by side. If nRF Connect sees all services but your app does not, the bug is in your discovery code.
2. Enabling and Capturing HCI Snoop Logs
When nRF Connect confirms the peripheral is fine but your app still misbehaves, you need to go deeper. Android provides HCI (Host Controller Interface) snoop logging, which captures every Bluetooth packet between the Android host stack and the Bluetooth controller. This is the BLE equivalent of a network packet capture.
Enabling HCI snoop logs
On most Android devices, you can enable HCI snoop logging through Developer Options:
- Go to Settings, then System, then Developer Options
- Find "Enable Bluetooth HCI snoop log" and toggle it on
- Reproduce the BLE issue
- Toggle the snoop log off
The log file location varies by manufacturer. On Pixel devices it is typically at /data/misc/bluetooth/logs/btsnoop_hci.log. On Samsung devices you may find it under /data/log/bt/. You can pull it using ADB:
adb pull /data/misc/bluetooth/logs/btsnoop_hci.log ./btsnoop_hci.log
If the file is not accessible due to permissions, use the bug report method instead:
adb bugreport bugreport.zip
The HCI log will be included inside the bugreport archive. Extract it and look for files with btsnoop in the name.
Programmatic capture trigger
For automated testing, you can enable HCI snoop logging programmatically via ADB:
adb shell settings put secure bluetooth_hci_log 1
adb shell svc bluetooth disable
adb shell svc bluetooth enable
Note that a Bluetooth restart is required for the setting to take effect. This is useful in CI pipelines where you want to capture HCI logs during integration tests with a real peripheral.
3. Analyzing BLE Packets in Wireshark
HCI snoop logs are in the BTSnoop format, which Wireshark reads natively. Open the file in Wireshark and you will see every BLE packet: connection requests, GATT operations, ATT protocol exchanges, L2CAP frames, and link-layer control.
Essential Wireshark display filters for BLE
# Show only ATT protocol (GATT reads, writes, notifications)
btatt
# Filter by specific ATT opcode (e.g., Write Request = 0x12)
btatt.opcode == 0x12
# Show only connection events
btle.advertising_header || btle.data_header
# Filter by Bluetooth device address
bthci_acl.src.bd_addr == "AA:BB:CC:DD:EE:FF"
# Show GATT notifications only
btatt.opcode == 0x1b
# Show errors (ATT Error Response)
btatt.opcode == 0x01
When debugging a specific issue, the ATT Error Response filter (btatt.opcode == 0x01) is often the fastest way to find problems. Wireshark decodes the error code for you, showing whether the peripheral returned "Insufficient Authentication," "Invalid Handle," "Write Not Permitted," or another ATT error.
Diagnosing connection parameter issues
One of the most common BLE problems on Android is connection parameter mismatches. In Wireshark, filter for bthci_evt.le_meta_subevent == 0x03 to see Connection Update Complete events. Check the resulting connection interval, peripheral latency, and supervision timeout. If the peripheral requests parameters that Android rejects, you will see the negotiation play out in the capture.
We once spent two days debugging intermittent disconnections on a neurostimulation device for a Canadian medical device company. The Wireshark capture showed that the peripheral was requesting a 7.5ms connection interval, but the Android device (a Samsung Galaxy S23) was counter-proposing 30ms. The firmware was not handling the parameter update correctly and would disconnect after 10 seconds. Without the packet capture, this would have been nearly impossible to diagnose.
4. Logcat Filtering for BluetoothGatt
Wireshark gives you the radio-level view. Logcat gives you the Android stack view. Together they cover both sides of the problem. Android's Bluetooth stack is quite verbose when you know where to look.
Key Logcat tags for BLE debugging
# Core BLE GATT operations
adb logcat -s BluetoothGatt:V BluetoothAdapter:V
# Broader Bluetooth stack logging
adb logcat -s bt_btif:V bt_btif_gatt:V BtGatt.GattService:V
# Connection management
adb logcat -s BluetoothGatt:V BtGatt.ContextMap:V
# Full Bluetooth filter (verbose, but catches everything)
adb logcat | grep -i "bluetooth\|bt_\|BtGatt\|BluetoothGatt"
Decoding BluetoothGatt status codes
The onConnectionStateChange, onServicesDiscovered, and onCharacteristicWrite callbacks all receive a status parameter. While BluetoothGatt.GATT_SUCCESS (0) is well documented, the error codes are not. Here are the ones you will encounter most often in production:
- Status 8 (0x08) - Connection Timeout: The peripheral did not respond within the supervision timeout. Usually a range issue or the peripheral crashed.
- Status 19 (0x13) - Remote User Terminated: The peripheral intentionally disconnected. Check your firmware logic.
- Status 22 (0x16) - Local Host Terminated: Android terminated the connection. Often caused by calling
close()while operations are pending. - Status 34 (0x22) - LMP Response Timeout: Link Manager Protocol timeout. Usually a chipset-level issue or severe radio interference.
- Status 62 (0x3E) - Connection Failed to Establish: Android could not establish the connection at all. Often seen when the peripheral stops advertising between scan result and connection attempt.
- Status 133 (0x85) - GATT_ERROR: The infamous catch-all error. Can mean almost anything. Common causes: calling
connectGatttoo quickly after a disconnect, GATT cache corruption, or exceeding the maximum number of simultaneous connections. - Status 257 (0x101) - GATT_INTERNAL_ERROR: Internal stack failure. Usually requires a Bluetooth adapter restart to recover.
Write a utility function that logs these codes in human-readable form:
fun gattStatusToString(status: Int): String = when (status) {
BluetoothGatt.GATT_SUCCESS -> "GATT_SUCCESS (0)"
8 -> "CONNECTION_TIMEOUT (8)"
19 -> "REMOTE_USER_TERMINATED (19)"
22 -> "LOCAL_HOST_TERMINATED (22)"
34 -> "LMP_RESPONSE_TIMEOUT (34)"
62 -> "CONNECTION_FAILED_TO_ESTABLISH (62)"
133 -> "GATT_ERROR (133)"
257 -> "GATT_INTERNAL_ERROR (257)"
else -> "UNKNOWN_STATUS ($status / 0x${status.toString(16)})"
}
5. Common BLE Error Patterns and Their Root Causes
After debugging hundreds of BLE issues across different Android devices, certain patterns emerge repeatedly. Recognizing these patterns quickly is the difference between a one-hour fix and a three-day investigation.
Pattern: Status 133 after reconnection
This is the single most common BLE bug on Android. After disconnecting from a peripheral and immediately attempting to reconnect, you get status 133 on every connection attempt. The root cause is almost always that BluetoothGatt.close() was not called after the disconnect, or that a new connectGatt() call was issued before the previous GATT client was fully cleaned up.
// WRONG: Will cause status 133 on reconnect
fun onDisconnect() {
bluetoothGatt?.disconnect()
// Missing close()! The GATT client is still allocated.
connectToDevice() // Immediate reconnect fails
}
// CORRECT: Always close, then delay reconnect
fun onDisconnect() {
bluetoothGatt?.close()
bluetoothGatt = null
handler.postDelayed({ connectToDevice() }, 1000)
}
Pattern: Service discovery returns empty
You call discoverServices(), get onServicesDiscovered with status 0, but getServices() returns an empty list. This is caused by Android's aggressive GATT caching. The device was previously connected with a different GATT table (perhaps during firmware development), and Android cached the old table. The fix is to clear the GATT cache using reflection:
private fun refreshGattCache(gatt: BluetoothGatt): Boolean {
return try {
val method = gatt.javaClass.getMethod("refresh")
method.invoke(gatt) as? Boolean ?: false
} catch (e: Exception) {
Log.w(TAG, "Failed to refresh GATT cache", e)
false
}
}
Call this immediately after connection, before service discovery. Note that this uses reflection on an internal API, so it may break on future Android versions. In production, we also implement a fallback that toggles Bluetooth off and on if the refresh call fails.
Pattern: Notifications stop arriving after a few minutes
You subscribe to notifications, they work for 2-3 minutes, then stop. The connection stays alive, but no data comes through. This is almost always caused by not writing to the Client Characteristic Configuration Descriptor (CCCD). Calling setCharacteristicNotification() on the Android side is not enough; you must also write 0x0100 to the CCCD on the peripheral:
fun enableNotifications(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
6. Building a BLE Debug Overlay in Your App
For development and QA testing, a debug overlay that displays real-time BLE state is invaluable. Instead of switching to Logcat every time something goes wrong, your QA team can see the BLE connection state, RSSI, MTU, and recent operations directly on screen.
Implementing a minimal debug overlay
class BleDebugOverlay(context: Context) {
data class BleDebugState(
val connectionState: String = "Disconnected",
val rssi: Int = 0,
val mtu: Int = 23,
val lastOperation: String = "",
val lastError: String = "",
val notificationCount: Long = 0,
val bytesReceived: Long = 0,
val connectionUptime: Long = 0
)
private val _state = MutableStateFlow(BleDebugState())
val state: StateFlow<BleDebugState> = _state.asStateFlow()
fun onConnectionStateChange(status: Int, newState: Int) {
_state.update {
it.copy(
connectionState = when (newState) {
BluetoothProfile.STATE_CONNECTED -> "Connected"
BluetoothProfile.STATE_DISCONNECTED -> "Disconnected"
BluetoothProfile.STATE_CONNECTING -> "Connecting..."
else -> "Unknown ($newState)"
},
lastError = if (status != 0) gattStatusToString(status) else it.lastError
)
}
}
fun onRssiRead(rssi: Int) {
_state.update { it.copy(rssi = rssi) }
}
fun onMtuChanged(mtu: Int) {
_state.update { it.copy(mtu = mtu) }
}
fun onNotificationReceived(bytes: Int) {
_state.update {
it.copy(
notificationCount = it.notificationCount + 1,
bytesReceived = it.bytesReceived + bytes,
lastOperation = "Notification (${bytes}B)"
)
}
}
}
Wire this into your GATT callback and render it as a small floating panel during debug builds. We typically show it as a semi-transparent overlay in the top-right corner that can be collapsed to just show connection state and RSSI.
Logging BLE events to a ring buffer
Beyond the overlay, maintain a ring buffer of the last 500 BLE events with timestamps. When a bug occurs, export this buffer as a text file or send it to your analytics backend. This is far more useful than Logcat output because it includes only BLE events, with your custom context attached:
class BleEventLogger(private val maxSize: Int = 500) {
private val events = ArrayDeque<BleEvent>(maxSize)
data class BleEvent(
val timestamp: Long = System.currentTimeMillis(),
val type: String,
val detail: String,
val status: Int? = null
)
@Synchronized
fun log(type: String, detail: String, status: Int? = null) {
if (events.size >= maxSize) events.removeFirst()
events.addLast(BleEvent(type = type, detail = detail, status = status))
}
@Synchronized
fun export(): String {
val formatter = java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
return events.joinToString("\n") { event ->
val time = java.time.Instant.ofEpochMilli(event.timestamp)
.atZone(java.time.ZoneId.systemDefault())
.format(formatter)
val statusStr = event.status?.let { " [status=$it]" } ?: ""
"$time ${event.type}: ${event.detail}$statusStr"
}
}
}
7. Remote Debugging BLE Issues in Production
The hardest BLE bugs are the ones that only happen on specific devices in the field. A user in Vancouver reports that the app disconnects every 30 seconds, but you cannot reproduce it on any device in your Toronto lab. Remote BLE debugging requires a different strategy.
Structured BLE telemetry
Ship structured BLE telemetry in your production builds. At a minimum, log these events to your analytics platform:
- Connection attempts (with device model, Android version, and BLE chipset if available)
- Connection successes and failures (with GATT status codes)
- Service discovery results (number of services found, duration)
- MTU negotiation results (requested vs. granted)
- Disconnection events (with status code and connection duration)
- Notification throughput (bytes per second, averaged over 10-second windows)
- RSSI samples (periodic, to detect range issues)
fun logBleEvent(event: String, params: Map<String, Any>) {
val baseParams = mapOf(
"device_model" to Build.MODEL,
"android_version" to Build.VERSION.SDK_INT,
"ble_adapter" to getBluetoothChipset(),
"app_version" to BuildConfig.VERSION_NAME,
"timestamp" to System.currentTimeMillis()
)
analytics.logEvent("ble_$event", baseParams + params)
}
// Usage
logBleEvent("disconnect", mapOf(
"status" to status,
"status_name" to gattStatusToString(status),
"connection_duration_ms" to connectionDuration,
"rssi_last" to lastRssi,
"bytes_transferred" to totalBytes
))
On-device diagnostic mode
For users who report issues, provide a hidden diagnostic mode (accessible via a settings toggle or a support deep link). This mode enables verbose BLE logging on the device and writes it to a local file that the user can share with your support team:
class BleDiagnosticMode(private val context: Context) {
private var logFile: File? = null
private var writer: BufferedWriter? = null
fun start() {
val dir = File(context.getExternalFilesDir(null), "ble_diagnostics")
dir.mkdirs()
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
logFile = File(dir, "ble_diag_$timestamp.txt")
writer = logFile?.bufferedWriter()
writer?.write("BLE Diagnostic Log\n")
writer?.write("Device: ${Build.MANUFACTURER} ${Build.MODEL}\n")
writer?.write("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
writer?.write("Started: ${Date()}\n\n")
}
fun log(message: String) {
val time = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date())
writer?.write("[$time] $message\n")
writer?.flush()
}
fun stop(): File? {
writer?.close()
return logFile
}
}
8. Putting It All Together: A Debugging Decision Tree
When a BLE bug arrives, follow this decision tree to pick the right tool:
- Can you reproduce with nRF Connect? If the issue appears in nRF Connect too, the bug is on the firmware side. Send the nRF Connect logs to your hardware team.
- Is it a connection or data issue? For connection failures (status 133, timeouts, unexpected disconnects), go straight to HCI snoop logs and Wireshark. Filter for connection events and error codes.
- Is it device-specific? Check your production telemetry for patterns by device model or Android version. Samsung devices with Exynos chipsets behave differently from Pixel devices with Qualcomm chipsets. MediaTek devices often have their own quirks with connection interval handling.
- Is it timing-related? Use the BLE event ring buffer to check the exact sequence and timing of operations. Many BLE bugs are race conditions between GATT operations that must be serialized.
- Can you only reproduce in the field? Enable diagnostic mode for the affected user. Have them reproduce the issue and share the log file. Combine with your analytics telemetry for a complete picture.
The combination of these tools covers the full spectrum of BLE debugging, from "the peripheral does not even advertise" all the way to "notifications drop 0.1% of packets on Samsung Galaxy A54 devices running Android 14." Build the debug infrastructure early in your project. When the hard bugs arrive (and they will), you will be glad you did.
Building a BLE-connected Android app and running into hard-to-diagnose issues? Talk to DEVSFLOW Neuro. We build BLE-connected mobile apps for neurotechnology and medical device companies across Canada.