El tema de Bluetooth és ample i complex. En aquest article abordarem el codi necessari per:
Altres temes pendents d'abordar aquí:
La pràctica que s'inclou aquí està pensada per a rebre dades d'un dispositiu ESP32. Podeu trobar aquí la pràctica de captura amb càmera i transmissió de dades per BLE.
Obtenir aquesta llista és senzill accedint BluetoothAdapter.bondedDevices. En aquest exemple, a més, filtrem els dispositius que poden ser del tipus BLE (Bluetooth Low Emission), però es pot treure el filtre si cal:
@SuppressLint("MissingPermission") fun updatePairedDevices() { // empty list dataset.clear() // update list val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager val bluetoothAdapter = bluetoothManager.adapter for( elem in bluetoothAdapter.bondedDevices.filter { device -> // Filtrar per dispositius BLE device.type == BluetoothDevice.DEVICE_TYPE_LE || device.type == BluetoothDevice.DEVICE_TYPE_DUAL || device.type == BluetoothDevice.DEVICE_TYPE_UNKNOWN } ) { // afegim element al dataset dataset.add( elem ) } }
La gestió de permisos del Bluetooth és una part més engorrosa.
Primer de tot, declarar els permisos estàtics a AndroidManifest.xml:
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- Per a Android 12 o superior --> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
Seguidament, necessitarem al nostre codi demanar els permisos dinàmics. Abans de cridar a la funció updatePairedDevices() ja vista abans, haurem de fer una funció «wrapper» que abans s'asseguri que els permisos estan ben gestionats.
private val REQUEST_CODE_BLUETOOTH = 100 // es pot posar un nombre aleatori no emprat en cap altre lloc private fun requestBluetoothPermissionAndUpdate() { val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ requereix BLUETOOTH_CONNECT Manifest.permission.BLUETOOTH_CONNECT } else { // Versions anteriors Manifest.permission.BLUETOOTH } if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { // Demanar el permís ActivityCompat.requestPermissions( this, arrayOf(permission), REQUEST_CODE_BLUETOOTH ) } else { // Permís ja concedit - llegir dispositius updatePairedDevices() } } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<out String>, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == REQUEST_CODE_BLUETOOTH) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { // Permís concedit - llegir dispositius updatePairedDevices() } else { // Permís denegat Toast.makeText(this, "Permís necessari per a llegir Bluetooth", Toast.LENGTH_SHORT).show() } } }
Per la comunicació de dades (sèrie) es sol utilitzar el Bluetooth GATT o Generic Attribute Profile. GATT és un protocol de serveis i característiques que defineix com s'intercanvien dades entre dispositius Bluetooth Low Energy (BLE). És la base de tota comunicació en dispositius BLE com ara wearables, sensors mèdics, beacons, etc. GATT és essencial per a l'ecosistema IoT i dispositius wearables, permetent una comunicació estructurada i eficient entre dispositius.
Conceptes claus:
Us presento un codi per recepció de dades BLE encapsulat dins un Dialog, per tal d'efectuar la recepció de les dades i tancar la comunicació, i tot dins d'un arxiu separat del codi de l'Activity o Fragment.
El BLEconnDialog no gestiona els permisos, assumim que ja s'ha fet abans en el codi per accedir al llistat de dispositius emparellats o fent un scan.
El layout conté diversos TextView per a l'estat de la comunicació, una ProgressBar pel progrés de la transmissió i una ImageView per mostrar la imatge rebuda (que també s'enregistrarà al sistema d'arxius del mòbil).
<?xml version="1.0" encoding="utf-8"?> <LinearLayout 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="wrap_content" android:orientation="vertical" android:padding="24dp"> <TextView android:id="@+id/tvDeviceName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="18sp" android:textStyle="bold" android:gravity="center" android:layout_marginBottom="16dp"/> <TextView android:id="@+id/tvDeviceAddress" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="14sp" android:gravity="center" android:layout_marginBottom="24dp"/> <ProgressBar android:id="@+id/progressBar" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:indeterminate="true" android:layout_marginBottom="16dp"/> <TextView android:id="@+id/tvStatus" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="14sp" android:gravity="center" android:layout_marginBottom="24dp"/> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="end" android:orientation="horizontal"> <Button android:id="@+id/btnCancel" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:text="@android:string/cancel" /> <Button android:id="@+id/btnConnect" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Connectar" /> </LinearLayout> <ImageView android:id="@+id/imageView" android:layout_width="match_parent" android:layout_height="wrap_content" tools:srcCompat="@tools:sample/avatars" /> </LinearLayout>
class BLEconnDialog( context: Context, private val device: BluetoothDevice, private val connectionCallback: BLEConnectionCallback ) : Dialog(context) { interface BLEConnectionCallback { fun onConnectionSuccess(gatt: BluetoothGatt) fun onConnectionFailed(error: String) fun onConnectionCancelled() fun onReceivedImage(file: File) } // Views private lateinit var tvDeviceName: TextView private lateinit var tvDeviceAddress: TextView private lateinit var tvStatus: TextView private lateinit var tvImage: ImageView private lateinit var progressBar: ProgressBar private lateinit var btnConnect: Button private lateinit var btnCancel: Button // BLE // UUIDs private val SERVICE_UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") private val CHARACTERISTIC_UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361b26a8") private var bluetoothGatt: BluetoothGatt? = null private val handler = Handler(Looper.getMainLooper()) private var isConnecting = false private val CONNECTION_TIMEOUT = 10000L // 10 segons private val SCAN_PERIOD: Long = 10000 private val RECEIVE_TIMEOUT: Long = 30000 // 30 segons timeout // Variables per foto private val receivedData = ByteArrayOutputStream() lateinit private var receivedFile : File private var totalSize = 0 private var isReceiving = false private var received = false private var lastPacketTime = 0L private var packetCount = 0 // Callback de timeout @SuppressLint("MissingPermission") private val connectionTimeoutRunnable = Runnable { if (isConnecting) { disconnect() connectionCallback.onConnectionFailed("Timeout de connexió") dismiss() } } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.dialog_ble_conn) // Inicialitzar views tvDeviceName = findViewById(R.id.tvDeviceName) tvDeviceAddress = findViewById(R.id.tvDeviceAddress) tvStatus = findViewById(R.id.tvStatus) tvImage = findViewById(R.id.imageView) progressBar = findViewById(R.id.progressBar) btnConnect = findViewById(R.id.btnConnect) btnCancel = findViewById(R.id.btnCancel) // Configurar dades del dispositiu val deviceName = device.name ?: "Dispositiu desconegut" tvDeviceName.text = deviceName tvDeviceAddress.text = device.address // Configurar botons btnConnect.setOnClickListener { if( received ) { connectionCallback.onReceivedImage(receivedFile) disconnect() dismiss() } if (!isConnecting) { connectToDevice() } } btnCancel.setOnClickListener { cancelConnection() } // Connectar automàticament al mostrar el dialog connectToDevice() } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun connectToDevice() { if (isConnecting) return isConnecting = true updateUIForConnecting() // Iniciar timeout handler.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT) // Connectar al dispositiu BLE // Nota: Necessites tenir el context de l'activitat o un context vàlid val context = context.applicationContext ?: context bluetoothGatt = device.connectGatt(context, false, gattCallback) // En alguns dispositius, cal fer un connect explícit // bluetoothGatt?.connect() } private fun updateUIForConnecting() { tvStatus.text = "Connectant..." btnConnect.isEnabled = false btnConnect.text = "Connectant..." progressBar.isIndeterminate = true } private fun updateUIForConnected() { tvStatus.text = "Connectat" btnConnect.isEnabled = false btnConnect.text = "Connectat" progressBar.isIndeterminate = false progressBar.progress = 100 } private fun updateUIForDisconnected() { tvStatus.text = "Desconnectat" btnConnect.isEnabled = true btnConnect.text = "Connectar" progressBar.isIndeterminate = false progressBar.progress = 0 } private fun updateUIForReceived() { //tvStatus.text = "Desconnectat" btnConnect.isEnabled = true btnConnect.text = "Fet!" //progressBar.isIndeterminate = false //progressBar.progress = 0 } @SuppressLint("MissingPermission") private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { handler.post { when (newState) { BluetoothProfile.STATE_CONNECTED -> { // Cancel·lar timeout handler.removeCallbacks(connectionTimeoutRunnable) isConnecting = false // Actualitzar UI updateUIForConnected() // Sol·licitar MTU més gran gatt.requestMtu(517) // Descobrir serveis //gatt.discoverServices() // Descobrir serveis després d'un breu retard handler.postDelayed({ gatt.discoverServices() }, 500) } BluetoothProfile.STATE_DISCONNECTED -> { handler.removeCallbacks(connectionTimeoutRunnable) isConnecting = false if (status == BluetoothGatt.GATT_SUCCESS) { updateUIForDisconnected() } else { // Error de connexió val errorMsg = when (status) { 0x08 -> "Timeout" 0x13 -> "Terminat per host local" 0x16 -> "Terminat per host remot" 0x3E -> "No connectat" else -> "Error desconegut: $status" } tvStatus.text = "Error: $errorMsg" connectionCallback.onConnectionFailed(errorMsg) } } } } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { super.onMtuChanged(gatt, mtu, status) handler.post { Log.d("BLE", "MTU canviat a: $mtu") tvStatus.text = "MTU: $mtu" } } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { super.onServicesDiscovered(gatt, status) if (status == BluetoothGatt.GATT_SUCCESS) { handler.post { //binding.statusText.text = "Serveis descoberts" } val service = gatt.getService(SERVICE_UUID) service?.let { val photoCharacteristic = it.getCharacteristic(CHARACTERISTIC_UUID) photoCharacteristic?.let { characteristic -> // Habilitar notificacions gatt.setCharacteristicNotification(characteristic, true) val descriptor = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") ) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) /*handler.post { binding.statusText.text = "Llest per rebre fotos!" binding.photoStatus.text = "Esperant foto..." Toast.makeText(this@MainActivity, "Connectat! Prem BOOT a l'ESP32", Toast.LENGTH_SHORT).show() }*/ } ?: run { handler.post { Log.d("BT","Error: Característica no trobada") //binding.statusText.text = "Error: Característica no trobada" } } } ?: run { handler.post { Log.d("BT","Error: Servei no trobat") //binding.statusText.text = "Error: Servei no trobat" } } } else { handler.post { Log.d("BT","Error descobrint serveis: $status") //binding.statusText.text = "Error descobrint serveis: $status" } } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { super.onCharacteristicChanged(gatt, characteristic) if (characteristic.uuid == CHARACTERISTIC_UUID) { handleIncomingData(characteristic.value) } } override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { super.onCharacteristicRead(gatt, characteristic, status) Log.d("BLE", "Característica llegida: ${characteristic.uuid}") } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { super.onCharacteristicWrite(gatt, characteristic, status) Log.d("BLE", "Característica escrita: $status") } } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun disconnect() { bluetoothGatt?.disconnect() bluetoothGatt?.close() bluetoothGatt = null isConnecting = false } @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun cancelConnection() { handler.removeCallbacks(connectionTimeoutRunnable) disconnect() connectionCallback.onConnectionCancelled() dismiss() } override fun dismiss() { handler.removeCallbacks(connectionTimeoutRunnable) super.dismiss() } override fun onDetachedFromWindow() { handler.removeCallbacks(connectionTimeoutRunnable) super.onDetachedFromWindow() } companion object { const val TAG = "BLEconnDialog" } // BLE data receive /////////////////////// private fun handleIncomingData(data: ByteArray) { handler.post { packetCount++ lastPacketTime = System.currentTimeMillis() Log.d("BLE", "Paquet $packetCount rebut: ${data.size} bytes") Log.d("BLE", "Primers bytes: ${data.take(4).joinToString("") { "%02X".format(it) }}") // Verificar si és paquet de finalització if (data.size == 4 && data.contentEquals(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte() ))) { if (isReceiving && receivedData.size() > 0) { completePhotoTransfer() } else { tvStatus.text = "Finalització rebuda sense dades" } return@post } // Primer paquet (pot ser la mida) if (!isReceiving && data.size == 4) { // Intentar interpretar com a mida de 32 bits try { totalSize = (data[0].toInt() and 0xFF) + ((data[1].toInt() and 0xFF) shl 8) + ((data[2].toInt() and 0xFF) shl 16) + ((data[3].toInt() and 0xFF) shl 24) isReceiving = true receivedData.reset() tvStatus.text = "Rebent foto ($totalSize bytes)..." progressBar.max = totalSize progressBar.progress = 0 Log.d("BLE", "Mida anunciada: $totalSize bytes") // Iniciar timeout startReceiveTimeout() } catch (e: Exception) { Log.e("BLE", "Error interpretant mida: ${e.message}") } return@post } // Si estem rebent, afegir dades if (isReceiving) { receivedData.write(data) val currentSize = receivedData.size() progressBar.progress = currentSize // Actualitzar estat cada certs paquets if (packetCount % 10 == 0 || currentSize == totalSize) { val percent = if (totalSize > 0) (currentSize * 100) / totalSize else 0 tvStatus.text = "Rebent: $currentSize/$totalSize bytes ($percent%)" Log.d("BLE", "Progrés: $currentSize/$totalSize ($percent%)") } // Reiniciar timeout amb cada paquet resetReceiveTimeout() // Si hem arribat a la mida esperada, completar if (totalSize > 0 && currentSize >= totalSize) { completePhotoTransfer() } } } } private fun startReceiveTimeout() { handler.removeCallbacks(receiveTimeoutRunnable) handler.postDelayed(receiveTimeoutRunnable, RECEIVE_TIMEOUT) } private fun resetReceiveTimeout() { handler.removeCallbacks(receiveTimeoutRunnable) handler.postDelayed(receiveTimeoutRunnable, RECEIVE_TIMEOUT) } private val receiveTimeoutRunnable = Runnable { handler.post { if (isReceiving) { tvStatus.text = "⏰ Timeout! Transferència incompleta" Log.e("BLE", "Timeout en recepció de foto") resetPhotoTransfer() } } } private fun completePhotoTransfer() { val finalSize = receivedData.size() handler.post { tvStatus.text = "✅ Foto rebuda: $finalSize bytes" progressBar.progress = finalSize val dataStr = receivedData.toString().trim() Log.v("FOTO",dataStr) // Guardar foto try { val decodedData = Base64.decode(dataStr,Base64.DEFAULT) savePhoto(decodedData) // Notificació //Toast.makeText(this, "Foto rebuda: $finalSize bytes", // Toast.LENGTH_LONG).show() Log.v("BT","Foto rebuda: $finalSize bytes") } catch (e : Exception) { Log.v("ERROR","Error en descodificació base64") e.printStackTrace() } // Reset resetPhotoTransfer() } } private fun resetPhotoTransfer() { isReceiving = false totalSize = 0 receivedData.reset() packetCount = 0 handler.removeCallbacks(receiveTimeoutRunnable) } private fun savePhoto(imageData: ByteArray) { try { val timestamp = System.currentTimeMillis() val filename = "ESP32_${timestamp}.jpg" // Guardar al directori de Pictures val picturesDir = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_PICTURES) val fri3dDir = File(picturesDir, "ESP32_Camera") if (!fri3dDir.exists()) { fri3dDir.mkdirs() } receivedFile = File(fri3dDir, filename) FileOutputStream(receivedFile).use { fos -> fos.write(imageData) fos.flush() } Log.d("Photo", "Foto guardada: ${receivedFile.absolutePath}") // preview image a l'app tvImage.setImageURI(receivedFile.toUri()) // Notificar galeria val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) mediaScanIntent.data = android.net.Uri.fromFile(receivedFile) context.sendBroadcast(mediaScanIntent) tvStatus.text = "💾 Guardat: $filename" received = true updateUIForReceived() //dismiss() } catch (e: Exception) { Log.e("Photo", "Error guardant foto: ${e.message}") tvStatus.text = "❌ Error guardant foto" } } }
Per utilitzar el BLEconnDialog afegirem la seva interfície a l'Activity:
class MainActivity : AppCompatActivity(), BLEconnDialog.BLEConnectionCallback { // El codi de MainActivity aquí... }
I hi implementarem les callback necessàries.
Quan volguem mostrar el BLEconnDialog cridarem showBLEDialog(device) amb el BluetoothDevice que haguem sel·leccionat prèviament (fent un scan o bé, en aquest cas, triant el device d'una llista de dispositius emparellats).
// DIALOG : cridar aquesta funció per mostrar-lo //////////////////////////////////////////////// private fun showBLEDialog(device: BluetoothDevice) { bleDialog = BLEconnDialog(this, device, this) bleDialog?.apply { setCancelable(false) setOnCancelListener { onConnectionCancelled() } show() } } // DIALOG CALLBACKS /////////////////////////////// override fun onConnectionSuccess(gatt: BluetoothGatt) { runOnUiThread { Toast.makeText(this, "Connectat amb èxit!", Toast.LENGTH_SHORT).show() // Aquí pots fer operacions amb el gatt connectat // Per exemple: llegir/escribre característiques } } override fun onConnectionFailed(error: String) { runOnUiThread { Toast.makeText(this, "Error de connexió: $error", Toast.LENGTH_LONG).show() } } override fun onConnectionCancelled() { runOnUiThread { Toast.makeText(this, "Connexió cancel·lada", Toast.LENGTH_SHORT).show() } } override fun onReceivedImage(file: File) { runOnUiThread { val filename = file.name Toast.makeText(this, "Imatge rebuda: $filename", Toast.LENGTH_SHORT).show() } } // Aquesta callback és de l'Activity override fun onDestroy() { super.onDestroy() bleDialog?.dismiss() }
La pràctica que s'inclou aquí està pensada per a rebre dades d'un dispositiu ESP32. Podeu trobar aquí la pràctica de captura amb càmera i transmissió de dades per BLE.