Saltar la navegación

4.3. Búsqueda de dispositivos

Para que dos dispositivos puedan conectarse, uno de ellos tiene que establecerse en modo de visible y conectable; el otro estará visible y en búsqueda hasta que encuentre al primero. Entonces se llevará a cabo el emparejamiento de los dos dispositivos.

El emparejamiento puede ser directo o requerir una contraseña. Una vez emparejados, los dispositivos pueden establecer una conexión y comenzar el intercambio de datos de uno a otro.

Veamos cómo programar una aplicación que busque los dispositivos Bluetooth que tenga a su alcance. Abrimos Android Studio y ejecutamos File > New > New Project... > Empty Activity > Finish. Tendremos la actividad MainActivity y su layout activity_main.

Primero vamos a añadir en el build.gradle del módulo de aplicación las siguientes librerías:

def coroutines_version = '1.3.7'
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// EventBus
implementation 'org.greenrobot:eventbus:3.2.0'
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.1.0'

Las primeras dos instrucciones implementation son para poder utilizar coroutines en nuestra app, de modo que podamos llamar a funciones de BT sin que el hilo principal se bloquee. Después incluimos EventBus para enviar mensajes desde una clase a otra. Por último, incluimos la librería de Android para utilizar las listas reciclables. El RecyclerView es un componente de interfaz gráfica que nos permite presentar una lista de elementos de forma eficiente.

Para manipular el dispositivo Bluetooth del terminal, nuestra app tendrá que registrar algunos registros en el manifest. Además, definiremos una clase App:



<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.bluetooth">
<uses-permission android:name="android.permission.BLUETOOTH"></uses-permission>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"></uses-permission>
<application android:name=".App" android:allowbackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundicon="@mipmap/ic_launcher_round" android:supportsrtl="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"></action>
<category android:name="android.intent.category.LAUNCHER"></category>
</intent-filter>
</activity>
</application>
</manifest>

Como la hemos definido en el manifest, tendremos que crear una clase Application para nuestra aplicación. Pulsemos con el botón derecho sobre el nombre del paquete de la app y elijamos File > New > Kotlin File/Class. El código es:

import android.app.Application
class App : Application() {
override fun onCreate() {
super.onCreate()
_instance = this
}
companion object {
private var _instance: App? = null
val instance: App
get() = _instance!!
}
}

Ahora podemos empezar a diseñar el layout. Añadiremos algunos botones y un par de RecyclerView que mostrarán listas de dispositivos Bluetooth:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btnEscanear"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginstart="16dp"
        android:layout_margintop="16dp"
        android:text="Escanear"
        app:layout_constraintstart_tostartof="parent"
        app:layout_constrainttop_totopof="parent"></Button>

    <Button
        android:id="@+id/btnVisible"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginend="16dp"
        android:text="VISIBLE"
        app:layout_constraintend_toendof="parent"
        app:layout_constrainttop_totopof="@+id/btnEscanear">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/lstEscaner"
            android:layout_width="0dp"
            android:layout_height="200dp"
            android:layout_marginend="16dp"
            android:layout_marginstart="16dp"
            android:layout_margintop="16dp"
            app:layout_constraintend_toendof="parent"
            app:layout_constraintstart_tostartof="parent"
            app:layout_constrainttop_tobottomof="@+id/btnEscanear">

            <ProgressBar
                android:id="@+id/progressBarEscaneo"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintbottom_tobottomof="@+id/lstEscaner"
                app:layout_constraintend_toendof="@+id/lstEscaner"
                app:layout_constraintstart_tostartof="@+id/lstEscaner"
                app:layout_constrainttop_totopof="@+id/lstEscaner"></ProgressBar>
        </androidx.recyclerview.widget.RecyclerView>
    </Button>

    <Button
        android:id="@+id/btnPareados"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margintop="32dp"
        android:text="Pareados"
        app:layout_constraintend_toendof="parent"
        app:layout_constraintstart_tostartof="parent"
        app:layout_constrainttop_tobottomof="@+id/lstEscaner"
        >

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/lstPareados"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginend="16dp"
            android:layout_marginstart="16dp"
            android:layout_margintop="16dp"
            app:layout_constraintbottom_tobottomof="parent"
            app:layout_constraintend_toendof="parent"
            app:layout_constraintstart_tostartof="parent"
            app:layout_constrainttop_tobottomof="@+id/btnPareados"></androidx.recyclerview.widget.RecyclerView>
    </Button>
</androidx.constraintlayout.widget.ConstraintLayout>

