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" />
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,
)
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
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()
}
}
}
// 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
}
}
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)
}
}
}
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()
}
}
The last lines to complete the BleScanManager
are the following
private fun executeBeforeScanActions() {
executeListOfFunctions(beforeScanActions)
}
private fun executeAfterScanActions() {
executeListOfFunctions(afterScanActions)
}
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 }
}
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>
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>
Conclusions
This is all to create a simple Bluetooth LE scanner app in Android.
The results will look something like this
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)