BLE под микроскопом. Андроид. Часть2

Загрузка проектов

Перед тем, как продолжить наши опыты с BLE и андроидом, я хочу показать вам, как загружать подготовленные мной проекты с GitHub-а. Делается это так. На заглавной странице выбираем кнопку Get from VCS и в новом окошке вводим адрес созданного в прошлой части проекта: https://github.com/pecherskikh/MyApplication

Страница выбора проектов

Страница выбора проектов

Клонирование проекта

Клонирование проекта

Нажимаем кнопку Clone, остальное среда Android Studio сделает сама. У вас скопируется, откомпилируется и откроется проект, который мы делали в первой части публикации.

Подготовка нового проекта

Выберем на начальной странице создание нового проекта, так как мы делали это в первой части. Назовем наш проект BleCentralDevice, так как на рисунке и с теми же настройками. Нажмем кнопку Finish.

Создание нового проекта

Создание нового проекта

После того, как среда закончит подготовку проекта, выберите файл манифеста app→manifests→AndroidManifest.xml и вставьте следующие строки, так как на рисунке ниже. Этим действием мы даем разрешения на использование ресурсов андроида.

    
    
    
    
    
    

Файл манифеста с разрешениями

Файл манифеста с разрешениями

Разместим на форме две кнопки Button, TextView и ListView, так как на рисунке и привяжем каждый элемент к краям экрана. У вас не должно быть ошибок, мы занимались этим в прошлый раз.

Заполнение формы элементами

Заполнение формы элементами

Напишите на кнопках Start и Stop, а на TextView — Status. Присоедините к компьютеру смартфон и нажмите зеленый треугольник Ran (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);
        });

Здесь появляются новые элементы, которые наш проект пока не знает. Последняя строчка пока закомментирована, она пригодится нам впоследствии. Как обычно, что бы красный текст стал черным, надо проинициализировать новые элементы.

Дополняем возможности элемента ListView

Дополняем возможности элемента ListView

Делаем это как обычно, импортируя необходимые классы в наш проект.

    TextView textViewTemp;
    //--------------------
    ArrayAdapter listAdapter;
    BluetoothDevice device;
    ArrayList 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();
    }

Функция инициализации Bluetooth

Функция инициализации Bluetooth

Мы видим новые элементы, которые как обычно надо инициализировать и импортировать.

Инициализация и импорт новых классов

Инициализация и импорт новых классов

    BluetoothLeScanner bluetoothLeScanner;
    BluetoothAdapter   bluetoothAdapter;
    BluetoothManager   bluetoothManager;

Однако этого не достаточно. Элемент Context остался выделенным красным, его класс надо импортировать наведя на текст мышку, как на рисунке, или ручками вставив в начале нашего файла соответствующую строку.

Импортируем класс Context

Импортируем класс 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 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 среда не понимает. Что бы это исправит, в заголовке надо импортировать файл Манифеста. Тогда эта константа станет черной. Но это только начало :-) Проще сразу импортировать пять новых классов.

Функция startScanning

Функция startScanning

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 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;

Теперь заголовок нашего файла выглядит так.

Импортируем 13 новых классов и задаем константу

Импортируем 13 новых классов и задаем константу

Заключительная часть

Давайте немного выдохнем. Мы ещё не сделали всего, но сделали самую тяжелую часть. У нас было много связанных функций с большим количеством новых для нас классов. Если вы всё сделали правильно, то у вас в проекте должна остаться всего одна ошибка в функции initializeBluetooth ().

Ошибка связанная с разрешениями

Ошибка связанная с разрешениями

Если перевести текст ошибки, который выдает среда, он выглядит так: «Для вызова требуется разрешение, которое может быть отклонено пользователем: код должен явно проверять наличие разрешения (с помощью `checkPermission`) или явно обрабатывать потенциальное «SecurityException`». Если подвести мышку к ошибке, то выскакивает такой вот транспарант.

Проверка разрешения

Проверка разрешения

Как я уже писал, это связано с выдачей разрешений на опасные функции. В данном случае на сканирование эфира. Мы пока не будем соглашаться на добавление проверки. Это связано с тем, что данная ошибка не помешает нам откомпилировать приложение, но проверка разрешения помешает нам его запустить. Не знаю почему, но она не видит разрешений, размещенных в Манифесте. Отложим этот вопрос на некоторое время. Я разберусь в нем и расскажу как правильно его обойти в третьей части публикации. Запускаем приложение. У нас сначала спросят два разрешения, но потом, при нажатии кнопки Start, наше приложение вывалится с ошибкой. Опять двадцать пять :-)

Запуск программы с разрешениями

Запуск программы с разрешениями

Анализ кода показал, что дело в команде deviceList.clear (); из функции startScanning (). Теперь всё ясно, хотя компонент deviceList и был указан в заголовке, но не был инициализирован. Это довольно частая ошибка для Си-шных программистов, вроде меня :-) Чтобы её убрать, добавим в раздел инициализации следующую строчку.

deviceList = new ArrayList<>();

Теперь окончание раздела инициализации выглядит так.

Добавление инициализации deviceList-a

Добавление инициализации deviceList-a

Теперь пробуем откомпилировать и запустить наше приложение. Ура, получилось. Теперь если нажать на кнопку Start, у нас поменяется статус устройства. Ну большего пока и не надо. Обработку сканирования мы ещё не написали :-)

823ede66b74d6de6fffcd0121ae62c5a.jpg

Теперь допишем ещё три функции. Первая — это остановка сканирования. У нас есть заготовка этой функции. Наполним её содержанием.

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. Собственно говоря, опираясь именно на него я и написал данную статью. Я ещё новичок в андроиде, поэтому многое не знаю. Если есть что добавить со содержанию статьи — пишите в комментариях. Вместе мы сможем помочь многим железячникам войти в мир андроида и сделать их разработки более конкурентоспособными. До встречи в третьей части.

Печерских Владимир

Сотрудник Группы Компаний «Цезарь Сателлит»

© Habrahabr.ru