Viaggio nei progetti Kotlin Multiplatform – Parte 2: BLE

Kotlin Multi Platform

Nel primo articolo della serie, abbiamo iniziato a scoprire quanto è potente Kotlin Multiplatform e come è applicato nel mondo delle applicazioni mobile. Abbiamo esplorato le basi, abbiamo visto i concetti diexpected e actual nsieme ad alcuni problemi che potrebbero verificarsi. Inoltre abbiamo preso, come esempio, una libreria per il discovery dei dispositivi Bluetooth Low Energy (BLE). In questo articolo vedremo di più.

Dopo l’implementazione del modulo comune di rilevamento dei dispositivi e delle due applicazioni di base (Android, iOS), ho deciso di migliorare il modulo Kotlin, consentendo alle implementazioni specifiche della piattaforma di eseguire operazioni semplici basate sul protocollo BLE. Volevo che la classe BluetoothAdapter fosse in grado di connettersi a un dispositivo BLE, scoprire i suoi servizi e per ogni servizio le sue caratteristiche.

L’obiettivo era implementare un’integrazione con Xiaomi Mi Band 2, un noto smart band e fitness tracker, che è stato in gran parte analizzato da alcuni utenti appassionati che hanno sviluppato app di terze parti su di esso (puoi trovare alcuni progetti interessanti su Github). Il Mi Band ha un solo pulsante hardware, che consente di visualizzare uno ad uno i dati di fitness acquisiti. La mia intenzione era solo quella di ascoltare gli eventi di tocco sui pulsanti e di inviare un messaggio all’utente nell’app ogni volta che il pulsante del Band fosse stato premuto.

Un’idea piuttosto semplice, ma in ogni caso impegnativa.

Un po’ di contesto su BLE

Prima di partire, chiariamo un po di più le cose sul protocollo BLE. In breve, la maggior parte dei dispositivi (comunemente chiamati periferiche) sono conformi al Generic Attribute profile (GATT), che è una specifica generica per l’invio di dati tra dispositivi tramite Bluetooth. Una periferica può essere un Client che invia comandi GATT (ad esempio uno smartphone), un Server che riceve tali comandi e restituisce risposte, o entrambi, in casi particolari. La comunicazione tra le due entità viene effettuata operando sulle caratteristiche del server. Su ciascuna di esse, è possibile eseguire molte operazioni, come leggere, scrivere e notificare. In particolare, l’operazione di notifica consente al client di ricevere aggiornamenti avviati dal server, idealmente come le notifiche push. Questo è stato il caso dei miei eventi di pressione dei pulsanti.Ciascuna periferica espone vari servizi, che sono gruppi di caratteristiche con un particolare scope. Degli UUID vengono utilizzati per identificare in modo univoco le entità menzionate.
Se vuoi saperne di più, qui puoi trovare riferimenti interessanti.

Iniziamo con il progetto Kotlin BLE

Per comunicare con una periferica, inizialmente occorre determinare che tipo di servizi e caratteristiche essa espone. Per questo, ho utilizzato un’applicazione ben nota chiamata LightBlue. È piuttosto semplice da usare e consente di esaminare tutte le informazioni sulle periferiche rilevate. Osservando anche i progetti Github di cui ho parlato prima (in particolare qui), ho scoperto il servizio e la caratteristica del Mi Band che fornisce notifiche quando si tocca il pulsante.

// BLE devices share a common UUID for identifying services and characteristics. Only 4 digits in the first UUID segment are different.
private const val SERVICE_BUTTON_PRESSED = "FEE0"
private const val CHAR_BUTTON_PRESSED = "0010"

Ho iniziato definendo un’interfaccia comune per connettersi a una periferica e interrogare i suoi servizi e le sue caratteristiche. Usando il paradigma expect/actual, la definizione finale appare così:

expect class BluetoothAdapter {
    var listener: BluetoothAdapterListener?
    fun discoverDevices(callback: (BluetoothDevice) -> Unit)
    fun stopScan()
    fun findBondedDevices(callback: (List<BluetoothDevice>) -> Unit)
    fun connect(device: BluetoothDevice)
    fun disconnect()
    fun discoverServices()
    fun discoverCharacteristics(service: BleService)
    fun setNotificationEnabled(char: BleCharacteristic)
    fun setNotificationDisabled(char: BleCharacteristic)
}