El resultado sería algo como esto:

Layout conexión BT

Antes de ponernos con la actividad, vamos a crear un objeto que llevará a cabo todo el trabajo de activar y escanear dispositivos Bluetooth. Pulsamos con el botón derecho sobre el nombre del paquete de nuestra aplicación y escogemos New > Kotlin File/Class. Lo llamaremos MiBluetooth, y lo codificaremos así:

import android.app.Activity
import android.bluetooth.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.util.Log
import androidx.core.content.ContextCompat.startActivity
import org.greenrobot.eventbus.EventBus
object MiBluetooth {
/// EventBus event class
class EscaneoTerminado
private val appContext: Context? by lazy(LazyThreadSafetyMode.NONE) {
App.instance
}
private val _adapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
val bluetoothManager =
appContext!!.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
private val adapter: BluetoothAdapter
get() = _adapter!!
val escaneados = ArrayList()
val estaDesactivado: Boolean
get() = !adapter.isEnabled
fun activar() {
adapter.enable()
}
fun visibilizar(context: Context, segundos: Int) {
val discoverableIntent
= Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply {
putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, segundos)
}
startActivity(context, discoverableIntent, null)
}
private val escanearBroadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when(intent.action) {
BluetoothDevice.ACTION_FOUND, BluetoothDevice.ACTION_NAME_CHANGED -> {
val device: BluetoothDevice
= intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)!!
escaneados.add(device)
EventBus.getDefault().post(device)
}
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
EventBus.getDefault().post(EscaneoTerminado())
}
}
}
}
fun comenzarEscaner(context: Context) {
val intentFilter = IntentFilter()
intentFilter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
intentFilter.addAction(BluetoothDevice.ACTION_NAME_CHANGED)
context.registerReceiver(escanearBroadcastReceiver, intentFilter)
adapter.startDiscovery()
}
fun finalizarEscaner(context: Context) {
try {
context.unregisterReceiver(escanearBroadcastReceiver)
} catch (e: Exception) { }
}
fun listarPareados(): List {
return adapter.bondedDevices.toList()
}
}

Podemos observar que BluetoothAdapter es la clase más importante de este código, pues está presente en todas las llamadas a Bluetooth. Para obtener una instancia de la clase, llamaremos a:

adapter = appContext.getSystemService(Context.BLUETOOTH_SERVICE).adapter

No debemos preocuparnos por la manera tan sofisticada del código, hace más o menos lo mismo, simplemente utilizamos by lazy para no obtener el objeto hasta que no sea necesario. El resto de funciones utilizarán este objeto: por ejemplo, la función activar no hace más que adapter.enable.

Para escanear los dispositivos Bluetooth cercanos, el proceso es algo más complicado.

Primero debemos crear un BroadcastReceiver, que es un objeto que recibe llamadas del sistema o de otras clases. Es un mecanismo del sistema Android bastante parecido a EventBus, de hecho, podríamos utilizar BroadcastReceiver en lugar de EventBus, pero este último es más moderno, cómodo y eficiente. Una vez creado el BroadcastReceiver, lo programamos para que solo reciba ciertos mensajes mediante el IntentFilter. En nuestro caso, nos interesan los mensajes: ACTION_NAME_CHANGED, que recibiremos cuando se detecte un nuevo dispositivo, y ACTION_DISCOVERY_FINISHED, que nos llegará cuando el sistema decida que el escáner ha terminado. Una vez preparado nuestro receptor de mensajes, llamaremos a adapter.startDiscovery.

Cada vez que recibamos un mensaje ACTION_NAME_CHANGED, lo guardaremos en una lista y enviaremos un mensaje EventBus para que cualquier otra clase pueda enterarse del evento.

Veamos ahora cómo podemos utilizar esta clase en nuestra actividad:

