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

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:

  1. Go to Settings, then System, then Developer Options
  2. Find "Enable Bluetooth HCI snoop log" and toggle it on
  3. Reproduce the BLE issue
  4. 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:

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:

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.