()
35 |
36 | /* Binding */
37 | private lateinit var binding: FragmentSingleDeviceBinding
38 |
39 | /* Bluetooth variables */
40 | private var ble: BLE? = null
41 | private var connection: BluetoothConnection? = null
42 |
43 | /* Misc */
44 | private var command = false
45 | private var isObserving: Boolean = false
46 |
47 | // region Fragment creation related methods
48 | override fun onCreate(savedInstanceState: Bundle?) {
49 | super.onCreate(savedInstanceState)
50 |
51 | // If you intend to use the permission handling, you need to instantiate the library in the onCreate method
52 | setupBluetooth()
53 | }
54 |
55 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
56 | binding = FragmentSingleDeviceBinding.inflate(inflater, container, false)
57 | return binding.root
58 | }
59 |
60 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
61 | super.onViewCreated(view, savedInstanceState)
62 |
63 | setupBinding()
64 | updateStatus(false, "Starting...")
65 | requestPermissions()
66 | }
67 | // endregion
68 |
69 | // region Fragment destruction related methods
70 | override fun onDestroy() {
71 | super.onDestroy()
72 |
73 | lifecycleScope.launch {
74 | // Closes the connection with the device
75 | connection?.close()
76 | connection = null
77 |
78 | // Destroys the ble instance
79 | ble?.stopScan()
80 | ble = null
81 | }
82 | }
83 | // endregion
84 |
85 | // region Private utility methods
86 | private fun showToast(message: String) {
87 | Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
88 | }
89 |
90 | private fun updateStatus(loading: Boolean, text: String) {
91 | binding.fsdCurrentStatus.text = "Current status: $text"
92 | binding.fsdClLoader.isVisible = loading
93 | }
94 |
95 | private fun setDeviceConnectionStatus(isConnected: Boolean) {
96 | binding.root.post {
97 | binding.fsdBtnToggle.isVisible = isConnected
98 | binding.fsdBtnRead.isVisible = isConnected
99 | binding.fsdBtnDisconnect.isVisible = isConnected
100 | binding.fsdBtnObserve.isVisible = isConnected
101 | binding.fsdBtnConnect.isVisible = !isConnected
102 | }
103 | }
104 |
105 | @SuppressLint("MissingPermission")
106 | private fun requestPermissions() = lifecycleScope.launch {
107 | Log.d("MainActivity", "Setting bluetooth manager up...")
108 |
109 | // Checks the bluetooth permissions
110 | val permissionsGranted = ble?.verifyPermissions(rationaleRequestCallback = { next ->
111 | showToast("We need the bluetooth permissions!")
112 | next()
113 | })
114 | if (permissionsGranted == false) {
115 | // Shows UI feedback if the permissions were denied
116 | showToast("Permissions denied!")
117 | return@launch
118 | }
119 |
120 | // Checks the bluetooth adapter state
121 | if (ble?.verifyBluetoothAdapterState() == false) {
122 | // Shows UI feedback if the adapter is turned off
123 | showToast("Bluetooth adapter off!")
124 | return@launch
125 | }
126 |
127 | // Checks the location services state
128 | if (ble?.verifyLocationState() == false) {
129 | // Shows UI feedback if location services are turned off
130 | showToast("Location services off!")
131 | return@launch
132 | }
133 | }
134 |
135 | private fun setupBluetooth() {
136 | Log.d("MainActivity", "Setting bluetooth manager up...")
137 |
138 | // Creates the bluetooth manager instance
139 | ble = BLE(this).apply {
140 | verbose = true// Optional variable for debugging purposes
141 | }
142 | }
143 |
144 | private fun setupBinding() {
145 | binding.apply {
146 | // Set the on click listeners
147 | fsdBtnRead.setOnClickListener { onButtonReadClick() }
148 | fsdBtnToggle.setOnClickListener { onButtonToggleClick() }
149 | fsdBtnObserve.setOnClickListener { onButtonObserveClick() }
150 | fsdBtnConnect.setOnClickListener { onButtonConnectClick() }
151 | fsdBtnDisconnect.setOnClickListener { onButtonDisconnectClick() }
152 | }
153 | }
154 | // endregion
155 |
156 | // region Event listeners
157 | private fun onButtonToggleClick() {
158 | lifecycleScope.launch {
159 | // Update variables
160 | updateStatus(true, "Sending data...")
161 |
162 | connection?.let {
163 | // According to the 'active' boolean flag, send the information to the bluetooth device
164 | val result = it.write(deviceCharacteristic, if (command) "0" else "1")
165 |
166 | // If the write operation was successful, toggle it
167 | if (result) {
168 | // Update variables
169 | updateStatus(false, "Sent!")
170 | command = !command
171 | } else {
172 | // Update variables
173 | updateStatus(false, "Information not sent!")
174 | }
175 | }
176 | }
177 | }
178 |
179 | private fun onButtonConnectClick() {
180 | lifecycleScope.launch {
181 | try {
182 | // Update variables
183 | updateStatus(true, "Connecting...")
184 |
185 | // Tries to connect with the provided mac address
186 | ble?.scanFor(macAddress = deviceMacAddress, timeout = 20000)?.let {
187 | onDeviceConnected(it)
188 | }
189 | } catch (e: ScanTimeoutException) {
190 | // Update variables
191 | setDeviceConnectionStatus(false)
192 | updateStatus(false, "No device found!")
193 | } catch (e: Exception) {
194 | e.printStackTrace()
195 | }
196 | }
197 | }
198 |
199 | private fun onButtonDisconnectClick() {
200 | lifecycleScope.launch {
201 | // Update variables
202 | updateStatus(true, "Disconnecting...")
203 |
204 | // Closes the connection
205 | connection?.close()
206 | connection = null
207 |
208 | // Update variables
209 | updateStatus(false, "Disconnected!")
210 | setDeviceConnectionStatus(false)
211 | }
212 | }
213 |
214 | private fun onButtonObserveClick() {
215 | this.connection?.let {
216 | // If the desired characteristic is not available to be observed, pick the first available one
217 | // This is not really needed, it is just that this would be nice to have for when using generic BLE devices with this sample app
218 | var candidates = it.notifiableCharacteristics
219 | if (candidates.isEmpty()) candidates = it.readableCharacteristics
220 | if (candidates.contains(deviceCharacteristic)) candidates = arrayListOf(deviceCharacteristic)
221 | val characteristic = candidates.first()
222 |
223 | // Observe
224 | if (isObserving) {
225 | it.stopObserving(characteristic)
226 | } else {
227 | it.observeString(characteristic, owner = this.viewLifecycleOwner, interval = 5000L) { new ->
228 | showToast("Value changed to $new")
229 | }
230 | }
231 |
232 | // Update the UI
233 | this.binding.fsdBtnObserve.setText(if (isObserving) R.string.fragment_single_device_observe_off_btn else R.string.fragment_single_device_observe_on_btn)
234 | this.isObserving = !isObserving
235 | }
236 | }
237 |
238 | private fun onButtonReadClick() {
239 | lifecycleScope.launch {
240 | // Update variables
241 | updateStatus(true, "Requesting read...")
242 |
243 | // If the desired characteristic is not available to be read, pick the first available one
244 | // This is not really needed, it is just that this would be nice to have for when using generic BLE devices with this sample app
245 | val characteristic = connection?.readableCharacteristics?.firstOrNull { it == deviceCharacteristic } ?: connection?.readableCharacteristics?.first()
246 | if (characteristic.isNullOrEmpty()) {
247 | updateStatus(false, "No valid characteristics...")
248 | return@launch
249 | }
250 |
251 | connection?.read(characteristic, Charsets.UTF_8)?.let { response ->
252 | if (response.isEmpty()) {
253 | updateStatus(false, "Error while reading")
254 | } else {
255 | showToast("Read value: $response")
256 | updateStatus(false, "Read successful")
257 | }
258 | }
259 |
260 | // Read remote connection rssi
261 | connection?.readRSSI()
262 | }
263 | }
264 |
265 | private fun onDeviceConnected(connection: BluetoothConnection) {
266 | connection.apply {
267 | this@SingleDeviceFragment.connection = connection
268 |
269 | // For larger messages, you can use this method to request a larger MTU
270 | // val success = connection?.requestMTU(512)
271 |
272 | // Define the on re-connect handler
273 | onConnect = {
274 | // Update variables
275 | setDeviceConnectionStatus(true)
276 | updateStatus(false, "Connected!")
277 | }
278 |
279 | // Define the on disconnect handler
280 | onDisconnect = {
281 | // Update variables
282 | setDeviceConnectionStatus(false)
283 | updateStatus(false, "Disconnected!")
284 | }
285 |
286 | // Update variables
287 | setDeviceConnectionStatus(true)
288 | updateStatus(false, "Connected!")
289 | }
290 | }
291 | // endregion
292 |
293 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # BLE Made Easy
2 |
3 | BLE on Android is verbose and hard. This library makes it easy to use.
4 |
5 | Kotlin-first library providing the simplest way to connect to BLE devices and communicate with them.
6 |
7 | [](https://jitpack.io/#LeandroSQ/android-ble-made-easy) [](https://android-arsenal.com/api?level=21) [](https://leandrosq.github.io/android-ble-made-easy/index.html)
8 |
9 | ## How to install it?
10 |
11 | - **Step 1.** Add the JitPack repository to your **project gradle file**
12 |
13 | ```groovy
14 | allprojects {
15 | repositories {
16 | ...
17 | maven { url 'https://jitpack.io' }
18 | }
19 | }
20 | ```
21 |
22 | - **Step 1.1** Only **if you have the file *settings.gradle*** at your project root folder
23 | - Add the JitPack repository to your **project settings.gradle file**
24 |
25 | ```groovy
26 | dependencyResolutionManagement {
27 | repositories {
28 | ...
29 | maven { url 'https://jitpack.io' }
30 | }
31 | }
32 | ```
33 | - Add the JitPack repository to your **project gradle file**
34 |
35 | ```groovy
36 | buildscript {
37 | repositories {
38 | ...
39 | maven { url 'https://jitpack.io' }
40 | }
41 | }
42 | ```
43 |
44 | - **Step 2.** Add the implementation dependency to your **app gradle file**
45 |
46 | ```groovy
47 | dependencies {
48 | ...
49 |
50 | implementation 'com.github.LeandroSQ:android-ble-made-easy:1.9.3'
51 |
52 | ...
53 | }
54 | ```
55 |
56 | - **Step 3.** Gradle sync
57 |
58 | - **Step 4.** Add these permissions to your **manifest.xml file**
59 |
60 | ```xml
61 |
64 |
67 |
68 |
69 |
71 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | ```
86 |
87 | And you are ready to go!
88 |
89 | ---
90 |
91 | ## How to use it?
92 |
93 | ### Permissions and hardware
94 |
95 | The library contains helper functions to handle permission and hardware requirements. You can use them to verify if the user has granted the permissions and if the hardware is available.
96 |
97 | #### Permissions request
98 |
99 | Asynchronous:
100 |
101 | ```kotlin
102 | ble.verifyPermissionsAsync(
103 | rationaleRequestCallback = { next ->
104 | // Include your code to show an Alert or UI explaining why the permissions are required
105 | // Calling the function bellow if the user agrees to give the permissions
106 | next()
107 | },
108 | callback = { granted ->
109 | if (granted) {
110 | // Continue your code....
111 | } else {
112 | // Include your code to show an Alert or UI indicating that the permissions are required
113 | }
114 | }
115 | )
116 | ```
117 |
118 | Or with coroutines:
119 |
120 | ```kotlin
121 | GlobalScope.launch {
122 | val granted = ble.verifyPermissions(
123 | rationaleRequestCallback = { next ->
124 | // Include your code to show an Alert or UI explaining why the permissions are required
125 | // Calling the function bellow if the user agrees to give the permissions
126 | next()
127 | }
128 | )
129 |
130 | if (granted) {
131 | // Continue your code....
132 | } else {
133 | // Include your code to show an Alert or UI indicating that the permissions are required
134 | }
135 | }
136 | ```
137 |
138 | #### Bluetooth hardware activation
139 |
140 | Asynchronous:
141 |
142 | ```kotlin
143 | ble.verifyBluetoothAdapterStateAsync { active ->
144 | if (active) {
145 | // Continue your code...
146 | } else {
147 | // Include your code to show an Alert or UI indicating that the Bluetooth adapter is required to be on in order to your project work
148 | }
149 | }
150 | ```
151 |
152 | Or with coroutines:
153 |
154 | ```kotlin
155 | GlobalScope.launch {
156 | if (ble.verifyBluetoothAdapterState()) {
157 | // Continue your code...
158 | } else {
159 | // Include your code to show an Alert or UI indicating that the Bluetooth adapter is required to be on in order to your project work
160 | }
161 | }
162 | ```
163 |
164 | #### Location services activation
165 |
166 | Asynchronous:
167 |
168 | ```kotlin
169 | ble.verifyLocationStateAsync{ active ->
170 | if (active) {
171 | // Continue your code...
172 | } else {
173 | // Include your code to show an Alert or UI indicating that Location is required to be on in order to your project work
174 | }
175 | }
176 | ```
177 |
178 | Or with coroutines:
179 |
180 | ```kotlin
181 | GlobalScope.launch {
182 | if (ble.verifyLocationState()) {
183 | // Continue your code...
184 | } else {
185 | // Include your code to show an Alert or UI indicating that Location is required to be on in order to your project work
186 | }
187 | }
188 | ```
189 |
190 | ### Create a BLE instance
191 |
192 | For interacting with the library you need to create a BLE instance. You can do it in 3 different ways:
193 |
194 | ```kotlin
195 | // For jetpack compose:
196 | val ble = BLE(componentActivity = this)
197 |
198 | // Or activities:
199 | val ble = BLE(activity = this)
200 |
201 | // Or fragments
202 | val ble = BLE(fragment = this)
203 | ```
204 |
205 | ### Fast scan for specific devices
206 |
207 | If you already know the device you wanna connect to, you could use this:
208 |
209 | Asynchronous:
210 |
211 | ```kotlin
212 | ble.scanForAsync(
213 | // You only need to supply one of these, no need for all of them!
214 | macAddress = "00:00:00:00",
215 | name = "ESP32",
216 | service = "00000000-0000-0000-0000-000000000000",
217 |
218 | onFinish = { connection ->
219 | if (connection != null) {
220 | // And you can continue with your code
221 | it.write("00000000-0000-0000-0000-000000000000", "Testing")
222 | } else {
223 | // Show an Alert or UI with your preferred error message about the device not being available
224 | }
225 | },
226 |
227 | onError = { errorCode ->
228 | // Show an Alert or UI with your preferred error message about the error
229 | }
230 | )
231 |
232 | // It is important to keep in mind that every single one of the provided arguments of the function shown above, are optionals! Therefore, you can skip the ones that you don't need.
233 | ```
234 |
235 | Or with coroutines:
236 |
237 | ```kotlin
238 | GlobalScope.launch {
239 | // You can specify filters for your device, being them 'macAddress', 'service' and 'name'
240 | val connection = ble.scanFor(
241 | // You only need to supply one of these, no need for all of them!
242 | macAddress = "00:00:00:00",
243 | name = "ESP32",
244 | service = "00000000-0000-0000-0000-000000000000"
245 | )
246 |
247 | // And it will automatically connect to your device, no need to boilerplate
248 | if (connection != null) {
249 | // And you can continue with your code
250 | it.write("00000000-0000-0000-0000-000000000000", "Testing")
251 | } else {
252 | // Show an Alert or UI with your preferred error message about the device not being available
253 | }
254 | }
255 | ```
256 |
257 | ### Scan for nearby devices
258 |
259 | Asynchronous:
260 |
261 | ```kotlin
262 | ble.scanAsync(
263 | duration = 10000,
264 |
265 | /* This is optional, if you want to update your interface in realtime */
266 | onDiscover = { device ->
267 | // Update your UI with the newest found device, in real time
268 | },
269 |
270 | onFinish = { devices ->
271 | // Continue with your code handling all the devices found
272 | },
273 | onError = { errorCode ->
274 | // Show an Alert or UI with your preferred error message
275 | }
276 | )
277 | ```
278 |
279 | Or with coroutines:
280 |
281 | ```kotlin
282 | GlobalScope.launch {
283 | try {
284 | // Continue with your code handling all the devices found
285 | val devices = ble.scan(duration = 10000)
286 | } catch (e: Exception) {
287 | // Show an Alert or UI with your preferred error message
288 | } catch (e: ScanFailureException) {
289 | // Show an Alert or UI with your preferred error message
290 | }
291 | }
292 | ```
293 |
294 | Or you could use the scan method without any timeout, only stopping it manually
295 |
296 | ```kotlin
297 | ble.scanAsync(
298 | duration = 0, // Disables the timeout
299 | onDiscover = { device ->
300 | // Update your UI with the newest found device, in real time
301 | },
302 | onError = { errorCode ->
303 | // Show an Alert or UI with your preferred error message
304 | }
305 | )
306 |
307 | // Stops your scan manually
308 | ble.stopScan()
309 | ```
310 |
311 | ### Connecting to a discovered device
312 |
313 | After a successful scan, you'll have your Bluetooth device to connect to it:
314 |
315 | ```kotlin
316 | ble.connect(device)?.let { connection ->
317 | // Continue with your code
318 | val value = connection.read("00000000-0000-0000-0000-000000000000")
319 | connection.write("00000000-0000-0000-0000-000000000000", "0")
320 | connection.close()
321 | }
322 | ```
323 |
324 | You can also define a priority for the connection, useful for higher priority tasks, to ensure preferential treatment for the connection. The default priority is `Priority.Balanced`, other options are `Priority.High` and `Priority.LowPower`.
325 |
326 | ```kotlin
327 | ble.connect(device, Priority.High)?.let { connection ->
328 | // Continue with your code
329 | val value = connection.read("00000000-0000-0000-0000-000000000000")
330 | connection.write("00000000-0000-0000-0000-000000000000", "0")
331 | connection.close()
332 | }
333 | ```
334 |
335 | ### Writing to a device
336 |
337 | After a successful scan, you'll have your Bluetooth device
338 |
339 | ```kotlin
340 | ble.connect(device)?.let { connection ->
341 | connection.write(characteristic = "00000000-0000-0000-0000-000000000000", message = "Hello World", charset = Charsets.UTF_8)
342 | connection.close()
343 | }
344 | ```
345 |
346 | ### Reading from a device
347 |
348 | After a successful scan, you'll have your Bluetooth device
349 | There's a catch, reading cannot be done on synchronously, so just like other methods you will have two options read and readAsync
350 |
351 | ```kotlin
352 | GlobalScope.launch {
353 | ble.connect(device)?.let { connection ->
354 | val value = connection.read(characteristic = "00000000-0000-0000-0000-000000000000")
355 | if (value != null) {
356 | // Do something with this value
357 | } else {
358 | // Show an Alert or UI with your preferred error message
359 | }
360 | }
361 | }
362 | ```
363 |
364 | Or you could use the read method with the 'async' prefix, providing a callback
365 |
366 | ```kotlin
367 | ble.connect(device)?.let { connection ->
368 | connection.readAsync(characteristic = "00000000-0000-0000-0000-000000000000") { value
369 | if (value != null) {
370 | // Do something with this value
371 | } else {
372 | // Show an Alert or UI with your preferred error message
373 | }
374 | }
375 | }
376 | ```
377 |
378 | ### Observing changes
379 |
380 | There are two ways to observe changes, the first is using the native BLE NOTIFY, which is the preferred option.
381 |
382 | ```kotlin
383 | // If you want to make use of the NOTIFY functionality
384 | ble.connect(device)?.let { connection ->
385 |
386 | // For watching bytes
387 | connection.observe(characteristic = "00000000-0000-0000-0000-000000000000") { value: ByteArray ->
388 | // This will run everytime the characteristic changes it's value
389 | }
390 |
391 | // For watching strings
392 | connection.observeString(characteristic = "00000000-0000-0000-0000-000000000000", charset = Charsets.UTF_8) { value: String ->
393 | // This will run everytime the characteristic changes it's value
394 | }
395 | }
396 |
397 | ```
398 |
399 | The second way is to manually read the characteristic in a fixed interval and compare with the last value. Which uses more battery, isn't as effective and should only be used when the characteristic doesn't provide the NOTIFY property.
400 | Fortunately the library handles both ways in a similar API.
401 |
402 | ```kotlin
403 | // If you want to use NOTIFY when available and fallback to the legacy way when it isn't
404 | ble.connect(device)?.let { connection ->
405 | connection.observe(
406 | characteristic = "00000000-0000-0000-0000-000000000000",
407 | owner = viewLifeCycleOwner, // The Lifecycle Owner to attach to
408 | interval = 1000L // The interval in ms (in this example 1 second)
409 | ) { value: ByteArray ->
410 | // This will run everytime the characteristic changes it's value
411 | }
412 | }
413 | ```
414 |
415 | ### MTU change request
416 |
417 | For write operations that require more than the default 23 bytes, you can request a MTU change, by doing the following:
418 | ```kotlin
419 | ble.connect(device)?.let { connection ->
420 | connection.requestMTU(bytes = 64)
421 | connection.write(characteristic = "00000000-0000-0000-0000-000000000000", message = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)) // Imagine a really long message here :)
422 | connection.close()
423 | }
424 | ```
425 |
426 | ### Forcing RSSI read
427 |
428 | ```kotlin
429 | ble.connect(device)?.let { connection ->
430 | if (connection.readRSSI()) { // This will enqueue a RSSI request read
431 | // Which will be reflected on
432 | Log.d("RSSI", connection.rssi)
433 | }
434 |
435 | }
436 | ```
437 |
438 | ### Sample App
439 |
440 | This repository also provides a working [Sample App](https://github.com/LeandroSQ/android-ble-made-easy/tree/master/app/src/main/java/quevedo/soares/leandro/blemadeeasy/sampleapp/view) which you can use as a reference.
441 |
442 | You can clone the repo and run it on your device.
443 |
444 | ---
445 |
446 | ## Why use it?
447 |
448 | ### Battle tested
449 |
450 | This library is battle tested in production apps, varying from industry, IoT and personal projects.
451 |
452 | It uses a compilation of techniques and best practices to handle known issues, for instance the [Issue 183108](https://code.google.com/p/android/issues/detail?id=183108), where Lolipop devices will not work properly without a workaround. Or the well-known [BLE 133](https://github.com/android/connectivity-samples/issues/18) error! **The nightmare of everyone** who has ever worked with BLE on Android.
453 |
454 | This library handles all of these issues for you, so you don't have to worry about it.
455 |
456 | ### Lifecycle
457 |
458 | This library is designed to work in Jetpack Compose, AndroidX and Android Support, also on Fragments, Activities and Services.
459 |
460 | ```kotlin
461 | // For jetpack compose:
462 | val ble = BLE(componentActivity = this)
463 | // For activities:
464 | val ble = BLE(activity = this)
465 | // For fragments
466 | val ble = BLE(fragment = this)
467 | ```
468 |
469 | ### Permissions
470 |
471 | This library handles all the permission requests for you, so you don't have to worry about it.
472 |
473 | ### Hardware activation
474 |
475 | The library handles the activation of the Bluetooth adapter hardware and the Location services, when required, so you don't have to worry about it.
476 |
477 | ### Asynchronous and Coroutines
478 |
479 | The library exposes asynchronous and coroutines methods for all the functions, so you can choose the one that fits better to your project.
480 |
481 | ### Operation queue
482 |
483 | All the operations, connections, reads and writes are queued, resulting in a more reliable and predictable behavior. When disconnecting, it will wait for the operations to finish before disconnecting, gracefully.
484 |
485 | ### Device cache
486 |
487 | The library caches the discovered devices, so you can connect to them without having to scan twice.
488 |
489 | ### Older APIs
490 |
491 | The library supports Android 5.0+ (API 21+), so you can use it in your projects.
492 |
493 | ### Kotlin
494 |
495 | From the beginning, this library was designed to be used in Kotlin and for Kotlin projects. Although it is theoretically possible to use it in Java projects, the main focus is on Kotlin.
496 |
497 | ### Documentation
498 |
499 | The library is fully documented, so you can easily understand how it works and how to use it.
500 |
501 | You can take a look on the online documentation [here](https://leandrosq.github.io/android-ble-made-easy/index.html).
502 |
503 | ### Bytes and Strings
504 |
505 | The library exposes read/write methods which converts the data to/from bytes and strings, so you don't have to worry about it.
506 |
507 | ### Observers
508 |
509 | The library exposes methods to observe changes in a characteristic, even when the NOTIFY property is not available.
510 |
511 |
512 |
513 |
514 |
515 | Made with
by Leandro Quevedo.
516 |
517 |
518 |
--------------------------------------------------------------------------------
/lib/src/main/java/quevedo/soares/leandro/blemadeeasy/BluetoothConnection.kt:
--------------------------------------------------------------------------------
1 | package quevedo.soares.leandro.blemadeeasy
2 |
3 | import android.Manifest
4 | import android.annotation.SuppressLint
5 | import android.bluetooth.*
6 | import android.content.Context
7 | import android.content.pm.PackageManager
8 | import android.os.Build.VERSION
9 | import android.os.Build.VERSION_CODES
10 | import android.util.Log
11 | import androidx.core.app.ActivityCompat
12 | import androidx.lifecycle.Lifecycle
13 | import androidx.lifecycle.LifecycleOwner
14 | import kotlinx.coroutines.*
15 | import quevedo.soares.leandro.blemadeeasy.enums.Priority
16 | import quevedo.soares.leandro.blemadeeasy.enums.GattState
17 | import quevedo.soares.leandro.blemadeeasy.enums.GattStatus
18 | import quevedo.soares.leandro.blemadeeasy.exceptions.ConnectionClosingException
19 | import quevedo.soares.leandro.blemadeeasy.models.BluetoothCharacteristic
20 | import quevedo.soares.leandro.blemadeeasy.models.BluetoothService
21 | import quevedo.soares.leandro.blemadeeasy.typealiases.Callback
22 | import quevedo.soares.leandro.blemadeeasy.typealiases.EmptyCallback
23 | import java.nio.charset.Charset
24 | import java.util.*
25 | import java.util.concurrent.atomic.AtomicInteger
26 | import kotlin.coroutines.resume
27 |
28 | typealias OnCharacteristicValueChangeCallback = (new: T) -> Unit
29 | typealias OnCharacteristicValueReadCallback = (new: T?) -> Unit
30 |
31 | @Suppress("unused")
32 | class BluetoothConnection internal constructor(private val device: BluetoothDevice) {
33 |
34 | /* Bluetooth */
35 | private var gatt: BluetoothGatt? = null
36 | private var closingConnection: Boolean = false
37 | private var connectionActive: Boolean = false
38 | private var priority: Priority = Priority.Balanced
39 |
40 | /* Callbacks */
41 | private var connectionCallback: Callback? = null
42 |
43 | /* Misc */
44 | private var operationsInQueue: AtomicInteger = AtomicInteger(0)
45 | private var activeObservers = hashMapOf>()
46 | private var enqueuedReadCallbacks = hashMapOf>()
47 |
48 | internal var coroutineScope: CoroutineScope? = null
49 |
50 | /**
51 | * Indicates whether additional information should be logged
52 | **/
53 | var verbose: Boolean = false
54 |
55 | /**
56 | * Called whenever a successful connection is established
57 | **/
58 | var onConnect: EmptyCallback? = null
59 |
60 | /**
61 | * Called whenever a connection is lost
62 | **/
63 | var onDisconnect: EmptyCallback? = null
64 |
65 | /**
66 | * Indicates whether the connection is active
67 | **/
68 | val isActive get() = this.connectionActive
69 |
70 | /**
71 | * Indicates the connection signal strength
72 | * Measured in dBm
73 | **/
74 | var rsii: Int = 0
75 | private set
76 |
77 | /**
78 | * Indicates the connection MTU, default 23
79 | * Measured in Bytes
80 | *
81 | * @see https://cs.android.com/android/platform/superproject/+/master:packages/modules/Bluetooth/system/stack/include/gatt_api.h;l=543;drc=6cf6099dcab87865e33439215e7ea0087e60c9f2#:~:text=%23define%20GATT_DEF_BLE_MTU_SIZE%2023
82 | **/
83 | var mtu: Int = 23
84 | private set
85 |
86 | /**
87 | * Holds the discovered services
88 | **/
89 | val services get() = this.gatt?.services?.map { BluetoothService(it) } ?: listOf()
90 |
91 | /** A list with all notifiable characteristics
92 | * @see observe
93 | **/
94 | val notifiableCharacteristics get() = this.dumpCharacteristics { it.isNotifiable }
95 |
96 | /** A list with all writable characteristics
97 | * @see write
98 | **/
99 | val writableCharacteristics get() = this.dumpCharacteristics { it.isWritable }
100 |
101 | /** A list with all readable characteristics
102 | * @see read
103 | **/
104 | val readableCharacteristics get() = this.dumpCharacteristics { it.isReadable }
105 |
106 | // region Utility related methods
107 | private fun log(message: String) {
108 | if (this.verbose) Log.d("BluetoothConnection", message)
109 | }
110 |
111 | private fun warn(message: String) {
112 | Log.w("BluetoothConnection", message)
113 | }
114 |
115 | private fun error(message: String) {
116 | Log.e("BluetoothConnection", message)
117 | }
118 |
119 | private fun dumpCharacteristics(filter: (BluetoothCharacteristic) -> Boolean): List {
120 | return this.services.map { service ->
121 | service.characteristics.filter { characteristic ->
122 | filter(characteristic)
123 | }.map { characteristic ->
124 | characteristic.uuid.toString().lowercase()
125 | }
126 | }.flatten().distinct()
127 | }
128 |
129 | @SuppressLint("MissingPermission")
130 | private fun setupGattCallback(): BluetoothGattCallback {
131 | return object : BluetoothGattCallback() {
132 |
133 | override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, state: Int) {
134 | super.onConnectionStateChange(gatt, status, state)
135 |
136 | val mappedStatus = GattStatus.fromCode(status)
137 | val mappedState = GattState.fromCode(state)
138 | log("onConnectionStateChange: status $status $mappedStatus state $state $mappedState")
139 |
140 | if (state == BluetoothProfile.STATE_CONNECTED) {
141 | if (status == BluetoothGatt.GATT_SUCCESS) {
142 | log("Device ${device.address} connected!")
143 |
144 | // Notifies that the connection has been established
145 | if (!connectionActive) {
146 | onConnect?.invoke()
147 | connectionActive = true
148 | }
149 |
150 | // Requests the connection priority
151 | if (gatt?.requestConnectionPriority(priority.toGattEnum()) != true) {
152 | log("Could not elevate connection priority to $priority!")
153 | }
154 |
155 | // Starts the services discovery
156 | this@BluetoothConnection.gatt?.discoverServices()
157 | } else {
158 | // Feedback of failed connection
159 | log("Something went wrong while trying to connect... STATUS: $mappedStatus\nForcing disconnect...")
160 |
161 | // Start disconnection
162 | startDisconnection()
163 | }
164 | } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
165 | if (status == 133) {// HACK: Multiple reconnections handler
166 | log("Found 133 connection failure! Reconnecting GATT...")
167 | } else if (closingConnection) {// HACK: Workaround for Lollipop 21 and 22
168 | endDisconnection()
169 | } else {
170 | // Notifies that the connection has been lost
171 | if (connectionActive) {
172 | log("Lost connection with ${device.address} STATUS: $mappedStatus")
173 | onDisconnect?.invoke()
174 | connectionActive = false
175 | }
176 | }
177 | }
178 | }
179 |
180 | override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
181 | super.onServicesDiscovered(gatt, status)
182 |
183 | coroutineScope?.launch {
184 | if (status == BluetoothGatt.GATT_SUCCESS) {
185 | log("onServicesDiscovered: ${gatt?.services?.size ?: 0} services found!")
186 | connectionCallback?.invoke(true)
187 | } else {
188 | error("Error while discovering services at ${device.address}! Status: $status")
189 | close()
190 | connectionCallback?.invoke(false)
191 | }
192 | }
193 | }
194 |
195 | override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) {
196 | super.onReadRemoteRssi(gatt, rssi, status)
197 |
198 | // Update the internal rsii variable
199 | if (status == BluetoothGatt.GATT_SUCCESS) {
200 | log("onReadRemoteRssi: $rssi")
201 | this@BluetoothConnection.rsii = rssi
202 | } else {
203 | error("Error while reading RSSI at ${device.address}! Status: $status")
204 | }
205 | }
206 |
207 | override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
208 | super.onMtuChanged(gatt, mtu, status)
209 |
210 | // Update MTU value
211 | if (status == BluetoothGatt.GATT_SUCCESS) {
212 | log("onMtuChanged: $mtu")
213 | this@BluetoothConnection.mtu = mtu
214 | } else {
215 | error("Error while changing MTU at ${device.address}! Status: $status")
216 | }
217 | }
218 |
219 | @Deprecated("Deprecated in Java")
220 | override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
221 | super.onCharacteristicChanged(gatt, characteristic)
222 | if (characteristic == null) return
223 |
224 | log("onCharacteristicChanged: $characteristic")
225 |
226 | val key = characteristic.uuid.toString().lowercase()
227 | coroutineScope?.launch {
228 | activeObservers[key]?.invoke(characteristic.value)
229 | }
230 | }
231 |
232 | @Deprecated("Deprecated in Java")
233 | override fun onCharacteristicRead(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) {
234 | super.onCharacteristicRead(gatt, characteristic, status)
235 | if (characteristic == null) return
236 |
237 | val key = characteristic.uuid.toString().lowercase()
238 | var value: ByteArray? = null
239 |
240 | if (status == BluetoothGatt.GATT_SUCCESS) {
241 | log("onCharacteristicRead: '${characteristic.uuid}' read '$key'")
242 | value = characteristic.value
243 | } else {
244 | error("onCharacteristicRead: Error while reading '$key' status: $status")
245 | }
246 |
247 | // Invoke callback and remove it from the queue
248 | coroutineScope?.launch {
249 | enqueuedReadCallbacks[key]?.invoke(value)
250 | enqueuedReadCallbacks.remove(key)
251 | }
252 | }
253 | }
254 | }
255 |
256 | private fun getCharacteristic(gatt: BluetoothGatt, characteristic: String): BluetoothCharacteristic? {
257 | // Converts the specified string into an UUID
258 | val characteristicUuid = UUID.fromString(characteristic)
259 |
260 | // Iterates trough every service on the gatt
261 | gatt.services?.forEach { service ->
262 | // Iterate trough every characteristic on the service
263 | service.getCharacteristic(characteristicUuid)?.let {
264 | return BluetoothCharacteristic(it)
265 | }
266 | }
267 |
268 | error("Characteristic $characteristic not found on device ${device.address}!")
269 | return null
270 | }
271 | // endregion
272 |
273 | // region Operation queue related methods
274 | private fun beginOperation() {
275 | // Don't allow operations while closing an active connection
276 | if (closingConnection) throw ConnectionClosingException()
277 |
278 | // Increment the amount of operations
279 | this.operationsInQueue.incrementAndGet()
280 | }
281 |
282 | private fun finishOperation() {
283 | // Decrement the amount of operations, because the current operation has finished
284 | this.operationsInQueue.decrementAndGet()
285 | }
286 | // endregion
287 |
288 | // region Misc
289 | /**
290 | * Request a MTU change
291 | * Note: even if this method returns true, it is possible that the other device does not accept it, so Android will fallback to the previous negotiated MTU value.
292 | * This will be reflected by the [mtu] variable.
293 | *
294 | * @see android.Manifest.permission.BLUETOOTH_CONNECT for Android 31+
295 | *
296 | * @return True when successfully requested MTU negotiation
297 | **/
298 | @SuppressLint("MissingPermission")
299 | fun requestMTU(bytes: Int): Boolean {
300 | if (bytes < 0) {
301 | this.error("MTU size should be greater than 0!")
302 | return false
303 | }
304 |
305 | if (bytes > GATT_MAX_MTU) {
306 | this.warn("Requested MTU is over the recommended amount of $GATT_MAX_MTU. Are you sure?")
307 | }
308 |
309 | // Request for MTU change
310 | this.log("Request MTU on device: ${device.address} (${bytes} bytes)")
311 | val success = this.gatt?.requestMtu(bytes) ?: false
312 | if (success) {
313 | this.log("MTU change request success on: ${device.address} (value: $bytes)")
314 | } else {
315 | this.error("Could not request MTU change on: ${device.address}")
316 | }
317 |
318 | return success
319 | }
320 |
321 | /**
322 | * Requests a read of the RSSI value, which is updated on the [rsii] variable
323 | *
324 | * @see android.Manifest.permission.BLUETOOTH_CONNECT for Android 31+
325 | *
326 | * @return True when successfully requested a RSSI read
327 | **/
328 | @SuppressLint("MissingPermission")
329 | fun readRSSI(): Boolean {
330 | // Request RSSI read
331 | log("Requesting rssi read on: ${device.address}")
332 | val success = this.gatt?.readRemoteRssi() ?: false
333 | if (success) {
334 | log("RSSI read success on: ${device.address} (value: $rsii)")
335 | } else {
336 | error("Could not read rssi on: ${device.address}")
337 | }
338 |
339 | return success
340 | }
341 | // endregion
342 |
343 | // region Value writing related methods
344 | /**
345 | * Performs a write operation on a specific characteristic
346 | *
347 | * @see [write] For a variant that receives a [String] value
348 | *
349 | * @param characteristic The uuid of the target characteristic
350 | *
351 | * @return True when successfully written the specified value
352 | **/
353 | fun write(characteristic: String, message: ByteArray): Boolean {
354 | // TODO: Add reliable writing implementation
355 | this.log("Writing to device ${device.address} (${message.size} bytes)")
356 | this.beginOperation()
357 |
358 | if (message.size > this.mtu) {
359 | this.warn("Message being written exceeds MTU size!")
360 | }
361 |
362 | // Null safe let of the generic attribute profile
363 | this.gatt?.let { gatt ->
364 | // Searches for the characteristic
365 | getCharacteristic(gatt, characteristic)?.let {
366 | // Tries to write its value
367 | val success = it.write(gatt, message)
368 | if (success) {
369 | this.finishOperation()
370 | return true
371 | } else {
372 | log("Could not write to device ${device.address}")
373 | }
374 | }
375 | }
376 |
377 | this.finishOperation()
378 | return false
379 | }
380 |
381 | /**
382 | * Performs a write operation on a specific characteristic
383 | *
384 | * @see [write] For a variant that receives a [ByteArray] value
385 | *
386 | * @param characteristic The uuid of the target characteristic
387 | *
388 | * @return True when successfully written the specified value
389 | **/
390 | fun write(characteristic: String, message: String, charset: Charset = Charsets.UTF_8): Boolean = this.write(characteristic, message.toByteArray(charset))
391 | // endregion
392 |
393 | // region Value reading related methods
394 | /**
395 | * Performs a read operation on a specific characteristic
396 | *
397 | * @see [read] For a variant that returns a [String] value
398 | * @see readAsync For a variation using callbacks
399 | *
400 | * @param characteristic The uuid of the target characteristic
401 | *
402 | * @return A nullable [ByteArray], null when failed to read
403 | **/
404 | suspend fun read(characteristic: String): ByteArray? {
405 | return suspendCancellableCoroutine { continuation ->
406 | readAsync(characteristic) {
407 | continuation.resume(it)
408 | }
409 | }
410 | }
411 |
412 | /**
413 | * Performs a read operation on a specific characteristic
414 | *
415 | * @see [read] For a variant that returns a [ByteArray] value
416 | * @see readAsync For a variation using callbacks
417 | *
418 | * @param characteristic The uuid of the target characteristic
419 | * @param charset The charset to decode the received bytes
420 | *
421 | * @return A nullable [String], null when failed to read
422 | **/
423 | suspend fun read(characteristic: String, charset: Charset = Charsets.UTF_8): String? = this.read(characteristic)?.let { String(it, charset) }
424 |
425 | /**
426 | * Performs a read operation on a specific characteristic
427 | *
428 | * @see [read] For a variant that returns a [String] value
429 | * @see [read] For a variation using coroutines suspended functions
430 | *
431 | * @param characteristic The uuid of the target characteristic
432 | *
433 | * @return A nullable [ByteArray], null when failed to read
434 | **/
435 | fun readAsync(characteristic: String, callback: (ByteArray?) -> Unit) {
436 | // Check if the characteristic is already in queue to be read, and if so ignore
437 | if (this.enqueuedReadCallbacks.containsKey(characteristic)) {
438 | error("read: '$characteristic' already waiting for read, ignoring read request.")
439 | return callback(null)
440 | }
441 |
442 | // Null safe let of the generic attribute profile
443 | this.beginOperation()
444 | gatt?.let { gatt ->
445 | // Searches for the characteristic
446 | this.getCharacteristic(gatt, characteristic)?.let {
447 | if (!it.read(gatt)) {
448 | // The operation was not successful
449 | error("read: '$characteristic' error while starting the read request.")
450 | this.finishOperation()
451 | return callback(null)
452 | }
453 |
454 | // Define the callback to resume the coroutine
455 | this.enqueuedReadCallbacks[characteristic] = { response ->
456 | if (response != null) {
457 | callback(response)
458 | } else {
459 | // The operation was not successful
460 | error("read: '$characteristic' error while starting the read request.")
461 | callback(null)
462 | }
463 |
464 | this.finishOperation()
465 | }
466 | }
467 | }
468 | }
469 |
470 | /**
471 | * Performs a read operation on a specific characteristic
472 | *
473 | * @see [read] For a variant that returns a [ByteArray] value
474 | * @see [read] For a variation using coroutines suspended functions
475 | *
476 | * @param characteristic The uuid of the target characteristic
477 | * @param charset The charset to decode the received bytes
478 | *
479 | * @return A nullable [String], null when failed to read
480 | **/
481 | fun readAsync(characteristic: String, charset: Charset, callback: (String?) -> Unit) = readAsync(characteristic) { callback(it?.let { String(it, charset) }) }
482 | // endregion
483 |
484 | // region Value observation related methods
485 | private fun legacyObserve(owner: LifecycleOwner, characteristic: BluetoothCharacteristic, callback: OnCharacteristicValueChangeCallback, interval: Long) {
486 | this.coroutineScope?.launch {
487 | var lastValue: ByteArray? = null
488 |
489 | // While the lifecycle owner is not destroyed and the observer is in the activeObservers
490 | val key = characteristic.uuid.toString().lowercase()
491 | var startTime: Long
492 | while (owner.lifecycle.currentState != Lifecycle.State.DESTROYED && activeObservers.containsKey(key)) {
493 | if (!enqueuedReadCallbacks.containsKey(key)) {
494 | // Read the characteristic
495 | startTime = System.currentTimeMillis()
496 | read(key)?.let { currentValue ->
497 | log("legacyObserve: [${currentValue.joinToString(", ")}]")
498 |
499 | // Check if it has changed
500 | if (!currentValue.contentEquals(lastValue)) {
501 | // It has, invoke the callback and store the current value
502 | if (lastValue != null) {
503 | callback.invoke(currentValue)
504 | log("legacyObserve: Value changed!")
505 | }
506 |
507 | lastValue = currentValue
508 | }
509 |
510 | // Calculate the elapsed time and subtract it from the interval time
511 | val endTime = System.currentTimeMillis()
512 | val elapsedTime = endTime - startTime
513 | val sleepTime = (interval - elapsedTime).coerceAtLeast(0L)
514 |
515 | // Updates too close can be harmful to the battery, warn the user
516 | if (sleepTime <= 0L) warn("The elapsed time $elapsedTime between reads exceeds the specified interval of ${interval}ms. You should consider increasing the interval!")
517 |
518 | delay(sleepTime)
519 | }
520 | }
521 | }
522 | }
523 | }
524 |
525 | /**
526 | * Observe a given [characteristic]
527 | * @see stopObserving
528 | *
529 | * If the characteristic does not contain property [BluetoothGattCharacteristic.PROPERTY_NOTIFY] it will try to poll the value
530 | *
531 | * @see observeString
532 | *
533 | * @param characteristic The characteristic to observe
534 | * @param callback The lambda to be called whenever a change is detected
535 | * @param interval *Optional* only used for characteristics that doesn't have the NOTIFY property, define the amount of time to wait between readings
536 | * @param owner *Optional* only used for characteristics that doesn't have the NOTIFY property, define the lifecycle owner of the legacy observer really useful to avoid memory leaks
537 | **/
538 | fun observe(characteristic: String, interval: Long = 5000, owner: LifecycleOwner? = null, callback: OnCharacteristicValueChangeCallback) {
539 | // Generate an id for the observation
540 | this.activeObservers[characteristic.lowercase()] = callback
541 |
542 | this.gatt?.let { gatt ->
543 | this.getCharacteristic(gatt, characteristic)?.let {
544 | if (!it.isNotifiable && it.isReadable && owner != null && coroutineScope != null) {
545 | legacyObserve(owner, it, callback, interval)
546 | } else {
547 | it.enableNotify(gatt)
548 | }
549 | }
550 | }
551 | }
552 |
553 | /**
554 | * Observe a given [characteristic]
555 | * @see stopObserving
556 | *
557 | * If the characteristic does not contain property [BluetoothGattCharacteristic.PROPERTY_NOTIFY] it will try to poll the value
558 | *
559 | * @see observe
560 | *
561 | * @param characteristic The characteristic to observe
562 | * @param callback The lambda to be called whenever a change is detected
563 | * @param interval *Optional* only used for characteristics that doesn't have the NOTIFY property, define the amount of time to wait between readings
564 | * @param owner *Optional* only used for characteristics that doesn't have the NOTIFY property, define the lifecycle owner of the legacy observer really useful to avoid memory leaks
565 | **/
566 | fun observeString(characteristic: String, interval: Long = 5000, owner: LifecycleOwner? = null, charset: Charset = Charsets.UTF_8, callback: OnCharacteristicValueChangeCallback) {
567 | this.observe(characteristic, interval = interval, owner = owner, callback = {
568 | callback.invoke(it.toString(charset))
569 | })
570 | }
571 |
572 | /**
573 | * Stops observing the characteristic
574 | * @see observeString
575 | * @see observe
576 | *
577 | * @param characteristic The characteristic to observe
578 | **/
579 | fun stopObserving(characteristic: String) {
580 | this.gatt?.let { gatt ->
581 | this.getCharacteristic(gatt, characteristic)?.disableNotify(gatt)
582 | }
583 |
584 | this.activeObservers.remove(characteristic.lowercase())
585 | }
586 | // endregion
587 |
588 | // region Workaround for lollipop
589 | @SuppressLint("ObsoleteSdkInt")
590 | private fun isLollipop() = VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && VERSION.SDK_INT <= VERSION_CODES.M
591 |
592 | @SuppressLint("MissingPermission")
593 | private fun startDisconnection() {
594 | try {
595 | this.closingConnection = true
596 | this.gatt?.disconnect()
597 | } catch (e: Exception) {
598 | e.printStackTrace()
599 | }
600 | }
601 |
602 | @SuppressLint("MissingPermission")
603 | private fun endDisconnection() {
604 | log("Disconnected succesfully from ${device.address}!\nClosing connection...")
605 |
606 | try {
607 | this.connectionActive = false
608 | this.connectionCallback?.invoke(false)
609 | this.onDisconnect?.invoke()
610 | this.gatt?.close()
611 | this.gatt = null
612 | this.closingConnection = false
613 | } catch (e: Exception) {
614 | log("Ignoring closing connection with ${device.address} exception -> ${e.message}")
615 | }
616 | }
617 | // endregion
618 |
619 | // region Connection handling related methods
620 | @SuppressLint("MissingPermission")
621 | internal fun establish(context: Context, priority: Priority = Priority.Balanced, callback: Callback) {
622 | this.connectionCallback = {
623 | // Clear the operations queue
624 | closingConnection = false
625 | operationsInQueue.set(0)
626 |
627 | // Calls the external connection callback
628 | callback(it)
629 | connectionCallback = null
630 | }
631 |
632 | // Update the connection priority
633 | this.priority = priority
634 |
635 | // Check for bluetooth connect permission
636 | if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
637 | error("Missing permission BLUETOOTH_CONNECT!")
638 | callback.invoke(false)
639 | return
640 | }
641 |
642 | // HACK: Android M+ requires a transport LE in order to skip the 133 of death status when connecting
643 | this.gatt = if (VERSION.SDK_INT >= VERSION_CODES.M)
644 | this.device.connectGatt(context, true, setupGattCallback(), BluetoothDevice.TRANSPORT_LE)
645 | else
646 | this.device.connectGatt(context, false, setupGattCallback())
647 | }
648 |
649 | /**
650 | * Closes the connection
651 | **/
652 | suspend fun close() {
653 | // Wait for ongoing operations to finish before closing the connection
654 | // Has a counter of 20 times 500ms each
655 | // Being 10s in total of timeout
656 | var counter = 0
657 | while (operationsInQueue.get() > 0 && counter < 20) {
658 | this.log("${operationsInQueue.get()} operations in queue! Waiting for 500ms (${counter * 500}ms elapsed)")
659 |
660 | delay(500)
661 | counter++
662 | }
663 |
664 | // HACK: Workaround for Lollipop 21 and 22
665 | this.startDisconnection()
666 | }
667 | // endregion
668 |
669 | }
--------------------------------------------------------------------------------
/lib/src/main/java/quevedo/soares/leandro/blemadeeasy/BLE.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MemberVisibilityCanBePrivate")
2 |
3 | package quevedo.soares.leandro.blemadeeasy
4 |
5 | import android.Manifest.permission
6 | import android.annotation.SuppressLint
7 | import android.app.Activity
8 | import android.bluetooth.BluetoothAdapter
9 | import android.bluetooth.BluetoothDevice
10 | import android.bluetooth.BluetoothManager
11 | import android.bluetooth.le.*
12 | import android.content.BroadcastReceiver
13 | import android.content.Context
14 | import android.content.Intent
15 | import android.content.IntentFilter
16 | import android.content.pm.PackageManager
17 | import android.os.Build
18 | import android.os.ParcelUuid
19 | import android.util.Log
20 | import androidx.activity.ComponentActivity
21 | import androidx.activity.result.ActivityResult
22 | import androidx.activity.result.IntentSenderRequest
23 | import androidx.activity.result.contract.ActivityResultContracts
24 | import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
25 | import androidx.annotation.RequiresFeature
26 | import androidx.annotation.RequiresPermission
27 | import androidx.appcompat.app.AppCompatActivity
28 | import androidx.fragment.app.Fragment
29 | import androidx.lifecycle.lifecycleScope
30 | import com.google.android.gms.common.api.ResolvableApiException
31 | import com.google.android.gms.location.LocationRequest
32 | import com.google.android.gms.location.LocationServices
33 | import com.google.android.gms.location.LocationSettingsRequest
34 | import kotlinx.coroutines.*
35 | import quevedo.soares.leandro.blemadeeasy.contracts.BluetoothAdapterContract
36 | import quevedo.soares.leandro.blemadeeasy.enums.Priority
37 | import quevedo.soares.leandro.blemadeeasy.exceptions.*
38 | import quevedo.soares.leandro.blemadeeasy.models.BLEDevice
39 | import quevedo.soares.leandro.blemadeeasy.typealiases.Callback
40 | import quevedo.soares.leandro.blemadeeasy.typealiases.EmptyCallback
41 | import quevedo.soares.leandro.blemadeeasy.typealiases.PermissionRequestCallback
42 | import quevedo.soares.leandro.blemadeeasy.utils.PermissionUtils
43 | import java.util.*
44 | import kotlin.coroutines.resume
45 |
46 | internal const val DEFAULT_TIMEOUT = 10000L
47 | internal const val GATT_133_TIMEOUT = 600L
48 |
49 | /** https://cs.android.com/android/platform/superproject/+/master:packages/modules/Bluetooth/system/stack/include/gatt_api.h;l=543;drc=6cf6099dcab87865e33439215e7ea0087e60c9f2#:~:text=%23define%20GATT_MAX_MTU_SIZE%20517 */
50 | internal const val GATT_MAX_MTU = 517
51 |
52 | @Suppress("unused")
53 | @RequiresFeature(name = PackageManager.FEATURE_BLUETOOTH_LE, enforcement = "android.content.pm.PackageManager#hasSystemFeature")
54 | class BLE {
55 |
56 | /* Context related variables */
57 | /** For Jetpack Compose activities use*/
58 | private var componentActivity: ComponentActivity? = null
59 | /** For regular activities use */
60 | private var appCompatActivity: AppCompatActivity? = null
61 | /** For Fragment use */
62 | private var fragment: Fragment? = null
63 | /** The provided context, based on [componentActivity], [appCompatActivity] or [fragment] */
64 | private var context: Context
65 | /** Coroutine scope based on the given context provider [componentActivity], [appCompatActivity] or [fragment] */
66 | private val coroutineScope: CoroutineScope get() = componentActivity?.lifecycleScope ?: appCompatActivity?.lifecycleScope ?: fragment?.lifecycleScope ?: GlobalScope
67 |
68 | /* Bluetooth related variables */
69 | private var manager: BluetoothManager? = null
70 | private var adapter: BluetoothAdapter? = null
71 | private var scanner: BluetoothLeScanner? = null
72 |
73 | /* Contracts */
74 | private lateinit var adapterContract: ContractHandler
75 | private lateinit var permissionContract: ContractHandler, Map>
76 | private lateinit var locationContract: ContractHandler
77 |
78 | /* Scan related variables */
79 | private val defaultScanSettings by lazy {
80 | ScanSettings.Builder().apply {
81 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
82 | log("[Legacy] ScanSettings: Using aggressive mode")
83 | setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
84 |
85 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
86 | setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
87 | }
88 |
89 | setReportDelay(0L)
90 | }
91 |
92 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
93 | setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
94 | setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
95 | }
96 | }.build()
97 | }
98 | private var scanCallbackInstance: ScanCallback? = null
99 | private var scanReceiverInstance: BroadcastReceiver? = null
100 | private var discoveredDeviceList: ArrayList = arrayListOf()
101 | var isScanRunning = false
102 | private set
103 |
104 | /* Verbose related variables */
105 | /**
106 | * Indicates whether additional information should be logged
107 | **/
108 | var verbose = false
109 |
110 | // region Constructors
111 | /**
112 | * Instantiates a new Bluetooth scanner instance
113 | * Support for Jetpack Compose
114 | *
115 | * @throws HardwareNotPresentException If no hardware is present on the running device
116 | **/
117 | constructor(componentActivity: ComponentActivity) {
118 | this.log("Setting up on a ComponentActivity!")
119 | this.componentActivity = componentActivity
120 | this.context = componentActivity
121 | this.setup()
122 | }
123 |
124 | /**
125 | * Instantiates a new Bluetooth scanner instance
126 | *
127 | * @throws HardwareNotPresentException If no hardware is present on the running device
128 | **/
129 | constructor(activity: AppCompatActivity) {
130 | this.log("Setting up on an AppCompatActivity!")
131 | this.appCompatActivity = activity
132 | this.context = activity
133 | this.setup()
134 | }
135 |
136 | /**
137 | * Instantiates a new Bluetooth scanner instance
138 | *
139 | * @throws HardwareNotPresentException If no hardware is present on the running device
140 | **/
141 | constructor(fragment: Fragment) {
142 | this.log("Setting up on a Fragment!")
143 | this.fragment = fragment
144 | this.context = fragment.requireContext()
145 | this.setup()
146 | }
147 |
148 | private fun setup() {
149 | this.verifyBluetoothHardwareFeature()
150 | this.registerContracts()
151 | this.setupBluetoothService()
152 | }
153 | // endregion
154 |
155 | // region Contracts related methods
156 | private fun registerContracts() {
157 | this.log("Registering contracts...")
158 |
159 | this.adapterContract = ContractHandler(BluetoothAdapterContract(), this.componentActivity, this.appCompatActivity, this.fragment)
160 | this.permissionContract = ContractHandler(RequestMultiplePermissions(), this.componentActivity, this.appCompatActivity, this.fragment)
161 | this.locationContract = ContractHandler(ActivityResultContracts.StartIntentSenderForResult(), this.componentActivity, this.appCompatActivity, this.fragment)
162 | }
163 |
164 | private fun launchPermissionRequestContract(callback: PermissionRequestCallback) {
165 | this.log("Requesting permissions to the user...")
166 |
167 | this.permissionContract.launch(PermissionUtils.permissions) { permissions: Map ->
168 | this.log("Permission request result: $permissions")
169 | callback(permissions.all { it.value })
170 | }
171 | }
172 |
173 | private fun launchBluetoothAdapterActivationContract(callback: PermissionRequestCallback? = null) {
174 | this.log("Requesting to enable bluetooth adapter to the user...")
175 |
176 | this.adapterContract.launch(Unit) { enabled ->
177 | this.log("Bluetooth adapter activation request result: $enabled")
178 | callback?.invoke(enabled)
179 | }
180 | }
181 | // endregion
182 |
183 | // region Hardware feature related methods
184 | private fun verifyBluetoothHardwareFeature() {
185 | this.log("Checking bluetooth hardware on device...")
186 |
187 | context.packageManager.let {
188 | if (!PermissionUtils.isBluetoothLowEnergyPresentOnDevice(it) || !PermissionUtils.isBluetoothPresentOnDevice(it)) {
189 | this.log("No bluetooth hardware detected on this device!")
190 | throw HardwareNotPresentException()
191 | } else {
192 | this.log("Detected bluetooth hardware on this device!")
193 | }
194 | }
195 | }
196 |
197 | private fun setupBluetoothService() {
198 | this.log("Setting up bluetooth service...")
199 | this.manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
200 | this.adapter = this.manager?.adapter
201 | this.scanner = this.adapter?.bluetoothLeScanner
202 | }
203 | // endregion
204 |
205 | // region Permission related methods
206 | /**
207 | * Checks if the following permissions are granted: [permission.BLUETOOTH], [permission.BLUETOOTH_ADMIN] and [permission.ACCESS_FINE_LOCATION]
208 | *
209 | * If any of these isn't granted, automatically requests it to the user
210 | *
211 | * @see verifyPermissions For a variation using coroutines suspended functions
212 | *
213 | * @param rationaleRequestCallback Called when rationale permission is required, should explain on the UI why the permissions are needed and then re-call this method
214 | * @param callback Called with a boolean parameter indicating the permission request state
215 | **/
216 | @RequiresPermission(allOf = [permission.BLUETOOTH, permission.BLUETOOTH_ADMIN, permission.ACCESS_FINE_LOCATION])
217 | fun verifyPermissionsAsync(rationaleRequestCallback: Callback? = null, callback: PermissionRequestCallback? = null) {
218 | this.log("Checking App bluetooth permissions...")
219 |
220 | if (PermissionUtils.isEveryBluetoothPermissionsGranted(this.context)) {
221 | this.log("All permissions granted!")
222 | callback?.invoke(true)
223 | } else {
224 | // Fetch an Activity from the given context providers
225 | val providedActivity = this.componentActivity ?: this.appCompatActivity ?: this.fragment?.requireActivity()!!
226 | if (PermissionUtils.isPermissionRationaleNeeded(providedActivity) && rationaleRequestCallback != null) {
227 | this.log("Permissions denied, requesting permission rationale callback...")
228 | rationaleRequestCallback {
229 | launchPermissionRequestContract { granted ->
230 | callback?.invoke(granted)
231 | }
232 | }
233 | } else {
234 | launchPermissionRequestContract { granted ->
235 | callback?.invoke(granted)
236 | }
237 | }
238 | }
239 | }
240 |
241 | /**
242 | * Checks if the following permissions are granted: [permission.BLUETOOTH], [permission.BLUETOOTH_ADMIN] and [permission.ACCESS_FINE_LOCATION]
243 | *
244 | * If any of these isn't granted, automatically requests it to the user
245 | *
246 | * @see verifyPermissionsAsync For a variation using callbacks
247 | *
248 | * @param rationaleRequestCallback Called when rationale permission is required, should explain on the UI why the permissions are needed and then re-call this method
249 | * @return True when all the permissions are granted
250 | **/
251 | @RequiresPermission(allOf = [permission.BLUETOOTH, permission.BLUETOOTH_ADMIN, permission.ACCESS_FINE_LOCATION])
252 | suspend fun verifyPermissions(rationaleRequestCallback: Callback? = null): Boolean {
253 | return suspendCancellableCoroutine { continuation ->
254 | this.verifyPermissionsAsync(rationaleRequestCallback) { status ->
255 | if (status) continuation.resume(true)
256 | else continuation.cancel(PermissionsDeniedException())
257 | }
258 | }
259 | }
260 | // endregion
261 |
262 | // region Adapter enabling related methods
263 | /**
264 | * Checks if the bluetooth adapter is active
265 | *
266 | * If not, automatically requests it's activation to the user
267 | *
268 | * @see verifyBluetoothAdapterState For a variation using coroutines suspended functions
269 | *
270 | * @param callback Called with a boolean parameter indicating the activation request state
271 | **/
272 | @RequiresPermission(permission.BLUETOOTH_ADMIN)
273 | fun verifyBluetoothAdapterStateAsync(callback: PermissionRequestCallback? = null) {
274 | this.log("Checking bluetooth adapter state...")
275 |
276 | if (this.adapter == null || this.adapter?.isEnabled != true) {
277 | this.log("Bluetooth adapter turned off!")
278 | launchBluetoothAdapterActivationContract(callback)
279 | } else callback?.invoke(true)
280 | }
281 |
282 | /**
283 | * Checks if the bluetooth adapter is active
284 | *
285 | * If not, automatically requests it's activation to the user
286 | *
287 | * @see verifyBluetoothAdapterState For a variation using callbacks
288 | *
289 | * @return True when the bluetooth adapter is active
290 | **/
291 | @RequiresPermission(permission.BLUETOOTH_ADMIN)
292 | suspend fun verifyBluetoothAdapterState(): Boolean {
293 | return suspendCancellableCoroutine { continuation ->
294 | this.verifyBluetoothAdapterStateAsync { status ->
295 | if (status) continuation.resume(true)
296 | else continuation.cancel(DisabledAdapterException())
297 | }
298 | }
299 | }
300 | // endregion
301 |
302 | // region Location enabling related methods
303 | /**
304 | * Checks if location services are active
305 | *
306 | * If not, automatically requests it's activation to the user
307 | *
308 | * @see verifyLocationState For a variation using coroutines suspended functions
309 | *
310 | * @param callback Called with a boolean parameter indicating the activation request state
311 | **/
312 | @RequiresPermission(anyOf = [permission.ACCESS_FINE_LOCATION, permission.ACCESS_COARSE_LOCATION])
313 | fun verifyLocationStateAsync(callback: PermissionRequestCallback? = null) {
314 | this.log("Checking location services state...")
315 |
316 | // Builds a location request
317 | val locationRequest = LocationRequest.create().apply {
318 | priority = LocationRequest.PRIORITY_LOW_POWER
319 | }
320 |
321 | // Builds a location settings request
322 | val settingsRequest = LocationSettingsRequest.Builder()
323 | .addLocationRequest(locationRequest)
324 | .setNeedBle(true)
325 | .setAlwaysShow(true)
326 | .build()
327 |
328 | // Execute the location request
329 | LocationServices.getSettingsClient(context).checkLocationSettings(settingsRequest).apply {
330 | addOnSuccessListener {
331 | callback?.invoke(true)
332 | }
333 |
334 | addOnFailureListener { e ->
335 | if (e is ResolvableApiException) {
336 | // If resolution is required from the Google services Api, build an intent to do it and launch the locationContract
337 | locationContract.launch(IntentSenderRequest.Builder(e.resolution).build()) {
338 | // Check the contract result
339 | if (it.resultCode == Activity.RESULT_OK) callback?.invoke(true)
340 | else callback?.invoke(false)
341 | }
342 | } else {
343 | e.printStackTrace()
344 | callback?.invoke(false)
345 | }
346 | }
347 | }
348 | }
349 |
350 | /**
351 | * Checks if location services are active
352 | *
353 | * If not, automatically requests it's activation to the user
354 | *
355 | * @see verifyLocationStateAsync For a variation using callbacks
356 | *
357 | * @return True when the location services are active
358 | **/
359 | @RequiresPermission(anyOf = [permission.ACCESS_FINE_LOCATION, permission.ACCESS_COARSE_LOCATION])
360 | suspend fun verifyLocationState(): Boolean {
361 | return suspendCancellableCoroutine { continuation ->
362 | verifyLocationStateAsync { status ->
363 | if (status) continuation.resume(true)
364 | else continuation.cancel(DisabledAdapterException())
365 | }
366 | }
367 | }
368 | // endregion
369 |
370 | // region Caching related methods
371 | @SuppressLint("MissingPermission")
372 | private fun fetchCachedDevice(macAddress: String): BluetoothDevice? {
373 | this.adapter?.getRemoteDevice(macAddress)?.let { device ->
374 | if (device.type != BluetoothDevice.DEVICE_TYPE_UNKNOWN) {
375 | log("Fetched device ${device.address} from Android cache!")
376 | return device
377 | }
378 | }
379 |
380 | return null
381 | }
382 | // endregion
383 |
384 | // region Device scan related methods
385 | @SuppressLint("MissingPermission")
386 | private fun setupScanCallback(onDiscover: Callback? = null, onUpdate: Callback>? = null, onError: Callback? = null) {
387 | fun onDeviceFound(device: BluetoothDevice, rssi: Int, advertisingId: Int = -1) {
388 | log("Scan result! ${device.name} (${device.address}) ${rssi}dBm")
389 |
390 | discoveredDeviceList.find { it.macAddress == device.address }?.let {
391 | log("Device update from ${it.rsii} to $rssi at ${device.name}")
392 |
393 | // If the device was already inserted on the list, update it's rsii value
394 | it.device = device
395 | if (it.rsii != rssi && rssi != 0) it.rsii = rssi
396 | } ?: run {
397 | // If the device was not inserted before, add to the discovered device list
398 | val bleDevice = BLEDevice(device, rssi, advertisingId)
399 | discoveredDeviceList.add(bleDevice)
400 | onDiscover?.invoke(bleDevice)
401 | }
402 |
403 | onUpdate?.invoke(discoveredDeviceList.toList())
404 | }
405 |
406 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
407 | // region Android 10 callback
408 | scanReceiverInstance = object : BroadcastReceiver() {
409 | override fun onReceive(context: Context?, intent: Intent?) {
410 | // Ignore other events
411 | if (intent == null) return
412 |
413 | // Fetch information from the intent extras
414 | val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) ?: return
415 | val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, 0).toInt()
416 |
417 | // Ignore non LowEnergy devices
418 | if (!listOf(BluetoothDevice.DEVICE_TYPE_DUAL, BluetoothDevice.DEVICE_TYPE_LE).contains(device.type)) return
419 |
420 | onDeviceFound(device, rssi, -1)
421 | }
422 | }
423 | context.registerReceiver(scanReceiverInstance, IntentFilter().apply {
424 | addAction(BluetoothDevice.ACTION_FOUND)
425 | addAction(BluetoothDevice.ACTION_UUID)
426 | addAction(BluetoothDevice.ACTION_NAME_CHANGED)
427 | addAction(BluetoothDevice.ACTION_CLASS_CHANGED)
428 | addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
429 | addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
430 | addAction(BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED)
431 | addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
432 | addAction(BluetoothDevice.ACTION_PAIRING_REQUEST)
433 |
434 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) addAction(BluetoothDevice.ACTION_ALIAS_CHANGED)
435 | })
436 | // endregion
437 | }
438 |
439 | // region Legacy callback
440 | scanCallbackInstance = object : ScanCallback() {
441 |
442 | override fun onScanResult(callbackType: Int, result: ScanResult?) {
443 | super.onScanResult(callbackType, result)
444 |
445 | // Gets the device from the result
446 | result?.device?.let { device ->
447 | val advertisingID = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) result.advertisingSid else -1
448 |
449 | onDeviceFound(device, result.rssi, advertisingID)
450 | }
451 | }
452 |
453 | override fun onScanFailed(errorCode: Int) {
454 | super.onScanFailed(errorCode)
455 |
456 | log("Scan failed! $errorCode")
457 |
458 | // Calls the error callback
459 | onError?.invoke(errorCode)
460 | }
461 |
462 | }
463 | // endregion
464 | }
465 |
466 | /**
467 | * Starts a scan for bluetooth devices
468 | * Can be used without [duration] running until [stopScan] is called.
469 | *
470 | * If only one device is required consider using [scanFor]
471 | *
472 | * @see scan For a variation using coroutines suspended functions
473 | *
474 | * @param filters Used to specify attributes of the devices on the scan
475 | * @param settings Native object to specify the scan settings (The default setting is only recommended for really fast scans)
476 | * @param duration Scan time limit, when exceeded stops the scan (Ignored when less then 0)
477 | *
478 | * Callbacks:
479 | * @param onFinish Called when the scan is finished with an Array of Bluetooth devices found
480 | * @param onDiscover Called whenever a new bluetooth device if found (Useful on realtime scans)
481 | * @param onUpdate Called whenever the list changes (e.g new device discovered, device name changed etc...)
482 | * @param onError Called whenever an error occurs on the scan (Of which will be automatically halted in case of errors)
483 | **/
484 | @SuppressLint("InlinedApi")
485 | @RequiresPermission(anyOf = [permission.BLUETOOTH_ADMIN, permission.BLUETOOTH_SCAN])
486 | fun scanAsync(
487 | filters: List? = null,
488 | settings: ScanSettings? = null,
489 | duration: Long = DEFAULT_TIMEOUT,
490 | onFinish: Callback>? = null,
491 | onDiscover: Callback? = null,
492 | onUpdate: Callback>? = null,
493 | onError: Callback? = null
494 | ) {
495 | this.coroutineScope.launch {
496 | log("Starting scan...")
497 |
498 | // Instantiate a new ScanCallback object
499 | setupScanCallback(onDiscover, onUpdate, onError)
500 |
501 | // Clears the discovered device list
502 | discoveredDeviceList = arrayListOf()
503 |
504 | // Starts the scanning
505 | isScanRunning = true
506 | adapter?.apply {
507 | if (isDiscovering) cancelDiscovery()
508 |
509 | startDiscovery()
510 | }
511 | scanner?.startScan(filters, settings ?: defaultScanSettings, scanCallbackInstance)
512 | scanner?.flushPendingScanResults(scanCallbackInstance)
513 |
514 | // Automatically stops the scan if a duration is specified
515 | if (duration > 0) {
516 | log("Scan timeout reached!")
517 |
518 | // Waits for the specified timeout
519 | delay(duration)
520 |
521 | if (isScanRunning) {
522 | // Stops the scan
523 | stopScan()
524 |
525 | // Calls the onFinished callback
526 | log("Scan finished! ${discoveredDeviceList.size} devices found!")
527 | onFinish?.invoke(discoveredDeviceList.toTypedArray())
528 | }
529 | } else {
530 | log("Skipped timeout definition on scan!")
531 | }
532 | }
533 | }
534 |
535 | /**
536 | * Starts a scan for bluetooth devices
537 | * Only runs with a [duration] defined
538 | *
539 | * If only one device is required consider using [scanFor]
540 | *
541 | * @see scan For a variation using callbacks
542 | *
543 | * @param filters Used to specify attributes of the devices on the scan
544 | * @param settings Native object to specify the scan settings (The default setting is only recommended for really fast scans)
545 | * @param duration Scan time limit, when exceeded stops the scan (Ignored when less then 0)
546 | *
547 | * @throws IllegalArgumentException When a duration is not defined
548 | * @throws ScanFailureException When an error occurs
549 | *
550 | * @return An Array of Bluetooth devices found
551 | **/
552 | @SuppressLint("InlinedApi")
553 | @RequiresPermission(anyOf = [permission.BLUETOOTH_ADMIN, permission.BLUETOOTH_SCAN])
554 | suspend fun scan(filters: List? = null, settings: ScanSettings? = null, duration: Long = DEFAULT_TIMEOUT): Array {
555 | return suspendCancellableCoroutine { continuation ->
556 | this.coroutineScope.launch {
557 | // Validates the duration
558 | if (duration <= 0) continuation.cancel(IllegalArgumentException("In order to run a synchronous scan you'll need to specify a duration greater than 0ms!"))
559 |
560 | scanAsync(
561 | filters,
562 | settings,
563 | duration,
564 | onFinish = {
565 | continuation.resume(it)
566 | },
567 | onError = { errorCode ->
568 | continuation.cancel(ScanFailureException(errorCode))
569 | }
570 | )
571 | }
572 | }
573 | }
574 |
575 | /**
576 | * Scans for a single bluetooth device and automatically connects with it
577 | * Requires at least one filter being them: [macAddress], [service] and [name]
578 | *
579 | * @see scanFor For a variation using coroutines suspended functions
580 | *
581 | * Filters:
582 | * @param macAddress Optional filter, if provided searches for the specified mac address
583 | * @param service Optional filter, if provided searches for the specified service uuid
584 | * @param name Optional filter, if provided searches for the specified device name
585 | *
586 | * @param settings Native object to specify the scan settings (The default setting is only recommended for really fast scans)
587 | * @param timeout Scan time limit, when exceeded throws an [ScanTimeoutException]
588 | *
589 | * @throws ScanTimeoutException When the [timeout] is reached
590 | * @throws ScanFailureException When an error occurs
591 | *
592 | * @return A nullable [BluetoothConnection] instance, when null meaning that the specified device was not found
593 | **/
594 | @SuppressLint("InlinedApi")
595 | @RequiresPermission(anyOf = [permission.BLUETOOTH_ADMIN, permission.BLUETOOTH_SCAN, permission.BLUETOOTH_CONNECT])
596 | fun scanForAsync(macAddress: String? = null, service: String? = null, name: String? = null, settings: ScanSettings? = null, priority: Priority = Priority.Balanced, timeout: Long = DEFAULT_TIMEOUT, onFinish: Callback? = null, onError: Callback? = null) {
597 | this.coroutineScope.launch {
598 | try {
599 | onFinish?.invoke(scanFor(macAddress, service, name, settings, priority, timeout))
600 | } catch (e: ScanTimeoutException) {
601 | onFinish?.invoke(null)
602 | } catch (e: ScanFailureException) {
603 | onError?.invoke(e.code)
604 | } catch (e: Exception) {
605 | onError?.invoke(-1)
606 | }
607 | }
608 | }
609 |
610 | /**
611 | * Scans for a single bluetooth device and automatically connects with it
612 | * Requires at least one filter being them: [macAddress], [service] and [name]
613 | *
614 | * @see scanForAsync For a variation using callbacks
615 | *
616 | * Filters:
617 | * @param macAddress Optional filter, if provided searches for the specified mac address
618 | * @param service Optional filter, if provided searches for the specified service uuid
619 | * @param name Optional filter, if provided searches for the specified device name
620 | *
621 | * @param settings Native object to specify the scan settings (The default setting is only recommended for really fast scans)
622 | * @param timeout Scan time limit, when exceeded throws an [ScanTimeoutException]
623 | *
624 | * @throws ScanTimeoutException When the [timeout] is reached
625 | * @throws ScanFailureException When an error occurs
626 | *
627 | * @return A nullable [BluetoothConnection] instance, when null meaning that the specified device was not found
628 | **/
629 | @SuppressLint("MissingPermission")
630 | suspend fun scanFor(macAddress: String? = null, service: String? = null, name: String? = null, settings: ScanSettings? = null, priority: Priority = Priority.Balanced, timeout: Long = DEFAULT_TIMEOUT): BluetoothConnection? {
631 | return suspendCancellableCoroutine { continuation ->
632 | // Validates the arguments
633 | if (macAddress == null && service == null && name == null) throw IllegalArgumentException("You need to specify at least one filter!\nBeing them: macAddress, service and name")
634 |
635 | this.coroutineScope.launch {
636 | // Automatically tries to connect with previously cached devices
637 | if (macAddress != null) {
638 | fetchCachedDevice(macAddress)?.let { device ->
639 | // Stops the current running scan, if any
640 | stopScan()
641 |
642 | // HACK: Adding a delay after stopping a scan and starting a connection request could solve the 133 in some cases
643 | delay(GATT_133_TIMEOUT)
644 |
645 | // Check if it is able to connect to the device
646 | withTimeoutOrNull(timeout) {
647 | connect(device, priority)
648 | }?.let { connection ->
649 | continuation.resume(connection)
650 | }
651 | }
652 | }
653 |
654 | // Instantiate a new ScanCallback object
655 | setupScanCallback(
656 | onDiscover = { bleDevice ->
657 | // HACK: On devices lower than Marshmallow, run the filtering manually!
658 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
659 | macAddress?.let {
660 | if (bleDevice.device.address != it) return@setupScanCallback
661 | }
662 |
663 | service?.let {
664 | val parcel = ParcelUuid(UUID.fromString(it))
665 | if (bleDevice.device.uuids.any { x -> x == parcel }) return@setupScanCallback
666 | }
667 |
668 | name?.let {
669 | if (bleDevice.device.name != it) return@setupScanCallback
670 | }
671 | }
672 |
673 | coroutineScope.launch {
674 | // Stops the current running scan, if any
675 | stopScan()
676 |
677 | // HACK: Adding a delay after stopping a scan and starting a connection request could solve the 133 in some cases
678 | delay(GATT_133_TIMEOUT)
679 |
680 | if (continuation.isActive) continuation.resume(connect(bleDevice, priority))
681 | }
682 | },
683 | onError = { errorCode ->
684 | if (continuation.isActive) continuation.cancel(ScanFailureException(errorCode))
685 | }
686 | )
687 |
688 | // Clears the discovered device list
689 | discoveredDeviceList = arrayListOf()
690 |
691 | // Builds the filters
692 | val filter = ScanFilter.Builder().run {
693 | macAddress?.let { setDeviceAddress(it) }
694 | service?.let { setServiceUuid(ParcelUuid(UUID.fromString(it))) }
695 | name?.let { setDeviceName(it) }
696 | build()
697 | }
698 | // HACK: Ignore the hardware filters on devices lower than MARSHMALLOW 23 (It doesn't work properly)
699 | val filters = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) arrayListOf(filter) else null
700 |
701 | // Starts the scan
702 | isScanRunning = true
703 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
704 | adapter?.apply {
705 | if (isDiscovering) cancelDiscovery()
706 |
707 | startDiscovery()
708 | }
709 | } else {
710 | scanner?.startScan(
711 | filters,
712 | settings ?: defaultScanSettings,
713 | scanCallbackInstance
714 | )
715 | }
716 |
717 | // Only cancels when a timeout is defined
718 | if (timeout > 0) {
719 | delay(timeout)
720 |
721 | if (isScanRunning) {
722 | log("Timeout! No device found in $timeout")
723 | stopScan()
724 | if (continuation.isActive) continuation.cancel(ScanTimeoutException())
725 | }
726 | }
727 | }
728 | }
729 | }
730 |
731 | /**
732 | * Stops the scan started by [scan]
733 | **/
734 | @SuppressLint("MissingPermission")
735 | fun stopScan() {
736 | this.log("Stopping scan...")
737 |
738 | if (!isScanRunning) return
739 |
740 | isScanRunning = false
741 |
742 | // Legacy
743 | this.scanCallbackInstance?.let {
744 | this.scanner?.flushPendingScanResults(scanCallbackInstance)
745 | this.scanner?.stopScan(it)
746 | this.scanCallbackInstance = null
747 | }
748 |
749 | // Android 11+
750 | this.scanReceiverInstance?.let {
751 | this.adapter?.cancelDiscovery()
752 | this.context.unregisterReceiver(it)
753 | this.scanReceiverInstance = null
754 | }
755 | }
756 | // endregion
757 |
758 | // region Utility methods
759 | private fun log(message: String) {
760 | if (this.verbose) Log.d("BluetoothMadeEasy", message)
761 | }
762 | // endregion
763 |
764 | // region Connection related methods
765 | /**
766 | * Establishes a connection with the specified bluetooth device
767 | *
768 | * @param device The device to be connected with
769 | *
770 | * @return A nullable [BluetoothConnection], null when not successful
771 | **/
772 | @RequiresPermission(permission.BLUETOOTH_CONNECT)
773 | suspend fun connect(device: BluetoothDevice, priority: Priority = Priority.Balanced): BluetoothConnection? {
774 | return suspendCancellableCoroutine { continuation ->
775 | this.log("Trying to establish a connection with device ${device.address}...")
776 |
777 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
778 | this.log("[Legacy] Waiting ${GATT_133_TIMEOUT}ms before connecting, to prevent GATT_133")
779 | runBlocking {
780 | delay(GATT_133_TIMEOUT)
781 | }
782 | }
783 |
784 | // Establishes a bluetooth connection to the specified device
785 | BluetoothConnection(device).also {
786 | it.verbose = this.verbose
787 | it.coroutineScope = this.coroutineScope
788 | it.establish(this.context, priority) { successful ->
789 | if (successful) {
790 | log("Connected successfully with ${device.address}!")
791 | continuation.resume(it)
792 | } else {
793 | log("Could not connect with ${device.address}")
794 | continuation.resume(null)
795 | }
796 | }
797 | }
798 | }
799 | }
800 |
801 | /**
802 | * Establishes a connection with the specified bluetooth device
803 | *
804 | * @param device The device to be connected with
805 | *
806 | * @return A nullable [BluetoothConnection], null when not successful
807 | **/
808 | @SuppressLint("InlinedApi")
809 | @RequiresPermission(permission.BLUETOOTH_CONNECT)
810 | suspend fun connect(device: BLEDevice, priority: Priority = Priority.Balanced): BluetoothConnection? = this.connect(device.device, priority)
811 | // endregion
812 |
813 | }
--------------------------------------------------------------------------------