DEV Community

Cover image for How to Create a Bluetooth LE Scanner for Android
Lorenzo Felletti
Lorenzo Felletti

Posted on • Originally published at Medium

How to Create a Bluetooth LE Scanner for Android

Creating a Bluetooth LE scanner for Android is not too complicated. What you need is some understanding of the Android architecture and of Java/Kotlin.

The most difficult part of building such an app would be…managing the permissions, or even finding what are the required permissions (at least for me).

What You Need to Follow This Tutorial

  • Android Studio installed on your computer
  • A BLE enabled smartphone for testing running Android 12 (or later)
  • A basic understanding of Kotlin (or Java, at least)
  • A (very) basic understanding of how to create a UI in Android

Creating the Project

I will assume that you already know how to create a project in Android Studio, so create a new project, choose Empty Activity, and name it whatever you like, I named mine SimpleBleScanner. Also, select API 31 as the Minimum SDK.

I know that requiring Android 12 could be a bit too tight of a requirement, but this choice was made to be able to experiment with the latest features.

Anyway, if you have the necessity to use an earlier version of the SDK, I think you won’t have to change much of the code.

Managing the Permissions

First, we are going to declare in the Manifest (AndroidManifest.xml) all the permissions we will use in the application. In particular, for Bluetooth LE scanning, the minimum required permissions are the following:

<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Enter fullscreen mode Exit fullscreen mode

As weird as it might sound, access to coarse and fine location is required in order to use BLE.

Next, inside the package containing the MainActivity, I chose to create another package named permissions, in which to put all the permission utilities.

Inside this newly created package, create two Kotlin objects named PermissionsUtilities and BleScanRequiredPermissions. These two objects will help us in managing the permissions without writing too much code inside the MainActivity.

BleScanRequiredPermissions will contain only the array of the permissions we are going to require for our app.

val permissions = arrayOf(
    Manifest.permission.BLUETOOTH_SCAN,
    Manifest.permission.ACCESS_COARSE_LOCATION,
    Manifest.permission.ACCESS_FINE_LOCATION,
    Manifest.permission.BLUETOOTH_ADMIN,
)
Enter fullscreen mode Exit fullscreen mode

PermissionsUtilities, on the other hand, is a more complex object, containing all the utility functions to manage permissions in our application.

Anyway, the focus of the article is not on permissions handling, so I will go fast on this part. Feel free to check the permissions package by yourself if interested.

The main functions of this module are:

/**
* Checks for a set of permissions. If not granted, the user is asked to grant them.
*
* @param activity The activity that is requesting the permissions
* @param permissions The permissions to be checked
* @param requestCode The request code to be used when requesting the permissions
*/
fun checkPermissions(activity: Activity, permissions: Array<out String>, requestCode: Int)

/**
* Checks whether a set of permissions is granted or not
*
* @param context The context to be used for checking the permissions
* @param permissions The permissions to be checked
*
* @return true if all permissions are granted, false otherwise
*/
fun checkPermissionsGranted(context: Context, permissions: Array<out String>): Boolean

/**
* Checks the result of a permission request, and dispatches the appropriate action
*
* @param requestCode The request code of the permission request
* @param grantResults The results of the permission request
* @param onGrantedMap maps the request code to the action to be performed if the permissions are granted
* @param onDeniedMap maps the request code to the action to be performed if the permissions are not granted
*/
fun dispatchOnRequestPermissionsResult(
    requestCode: Int, grantResults: IntArray,
    onGrantedMap: Map<Int, () -> Unit>,
    onDeniedMap: Map<Int, () -> Unit>
)

/**
* Checks whether a permission is granted in the context
*
* @param context The context to be used for checking the permission
* @param permission The permission to be checked
*
* @return true if the permission is granted, false otherwise
*/
private fun checkPermissionGranted(context: Context, permission: String): Boolean

/**
* Checks the results of a permission request
*
* @param grantResults The results of the permission request
*
* @return true if all permissions were granted, false otherwise
*/
private fun checkGrantResults(grantResults: IntArray): Boolean
Enter fullscreen mode Exit fullscreen mode

Otherwise, you could check this article about a permission management library I made, and try to use it instead… Android Permissions Made Easy.

Scanning for devices

To manage the scanning, we will create a new package named blescanner, containing two sub-packages named model and adapter.