import android.app.Activity
import android.bluetooth.BluetoothDevice
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.activity_main.*
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnVisible.setOnClickListener {
MiBluetooth.visibilizar(this, CINCO_MINUTOS)
}
btnEscanear.setOnClickListener {
btnEscanear.isEnabled = false
progressBarEscaneo.visibility = View.VISIBLE
MiBluetooth.comenzarEscaner(applicationContext)
}
btnPareados.setOnClickListener {
btnPareados.isEnabled = false
lstPareados.adapter = BluetoothListAdapter(MiBluetooth.listarPareados())
btnPareados.isEnabled = true
}
progressBarEscaneo.visibility = View.GONE
val layoutManager1 = LinearLayoutManager(this)
lstEscaner.layoutManager = layoutManager1
val layoutManager2 = LinearLayoutManager(this)
lstPareados.layoutManager = layoutManager2
}
override fun onResume() {
super.onResume()
MiBluetooth.activar()
EventBus.getDefault().register(this)
}
override fun onPause() {
super.onPause()
MiBluetooth.finalizarEscaner(this)
EventBus.getDefault().unregister(this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onBluetoothDevice(device: BluetoothDevice) 
lstEscaner.adapter = BluetoothListAdapter(MiBluetooth.escaneados)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onBluetoothEscaneoTerminado(escaneo: MiBluetooth.EscaneoTerminado) {
btnEscanear.isEnabled = true
progressBarEscaneo.visibility = View.GONE
lstEscaner.adapter =
BluetoothListAdapter(MiBluetooth.escaneados)
}
companion object {
private const val CINCO_MINUTOS = 300
}
}

En onCreate establecemos las acciones para los botones.

  • Para el botón Visible, llamamos a MiBluetooth.visibilizar, que pedirá al sistema Android que haga visible el dispositivo actual durante el tiempo especificado.
  • Para el botón Escanear, llamamos a la función MiBluetooth.comenzarEscaner. Para obtener los dispositivos ya pareados, llamamos a MiBluetooth.listarPareados. El resultado se utiliza como parámetro del objeto BluetoothListAdapter. Tras establecer las acciones para los botones, vemos cómo creamos un layout que estableceremos en sendos RecyclerView. Esto es así debido a la gran capacidad de adaptación que tiene este control. En nuestro caso, nos basta con el layout más sencillo, pues solo le pedimos a cada elemento de la lista que muestre un texto plano.
  • Después de onCreate, tenemos onResume y onPause. En estas funciones aprovechamos para registrarnos como observadores de mensajes EventBus, de modo que podamos escuchar las respuestas de MiBluetooth. Además, en onResume activamos el Bluetooth de nuestro dispositivo, por si no estuviese activo aún.
  • Ahora vemos dos funciones de suscripción a mensajes de EventBus. Sus nombres no importan realmente, ya que EventBus los distingue por los parámetros, de modo que el primero recibe un BluetoothDevice, que es el mensaje que obtendremos cuando el sistema nos comunique que ha encontrado un nuevo dispositivo BT, y el segundo mensaje lo recibiremos cuando el escáner haya terminado. Si hubiésemos metido todo el código de MiBluetooth en MainActivity, nos hubiésemos ahorrado los mensajes EventBus, pues nos llegarían mensajes directamente al BroadcastReceiver, pero de este modo demostramos que es posible liberar a la activity de cualquier tarea, incluso de las que están asociadas a su contexto.

Por último, solo nos queda entender cómo funciona el BluetoothListAdapter. Esta clase será la responsable de administrar los elementos de los RecyclerView. Veamos su código:

import android.bluetooth.BluetoothDevice
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class BluetoothListAdapter(
private val dataSet: List<BluetoothDevice>)
: RecyclerView.Adapter<BluetoothListAdapter.ViewHolder>() {
class ViewHolder(v: View) : RecyclerView.ViewHolder(v) {
val textView: TextView = v.findViewById(R.id.txt)
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
val v = LayoutInflater
.from(viewGroup.context)
.inflate(R.layout.bluetooth_item, viewGroup, false)
return ViewHolder(v)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
viewHolder.textView.text = dataSet[position].name
viewHolder.textView.setOnClickListener {
val device: BluetoothDevice = dataSet[position]
}
}
override fun getItemCount() = dataSet.size
}

Esta clase recibe una lista de objetos, en nuestro caso, una lista de BluetoothDevices. La eficiencia de los RecyclerView reside en que, para mostrar una lista de cientos de elementos, el componente solo tiene en memoria la representación gráfica de los objetos que pueden verse, y no más. Para ello, define la clase ViewHolder, que es la vista de un elemento. Cuando el usuario haga scroll sobre la lista, el RecyclerView irá reutilizando (reciclando) los ViewHolder con los datos de los nuevos elementos ahora visibles. Vemos cómo en onCreateViewHolder utilizamos otro layout para definir qué aspecto tendrán los elementos de la lista. En nuestro caso, el layout del elemento es:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/txt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
tools:text="Bluetooth Fake Name"
/>
</FrameLayout>

Simplemente, un campo de texto que recibirá el nombre del Bluetooth. Como vemos, las funciones relativas al Bluetooth son bastante sencillas gracias a las clases del SDK de Android.