Come puoi vedere nelle firme dei metodi, ho fatto alcune piccole astrazioni sulle implementazioni specifiche della piattaforma per quanto riguarda periferiche, servizi e caratteristiche. Ciò consente alla codebase comune di lavorare su di esse senza conoscere il relativo framework implementativo.

expect class BluetoothDevice {
    val id: String
    val name: String
}
data class BleService(
    val id: String,
    val device: BluetoothDevice
)
data class Ble Characteristic(
    val id: String,
    val value: ByteArray?,
    val service: BleService
)

Nota che ho contrassegnato BluetoothDevice come expect. Ciò è dovuto al fatto che dovevo mantenere collegate le istanze di BluetoothDevice per Android e CBPeripheral per iOS con l’attualizzazione di BluetoothDevice.

Kotlin BLE – modulo Android

actual data class BluetoothDevice(
    actual val id: String,
    actual val name: String,
    internal val androidDevice: BluetoothDevice
)

Kotlin BLE – modulo iOS

actual data class BluetoothDevice(
    actual val id: String,
    actual val name: String,
    internal val peripheral: CBPeripheral
)

Per ottenere l’accesso a servizi e caratteristiche, di solito è necessario seguire alcuni passaggi, che possono variare tra Android e iOS. Innanzitutto, occorre connettersi al dispositivo. Una volta connesso, si avvia il rilevamento dei servizi e quindi da li si può procedere al discovery delle caratteristiche esposte per ciascun servizio.

Per descrivere facilmente in quale stato si trovava il processo, ho definito una sealed class nel modulo condiviso, che alla fine ha preso questa forma:

sealed class BleState {
    data class Connected(val device: BluetoothDevice): BleState()
    data class Disconnected(val device: BluetoothDevice): BleState()
    data class ServicesDiscovered(val device: BluetoothDevice, val services: List<BleService>): BleState()
    data class CharacteristicsDiscovered(val device: BluetoothDevice, val chars: List<BleCharacteristic>): BleState()
    data class CharacteristicChanged(val device: BluetoothDevice, val characteristic: BleCharacteristic): BleState()
}

L’istanza dello stato viene emessa da BluetoothAdapter chiamando un metodo di interfacciaBluetoothAdapterListener. Ho scelto questo paradigma perché tutti i passaggi erano asincroni (come puoi immaginare).

interface BluetoothAdapterListener {
    fun onStateChange(state: BleState)
}

Quindi, ho implementato il mio BluetoothAdapter per due piattaforme. Le implementazioni actual erano piuttosto semplici. Inoltre, ho aggiunto alcune logiche dell’interfaccia utente condivise applicando MVP, ma tratterò questo argomento nel prossimo post, te lo prometto.

Come ho detto prima, i passaggi per scoprire servizi e caratteristiche possono variare tra le due piattaforme. Su iOS occorre avviare manualmente il discovery delle caratteristiche per il servizio a cui sei interessato e, per soddisfare questa esigenza, ho esposto un metodo nella mia classe BluetoothAdapter. Al contrario, sulla piattaforma Android, quando un servizio viene scoperto, è possibile accedere a tutte le sue caratteristiche senza problemi. 

Ma perché?

Probabilmente, in fase di realizzazione, gli ingegneri Android hanno pensato che una volta scoperto un servizio, un utente avrebbe voluto giocare da subito con le sue caratteristiche. E probabilmente, gli ingegneri Apple hanno limitato l’accesso immediato per motivi di sicurezza. Forse per gli stessi motivi per cui su Android ottieni l’indirizzo MAC della periferica immediatamente e su iOS devi invece richiederlo esplicitamente. Ma questa è un’altra storia.Inoltre, fai attenzione al threading.

