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

Перед тем, как продолжить наши опыты с 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);
        });

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

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

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

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

Функция 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<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;

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

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

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

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

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

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

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

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

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

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

deviceList = new ArrayList<>();

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

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

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

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

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

Комментарии (13)


  1. Indemsys
    11.07.2024 09:26
    +2

    А не рассматривали для создания своего приложения просто рефакторинг завершенных приложений типа airoc-connect-android? Там есть вся инфраструктура. И сканирование, и чтение базы GATT, и чтение и запись аттрибутов и пайринг и бондинг. И меню вспомогательные и панели с настройкой параметров.
    A ChatGPT отлично разбирается во всех нюансах и IDE и языка и библиотек. Весь поток разработки подробно опишет.

    Прикольно, что в Android Studio встроен ИИ под название Gemini и он проанализировав airoc-connect-android сказал, что это приложение заказа еды из ресторана. Так что на Gemini я бы надежд не возлагал в изучении проектов.


    1. pecherskih Автор
      11.07.2024 09:26

      На GitHub-е есть много разных готовых приложений BLE под андроид. Но если вы новичок в андроиде начинать надо с самого простого. Я перебрал три десятка проектов с GitHub-а, а запустить из них на моей машине получилось только два. Почему - это вопрос различных совместимостей, коих в андроиде миллион. Для новичков надо что попроще. Именно поэтому я и рассказал как создать проект с нуля. Когда появится опыт, тогда уже можно переходить к более сложным вещам, если нужно. Мне, к примеру, не нужно. Достаточно таблицы и двух-трех кнопок, т.к. основная моя работа - это разработка проектов на микроконтроллерах. Для их поддержки я и решил разобраться с Андроид студией.


      1. Indemsys
        11.07.2024 09:26
        +1

        Это конечно интересно, когда новичок учит новичков с нуля.
        Но тут можно попасть в ловушку плохой архитектуры, которая не даст потом уйти дальше двух кнопочек.
        Посмотрите как сделано в  airoc-connect-android. Там BluetoothManager создается в отдельном сервисе. Списки в отдельных фрагментах. Сканирование в отдельных потоках.
        Из-за плохой архитектуры вам придется все время рефакторить свой код на выском уровне организации лайаутов и классов.
        Новички за такое спасибо не скажут.
        Полезнее было бы выложить готовое приложение и уже его объяснять.


        1. pecherskih Автор
          11.07.2024 09:26

          Увы, это пока не мой уровень. Что смог освоить, тем и поделился. Может тогда и вы включитесь у эту работу? Расскажите как правильно все написать. Я с удовольствием поучусь. С потоками согласен, это часто приводит к тормозам всего приложения. Я это ещё не освоил. Ну так я и не программист GUI высокого уровня. Так что замечания в свой адрес я принимаю как вполне законные и адекватные. Но в ответ выставляю предложение о сотрудничестве. Напишите статью с более высоком уровнем управления BLE устройствами?


  1. NutsUnderline
    11.07.2024 09:26

    Антипортеряйки именно такие на китайском маркете продаются задешево по две штуки. Внутри в них похоже что Lenze ST17H66B2. Как раз думал ее взять для своей задачки - поэтому сразу вопрос есть ли у них уникальный идентификатор? А то мне их нужно 10к более менее одновременно.


    1. pecherskih Автор
      11.07.2024 09:26
      +1

      Ну так купи сначала парочку и посмотри что у них есть внутри. Насколько я увидел - уникальный у них только МАС адрес. Если этого достаточно, то покупай. Но китайцы они такие китайцы, ничему верить нельзя :-)


      1. NutsUnderline
        11.07.2024 09:26

        А не логичнее сперва спросить у компетентного человека у которого к тому же такая штука уже в наличии? Быстрее получиться.

        Впрочем заказчик уже не дал добро на эту идею (нужны кнопки посолиднее и провод) .


        1. pecherskih Автор
          11.07.2024 09:26
          +1

          А что значит провод? Чем то управлять надо? Других кнопок в массовом сегменте я особо и не знаю. Есть в другом корпусе, как шайба, но начинка та же.


          1. NutsUnderline
            11.07.2024 09:26

            Повод значит что подтянуть к каждой кнопке кабель, никаких блютусов. А более классические средства автоматизации. Нужно управлять некоторым процессом из нескольких точек, можно было бы дать каждому работнику свою, да под запись - кто и когда. Но работникам тонкую технику давать в руки черевато.


  1. leon_shtuet
    11.07.2024 09:26

    Вам бы еще грамматику подтянуть. Если что, это не упрек, а пожелание.

    Добрый сегодня я. ;) Граммар наzи


    1. pecherskih Автор
      11.07.2024 09:26

      Чет у тебя карма в минусе и публикаций нет, может не стоит слушать таких учителей?


      1. leon_shtuet
        11.07.2024 09:26

        карма в минусе и публикаций нет, может не стоит слушать таких учителей?

        Вот из-за таких грамотеев карма и в минусе. Я не учитель, только обратил Ваше внимание на Вашу безграмотность. Но слушать меня или нет, Ваше право и Ваш выбор. Я бы послушал, быть грамотным полезно. ;)

        Чукча не писатель, чукча читатель. Публикаций нет, потому-что каждый должен заниматься своим делом, в котором он спец. Вам за публикации спасибо, но плюсики Вам ставить не могу, из-за тех самых пид грамотеев.


    1. pecherskih Автор
      11.07.2024 09:26

      Пардон, у вас, раз мы на вы