NordicSemiconductor Android BLE Library полностью устраивает большую часть разработчиков интерфейсов c BLE. Эта библиотека достаточно просто позволяет решить большую часть проблем, работы со стеком BLE на платформе Android.
Также, есть прекрасная облегчённая библиотека Мартейна ван Вейлли BLESSED написанная на Java и аналогичная версия на Kotlin Coroutines BLESSED
Живая и вполне поддерживаемая небольшая библиотека BluetoothCommunicator
Одним словом, есть вполне работоспособные и действующие проекты, которые в значительной степени избавляют от необходимости изобретения очередного велосипеда и серии самоподрывов в собственном приложении из-за багов библиотеки BLE, накладывающихся на баги интерфейса.
Просмотр кода библиотеки NordicSemiconductor Android BLE Library быстро отрезвляет и приводит к выводу, что лучше пользоваться громоздким, сложным, но уже готовым, чем изобретать велосипед. Создание собственной библиотеки BLE имеет, скорее учебное значение, нежели практическое применение. Однако, своей библиотекой BLIN интерсивно пользуюсь в собственных разработках. Например, для создания интерфейсов управления микропроцессорными устройствами на базе ESP32, STM32 PIC, AVE и т.д.
Что касается 'нативого' стека BLE, с ним, по ряду вполне понятных причин, среди разработчиков мало кто хочет связываться. За десять лет 'нарисовался' изрядный список, порой, довольно странных сложностей (Issues), которые неизбежно возникают при создании собственного стека работы с BLE Android и которые сама компания Google, похоже исправлять не собирается от слова "совсем". К сожалению, официальном руководстве Android BLE об этом списке проблем почти ничего не говорится.
-
Самый мистичный момент — параметр autoConnect функции public BluetoothGatt connectGatt (Context context, boolean autoConnect, BluetoothGattCallback callback).
Можно, конечно «ломануться» напрямую в стиле:
bleDeviceManager.connect(selectedBluetoothDevice.getDevice()) .retry(3, 200) .useAutoConnect(false) .enqueue()Однако, это далеко не всегда помогает.
Цитирую дословно Мартейна Велле:
«Чтобы узнать, было ли кэшировано устройство, вы можете использовать небольшой трюк. После создания BluetoothDevice вы должны сделать это, getType() и если он вернет TYPE_UNKNOWN, устройство явно не закэшировано.
Это решение, придуманно программистами NordicSemiconductor: в качестве значения
autoConnectиспользоватьbluetoothDevice!!.type == bluetoothDevice.DEVICE_TYPE_UNKNOWN.Подробнее, см. Martin van Wellie // Making Android BLE work — part 2
Вообще, правильное значение параметра,
autoConnect, зависит от версии Android и модели мобильного телефона. Недокументированная мистика.В коде же получается что-то такое:
@SuppressLint("MissingPermission") private fun connect(address: String) : BluetoothGatt? { bleManager.bluetoothAdapter.getRemoteDevice(address)?.let { bluetoothDevice -> return device.connectGatt( bleManager.applicationContext, device.type == BluetoothDevice.DEVICE_TYPE_UNKNOWN, bleGattCallback, BluetoothDevice.TRANSPORT_LE ) } return null }
-
Ошибка
133. Функция public boolean discoverServices () часто возвращаетfalseи генерирует ошибки с кодом6/133.… E/DfuBaseService: Service discovery error: 129 E/DfuBaseService: An error occurred while connecting to the device:16513 D/BluetoothGatt: cancelOpen() - device: FA:B0:BE:04:6C:7E D/BluetoothGatt: onClientConnectionState() - status=133 clientIf=7 device=FA:B0:BE:04:6C:7E E/DfuBaseService: Connection state change error: 133 newState: 0 …Разработчики Nordic Semiconductor проснифили обмен с устройством блютуз и обнаружили, что в работе стека BLE Android нарушен протокол Bluetooth 4+.
«Это известная проблема с Android, у нас были похожие проблемы, когда возвращалась ошибка
133. В случае появления этой ошибки мы запускали сниффер, и увидели, что телефон сначала отправляет LL_VERSION_IND, а затем отправляет LL_FEATURE_REQ до того, как периферийное устройство отправило свою LL_VERSION_IND. Другими словами, телефон инициирует вторую процедуру управления LL до завершения первой, и это явное нарушение спецификации Bluetooth. Из-за этой ошибки SoftDevice отключается.» ©Не исключено, что это сделано намеренно, чтобы предотвратить действия злоумышленников, но это безумно раздражает!
Самый простой способ решения проблемы, предложенный, опять-таки программистами Nordic-Semiconductor (значится, как «BLE status = 133 problem #1») — при получении ошибки быстрое сканирование устройства с фильтром по адресу и повторная попытка подключения. Есть небольшая проблема: согласно официальному руководству, за 30 секунд у нас всего 5 попыток. После этого устройство блокируется системой примерно на 1 минуту.
В штатном описании
133-й ошибки, вообще отсутствует. Есть одинокая константа в файле gatt_api.h#define GATT_ERROR 0x85Опять-таки, у Мартейна ван Велле говорится, что в этом случае, нужно просканировать устройство с фильтром по его mac-адресу (используя неагрессивный режим сканирования). После этого можно снова использовать автоподключение» Мартин Веллие пишет, что при возврате такой ошибки надо пересканировать устройство и повторить попытку подключения.
-
Проблема работы фильтров при сканировании BLE устройств, так до сих пор и не решена. Это известная «умолчанка» про BluetoothLeScanner висит в списке issues уже лет десять. Так же, официально не заявлено, что при шестикратном повторном запуске сканирования, сканирование вообще блокируется на минуту.
-
В официальном руководстве не сказано о том, что не существует какого либо специального флага уведомлений о том, что характеристика работает в режиме ответа на запись ENABLE_NOTIFICATION_VALUE/ENABLE_INDICATION_VALUE. На самом деле, этот параметр не принадлежит характеристики. Это — значение дескриптора c UUID
00002902-0000-1000-8000-00805f9b34fb. Так, что придётся самим создать список характеристик, которые переведены в режим 'NOTIFICATION' и не забыть их вернуть в обычный режим по окончании работы приложения. Есть метод setCharacteristicNotification(BluetoothGattCharacteristic characteristic, Boolean enable). Но толку от него мало, если Вы вручную не поменяете значение соответствующей notification/indication характеристики.@SuppressLint("MissingPermission") private fun enableNotifyCharacteristic(bluetoothGattCharacteristic: BluetoothGattCharacteristic) { bluetoothGatt?.let { gatt -> bluetoothGattCharacteristic.getDescriptor(NOTIFY_DESCRIPTOR_UUID) ?.let { bluetoothGattDescriptor -> gatt.setCharacteristicNotification(bluetoothGattCharacteristic, true) bluetoothGattDescriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE writeGattData(GattData(bluetoothGattDescriptor)) } } } @SuppressLint("MissingPermission") fun notifyCharacteristic(bluetoothGattCharacteristic: BluetoothGattCharacteristic) { if (isCharacteristicNotified(bluetoothGattCharacteristic)) { disableNotifyCharacteristic(bluetoothGattCharacteristic) } else { enableNotifyCharacteristic(bluetoothGattCharacteristic) } }
И чтобы отследить изменения состояния Характеристики, нужно добавить слежение за изменением значений соответствующего дескриптора
val NOTIFY_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb".uppercase())Штатный метод public void onCharacteristicChanged (BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) срабатывает только, на включение
NOTIFICATION/INDICATION.Придётся отлавливать изменения режима при помощи функции public void onDescriptorRead (BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status, byte[] value) и соорудить что-то вроде
private fun onDescriptorWrite(gatt : BluetoothGatt?, descriptor: BluetoothGattDescriptor?, ) { if (gatt != null && descriptor != null && descriptor.uuid == NOTIFY_DESCRIPTOR_UUID) { val notify = ByteBuffer .wrap(bluetoothGattDescriptor.value) .order(ByteOrder.LITTLE_ENDIAN).short.toInt() when(notify) { 0 -> { if (isCharacteristicNotified(bluetoothGattDescriptor.characteristic)) { Log.d(tagLog, "onCharacteristicChanged(${bluetoothGattDescriptor.characteristic.uuid}, notifyDisable)") mutableListNotifiedCharacteristic.remove(bluetoothGattDescriptor.characteristic) mutableSharedFlowCharacteristicNotify .tryEmit(BleCharacteristicNotify(bluetoothGattDescriptor.characteristic.uuid, false)) } } 1 -> { if (!isCharacteristicNotified(bluetoothGattDescriptor.characteristic)) { Log.d(tagLog, "onCharacteristicChanged(${bluetoothGattDescriptor.characteristic.uuid}, notifyEnable)") mutableListNotifiedCharacteristic.add(bluetoothGattDescriptor.characteristic) mutableSharedFlowCharacteristicNotify .tryEmit(BleCharacteristicNotify(bluetoothGattDescriptor.characteristic.uuid, true)) } } 2 -> { } else -> { } } } }
-
Буфферы обмена надо делать самому. И, в данной ситуации это даже хорошо. Можно реализовать фичи, вроде Back-pressure и т.д.
-
Про Knox-проблемы, которые получаются в жизни корейских устройств можно написать целую книгу. В частности, public boolean createBond() у меня так и не заработала на Samsung Galaxy 10se
-
BluetoothGattCallback.onConnectionStateChange не всегда срабатывает при отключении устройства, если скажем, оно не сопряжено с телефоном (некоторые устройства без сопряжения автоматически разрывают связь через 30 секунд) Поэтому, надо устанавливать ожидание сообщения об отключении, перед тем, как закрыть приложение. Иначе, некий внутренний счётчик подключений переполнится и устройство будет всё-время возвращать 6/133. Придётся очищать стек подключения (Настройки/Приложения/Показать Системны/Bluetooth/Очистить память, перезагрузка устройства), а заодно и поубирать Характеристики "взведённые" в режим подтверждения получения данных.
private fun doDisconnect() = runBlocking { bluetoothGatt?.let { gatt -> mutableListNotifiedCharacteristic.forEach { characteristic -> disableNotifyCharacteristic(characteristic) } while (mutableListNotifiedCharacteristic.isNotEmpty()) { delay(20) } Log.d(tagLog, "doDisconnect($bluetoothDevice)") gatt.disconnect() while (connectState != State.Disconnected) { delay(20) } gatt.close() } } -
Количество разрешений, необходимых для включения/выключения, сканирования, считывания рекламы
Bluetooth, BLE, постоянно растёт от одной версии к другой и нужно учитывать с какой версией мы имеем дело, чтобы программа нормально работала на наибольшем количестве устройств. Причём, с каждой версией список только растёт. Это не совсем баг, но явление довольно неприятное, хотя и нормальное для нормального развития любой библиотеки. -
У Вас есть только 5 попыток пересканирования за 30 секунд. В противном случае, сканирование блокируется примерно на 1 минуту. Причём, «Штатный» ScanCallback вообще не возвращает никаких ошибок, кроме сообщения в консоли отладчика, типа
D/BluetoothLeScanner: onScannerRegistered() - status=6 scannerId=-1 mScannerId=0Отловить эту ошибку фактически невозможно: она никуда не передаётся. Можете сами убедиться. Код предельно простой:
repeat(6) { runBlocking { startScan() delay(100) stopScan() } }
Однако,
scanCallbackна основе PendingIntent вполне решает проблему с контролем за ошибками подключения, но об этом, позже. -
Режим фильтрации сканирования по адресу или UUID-Сервиса на основе ScanFilter на многих устройствах до сих пор не работает. У меня не заработал ни на одном доступном мне телефоне. По какой-то причине этот дефект не устранён за последние... да лет пять тому, как, чтобы не соврать. Поэтому, нужно делать свой фильтр, что совсем не сложно, но не понятно, почему до сих пор не реализован в
штатномрежиме?
Однако, несмотря на изрядный набор ошибок и недоработок, иногда бывает нужно сделать что-то совершенно своё, особенное. Для этого надо хорошее понимание основных проблемм работы со стеком BLE и умение с ним обращаться. Например, захотелось триангулировать своё положение при помощи стека BLE. Возможно? Вполне. Например, Determining the Proximity to an iBeacon Device
-
Официальная документация по стеку Bluetooth LE Android написана внятно и прозрачно. Однако, существует содержит длинный, список упорно не исправляемых разработчиками Google проблем (Issues). Если хотите, загляните в финал документа, там есть несколько ссылок, в т.ч. Android BLE Issues от Google.
-
Однако, список не документированных проблем достаточно длинный. Скажем, вполне опрятный список есть у SweetBlue Android BLE Issues
-
Наконец, самый толковый документ для начинающего BLE-разработчика — это цикл статей Мартейна ван Велли (Martjein van Wellie, Amsterdam):
- Making Android BLE work — part 1 // Martin van Welie Мартейн ван Велли. Часть 1. Как заставить Android BLE работать - часть 1
- Making Android BLE work — part 2 // Martin van Welie Мартейн ван Велли. Часть 2. Подключение, отключение, исследование сервисов
- Making Android BLE work — part 3 // Martin van Welie Мартейн ван Велли. Часть 3. чтение/запись характеристик; включение/выключение уведомлений
- Making Android BLE work — part 4 // Martin van Welie Мартейн ван Велли. Часть 4. Сопряжение с устройствами
- Есть отличный перевод этого цикла на Хабре:
- Перевод статьи Мартейна ван Велле. Часть 1. Сканирование
- Перевод статьи Мартейна ван Велле. Часть 2. Подключение/Отключение
- Перевод статьи Мартейна ван Велле. Часть 3. Чтение/Запись характеристик
- Перевод статьи Мартейна ван Велле. Часть 4. Сопряжение. Очереди. Включение/Выключение уведомлений характеристик
- Библиотеки Мартейна ван Велле
- Wellie Blessed — Библиотека для Андроид Blessed (Java)
- Wellie Blessed Coroutine — Библиотека Мартейна ван Велле Blessed на Котлин
-
Существует небольшой, но очень дельный
китаёзныйгайд Chee Yi Ong — The Ultimate Guide to Android Bluetooth Low Energy. Настоятельно рекомендую для чтения, если Вы всё-таки решили «залезть» в тему BLE. Можно с уверенностью утверждать, что если Вы будете следовать в разработке своей библиотеки рекомендациям эти руководства, Ваше приложение будет работать хотя бы на 80% современных мобильных устройств, учитывая что особо не хочется поддерживать всё, что ниже версии Marshmallow (хотя, это не так уж и трудно — описано довольно подробно) и на 12-й версии описывают какие-то трудно объяснимые проблемы. В частности, Android 12 Новые разрешения Bluetooth. Однако, утверждается, что эти проблемы исправлены. Нет гарантии, что не возникнут новые. -
Разработка Nordic Semiconductor
- BLE on Android v1.0.1 — несколько устаревшее, но подробное и внятное описание работы со стэком Bluetooth Low Energy на Android.
- Android BLE Library — лучшая на сей день работа в тематике Android BLE. Если Вы хотите залезть в тему по-настоящему, читайте исходные коды. Найдёте много
адаудивительного.
-
Китаёзный гайд от Espressif
- Рекомендации к Андроид-приложению
- Пример приложения RainMaker — Это официальное приложение для Android для ESP RainMaker — комплексного решения, предлагаемого Espressif для удаленного управления и мониторинга продуктов на базе ESP32-S2 и ESP32 без какой-либо настройки в облаке.
- Библиотека для комплексной работы с EPS32 — Библиотека для отправки сетевых учетных данных и/или пользовательских данных на устройства ESP32 (или его варианты, такие как S2, S3, C3 и т. д.) или устройств ESP8266.
-
Проект состоит из Приложения ('app'),
-
Библиотеки Bluetooth Low Energy, которую почему-то назвал BLIN (Bluetooth Interface, а не то, что Вы подумали),
-
Зачем-то сделал виджет MultistateButton. Честно говоря, сделал его просто так. Просто, чтобы разгрузить основной код. Хотя... нет. Думаю, хотел понять, почему большая часть самопальных виджетов неправильно позиционируется в ConstraintLayout. Может быть, это какой-то заговор? Увы, нет, не заговор. Дамы и господа, когда что-то клепаете, мануалы всё-таки, надо читать!
Библиотека состоит из четырёх основных модулей:
- BleManager.kt
- BleScanManager.kt — Модуль сканирования с использованием BroadcastReceiver и PendingIntent
- BleGattManager.kt — Модуль подключения к BluetoothGatt. Подключение, исследование GATT, буферизация IO данных, отправка и получение значений Характеристик и Дескрипторов.
- BleBondManager.kt — Модуль сопряжения устройства с телефоном. Без сопряжения устройство, скажем на основе ESP32 держит связь около 30 секунд, а потом подключение закрывается
- RequestPermissions.kt — Отдельно в библиотеке реализован класс для запроса Permissions, хотя это не совсем правильно, ведь разрешения привызываются к Activity.
- Idling — Набор ожидалок для инструментального тестирования приложения.
- FakeBleManager.kt — Фейковая заглушка, имитирующая работу всех сервисов, Сканирование, Сопряжение, Подключение, обмен данными и т.д. Поскольку BleManager.kt, FakeBleManager.kt наследованы от BleManagerInterface.kt вполне можно писать собственные тестовые фейки и подменять экземпляр
bleManager, который традиционно лежит в Application-классе. Других вариантов для инструментального тестирования, увы у меня для вас нет :( - Обмен данными реализован при помощи связок MutableStateFlow/StateFlow и MutableSharedFlow/SharedFlow. Согласитесь, что библиотека Корутин и реализация холдных/горячих потоков, их преобразование «на лету» из одних в другие, выглядит потрясающе даже на фоне знаменитой RxJava. А уж на фоне унылых LiveData, так и вовсе, Космос рядом с крестьянской лошадкой...
AndroidManifest.xml — Файл манифеста библиотеки BLIN.
Содержит в себе только запросы разрешений, слизанные из заметки Bluetooth permissions:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.grandfatherpikhto.blin">
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Needed only if your app looks for Bluetooth devices.
If your app doesn't use Bluetooth scan results to derive physical
location information, you can strongly assert that your app
doesn't derive physical location. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Needed only if your app makes the device discoverable to Bluetooth
devices. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- Needed only if your app communicates with already-paired Bluetooth
devices. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Needed only if your app uses Bluetooth scan results to derive physical location. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
</manifest>Понятно, что «опасные» разрешения, такие как android.permission.ACCESS_COARSE_LOCATION необходимо запрашивать программно при помощи registerForActivityResult
BleScanManager — Менеджер Сканирования
Реализованы примитивные фильтры по имени устройства, адресу и UUID.
@SuppressLint("MissingPermission")
fun startScan(addresses: List<String> = listOf(),
names: List<String> = listOf(),
services: List<String> = listOf(),
stopOnFind: Boolean = false,
filterRepeatable: Boolean = false,
stopTimeout: Long = 0L
) : Boolean {
// ...
return true
}Фильтрация по адресу addresses: List<String> понадобится при подключении к устройству. Как и было сказано, часто случается, так, что метод
BluetoothDevice.connectGatt() возвращает ошибку (6, 133). Чтобы устранить её, надо провести быстрое сканирование по адресу устройства подключения и снова повторить попытку подключения.
Для этого добавлена переменная stopOnFind: Boolean = false — после первого найденного совпадения, сканирование останавливается. Можно делать повторную попытку подключения.
Если параметр stopTimeout: Long = 0L не равен нулю, сканирование будет остановленно через указанное количество миллисекунд, вне зависимости от результатов. В противном случае, сканирование будет длиться вечно. Ну или, пока аппарат не разрядится или не сдохнет.
Параметр filterRepeatable: Boolean = false применяется для фильтрации повторяющихся значений. Сканнер периодически обходит цепочку доступных устройств, и генерирует сообщение каждый раз, а это не всегда нужно. В некоторых случаях бывает нужно эмитировать устройство с уникальными именем/адресом единожды. Для однократного оповещения надо включить filterRepeateble = true
В сканнере реализована функция обратного вызова, через PendingIntent и, соответственно BroadcastReceiver. BcScanReceiver.kt получает уведомления о найденных устройствах в виде ScanResult и уведомления об ошибках. И при помощи функции обработного вызова отдаёт результаты в BleScanManager, где они филтруются (если фильтры списков не пустые) и эмитируется событие stateFlowScanResult и stateFlowBleScanResult.
Эмитирование ScanResult «висит в пустоте» (никуда не передаётся). Антресольный рефлекс — на всякий случай.
Принял решение передавать только обёрнутые данные, в которых находятся только необходимые данные. Это, конечно, ограничивает возможности библиотеки, зато облегчает задачу тестирования. Генерирование и «мокание» BluetoothDevice, ScanResult — на Андроид устройстве, занятие муторное. Есть, конечно, пакет Robolectric с его чудными Тенями, но... он тоже работает только на компьютере. Система Андроид фактически мокинг не поддерживает.
В принципе, есть два способа подключения PendingIntent:
-
Статический. В этом случае надо создать статические «получатели» PendingIntent(), в классе, наследованном от BroadcastReceiver():
// ... companion object Receiver { private const val TAG="BleScanReceiver" private const val ACTION_BLE_SCAN = "com.rqd.testscanbt.ACTION_BLE_SCAN" private fun newIntent(context: Context): Intent { Log.e(TAG, "newIntent()") val intent = Intent( context, BleScanReceiver::class.java ) intent.action = ACTION_BLE_SCAN return intent } @SuppressLint("UnspecifiedImmutableFlag") fun getBroadcast(context: Context, requestCode: Int): PendingIntent { Log.e(TAG, "getBroadcast(requestCode = $requestCode, " + "flag = ${PendingIntent.FLAG_UPDATE_CURRENT})") return PendingIntent.getBroadcast( context, requestCode, newIntent(context), PendingIntent.FLAG_UPDATE_CURRENT ) } } private var bleScanManager:BleScanManager? = null // ...
В этом случае, нам остаётся получить в BleScanManager.kt экземпляр Намерения при помощи обращения к ленивому неизменяемому свойству bleScanPendingIntent
private val bleScanPendingIntent: PendingIntent by lazy { BleScanReceiver.getBroadcast( context, REQUEST_CODE_BLE_SCANNER_PENDING_INTENT ) }
В этом случае важно не забыть прописать в
AndroidManifest.xmlэтот рессивер фильтры к нему:<application android:allowBackup="true" android:name=".BleApplication" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.LessonBleScan03"> <receiver android:name=".BleScanReceiver" android:enabled="true" android:exported="false"> <intent-filter> <category android:name="android.intent.category.DEFAULT" /> <action android:name="com.rqd.testscanbt.ACTION_BLE_SCAN" /> <!-- <action android:name="android.bluetooth.le.extra.LIST_SCAN_RESULT" />--> <!-- <action android:name="android.bluetooth.le.extra.CALLBACK_TYPE" />--> <!-- <action android:name="android.bluetooth.le.extra.ERROR_CODE" />--> </intent-filter> </receiver> </application>
К существенным недостаткам этого способа стоит отнести, что надо где-то подвешивать либо SingleTone, либо companion object, через которые обмениваться данными между элементами UI, Repsitory, ViewModel и т.д. что само по себе уже плохо (в частности, для отладки — куча проблем). Андроидоводы вполне заслуженно уже давно отнесли SingleTone к антипаттернам.
Достоинство: не надо загромождать код созданием объекта BroadcastReceiver и писать линейки фильтров. Весьма сомнительная радость... в нашем проекте точно не подойдёт. Библиотека, даже если она называется BLIN, должна быть максимально независима от приложения, поэтому, минимум обращений к
AndroidManifest.xml -
Динамический (программный) Этот подход существенно проще. Нам не надо искать значения переменных для
AndroidManifest.xml.В рессивере пишем ленивую lazy (да здравствуют делегаты!) константную переменную
/** * Почему Missing PendingIntent mutability flag? */ private val bcPendingIntent: PendingIntent by lazy { PendingIntent.getBroadcast( bleScanManager.applicationContext, REQUEST_CODE_BLE_SCANNER_PENDING_INTENT, Intent(ACTION_BLE_SCAN), PendingIntent.FLAG_UPDATE_CURRENT ) }
И уже в BleScanManager получаем экземпляр класса и обращаемся к методу:
private var bleScanPendingIntent: PendingIntent = bcScanReceiver.pendingIntent
Поскольку, все менеджеры наследованы от DefaultLifecycleObserver, используем штатные коллбэки LifeCycle: DefaultLifecycleObserver.onCreate(), DefaultLifecycleObserver.onDestroy(). И в DefaultLifecycleObserver.onCreate() вызываем регистрацию рессивера, а в DefaultLifecycleObserver.onDestroy() — его разрегистрацию:
override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) Log.d(tagLog, "onCreate()") bleManager.applicationContext.registerReceiver(bcScanReceiver, makeIntentFilters()) } override fun onDestroy(owner: LifecycleOwner) { bleManager.applicationContext.unregisterReceiver(bcScanReceiver) stopScan() super.onDestroy(owner) }
и делаем метод для формирования списка фильтров событий:
private fun makeIntentFilters() : IntentFilter = IntentFilter().let { intentFilter -> intentFilter.addAction(Intent.CATEGORY_DEFAULT) intentFilter.addAction(BcScanReceiver.ACTION_BLE_SCAN) intentFilter }
Основная часть работы совершается в интерфейсе обратных вызовов, наследованном от BluetoothLeScanner
Перегрузка метода [onReceive(context: Context?, intent: Intent?)] перехватывает события сканирования
override fun onReceive(context: Context?, intent: Intent?) {
if ( context != null && intent != null ) {
when (intent.action) {
ACTION_BLE_SCAN -> {
extractScanResult(intent)
}
else -> {
Log.d(tagLog, "Action: ${intent.action}")
}
}
}
}Если intent не содержит ошибок intent.hasExtra(BluetoothLeScanner.EXTRA_ERROR_CODE), можно извлечь 'ScanResult':
if (errorCode == -1 && intent.hasExtra(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)) {
intent.getParcelableArrayListExtra<ScanResult>(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
?.let { results ->
results.forEach { result ->
result.device?.let { _ ->
// Log.d(tagLog, "Device: $device")
bleScanManager.onReceiveScanResult(result)
return result
}
}
}
}Осталось «прогнать» результат через фильтры (если они заполнены значениями) и можно эмитрировать результат и остановить сканирование при совпадении, если взведена переменная stopOnFind (для рескана при получении ошибки 133)
private val mutableSharedFlowScanResult = MutableSharedFlow<ScanResult>(replay = 100)
val sharedFlowScanResult:SharedFlow<ScanResult> get() = mutableSharedFlowScanResult.asSharedFlow()
@SuppressLint("MissingPermission")
fun onReceiveScanResult(scanResult: ScanResult) {
scanResult.device.let { bluetoothDevice ->
if ( filterName(bluetoothDevice)
.and(filterAddress(bluetoothDevice))
.and(filterUuids(bluetoothDevice.uuids))
) {
if (isEmitScanResult(scanResult)) {
mutableSharedFlowScanResult.tryEmit(scanResult)
mutableSharedFlowBleScanResult.tryEmit(BleScanResult(scanResult))
}
if (stopOnFind &&
(names.isNotEmpty()
.or(addresses.isNotEmpty()
.or(uuids.isNotEmpty())))
) {
stopScan()
}
}
}
}В некоторых случаях бывает нужен список отсканированных устройств. Перехватывать внутренний буффер MutableSharedFlow — занятие вредное и совершенно бессмысленное. Примерно, как искать последний элемент в горячем общем потоке (Hot Shared Flow). В силу невероятной гибкости Котлин, это можно, конечно, сделать, но по-моему, лучше так делать не надо. Так что, есть отдельный массив, содержащий неповторяющиеся элементы сканирования.
val scanResults = mutableListOf<ScanResult>()При помощи которого можно при необходимости отбрасывать повторные результаты сканирования. Вряд ли в списке будет больше 100 элементов, поэтому, нагрузка от такого массива будет не велика. А пока, что можно было и обойтись буффером из MutableSharedFlow
Конструктор класса BleScanManager получает context. Это нужно для того, чтобы получить BluetoothManager и из него BluetoothAdapter и BluetoothLeScanner, необходимый для сканирования BLE-устройств.
Класс BleScanManager наследован от DefaultLifecycleObserver. Это не очень хорошо, так как лишает его универсальности. Ведь события создания класса и его разрушения привязаны к DefaultLifecycleObserver.onCreate() и DefaultLifecycleObserver.onDestroy() родительского объекта. Потому, что надо обязательно закрывать подключение или останавливать сканирование при закрытии приложения. Иначе при повтороном подключении/сканировании, получим довольно удивительные ошибки.
В принципе, правильным было бы сделать метод onDestroy() и на этом «закрыть» тему на ответственность программиста, использующего библиотеку. Но...
А где Вы его собираетесь создавать? В классе, наследованным от Application? Его разрушение можно отследить только из Service. Так, что придётся использовать в качестве создателя либо Activity, либо Service. А у этих классов с lifecycle всё в порядке.
Второй аргумент, который получает класс BleScanManager — CoroutineDispatcher. Он нужен для того, чтобы при тестировании превратить асинхронные события в последовательные синхронные при помощи UnconfinedTestDispatcher. У аргумента есть «умолчальное» значение — Dispatchers.IO.
Unit-тестирование BleScanManager
Используется классическая связка Mockito/Robolectric.
Сделано несколько примитивных вспомогательных функций для генерирования ScanResult, BluetoothDevice, BluetoothGatt в MockHelper. Например, генерация рандомных ScanResult и BluetoothDevice
fun mockBluetoothDevice(name: String? = null, address: String? = null): BluetoothDevice {
val bluetoothDevice = mock<BluetoothDevice> { bluetoothDevice ->
on {bluetoothDevice.name} doReturn name
on {bluetoothDevice.address} doReturn (
address ?: Random.nextBytes(6)
.joinToString (":") {
String.format("%02X", it) })
}
return bluetoothDevice
}
// ...Так, что код проверки ставится очень простым. Например, можно посмотреть состояние bleScanManager.flowState после запуска сканирования. Запустить набор сгенерированных BluetoothDevices, проверить список отфильтрованных устройств на совпадение. Проверить, остановилось ли сканирование bleScanManager.flowState
/**
* Проверяет состояние после запуска сканирования.
* "Запускает" набор сгенерированных BluetoothDevices
* и проверяет список отфильтрованных устройств на совпадение
* Останавливает сканирование и проверяет состояние flowState
*/
@Test
fun testScan() = runTest(UnconfinedTestDispatcher()) {
bleManager.startScan()
assertEquals(BleScanManager.State.Scanning, bleManager.scanState)
val scanResults = mockRandomScanResults(7)
scanResults.forEach { scanResult ->
bleManager.bleScanManager.onReceiveScanResult(scanResult)
}
assertEquals(bleManager.bleScanManager.scanResults.map { it.device }, scanResults.map { it.device })
bleManager.stopScan()
assertEquals(BleScanManager.State.Stopped, bleManager.scanState)
}И так далее. Здесь можно использовать теневые объекты и творить что Вам заблагорассудится на любой фасон и вкус. Во-всяком случае, логику сканнера можно проверить полностью, хватило бы терпения и изобретательности.
Важно не забывать, что для тестирования асинхронных процессов надо использовать библиотеку org.jetbrains.kotlinx:kotlinx-coroutines-test
dependicies {
...
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0'
testImplementation 'org.mockito:mockito-inline:4.6.1'
testImplementation 'org.robolectric:robolectric:4.8.1'
testImplementation 'androidx.test:core:1.5.0-alpha01'
testImplementation 'androidx.test:core-ktx:1.5.0-alpha01'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
...
}Это нам даст
-
kotlinx-coroutines-test даст возможность использовать
runTest(UnconfinedTestDispatcher())см. UnconfinedTestDispatcher и runTest -
androidx-test:core и androidx-test:core-ktx дадут возможность использовать управляемый хорошо замоканный
ApplicationProvider.getApplicationContext<Context?>().applicationContext -
mockito-inline — доступ к
finalклассамJava. -
mockito-kotlin — упрощает доступ к «моканию» в языке Котлин и избегать труднонабираемых конструкций, типа
`when`(bluetoothDevice.name).thenReturn(name)связанных с совпадением ключевого слова
whenв Котлин и в Mockito. Так, что пользуйтесь классическим стилем mockito-kotlin и вместо привычного явистам, переползшим на Котлинlenient().`when`(bluetoothDevice.name).thenReturn(name)делайте более котлински:
mock<BluetoothDevice> { bluetoothDevice -> on { bluetoothDevice.name } doReturn name // ... }
-
JUnit 4 — думаю, в разъяснениях особо не нуждается. Хотя, всё время маячит вопрос, почему, не JUnit 5, который гораздо удобнее, чем Андроидовская четвёрка. Ответ простой: гугловцы до сих пор не удосужились перетащить Jupiter в Andoird Framework. Хотя, есть некоторые манипуляции с бубном, от MannoDerMaus которые позволяют использовать в Unit-тестах пятую версию. Правильный мануал придётся читать очень внимательно. Там много действий и они весьма запутанные. Стоит вся эта возня ожидаемого результата или нет, решайте сами. Но всё прилично работает и вполне соответствует документации.
Примечание: не забудьте добавить в build.gradle в разделе andoird,
testOptions {
unitTests {
includeAndroidResources = true
}
}иначе, Robolectric выдаст сообщение о невозможности получить ресурсы проекта.
BleGattManager — Менеджер подключения и обмена данными с Bluetooth устройством
Этот класс, в отличие от BleScanManager получает в конструкторе ещё и указатель на объект BleScanManager, поскольку в случае получения пресловутой ошибки 133, необходимо пересканировать устройства с фильтром адреса устройства, к которому подключаемся.
Так, что главный конструктор выглядит так:
class BleGattManager constructor(private val context: Context,
private val bleScanManager: BleScanManager,
dispatcher: CoroutineDispatcher = Dispatchers.IO)
: DefaultLifecycleObserver {
// ...
}главные методы этого класса — подключение к устройству с указанным адресом
fun connect(address:String):BlutoothGatt? {
// ...
return null
}и отключение
fun disconnect() {
// ...
}Причём, отключение реализуется в ждущем режиме: пока не будет получено уведомление об отключении, объект класса не разрушается. Это связано с тем, что внутренний счётчик подключений системы Андроид может переполниться и подключение к устройству будет постоянно возвращать ошибку 6.
@SuppressLint("MissingPermission")
private fun doDisconnect() = runBlocking {
bluetoothGatt?.let { gatt ->
mutableListNotifiedCharacteristic.forEach { characteristic ->
disableNotifyCharacteristic(characteristic)
}
withTimeout(WAIT_TIMEOUT) {
while (mutableListNotifiedCharacteristic.isNotEmpty()) {
delay(20)
}
}
gatt.disconnect()
withTimeout(WAIT_TIMEOUT) {
while (connectState != State.Disconnected) {
delay(20)
}
}
gatt.close()
}
}Тогда придётся сбрасывать кэш памяти Bluetooth в настройках телефона: Настройки / Приложения(Показать системные) / Bluetooth / Сбросить память.
Система сообщений BluetoothGattCallback обёрнута в собственную систему оповещений, поскольку, может понадобиться отображение процесса подключения к устройству, отключения от него и т.д. и из-за того, что устройство считается подключённым только после того как обработан коллбэк onServicesDiscovered, а это событие происходит далеко не внутри onConnectionStateChange
Так, что создан enum с набором состояний:
enum class State(val value:Int) {
Disconnected (0x00), // Отключены
Disconnecting (0x01), // Отключаемся
Connecting (0x02), // Подключаемся
Connected (0x02), // Подключены
Error (0xFF), // Получена ошибка
}Соответственно, Disconnected генерируется, когда в onConnectionStateChange приходит BluetoothProfile.STATE_DISCONNECTED, Disconnecting эмитируется после вызова функции disconnect(). Connecting — в момент вызова функции connect(address: String) и Connected, после успешного обратного вызова onConnectionStateChange. Можно, конечно, усложнить градацию состояний оповещения, скажем, добавить что-то вроде Rescan, Reconnect, но на мой взгляд это ненужно усложнит систему оповещения.
В методе connect(address: String) умышленно передаётся именно строковый адрес устройства. Это дополнительная проверка того, что устройство может быть вообще подключено при помощи метода getRemoteDevice(address: String). Однако, не следует забывать, что согласно официальной документации, адрес надо передавать в верхнем регистре, иначе можем получать непонятные сообщения об ошибке. Поэтому, в код добавлена статическая функция (обратите внимание, статическая, а то будете искать её в объекте bluetoothManager...)
Действительные аппаратные адреса Bluetooth должны быть указаны в верхнем регистре, в порядке следования байтов и в таком формате, как «00:11:22:33:AA:BB». Метод checkBluetoothAddress(String) поможет проверить адрес Bluetooth. BluetoothDevice будет считать аппаратный адрес действительным, даже если адаптер BluetoothAdapter никогда не видел это устройство.
Итого, метод connect(address:String) просто получает объект устройства и записывает его в глобальную переменную bluetoothDevice
fun connect(address:String) : BluetoothGatt? {
val validAddress = address.uppercase()
Log.d(tagLog, "connect($validAddress)")
if (connectState == State.Disconnected) {
connectIdling?.idling = false
if (BluetoothAdapter.checkBluetoothAddress(validAddress)) {
bluetoothAdapter.getRemoteDevice(validAddress)?.let { device ->
mutableStateFlowConnectState.tryEmit(State.Connecting)
bluetoothDevice = device
attemptReconnect = true
reconnectAttempts = 0
doConnect()
}
}
}
return null
}и если всё прошло успешно, вызовет приватный метод doConnect(). Он рассчитан на то, что уже получен ненулевой объект BluetoothDevice и в вызове connectGatt реализует загадочное решение от Nordic Semiconductor: autoConnect = (device.type == BluetoothDevice.DEVICE_TYPE_UNKNOWN)
@SuppressLint("MissingPermission")
private fun doConnect() : BluetoothGatt? {
bluetoothDevice?.let { device ->
reconnectAttempts ++
return device.connectGatt(
applicationContext,
device.type == BluetoothDevice.DEVICE_TYPE_UNKNOWN,
bleGattCallback,
BluetoothDevice.TRANSPORT_LE
)
}
return null
}Честно говоря, можно было не реализовывать включение и выключение NOTIFICATION/INDICATION Характеристик, но раз уж есть, пусть будет. Суть в том, что штатный метод setCharacteristicNotification вообще не включает ничего и не включает. Поэтому надо устанавливать правильное значение в соответствующий Дескриптор
val NOTIFY_DESCRIPTOR_UUID: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb".uppercase())Соответственно, придётся для включения уведомлений записывать в value Дескриптора BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE и BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE.
@SuppressLint("MissingPermission")
private fun disableNotifyCharacteristic(bluetoothGattCharacteristic: BluetoothGattCharacteristic) {
bluetoothGatt?.let { gatt ->
bluetoothGattCharacteristic.getDescriptor(NOTIFY_DESCRIPTOR_UUID)
?.let { bluetoothGattDescriptor ->
gatt.setCharacteristicNotification(bluetoothGattCharacteristic, false)
bluetoothGattDescriptor.value =
BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
writeGattData(GattData(bluetoothGattDescriptor))
}
}
}
@SuppressLint("MissingPermission")
private fun enableNotifyCharacteristic(bluetoothGattCharacteristic: BluetoothGattCharacteristic) {
bluetoothGatt?.let { gatt ->
bluetoothGattCharacteristic.getDescriptor(NOTIFY_DESCRIPTOR_UUID)
?.let { bluetoothGattDescriptor ->
gatt.setCharacteristicNotification(bluetoothGattCharacteristic, true)
bluetoothGattDescriptor.value =
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
writeGattData(GattData(bluetoothGattDescriptor))
}
}
}Соответственно, придётся хранить список устройств со включённым УВЕДОМЛЕНИЕМ/ИНДИКАЦИЕЙ в переменной
private val mutableListNotifiedCharacteristic = mutableListOf<BluetoothGattCharacteristic>()и в момент выключения не забыть вернуть в исходное состояние все перекоряченные характеристики. Хотя... честно говоря, не думаю, что этим кто-то будет пользоваться. Этот режим работает очень медленно и при начилии буффера для обмена данными, вообще не нужен. Достаточно уведомлений о прочтении/записи характеристики/дескриптора в BluetoothGattCallback
Буффер исходящих сообщений реализован совершенно варварски, на основе MutableList и прицеплен к BleGattCallback, поскольку удобнее всего обрабатывать события onCharacteristicWrite/[onDescriptorRead] сразу внутри интерфейса, а не тащить их ещё куда-то.
OutputBuffer Это обычный блокирующий буффер, хранящий объект MutableListQueue, что чистое варварство по всем статьям.
Если не всё понятно, подробнее об Очередях, можно почитать, например в книжке Структуры данных и алгоритмы очень спокойное объяснение для начинашек.
Если список исходящих сообщений в буфере пуст, данные сразу отправляются на запись, если очередь не пуста, значение добавляется в очередь. Никаких решений, скажем, с BackPressure, пока не сделано, но добавить такую возможность довольно просто.
fun writeGattData(gattData: GattData) {
buffer.enqueue(gattData)
if (buffer.count == 1) {
writeNextGattData(gattData)
}
}Соответственно, когда приходит уведомление onCharacteristicWrite/onDescriptorWrite и status == BluetoothGatt.GATT_SUCCESS, данные изымаются из очереди и считаются записанными
private fun dequeueAndWriteNextGattData(gattData: GattData) {
if (buffer.peek() == gattData) {
buffer.dequeue()
buffer.peek()?.let { nextGattData ->
writeNextGattData(nextGattData)
}
}
}Данные в очереди обёрнуты в самопальный класс BleGattItem:
data class BleGattItem (val uuidService: UUID,
val uuidCharacteristic: UUID? = null,
val uuidDescriptor: UUID? = null,
val value:ByteArray? = null,
) {
// ...
constructor(bluetoothGattDescriptor: BluetoothGattDescriptor) :
this(
uuidService = bluetoothGattDescriptor.characteristic.service.uuid,
uuidCharacteristic = bluetoothGattDescriptor.characteristic.uuid,
uuidDescriptor = bluetoothGattDescriptor.uuid,
value = bluetoothGattDescriptor.value,
)
// ...
fun getDescriptor(bluetoothGatt: BluetoothGatt): BluetoothGattDescriptor? =
if (uuidCharacteristic == null && uuidDescriptor == null) {
null
} else {
bluetoothGatt.getService(uuidService)?.let { service ->
service.getCharacteristic(uuidCharacteristic)?.let { characteristic ->
characteristic.getDescriptor(uuidDescriptor)
}
}
}
// ...
}За счёт дополнительных конструкторов можно инициализировать данные из объектов BluetoothGattService, BluetoothGattCharacteristic, BluetoothGattDescriptor. Соответственно, сделано три функции для получения Сервиса, Характеристики и Дескриптора, что несложно при наличии объекта BluetoothGatt. Объекты BluetoothGattService, BluetoothGattCharacteristic, BluetoothGattDescriptor,как ни странно прекрасно генерируются без всякого «мокания». Подменный объект BleGattItem сделан не из отладочных соображений, а для того, чтобы не использовать в очереди разнородные объекты и, упаси Вселенная, тип Any. Отладка с таким типом — тот ещё ад.
Так, что запись очередного значения из очереди выглядит очень просто:
@SuppressLint("MissingPermission")
private fun writeNextCharacteristic(bleGattItem: BleGattItem) : Boolean {
bluetoothGatt?.let { gatt ->
bleGattItem.getCharacteristic(gatt)?.let { characteristic ->
characteristic.value = bleGattItem.value
return gatt.writeCharacteristic(characteristic)
}
}
return false
}
@SuppressLint("MissingPermission")
private fun writeNextDescriptor(bleGattItem: BleGattItem) : Boolean {
bluetoothGatt?.let { gatt ->
bleGattItem.getDescriptor(gatt)?.let { descriptor ->
descriptor.value = bleGattItem.value
return gatt.writeDescriptor(descriptor)
}
}
// ...
return true
}Остаётся дождаться уведомления onCharacteristicWrite/onDescriptorWrite от наследника BluetoothGattCallback и убрать соответствующее значение из очереди.
Юнит-тестирование BleGattManager
Состоит из двух частей:
Тестирование очереди QueueBuffer
Используется, опять-таки Shadow из пакета Robolectric. Правда, придётся создать собственную Тень BluetoothGatt:
@SuppressLint("PrivateApi")
@SuppressWarnings("unchecked")
@Implements(BluetoothGatt::class)
class ShadowBluetoothGatt {
companion object {
private var bluetoothGatt:BluetoothGatt? = null
fun newInstance(bluetoothDevice: BluetoothDevice):BluetoothGatt
= bluetoothGatt ?: Shadow.newInstanceOf(BluetoothGatt::class.java,
).let { gatt ->
bluetoothGatt = gatt
gatt
}
}
private val mutableListServices = mutableListOf<BluetoothGattService>()
init {
(1..Random.nextInt(2,5)).forEach { _ ->
val service = BluetoothGattService(UUID.randomUUID(), BluetoothGattService.SERVICE_TYPE_PRIMARY)
(1..Random.nextInt(2,5)).forEach { _ ->
val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(),
BluetoothGattCharacteristic.PROPERTY_NOTIFY
.or(BluetoothGattCharacteristic.PROPERTY_READ)
.or(BluetoothGattCharacteristic.PROPERTY_WRITE),
0
)
service.addCharacteristic(characteristic)
}
mutableListServices.add(service)
}
}
val services: ArrayList<BluetoothGattService>
get() = mutableListServices as ArrayList<BluetoothGattService>
fun getService(uuid: UUID) : BluetoothGattService? =
mutableListServices.find { it.uuid == uuid }
// ...
}Не забываем аннотацию @Implements(BluetoothGatt::class), иначе получим ворох ошибок.
Пользовательскую тень пришлось создать потому, что в BleGattItem используются методы getService(bluetoothGatt: BluetoothGatt), getCharacteristic(bluetoothGatt: BluetoothGatt) и getDescriptor(bluetoothGatt: BluetoothGatt). Это связано с тем, что в списке очереди хранятся только UUID'ы Сервиса, Характеристики и Дескриптора. Если UUID дескриптора равен null, значит в BleGattItem хранится Характеристика, если UUID'ы Дескриптора и Характеристики равны null, в объекте хранится сервис. Так сделано, чтобы не использовать тип Any и хранить в списке объекты всех необходимых типов.
Конструкторы BleGattItem выглядят так:
data class BleGattItem (val uuidService: UUID,
val uuidCharacteristic: UUID? = null,
val uuidDescriptor: UUID? = null,
val value:ByteArray? = null,
val type: Type = Type.Write
) {
enum class Type (val value: Int) {
Write(0x01),
Read(0x02),
}
// ...
}В процессе обработки очереди есть накладные расходы на вызовах типа
bluetoothGatt.getService(uuidService)
?.getCharacteristic(uuidCharacteristic)
?.getDescriptor(uuidDescriptor)Зато, объект получился универсальным. За счёт указания типа Type.Read и Type.Write в очереди можно запрашивать как запись Характеристики/Дескриптора, так и чтение.
Осталось заполнить очередь данными. Допустим, запросами на запись Характеристики и вызвать в очереди метод onCharacteristicWrite(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, state: Int) с соответствующими данными. Если после всех операций очередь пуста, значит всё работает.
@Test
fun testQueue() = runTest (dispatcher) {
queueBuffer.bluetoothGatt = bluetoothGatt
val gattItems = mutableListOf<BleGattItem>()
(0..99).forEach { idx ->
val service = bluetoothGatt.services[idx.rem(bluetoothGatt.services.size)]
val characteristic = service.characteristics[idx.rem(service.characteristics.size)]
characteristic.value = Random.nextBytes(Random.nextInt(1, 10))
val gattItem = BleGattItem(characteristic, BleGattItem.Type.Write)
gattItems.add(gattItem)
queueBuffer.addGattData(gattItem)
}
assertEquals(100, queueBuffer.count)
gattItems.forEach { item ->
queueBuffer.onCharacteristicWrite(bluetoothGatt, item.getCharacteristic(bluetoothGatt), BluetoothGatt.GATT_SUCCESS)
}
assertEquals(0, queueBuffer.count)
}Халтура, конечно. Неплохо бы добавить тестирование записи/чтения Характеристик/Дескрипторов.
Тестирование подключения/отключения BleGattManagerTest
Здесь тестируется сам процесс подключения:
@Test
fun testConnect() {
bleManager.connect(ADDRESS)
val gatt = mockBluetoothGatt(ADDRESS)
bleManager.bleGattManager.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
bleManager.bleGattManager.onGattDiscovered(gatt, BluetoothGatt.GATT_SUCCESS)
assertEquals(AbstractBleGattManager.State.Connected, bleManager.connectState)
assertEquals(gatt, bleManager.bleGattManager.bluetoothGatt)
}Причём, было бы правильным использовать «Тени» ShadowBluetoothGatt и ShadowBluetoothDevice и далее, writeCharacteristic, readCharacteristic... так тестирование не надо было бы разбивать на части
val adapter = (ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
val bluetoothDevice = adapter.getRemoteDevice(Random.nextBytes(6)
.joinToString (":"){ String.format("%02X", it) })
shadowOf(bluetoothDevice).simulateGattConnectionChange(BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
val bluetoothGatt = ShadowBluetoothGatt.newInstance(bluetoothDevice)
bluetoothGatt.stub {
// ...
}
// ...и можно было бы протестировать в одном флаконе подключение, работу буффера, чтение/запись характеристик/дескрипторов. Но тест получается очень громоздким. Так, что остановимся на более простом варианте, в котором просто вызываются методы BleGattCallback с нужными значениями.
Этого достаточно, чтобы протестировать подключение с ошибкой 133 и последющим ресканом с фильтром по адресу.
@Test
fun testReconnectWithRescan() {
bleManager.connect(ADDRESS)
val bluetoothDevice = mockBluetoothDevice(address = ADDRESS, name = NAME)
val bluetoothGatt = mockBluetoothGatt(bluetoothDevice)
val scanResult = mockScanResult(bluetoothDevice)
bleManager.connect(bluetoothDevice.address)
bleManager.bleGattManager.onConnectionStateChange(null, ERROR_133, 0)
assertEquals(AbstractBleScanManager.State.Scanning, bleManager.scanState)
bleManager.bleScanManager.onReceiveScanResult(scanResult)
assertEquals(AbstractBleScanManager.State.Stopped, bleManager.scanState)
bleManager.bleGattManager.onGattDiscovered(bluetoothGatt, BluetoothGatt.GATT_SUCCESS)
assertEquals(AbstractBleGattManager.State.Connected, bleManager.connectState)
assertEquals(bluetoothGatt, bleManager.bleGattManager.bluetoothGatt)
}Менеджер сопряжения BLE-устройств BleBondManager
В основе — класс обработки широковещательных событий BcBondReceiver. Он наследуется от BroadcastReceiver и перехватывает событие сопряжения устройства и инициализировать повторное подключение. Проблема в том, что событие BluetoothDevice.ACTION_BOND_STATE_CHANGED генерируется при любом подключении к устройству. Поэтому, сначала надо перехватить запрос BluetoothDevice.ACTION_PAIRING_REQUEST, а потом сравнить адрес устройства в запросе на сопряжение, и при совпадении сформировать событие «Устройство сопряжено».
override fun onReceive(context: Context?, intent: Intent?) {
if ( context != null && intent != null ) {
when (intent.action) {
BluetoothDevice.ACTION_BOND_STATE_CHANGED -> {
val bondState: Int = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
val previousBondState: Int =
intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1)
val bluetoothDevice: BluetoothDevice =
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
// Log.d(TAG, "ACTION_BOND_STATE_CHANGED(${device?.address}): $previousBondState => $bondState")
bleBondManager.onSetBondingDevice(bluetoothDevice, previousBondState, bondState)
}
else -> {
}
}
}
}Осталось в классе BleBondManager зарегистрировать получатель:
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
applicationContext.applicationContext.registerReceiver(bcBondReceiver,
makeIntentFilter())
}
override fun onDestroy(owner: LifecycleOwner) {
applicationContext.unregisterReceiver(bcBondReceiver)
super.onDestroy(owner)
}
private fun makeIntentFilter() = IntentFilter().let { intentFilter ->
intentFilter.addAction(BluetoothAdapter.ACTION_REQUEST_ENABLE)
intentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
intentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
intentFilter.addAction(BluetoothDevice.ACTION_PAIRING_REQUEST)
intentFilter.addAction(BluetoothDevice.ACTION_FOUND)
intentFilter
}вызвать метод bondRequest(address: String) и следить за событиями:
/**
* BluetoothDevice.BOND_NONE 10
* BluetoothDevice.BOND_BONDING 11
* BluetoothDevice.BOND_BONDED 12
*/
fun onSetBondingDevice(bluetoothDevice: BluetoothDevice?, oldState: Int, newState: Int) {
bluetoothDevice?.let { device ->
if (device == requestDevice) {
Log.d(logTag, "onSetBondingDevice($bluetoothDevice, $oldState, $newState)")
when(newState) {
BluetoothDevice.BOND_BONDING -> { mutableStateFlowBleBondState
.tryEmit(BleBondState(BleDevice(bluetoothDevice), State.Bonding)) }
BluetoothDevice.BOND_BONDED -> { mutableStateFlowBleBondState
.tryEmit(BleBondState(BleDevice(bluetoothDevice), State.Bonded)) }
BluetoothDevice.BOND_NONE -> { mutableStateFlowBleBondState
.tryEmit(BleBondState(BleDevice(bluetoothDevice), State.Reject)) }
else -> { Log.d(logTag, "Unknown State: $newState")}
}
}
}
}К сожалению, такое сопряжение не будет работать на ряде устройств Samsung из-за Knox. Как это обойти, пока, увы, не знаю.
Юнит-тестирование BleBondManager
В классе BleBondManagerTest снова ничего сложного, однако, надо не забыть: этот класс — наследник DefaultLifecycleObserver, а значит, надо «замокать» основные события DefaultLifecycleObserver.onCreate() и DefaultLifecycleObserver.onDestroy()
Значит, надо «замокать» жизненный цикл. Сделать это очень просто при помощи Robolectric.
private val controllerActivity = Robolectric.buildActivity(AppCompatActivity::class.java)
.create()
.start()
private val appCompatActivity = controllerActivity.get()Проще говоря, создана пустая активность (Основной класс — AppCompatActivity и сразу переведена в состояния create() и start(). После этого зовём геттер get() и получаем appCompatActivity
Теперь, можно добавить bleBondManager к жизненному циклу:
@Before
fun setUp() {
closeable = MockitoAnnotations.openMocks(this)
appCompatActivity.lifecycle.addObserver(bleBondManager)
}На всякий случай, оставлен closeable = MockitoAnnotations.openMocks(this), чтобы можно было использовать аннотирование Mockito.
Теперь, есть возможность провести самое «глубинное» тестирование, которого в «чистом» Mockito или MockK добиться довольно... громоздко. Т.е., можно отправить интенцию (Намерение Intent), принять её в BroadcastReceiver и отследить всю цепочку до подтверждения или отказа от сопряжения устройства:
private fun putIntentDevice(bluetoothDevice: BluetoothDevice, newBondState: Int = BluetoothDevice.BOND_BONDED) =
applicationContext.sendBroadcast(Intent(BluetoothDevice.ACTION_BOND_STATE_CHANGED).let {
it.putExtra(BluetoothDevice.EXTRA_DEVICE, bluetoothDevice)
it.putExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.BOND_NONE)
it.putExtra(BluetoothDevice.EXTRA_BOND_STATE, newBondState)
it
})Осталось создать «Тень» отправляемого BluetoothDevice
private fun shadowBluetoothDevice(address: String, name: String, bondState: Int = BluetoothDevice.BOND_NONE) : BluetoothDevice =
bluetoothAdapter.getRemoteDevice(address).let { bluetoothDevice ->
shadowOf(bluetoothDevice).setBondState(bondState)
shadowOf(bluetoothDevice).setName(name)
return bluetoothDevice
}В тесте важно не забыть штатного роболектриковскго «ждуна». Если этого не сделать, событие просто не успеет произойти и Вы получите совсем не тот результат, которого ожидаете!
Учитывая, что внутри библиотеки обмен данными происходит с помощью упрощённых классов устройства — BleDevice и для передачи оповещения используется специальный дата-класс BleBondState(val bleDevice:BleDevice), state: BleBondManager.State, проверка будет выглядеть так:
@Test
fun bondDevice() = runTest(dispatcher) {
// Буквы адреса должны быть в ВЕРХНЕМ регистре
val address = randomBluetoothAddress
val bluetoothDevice = shadowBluetoothDevice(address, NAME)
// Метод [createBond()] вернёт true
shadowOf(bluetoothDevice).setCreatedBond(true)
// Вызываем запрос на сопряжение в штатном режиме
bleBondManager.bondRequest(address)
assertEquals(BleBondState(BleDevice(bluetoothDevice),
BleBondManager.State.Request), bleBondManager.bondState)
// Оговариваем, что устройство теперь относится к списку сопряжённых.
shadowAdapter.setBondedDevices(mutableSetOf(bluetoothDevice))
shadowOf(bluetoothDevice).setBondState(BluetoothDevice.BOND_BONDED)
// Отправляем Намерение с BluetoothDevice и кодом BOND_BONDED
putIntentDevice(bluetoothDevice)
// Включить штатного роболектривского «ждуна»!
ShadowLooper.shadowMainLooper().idle()
assertEquals(BleBondState(BleDevice(bluetoothDevice), BleBondManager.State.Bonded),
bleBondManager.bondState)
}Наконец, последний класс — BleManager
Это просто обёртка для потоков, данных, методов всех трёх основных менеджеров BleScannManager, BleGattManager, BleBondManager
BleManager наследуютеся от BleManagerInterface. Это нужно, чтобы в app (приложении) можно было создать свой фейковый объект, скажем FakeBleManager и запустить с его помощью управляемый инструментальный тест. Он крайне тупо состоит из одних геттеров и присваиваний. А больше здесь особо ничего и не нужно:
class BleManager constructor(private val context: Context,
dispatcher: CoroutineDispatcher = Dispatchers.IO)
: BleManagerInterface {
private val logTag = this.javaClass.simpleName
private val scope = CoroutineScope(dispatcher)
val applicationContext:Context get() = context.applicationContext
val bleScanManager: BleScanManager = BleScanManager(context, dispatcher)
val bleGattManager: BleGattManager = BleGattManager(context, bleScanManager, dispatcher)
val bleBondManager: BleBondManager = BleBondManager(context, dispatcher)
override val stateFlowScanState get() = bleScanManager.stateFlowScanState
override val scanState get() = bleScanManager.scanState
override val sharedFlowBleScanResult get() = bleScanManager.sharedFlowBleScanResult
override val scanResults get() = bleScanManager.scanResults.map { BleScanResult(it) }
// ...
override
fun startScan(addresses: List<String>,
names: List<String>,
services: List<String>,
stopOnFind: Boolean,
filterRepeatable: Boolean,
stopTimeout: Long
) : Boolean = bleScanManager.startScan( addresses, names, services,
stopOnFind, filterRepeatable, stopTimeout )
override fun stopScan() = bleScanManager.stopScan()
override fun connect(address: String): BleGatt? {
bleGattManager.connect(address)?.let {
return BleGatt(it)
}
return null
}
override fun disconnect() = bleGattManager.disconnect()
// ...
}Есть группа «абстрактных» классов AbstractBleManager, AbstractBleScanManager, AbstractBleGattManager, AbstractBleBondManager. От них можно наследовать собственные менеджеры подключения, сканирования и сопряжения и общий менеджер. Слово «абстрактные» взято в кавычки потому, что классы-то на самом деле конкретные. Сделаны абстрактными затем, чтобы избежать полной перегрузки всех методов при наследовании, но при этом сделать наследование необходимым и возможным создание переменной общего типа для нескольких разных менеджеров.
В частности, это можно использовать для инструментального тестирования приложения, подменяя «штатный» менеджер BLE на фиктивный.
Сервис взаимодействия с BLE устройством создан
BleScanApp — экземпляр Application
Используется для хранения менеджера BLE:
class BleScanApp : Application() {
var bleManager: AppBleManager? = null
}Пришлось создать новый абстрактный класс AppBleManager, наследованный от AbstractBleManager. Это нужно для того, чтобы обмениваться сурроагатами BleGatt, BleScanResult, BleDevice и т.д.
Плюс, можно создать фиктивный менеджер работы с BLE для инструментального тестирования, который тоже наследует AppBleManager, а значит, может заменить оригинальный.
Поскольку «мокание» на виртуальной Java-машине мобильного телефона, крайне усечённое, здесь уже трюки Mockito, MockK, Robolectric не пройдут. Придётся обмениваться объектами, которые можно легко создавать и изменять. В этом есть свой плюс: такие объекты занимают гораздо меньше места в памяти, чем оригинальные BluetoothGatt, ScanResult и BluetoothDevice. Тем более если понадобятся дополнительные данные, их всегда можно добавить в класс-суррогат.
В проекте не применяются DI, типа Dagger и т.д. Т.е., все инъекции делаются «врукопашную». Честно говоря, этому разумного обоснования нет. Просто, так нагляднее, что ли...
Здесь инициализируется объект AppBleManager:
private fun initBleManager() {
var fake = false
intent.extras?.let { extras ->
fake = extras.getBoolean(FAKE, false)
}
bleManager = if (fake) {
FakeBleManager(applicationContext)
} else {
MainBleManager(applicationContext)
}
(applicationContext as BleScanApp).bleManager = bleManager
}И единственное отличие от «штатного» MainActivity, сгенерированного мастером AndroidStudio, то, что здесь используется MenuProvider. Это гораздо удобнее устаревающего
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
return super.onCreateOptionsMenu(menu)
}И хорошо тем, что во всех Фрагментах будет использоваться тот же способ. Единообразная система работы с меню безо всяких унылых setHasOptionsMenu(true).
Для упрощения «прицепления» меню к Активностям и Фрагментам, сделаны дополнительные функции в Helper:
fun AppCompatActivity.linkMenuProvider(menuProvider: MenuProvider) {
(this as MenuHost).addMenuProvider(menuProvider)
}
fun AppCompatActivity.unlinkMenuProvider(menuProvider: MenuProvider) {
(this as MenuHost).removeMenuProvider(menuProvider)
}
fun Fragment.linkMenuProvider(menuProvider: MenuProvider) {
(requireActivity() as MenuHost).addMenuProvider(menuProvider)
}
fun Fragment.unlinkMenuProvider(menuProvider: MenuProvider) {
(requireActivity() as MenuHost).removeMenuProvider(menuProvider)
}Мелочь, но делает жизнь чуть-чуть приятнее.
Инструментальное тестирование BleScanManager
А вот здесь всё становится неопрятным и громоздким. Потому, что увы, даже MockK стрёмно съехал с темы на ошибке Unable to dlopen libmockkjvmtiagent.so: dlopen failed: library "libmockkjvmtiagent.so" not found. Хотя, в Официальной Документации утверждается, что всё прекрасно должно работать... не работает, зараза.
Проверить «чистый» Dexopener пока руки не дошли.
Так или иначе, придётся сооружать громоздкий, но зато управляемый FakeBleManager, который и будет генерировать нужные события и передавать данные в проект для проверки работы UI. Впрочем, кроме громоздкости в нём ничего сложного нет и чудить можно сколько угодно. Тем более, что в процессе обмена «нативные» объекты, такие, как ScanResult, BluetoothDevice, BluetoothGatt, заменены на обёртки данных, такие, как BleScanResult, BleGatt, BleGattItem, BleDevice. Конечно, это данные для бедных и, если что, придётся лезть в библиотеку, добавлять поля, сурово править код на всех уровнях... но зато в тестировании можно сколько угодно «фейкать», «мокать» и «стабить».
Кроме того, в библиотеку приш добавить трёх «Ждунов» — ConnectingIdling, DisconnectingIdling, ScanIdling. Они нужны для того, чтобы не давать приложению совершать определённые шаги до тех пор пока фейковое сканирование, подключени, отключение не будут завершены. (См. Ресурсы для работы с Espresso на холостом ходу)
Осталось подменить в основном классе приложения
- Все работы Мартейна Ван Велле Самое толковое и подробное описание работы с Bluetooth BLE, с кучей ссылок на различные источники. Подробно о сканировании устройств. Почему-то не отражена проблема сканирования устройств с фильтрами.
- Making Android BLE work — part 1 // Martijn van Welie Часть 1. Как заставить Android BLE работать - часть 1
- Making Android BLE work — part 2 // Martijn van Welie Часть 2. Подключение, отключение, исследование сервисов
- Making Android BLE work — part 3 // Martijn van Welie Часть 3. чтение/запись характеристик; включение/выключение уведомлений
- Making Android BLE work — part 4 // Martijn van Welie Часть 4. Сопряжение с устройствами
- Перевод статьи Мартейна ван Велле. Часть 1. Сканирование
- Перевод статьи Мартейна ван Велле. Часть 2. Подключение/Отключение
- Перевод статьи Мартейна ван Велле. Часть 3. Чтение/Запись характеристик
- Перевод статьи Мартейна ван Велле. Часть 4. Сопряжение устройств
- BLESSED A very compact Bluetooth Low Energy (BLE) library for Android 5 and higher, that makes working with BLE on Android very easy.
- BLESSED A very compact Bluetooth Low Energy (BLE) library for Android 8 and higher, that makes working with BLE on Android very easy. It is powered by Kotlin's Coroutines and turns asynchronous GATT methods into synchronous methods! It is based on the Blessed Java library and has been rewritten in Kotlin using Coroutines.
- (Talk) Bluetooth Low Energy On Android // Stuart Kent (Обсуждение) Bluetooth Low Energy на Android // Стюарт Кент //
- Gist by Stuart Kent to Android BLE Talk Огромное количество ссылок на разные ресурсы
- The Ultimate Guide to Android Bluetooth Low Energy Дельный и короткий гайд по работе со стеком BLE
- Android BLE Library Пожалуй, единственная Android библиотека, которая реально решает множество проблем Android с низким энергопотреблением Bluetooth и действительно нормально работает.
- Samsung Bluetooth Knox API Работа с BLE на Samsung
- Samsung API
- Android BLE Issues This is a short list of issues you will encounter if you try to use the native Android BLE stack directly // Краткий список проблем, с которыми вы столкнетесь, если попытаетесь напрямую использовать собственный стек Android BLE
- NordicSemiconductor - BLE Issues Список проблем работы с BLE на GitHub
- Google: Fix Bluetooth problems on Android Список проблем работы с Bluetooth от Google
- Android BLE Issues - SweetBlue Ещё один, немного устаревший список проблем работы со стеком BLE
- Android BLE scan with filter issue Проблемы сканирования с фильтром. Похоже, до сих пор не исправлены
- We’ll prevent applications from starting and stopping scans more than 5 times in 30 second
- Описание Bluetooth Подробная статья о Bluetooth на Википедии.
- Bluetooth specifications Спецификации Bluetooth.
- BLE Android official guide Официальное руководство по работе с BLE.
- Find BLE Devices Официальное руководство по работе с BLE. Сканирование.
- Connect GATT Server Подключение к серверу GATT.
- Transver BLE Data Передача/Приём данных через GATT.
- Android connectivity samples Официальный набор отдельных проектов Android Studio, которые помогут вам приступить к написанию приложений Connectivity на Android.
- Android BLE Library NordicSemiconductor Android BLE Library // Самая надёжная и быстрая библиотека стека BLE
- Android BluetoothLeGatt Sample В этом примере показано, как использовать общий профиль атрибутов Bluetooth LE (GATT) для передачи произвольных данных между устройствами.