The model the classes modeling the BleDevice and the BleScanCallback, while the adapter will contain the BleDeviceAdapter, used as adapter for the RecyclerView showing the results of the scan.

//BleDevice.kt
data class BleDevice(val name: String) {
    companion object {
        fun createBleDevicesList(): MutableList<BleDevice> {
            return mutableListOf()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// BleDeviceAdapter.kt
class BleDeviceAdapter(private val devices: List<BleDevice>) : RecyclerView.Adapter<BleDeviceAdapter.ViewHolder>() {
    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val deviceNameTextView: TextView = itemView.findViewById<TextView>(R.id.device_name)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val context = parent.context
        val inflater = LayoutInflater.from(context)
        val deviceView = inflater.inflate(R.layout.device_row_layout, parent, false)
        return ViewHolder(deviceView)
    }

    override fun onBindViewHolder(holder: BleDeviceAdapter.ViewHolder, position: Int) {
        val device = devices[position]
        val textView = holder.deviceNameTextView
        textView.text = device.name
    }

    override fun getItemCount(): Int {
        return devices.size
    }
}
Enter fullscreen mode Exit fullscreen mode

Other than these three classes, we will create a BleScanManager, that will manage the scanning.

The BleScanManager class contains various members to manage the scanning state and what to do before/after the scanning.


class BleScanManager(
    btManager: BluetoothManager,
    private val scanPeriod: Long = DEFAULT_SCAN_PERIOD,
    private val scanCallback: BleScanCallback = BleScanCallback()
) {
    private val btAdapter = btManager.adapter
    private val bleScanner = btAdapter.bluetoothLeScanner

    var beforeScanActions: MutableList<() -> Unit> = mutableListOf()
    var afterScanActions: MutableList<() -> Unit> = mutableListOf()

    /** True when the manager is performing the scan */
    private var scanning = false

    private val handler = Handler(Looper.getMainLooper())

    /**
     * Scans for Bluetooth LE devices and stops the scan after [scanPeriod] seconds.
     * Does not checks the required permissions are granted, check must be done beforehand.
     */
    @SuppressLint("MissingPermission")
    fun scanBleDevices() {
        fun stopScan() {
            scanning = false
            bleScanner.stopScan(scanCallback)

            // execute all the functions to execute after scanning
            executeAfterScanActions()
        }

        // scans for bluetooth LE devices
        if (scanning) {
            stopScan()
        } else {
            // stops scanning after scanPeriod millis
            handler.postDelayed({ stopScan() }, scanPeriod)
            // execute all the functions to execute before scanning
            executeBeforeScanActions()

            // starts scanning
            scanning = true
            bleScanner.startScan(scanCallback)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The constructor will take as input a BluetoothManager, a scan period (set to a default value if omitted), and a list of actions to do before and after the scanning. The list of actions is nothing but a list of functions that will be invoked before/after the scanning start.

The Handler is used to schedule the stopping of the scan after the scan period time.

The companion object of the BleScanManager will contain the default value of the scan period, and a function to execute the list of actions.

companion object {
    const val DEFAULT_SCAN_PERIOD: Long = 10000

    private fun executeListOfFunctions(toExecute: List<() -> Unit>) {
        toExecute.forEach {
            it()
        }
    }
Enter fullscreen mode Exit fullscreen mode

The last lines to complete the BleScanManager are the following

private fun executeBeforeScanActions() {
    executeListOfFunctions(beforeScanActions)
}

private fun executeAfterScanActions() {
    executeListOfFunctions(afterScanActions)
}
Enter fullscreen mode Exit fullscreen mode

The Main Activity

The main activity will contain the code that “connects” all the modules we created so far:

  • the module managing the permissions
  • the BLE scan manager
  • the adapter for the RecyclerView

The code is pretty straightforward, the only two methods are the onCreate and the onRequestPermissionResult.

class MainActivity : AppCompatActivity() {
    private lateinit var btnStartScan: Button
    private lateinit var btManager: BluetoothManager
    private lateinit var bleScanManager: BleScanManager
    private lateinit var foundDevices: MutableList<BleDevice>

    @SuppressLint("NotifyDataSetChanged", "MissingPermission")
    @RequiresApi(Build.VERSION_CODES.S)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // RecyclerView handling
        val rvFoundDevices = findViewById<View>(R.id.rv_found_devices) as RecyclerView
        foundDevices = BleDevice.createBleDevicesList()
        val adapter = BleDeviceAdapter(foundDevices)
        rvFoundDevices.adapter = adapter
        rvFoundDevices.layoutManager = LinearLayoutManager(this)

        // BleManager creation
        btManager = getSystemService(BluetoothManager::class.java)
        bleScanManager = BleScanManager(btManager, 5000, scanCallback = BleScanCallback({
            val name = it?.device?.address
            if (name.isNullOrBlank()) return@BleScanCallback

            val device = BleDevice(name)
            if (!foundDevices.contains(device)) {
                foundDevices.add(device)
                adapter.notifyItemInserted(foundDevices.size - 1)
            }
        }))

        // Adding the actions the manager must do before and after scanning
        bleScanManager.beforeScanActions.add { btnStartScan.isEnabled = false }
        bleScanManager.beforeScanActions.add {
            foundDevices.clear()
            adapter.notifyDataSetChanged()
        }
        bleScanManager.afterScanActions.add { btnStartScan.isEnabled = true }

        // Adding the onclick listener to the start scan button
        btnStartScan = findViewById(R.id.btn_start_scan)
        btnStartScan.setOnClickListener {
            // Checks if the required permissions are granted and starts the scan if so, otherwise it requests them
            when (PermissionsUtilities.checkPermissionsGranted(
                this,
                BleScanRequiredPermissions.permissions
            )) {
                true -> bleScanManager.scanBleDevices()
                false -> PermissionsUtilities.checkPermissions(
                    this, BleScanRequiredPermissions.permissions, BLE_PERMISSION_REQUEST_CODE
                )
            }
        }
    }

    @RequiresApi(Build.VERSION_CODES.S)
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<out String>, grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        dispatchOnRequestPermissionsResult(
            requestCode,
            grantResults,
            onGrantedMap = mapOf(BLE_PERMISSION_REQUEST_CODE to { bleScanManager.scanBleDevices() }),
            onDeniedMap = mapOf(BLE_PERMISSION_REQUEST_CODE to { Toast.makeText(this,
                    "Some permissions were not granted, please grant them and try again",
                    Toast.LENGTH_LONG).show() })
        )
    }

    companion object { private const val BLE_PERMISSION_REQUEST_CODE = 1 }
}
Enter fullscreen mode Exit fullscreen mode

The latter is used to manage what to do “after” requesting the permissions to the user, while the former sets the ground for the application by setting the RecyclerView adapter, creating the BleScanManager, and setting the onClickListener for the button to start the scan.

Right after the BleScanManager is created, we add to it before/afterScanActions the code to enable/disable the scan button — because we want to disable the scan button while we’re performing it.

The onClickListener to start the scan checks first that the required permissions are granted, and, if so, it proceeds to start the scan, otherwise it starts permissions request flow.

The GUI

The GUI will be very (very) simple, just a Button to start the scanning, and a RecyclerView to show the scanning results.


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_start_scan"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/btn_start_scan_text"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.498"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.844" />

    <TextView
        android:id="@+id/lbl_scan_results"
        android:layout_width="163dp"
        android:layout_height="19dp"
        android:layout_marginTop="28dp"
        android:clickable="false"
        android:gravity="center"
        android:text="@string/tw_scan_results_title"
        android:textSize="16sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_found_devices"
        android:layout_width="375dp"
        android:layout_height="516dp"
        app:layout_constraintBottom_toTopOf="@+id/btn_start_scan"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.444"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/lbl_scan_results"
        app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
Enter fullscreen mode Exit fullscreen mode

Other than the activity_main.xml we will need another layout file to define the layout of a RecyclerView row.


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="10dp"
    android:paddingBottom="10dp"
    >
    <TextView
        android:id="@+id/device_name"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        />
</LinearLayout>
Enter fullscreen mode Exit fullscreen mode

Conclusions

This is all to create a simple Bluetooth LE scanner app in Android.

The results will look something like this

final result

The application's UI while performing a scan

As you can see, the app is very simple on the UI side, this because I wanted to focus more on the functionality. If you want, feel free to fork the repo and improve the application’s UI, or extend its functionalities.

This is all for this article, I hope you enjoyed reading it and that you found it useful. Let me know with a comment!


Cover image by Zarak Khan on Unsplash

Top comments (0)