Sulla piattaforma Android, ogni metodo di callback di BluetoothGattCallback (chiamato quando viene scoperto un servizio, quando un dispositivo è collegato ecc.) viene chiamato da un thread in background. Ciò può causare problemi se si esegue un codice relativo all’interfaccia utente nelle implementazioni del listener.

Ben fatto! La tua app Kotlin BLE è quasi terminata

Cool, I implemented using Kotlin multiplatform class which allows BLE communication!
When I first run it on Android I was excited…

Ma…

Ma il mio entusiasmo è stato frenato ben presto.

La scoperta delle caratteristiche ha funzionato alla grande, così come l’abilitazione delle notifiche. Ma ragazzi, non sono stato in grado di ricevere le notifiche dei tocchi dei pulsanti.

Era così ironico e così strano che avessi implementato completamente la codebase condivisa per un’applicazione multi-piattaforma, e l’unico punto di errore fosse stato il codice relativo alla piattaforma specifica! Fortunatamente, non è stata colpa mia.

Per connettersi a Mi Band 2, il dispositivo deve essere autorizzato dall’applicazione Mi Fit, ed è necessario eseguire la connessione GATT scegliendo il Band dall’elenco delle periferiche già collegate. Probabilmente, quando è connessa ad essa, la sua app scrive su una caratteristica alcune informazioni che consentono al Band di mantenere viva la connessione. Un fatto che ho notato è che dopo poco tempo Mi Band ha interrotto la connessione a un dispositivo senza l’app Mi Fit installata e configurata.

Una volta installata la mia app multipiattaforma sul mio telefono personale dove ho i dati del mio Mi Band, tutto ha funzionato alla grande.

Dopo aver compilato su iOS, ho riscontrato un altro problema con il framework iOS che è stato generato e incorporato in Xcode. Inizialmente, la classe BleState.CharacteristicChanged della mia base di codice Kotlin aveva la proprietà characteristic chiamata char, per semplicità.

Penso che tu possa capire il problema ?

Soluzione del problema

Durante la compilazione per il target iOS, tutto il codice è visibile dal progetto Swift utilizzando dei file header di Objective-C. E sappiamo tutti che char è il nome di un tipo in Obj-C. Il problema era che il file di intestazione generato conteneva un errore di sintassi nella dichiarazione di classe, come puoi vedere nello screenshot qui sotto.

The syntax error into the compiled Obj-C header.
The syntax error into the compiled Obj-C header.

Una volta eseguito il refactoring, l’errore ovviamente è scomparso.

Quindi, morale della favola: usa sempre nomi significativi.

Ok, scusa, non proprio il caso.

E così la morale della storia è: usa sempre nomi che non collidono con parole chiave riservate della tua piattaforma di destinazione.

Più multiplatform-ish.

Fine del progetto Kotlin BLE

Vorrei approfittare dei problemi che ho dovuto affrontare per sottolineare che Kotlin MPP non mi ha dato alcun problema strano o errore inaspettato da solo, dal momento che tutto il progetto e la codebase multipiattaforma ha funzionato come previsto. I problemi che ho riscontrato erano dovuti a una limitazione di terze parti (nel caso del Mi Band) e a un uso improprio dell’API (errore di sintassi iOS).

Sono molto affascinato da questa potente tecnologia e penso che inizierò a sostenere la sua adozione in alcuni progetti in arrivo, dove sarà opportuno.

Conclusione

Ma il viaggio non è arrivato alla fine! Abbiamo esplorato un MPP utilizzando il Bluetooth, che non è un caso d’uso poi così comune per le nostre amate applicazioni mobile. Nella prossima parte di questa serie, scopriremo come recuperare alcuni dati dalla rete e presentarli all’utente adottando il pattern MVP… il tutto accompagnato dalle Coroutine di Kotlin.

Resta sintonizzato, grazie per aver letto l’articolo!

Puoi trovare tutto il codice dietro questo progetto in questo repository Gihub: https://github.com/MOLO17/kotlin-mpp-poc

Leggi qui la serie Viaggio nei progetti Kotlin Multiplatform.

conosciamo solo un quinto di ciò che vive in profondità. tutto il resto è da esplorare.

coderspace sta arrivando.