Skip to Main Content
Ultimate Guide to BLE and Android

The Ultimate Guide to Android Bluetooth Low Energy

Table of Contents
    56 minute read

    Note: This blog post is up-to-date for compileSdkVersion and targetSdkVersion 33 (Android 13), and was last updated on Nov 14, 2022.


    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 76% of market share worldwide.

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

    For new readers not familiar with who we are and what we do, we are Punch Through: an engineering consulting firm specializing in firmware, software mobile, and hardware solutions that help engineering leaders and teams through the complex journey of building a Bluetooth product.

    In this post, we’ll go over the basics of BLE that Android developers need to know, as well as walk through some simple yet real-world examples of performing common BLE operations on Android like scanning, connecting, reading, writing and setting up indications or notifications. Most code snippets were written in Kotlin, but they translate well over to Java too.

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

    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

    Term & Definition
    BLE
    Bluetooth Low Energy, a subset of the 2.4 GHz Bluetooth wireless technology that specializes in low power and oftentimes infrequent data transmissions for connected devices.
    Central/Client
    A device that scans for and connects to BLE peripherals in order 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 by a central in order to accomplish some task. In the context of 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 feature of a device, e.g. the Device Information service can contain a characteristic representing the serial number of the device, and another characteristic representing the battery level of the device.
    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 that 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 means for a BLE peripheral to notify the central when a characteristic’s value changes. The central doesn’t need to acknowledge that it’s received the packet.
    Indications
    Same as a notification, except each data packet is acknowledged by the central. This guarantees their delivery at the cost of throughput.
    UUID
    Universally unique identifier, 128-bit number used 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 for transmitting 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 important distinction between Bluetooth Classic and BLE 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 implementation for a limited number of Bluetooth Classic profiles out of the box.
    • Once paired and connected, you need to spin up and manage a dedicated Android Thread object yourself in order 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, but 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’s no longer able to be connected to.

    It helps to also 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 which the client (central) accesses via BLE. It’s important to note that your Android device can also behave as a peripheral, but for this post 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 3 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 some server-side operation in response to it. 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 upon a protocol that has been 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 blog post, we’ll assume the app is targeting 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 off by introducing the main classes from the Android SDK that 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 that are bonded to Android, and also provides us with the ability 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, whereas ACCESS_FINE_LOCATION is required for Android 10 and above.
    ScanFilter
    Allows us 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
    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 also 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. Key information provided by this class include: the device name if it’s available, the device’s MAC address, and the device’s current bond state.
    BluetoothGatt
    Entry point to the BLE device’s GATT profile. Allows us to perform service discovery, 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 a BLE connection that was established.
    BluetoothGattService, BluetoothGattCharacteristic and BluetoothGattDescriptor
    Wrapper classes representing GATT services, characteristics and descriptors, as defined in the Table of Glossary earlier in this post.
    BluetoothGattCallback
    The main interface that the app has to implement in order to receive callbacks for most BluetoothGatt-related operations like reading, writing, or getting notified about incoming notifications or indications.

    Quick tips to navigate your way around the Android BLE API

    If there’s only one thing to take away from this post, 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 post, 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 to happen for a previous operation before you initiate a new one. This may sound like it only matters for successive read and write operations, but it actually applies to all BLE operations, including connecting, service discovery, MTU request, and even connection teardown. Don’t worry, as we’ll guide you through implementing your own queuing mechanism in this post as well.
    • Try to not 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 a 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 very specific use case that is complemented by these private APIs, developer should typically 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 post, we’ll walk you through creating your own Android app that communicates with a BLE device. This post aims 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 implementation is available on 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!

    Project set up basics

    For apps that utilize the BLE APIs, we like to go with Kotlin as the main language and we support a minimum API level of 21 (Android 5.0 Lollipop). Check out our Android architecture post for more information on why we made these choices! Finally, make sure “Use androidx.* artifacts” is checked in the project wizard. If you’re working on an existing project, ensure that you’ve migrated to AndroidX because Google is moving their support libraries to the AndroidX package.

    This blog post is up-to-date with targetSdkVersion of 33 (Android 13).

    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 out 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 own apps.
    • All code belonging to a screen is stuffed into the Activity class representing that screen. For your own app, you should determine if some of the code can be refactored out 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 to handle the most egregious of 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 not met. In other words, we assume all pieces of information are always present when we need them. In your own 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 in order 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 explicitly require the users to grant this permission 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 device 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 request for this permission in addition to ACCESS_FINE_LOCATION.
    • BLUETOOTH_SCAN: For devices running Android 12 and newer, developers can finally request for explicit permission to perform Bluetooth scans without having to obtain location access.
    • BLUETOOTH_CONNECT: For devices running Android 12 and newer, developers can request for this permission in order to connect to Bluetooth peripherals that are currently bonded to the system.

    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 install 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 to you, add the following snippet below the <uses-permission> tags.

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

    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 the users keep Bluetooth enabled while they’re using your app.

    Head to your launch Activity and add a top-level constant definition for the request code corresponding to the Bluetooth-enabling action we’ll soon request from the user. It can be any positive integer value:

    private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1

    Next, 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
    }
     
    override fun onResume() {
        super.onResume()
        if (!bluetoothAdapter.isEnabled) {
            promptEnableBluetooth()
        }
    }
     
    private fun promptEnableBluetooth() {
        if (!bluetoothAdapter.isEnabled) {
            val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
            startActivityForResult(enableBtIntent, ENABLE_BLUETOOTH_REQUEST_CODE)
        }
    }

    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 enables Bluetooth on their Android device.

    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. You’ll notice that if you disable Bluetooth and get to your Activity, a system dialog shows up prompting you to enable Bluetooth. If you dismiss the dialog, the dialog goes away without doing anything. 

    Since the app requires Bluetooth to be enabled before it can do anything, we want to be able to react to the user’s selection for the system alert. We can do this by overriding onActivityResult and checking the requestCode and resultCode:

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when (requestCode) {
            ENABLE_BLUETOOTH_REQUEST_CODE -> {
                if (resultCode != Activity.RESULT_OK) {
                    promptEnableBluetooth()
                }
            }
        }
    }
    

    In our code snippet, what this allows us to do is 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.

    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 required runtime permissions have been granted by the user. The following Context extension functions work together to do just this:

    fun Context.hasPermission(permissionType: String): Boolean {
        return ContextCompat.checkSelfPermission(this, permissionType) ==
            PackageManager.PERMISSION_GRANTED
    }
    
    fun Context.hasRequiredRuntimePermissions(): 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 another top-level constant declaration outside your Activity’s class declaration:

    private const val RUNTIME_PERMISSION_REQUEST_CODE = 2

    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, and that it has an ID called “scan_button”.

    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:

    scan_button.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 (!hasRequiredRuntimePermissions()) {
            requestRelevantRuntimePermissions()
        } else { /* TODO: Actually perform scan */ }
    }
    
    private fun Activity.requestRelevantRuntimePermissions() {
        if (hasRequiredRuntimePermissions()) { return }
        when {
            Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
                requestLocationPermission()
            }
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
                requestBluetoothPermissions()
            }
        }
    }
     
    private fun requestLocationPermission() {
        runOnUiThread {
            alert {
                title = "Location permission required"
                message = "Starting from Android M (6.0), the system requires apps to be granted " +
                    "location access in order to scan for BLE devices."
                isCancelable = false
                positiveButton(android.R.string.ok) {
                    ActivityCompat.requestPermissions(
                        this,
                        arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                        RUNTIME_PERMISSION_REQUEST_CODE
                    )
                }
            }.show()
        }
    }
    
    private fun requestBluetoothPermissions() {
        runOnUiThread {
            alert {
                title = "Bluetooth permissions required"
                message = "Starting from Android 12, the system requires apps to be granted " +
                    "Bluetooth access in order to scan for and connect to BLE devices."
                isCancelable = false
                positiveButton(android.R.string.ok) {
                    ActivityCompat.requestPermissions(
                        this,
                        arrayOf(
                            Manifest.permission.BLUETOOTH_SCAN,
                            Manifest.permission.BLUETOOTH_CONNECT
                        ),
                        RUNTIME_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 hasRequiredRuntimePermissions(), an extension function that we’ve shared earlier.

    If at least one runtime permissions is missing, an alert will then be shown, informing the users 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 RUNTIME_PERMISSION_REQUEST_CODE:

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            RUNTIME_PERMISSION_REQUEST_CODE -> {
                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 && hasRequiredRuntimePermissions() -> {
                        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 that on Android 11 and newer, a repeated denial is implicitly treated as a permanent denial by Android, and the app can no longer prompt for 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 Bluetooth 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.

    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, we 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 BLE scan with optional scan results filtering.
    2. Obtain ScanResult object.
    3. Call ScanResult object’s getDevice() method to obtain BluetoothDevice object.
    4. Call BluetoothDevice object’s connectGatt() method to initiate a BLE connection.

    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 UUID. The app then performs scan filtering based on the UUID, and since UUIDs are unique enough for practical purposes, we can expect the scan to only 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 that are scanning in the foreground should use SCAN_MODE_BALANCED for scans that’ll go on for longer than approximately 30 seconds.
      • SCAN_MODE_LOW_LATENCY is recommended if the app will only be scanning for a brief period of time, typically to find a very specific type of device.
      • SCAN_MODE_LOW_POWER is used for extremely long-duration scans, or for scans that take place 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 encountered BLE advertisement packets.
      • 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 ones that are outdated (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 that is 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 that you’ll see while performing BLE scans is the undocumented “App is scanning too frequently” error. Android has an internal limit of 5 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 went beyond this limit, the scan would appear to have started but no scan results would get delivered to your callback body. After 30 seconds have elapsed, your app should call stopScan() followed by startScan(...) again in order 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 and perhaps warn your users about if 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 to this is to request for the user to disable and re-enable Bluetooth on their device. If this fails, which it mostly does, the next and only thing to do would be to 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 a whole lot other than making sure that the location permission has been granted by the user. In this section, we’ll 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, meaning 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 bluetoothAdapter and also bleScanner to when we actually need them, we avoid a crash that would happen if bluetoothAdapter was initialized before onCreate() has returned.

    Before we can start scanning, we need to specify the scan settings. You can tweak yours accordingly based on what we talked about 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 an 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 { scan_button.text = if (value) "Stop Scan" else "Start Scan" }
        }
    

    We also override its setter so that whenever the field’s value changes, we update scan_button’s text to be the opposite of the scan status. The user will tap on scan_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 (!hasRequiredRuntimePermissions()) {
            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 a means to start and stop a scan, we can update the scan_button’s OnClickListener to now perform the right operation for a given isScanning value.

    scan_button.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 in the vicinity. 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, which is an Android component that allows you to display a collection of items in a scrollable interface.

    First, you’ll want to implement your RecyclerView.Adapter class and your layout XML for each scan result. To keep this post focused on the BLE aspect of things, we won’t go into details in this post. Instead, you can find our example implementation here. We’ll continue this section assuming your adapter class has been written similarly to ours, and that 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 (!hasRequiredRuntimePermissions()) {
            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 ScanResults belonging to the same set of devices, but with updated signal strength (RSSI) readings. In order to keep the UI up to date with the latest RSSI readings, we first check to see if our scanResults 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. In the event that this is a new scan result, we’ll add it to our scanResults 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 be updating 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 be used to 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: 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 this blog post. The other overloads are available only from API level 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 if the device is already discoverable in the immediate vicinity. Our preference when developing BLE apps is to set the flag to false, and instead rely on the onConnectionStateChange callback to inform us on 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 variants of connectGatt(...) return a BluetoothGatt object, which can be thought of as a handle on the BLE connection we’re establishing which allows 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 themselves, and what they do are well-documented, so we’ll leave the explaining 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 that is also 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 that’s 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 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 status code is either GATT_INSUFFICIENT_ENCRYPTION or GATT_INSUFFICIENT_AUTHENTICATION: call createBond() first and wait for the bonding process to succeed first before calling connectGatt() again. We’ll go through how to initiate a bond with a BLE device in a later section.
    • 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 actually a viable strategy, considering the fact 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 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 then 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 callback = 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 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 a blog post 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 and BLE devices, so it’s prudent to not make any assumptions with regards 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 for a larger ATT MTU so that more information can be exchanged per transmission.

    This can be done easily enough on the Android side 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 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)

    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 that is different from what was originally requested, because the request has to be negotiated between Android and the BLE device’s firmware.

    override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
        Timber.w("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 firmwares that don’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.

    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 that 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 an important thing 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 of those 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 documentations 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 “?” so we 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 an 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 if the read operation had succeeded or not.

    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")
                }
            }
        }
    }
     
    // ... 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).

    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 in order to calculate the final numerical value. Most BLE firmware 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 are 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 support one of the write types but can also support both write types as well:

    • Write with response. Also known as write request, this write type requires acknowledgement 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 write command, this write type requires no acknowledgement from the BLE device. Albeit a rare occurrence for most use cases, it is technically possible for the write to be ignored or dropped due to 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 response 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 as opposed to 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 to be written to supports 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 MTU, Data Length Extension (see our blog post 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.

    BluetoothGatt’s writeCharacteristic() method takes in the BluetoothGattCharacteristic we’re writing to as its sole argument. So, how do we specify the write type and the payload we’re writing? The answer is we have to set those fields on the BluetoothGattCharacteristic itself by using setWriteType() and setValue().

    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 ->
            characteristic.writeType = writeType
            characteristic.value = payload
            gatt.writeCharacteristic(characteristic)
        } ?: error("Not connected to a BLE device!")
    }

    This function chooses the most appropriate write type based on the characteristic’s properties. Note how we’re prioritizing using write with response if a characteristic happens to support both write types — write without response will only ever 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 around such 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 to not 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) {
            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.

    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 post, 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 post, notifications and indications are a means for a BLE device (peripheral) to inform the Android device (central) when a characteristic’s value changes, and in practice is 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 response 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 acknowledgement by the client when it receives the packet, whereas indications do require this sort of acknowledgement and is therefore slower. This acknowledgement 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 by 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.

    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 ->
            descriptor.value = payload
            gatt.writeDescriptor(descriptor)
        } ?: error("Not connected to a BLE device!")
    }
    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:

    override fun onCharacteristicChanged(
        gatt: BluetoothGatt,
        characteristic: BluetoothGattCharacteristic
    ) {
        with(characteristic) {
            Log.i("BluetoothGattCallback", "Characteristic $uuid changed | value: ${value.toHexString()}")
        }
    }

    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! Our work is far from done, however. 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 10’s Settings app uses the copy “Pair new device.” This 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 the 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 because 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’ll be able to 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).

    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 surprisingly 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, and 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 to 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, but as mentioned in our Android BLE development tips blog post, 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, and also if the app is 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 very 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 changes

    Bond state change updates are delivered via a Broadcast with an action of ACTION_BOND_STATE_CHANGED and processed with a BroadcastReceiver. 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 address property of the device to make sure 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) {
        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 = getParcelableExtra<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"
        }
    }

    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.

    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.

    Programmatic ways aside, you can achieve the same effect by instructing your users to navigate to Bluetooth settings and forgetting a bonded device from there.

    Implementing a basic queuing mechanism

    In this section, we’ll provide some pointers on how you can go about implementing 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 post, as well as in our BLE pitfalls blog post, performing BLE operations back-to-back in a rapidfire 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, and you intend to chain these operations, you’ll quickly realize that, oftentimes, only the first operation would succeed and all the others seemingly dropping into a blackhole, 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 proceed with say, updating 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 have 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 make sure 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 operation.

    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, which is 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 been helpful in giving you ideas on how to go about implementing your own queuing mechanism. Check out this commit on our code sample repo to see our full implementation.

    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.

    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 on implementing your own foreground service, so we won’t go into details here. 

    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. The probability of getting terminated 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.

    Using a foreground service isn’t entirely bulletproof either, as Android P and above has 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. As of now, 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.

    Other Android background processing techniques like relying on AlarmManager and WorkManager are also viable, but connection events won’t be instantaneous and there may be some missed BLE notifications or indications if the app happens to not be running 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, and can instead wake up periodically, connect to the BLE device again (if it’s in the vicinity), 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.

    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 mocked 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 a lot of it.
    • 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 obviously many other libraries that are open source on GitHub, but 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 implementing them yourself.

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

    In case you run into any unforeseen issues that are affecting your delivery timeline, feel free to get in touch with our team!

    Conclusion

    This post covered all the fundamentals you need to know about getting started with BLE development on Android. We hope these have been helpful and informative to you! If there’s anything you think we missed, or if you have feedback or ideas for improvement, please contact us.

    Stay tuned for other future blog posts that’ll expand on certain topics covered in this post. For more resources, check out our other blog posts on Android development:

    4 Tips to Make Android BLE Actually Work
    Our Preferred Android Development Tools and Patterns

    Interested in Learning More?

    We're all too familiar with the difficult journey of product development that's filled with technical and cultural hurdles. Software, Firmware, Hardware, and Mobile Development shouldn't be that hard, and that's why we're here to help.