Загрузка проектов
Перед тем, как продолжить наши опыты с BLE и андроидом, я хочу показать вам, как загружать подготовленные мной проекты с GitHub-а. Делается это так. На заглавной странице выбираем кнопку Get from VCS и в новом окошке вводим адрес созданного в прошлой части проекта.
Нажимаем кнопку Clone, остальное среда Android Studio сделает сама. У вас скопируется, откомпилируется и откроется проект, который мы делали в первой части публикации.
Подготовка нового проекта
Выберем на начальной странице создание нового проекта, так как мы делали это в первой части. Назовем наш проект BleCentralDevice, так как на рисунке и с теми же настройками. Нажмем кнопку Finish.
После того, как среда закончит подготовку проекта, выберите файл манифеста app → manifests → AndroidManifest.xml и вставьте следующие строки, так как на рисунке ниже. Этим действием мы даем разрешения на использование ресурсов андроида.
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
Разместим на форме две кнопки Button, TextView и ListView, так как на рисунке и привяжем каждый элемент к краям экрана. У вас не должно быть ошибок, мы занимались этим в прошлый раз.
Напишите на кнопках Start и Stop, а на TextView — Status. Присоедините к компьютеру смартфон и нажмите зеленый треугольник Run (Shift+F10). У вас проект должен успешно откомпилироваться и загрузиться в ваш телефон. Получиться примерно следующее.
Продолжим работать над нашим проектом. В заголовке проекта определим названия наших элементов. Если они будут окрашены красным цветом, как на рисунке внизу, то сделаем так же как в прошлом уроке — импортируем классы. Иногда среда делает это без нашего участия.
Button startScanningButton;
Button stopScanningButton;
ListView deviceListView;
TextView textViewTemp;
Ниже по тексту инициализируем Button, TextView и ListView. Так же создадим пустые функции для обработки нажатия кнопок (сделайте это сами). Должно получится примерно следующее. При этом красный текст вверху должен стать черным.
textViewTemp = findViewById(R.id.textView);
//-------------------------------------------------------------------------------------
startScanningButton = findViewById(R.id.button);
startScanningButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startScanning();
}
});
//-------------------------------------------------------------------------------------
stopScanningButton = findViewById(R.id.button2);
stopScanningButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
stopScanning();
}
});
//-------------------------------------------------------------------------------------
deviceListView = findViewById(R.id.listView);
Запустите программу на исполнение. Ошибок быть не должно.
Дополним возможности элемента ListView. Введем для этого адаптер. Кроме того, будем обрабатывать нажатие на один из его элементов. Вот код выполняющий это.
deviceListView = findViewById(R.id.listView);
listAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1);
deviceListView.setAdapter(listAdapter);
deviceListView.setOnItemClickListener((adapterView, view, position, id) -> {
stopScanning();
device = deviceList.get(position);
//mBluetoothGatt = device.connectGatt(MainActivity.this, false, gattCallback);
});
Здесь появляются новые элементы, которые наш проект пока не знает. Последняя строчка пока закомментирована, она пригодится нам впоследствии. Как обычно, что бы красный текст стал черным, надо проинициализировать новые элементы.
Делаем это как обычно, импортируя необходимые классы в наш проект.
TextView textViewTemp;
//--------------------
ArrayAdapter<String> listAdapter;
BluetoothDevice device;
ArrayList<BluetoothDevice> deviceList;
Как обычно, запустите проект на исполнение. Если появляются ошибки, их легче найти, пока изменения кода невелики. Ещё хочется сказать несколько слов о новых элементах. Элемент listAdapter — это адаптер (набор полочек), элементами которого являются строки. Элемент devise — это более сложный элемент. Все BLE устройства, которые наше приложение сможет увидеть в эфире, обладают большим набором параметров. Это МАС адрес, состояние и многое другое. Все они описываются в классе BluetoothDevice, частью которого и является элемент device. Элемент deviseList — это массив элементов типа BluetoothDevice.
Немного теории и в путь
Прежде чем мы пойдем дальше, предлагаю вам сделать паузу и немного ознакомиться с теорией :-). На Хабре есть отличный цикл статей, который обязателен к прочтению. Вот он. Это перевод статьи Martijn van Welie. Собственно, после её прочтения, я наконец решился заняться этой темой. Не все её положения я использую у себя. Это связано в первую очередь с упрощением. Я хочу научить вас делать простой, работающий проект. Остальные важные плюшки, вы можете навешать сами. Читайте, разбирайтесь, а мы будем двигаться дальше. Создадим функцию инициализации Bluetooth и вызовем её.
public void initializeBluetooth() {
bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
}
Мы видим новые элементы, которые как обычно надо инициализировать и импортировать.
BluetoothLeScanner bluetoothLeScanner;
BluetoothAdapter bluetoothAdapter;
BluetoothManager bluetoothManager;
Однако этого не достаточно. Элемент Context остался выделенным красным, его класс надо импортировать наведя на текст мышку, как на рисунке, или ручками вставив в начале нашего файла соответствующую строку.
import android.content.Context;
Теперь полный порядок. Идем дальше. Наполним функцию начала сканирования следующим содержанием (смотри ниже). Мы видим, что до начала сканирования, надо убедиться, что bluetoothAdapter включен и спросить разрешение у пользователя на определение местоположения (ACCESS_FINE_LOCATION). Подробнее об этом читайте в этой статье. Если в двух словах, то после 6-й версии андроида разрешения на доступ к ресурсам устройства разделили на обычные и опасные. Последние надо запрашивать у пользователя в процессе работы. Если этого не сделать, наш проект не запустится. Кроме того мы создаем скан фильтр, который однако мы не будем использовать, что бы принимать все устройства вокруг. Самой последней командой запускаем сканирование.
public void startScanning() {
if (!bluetoothAdapter.isEnabled()) {
promptEnableBluetooth();
}
// We only need location permission when we start scanning
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
requestLocationPermission();
} else {
deviceList.clear();
listAdapter.clear();
stopScanningButton.setEnabled(true);
startScanningButton.setEnabled(false);
textViewTemp.setText("Поиск устройства");
List<ScanFilter> filters = new ArrayList<>();
ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder();
filters.add(scanFilterBuilder.build());
ScanSettings.Builder settingsBuilder = new ScanSettings.Builder();
settingsBuilder.setLegacy(false);
AsyncTask.execute(() -> bluetoothLeScanner.startScan(leScanCallBack));
}
}
В проекте всё это, снова выглядит как светофор. Ну что ж, будем всё исправлять. Странно, что константу ACCESS_FINE_LOCATION среда не понимает. Что бы это исправит, в заголовке надо импортировать файл Манифеста. Тогда эта константа станет черной. Но это только начало :-) Проще сразу импортировать пять новых классов.
import android.Manifest;
import java.util.List;
import android.bluetooth.le.ScanFilter;
import android.os.AsyncTask;
import android.bluetooth.le.ScanSettings;
А так же написать пять новых функций :-) Я предупреждал, что будет нелегко :-) Все они, кроме последней, относятся к различным разрешениям. Я не буду их комментировать, познакомьтесь с ними сами. Последняя функция будет отображать результаты сканирования. Функция listShow(result, true, true) в ней пока закомментирована.
private boolean hasPermission(String permissionType) {
return ContextCompat.checkSelfPermission(this, permissionType) == PackageManager.PERMISSION_GRANTED;
}
private void promptEnableBluetooth() {
if (!bluetoothAdapter.isEnabled()) {
Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
activityResultLauncher.launch(enableIntent);
}
}
ActivityResultLauncher<Intent> activityResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() != MainActivity.RESULT_OK) {
promptEnableBluetooth();
}
}
);
private void requestLocationPermission() {
if (hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
return;
}
runOnUiThread(() -> {
AlertDialog alertDialog = new AlertDialog.Builder(this).create();
alertDialog.setTitle("Location Permission Required");
alertDialog.setMessage("This app needs location access to detect peripherals.");
alertDialog.setButton(DialogInterface.BUTTON_POSITIVE, "OK", (dialog, which) -> ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, LOCATION_PERMISSION_REQUEST_CODE));
alertDialog.show();
});
}
//************************************************************************************
// S C A N C A L L B A C K
//************************************************************************************
// The BluetoothLEScanner requires a callback function, which would be called for every device found.
private final ScanCallback leScanCallBack = new ScanCallback() {
@SuppressLint("MissingPermission")
@Override
public void onScanResult(int callbackType, ScanResult result) {
if (result.getDevice() != null) {
synchronized (result.getDevice()) {
//listShow(result, true, true);
}
}
}
@Override
public void onScanFailed(int errorCode) {
super.onScanFailed(errorCode);
Log.e(TAG, "onScanFailed: code:" + errorCode);
}
};
}
В тексте это выглядит так. Один красный текст ушел, зато появилось много нового :-) Крепитесь, делать нечего, будем и дальше с ним бороться.
Что бы разом закрасить весь красный текст, придется импортировать сразу 13 классов и одну константу private static final int LOCATION_PERMISSION_REQUEST_CODE = 2;
import androidx.core.content.ContextCompat;
import android.content.Intent;
import android.content.pm.PackageManager;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import android.app.AlertDialog;
import android.content.DialogInterface;
import androidx.core.app.ActivityCompat;
import android.bluetooth.le.ScanCallback;
import android.annotation.SuppressLint;
import android.bluetooth.le.ScanResult;
import android.util.Log;
import static android.content.ContentValues.TAG;
Теперь заголовок нашего файла выглядит так.
Заключительная часть
Давайте немного выдохнем. Мы ещё не сделали всего, но сделали самую тяжелую часть. У нас было много связанных функций с большим количеством новых для нас классов. Если вы всё сделали правильно, то у вас в проекте должна остаться всего одна ошибка в функции initializeBluetooth().
Если перевести текст ошибки, который выдает среда, он выглядит так: «Для вызова требуется разрешение, которое может быть отклонено пользователем: код должен явно проверять наличие разрешения (с помощью `checkPermission`) или явно обрабатывать потенциальное „SecurityException`“. Если подвести мышку к ошибке, то выскакивает такой вот транспарант.
Как я уже писал, это связано с выдачей разрешений на опасные функции. В данном случае на сканирование эфира. Мы пока не будем соглашаться на добавление проверки. Это связано с тем, что данная ошибка не помешает нам откомпилировать приложение, но проверка разрешения помешает нам его запустить. Не знаю почему, но она не видит разрешений, размещенных в Манифесте. Отложим этот вопрос на некоторое время. Я разберусь в нем и расскажу как правильно его обойти в третьей части публикации. Запускаем приложение. У нас сначала спросят два разрешения, но потом, при нажатии кнопки Start, наше приложение вывалится с ошибкой. Опять двадцать пять :-)
Анализ кода показал, что дело в команде deviceList.clear(); из функции startScanning(). Теперь всё ясно, хотя компонент deviceList и был указан в заголовке, но не был инициализирован. Это довольно частая ошибка для Си-шных программистов, вроде меня :-) Чтобы её убрать, добавим в раздел инициализации следующую строчку.
deviceList = new ArrayList<>();
Теперь окончание раздела инициализации выглядит так.
Теперь пробуем откомпилировать и запустить наше приложение. Ура, получилось. Теперь если нажать на кнопку Start, у нас поменяется статус устройства. Ну большего пока и не надо. Обработку сканирования мы ещё не написали :-)
Теперь допишем ещё три функции. Первая - это остановка сканирования. У нас есть заготовка этой функции. Наполним её содержанием.
public void stopScanning() {
stopScanningButton.setEnabled(false);
startScanningButton.setEnabled(true);
textViewTemp.setText("Поиск остановлен");
AsyncTask.execute(() -> bluetoothLeScanner.stopScan(leScanCallBack));
}
Как мы видим, функция остановки сканирования так же требует разрешения. Мы его так же проигнорируем (причины смотри выше). Далее в функции onScanResult мы разблокируем вызов результатов сканирования listShow(result, true, true), а чуть ниже по тексту добавим её обработку.
@SuppressLint("MissingPermission")
// Called by ScanCallBack function to check if the device is already present in listAdapter or not.
private boolean listShow(ScanResult res, boolean found_dev, boolean connect_dev) {
device = res.getDevice();
String itemDetails;
int i;
for (i = 0; i < deviceList.size(); ++i) {
String addedDeviceDetail = deviceList.get(i).getAddress();
if (addedDeviceDetail.equals(device.getAddress())) {
itemDetails = device.getAddress() + " " + rssiStrengthPic(res.getRssi()) + " " + res.getRssi();
itemDetails += res.getDevice().getName() == null ? "" : "\n " + res.getDevice().getName();
Log.d(TAG, "Index:" + i + "/" + deviceList.size() + " " + itemDetails);
listAdapter.remove(listAdapter.getItem(i));
listAdapter.insert(itemDetails, i);
return true;
}
}
itemDetails = device.getAddress() + " " + rssiStrengthPic(res.getRssi()) + " " + res.getRssi();
itemDetails += res.getDevice().getName() == null ? "" : "\n " + res.getDevice().getName();
Log.e(TAG, "NEW:" + i + " " + itemDetails);
listAdapter.add(itemDetails);
deviceList.add(device);
return false;
}
//************************************************************************************
private String rssiStrengthPic(int rs) {
if (rs > -45) {
return "▁▃▅▇";
}
if (rs > -62) {
return "▁▃▅";
}
if (rs > -80) {
return "▁▃";
}
if (rs > -95) {
return "▁";
} else
return "";
}
В коде у вас это должно выглядеть так. Красным кружком я обозначил указатель на ошибки. Последняя функция нужна для красивой визуализации уровня принимаемых сигналов.
Запускаем наше приложение. Ура!!! Революция, о которой так долго твердили большевики — свершилась (Ленин). Наше приложение запустилось и после нажатия кнопки Start мы принимаем и выводим на экран информацию о BLE устройствах с их уровнем сигнала RSSI.
Послесловие
Мы наконец то подошли к концу нашего пути. Путем долгих усилий, научились сканировать эфир и видеть окружающие нас BLE устройства. Что дальше? В третьей части публикации мы будем дорабатывать наш проект и присоединяться к выбранным устройствам, а так же считывать и записывать данные. Я буду делать это с антипотеряйкой, которую уже упоминал в одной из своих публикаций. Её можно купить во многих местах, например здесь, здесь или здесь. В самом конце хочу выразить огромную благодарность одному хорошему человеку с ником doafirst за его проект le_scan_classic_connect с сайта github.com. Собственно говоря, опираясь именно на него я и написал данную статью. Я ещё новичок в андроиде, поэтому многое не знаю. Если есть что добавить со содержанию статьи — пишите в комментариях. Вместе мы сможем помочь многим железячникам войти в мир андроида и сделать их разработки более конкурентоспособными. До встречи в третьей части.
Печерских Владимир
Сотрудник Группы Компаний «Цезарь Сателлит»