Skip to Main Content
The Ultimate Guide for Android Bluetooth Low Energy

The Ultimate Guide to Android Bluetooth Low Energy

Table of Contents
    62 minute read

    Originally published on May 15, 2020, and updated on April 17, 2024.

    What’s been updated:

    • This ultimate guide and its companion code repository now support compileSdkVersion and targetSdkVersion 34 (Android 14).
    • The APIs used in this guide are from the android.bluetooth package, and not from the very similarly-named androidx.bluetooth package which is still in alpha as of the time of writing.
    • The approaches detailed in this guide give developers full control over the device discovery and connection process, which is very different from the Companion Device Pairing approach that abstracts away a lot of control (and complexities) from developers. Stay tuned for a future article that covers this other approach.

    Introduction

    With its ability to consume very little power yet still provide the connectivity to communicate with small devices, more and more people are looking to hop on the Bluetooth Low Energy (BLE) bandwagon for Android apps. Unfortunately, the Android SDK’s BLE API is full of undocumented pitfalls and leaves a lot to be desired despite the platform commanding over 70% of market share worldwide.

    Fret not, as the Punch Through team has learned a lot over the 15+ years working on BLE-connected Android apps, and we’re here to share our connectivity expertise, experiences, and important lessons we’ve learned with our readers!

    Expert Guidance From the Trenches

    For new readers unfamiliar with who we are and what we do, we are Punch Through, the leading custom software development consultancy for connected products and mobile-mediated device-to-cloud connectivity.

    Our software engineers write our guides, and our insights come from firsthand experience solving complex BLE connectivity problems across connected product ecosystems. Punch Through’s multidisciplinary team helps product and engineering leaders and their teams through the complex journey of building innovative, secure, compliant, and reliable connected solutions for mission-critical projects across medical devices, consumer tech, and the entire IoT space.

    We’ve helped Google Identify and fix numerous Android BLE Issues:
    Particularly around the rocky Android 13 release that broke BLE for countless users. Our detailed bug report on “Android 13: Unable to reconnect reliably to bonded peripheral” garnered more than 885 +1s and was assigned P1 (Priority 1) and S1 (Severity 1) ratings. This report resulted in a fix in Android 13 QPR1 that we helped test when QPR1 was in Beta 2, and we have other reports that Google is still working on at the time of writing: Android 13: Unable to read value from an encrypted Bluetooth Low Energy GATT characteristic and Android 13: onCharacteristicRead status 133 upon confirming OS bonding dialog.

    What’s In This Ultimate Guide

    This updated guide goes over the basics of BLE that Android developers need to know and walks through some simple yet real-world examples of performing common BLE operations on Android, like scanning, connecting, reading, writing, and setting up Notifications or Indications. Most code snippets and examples were written in Kotlin, but they translate well into Java, too.

    Code Companion & Examples

    All the code snippets in this guide aim to showcase how a given BLE operation should be performed. The actual, full implementation in the context of an example app is available on our open-source GitHub repo.

    Note: As of April 17, 2024, we’ve updated this entire repo to support a targetSdk of Android 14, along with new Nearby Devices permission handling for Android 12+ and the new BluetoothGattCallback methods for Android 13+.

    Email Sign-up. Subscribe to stay informed.

    BLE Basics for Android Developers

    We kick things off by listing some keywords that you’ll come across when getting started with BLE development on Android.

    Table of Glossary

    Terms & Definitions
    BLE
    It stands for Bluetooth Low Energy, a subset of the 2.4 GHz Bluetooth wireless technology specializing in low-power and often infrequent data transmissions for connected devices.
    Central/Client
    A device that scans for and connects to BLE Peripherals to perform some operation. In the context of app development, this is typically an Android device.
    Peripheral/Server
    A device that advertises its presence and is connected to a Central to accomplish some task. In app development, this is typically a BLE device you’re working with, like a heart rate monitor.
    GATT Service
    A collection of characteristics (data fields) that describes a device’s feature, e.g., the Device Information service, can contain a characteristic representing the serial number of the device and another characteristic representing the device’s battery level.
    GATT Characteristic
    An entity containing meaningful data that can typically be read from or written to, e.g., the Serial Number String characteristic.
    GATT Descriptor
    A defined attribute that describes the characteristic it’s attached to, e.g., the Client Characteristic Configuration descriptor, shows if the Central is currently subscribed to a characteristic’s value change.
    Notifications
    A way for a BLE Peripheral to notify the Central when a characteristic’s value changes. The Central often doesn’t need to acknowledge receiving the packet.
    Indications
    It is the same as a Notification, except the Central acknowledges each data packet. This guarantees their delivery at the cost of throughput.
    UUID
    Universally Unique Identifier. It’s a 128-bit number to identify services, characteristics, and descriptors.

    How is BLE different from Bluetooth Classic?

    The “Low Energy” part of the BLE acronym basically gave it away. While Bluetooth Classic is designed to transmit continuous streams of data, such as music playback, BLE is optimized for power efficiency. A BLE device can typically run on a small battery for weeks, if not months, or even years, making it perfect for sensor-based or Internet of Things (IoT) use cases.

    Another critical distinction between Bluetooth Classic and Bluetooth Low Energy is that BLE is far more developer-friendly. BLE opens up a world of endless possibilities by allowing developers to specify various custom profiles for different use cases, whereas Bluetooth Classic primarily supports the Serial Port Profile (SPP) for sending custom data. The Android Bluetooth API is also not very straightforward to work with for Bluetooth Classic use cases due to the following reasons:

    • The Bluetooth Classic scanning API uses a BroadcastReceiver, which is multicast in nature, messy, and typically avoided in modern Android development.
    • The Android SDK requires Bluetooth Classic devices to be paired with Android before an RFCOMM connection can be established, whereas the BLE use case doesn’t have this restriction imposed.
    • The Android SDK only provides an implementation for a limited number of Bluetooth Classic profiles that are out of the box.
    • Once paired and connected, you need to spin up and manage a dedicated Android Thread object yourself to communicate with a Bluetooth Classic device via the BluetoothSocket object, which offers flexibility but is also error-prone at the same time.

    The Central-Peripheral Relationship in BLE

    Your Android device acting as a Central can connect to multiple peripherals (external BLE devices) simultaneously. Still, each BLE device acting as a Peripheral can typically only interact with one Central at a time. The most common behavior is that when a BLE device is connected to a Central, it’ll stop advertising as a Peripheral because it can no longer be connected to it.

    It also helps to think of the relationship between a BLE Central and Peripheral as a client-server relationship. The server (Peripheral) hosts a GATT database that provides information the client (Central) accesses via BLE. It’s important to note that your Android device can also behave as a Peripheral, but for this guide, we’ll focus on the vastly more popular scenario of it acting as a BLE Central.

    The gist of what’s possible with BLE communication can be summarized into three common BLE operations:

    • Write: The client (app) writes some bytes to a characteristic or descriptor on the server (BLE device). The server’s firmware processes the write and performs server-side operations in response. For example, a smart thermostat may have a characteristic that changes the target temperature when written to.
    • Read: The client (app) reads the value of a characteristic or descriptor on the server (BLE device) and interprets it based on a protocol established beforehand. A smart thermostat may have a characteristic whose value represents the current target temperature.
    • Notify/Indicate: The client (app) subscribes to a characteristic for Notifications or Indications and is notified by the server when the value of the characteristic changes. A smart thermostat may have a notifiable characteristic that will report changes in ambient temperature when it’s subscribed to.

    BLE in the Android SDK

    Note: As explained in our Android BLE Development Tips article, we assume the app targets a minimum of API 21 (Android 5.0) due to the availability of better BLE APIs such as BluetoothLeScanner and ScanFilter.

    We start this section by introducing the main classes from the Android SDK we’ll be using.

    Meeting the Android BLE API

    Class & Purpose
    BluetoothAdapter
    A representation of the Android device’s Bluetooth hardware. An instance of this class is provided by the BluetoothManager class. BluetoothAdapter provides information on the on/off state of the Bluetooth hardware, allows us to query for Bluetooth devices bonded to Android, and allows us to start BLE scans.
    BluetoothLeScanner
    Provided by the BluetoothAdapter class, this class allows us to start a BLE scan.
    Note: ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION is required for BLE scans starting from Android M (6.0) and above, ACCESS_FINE_LOCATION is required for Android 10 and above, and BLUETOOTH_SCAN is required for Android 12 and above.
    ScanFilter
    It allows one to narrow down scan results to target specific devices we’re looking for during a BLE scan. A typical use case for apps is to filter BLE scan results based on the BLE devices’ advertised service UUIDs.
    ScanResult
    It represents a BLE scan result obtained via BLE scan and contains information such as the BLE device’s MAC address, RSSI (signal strength), and advertisement data. The getDevice() method exposes the BluetoothDevice handle, which may contain the name of the BLE device and allows the app to connect to it.
    BluetoothDevice
    Represents a physical Bluetooth (not specifically BLE) device that the app can connect to, bond (pair) with, or both. This class provides key information, including the device name, if it’s available, its MAC address, and its current bond state.
    BluetoothGatt
    An entry point to the BLE device’s GATT profile. It allows us to perform service discovery and connection teardown, request MTU updates (more on this later), and get access to the services and characteristics that are present on the BLE device. We can think of this as a handle to an established BLE connection.
    BluetoothGattService, BluetoothGattCharacteristic and BluetoothGattDescriptor
    Wrapper classes represent GATT services, characteristics, and descriptors, as defined in the Table of Glossary earlier in this guide.
    BluetoothGattCallback
    The app must implement the main interface to receive callbacks for most BluetoothGatt-related operations like reading, writing, or getting notified about incoming Notifications or Indications.

    Quick Tips to Navigate Around the Android BLE API

    Key Takeaway: If there’s only one thing to take away from this guide, it’s that Android doesn’t like performing rapid, asynchronous, out-of-order BLE operations. There’s no internal queuing mechanism to guard against developer errors or (blissful, innocent) ignorance — we’re on our own.

    We highlighted a few common pitfalls in Android BLE development in another article, so here’s a quick summary:

    • Don’t do BLE things in a successive, rapid-fire fashion. Queue operations and execute them serially, and always wait for a callback for a previous operation before you initiate a new one. This may sound like it only matters for successive read and write operations. Still, it applies to all BLE operations, including connecting, service discovery, MTU request, and even connection teardown. Don’t worry; we’ll also guide you through implementing your queuing mechanism in this guide.
    • Try not to use reflection to call private APIs. There are a lot of examples out there dated before 2017 that likely showcase how to use private APIs to achieve a forced refresh of the local GATT database or to force an LE-type bond. As of Android 9, this is no longer recommended due to Google’s new stance on non-SDK interfaces. Unless your app has a specific use case complemented by these private APIs, a developer should steer clear from using private APIs.
    • Always assume that any operation has a nonzero probability of failing, and the app needs to be able to handle those failures. Even a simple disconnect and reconnect behind the scenes would solve most problems.

    Over the remainder of the guide, we’ll walk you through creating your own Android app that communicates with a BLE device. We aim to provide a high-level overview of how to perform BLE operations so that you can read and follow along as you code. A full example of the implementation is available in our open-source GitHub repo.

    With the basic introductions and tips out of the way, it’s time to roll up our sleeves and start coding!


    The Basics of Setting Up Your Project

    For apps that utilize the BLE APIs, we like to go with Kotlin as the main language, and the lowest minimum API level we support is 21 (Android 5.0 Lollipop). This is likely too low for most modern projects — a minimum API level of 29 (Android 10) will do just fine too. Check out our Android Architecture article for more information on why we made these choices! Finally, ensure “Use androidx.* artifacts” is checked in the project wizard. If you’re working on an existing project, ensure you’ve migrated to AndroidX because Google is moving its support libraries to the AndroidX package.

    Note: This article is up-to-date with a compileSdkVersion and targetSdkVersion of 34 (Android 14).

    Thoughts on Android Best Practices

    To showcase the bare basics of working with Android BLE APIs, we’ll be taking little shortcuts in our code snippets and example implementation to save time:

    • Strings to be shown to the user on the UI are hardcoded as string literals in code rather than extracted into a localizable strings.xml file. This is to improve code readability and make the intent easier to follow. You should be extracting these strings into your strings.xml file for your apps.
    • All code belonging to a screen is stuffed into the Activity class representing that screen. For your app, you should determine if some code can be refactored into a common helper class or delegated to another entity, like a ViewModel class.
    • Handling of edge cases and UX are kept to a bare minimum, just enough for the app to work on a happy path scenario and handle the most egregious errors. A production-level app should have more advanced error-handling flows or potentially consider educating the users on how to recover from an error.
    • We’ll call Kotlin’s error(…) function, which throws an IllegalStateException that’ll crash the app when preconditions for certain operations are unmet. In other words, we assume all pieces of information are always present when needed. In your app, you should handle errors by surfacing them to your users, remedying it yourself, or by judiciously ignoring them.

    Permissions Handling

    Declaring Required Permissions in AndroidManifest.xml

    Once you have a new project set up, or if you’ve opened your existing project, the first thing we want to do is declare a few permissions that the app will need to perform BLE tasks, if they haven’t been declared already.

    Navigate to the project’s AndroidManifest.xml file or expand app → manifests → AndroidManifest.xml via the Project tool window. The permissions we’ll be adding inside the <manifest> tag are:

    <!-- Request legacy Bluetooth permissions on versions older than API 31 (Android 12). -->
    <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_COARSE_LOCATION"
        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"
        tools:targetApi="s" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

    Here’s what each of these permissions do:

    • BLUETOOTH: Legacy permission that allows the app to connect to Bluetooth devices.
    • BLUETOOTH_ADMIN: Legacy permission that allows the app to scan for and bond with Bluetooth devices.
    • ACCESS_FINE_LOCATION: Between Android 6 and Android 11 (inclusive on both ends), location permission is required for the app to get BLE scan results. The main motivation behind having to require the users to grant this permission explicitly is to protect users’ privacy.  A BLE scan can often unintentionally reveal the user’s location to unscrupulous app developers who scan for specific BLE beacons, or some BLE devices may advertise location-specific information. Before Android 10, ACCESS_COARSE_LOCATION can be used to gain access to BLE scan results, but we recommend using ACCESS_FINE_LOCATION instead since it works for all versions of Android.
    • ACCESS_COARSE_LOCATION: Apps targeting Android 12 (API 31) and above must also request this permission in addition to ACCESS_FINE_LOCATION.
    • BLUETOOTH_SCAN: For apps targeting Android 12 and above, developers can finally request explicit permission to perform only Bluetooth scans without having to obtain location access for devices running Android 12 and above.
    • BLUETOOTH_CONNECT: For apps targeting Android 12 and above, developers can request this permission to connect to Bluetooth peripherals that are currently bonded to an Android device running Android 12 and above.

    Out of all these permissions, ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION, BLUETOOTH_SCAN, and BLUETOOTH_CONNECT are considered — in Android terms — dangerous or runtime permissions. What this means is that while some permissions are automatically granted to the app during installation time, the user needs to explicitly grant these permissions to the app from the app UI. It’s of utmost importance to stress to the user that your app won’t use their location for anything aside from ensuring BLE scanning works if that’s the case.

    If BLE hardware is a requirement for your app, you can optionally declare that your app uses BLE features on Android devices. By doing so, users on devices without BLE capabilities won’t see your app on the Google Play Store. If this behavior sounds good, add the following snippet below the <uses-permission> tags.

    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="true" />

    Requesting Runtime Permissions From the User

    One of the things we recommend you have in your Activity, ViewModel, or at least in a Context extension function, is a way to know whether the user has granted the required runtime permissions. The following Context extension functions work together to do just this:

    /**
     * Determine whether the current [Context] has been granted the relevant [Manifest.permission].
     */
    fun Context.hasPermission(permissionType: String): Boolean {
        return ContextCompat.checkSelfPermission(this, permissionType) ==
            PackageManager.PERMISSION_GRANTED
    }
    
    /**
     * Determine whether the current [Context] has been granted the relevant permissions to perform
     * Bluetooth operations depending on the mobile device's Android version.
     */
    fun Context.hasRequiredBluetoothPermissions(): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            hasPermission(Manifest.permission.BLUETOOTH_SCAN) &&
                hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
        } else {
            hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
        }
    }

    The two functions above are extension functions on the Context object, meaning each Context instance with access to these functions can call them and use them as if these functions were part of the original class declaration. Depending on how you structure your source code, these extension functions may reside in another Kotlin source file so that they can be accessed by other Activities as well.

    Now add a top-level constant declaration outside your Activity‘s class declaration — the actual value of the constant can be any positive integer you want:

    private const val PERMISSION_REQUEST_CODE = 1

    Since the runtime permissions are only needed from the moment when the user wants to perform a BLE scan, it makes sense that we should only prompt the user to grant this permission when they want to initiate a BLE scan. The following snippets assume you have a Button that’ll start scanning for BLE devices when tapped.

    In that Button’s OnClickListener, we want to call a function startBleScan(). If that’s the only thing the Button will do, your code should look something like this:

    scanButton.setOnClickListener { startBleScan() }

    startBleScan() will essentially check to see if the required runtime permissions have been granted before allowing the user to proceed with performing a BLE scan.

    private fun startBleScan() {
        if (!hasRequiredBluetoothPermissions()) {
            requestRelevantRuntimePermissions()
        } else { /* TODO: Actually perform scan */ }
    }
    
    private fun Activity.requestRelevantRuntimePermissions() {
        if (hasRequiredBluetoothPermissions()) { return }
        when {
            Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
                requestLocationPermission()
            }
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
                requestBluetoothPermissions()
            }
        }
    }
    
    private fun requestLocationPermission() = runOnUiThread {
        AlertDialog.Builder(this)
            .setTitle("Location permission required")
            .setMessage(
                "Starting from Android M (6.0), the system requires apps to be granted " +
                "location access in order to scan for BLE devices."
            )
            .setCancelable(false)
            .setPositiveButton(android.R.string.ok) { _, _ ->
                ActivityCompat.requestPermissions(
                    this,
                    arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                    PERMISSION_REQUEST_CODE
                )
            }
            .show()
    }
    
    @RequiresApi(Build.VERSION_CODES.S)
    private fun requestBluetoothPermissions() = runOnUiThread {
        AlertDialog.Builder(this)
            .setTitle("Bluetooth permission required")
            .setMessage(
                "Starting from Android 12, the system requires apps to be granted " +
                    "Bluetooth access in order to scan for and connect to BLE devices."
            )
            .setCancelable(false)
            .setPositiveButton(android.R.string.ok) { _, _ ->
                ActivityCompat.requestPermissions(
                    this,
                    arrayOf(
                        Manifest.permission.BLUETOOTH_SCAN,
                        Manifest.permission.BLUETOOTH_CONNECT
                    ),
                    PERMISSION_REQUEST_CODE
                )
            }
            .show()
        }
    }

    startBleScan() will call requestRelevantRuntimePermissions() if we don’t yet have the relevant runtime permissions — this API-dependent check is encapsulated within hasRequiredBluetoothPermissions(), an extension function that we shared earlier.

    If at least one runtime permission is missing, an alert will then be shown to inform the user of the need to grant the missing runtime permission(s) and giving them the sole option of “OK,” which will trigger a system dialog prompting the user to grant the relevant permission(s) to the app.

    We’ll now need to do the same thing as before and react to the user’s actions, this time by overriding the onRequestPermissionsResult() function on ActivityCompat to handle the case when the requestCode is equal to PERMISSION_REQUEST_CODE:

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode != PERMISSION_REQUEST_CODE) return
    
        val containsPermanentDenial = permissions.zip(grantResults.toTypedArray()).any {
            it.second == PackageManager.PERMISSION_DENIED &&
                !ActivityCompat.shouldShowRequestPermissionRationale(this, it.first)
        }
        val containsDenial = grantResults.any { it == PackageManager.PERMISSION_DENIED }
        val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
        when {
            containsPermanentDenial -> {
                // TODO: Handle permanent denial (e.g., show AlertDialog with justification)
                // Note: The user will need to navigate to App Settings and manually grant
                // permissions that were permanently denied
            }
            containsDenial -> {
                requestRelevantRuntimePermissions()
            }
            allGranted && hasRequiredBluetoothPermissions() -> {
                startBleScan()
            }
            else -> {
                // Unexpected scenario encountered when handling permissions
                recreate()
            }
        }
    }

    If the user agrees to grant the app with the runtime permissions that we’ve requested, we’re good to go and can start scanning for BLE devices; otherwise, we’ll keep asking them to grant those permissions if they have yet to permanently deny the request by checking the “Don’t ask again” box.

    Note on Android 11 and newer: A repeated denial is implicitly treated as a permanent denial by Android, and the app can no longer prompt the user to grant those permissions. In this case, developers should have proper UX explaining why the permissions are needed and how to remedy this situation by directing the users to App Settings so they can manually grant those denied permissions. A common way of doing so is by launching an Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) and by setting the Intent‘s data to a specific URI which describes the app’s package name, e.g., package:com.punchthrough.lightblueandroid.

    Again, a well-designed app should have a more graceful way of handling the various rejection scenarios, but we’ll leave that as an exercise for the reader. 

    Onwards!

    At this point, we’d like you to once again build and run the app on a physical Android device. If your Android device is running Android 6 – 11 (inclusive on both ends of the range), you should see a location access prompt appear as you tap “Start Scan”; if your device is running Android 12 or newer, you should see a nearby Bluetooth devices access prompt appear instead. Verify that the deny option will cause the permission request prompt to reappear, after which you should grant the requested permissions to avoid getting into the permanent denial execution branch.

    For the sake of brevity, we’ll assume these runtime permissions have been granted for all the following sections.

    Making Sure That Bluetooth is Enabled

    If Bluetooth being enabled is crucial to your app’s core functionality, you’ll want to make sure that your users keep Bluetooth enabled while they’re using your app. Fortunately, Android provides an Intent action that can prompt a user to turn on Bluetooth on their device. The only caveat is that if your app targets Android 12 or higher, the new BLUETOOTH_CONNECT permission must be granted before you can use this Intent action.

    In our example below, we want to check in the Activity’s onResume() if Bluetooth is enabled; if it’s not, we display an alert:

    private val bluetoothAdapter: BluetoothAdapter by lazy {
        val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothManager.adapter
    }
    
    private val bluetoothEnablingResult = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == Activity.RESULT_OK) {
            // Bluetooth is enabled, good to go
        } else {
            // User dismissed or denied Bluetooth prompt
            promptEnableBluetooth()
        }
    }
    
    override fun onResume() {
        super.onResume()
        if (!bluetoothAdapter.isEnabled) {
            promptEnableBluetooth()
        }
    }
    
    /**
     * Prompts the user to enable Bluetooth via a system dialog.
     *
     * For Android 12+, [Manifest.permission.BLUETOOTH_CONNECT] is required to use
     * the [BluetoothAdapter.ACTION_REQUEST_ENABLE] intent.
     */
    private fun promptEnableBluetooth() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
            !hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
        ) {
            // Insufficient permission to prompt for Bluetooth enabling
            return
        }
        if (!bluetoothAdapter.isEnabled) {
            Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE).apply {
                bluetoothEnablingResult.launch(this)
            }
        }
    }

    When your first Activity is about to show with these additions, we check if the BluetoothAdapter is enabled. If it’s not, we display a system alert utilizing BluetoothAdapter. ACTION_REQUEST_ENABLE to request that the user enable Bluetooth on their Android device. Remember that you need the BLUETOOTH_CONNECT permission before you can launch this Intent on Android 12 and above!

    At this point, take the time to build and run the app on a physical Android device. We’re using an Android physical device because simulators don’t yet come with Bluetooth support at the time of writing. You’ll notice that if you disable Bluetooth and get to your Activity, a system dialog prompts you to enable Bluetooth. If you dismiss the dialog, the app attempts to keep displaying the same alert over and over again until the user accepts our recommendation to turn on Bluetooth.

    In a production app, there’ll likely be some kind of UX around this to educate the users on why they should enable Bluetooth. Instead of the very crude example of calling promptEnableBluetooth(), you can either display a new informational alert that’ll later call into your function to display the Bluetooth-enabling system alert again or kick-start some other fancy user education flow.

    The Android Studio “MissingPermission” Warning

    The code samples in the following sections assume that they’ll only ever be called by the app code when the user has already granted all the relevant runtime permissions.

    For the purposes of running our sample code, if you’re encapsulating all the BLE scanning and connecting logic in one or two main classes, you may want to add a @SuppressLint for the specific “MissingPermission” warning that Android Studio may emit for classes that utilize the Android Bluetooth APIs without an explicit runtime permission check surrounding these usages.

    @SuppressLint("MissingPermission") // App's role to ensure permissions are available
    class BluetoothManager { ... }

    Performing a BLE Scan

    Unless the BluetoothDevice handle was cached from a recent scan or the BLE device itself is bonded or connected with the system already, we’d usually need to perform a BLE scan before we can connect to a BLE device. A typical BLE app connection setup flow looks like this:

    1. Perform a BLE scan with optional scan results filtering.
    2. Obtain a ScanResult object matching the BLE device of interest.
    3. Call the ScanResult object’s getDevice() method to obtain the underlying BluetoothDevice object.
    4. Call the BluetoothDevice object’s connectGatt() method to initiate the BLE connection process.

    A Primer to BLE Scanning on Android

    Scan Results Filtering

    Before we scan for surrounding devices, it helps to ask if the devices we’re interested in have distinctive traits that’ll help the Android BLE scanner narrow down scan results to only the ones we care about. The ScanFilter class allows us to filter incoming ScanResults based on:

    • Advertised service UUIDs
    • Service data for the advertised services
    • Name of the advertising BLE device
    • MAC address of the advertising BLE device
    • Additional manufacturer-specific data

    While some apps do have use cases for scanning without a ScanFilter (our very own LightBlue® app is one of them), most apps use BLE to connect to a specific type of device because they are designed to perform certain meaningful tasks with only that type of device. For example, an app that shows the current temperature, humidity, and air pressure would only try to connect to BLE devices with those capabilities, like devices that advertise the Environmental Sensing Service.

    We can create a ScanFilter using the ScanFilter.Builder class and calling its methods to set the filtering criteria before finally calling build(), for example:

    val filter = ScanFilter.Builder().setServiceUuid(
        ParcelUuid.fromString(ENVIRONMENTAL_SERVICE_UUID.toString())
    ).build()

    Based on our experience developing BLE-intensive apps for our clients, if a custom firmware is involved in any way, the easiest way to make sure an app only ever picks up devices running said custom firmware is to generate a random UUID and have the firmware advertise this service UUID. The app then performs scan filtering based on this UUID, and since UUIDs are unique enough for practical purposes, we can expect the scan only to pick up our devices of interest.

    Parsing and Understanding Scan Results

    A ScanResult object gets surfaced as part of ScanCallback’s onScanResult(...) method, and generally the things we care about in a ScanResult are:

    • The MAC address of the device that identifies the advertising scan results.
      • Obtained via getDevice() followed by getAddress(), or simply device.address in Kotlin.
      • Warning: a device implementing Bluetooth 4.2’s LE Privacy feature will randomize its public MAC address periodically, so a MAC address obtained via scanning should not generally be used as a long-term means to identify a device — unless the firmware guarantees that it’s not rotating MAC addresses, or if it has an out-of-band way to communicate what it’s current public MAC address is, or if the use case involves bonding, which would allow Android to derive the latest/current MAC address of the device.
    • The name of the device that we can show to the user.
      • Obtained via getDevice() followed by getName(), or simply device.name in Kotlin. Not all BLE devices advertise the device name, so some BLE devices’ names may be null.
    • The RSSI or signal strength of the advertising BLE device, measured in dBm.
      • Obtained via getRssi(), or simply rssi in Kotlin.
      • Sorting scan results by descending order of signal strength is a good way to find the peripheral closest to the Android device, but it’s not a 100% guarantee because RSSI can be affected by the transmission power of the advertising device’s antenna, and other physical factors such as the presence of metallic objects around the Android or BLE device.
      • The decibel values are oftentimes relative and not based on an absolute scale. This means that an RSSI reading of -42 dBm can be “close range” for one Android phone but “medium range” for another. It’s generally not recommended to universally map RSSI readings to real-world physical distances.
    • The BluetoothDevice handle that we need in order to connect to the device, accessed via the getDevice() method.
    • Extra advertisement data in the ScanResult’s ScanRecord, accessed via getScanRecord().
      • ScanRecord conveniently parses out any manufacturer specific data and service data from the scan record and these can be accessed using the getManufacturerSpecificData(...) and getServiceData(...) methods.
      • The raw scan record bytes can be accessed using the getBytes() method.

    Specifying Scan Settings

    Aside from scan results filtering, Android also allows us to specify the scan settings to use during scanning, represented by the ScanSettings class, which also comes with its own ScanSettings.Builder builder class. Here are a few practical and commonly used scan settings that Android allows us to tweak:

    • Specify the desired BLE scan mode, from low-powered high-latency scans to high-powered low-latency scans.
      • Most apps scanning in the foreground should use SCAN_MODE_BALANCED for scans that will last longer than 30 seconds.
      • SCAN_MODE_LOW_LATENCY is recommended if the app only scans for a brief period, typically to find a very specific type of device.
      • SCAN_MODE_LOW_POWER is used for extremely long-duration scans or scans that occur in the background (with the user’s permission). Note that the high latency nature of this low-power scan mode may result in missed advertisement packets if the device you’re scanning for has a high enough advertising interval that it doesn’t overlap with the app’s scan frequency.
    • Specify the callback type for the BLE advertisement packets that were encountered.
      • Apps like LightBlue that require continuous updating of incoming advertisement packets should use CALLBACK_TYPE_ALL_MATCHES to get notified about all incoming packets. This is the default setting if an app doesn’t specify the desired callback type.
      • CALLBACK_TYPE_FIRST_MATCH is used if an app is only concerned about getting a single callback for each device that matches the filter criteria specified by ScanFilter (or all devices in the vicinity if a ScanFilter wasn’t specified). 
      • CALLBACK_TYPE_MATCH_LOST is a bit of an odd one — we’ve had mixed experience using this and don’t recommend it in general. You’re typically better off implementing a timer yourself that periodically goes through your list of ScanResults and removing outdated ones (e.g., haven’t been encountered in the last 10 seconds or so) based on ScanResult’s getTimestampNanos() method, but note that this method provides a timestamp since Android’s system boot time and not the time since Epoch time.
    • Specify the threshold at which an advertisement packet sighting should be surfaced as a scan result.
      • MATCH_MODE_STICKY is useful in filtering out advertising BLE devices that are too far away from the Android device since it requires a higher threshold of signal strength and number of sightings before that BLE device is surfaced as a scan result to our app.
      • MATCH_MODE_AGGRESSIVE is the opposite of MATCH_MODE_STICKY and will show you every device advertising in the range of the BLE scanner, near or far.

    In the Implementing BLE scanning section, we’ll show you a simple example of a low-latency ScanSetting setup.

    Notable Scan Errors

    The most common error you’ll see while performing BLE scans is the undocumented “App is scanning too frequently” error. Android has an internal limit of five startScan(…) method calls every 30 seconds per app on a BluetoothLeScanner object, and going beyond that doesn’t trigger any error callbacks — merely an obscure entry in Logcat saying your app is scanning too frequently. What’s more bizarre is that you won’t normally see this Logcat entry unless you don’t have any filtering active in your Logcat window because that entry has an unknown package name of “?” associated with it.

    If your app exceeded this limit, the scan would appear to have started, but no scan results would be delivered to your callback body. After 30 seconds have elapsed, your app should call stopScan() followed by startScan(...) again to start receiving scan results — the old startScan(...) method call that resulted in the error doesn’t automatically start receiving scan results again once the 30-second cooldown timer is complete. Realistically, most apps won’t exceed this internal limit, but this is an error you should watch out for. Perhaps you should warn your users about whether your code or your users can potentially start and stop BLE scans repeatedly.

    Another obscure error that you may run into is the SCAN_FAILED_APPLICATION_REGISTRATION_FAILED error. This error is surfaced by ScanCallback’s onScanFailed(...) method and is vaguely described as the app failing to register with the BLE scanner. The undocumented solution is to request that the user disable and re-enable Bluetooth on their device. If this fails, which it mostly does, the next and only thing to do is ask your users to perform an Android reboot. Oh, joy!

    Implementing BLE scanning

    The eagle-eyed among you will notice that our startBleScan() function from the previous section on requesting location permission from the user doesn’t do much other than ensure that the user has granted the location permission. This section will show you how to scan for surrounding BLE devices.

    First, we’ll add a lazy property called bleScanner:

    private val bleScanner by lazy {
        bluetoothAdapter.bluetoothLeScanner
    }
    // From the previous section:
    private val bluetoothAdapter: BluetoothAdapter by lazy {
        val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothManager.adapter
    }

    The bleScanner property is a val property whose value is set lazily — it’s initialized only when needed. The reason why we didn’t declare it as just bluetoothAdapter.bluetoothLeScanner is because bluetoothAdapter — itself a lazy property — relies on the getSystemService() function, which is only available after onCreate() has already been called for our Activity

    By deferring the initialization of the BluetoothAdapter and also the bleScanner when we need them, we avoid a crash that would happen if the BluetoothAdapter was initialized before onCreate() returned.

    Before we can start scanning, we need to specify the scan settings. You can tweak yours accordingly based on what we discussed in the Specifying Scan Settings section, but here’s a simple one we’ll add as a property in our Activity for this example:

    private val scanSettings = ScanSettings.Builder()
        .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
        .build()

    Next, we need to create an object that implements the functions in ScanCallback so that we’ll be notified when a scan result is available:

    private val scanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            with(result.device) {
                Log.i(“ScanCallback”, "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
            }
        }
    }

    With all the pieces in play, we can finally modify our startBleScan() function from earlier to actually perform a scan:

    private fun startBleScan() {
        if (!hasRequiredRuntimePermissions()) {
            requestRelevantRuntimePermissions()
        } else {
            bleScanner.startScan(null, scanSettings, scanCallback)
        }
    }

    Run and build the app on your physical Android device, and tap on “Start Scan” — you should start seeing scan results come in rapidly in the Logcat window if you’re surrounded by some BLE devices that are actively advertising.

    Stopping a BLE Scan

    To stop an ongoing BLE scan, BluetoothLeScanner provides a stopScan() method.

    Building on our example up to this point, we can add a isScanning property that’ll inform us of the state of the BluetoothLeScanner, seeing as the scanner’s status isn’t publicly exposed.

    private var isScanning = false
        set(value) {
            field = value
            runOnUiThread { scanButton.text = if (value) "Stop Scan" else "Start Scan" }
        }

    We also override its setter so that whenever the field’s value changes, we update the scan button text to be the opposite of the scan status. The user will tap on the button to stop a scan if it’s ongoing, and they’ll want to start a scan if it’s not already going.

    Then, we’ll update our startBleScan() function to set isScanning to true when a scan is started, and also implement a stopBleScan() function:

    private fun startBleScan() {
        if (!hasRequiredBluetoothPermissions()) {
            requestRelevantRuntimePermissions()
        } else {
            bleScanner.startScan(null, scanSettings, scanCallback)
            isScanning = true
        }
    }
    
    private fun stopBleScan() {
        bleScanner.stopScan(scanCallback)
        isScanning = false
    }

    Now that we’re aware of the state of the BLE scanner and have the means to start and stop a scan, we can update the scan button’s OnClickListener to perform the right operation for a given isScanning value.

    scanButton.setOnClickListener {
        if (isScanning) {
            stopBleScan()
        } else {
            startBleScan()
        }
    }

    There, done and done! Run the app once again, and verify that your app now has the ability to start and stop a BLE scan.

    Surfacing Scan Results to the User

    So far, we’ve only been able to look at Logcat to figure out what BLE devices are nearby. That’s not very interesting now, is it? In this section, we’ll update our app to display these advertising BLE devices on the UI using a RecyclerView. This Android component allows you to display a collection of items in a scrollable interface. Alternatively, you may choose to use a LazyColumn to accomplish the same thing if your project uses Jetpack Compose.

    If you choose to proceed with a RecyclerView, you’ll want to implement your RecyclerView.Adapter class and your layout XML for each scan result. To keep this guide focused on the BLE aspect of things, we won’t go into too much detail here. Instead, you can find our example implementation in our open-source GitHub Repo. We’ll continue this section assuming your adapter class has been written similarly to ours and you’ve already set up your RecyclerView accordingly in your Activity.

    Back in our Activity, we start by creating a property scanResults that’ll hold our BLE scan results and a scanResultAdapter that represents the adapter class that we just implemented. Note the TODO in the lambda expression. That’s where we’ll implement our OnClickListener when a row item representing a scan result is tapped on, which we’ll get to in a bit.

    private val scanResults = mutableListOf<ScanResult>()
    private val scanResultAdapter: ScanResultAdapter by lazy {
        ScanResultAdapter(scanResults) {
            // TODO: Implement
        }
    }

    Since we now maintain a list of scan results, it’s good practice to clear the list of its contents before we start each BLE scan. Update your startBleScan() function such that whenever a scan is started, we clear the data backing the RecyclerView because they’re now considered out of date:

    private fun startBleScan() {
        if (!hasRequiredBluetoothPermissions()) {
            requestRelevantRuntimePermissions()
        } else {
            scanResults.clear()
            scanResultAdapter.notifyDataSetChanged()
            bleScanner.startScan(null, scanSettings, scanCallback)
            isScanning = true
        }
    }

    Next, we want to correctly populate our scanResults property whenever a new BLE scan result comes in. We’ll modify our scanCallback property’s onScanResult callback and also implement the onScanFailed method so any errors will get logged to Logcat:

    private val scanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            val indexQuery = scanResults.indexOfFirst { it.device.address == result.device.address }
            if (indexQuery != -1) { // A scan result already exists with the same address
                scanResults[indexQuery] = result
                scanResultAdapter.notifyItemChanged(indexQuery)
            } else {
                with (result.device) {
                    Log.i("ScanCallback", "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
                }
                scanResults.add(result)
                scanResultAdapter.notifyItemInserted(scanResults.size - 1)
            }
        }
        override fun onScanFailed(errorCode: Int) {
            Log.e("ScanCallback", "onScanFailed: code $errorCode")
        }
    }

    Since we didn’t explicitly specify CALLBACK_TYPE_FIRST_MATCH as the callback type under our ScanSettings, our onScanResult callback is flooded by scanResult belonging to the same set of devices, but with varying signal strength (RSSI) readings. To keep the UI updated with the latest RSSI readings, we first check to see if our scanResult List already has a scan result whose MAC address is identical to the new incoming scanResult. If so, we replace the older entry with the newer one. If this is a new scan result, we’ll add it to our scanResult List. For both cases, we’ll inform our scanResultAdapter of the updated item so that our RecyclerView can be updated accordingly.

    Take some time to build and run the app on your Android device, and you should see your RecyclerView being populated by scan results representing BLE devices in your vicinity. They should update themselves every time a new scan result comes in (easily verified if you show the scan results’ RSSI as part of your row item layout).


    Connecting to a BLE Device

    Overview of Initiating a BLE Connection on Android

    The typical flow for initiating a BLE connection in apps can be broken down into roughly two types:

    • Automatic Connection: The app connects autonomously to a device from returned scan results based on specific heuristics, e.g., we’re scanning for devices advertising certain private service UUIDs, and there is only one such device after scanning for a few seconds on low latency mode.
    • Manual Connection: The user peruses the list of scan results and manually selects a device to connect to.

    Our example for this section assumes a use case of manual connection. Regardless of which use case your app falls under, you should always stop your BLE scan before connecting to a BLE device. Doing so saves power and, more importantly — in our experience — also helps increase the reliability of the connection process.

    The connectGatt() Method and its autoConnect Argument

    The connectGatt() method on a BluetoothDevice handle will initiate a connection with a BLE device. There are multiple overloads for connectGatt(), but the one we’ll be using has this signature:

    public BluetoothGatt connectGatt(
        Context context, 
        boolean autoConnect, 
        BluetoothGattCallback callback
    )

    The reason is that this version of connectGatt(...) is available as early as API level 18, and we intend to work with a minimum API level of 21 (Android 5.0 Lollipop), as explained in our article, 4 Tips to Make Android BLE Actually Work. The other overloads are available only from API levels 23 or 26.

    If you have the luxury of only supporting API level 23 and above, you may opt to use this connectGatt(...) overload with an int transport argument in it, which allows you to specify the transport as BluetoothDevice.TRANSPORT_LE.

    All variants of this method take in an autoConnect boolean among their arguments that can be misleading to newcomers — setting this flag to true doesn’t cause Android to automatically try to reconnect to the BluetoothDevice in the event of a connection! Instead, setting the flag to true simply causes the connect operation to not timeout, which can be helpful if you’re trying to connect to a BluetoothDevice you had cached from earlier.

    In our experience, setting the flag to true may result in a slower-than-usual connection process even if the device is already discoverable in the immediate vicinity. When developing BLE apps, we prefer to set the flag to false and instead rely on the onConnectionStateChange callback to inform us of whether the connection process succeeded. In the event of a connection failure, we can simply try to connect again with autoConnect set to false.

    All connectGatt(...) variants return a BluetoothGatt object, which can be thought of as a handle on the BLE connection we’re establishing, allowing us to initiate read and write operations. However, we typically don’t keep a reference to this returned BluetoothGatt, and instead only keep a reference to the one that’s given to us as an argument for BluetoothGattCallback callback methods.

    Implementing Callbacks in BluetoothGattCallback

    The connectGatt() method mentioned previously also takes in an object called BluetoothGattCallback. It’s an abstract class with methods we have to override to get notified about BluetoothGatt-related callbacks. The methods and what they do are well-documented, so we’ll leave the explanation to the official documentation.

    The main callback method we need to implement at this stage is onConnectionStateChange(), which provides crucial information on the status of the BLE connection. This is the source of truth for connection and disconnection events pertaining to a given BLE connection.

    Identifying a Successful Connection Attempt

    A successful connection attempt will see the onConnectionStateChange() callback happen with its status parameter set to GATT_SUCCESS and newState parameter set to BluetoothProfile.STATE_CONNECTED. At this point, it’s crucial to store a reference to the BluetoothGatt object provided by this callback. This will be the main interface through which we issue commands to the BLE device.

    Handling Common Error Scenarios While Connecting

    Errors while establishing a BLE connection are surfaced via BluetoothGattCallback’s onConnectionStateChange() method as well. A typical error-checking flow checks to see if onConnectionStateChange()’s status parameter is GATT_SUCCESS. If it’s not, we have an error on our hands represented by the status parameter.

    Some of the error status codes are documented as BluetoothGatt’s public constants, but the single most common error we’ve encountered while attempting to connect to a BluetoothDevice has to be the infamous status 133 error. Not only is this error undocumented under the BluetoothGatt list of constants, but a look inside the Android source code reveals its name as simply GATT_ERROR (0x85, which is hex for 133). You’re not alone if you think this is super vague. We strongly believe Google can do better here.

    Aside from the random occurrences of 133, we’ve generally seen error 133 happen most frequently in one of the two following scenarios:

    • The BluetoothDevice we’re trying to connect to is no longer advertising or within Bluetooth range, and the connectGatt() call has timed out after about 30 seconds (OEM-dependent value — we’ve seen Samsung devices time out after 10 seconds) of trying to connect to it with the autoConnect argument set to false.
    • The firmware running on the BLE device has rejected the connection attempt by Android.

    Regardless of which error status code you got in your onConnectionStateChange() callback, the recovery flow typically goes like this:

    • Call close() on the BluetoothGatt object to indicate that we’re done with this BluetoothGatt instance and that the system can release any pending resources.
    • Null out any references to this BluetoothGatt object.
    • If the status code is either GATT_INSUFFICIENT_ENCRYPTION or GATT_INSUFFICIENT_AUTHENTICATION, Call createBond() first and wait for the bonding process to succeed before calling connectGatt() again. In a later section, we’ll go through how to initiate a bond with a BLE device.
    • For other status codes: Either surface an error to the user or attempt to reconnect again silently for a few times before giving up. The latter is a viable strategy, considering that error 133 can sometimes be very random and occur a few times in a row before the connection attempt eventually succeeds.

    Implementing a BLE Connect Flow

    Since we’re implementing a flow where the user is responsible for telling us which device he or she is interested in connecting to, we can update our ScanResultsAdapter from before. The following code gets called when the user taps on a BLE scan result displayed in the RecyclerView:

    private val scanResultAdapter: ScanResultAdapter by lazy {
        ScanResultAdapter(scanResults) { result ->
            // User tapped on a scan result
            if (isScanning) {
                stopBleScan()
            }
            with(result.device) {
                Log.w("ScanResultAdapter", "Connecting to $address")
                connectGatt(context, false, gattCallback)
            }
        }
    }
    

    These additions should be straightforward: we want to stop the BLE scan if it’s ongoing, and we call connectGatt() on the relevant scanResult’s BluetoothDevice handle, passing in a BluetoothGattCallback object that is defined as:

    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            val deviceAddress = gatt.device.address
            if (status == BluetoothGatt.GATT_SUCCESS) {
                if (newState == BluetoothProfile.STATE_CONNECTED) {
                    Log.w("BluetoothGattCallback", "Successfully connected to $deviceAddress")
                    // TODO: Store a reference to BluetoothGatt
                } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                    Log.w("BluetoothGattCallback", "Successfully disconnected from $deviceAddress")
                    gatt.close()
                }
            } else {
                Log.w("BluetoothGattCallback", "Error $status encountered for $deviceAddress! Disconnecting...")
                gatt.close()
            }
        }
    }

    Note the TODO in the success branch, we want to store a reference to the BluetoothGatt object here. As mentioned earlier, it’s the gateway to other BLE operations, such as service discovery, reading and writing data, and even performing a connection teardown. Run your app, start a BLE scan, and tap on a scan result. Check Logcat to see the outcome of the connection attempt and optionally retry if it failed the first few times.


    Discovering Services

    If you made it this far, congratulations. Your app can now connect to BLE devices! Now that we’re connected to a device, we realize there’s not a whole lot of interesting things we can do at the moment. It’s essential to then “walk the GATT table,” as we like to call it, to discover the services and characteristics available on a BLE device. 

    Service discovery is essential after establishing a BLE connection with a device. It allows us to explore what the device is capable of, and it’s a bit like going to a mall for the first time and looking at its floor directory. Since a service is a collection of characteristics, and each characteristic can have descriptors defining its attributes, we can think of each service as a floor or level in the mall, a characteristic as an individual store on a given level, and a descriptor as specific aisles or sections in a given store.

    Performing service discovery is straightforward. Using the BluetoothGatt object we saved from a successful connection attempt, we simply call discoverServices() on it:

    gatt.discoverServices()

    As weird as it sounds, we highly recommend calling discoverServices() from the main/UI thread to prevent a rare threading issue from causing a deadlock situation where the app can be left waiting for the onServicesDiscovered() callback that somehow got dropped. This issue happens very rarely and may have been fixed in recent Android versions, but since we’re supporting Android 5.0 and up, this precautionary workaround is worth it just for the peace of mind.

    After calling discoverServices(), the outcome of service discovery will be delivered via BluetoothGattCallback’s onServicesDiscovered() method, which we should override in our BluetoothGattCallback callback body to ensure we’re notified of the event. After the onServicesDiscovered() callback has been delivered, we can safely go through the available services and characteristics using BluetoothGatt’s getServices() method.

    Here’s a quick example of an extension function that prints out all the UUIDs of available services and characteristics that the BluetoothGatt of a BLE device has to offer:

    private fun BluetoothGatt.printGattTable() {
        if (services.isEmpty()) {
            Log.i("printGattTable", "No service and characteristic available, call discoverServices() first?")
            return
        }
        services.forEach { service ->
            val characteristicsTable = service.characteristics.joinToString(
                separator = "\n|--",
                prefix = "|--"
            ) { it.uuid.toString() }
            Log.i("printGattTable", "\nService ${service.uuid}\nCharacteristics:\n$characteristicsTable"
            )
        }
    }

    Service Discovery as Part of the Connection Flow

    Since service discovery is an essential operation to perform after a BLE connection has been established, it’s often ideal to consider it as part of the connection flow. Consider the following modifications to our BluetoothGattCallback property:

    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            val deviceAddress = gatt.device.address
            if (status == BluetoothGatt.GATT_SUCCESS) {
                if (newState == BluetoothProfile.STATE_CONNECTED) {
                    Timber.w("BluetoothGattCallback", "Successfully connected to $deviceAddress")
                    bluetoothGatt = gatt
                    Handler(Looper.getMainLooper()).post {
                        bluetoothGatt?.discoverServices()
                    }
                } else if (...) { /* Omitted for brevity */ }
            } else { /* Omitted for brevity */ }
        }
    
        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            with(gatt) {
                Log.w("BluetoothGattCallback", "Discovered ${services.size} services for ${device.address}")
                printGattTable() // See implementation just above this section
                // Consider connection setup as complete here
            }
        }
    }

    With this change, each successful connection will automatically result in service discovery on the BluetoothGatt object before the connection setup is considered complete. This is especially important if you’re creating a ConnectionManager type class that abstracts away all BLE implementation details from the Activity classes because the Activity classes don’t need to know about service discovery. They want to call some connect() function and be done! Also, note that service discovery is being performed on the main thread.

    A Note on Services’ and Characteristics’ UUIDs

    You may have noticed that the official Bluetooth website lists the UUIDs of GATT services and characteristics as 16-bit (2-byte) values (e.g., 0x180F for the Battery Service). If we try to create an instance of java.util.UUID using just the 16-bit values:

    val batteryServiceUuid = UUID.fromString("180F")

    We would very quickly see a crash when batteryServiceUuid gets instantiated:

    java.lang.IllegalArgumentException: Invalid UUID string: 180F

    When dealing with UUIDs on Android, it’s important to know that the 16-bit values are supposed to be used together with a base UUID of 00000000-0000-1000-8000-00805f9b34fb, as detailed (rather obscurely, we think) on the Service Discovery page of the official Bluetooth website, so the fully-qualified UUID for the Battery Service is:

    val batteryServiceUuid = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")

    Requesting for a Larger ATT MTU

    Before we dive into performing read and write operations, we should make sure the data we’re sending or receiving will fit well inside the connection’s ATT Maximum Transmission Unit (MTU). The maximum length of an ATT data packet is determined by the ATT MTU, which is typically negotiated between Android and the BLE device as part of the connection process. We have an article that you can read if you’re interested in learning more about ATT MTU and how it affects BLE throughput.

    The negotiated ATT MTU at the beginning of a BLE connection differs between combinations of Android devices (across different versions and OEMs) and BLE devices, so it’s prudent to not make any assumptions with regard to this. However, if we intend to exchange larger chunks of data (typically more than 20 bytes of application data from Android) with a BLE device, we should request a larger ATT MTU so that more information can be exchanged per transmission.

    This can be done easily enough on Android using BluetoothGatt’s requestMtu() method. A viable strategy is to try for the largest possible ATT MTU value upon connecting to a device, and the gatt_api.h header file in the Android source code reveals that the maximum possible MTU size that can be requested by Android, as detailed by the constant GATT_MAX_MTU_SIZE is 517.

    // Top level declaration
    private const val GATT_MAX_MTU_SIZE = 517
     
    // Call site
    gatt.requestMtu(GATT_MAX_MTU_SIZE)

    Starting from Android 14, even if your app doesn’t target API 34, Android automatically negotiates for an ATT MTU of 517 (the maximum value) when an app that is acting as a GATT client invokes the requestMtu method on a BLE connection — even if a different value was provided in the method call — and subsequent requestMtu method invocations are ignored. This makes everyone’s lives easier, especially for a BLE device use case involving multiple apps connecting to that same device, because version 5.2 of the Bluetooth Core Specification explicitly states, “Once negotiated, however, [the ATT MTU] cannot be changed during this connection.”

    After requesting a certain ATT MTU value, a callback onMtuChanged() (unsurprisingly, part of BluetoothGattCallback) should be delivered that informs the app if the request was successful or not and what the current ATT MTU is. It’s common to get a final ATT MTU value different from what was originally requested because the final value has to be negotiated between Android and the BLE device’s firmware.

    override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
        Log.w("BluetoothGattCallback", "ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}")
    }

    Unfortunately, we’ve also seen the onMtuChanged() callback not getting delivered sometimes when working with closed-source firmware, which can put a damper on things if the app is relying on the callback being delivered before proceeding with something. Our advice is to always assume the worst case — that the ATT MTU is at its minimum value of 23 — and to plan around that when working with closed-source firmware, with any successful onMtuChanged() calls being considered as added bonuses.

    On BLE device firmware that doesn’t support higher ATT MTUs, the negotiated ATT MTU after each requestMtu(...) call will likely remain unchanged at the minimum value of 23. Factoring in the 3 bytes of ATT headers for most read, write, and indication/notification operations (opcode and attribute handle), the maximum number of application bytes we can send in this case is 20 bytes. If our app performs a write that exceeds the connection’s ATT MTU, the write will fail, and our onCharacteristicWrite(...) callback will present a status parameter of GATT_INVALID_ATTRIBUTE_LENGTH.


    Performing a Read or Write Operation

    Now that we know what services and characteristics are available, and we’re certain that whatever needs to be transmitted will fit within the ATT MTU of our connection, we can proceed to learn more about the properties of each characteristic and perform read and write operations on characteristics that support them.

    Refrain From Interacting With Restricted Services/Characteristics

    If you’re implementing a BLE app like LightBlue® that intends to work with all sorts of BLE devices, there’s a nice little surprise that may sneak up behind you and crash your app in the field — Android considers certain services and characteristics to be “restricted” and interacting with them in any form will result in a java.lang.SecurityException.

    As of the time of writing, looking at GattService.java, Android restricts interactions with characteristics and services that fall under the buckets below:

    • Human Interface Device (HID) service: 0x1812
      • HID Information characteristic: 0x2A4A
      • HID Report Map: 0x2A4B
      • HID Control Point: 0x2A4C
      • HID Report: 0x2A4D
    • FIDO Authentication Service: 0xFFFD
    • Android TV Remote Service: AB5E0001-5A21-4F05-BC7D-AF01F617B664
    • LE Audio services:
      • Volume Control Service: 0x1844
      • Volume Offset Control service: 0x1845
      • Audio Input Control service: 0x1843
      • Published Audio Capabilities service: 0x1850
      • Audio Stream Control service: 0x184E
      • Broadcast Audio Scan service: 0x184F
      • Hearing Access service: 0x1854
      • Coordinated Set Identification service: 0x1846

    Parsing BluetoothGattCharacteristic Properties

    Before we can perform read or write operations on a BluetoothGattCharacteristic, we need to make sure they’re readable or writable. Otherwise, the operation would fail with a GATT_READ_NOT_PERMITTED or GATT_WRITE_NOT_PERMITTED error (both constants being part of BluetoothGatt).

    BluetoothGattCharacteristic contains a getProperties() method that is a bitmask of its properties as represented by the BluetoothGattCharacteristic.PROPERTY_* constants. We can then perform a bitwise AND between getProperties() and a given PROPERTY_* constant to figure out if a certain property is present on the characteristic or not.

    Right now, the only constants we’re concerned with are PROPERTY_READ, PROPERTY_WRITE, and PROPERTY_WRITE_NO_RESPONSE. We’ll use the following extension functions to determine if a characteristic is readable, writable, or both:

    fun BluetoothGattCharacteristic.isReadable(): Boolean =
        containsProperty(BluetoothGattCharacteristic.PROPERTY_READ)
    
    fun BluetoothGattCharacteristic.isWritable(): Boolean =
        containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE)
    
    fun BluetoothGattCharacteristic.isWritableWithoutResponse(): Boolean =
        containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)
    
    fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean {
        return properties and property != 0
    }

    Reading From a Characteristic

    Reading the value of a characteristic is probably the more common operation compared to writing a value to it. It allows us to make use of information that the BLE device has to offer, in line with our expectations of the server-client interaction where the server (BLE Peripheral) performs some work and offers up information to the client (BLE Central). For example, an IoT sensor that measures environmental readings may also have its battery level available as part of the Battery Service, which contains the Battery Level characteristic we can read from.

    After performing a read on a readable characteristic, we’ll be presented with an array of bytes (a ByteArray in Kotlin) that represent the value of a given characteristic during the time when the read was performed. This is important to note since a characteristic’s value may change at any time. We’ll cover how to get notified of a characteristic’s change in value in the next section.

    When working with characteristics that have special UUIDs (Assigned Numbers) standardized by the Bluetooth SIG, we can read through the XMLs pertaining to each characteristic here to understand how to make sense of the raw bytes that we’ll encounter after reading. For custom and proprietary services or characteristics, the format in which the bytes should be parsed will depend on the BLE firmware’s implementation of some sort of data transfer protocol, the details of which may or may not be available publicly.

    For the rest of our examples, we’ll assume we’re trying to read the value of a BLE device’s battery level. Depending on the BLE device you’re working with, it may not contain the Battery Service and, therefore, the Battery Level characteristic. At this point, pick any one of the other available characteristics that are readable. If you’re stuck, refer to the Service Discovery as part of the Connection Flow section, which contains code that prints out which characteristics on a BLE device are readable after service discovery is complete.

    Remember in the Discovering Services section that our BluetoothGatt object now contains all discovered services and characteristics that can be accessed via getService() and subsequently getCharacteristics(). Also, recall that the UUIDs for GATT services and characteristics that are part of the Bluetooth specification need to be combined with a base UUID of 00000000-0000-1000-8000-00805f9b34fb before they can be used. With this knowledge, we can perform a read on a BLE device’s Battery Level characteristic by doing the following:

    private fun readBatteryLevel() {
        val batteryServiceUuid = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
        val batteryLevelCharUuid = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
        val batteryLevelChar = gatt
            .getService(batteryServiceUuid)?.getCharacteristic(batteryLevelCharUuid)
    
        if (batteryLevelChar?.isReadable() == true) {
            gatt.readCharacteristic(batteryLevelChar)
        }
    }

    Note how we took advantage of Kotlin’s optional chaining to get to the Battery Level characteristic in the bolded part above. By default, because getService() and getCharacteristic() aren’t annotated with @Nullable, both methods return a BluetoothGattService! and a BluetoothGattCharacteristic! type — note a single “!” means that these returned values are platform types, which may or may not be null. 

    Since the official documentation mentioned that these method calls can indeed return null if a given UUID doesn’t exist among the discovered services or characteristics, we can (and should) append a “?” to get null safety while calling those methods. Otherwise, we’d be faced with a NullPointerException when either getService() or getCharacteristic() returns null. And since batteryLevelChar is optional, we use == true to both check for the fact that it is nonnull and that it is indeed a readable characteristic.

    Shortly after the call to BluetoothGatt’s readCharacteristic(), a callback should get delivered to your BluetoothGattCallback’s onCharacteristicRead(), regardless of whether the read operation succeeded.

    Note that there are two versions of the onCharacteristicRead() callback, one that has been deprecated and that should only be used for API < 33 and a newer version that is only available for API >= 33. This newer version provides the read characteristic value as part of the callback method parameter and is more memory-safe. Unfortunately, if we’re supporting Android versions older than Android 13, we’ll need to implement both callback methods in our BluetoothGattCallback, even if they may contain largely the same logic.

    @Deprecated("Deprecated for Android 13+")
    @Suppress("DEPRECATION")
    override fun onCharacteristicRead(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        status: Int
    ) {
        with(characteristic) {
            when (status) {
                BluetoothGatt.GATT_SUCCESS -> {
                    Log.i("BluetoothGattCallback", "Read characteristic $uuid:\n${value.toHexString()}")
                }
                BluetoothGatt.GATT_READ_NOT_PERMITTED -> {
                    Log.e("BluetoothGattCallback", "Read not permitted for $uuid!")
                }
                else -> {
                    Log.e("BluetoothGattCallback", "Characteristic read failed for $uuid, error: $status")
                }
            }
        }
    }
    
    override fun onCharacteristicRead(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        value: ByteArray,
        status: Int
    ) {
        val uuid = characteristic.uuid
        when (status) {
            BluetoothGatt.GATT_SUCCESS -> {
                Log.i("BluetoothGattCallback", "Read characteristic $uuid:\n${value.toHexString()}")
            }
            BluetoothGatt.GATT_READ_NOT_PERMITTED -> {
                Log.e("BluetoothGattCallback", "Read not permitted for $uuid!")
            }
            else -> {
                Log.e("BluetoothGattCallback", "Characteristic read failed for $uuid, error: $status")
            }
        }
    }
    
    // ... somewhere outside BluetoothGattCallback
    fun ByteArray.toHexString(): String =
        joinToString(separator = " ", prefix = "0x") { String.format("%02X", it) }

    Like most things BluetoothGatt, the status parameter tells the whole story of an operation’s success or failure. In the case of a characteristic read operation, if the status parameter isn’t GATT_SUCCESS, it’s likely GATT_READ_NOT_PERMITTED, which our code hopefully mitigated as we checked that the characteristic contains the PROPERTY_READ flag before attempting to perform the read. If the read had succeeded, the read data can be accessed by calling BluetoothGattCharacteristic’s getValue() method (or in Kotlin by simply using the value property) in the deprecated callback method or by referencing the value parameter directly for the newer callback method because BluetoothGattCharacteristic’s getValue() method has been deprecated for API >= 33.

    Parsing and Making Sense of Data

    Running this piece of code, we realized that upon a successful read, the toHexString() extension function called on the read value printed “0x64”. Pulling up the Battery Level characteristic specifications from the Bluetooth website, we see that the value is supposed to be interpreted as an unsigned byte, with a possible value ranging between 0 to 100, representing the battery’s charge level in percentages.

    Since Java’s (and therefore Kotlin’s) native byte type is a signed byte with a value between -127 to +127, and we’re certain that the incoming ByteArray will only ever contain a single byte, we can get away with simply calling toInt() on the ByteArray’s first element without having to worry about overflowing (battery level is between 0-100).

    val readBytes: ByteArray // ... obtained from onCharacteristicRead()
    val batteryLevel = readBytes.first().toInt() // 0x64 -> 100 (percent)

    In other cases where you might be dealing with a few bytes of data, you’ll need to perform some byte shifting and bit masking to calculate the final numerical value. Most BLE firmware implementations utilize little-endian byte order, so it’s important to make sure you parse the bytes in the right order. As an example, given a ByteArray of size 4 (32 bytes) that is little-endian, the final Int value can be calculated like so:

    val fourBytes: ByteArray // ... obtained from onCharacteristicRead()
    val numericalValue = (fourBytes[3].toInt() and 0xFF shl 24) +
        (fourBytes[2].toInt() and 0xFF shl 16) +
        (fourBytes[1].toInt() and 0xFF shl 8) +
        (fourBytes[0].toInt() and 0xFF)

    Writing to a Characteristic

    While reading characteristics’ values are straightforward, writing values to them is slightly more complicated — but only slightly so. The key thing to know is that there are two main write types, and a characteristic that supports write operations usually supports one of the write types but can also support both write types:

    • Write with response. Also known as write request, this write type requires acknowledgment from the BLE device firmware that it has either processed the write or send an error packet if the write has failed. Represented by WRITE_TYPE_DEFAULT.
    • Write without response. Also known as a write command, this write type requires no acknowledgment from the BLE device. Although a rare occurrence in most use cases, it is technically possible for the write to be ignored or dropped due to a lack of bandwidth or processing power to handle it. Represented by WRITE_TYPE_NO_RESPONSE.

    Generally speaking, most simple BLE use cases make do with just writes with responses because throughput isn’t usually a priority for those use cases, and it’s often preferable to be notified of the status of your writes instead of being in the dark. For the majority of BLE use cases, using writes with response from Android would be our recommendation as opposed to writes without response in cases where the characteristic is to be written to support both write types.

    Use cases that warrant the use of writes without response are typically high throughput or low latency short burst use cases. For example, a BLE device that has the capability to receive large files from an app may want to use writes without response with a combination of a high ATT MTU, Data Length Extension (see our guide on DLE), and some sort of retry mechanism with CRC support in case an ATT payload gets dropped. The connection parameters such as the connection interval, slave latency, and supervision timeout may also need to be tweaked to optimize for such a use case, thus making writes without response more suited for advanced systems implementations where the team (or individual) has access to both mobile and BLE firmware source code.

    Similar to reading a characteristic, we should make sure the characteristic we’re writing to supports the write type we intend to use before we perform a write operation. Refer to the extension functions in the Parsing BluetoothGattCharacteristic Properties section for details on how to perform this check.

    Similar to the characteristic reading scenario, there are once again two variants of BluetoothGatt’s writeCharacteristic() method: the older one that has been deprecated for API 33+ takes in the BluetoothGattCharacteristic we’re writing to as its sole argument; the newer one available for only API 33+ takes in the payload we’re writing and our desired write type as additional arguments for the method call. 

    So, how do we specify the write type and the payload we’re writing for the older method variant? We have to set those fields on the BluetoothGattCharacteristic using setWriteType() and setValue(), but keep in mind that both of those have also been deprecated for API 33+, so they’ll not be used outside of supporting Android 12 and lower versions.

    Here’s an example of a function that performs a write on a characteristic:

    fun writeCharacteristic(characteristic: BluetoothGattCharacteristic, payload: ByteArray) {
        val writeType = when {
            characteristic.isWritable() -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
            characteristic.isWritableWithoutResponse() -> {
                BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
            }
            else -> error("Characteristic ${characteristic.uuid} cannot be written to")
        }
        bluetoothGatt?.let { gatt ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                gatt.writeCharacteristic(characteristic, payload, writeType)
            } else {
                // Fall back to deprecated version of writeCharacteristic for Android <13
                gatt.legacyCharacteristicWrite(characteristic, payload, writeType)
            }
        } ?: error("Not connected to a BLE device!")
    }
    
    @TargetApi(Build.VERSION_CODES.S)
    @Suppress("DEPRECATION")
    private fun BluetoothGatt.legacyCharacteristicWrite(
        characteristic: BluetoothGattCharacteristic,
        value: ByteArray,
        writeType: Int
    ) {
        characteristic.writeType = writeType
        characteristic.value = value
        writeCharacteristic(characteristic)
    }

    This example above chooses the most appropriate write type based on the characteristic’s properties. Note how we prioritize using write with response if a characteristic happens to support both write types — write without response will only be used as the write type for the write operation if it is the only write type supported by the characteristic. Feel free to swap the order so that write without response is prioritized if that suits your use case more.

    Depending on the write type, you may or may not get BluetoothGattCallback’s onCharacteristicWrite() callback. This callback is guaranteed for WRITE_TYPE_DEFAULT (write with response), but for WRITE_TYPE_NO_RESPONSE (write without response), where we’d expect the callback not to be delivered, it does still get delivered — at least with the Pixel devices that we’ve been testing with. 

    Here’s the StackOverflow answer that explains this behavior. Due to the undocumented nature of this behavior, we’d still recommend you use WRITE_TYPE_DEFAULT over WRITE_TYPE_NO_RESPONSE if the characteristic supports both write types.

    override fun onCharacteristicWrite(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        status: Int
    ) {
        with(characteristic) {
            val value = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
                characteristic.value
            } else {
                TODO("Get from cache somewhere as getValue is deprecated for Android 13+")
            }
            when (status) {
                BluetoothGatt.GATT_SUCCESS -> {
                    Log.i("BluetoothGattCallback", "Wrote to characteristic $uuid | value: ${value.toHexString()}")
                }
                BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH -> {
                    Log.e("BluetoothGattCallback", "Write exceeded connection ATT MTU!")
                }
                BluetoothGatt.GATT_WRITE_NOT_PERMITTED -> {
                    Log.e("BluetoothGattCallback", "Write not permitted for $uuid!")
                }
                else -> {
                    Log.e("BluetoothGattCallback", "Characteristic write failed for $uuid, error: $status")
                }
            }
        }
    }

    As expected, our onCharacteristicWrite() callback needs to check if the status is GATT_SUCCESS before processing the success case. As mentioned in the “Requesting for a larger ATT MTU” section above, it is possible to get a status of GATT_INVALID_ATTRIBUTE_LENGTH if the app attempts to write a number of bytes that is larger than the connection’s current ATT MTU, so we should handle this appropriately. Finally, we should also check for GATT_WRITE_NOT_PERMITTED, which our previous code snippet should mitigate since we’re making sure that the characteristic can be written to before performing the write.

    As for the written value, it’s generally safe to assume that if the status is GATT_SUCCESS the value you wrote went across the wire. For devices running on Android 12 or older, you can double-check that this is indeed the case by accessing BluetoothGattCharacteristic’s getValue(), but note that this method has been deprecated for Android 13 and above.

    What About Descriptor Reads and Writes?

    You may recall that descriptors are attributes that describe a characteristic. Descriptors are nested under characteristics similar to how characteristics are nested under a service, hierarchically speaking. A descriptor is predictably represented by a BluetoothGattDescriptor object, and we can check its permissions via getPermissions(), much like BluetoothGattCharacteristic’s getProperties().

    That said, we’d highly recommend NOT checking for BluetoothGattDescriptor permissions when you perform reads or writes on them. In our experience, the permission flags oftentimes don’t reflect the actual readability or writability of the descriptors.

    For instance, the two most common descriptors we’ll work with are the Client Characteristic Configuration Descriptor (CCCD) and the Characteristic User Description Descriptor (CUDD). The CCCD can be written to and read from because it controls whether Notifications or Indications are enabled for a certain characteristic. The CUDD provides a human-readable name for a custom characteristic and is, therefore, readable. However, the Android SDK’s getPermissions() method always returns false when checking for these characteristics’ readability and writability.

    For informational purposes, here are some extension functions that allow you to check if a BluetoothGattDescriptor is readable or writable based on how the permission flags are specified.

    fun BluetoothGattDescriptor.isReadable(): Boolean =
        containsPermission(BluetoothGattDescriptor.PERMISSION_READ) ||
            containsPermission(BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED) ||
            containsPermission(BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED_MITM)
    
    fun BluetoothGattDescriptor.isWritable(): Boolean =
        containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE) ||
            containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED) ||
            containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED_MITM) ||
            containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_SIGNED) ||
            containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_SIGNED_MITM)
     
    fun BluetoothGattDescriptor.containsPermission(permission: Int): Boolean =
        permissions and permission != 0

    The rest of how to perform a read and write on a descriptor is very similar to how you’d do it on a characteristic — with analogously named methods — the only exception being there is no need to specify a write type for descriptor writes.

    CharacteristicDescriptor
    writeCharacteristicwriteDescriptor
    readCharacteristicreadDescriptor
    onCharacteristicWriteonDescriptorWrite
    onCharacteristicReadonDescriptorRead

    A Gentle Reminder: Don’t Flood the Pipeline!

    We round up this section with a gentle reminder that we should always hear back from the previous operation before kicking off a new one. When performing any BLE operation at all on Android, we heavily recommend that you wait for a callback to come in before executing the next action. In the context of performing reads and writes, this means waiting for the onCharacteristicRead() and onCharacteristicWrite() callbacks before proceeding with other operations.

    For writes without response, if the write is a one-off then there’s likely nothing to worry about. But if you’re doing back-to-back writes, it’s probably a good idea to pace your writes with onCharacteristicWrite if you do get it, or have the writes be spaced out by a timer that is roughly equivalent to the connection interval if you don’t see onCharacteristicWrite being delivered.

    Later on in this guide, we’ll guide you through implementing your own queuing mechanism that’ll alleviate these pain points when dealing with the Android BLE APIs.


    Subscribing to Notifications or Indications

    Now that we’re familiar with reading from and writing to a BLE device, we get to the interesting part: Notifications and Indications.

    As detailed in the Table of Glossary section at the beginning of the guide, Notifications and Indications are means for a BLE device (Peripheral) to inform the Android device (Central) when a characteristic’s value changes, and in practice, the primary mechanism for the Peripheral to communicate with the Central.

    It helps to think of Notifications and Indications as mirrored versions of writes without and with responses respectively, only the data is now coming from the server (Peripheral) rather than the client (Central). At a high level, from the mobile development perspective, the only difference between Notifications and Indications is that notifications don’t require acknowledgment by the client when it receives the packet, whereas Indications do require this sort of acknowledgment and are therefore, slower. This acknowledgment is handled by the Android Bluetooth stack however, so there is nothing special our apps need to do between the two.

    Ensuring a Characteristic Supports Notifications/Indications

    Much like the other properties we check against when ensuring a characteristic can be written to or read from, we can use BluetoothGattCharacteristic’s PROPERTY_INDICATE and PROPERTY_NOTIFY to ensure a characteristic supports sending notifications or indications before subscribing to its value changes.

    fun BluetoothGattCharacteristic.isIndicatable(): Boolean =
        containsProperty(BluetoothGattCharacteristic.PROPERTY_INDICATE)
     
    fun BluetoothGattCharacteristic.isNotifiable(): Boolean =
        containsProperty(BluetoothGattCharacteristic.PROPERTY_NOTIFY)
     
    fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean =
        properties and property != 0

    Enabling and Disabling Notifications or Indications

    Calling setCharacteristicNotification()

    Once we’re certain that a characteristic supports Notifications or Indications (or both), the Android Bluetooth stack needs to be informed that the app intends to opt in or opt out from notifications of a characteristic’s value changes. We do that by calling setCharacteristicNotification() on our BluetoothGatt object and making sure that it returns true.

    Writing to the Client Characteristic Configuration Descriptor (CCCD)

    The CCCD (Client Characteristic Configuration Descriptor) special descriptor has a name that’s a handful to remember, but it’s vital to the process of subscribing to Notifications or Indications. Any characteristic that supports sending Notifications or Indications would have this descriptor. Writing to the CCCD enables or disables Notifications or Indications on the BLE device.

    While interacting with the CCCD, it should be expected that this special descriptor can be written to and read from, even if it doesn’t contain the PERMISSION_READ and PERMISSION_WRITE permissions from BluetoothGattDescriptor’s getPermissions().

    Depending on whether we’re enabling Notifications or Indications, we need to set the CCCD’s value to either ENABLE_INDICATION_VALUE or ENABLE_NOTIFICATION_VALUE (both defined in BluetoothGattDescriptor), and write the value to the remote descriptor on the BLE device. To disable Notifications, Indications, or both, we simply write DISABLE_NOTIFICATION_VALUE to the CCCD instead.

    Similar to the characteristic write scenario, there are once again two versions of the writeDescriptor method: one for API < 33 that has been deprecated and another for API >= 33.

    An example of a function that performs a descriptor may look like this, note that we’re short-circuiting the properties check if the descriptor’s UUID matches the CCCD UUID:

    fun writeDescriptor(descriptor: BluetoothGattDescriptor, payload: ByteArray) {
        bluetoothGatt?.let { gatt ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                gatt.writeDescriptor(descriptor, payload)
            } else {
                // Fall back to deprecated version of writeDescriptor for Android <13
                gatt.legacyDescriptorWrite(descriptor, payload)
            }
        } ?: error("Not connected to a BLE device!")
    }
    
    @TargetApi(Build.VERSION_CODES.S)
    @Suppress("DEPRECATION")
    private fun BluetoothGatt.legacyDescriptorWrite(
        descriptor: BluetoothGattDescriptor,
        value: ByteArray
    ): Boolean {
        descriptor.value = value
        writeDescriptor(descriptor)
    }

    Tying it all Together…

    The code snippet below shows how we typically implement functions to enable and disable Notifications or Indications. We try to make our functions as easy to work with as possible, meaning the function doesn’t take an argument specifying if we want to enable Notifications or Indications. It tries to figure out the appropriate notification type we’re enabling by checking the characteristic’s properties.

    fun enableNotifications(characteristic: BluetoothGattCharacteristic) {
        val cccdUuid = UUID.fromString(CCC_DESCRIPTOR_UUID)
        val payload = when {
            characteristic.isIndicatable() -> BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
            characteristic.isNotifiable() -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
            else -> {
                Log.e("ConnectionManager", "${characteristic.uuid} doesn't support notifications/indications")
                return
            }
        }
     
        characteristic.getDescriptor(cccdUuid)?.let { cccDescriptor ->
            if (bluetoothGatt?.setCharacteristicNotification(characteristic, true) == false) {
                Log.e("ConnectionManager", "setCharacteristicNotification failed for ${characteristic.uuid}")
                return
            }
            writeDescriptor(cccDescriptor, payload)
        } ?: Log.e("ConnectionManager", "${characteristic.uuid} doesn't contain the CCC descriptor!")
    }
     
    fun disableNotifications(characteristic: BluetoothGattCharacteristic) {
        if (!characteristic.isNotifiable() && !characteristic.isIndicatable()) {
            Log.e("ConnectionManager", "${characteristic.uuid} doesn't support indications/notifications")
            return
        }
     
        val cccdUuid = UUID.fromString(CCC_DESCRIPTOR_UUID)
        characteristic.getDescriptor(cccdUuid)?.let { cccDescriptor ->
            if (bluetoothGatt?.setCharacteristicNotification(characteristic, false) == false) {
                Log.e("ConnectionManager", "setCharacteristicNotification failed for ${characteristic.uuid}")
                return
            }
            writeDescriptor(cccDescriptor, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
        } ?: Log.e("ConnectionManager", "${characteristic.uuid} doesn't contain the CCC descriptor!")
    }

    At the risk of sounding like a broken record, yes, we do need to check for the GATT_SUCCESS status parameter in BluetoothGattCallback’s onDescriptorWrite() callback before concluding that the enabling or disabling of Notifications or Indications has succeeded.

    Once we’ve enabled Notifications or Indications on a characteristic, any incoming notification or indication is delivered via BluetoothGattCallback’s onCharacteristicChanged() callback. This callback also has two variants that need to be implemented, similar to the onCharacteristicRead() callback.

    @Deprecated("Deprecated for Android 13+")
    @Suppress("DEPRECATION")
    override fun onCharacteristicChanged(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic
    ) {
        with(characteristic) {
            Log.i("BluetoothGattCallback", "Characteristic $uuid changed | value: ${value.toHexString()}")
        }
    }
    
    override fun onCharacteristicChanged(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        value: ByteArray
    ) {
        val newValueHex = value.toHexString()
        with(characteristic) {
            Log.i("BluetoothGattCallback", "Characteristic $uuid changed | value: $newValueHex")
        }
    }

    It’s worth noting that our usual flow of setting up Notifications or Indications happens as part of our connection setup process, which generally looks like this:

    1. Scan for a device.
    2. Connect to the device.
    3. [Optional] Request for maximum ATT MTU.
    4. Discover services and characteristics.
    5. Enable Notifications or Indications on characteristic(s) of interest.
    6. Signal to the higher level caller (typically the UI or ViewModel layer) that connection setup is done, and the BLE device is ready to be interacted with (i.e., it’s now OK to perform read and write operations).

    Bonding With a BLE Device

    If you made it this far, congratulations. Your app can now connect to a BLE device, opt in or out of characteristic notifications, and perform read-and-write operations! However, our work is far from being done. In this section, we’ll walk you through what bonding is and how to bond with a BLE device.

    Bonding vs. Pairing

    You may have seen the terms “bonding” and “pairing” used interchangeably, and even Android’s Settings app uses the copy “Pair new device.” This has led to consumers and even developers thinking that bonding and pairing are one and the same. From the consumer perspective, they may as well be, but it’s technically inaccurate.

    Pairing is the exchange of temporary encryption keys that allows for the exchange of long-term encryption keys — the bonding process — to take place. End of story.

    Imagine you wanted to exchange a secret handshake with a friend when you’re in a public venue, but you don’t want others to see it, so you erect a tent and exchange the secret handshake while both of you are inside the tent. The process of erecting the tent would then be synonymous with pairing, whereas bonding is represented by the exchange of your secret handshake while inside the tent (a temporary, encrypted channel established by the pairing process).

    As Android developers, the only thing we care about is bonding with a BLE device; the Bluetooth stack takes care of the pairing process for us.

    Why Bond?

    There typically isn’t a need to bond when interacting with simple devices like a smart thermostat or other IoT devices because they offer information that apps can consume. But the main motivation behind creating a bond is to set up an encrypted communication channel between a BLE Central and Peripheral. 

    There are packet analyzers that can “sniff” for BLE packet transmissions as they happen over the air, and any traffic that isn’t encrypted is fair game. Imagine a person with malicious intent sitting in a coffee shop and analyzing BLE packets that are being transmitted over the air. If someone’s smartwatch, for instance, isn’t using BLE bonding to encrypt its communication channel with the smartphone, any text messages or contacts would be exposed as they’re being synced between the smartwatch and the user’s smartphone.

    Another reason why you may consider bonding as part of your system implementation is that it allows an Android app to remember and being able to reconnect to devices that leverage LE Privacy, a Bluetooth 4.2+ feature allowing devices to rotate their MAC addresses periodically. Without bonding, there’s no way for Android to identify the specific Bluetooth device because its MAC address changes periodically — this is by design. However, when Android is bonded with the device, it can derive or resolve the device’s current public MAC address. All developers need to do is save the device’s MAC address found during the initial scan. Even if the device’s MAC address changes subsequently, this initial MAC address will allow Android to find the device you’re after by using APIs like getRemoteDevice(macAddress: String) and getRemoteLeDevice(macAddress: String, addressType: Int)

    That said, if you’re working with a generic BLE device and have no access to its firmware source code, and the device manufacturer doesn’t explicitly mention that bonding is required, there usually isn’t a need to bond with the device unless you’ll be working with sensitive information.

    Initiating a Bond

    The request to bond can come from either the Central or the Peripheral, depending on the system design. There are multiple ways to initiate the bonding process, listed here in descending order of our preference:

    1. Android-initiated: Have Android start the bonding process automatically when the BLE device emits an Insufficient Authentication error due to an unauthorized ATT request.
    2. Developer-initiated: Proactively start the bonding process ourselves by calling BluetoothDevice’s createBond() method.
    3. Peripheral-initiated: If you have access to the source code of the firmware running on the BLE device, you can have it send a security request proactively that’ll kickstart the bonding process as soon as it’s connected to a Central.

    Android-Initiated Bonding

    The first method involves Android initiating bonding on its own without any action required by developers. This usually happens when an Insufficient Authentication error code is received as a result of Android attempting to perform an unauthorized ATT request — such as reading the value of a characteristic that’s encrypted on the peripheral.

    Using this method to initiate bonding is the most reliable for most Android devices. Triggering the bonding process this way also happens to be the technique recommended by Apple in their Accessory Design Guidelines document. It is actually what happens under the hood on iOS devices, seeing as developers have no explicit means of initiating the bonding process on iOS.

    In the interest of parity between the mobile platforms, we recommend readers who have control over both their app and their BLE device’s firmware utilize this technique whenever possible by including an encrypted characteristic that the app then reads as part of the connection process (after having performed service discovery beforehand).

    For most Android devices we’ve tested, this is enough for Android to automatically start the bonding process with the BLE device on our behalf. Doing things this way also seems to have a higher chance of succeeding as opposed to the other two methods.

    Developer-Initiated Bonding

    Moving on to the second method, which is likely more well-known than the first due to it actually being a public API, we see that as opposed to iOS, the Android SDK provides a public createBond() method as part of the BluetoothDevice object that we can use to proactively have Android initiate the bonding process with the peripheral. This is what gets called under the hood when you navigate to Android’s Bluetooth settings and tap on a device that is advertising in the vicinity to bond with it. 

    Some websites with outdated instructions may suggest that you use Java Reflection to gain access to private overloads of the createBond() method. Still, as mentioned in our article, Android BLE Development Tips, Google has started restricting the usage of private APIs, and using private APIs is no longer something we recommend our clients do, even if the private API of discussion isn’t yet on the greylist.

    This createBond() way of initiating the bonding process is helpful when the first method mentioned previously has failed. There were certain devices from Xiaomi and Samsung that we’ve seen wouldn’t initiate bonding when the first method was used, and createBond() was used to great effect for those devices.

    The timing of the createBond() call is very important. The firmware running on the BLE device may reject the request to bond if it isn’t expecting to bond or if it simply doesn’t support bonding. We’ve also seen cases where createBond() calls have varying probabilities of success depending on the OEM and Android version, as well as if the app was already connected to the BLE device when the bonding request was made.

    Our recommendation is to try the first method of Android-initiated bonding before trying to use createBond(). If createBond() also fails consistently for your Android device, try connecting to the BLE device first before calling createBond() to see if that helps, or call createBond() before connectGatt() if you were already connected when createBond() failed.

    Peripheral-Initiated Bonding

    It’s rare for us to recommend that the Peripheral initiate the bonding process. But if both the aforementioned methods have failed you, and you have access to the source code of the firmware running on the BLE device, you can have the BLE firmware send a security request proactively that’ll kickstart the bonding process at a timing of your choosing, typically as soon as it’s connected to a Central. However, a word of caution: we’ve seen weird behaviors on certain OEMs’ Android devices that would just outright reject this request to bond. We’ve not seen too many success cases from bonding this way, but it’s something you can try as a last resort.

    The Bond Confirmation Dialog

    Depending on the version of Android running on your device and if the OEM has made specific modifications to it, there is usually a bond confirmation dialog that’ll get displayed to the user, and they’re the ones who have the last say in confirming the establishment of the bond between Android and your BLE device.

    Older versions of Android that we’ve worked on (Android 7.0 and older) sometimes don’t require explicit user consent before an app can bond with a BLE device. Newer versions of Android are increasingly privacy-conscious and are handing back more control of the Android device to the users, which we believe is a step in the right direction. This also means that app developers can no longer rely on createBond() silently succeeding and creating a bond with a BLE device this way. Like almost everything else, user education is paramount to executing and delivering a good app experience.

    Listening to Bond State Change

    Bond state change updates are delivered via a Broadcast with an action of ACTION_BOND_STATE_CHANGED and processed with a BroadcastReceiver

    Note: An app running on Android 12+ must first be granted the BLUETOOTH_CONNECT runtime permission before it can listen to this Broadcast.

    The bond state changes aren’t delivered via a BluetoothGattCallback because the app doesn’t necessarily need to be connected to a BLE device for its bond state to change. However, due to the multicast nature of a Broadcast, you’ll also get notified about bond state changes of other devices you may not be interested in, so it’s important to check the address of the BluetoothDevice that the Broadcast is for before doing anything.

    According to official documentation, the Broadcast will always contain the following extras:

    • EXTRA_DEVICE: Contains the BluetoothDevice whose bond state has changed. Most apps will want to check the device’s address property to ensure the delivered Broadcast concerns a device of interest.
    • EXTRA_BOND_STATE: Represents the current bond state of the BluetoothDevice.
    • EXTRA_PREVIOUS_BOND_STATE: Represents the previous bond state of the BluetoothDevice. Basically, the BluetoothDevice is transitioning from this state to the state represented in EXTRA_BOND_STATE.

    While EXTRA_DEVICE is a Parcelable extra field, EXTRA_BOND_STATE and EXTRA_PREVIOUS_BOND_STATE are Int extra fields with possible values of BOND_NONE, BOND_BONDING, BOND_BONDED. By checking both Int extra fields, we can make sense of the transition in bond states for a given BluetoothDevice, like so:

    fun listenToBondStateChanges(context: Context) {
        // Note: BLUETOOTH_CONNECT permission required for Android 12+
        context.applicationContext.registerReceiver(
            broadcastReceiver,
            IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
        )
    }
    
    private val broadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            with(intent) {
                if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
                    val device = parcelableExtraCompat<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
                    val previousBondState = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1)
                    val bondState = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
                    val bondTransition = "${previousBondState.toBondStateDescription()} to " +
                        bondState.toBondStateDescription()
                    Log.w("Bond state change", "${device?.address} bond state changed | $bondTransition")
                }
            }
        }
    
        private fun Int.toBondStateDescription() = when(this) {
            BluetoothDevice.BOND_BONDED -> "BONDED"
            BluetoothDevice.BOND_BONDING -> "BONDING"
            BluetoothDevice.BOND_NONE -> "NOT BONDED"
            else -> "ERROR: $this"
        }
    }
    
    /**
     * A backwards compatible approach of obtaining a parcelable extra from an [Intent] object.
     *
     * NOTE: Despite the docs stating that [Intent.getParcelableExtra] is deprecated in Android 13,
     * Google has confirmed in https://issuetracker.google.com/issues/240585930#comment6 that the
     * replacement API is buggy for Android 13, and they suggested that developers continue to use the
     * deprecated API for Android 13. The issue will be fixed for Android 14 (U).
     */
    internal inline fun <reified T : Parcelable> Intent.parcelableExtraCompat(key: String): T? = when {
        Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU -> getParcelableExtra(key, T::class.java)
        else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
    }

    Checking a Device’s Bond State on Demand

    If your app doesn’t need to know about the transitions in bond states of a BLE device, merely what state it’s currently in, you can utilize BluetoothDevice’s getBondState() method (or simply the bondState property in Kotlin), which returns the same possible constants of BOND_NONE, BOND_BONDING, BOND_BONDED. Similar to listening to bond state changes, the BLUETOOTH_CONNECT runtime permission is required from Android 12 onwards.

    Unbonding From a Device

    Unfortunately, there’s no public method to remove a bond with a BluetoothDevice.  While there is a private removeBond() method in BluetoothDevice that can be called using Java Reflection, this is no longer something we recommend you do with Google’s latest stance on non-SDK interfaces (a fancier way of saying private APIs) — though you’re free to proceed at your own risk because removeBond() is not yet on Android’s private API greylist as of the time of writing.

    fun removeBond(device: BluetoothDevice) {
        try {
            device::class.java.getMethod("removeBond").invoke(device)
        } catch (e: Throwable) {
            Timber.e("Failed to remove bond ${e.localizedMessage}")
        }
    }

    Programmatic ways aside, you can achieve the same effect by instructing your users to navigate to Bluetooth settings and forgetting a bonded device from the system UI, much like what iOS users have to go through.


    Implementing a Basic Queuing Mechanism

    In this section, we’ll provide some pointers on how you can implement your own queuing mechanism for BLE operations. However, note that our approach utilizes the conventional callback-based approach rather than something like RxJava or Kotlin coroutines (though that’ll be a topic we may cover in the future).

    As mentioned earlier in this guide and our Android BLE pitfalls article, performing BLE operations back-to-back in a rapid-fire fashion is the biggest reason behind unexpected platform behavior on Android when it comes to BLE app development. If you’ve been following along and implementing your own read-and-write functions for characteristics and descriptors. If you intend to chain these operations, you’ll quickly realize that, oftentimes, only the first operation will succeed. All the others seemingly dropped into a black hole, never to be heard from again. Obviously, this is not ideal. Most of our app logic relies on these read-and-write operations completing successfully before we update the UI with data we just read off the device.

    If your BLE logic only spans a single screen or Activity, and your needs are incredibly straightforward, you may opt to do something easy like waiting for your onCharacteristicWrite() to succeed before performing another BLE operation. However, this is not scalable at all, especially considering all BLE operations (descriptor read/write, characteristic read/write, connecting, disconnecting, performing MTU update requests, service discovery, etc.) have to ideally wait for a prior BLE operation to either succeed or fail first before they should be performed. 

    For most BLE use cases on Android, implementing a basic queuing mechanism solves a lot of pain points with concurrency-related BLE issues.

    Creating an Abstraction for BLE Operations

    To start, you’ll need an abstraction for what constitutes a BLE operation. We recommend taking advantage of Kotlin-sealed classes and having each type of BLE operation represented as a subclass of a parent-sealed class. For example, you can create a sealed class called BleOperationType that has the following subclasses:

    • Connect
    • Disconnect
    • CharacteristicWrite
    • CharacteristicRead
    • DescriptorWrite
    • DescriptorRead
    • MtuRequest

    Each subclass should encapsulate what the operation it’s representing needs to get executed successfully — a Connect would need the BluetoothDevice handle and a Context object, a CharacteristicWrite would need the BluetoothDevice handle, the BluetoothGattCharacteristic we want to write to, the write type to use, and, of course, a ByteArray representing the write payload. 

    Since each operation needs to contain a BluetoothDevice handle, we can even make the BleOperationType (which each operation subclasses) contain a BluetoothDevice handle as an abstract property that its subclasses would override.

    /** Abstract sealed class representing a type of BLE operation */
    sealed class BleOperationType {
        abstract val device: BluetoothDevice
    }
     
    data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType()
    
    data class CharacteristicRead(
        override val device: BluetoothDevice,
        val characteristicUuid: UUID
    ) : BleOperationType()

    Implementing a Thread-Safe, FIFO Queue

    Now, we need a queue data structure to hold operations that need to be performed. This queue object will likely live in a class (probably a singleton) that all your ViewModels or Activities have access to. A good candidate can be a ConnectionManager type class that handles all the app’s BLE needs.

    The logic behind this FIFO (first-in, first-out) queue is simple: if an operation gets enqueued, and there is no operation that is currently running or pending, this operation gets executed. Otherwise, nothing happens. Once a pending operation completes, it should signal for the queue’s containing class (e.g., ConnectionManager) to check if there are any operations in the queue waiting to get executed. If there is at least one such operation, it gets popped off the queue to be executed.

    Since BLE operations may be enqueued from different threads (even from the UI thread when it’s in response to a button click, for instance), we want to ensure our queue is thread-safe. If you have special threading needs, you may opt to use a ReentrantLock to ensure that only one thread ever gets to handle the BLE operation queue. We typically choose to use a ConcurrentLinkedQueue (without a ReentrantLock) that is part of the java.util.concurrent package to hold our operations. Aside from the queue property, we’ll also maintain a separate property to keep track of any pending operations.

    private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
    private var pendingOperation: BleOperationType? = null

    Annotating Sensitive Functions with @Synchronized

    Kotlin notably deprecated the synchronized() function that allows us to specify arbitrary blocks of code that should be protected from concurrent execution by different threads. But we can still use the @Synchronized annotation on functions, equivalent to specifying a Java method as being synchronized

    Enqueuing BLE Operations to the Queue and Executing Them

    For our operation queue, we’ll implement a synchronized function that adds a new operation to the queue. It should also kick off the added operation if there is no pending operation.

    @Synchronized
    private fun enqueueOperation(operation: BleOperationType) {
        operationQueue.add(operation)
        if (pendingOperation == null) {
            doNextOperation()
        }
    }
     
    @Synchronized
    private fun doNextOperation() {
        if (pendingOperation != null) {
            Log.e("ConnectionManager", "doNextOperation() called when an operation is pending! Aborting.")
            return
        }
     
        val operation = operationQueue.poll() ?: run {
            Timber.v("Operation queue empty, returning")
            return
        }
        pendingOperation = operation
     
        when (operation) {
        	is Connect -> // operation.device.connectGatt(...)
        	is Disconnect -> // ...
        	is CharacteristicWrite -> // ...
        	is CharacteristicRead -> // ...
        	// ...
        }
    }

    In the above code snippet, we also provided an example of a doNextOperation() function that essentially pops an operation off the head of our operation queue, caches it as the pendingOperation property, and then executes the operation depending on the type of BleOperationType subclass the operation represents.

    Signaling Operation Completion

    There is one final missing piece of the puzzle. Since enqueueOperation relies on pendingOperation being null as reassurance that it’s safe to execute another operation, our code presents a deadlock: once an operation gets enqueued and executes to completion, there is no way for us to continue executing any queued operations. We’ll fix this by implementing a way for operations to signal their completions.

    @Synchronized
    private fun signalEndOfOperation() {
        Log.d("ConnectionManager", "End of $pendingOperation")
        pendingOperation = null
        if (operationQueue.isNotEmpty()) {
            doNextOperation()
        }
    }

    This function signalEndOfOperation() will need to be called in places where BLE operations may reach their terminal states, both success and failure. Here’s an example for a characteristic write operation:

    override fun onCharacteristicWrite(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic,
        status: Int
    ) {
        // ...
     
        if (pendingOperation is CharacteristicWrite) {
            signalEndOfOperation()
        }
    }

    Note how we only signal the end of an operation if pendingOperation is of the expected type. This is to prevent (to a certain extent) rogue or unexpected callbacks from accidentally unblocking the queue.

    We hope this section has helped you find ideas for implementing your queuing mechanism. For our full implementation, check out this commit on our open-source GitHub repo.


    Staying Connected in the Background (Unbonded Use Cases)

    The default behavior for BLE on Android for unbonded devices is that the app owns the connection to the BLE device. In contrast, the Android OS owns the connection for bonded devices. What this means is that for unbonded use cases, if your app gets terminated in the background by the OS due to resource constraints, or if your users swipe your app away (effectively terminating it immediately), the BLE connection is lost and you should see most BLE devices start advertising again.

    Leveraging Foreground Services

    The most straightforward way to try and keep your app process alive for as long as possible is by using a foreground service. The linked official documentation has clearly defined steps for implementing your own foreground service, so we won’t go into details here aside from recommending a foreground service type of android:foregroundServiceType="connectedDevice" to represent the always-connected BLE use case if that applies to your app, now that this is required for Android 14+.

    Structuring Your App for Reliable BLE Connections

    A foreground service is essentially a Service that runs with a persistent banner in the notification drawer, keeping the user in the loop that your app is doing something in the background. Users can dismiss this notification starting in Android 13. Still, in our testing, this didn’t seem to terminate the associated foreground service unless the user manually terminates the app process using the Android Task Manager. With a foreground service, the probability of your process getting terminated or suspended by the system due to memory constraints is very low but not impossible.

    It’s worth noting that when the user swipes away your app while your foreground service is running, your app process survives, but your Activity stack and any other user-facing elements get blown away. Therefore, extra care must be taken to ensure your BLE logic lives outside those entities, as would be the case in a singleton called ConnectionManager that handles all the app’s BLE needs. 

    Generally, your Activities and Fragments shouldn’t make any assumptions about the state of the BLE connection. Instead, they should rely on the ConnectionManager (or whatever entity manages your app’s BLE needs) as the sole source of truth and have the UI updated accordingly in onCreate() and onResume(), depending on the needs of the UI.

    Limitations and Alternative Techniques

    Using a foreground service isn’t entirely bulletproof either. Android versions 9 and above have an Adaptive Battery feature that can sometimes limit the uptime of app processes that have running foreground services, but this happens seemingly randomly in our experience. Currently, the only way to work around this limitation is to request that your users turn off battery optimization for your app, which is an option buried deep inside the Settings app. To make it easier for your users to understand what they need to do, https://dontkillmyapp.com/ has some very well-written instructions that you can link your users to.

    Other Android background processing techniques, like relying on AlarmManager and WorkManager are also viable. Still, connection events won’t be instantaneous, and there may be some missed BLE notifications or indications if the app does not run when those events happen. Long story short, these other techniques are good if your app doesn’t need to know about events as they happen. Instead, it can wake up periodically, connect to the BLE device again (if nearby), and query it for new data.


    Third-Party BLE Libraries

    Phew, we’re almost at the end now. At this point, you should know the basics of how to set up the required app permissions, perform BLE scans, connect to a device, discover all its capabilities (services), read from or write to characteristics and descriptors, as well as get notified via notifications or indications when a characteristic’s value changes. You should feel proud of yourself!

    Now, imagine a round of applause around you, just for you. 

    BLE on Android is perfectly doable if you’re careful and always do things in a serial manner, though we may be slightly biased when we say that. We understand this is a lot to absorb, and it’s perfectly OK to want to roll with something a bit more battle-tested than your homebrew BLE solution for Android.

    Reliable BLE Libraries

    To help you with your search, we’ve compiled a short list of great and reliable open-source third-party BLE libraries that’ll get the job done just fine:

    • RxAndroidBle: Based on and requires knowledge of RxJava. Big, active community. This would be a good fit if a project follows the Rx paradigm. Huge bonus: RxAndroidBle is heavy on interfaces and, therefore, easily testable as they also provide mock versions of their main interfaces.
    • Nordic Android BLE Library: We use a lot of Nordic Semiconductor’s hardware at Punch Through, and their Nordic SDK is a great way to write embedded firmware. This library is their take on abstracting away the complexities and dangers of the official Android BLE SDK. Great community and contributors who are responsive to questions. Highly recommended.
    • BleGattCoroutines: Based on and requires knowledge of Kotlin coroutines. This is a great library to use if you’re comfortable with Kotlin coroutines or if your app uses many of them.
    • SweetBlue: SweetBlue was a popular library back in its 1.x and 2.x days, but it’s not free nor open source anymore since version 3.0. Nevertheless, this is based on conventional Java-style callback interfaces, much like the APIs provided in the Android SDK, and should be the most beginner-friendly to start working with. If you’re a tinkerer, you can fork the project from its 2.x days, learn about its inner workings, and customize it to your liking.

    There are many other open-source libraries on GitHub. Still, we recommend our readers try implementing the BLE bits and pieces themselves because we feel it’s far more rewarding to learn about all the moving parts and implement them yourself.

    The Risks of Relying on Third-Party Libraries 

    Relying on a third-party dependency also means that your app is vulnerable to lapses in maintenance or upkeep if an Android update breaks the library’s functionalities in the future. If you’re doing this for your company, there may be licensing and IP issues. That said, if you still think an open-source library is the way to go, we respect that decision.


    Final Thoughts…

    Congratulations! You’ve reached the end of our Ultimate Guide to Android Bluetooth Low Energy. By now, you should have a solid grasp of the fundamentals and key concepts to build your BLE apps on Android. The best way to cement this knowledge is to dive in and experiment. Don’t be afraid to get your hands dirty with code and explore what’s possible. Also, remember to leverage our examples and code repo to help.

    We hope you found this ultimate guide valuable. If you want to deepen your understanding of BLE further, we recommend pairing this guide with our other ultimate guide below. It’ll give you a great overarching view of BLE development.

    Now go forth and create something amazing with BLE on Android! We’re excited to see what you build. Happy coding!

    Punch Through's Mobile App Development for Android

    For more expert connectivity development resources, check out our other great guides:

    The Ultimate Guide to BLE Connectivity Architecture

    BLE Connectivity Architecture

    Explore the foundational topics you need to understand and effectively plan and architect a BLE-connected device. Plus, get an inside look at how we at Punch Through tackle the discovery, planning, and architecture stages.

    The Ultimate Guide to Managing Your BLE Connection

    Managing Your BLE Connection

    Learn all the basics of managing your BLE connection, peripheral advertising, and power consumption vs latency considerations for connected devices.

    The Ultimate Guide to Maximizing BLE Throughput

    Maximizing BLE Throughput, Part 4

    Discover techniques and best practices to optimize data transfer rates and maximize throughput when developing Bluetooth Low Energy (BLE) applications.